useScaleElement.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
  1. import { useCallback } from 'react';
  2. import { useCanvasStore } from '@/lib/store';
  3. import { useKeyboardStore } from '@/lib/store/keyboard';
  4. import type {
  5. PPTElement,
  6. PPTLineElement,
  7. PPTImageElement,
  8. PPTShapeElement,
  9. } from '@/lib/types/slides';
  10. import {
  11. OperateResizeHandlers,
  12. type AlignmentLineProps,
  13. type MultiSelectRange,
  14. } from '@/lib/types/edit';
  15. import { MIN_SIZE } from '@/configs/element';
  16. import { SHAPE_PATH_FORMULAS } from '@/configs/shapes';
  17. import { type AlignLine, uniqAlignLines } from '@/lib/utils/element';
  18. import { useHistorySnapshot } from '@/lib/hooks/use-history-snapshot';
  19. import { useCanvasOperations } from '@/lib/hooks/use-canvas-operations';
  20. interface RotateElementData {
  21. left: number;
  22. top: number;
  23. width: number;
  24. height: number;
  25. }
  26. /**
  27. * Calculate the positions of the eight scale points of a rotated element
  28. * @param element Original position and size of the element
  29. * @param angle Rotation angle
  30. */
  31. const getRotateElementPoints = (element: RotateElementData, angle: number) => {
  32. const { left, top, width, height } = element;
  33. const radius = Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2)) / 2;
  34. const auxiliaryAngle = (Math.atan(height / width) * 180) / Math.PI;
  35. const tlbraRadian = ((180 - angle - auxiliaryAngle) * Math.PI) / 180;
  36. const trblaRadian = ((auxiliaryAngle - angle) * Math.PI) / 180;
  37. const taRadian = ((90 - angle) * Math.PI) / 180;
  38. const raRadian = (angle * Math.PI) / 180;
  39. const halfWidth = width / 2;
  40. const halfHeight = height / 2;
  41. const middleLeft = left + halfWidth;
  42. const middleTop = top + halfHeight;
  43. const leftTopPoint = {
  44. left: middleLeft + radius * Math.cos(tlbraRadian),
  45. top: middleTop - radius * Math.sin(tlbraRadian),
  46. };
  47. const topPoint = {
  48. left: middleLeft + halfHeight * Math.cos(taRadian),
  49. top: middleTop - halfHeight * Math.sin(taRadian),
  50. };
  51. const rightTopPoint = {
  52. left: middleLeft + radius * Math.cos(trblaRadian),
  53. top: middleTop - radius * Math.sin(trblaRadian),
  54. };
  55. const rightPoint = {
  56. left: middleLeft + halfWidth * Math.cos(raRadian),
  57. top: middleTop + halfWidth * Math.sin(raRadian),
  58. };
  59. const rightBottomPoint = {
  60. left: middleLeft - radius * Math.cos(tlbraRadian),
  61. top: middleTop + radius * Math.sin(tlbraRadian),
  62. };
  63. const bottomPoint = {
  64. left: middleLeft - halfHeight * Math.sin(raRadian),
  65. top: middleTop + halfHeight * Math.cos(raRadian),
  66. };
  67. const leftBottomPoint = {
  68. left: middleLeft - radius * Math.cos(trblaRadian),
  69. top: middleTop + radius * Math.sin(trblaRadian),
  70. };
  71. const leftPoint = {
  72. left: middleLeft - halfWidth * Math.cos(raRadian),
  73. top: middleTop - halfWidth * Math.sin(raRadian),
  74. };
  75. return {
  76. leftTopPoint,
  77. topPoint,
  78. rightTopPoint,
  79. rightPoint,
  80. rightBottomPoint,
  81. bottomPoint,
  82. leftBottomPoint,
  83. leftPoint,
  84. };
  85. };
  86. /**
  87. * Get the opposite point of a given scale point, e.g. [top] corresponds to [bottom], [left-top] corresponds to [right-bottom]
  88. * @param direction The current scale point being operated
  89. * @param points Positions of the eight scale points of the rotated element
  90. */
  91. const getOppositePoint = (
  92. direction: OperateResizeHandlers,
  93. points: ReturnType<typeof getRotateElementPoints>,
  94. ): { left: number; top: number } => {
  95. const oppositeMap = {
  96. [OperateResizeHandlers.RIGHT_BOTTOM]: points.leftTopPoint,
  97. [OperateResizeHandlers.LEFT_BOTTOM]: points.rightTopPoint,
  98. [OperateResizeHandlers.LEFT_TOP]: points.rightBottomPoint,
  99. [OperateResizeHandlers.RIGHT_TOP]: points.leftBottomPoint,
  100. [OperateResizeHandlers.TOP]: points.bottomPoint,
  101. [OperateResizeHandlers.BOTTOM]: points.topPoint,
  102. [OperateResizeHandlers.LEFT]: points.rightPoint,
  103. [OperateResizeHandlers.RIGHT]: points.leftPoint,
  104. };
  105. return oppositeMap[direction];
  106. };
  107. /**
  108. * Scale element Hook
  109. *
  110. * @param elementListRef - Element list ref (stores the latest value)
  111. * @param setElementList - Element list setter (used to trigger re-render)
  112. * @param setAlignmentLines - Alignment lines setter
  113. */
  114. export function useScaleElement(
  115. elementListRef: React.RefObject<PPTElement[]>,
  116. setElementList: React.Dispatch<React.SetStateAction<PPTElement[]>>,
  117. setAlignmentLines: React.Dispatch<React.SetStateAction<AlignmentLineProps[]>>,
  118. ) {
  119. const setScalingState = useCanvasStore.use.setScalingState();
  120. const activeElementIdList = useCanvasStore.use.activeElementIdList();
  121. const activeGroupElementId = useCanvasStore.use.activeGroupElementId();
  122. const canvasScale = useCanvasStore.use.canvasScale();
  123. const viewportRatio = useCanvasStore.use.viewportRatio();
  124. const viewportSize = useCanvasStore.use.viewportSize();
  125. const updateSlide = useCanvasOperations().updateSlide;
  126. const ctrlOrShiftKeyActive = useKeyboardStore((state) => state.ctrlOrShiftKeyActive());
  127. const { addHistorySnapshot } = useHistorySnapshot();
  128. // Scale element
  129. const scaleElement = useCallback(
  130. (
  131. e: React.MouseEvent | React.TouchEvent,
  132. element: Exclude<PPTElement, PPTLineElement>,
  133. command: OperateResizeHandlers,
  134. ) => {
  135. const native = e.nativeEvent;
  136. const isTouchEvent = native instanceof TouchEvent;
  137. if (isTouchEvent && !native.changedTouches?.length) return;
  138. let isMouseDown = true;
  139. setScalingState(true);
  140. const elOriginLeft = element.left;
  141. const elOriginTop = element.top;
  142. const elOriginWidth = element.width;
  143. const elOriginHeight = element.height;
  144. const originTableCellMinHeight = element.type === 'table' ? element.cellMinHeight : 0;
  145. const elRotate = 'rotate' in element && element.rotate ? element.rotate : 0;
  146. const rotateRadian = (Math.PI * elRotate) / 180;
  147. const fixedRatio = ctrlOrShiftKeyActive || ('fixedRatio' in element && element.fixedRatio);
  148. const aspectRatio = elOriginWidth / elOriginHeight;
  149. const startPageX = isTouchEvent ? native.changedTouches[0].pageX : native.pageX;
  150. const startPageY = isTouchEvent ? native.changedTouches[0].pageY : native.pageY;
  151. // Minimum scale size limit for element
  152. const minSize = MIN_SIZE[element.type] || 20;
  153. const getSizeWithinRange = (size: number, type: 'width' | 'height') => {
  154. if (!fixedRatio) return size < minSize ? minSize : size;
  155. let minWidth = minSize;
  156. let minHeight = minSize;
  157. const ratio = element.width / element.height;
  158. if (ratio < 1) minHeight = minSize / ratio;
  159. if (ratio > 1) minWidth = minSize * ratio;
  160. if (type === 'width') return size < minWidth ? minWidth : size;
  161. return size < minHeight ? minHeight : size;
  162. };
  163. let points: ReturnType<typeof getRotateElementPoints>;
  164. let baseLeft = 0;
  165. let baseTop = 0;
  166. let horizontalLines: AlignLine[] = [];
  167. let verticalLines: AlignLine[] = [];
  168. // When scaling a rotated element, introduce a base point concept: the point opposite to the current scale handle
  169. // For example, when dragging the bottom-right corner, the top-left corner is the base point that stays fixed while other points move to achieve scaling
  170. if ('rotate' in element && element.rotate) {
  171. const { left, top, width, height } = element;
  172. points = getRotateElementPoints({ left, top, width, height }, elRotate);
  173. const oppositePoint = getOppositePoint(command, points);
  174. baseLeft = oppositePoint.left;
  175. baseTop = oppositePoint.top;
  176. }
  177. // Non-rotated elements support alignment snapping during scaling; collect alignment snap lines here
  178. // Includes snappable alignment positions (top, bottom, left, right edges) of all elements on the canvas except the target element
  179. // Line elements and rotated elements are excluded from alignment snapping
  180. else {
  181. const edgeWidth = viewportSize;
  182. const edgeHeight = viewportSize * viewportRatio;
  183. const isActiveGroupElement = element.id === activeGroupElementId;
  184. for (const el of elementListRef.current) {
  185. if ('rotate' in el && el.rotate) continue;
  186. if (el.type === 'line') continue;
  187. if (isActiveGroupElement && el.id === element.id) continue;
  188. if (!isActiveGroupElement && activeElementIdList.includes(el.id)) continue;
  189. const left = el.left;
  190. const top = el.top;
  191. const width = el.width;
  192. const height = el.height;
  193. const right = left + width;
  194. const bottom = top + height;
  195. const topLine: AlignLine = { value: top, range: [left, right] };
  196. const bottomLine: AlignLine = { value: bottom, range: [left, right] };
  197. const leftLine: AlignLine = { value: left, range: [top, bottom] };
  198. const rightLine: AlignLine = { value: right, range: [top, bottom] };
  199. horizontalLines.push(topLine, bottomLine);
  200. verticalLines.push(leftLine, rightLine);
  201. }
  202. // Four edges of the visible canvas area, horizontal center, and vertical center
  203. const edgeTopLine: AlignLine = { value: 0, range: [0, edgeWidth] };
  204. const edgeBottomLine: AlignLine = {
  205. value: edgeHeight,
  206. range: [0, edgeWidth],
  207. };
  208. const edgeHorizontalCenterLine: AlignLine = {
  209. value: edgeHeight / 2,
  210. range: [0, edgeWidth],
  211. };
  212. const edgeLeftLine: AlignLine = { value: 0, range: [0, edgeHeight] };
  213. const edgeRightLine: AlignLine = {
  214. value: edgeWidth,
  215. range: [0, edgeHeight],
  216. };
  217. const edgeVerticalCenterLine: AlignLine = {
  218. value: edgeWidth / 2,
  219. range: [0, edgeHeight],
  220. };
  221. horizontalLines.push(edgeTopLine, edgeBottomLine, edgeHorizontalCenterLine);
  222. verticalLines.push(edgeLeftLine, edgeRightLine, edgeVerticalCenterLine);
  223. horizontalLines = uniqAlignLines(horizontalLines);
  224. verticalLines = uniqAlignLines(verticalLines);
  225. }
  226. // Alignment snapping method
  227. // Compare collected alignment snap lines with the target element's current position/size data; auto-correct when the difference is within threshold
  228. // Horizontal and vertical directions are calculated separately
  229. const alignedAdsorption = (currentX: number | null, currentY: number | null) => {
  230. const sorptionRange = 5;
  231. const _alignmentLines: AlignmentLineProps[] = [];
  232. let isVerticalAdsorbed = false;
  233. let isHorizontalAdsorbed = false;
  234. const correctionVal = { offsetX: 0, offsetY: 0 };
  235. if (currentY || currentY === 0) {
  236. for (let i = 0; i < horizontalLines.length; i++) {
  237. const { value, range } = horizontalLines[i];
  238. const min = Math.min(...range, currentX || 0);
  239. const max = Math.max(...range, currentX || 0);
  240. if (Math.abs(currentY - value) < sorptionRange && !isHorizontalAdsorbed) {
  241. correctionVal.offsetY = currentY - value;
  242. isHorizontalAdsorbed = true;
  243. _alignmentLines.push({
  244. type: 'horizontal',
  245. axis: { x: min - 50, y: value },
  246. length: max - min + 100,
  247. });
  248. }
  249. }
  250. }
  251. if (currentX || currentX === 0) {
  252. for (let i = 0; i < verticalLines.length; i++) {
  253. const { value, range } = verticalLines[i];
  254. const min = Math.min(...range, currentY || 0);
  255. const max = Math.max(...range, currentY || 0);
  256. if (Math.abs(currentX - value) < sorptionRange && !isVerticalAdsorbed) {
  257. correctionVal.offsetX = currentX - value;
  258. isVerticalAdsorbed = true;
  259. _alignmentLines.push({
  260. type: 'vertical',
  261. axis: { x: value, y: min - 50 },
  262. length: max - min + 100,
  263. });
  264. }
  265. }
  266. }
  267. setAlignmentLines(_alignmentLines);
  268. return correctionVal;
  269. };
  270. const handleMouseMove = (e: MouseEvent | TouchEvent) => {
  271. if (!isMouseDown) return;
  272. const currentPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX;
  273. const currentPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY;
  274. const x = currentPageX - startPageX;
  275. const y = currentPageY - startPageY;
  276. let width = elOriginWidth;
  277. let height = elOriginHeight;
  278. let left = elOriginLeft;
  279. let top = elOriginTop;
  280. // For rotated elements, recalculate the scaling distance based on the rotation angle (distance moved after mouse down)
  281. if (elRotate) {
  282. const revisedX = (Math.cos(rotateRadian) * x + Math.sin(rotateRadian) * y) / canvasScale;
  283. let revisedY = (Math.cos(rotateRadian) * y - Math.sin(rotateRadian) * x) / canvasScale;
  284. // Lock aspect ratio (only triggered by four corners, not edges)
  285. // Use horizontal scaling distance as the basis to calculate vertical scaling distance, maintaining the same ratio
  286. if (fixedRatio) {
  287. if (
  288. command === OperateResizeHandlers.RIGHT_BOTTOM ||
  289. command === OperateResizeHandlers.LEFT_TOP
  290. )
  291. revisedY = revisedX / aspectRatio;
  292. if (
  293. command === OperateResizeHandlers.LEFT_BOTTOM ||
  294. command === OperateResizeHandlers.RIGHT_TOP
  295. )
  296. revisedY = -revisedX / aspectRatio;
  297. }
  298. // Calculate element size and position after scaling based on the operation point
  299. // Note:
  300. // The position calculated here needs correction later, because scaling a rotated element changes the base point position (visually the base point stays fixed, but that's the combined result of rotation + translation)
  301. // However, the size does not need correction since the scaling distance was already recalculated above
  302. if (command === OperateResizeHandlers.RIGHT_BOTTOM) {
  303. width = getSizeWithinRange(elOriginWidth + revisedX, 'width');
  304. height = getSizeWithinRange(elOriginHeight + revisedY, 'height');
  305. } else if (command === OperateResizeHandlers.LEFT_BOTTOM) {
  306. width = getSizeWithinRange(elOriginWidth - revisedX, 'width');
  307. height = getSizeWithinRange(elOriginHeight + revisedY, 'height');
  308. left = elOriginLeft - (width - elOriginWidth);
  309. } else if (command === OperateResizeHandlers.LEFT_TOP) {
  310. width = getSizeWithinRange(elOriginWidth - revisedX, 'width');
  311. height = getSizeWithinRange(elOriginHeight - revisedY, 'height');
  312. left = elOriginLeft - (width - elOriginWidth);
  313. top = elOriginTop - (height - elOriginHeight);
  314. } else if (command === OperateResizeHandlers.RIGHT_TOP) {
  315. width = getSizeWithinRange(elOriginWidth + revisedX, 'width');
  316. height = getSizeWithinRange(elOriginHeight - revisedY, 'height');
  317. top = elOriginTop - (height - elOriginHeight);
  318. } else if (command === OperateResizeHandlers.TOP) {
  319. height = getSizeWithinRange(elOriginHeight - revisedY, 'height');
  320. top = elOriginTop - (height - elOriginHeight);
  321. } else if (command === OperateResizeHandlers.BOTTOM) {
  322. height = getSizeWithinRange(elOriginHeight + revisedY, 'height');
  323. } else if (command === OperateResizeHandlers.LEFT) {
  324. width = getSizeWithinRange(elOriginWidth - revisedX, 'width');
  325. left = elOriginLeft - (width - elOriginWidth);
  326. } else if (command === OperateResizeHandlers.RIGHT) {
  327. width = getSizeWithinRange(elOriginWidth + revisedX, 'width');
  328. }
  329. // Get current base point coordinates, compare with initial base point, and correct element position by the difference
  330. const currentPoints = getRotateElementPoints({ width, height, left, top }, elRotate);
  331. const currentOppositePoint = getOppositePoint(command, currentPoints);
  332. const currentBaseLeft = currentOppositePoint.left;
  333. const currentBaseTop = currentOppositePoint.top;
  334. const offsetX = currentBaseLeft - baseLeft;
  335. const offsetY = currentBaseTop - baseTop;
  336. left = left - offsetX;
  337. top = top - offsetY;
  338. }
  339. // For non-rotated elements, simply calculate the new position and size without complex corrections
  340. // Additionally handle alignment snapping operations
  341. // Aspect ratio locking logic is the same as above
  342. else {
  343. let moveX = x / canvasScale;
  344. let moveY = y / canvasScale;
  345. if (fixedRatio) {
  346. if (
  347. command === OperateResizeHandlers.RIGHT_BOTTOM ||
  348. command === OperateResizeHandlers.LEFT_TOP
  349. )
  350. moveY = moveX / aspectRatio;
  351. if (
  352. command === OperateResizeHandlers.LEFT_BOTTOM ||
  353. command === OperateResizeHandlers.RIGHT_TOP
  354. )
  355. moveY = -moveX / aspectRatio;
  356. }
  357. if (command === OperateResizeHandlers.RIGHT_BOTTOM) {
  358. const { offsetX, offsetY } = alignedAdsorption(
  359. elOriginLeft + elOriginWidth + moveX,
  360. elOriginTop + elOriginHeight + moveY,
  361. );
  362. moveX = moveX - offsetX;
  363. moveY = moveY - offsetY;
  364. if (fixedRatio) {
  365. if (offsetY) moveX = moveY * aspectRatio;
  366. else moveY = moveX / aspectRatio;
  367. }
  368. width = getSizeWithinRange(elOriginWidth + moveX, 'width');
  369. height = getSizeWithinRange(elOriginHeight + moveY, 'height');
  370. } else if (command === OperateResizeHandlers.LEFT_BOTTOM) {
  371. const { offsetX, offsetY } = alignedAdsorption(
  372. elOriginLeft + moveX,
  373. elOriginTop + elOriginHeight + moveY,
  374. );
  375. moveX = moveX - offsetX;
  376. moveY = moveY - offsetY;
  377. if (fixedRatio) {
  378. if (offsetY) moveX = -moveY * aspectRatio;
  379. else moveY = -moveX / aspectRatio;
  380. }
  381. width = getSizeWithinRange(elOriginWidth - moveX, 'width');
  382. height = getSizeWithinRange(elOriginHeight + moveY, 'height');
  383. left = elOriginLeft - (width - elOriginWidth);
  384. } else if (command === OperateResizeHandlers.LEFT_TOP) {
  385. const { offsetX, offsetY } = alignedAdsorption(
  386. elOriginLeft + moveX,
  387. elOriginTop + moveY,
  388. );
  389. moveX = moveX - offsetX;
  390. moveY = moveY - offsetY;
  391. if (fixedRatio) {
  392. if (offsetY) moveX = moveY * aspectRatio;
  393. else moveY = moveX / aspectRatio;
  394. }
  395. width = getSizeWithinRange(elOriginWidth - moveX, 'width');
  396. height = getSizeWithinRange(elOriginHeight - moveY, 'height');
  397. left = elOriginLeft - (width - elOriginWidth);
  398. top = elOriginTop - (height - elOriginHeight);
  399. } else if (command === OperateResizeHandlers.RIGHT_TOP) {
  400. const { offsetX, offsetY } = alignedAdsorption(
  401. elOriginLeft + elOriginWidth + moveX,
  402. elOriginTop + moveY,
  403. );
  404. moveX = moveX - offsetX;
  405. moveY = moveY - offsetY;
  406. if (fixedRatio) {
  407. if (offsetY) moveX = -moveY * aspectRatio;
  408. else moveY = -moveX / aspectRatio;
  409. }
  410. width = getSizeWithinRange(elOriginWidth + moveX, 'width');
  411. height = getSizeWithinRange(elOriginHeight - moveY, 'height');
  412. top = elOriginTop - (height - elOriginHeight);
  413. } else if (command === OperateResizeHandlers.LEFT) {
  414. const { offsetX } = alignedAdsorption(elOriginLeft + moveX, null);
  415. moveX = moveX - offsetX;
  416. width = getSizeWithinRange(elOriginWidth - moveX, 'width');
  417. left = elOriginLeft - (width - elOriginWidth);
  418. } else if (command === OperateResizeHandlers.RIGHT) {
  419. const { offsetX } = alignedAdsorption(elOriginLeft + elOriginWidth + moveX, null);
  420. moveX = moveX - offsetX;
  421. width = getSizeWithinRange(elOriginWidth + moveX, 'width');
  422. } else if (command === OperateResizeHandlers.TOP) {
  423. const { offsetY } = alignedAdsorption(null, elOriginTop + moveY);
  424. moveY = moveY - offsetY;
  425. height = getSizeWithinRange(elOriginHeight - moveY, 'height');
  426. top = elOriginTop - (height - elOriginHeight);
  427. } else if (command === OperateResizeHandlers.BOTTOM) {
  428. const { offsetY } = alignedAdsorption(null, elOriginTop + elOriginHeight + moveY);
  429. moveY = moveY - offsetY;
  430. height = getSizeWithinRange(elOriginHeight + moveY, 'height');
  431. }
  432. }
  433. // Update local element list during mousemove
  434. const newElements = elementListRef.current.map((el) => {
  435. if (element.id !== el.id) return el;
  436. if (el.type === 'shape' && 'pathFormula' in el && el.pathFormula) {
  437. const pathFormula = SHAPE_PATH_FORMULAS[el.pathFormula];
  438. let path = '';
  439. if ('editable' in pathFormula) path = pathFormula.formula(width, height, el.keypoints!);
  440. else path = pathFormula.formula(width, height);
  441. return {
  442. ...el,
  443. left,
  444. top,
  445. width,
  446. height,
  447. viewBox: [width, height] as [number, number],
  448. path,
  449. };
  450. }
  451. if (el.type === 'table') {
  452. let cellMinHeight =
  453. originTableCellMinHeight + (height - elOriginHeight) / el.data.length;
  454. cellMinHeight = cellMinHeight < 36 ? 36 : cellMinHeight;
  455. if (cellMinHeight === originTableCellMinHeight) return { ...el, left, width };
  456. return {
  457. ...el,
  458. left,
  459. top,
  460. width,
  461. height,
  462. cellMinHeight: cellMinHeight < 36 ? 36 : cellMinHeight,
  463. };
  464. }
  465. return { ...el, left, top, width, height };
  466. });
  467. // Update both ref and state
  468. elementListRef.current = newElements;
  469. setElementList(newElements);
  470. };
  471. const handleMouseUp = (e: MouseEvent | TouchEvent) => {
  472. isMouseDown = false;
  473. document.ontouchmove = null;
  474. document.ontouchend = null;
  475. document.onmousemove = null;
  476. document.onmouseup = null;
  477. setAlignmentLines([]);
  478. const currentPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX;
  479. const currentPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY;
  480. if (startPageX === currentPageX && startPageY === currentPageY) return;
  481. setScalingState(false);
  482. updateSlide({ elements: elementListRef.current });
  483. addHistorySnapshot();
  484. };
  485. if (isTouchEvent) {
  486. document.ontouchmove = handleMouseMove;
  487. document.ontouchend = handleMouseUp;
  488. } else {
  489. document.onmousemove = handleMouseMove;
  490. document.onmouseup = handleMouseUp;
  491. }
  492. },
  493. [
  494. elementListRef,
  495. setElementList,
  496. canvasScale,
  497. activeElementIdList,
  498. activeGroupElementId,
  499. viewportRatio,
  500. viewportSize,
  501. ctrlOrShiftKeyActive,
  502. setScalingState,
  503. setAlignmentLines,
  504. updateSlide,
  505. addHistorySnapshot,
  506. ],
  507. );
  508. // Scale multiple selected elements
  509. const scaleMultiElement = useCallback(
  510. (e: React.MouseEvent, range: MultiSelectRange, command: OperateResizeHandlers) => {
  511. let isMouseDown = true;
  512. const { minX, maxX, minY, maxY } = range;
  513. const operateWidth = maxX - minX;
  514. const operateHeight = maxY - minY;
  515. const aspectRatio = operateWidth / operateHeight;
  516. const startPageX = e.pageX;
  517. const startPageY = e.pageY;
  518. const originElementList: PPTElement[] = JSON.parse(JSON.stringify(elementListRef.current));
  519. const handleMouseMove = (e: MouseEvent) => {
  520. if (!isMouseDown) return;
  521. const currentPageX = e.pageX;
  522. const currentPageY = e.pageY;
  523. const x = (currentPageX - startPageX) / canvasScale;
  524. let y = (currentPageY - startPageY) / canvasScale;
  525. // Lock aspect ratio, same logic as above
  526. if (ctrlOrShiftKeyActive) {
  527. if (
  528. command === OperateResizeHandlers.RIGHT_BOTTOM ||
  529. command === OperateResizeHandlers.LEFT_TOP
  530. )
  531. y = x / aspectRatio;
  532. if (
  533. command === OperateResizeHandlers.LEFT_BOTTOM ||
  534. command === OperateResizeHandlers.RIGHT_TOP
  535. )
  536. y = -x / aspectRatio;
  537. }
  538. // Overall range of all selected elements
  539. let currentMinX = minX;
  540. let currentMaxX = maxX;
  541. let currentMinY = minY;
  542. let currentMaxY = maxY;
  543. if (command === OperateResizeHandlers.RIGHT_BOTTOM) {
  544. currentMaxX = maxX + x;
  545. currentMaxY = maxY + y;
  546. } else if (command === OperateResizeHandlers.LEFT_BOTTOM) {
  547. currentMinX = minX + x;
  548. currentMaxY = maxY + y;
  549. } else if (command === OperateResizeHandlers.LEFT_TOP) {
  550. currentMinX = minX + x;
  551. currentMinY = minY + y;
  552. } else if (command === OperateResizeHandlers.RIGHT_TOP) {
  553. currentMaxX = maxX + x;
  554. currentMinY = minY + y;
  555. } else if (command === OperateResizeHandlers.TOP) {
  556. currentMinY = minY + y;
  557. } else if (command === OperateResizeHandlers.BOTTOM) {
  558. currentMaxY = maxY + y;
  559. } else if (command === OperateResizeHandlers.LEFT) {
  560. currentMinX = minX + x;
  561. } else if (command === OperateResizeHandlers.RIGHT) {
  562. currentMaxX = maxX + x;
  563. }
  564. // Overall width and height of all selected elements
  565. const currentOppositeWidth = currentMaxX - currentMinX;
  566. const currentOppositeHeight = currentMaxY - currentMinY;
  567. // Ratio of the currently operated element's width/height to the overall width/height of all selected elements
  568. let widthScale = currentOppositeWidth / operateWidth;
  569. let heightScale = currentOppositeHeight / operateHeight;
  570. if (widthScale <= 0) widthScale = 0;
  571. if (heightScale <= 0) heightScale = 0;
  572. // Calculate and update the position and size of all selected elements based on the computed ratio
  573. const newElements = elementListRef.current.map((el) => {
  574. if ((el.type === 'image' || el.type === 'shape') && activeElementIdList.includes(el.id)) {
  575. const originElement = originElementList.find((originEl) => originEl.id === el.id) as
  576. | PPTImageElement
  577. | PPTShapeElement;
  578. return {
  579. ...el,
  580. width: originElement.width * widthScale,
  581. height: originElement.height * heightScale,
  582. left: currentMinX + (originElement.left - minX) * widthScale,
  583. top: currentMinY + (originElement.top - minY) * heightScale,
  584. };
  585. }
  586. return el;
  587. });
  588. elementListRef.current = newElements;
  589. setElementList(newElements);
  590. };
  591. const handleMouseUp = (e: MouseEvent) => {
  592. isMouseDown = false;
  593. document.onmousemove = null;
  594. document.onmouseup = null;
  595. if (startPageX === e.pageX && startPageY === e.pageY) return;
  596. updateSlide({ elements: elementListRef.current });
  597. addHistorySnapshot();
  598. };
  599. document.onmousemove = handleMouseMove;
  600. document.onmouseup = handleMouseUp;
  601. },
  602. [
  603. elementListRef,
  604. setElementList,
  605. canvasScale,
  606. activeElementIdList,
  607. ctrlOrShiftKeyActive,
  608. updateSlide,
  609. addHistorySnapshot,
  610. ],
  611. );
  612. return {
  613. scaleElement,
  614. scaleMultiElement,
  615. };
  616. }