useDragElement.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import { useCallback } from 'react';
  2. import { useCanvasStore, useKeyboardStore } from '@/lib/store';
  3. import { useHistorySnapshot } from '@/lib/hooks/use-history-snapshot';
  4. import type { PPTElement } from '@/lib/types/slides';
  5. import type { AlignmentLineProps } from '@/lib/types/edit';
  6. import { getRectRotatedRange, uniqAlignLines, type AlignLine } from '@/lib/utils/element';
  7. import { useCanvasOperations } from '@/lib/hooks/use-canvas-operations';
  8. /**
  9. * Drag element hook
  10. *
  11. * @param elementListRef - Element list ref (holds latest value)
  12. * @param setElementList - Element list setter (triggers re-render)
  13. * @param setAlignmentLines - Alignment lines setter
  14. */
  15. export function useDragElement(
  16. elementListRef: React.RefObject<PPTElement[]>,
  17. setElementList: React.Dispatch<React.SetStateAction<PPTElement[]>>,
  18. setAlignmentLines: React.Dispatch<React.SetStateAction<AlignmentLineProps[]>>,
  19. ) {
  20. const activeElementIdList = useCanvasStore.use.activeElementIdList();
  21. const activeGroupElementId = useCanvasStore.use.activeGroupElementId();
  22. const canvasScale = useCanvasStore.use.canvasScale();
  23. const shiftKeyState = useKeyboardStore((state) => state.shiftKeyState);
  24. const viewportRatio = useCanvasStore.use.viewportRatio();
  25. const viewportSize = useCanvasStore.use.viewportSize();
  26. const updateSlide = useCanvasOperations().updateSlide;
  27. const { addHistorySnapshot } = useHistorySnapshot();
  28. const dragElement = useCallback(
  29. (e: React.MouseEvent | React.TouchEvent, element: PPTElement) => {
  30. const native = e.nativeEvent;
  31. const isTouchEvent = native instanceof TouchEvent;
  32. if (isTouchEvent && !native.changedTouches?.length) return;
  33. if (!activeElementIdList.includes(element.id)) return;
  34. let isMouseDown = true;
  35. const edgeWidth = viewportSize;
  36. const edgeHeight = viewportSize * viewportRatio;
  37. const sorptionRange = 5;
  38. // Save original element list for computing multi-select offsets
  39. const originElementList: PPTElement[] = JSON.parse(JSON.stringify(elementListRef.current));
  40. const originActiveElementList = originElementList.filter((el) =>
  41. activeElementIdList.includes(el.id),
  42. );
  43. const elOriginLeft = element.left;
  44. const elOriginTop = element.top;
  45. const elOriginWidth = element.width;
  46. const elOriginHeight = 'height' in element && element.height ? element.height : 0;
  47. const elOriginRotate = 'rotate' in element && element.rotate ? element.rotate : 0;
  48. const startPageX = isTouchEvent ? native.changedTouches[0].pageX : native.pageX;
  49. const startPageY = isTouchEvent ? native.changedTouches[0].pageY : native.pageY;
  50. let isMisoperation: boolean | null = null;
  51. const isActiveGroupElement = element.id === activeGroupElementId;
  52. // Collect alignment snap lines
  53. // Includes snap positions of other elements on canvas (excluding the target): top/bottom/left/right edges, horizontal/vertical centers
  54. // Lines and rotated elements need their bounding ranges recalculated
  55. let horizontalLines: AlignLine[] = [];
  56. let verticalLines: AlignLine[] = [];
  57. for (const el of elementListRef.current) {
  58. if (el.type === 'line') continue;
  59. if (isActiveGroupElement && el.id === element.id) continue;
  60. if (!isActiveGroupElement && activeElementIdList.includes(el.id)) continue;
  61. let left, top, width, height;
  62. if ('rotate' in el && el.rotate) {
  63. const { xRange, yRange } = getRectRotatedRange({
  64. left: el.left,
  65. top: el.top,
  66. width: el.width,
  67. height: el.height,
  68. rotate: el.rotate,
  69. });
  70. left = xRange[0];
  71. top = yRange[0];
  72. width = xRange[1] - xRange[0];
  73. height = yRange[1] - yRange[0];
  74. } else {
  75. left = el.left;
  76. top = el.top;
  77. width = el.width;
  78. height = el.height;
  79. }
  80. const right = left + width;
  81. const bottom = top + height;
  82. const centerX = top + height / 2;
  83. const centerY = left + width / 2;
  84. const topLine: AlignLine = { value: top, range: [left, right] };
  85. const bottomLine: AlignLine = { value: bottom, range: [left, right] };
  86. const horizontalCenterLine: AlignLine = {
  87. value: centerX,
  88. range: [left, right],
  89. };
  90. const leftLine: AlignLine = { value: left, range: [top, bottom] };
  91. const rightLine: AlignLine = { value: right, range: [top, bottom] };
  92. const verticalCenterLine: AlignLine = {
  93. value: centerY,
  94. range: [top, bottom],
  95. };
  96. horizontalLines.push(topLine, bottomLine, horizontalCenterLine);
  97. verticalLines.push(leftLine, rightLine, verticalCenterLine);
  98. }
  99. // Canvas viewport edges: four boundaries, horizontal center, vertical center
  100. const edgeTopLine: AlignLine = { value: 0, range: [0, edgeWidth] };
  101. const edgeBottomLine: AlignLine = {
  102. value: edgeHeight,
  103. range: [0, edgeWidth],
  104. };
  105. const edgeHorizontalCenterLine: AlignLine = {
  106. value: edgeHeight / 2,
  107. range: [0, edgeWidth],
  108. };
  109. const edgeLeftLine: AlignLine = { value: 0, range: [0, edgeHeight] };
  110. const edgeRightLine: AlignLine = {
  111. value: edgeWidth,
  112. range: [0, edgeHeight],
  113. };
  114. const edgeVerticalCenterLine: AlignLine = {
  115. value: edgeWidth / 2,
  116. range: [0, edgeHeight],
  117. };
  118. horizontalLines.push(edgeTopLine, edgeBottomLine, edgeHorizontalCenterLine);
  119. verticalLines.push(edgeLeftLine, edgeRightLine, edgeVerticalCenterLine);
  120. // Deduplicate alignment snap lines
  121. horizontalLines = uniqAlignLines(horizontalLines);
  122. verticalLines = uniqAlignLines(verticalLines);
  123. const handleMouseMove = (e: MouseEvent | TouchEvent) => {
  124. const currentPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX;
  125. const currentPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY;
  126. // If mouse movement is too small, consider it a misoperation:
  127. // null = first move, need to check; true = still in misoperation range; false = moved beyond range
  128. if (isMisoperation !== false) {
  129. isMisoperation =
  130. Math.abs(startPageX - currentPageX) < sorptionRange &&
  131. Math.abs(startPageY - currentPageY) < sorptionRange;
  132. }
  133. if (!isMouseDown || isMisoperation) return;
  134. let moveX = (currentPageX - startPageX) / canvasScale;
  135. let moveY = (currentPageY - startPageY) / canvasScale;
  136. // Lock to horizontal or vertical direction when Shift is held
  137. if (shiftKeyState) {
  138. if (Math.abs(moveX) > Math.abs(moveY)) moveY = 0;
  139. if (Math.abs(moveX) < Math.abs(moveY)) moveX = 0;
  140. }
  141. // Base target position
  142. let targetLeft = elOriginLeft + moveX;
  143. let targetTop = elOriginTop + moveY;
  144. // Calculate target element's bounding range on canvas for alignment snapping
  145. // Must distinguish single-select vs multi-select; single-select further distinguishes line, normal, and rotated elements
  146. let targetMinX: number, targetMaxX: number, targetMinY: number, targetMaxY: number;
  147. if (activeElementIdList.length === 1 || isActiveGroupElement) {
  148. if (elOriginRotate) {
  149. const { xRange, yRange } = getRectRotatedRange({
  150. left: targetLeft,
  151. top: targetTop,
  152. width: elOriginWidth,
  153. height: elOriginHeight,
  154. rotate: elOriginRotate,
  155. });
  156. targetMinX = xRange[0];
  157. targetMaxX = xRange[1];
  158. targetMinY = yRange[0];
  159. targetMaxY = yRange[1];
  160. } else if (element.type === 'line') {
  161. targetMinX = targetLeft;
  162. targetMaxX = targetLeft + Math.max(element.start[0], element.end[0]);
  163. targetMinY = targetTop;
  164. targetMaxY = targetTop + Math.max(element.start[1], element.end[1]);
  165. } else {
  166. targetMinX = targetLeft;
  167. targetMaxX = targetLeft + elOriginWidth;
  168. targetMinY = targetTop;
  169. targetMaxY = targetTop + elOriginHeight;
  170. }
  171. } else {
  172. const leftValues = [];
  173. const topValues = [];
  174. const rightValues = [];
  175. const bottomValues = [];
  176. for (let i = 0; i < originActiveElementList.length; i++) {
  177. const element = originActiveElementList[i];
  178. const left = element.left + moveX;
  179. const top = element.top + moveY;
  180. const width = element.width;
  181. const height = 'height' in element && element.height ? element.height : 0;
  182. const rotate = 'rotate' in element && element.rotate ? element.rotate : 0;
  183. if ('rotate' in element && element.rotate) {
  184. const { xRange, yRange } = getRectRotatedRange({
  185. left,
  186. top,
  187. width,
  188. height,
  189. rotate,
  190. });
  191. leftValues.push(xRange[0]);
  192. topValues.push(yRange[0]);
  193. rightValues.push(xRange[1]);
  194. bottomValues.push(yRange[1]);
  195. } else if (element.type === 'line') {
  196. leftValues.push(left);
  197. topValues.push(top);
  198. rightValues.push(left + Math.max(element.start[0], element.end[0]));
  199. bottomValues.push(top + Math.max(element.start[1], element.end[1]));
  200. } else {
  201. leftValues.push(left);
  202. topValues.push(top);
  203. rightValues.push(left + width);
  204. bottomValues.push(top + height);
  205. }
  206. }
  207. targetMinX = Math.min(...leftValues);
  208. targetMaxX = Math.max(...rightValues);
  209. targetMinY = Math.min(...topValues);
  210. targetMaxY = Math.max(...bottomValues);
  211. }
  212. const targetCenterX = targetMinX + (targetMaxX - targetMinX) / 2;
  213. const targetCenterY = targetMinY + (targetMaxY - targetMinY) / 2;
  214. // Compare alignment snap lines with target position; auto-correct when difference is within threshold
  215. // Horizontal and vertical directions are calculated separately
  216. const _alignmentLines: AlignmentLineProps[] = [];
  217. let isVerticalAdsorbed = false;
  218. let isHorizontalAdsorbed = false;
  219. for (let i = 0; i < horizontalLines.length; i++) {
  220. const { value, range } = horizontalLines[i];
  221. const min = Math.min(...range, targetMinX, targetMaxX);
  222. const max = Math.max(...range, targetMinX, targetMaxX);
  223. if (Math.abs(targetMinY - value) < sorptionRange && !isHorizontalAdsorbed) {
  224. targetTop = targetTop - (targetMinY - value);
  225. isHorizontalAdsorbed = true;
  226. _alignmentLines.push({
  227. type: 'horizontal',
  228. axis: { x: min - 50, y: value },
  229. length: max - min + 100,
  230. });
  231. }
  232. if (Math.abs(targetMaxY - value) < sorptionRange && !isHorizontalAdsorbed) {
  233. targetTop = targetTop - (targetMaxY - value);
  234. isHorizontalAdsorbed = true;
  235. _alignmentLines.push({
  236. type: 'horizontal',
  237. axis: { x: min - 50, y: value },
  238. length: max - min + 100,
  239. });
  240. }
  241. if (Math.abs(targetCenterY - value) < sorptionRange && !isHorizontalAdsorbed) {
  242. targetTop = targetTop - (targetCenterY - value);
  243. isHorizontalAdsorbed = true;
  244. _alignmentLines.push({
  245. type: 'horizontal',
  246. axis: { x: min - 50, y: value },
  247. length: max - min + 100,
  248. });
  249. }
  250. }
  251. for (let i = 0; i < verticalLines.length; i++) {
  252. const { value, range } = verticalLines[i];
  253. const min = Math.min(...range, targetMinY, targetMaxY);
  254. const max = Math.max(...range, targetMinY, targetMaxY);
  255. if (Math.abs(targetMinX - value) < sorptionRange && !isVerticalAdsorbed) {
  256. targetLeft = targetLeft - (targetMinX - value);
  257. isVerticalAdsorbed = true;
  258. _alignmentLines.push({
  259. type: 'vertical',
  260. axis: { x: value, y: min - 50 },
  261. length: max - min + 100,
  262. });
  263. }
  264. if (Math.abs(targetMaxX - value) < sorptionRange && !isVerticalAdsorbed) {
  265. targetLeft = targetLeft - (targetMaxX - value);
  266. isVerticalAdsorbed = true;
  267. _alignmentLines.push({
  268. type: 'vertical',
  269. axis: { x: value, y: min - 50 },
  270. length: max - min + 100,
  271. });
  272. }
  273. if (Math.abs(targetCenterX - value) < sorptionRange && !isVerticalAdsorbed) {
  274. targetLeft = targetLeft - (targetCenterX - value);
  275. isVerticalAdsorbed = true;
  276. _alignmentLines.push({
  277. type: 'vertical',
  278. axis: { x: value, y: min - 50 },
  279. length: max - min + 100,
  280. });
  281. }
  282. }
  283. setAlignmentLines(_alignmentLines);
  284. let newElements: PPTElement[];
  285. // In single-select mode or when the active group element is being operated, only update that element's position
  286. if (activeElementIdList.length === 1 || isActiveGroupElement) {
  287. newElements = elementListRef.current.map((el) => {
  288. if (el.id === element.id) {
  289. return { ...el, left: targetLeft, top: targetTop };
  290. }
  291. return el;
  292. });
  293. }
  294. // In multi-select mode, also update positions of other selected elements
  295. // Their positions are calculated from the movement offset of the handle element
  296. else {
  297. const handleElement = elementListRef.current.find((el) => el.id === element.id);
  298. if (!handleElement) return;
  299. newElements = elementListRef.current.map((el) => {
  300. if (activeElementIdList.includes(el.id)) {
  301. if (el.id === element.id) {
  302. return { ...el, left: targetLeft, top: targetTop };
  303. }
  304. return {
  305. ...el,
  306. left: el.left + (targetLeft - handleElement.left),
  307. top: el.top + (targetTop - handleElement.top),
  308. };
  309. }
  310. return el;
  311. });
  312. }
  313. // Update both ref (latest value) and state (trigger re-render)
  314. elementListRef.current = newElements;
  315. setElementList(newElements);
  316. };
  317. const handleMouseUp = (e: MouseEvent | TouchEvent) => {
  318. isMouseDown = false;
  319. document.ontouchmove = null;
  320. document.ontouchend = null;
  321. document.onmousemove = null;
  322. document.onmouseup = null;
  323. setAlignmentLines([]);
  324. const currentPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX;
  325. const currentPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY;
  326. if (startPageX === currentPageX && startPageY === currentPageY) return;
  327. updateSlide({ elements: elementListRef.current });
  328. addHistorySnapshot();
  329. };
  330. if (isTouchEvent) {
  331. document.ontouchmove = handleMouseMove;
  332. document.ontouchend = handleMouseUp;
  333. } else {
  334. document.onmousemove = handleMouseMove;
  335. document.onmouseup = handleMouseUp;
  336. }
  337. },
  338. [
  339. activeElementIdList,
  340. activeGroupElementId,
  341. shiftKeyState,
  342. canvasScale,
  343. elementListRef,
  344. setElementList,
  345. setAlignmentLines,
  346. updateSlide,
  347. addHistorySnapshot,
  348. viewportRatio,
  349. viewportSize,
  350. ],
  351. );
  352. return {
  353. dragElement,
  354. };
  355. }