resizeElements.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
  1. import { SHIFT_LOCKING_ANGLE } from "../constants";
  2. import { rescalePoints } from "../points";
  3. import {
  4. rotate,
  5. adjustXYWithRotation,
  6. centerPoint,
  7. rotatePoint,
  8. } from "../math";
  9. import {
  10. ExcalidrawLinearElement,
  11. ExcalidrawTextElement,
  12. NonDeletedExcalidrawElement,
  13. NonDeleted,
  14. ExcalidrawElement,
  15. ExcalidrawTextElementWithContainer,
  16. } from "./types";
  17. import {
  18. getElementAbsoluteCoords,
  19. getCommonBounds,
  20. getResizedElementAbsoluteCoords,
  21. getCommonBoundingBox,
  22. } from "./bounds";
  23. import {
  24. isArrowElement,
  25. isBoundToContainer,
  26. isFreeDrawElement,
  27. isLinearElement,
  28. isTextElement,
  29. } from "./typeChecks";
  30. import { mutateElement } from "./mutateElement";
  31. import { getFontString } from "../utils";
  32. import { updateBoundElements } from "./binding";
  33. import {
  34. TransformHandleType,
  35. MaybeTransformHandleType,
  36. TransformHandleDirection,
  37. } from "./transformHandles";
  38. import { Point, PointerDownState } from "../types";
  39. import Scene from "../scene/Scene";
  40. import {
  41. getApproxMinLineHeight,
  42. getApproxMinLineWidth,
  43. getBoundTextElement,
  44. getBoundTextElementId,
  45. getContainerElement,
  46. handleBindTextResize,
  47. getMaxContainerWidth,
  48. } from "./textElement";
  49. export const normalizeAngle = (angle: number): number => {
  50. if (angle >= 2 * Math.PI) {
  51. return angle - 2 * Math.PI;
  52. }
  53. return angle;
  54. };
  55. // Returns true when transform (resizing/rotation) happened
  56. export const transformElements = (
  57. pointerDownState: PointerDownState,
  58. transformHandleType: MaybeTransformHandleType,
  59. selectedElements: readonly NonDeletedExcalidrawElement[],
  60. resizeArrowDirection: "origin" | "end",
  61. shouldRotateWithDiscreteAngle: boolean,
  62. shouldResizeFromCenter: boolean,
  63. shouldMaintainAspectRatio: boolean,
  64. pointerX: number,
  65. pointerY: number,
  66. centerX: number,
  67. centerY: number,
  68. ) => {
  69. if (selectedElements.length === 1) {
  70. const [element] = selectedElements;
  71. if (transformHandleType === "rotation") {
  72. rotateSingleElement(
  73. element,
  74. pointerX,
  75. pointerY,
  76. shouldRotateWithDiscreteAngle,
  77. pointerDownState.originalElements,
  78. );
  79. updateBoundElements(element);
  80. } else if (
  81. isTextElement(element) &&
  82. (transformHandleType === "nw" ||
  83. transformHandleType === "ne" ||
  84. transformHandleType === "sw" ||
  85. transformHandleType === "se")
  86. ) {
  87. resizeSingleTextElement(
  88. element,
  89. transformHandleType,
  90. shouldResizeFromCenter,
  91. pointerX,
  92. pointerY,
  93. );
  94. updateBoundElements(element);
  95. } else if (transformHandleType) {
  96. resizeSingleElement(
  97. pointerDownState.originalElements,
  98. shouldMaintainAspectRatio,
  99. element,
  100. transformHandleType,
  101. shouldResizeFromCenter,
  102. pointerX,
  103. pointerY,
  104. );
  105. }
  106. return true;
  107. } else if (selectedElements.length > 1) {
  108. if (transformHandleType === "rotation") {
  109. rotateMultipleElements(
  110. pointerDownState,
  111. selectedElements,
  112. pointerX,
  113. pointerY,
  114. shouldRotateWithDiscreteAngle,
  115. centerX,
  116. centerY,
  117. );
  118. return true;
  119. } else if (
  120. transformHandleType === "nw" ||
  121. transformHandleType === "ne" ||
  122. transformHandleType === "sw" ||
  123. transformHandleType === "se"
  124. ) {
  125. resizeMultipleElements(
  126. pointerDownState,
  127. selectedElements,
  128. transformHandleType,
  129. shouldResizeFromCenter,
  130. pointerX,
  131. pointerY,
  132. );
  133. return true;
  134. }
  135. }
  136. return false;
  137. };
  138. const rotateSingleElement = (
  139. element: NonDeletedExcalidrawElement,
  140. pointerX: number,
  141. pointerY: number,
  142. shouldRotateWithDiscreteAngle: boolean,
  143. originalElements: Map<string, NonDeleted<ExcalidrawElement>>,
  144. ) => {
  145. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  146. const cx = (x1 + x2) / 2;
  147. const cy = (y1 + y2) / 2;
  148. let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
  149. if (shouldRotateWithDiscreteAngle) {
  150. angle += SHIFT_LOCKING_ANGLE / 2;
  151. angle -= angle % SHIFT_LOCKING_ANGLE;
  152. }
  153. angle = normalizeAngle(angle);
  154. const boundTextElementId = getBoundTextElementId(element);
  155. mutateElement(element, { angle });
  156. if (boundTextElementId) {
  157. const textElement =
  158. Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>(
  159. boundTextElementId,
  160. );
  161. if (textElement && !isArrowElement(element)) {
  162. mutateElement(textElement, { angle });
  163. }
  164. }
  165. };
  166. const rescalePointsInElement = (
  167. element: NonDeletedExcalidrawElement,
  168. width: number,
  169. height: number,
  170. normalizePoints: boolean,
  171. ) =>
  172. isLinearElement(element) || isFreeDrawElement(element)
  173. ? {
  174. points: rescalePoints(
  175. 0,
  176. width,
  177. rescalePoints(1, height, element.points, normalizePoints),
  178. normalizePoints,
  179. ),
  180. }
  181. : {};
  182. const MIN_FONT_SIZE = 1;
  183. const measureFontSizeFromWidth = (
  184. element: NonDeleted<ExcalidrawTextElement>,
  185. nextWidth: number,
  186. ): number | null => {
  187. // We only use width to scale font on resize
  188. let width = element.width;
  189. const hasContainer = isBoundToContainer(element);
  190. if (hasContainer) {
  191. const container = getContainerElement(element);
  192. if (container) {
  193. width = getMaxContainerWidth(container);
  194. }
  195. }
  196. const nextFontSize = element.fontSize * (nextWidth / width);
  197. if (nextFontSize < MIN_FONT_SIZE) {
  198. return null;
  199. }
  200. return nextFontSize;
  201. };
  202. const getSidesForTransformHandle = (
  203. transformHandleType: TransformHandleType,
  204. shouldResizeFromCenter: boolean,
  205. ) => {
  206. return {
  207. n:
  208. /^(n|ne|nw)$/.test(transformHandleType) ||
  209. (shouldResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
  210. s:
  211. /^(s|se|sw)$/.test(transformHandleType) ||
  212. (shouldResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
  213. w:
  214. /^(w|nw|sw)$/.test(transformHandleType) ||
  215. (shouldResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
  216. e:
  217. /^(e|ne|se)$/.test(transformHandleType) ||
  218. (shouldResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
  219. };
  220. };
  221. const resizeSingleTextElement = (
  222. element: NonDeleted<ExcalidrawTextElement>,
  223. transformHandleType: "nw" | "ne" | "sw" | "se",
  224. shouldResizeFromCenter: boolean,
  225. pointerX: number,
  226. pointerY: number,
  227. ) => {
  228. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  229. const cx = (x1 + x2) / 2;
  230. const cy = (y1 + y2) / 2;
  231. // rotation pointer with reverse angle
  232. const [rotatedX, rotatedY] = rotate(
  233. pointerX,
  234. pointerY,
  235. cx,
  236. cy,
  237. -element.angle,
  238. );
  239. let scale: number;
  240. switch (transformHandleType) {
  241. case "se":
  242. scale = Math.max(
  243. (rotatedX - x1) / (x2 - x1),
  244. (rotatedY - y1) / (y2 - y1),
  245. );
  246. break;
  247. case "nw":
  248. scale = Math.max(
  249. (x2 - rotatedX) / (x2 - x1),
  250. (y2 - rotatedY) / (y2 - y1),
  251. );
  252. break;
  253. case "ne":
  254. scale = Math.max(
  255. (rotatedX - x1) / (x2 - x1),
  256. (y2 - rotatedY) / (y2 - y1),
  257. );
  258. break;
  259. case "sw":
  260. scale = Math.max(
  261. (x2 - rotatedX) / (x2 - x1),
  262. (rotatedY - y1) / (y2 - y1),
  263. );
  264. break;
  265. }
  266. if (scale > 0) {
  267. const nextWidth = element.width * scale;
  268. const nextHeight = element.height * scale;
  269. const nextFontSize = measureFontSizeFromWidth(element, nextWidth);
  270. if (nextFontSize === null) {
  271. return;
  272. }
  273. const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
  274. element,
  275. nextWidth,
  276. nextHeight,
  277. false,
  278. );
  279. const deltaX1 = (x1 - nextX1) / 2;
  280. const deltaY1 = (y1 - nextY1) / 2;
  281. const deltaX2 = (x2 - nextX2) / 2;
  282. const deltaY2 = (y2 - nextY2) / 2;
  283. const [nextElementX, nextElementY] = adjustXYWithRotation(
  284. getSidesForTransformHandle(transformHandleType, shouldResizeFromCenter),
  285. element.x,
  286. element.y,
  287. element.angle,
  288. deltaX1,
  289. deltaY1,
  290. deltaX2,
  291. deltaY2,
  292. );
  293. mutateElement(element, {
  294. fontSize: nextFontSize,
  295. width: nextWidth,
  296. height: nextHeight,
  297. x: nextElementX,
  298. y: nextElementY,
  299. });
  300. }
  301. };
  302. export const resizeSingleElement = (
  303. originalElements: PointerDownState["originalElements"],
  304. shouldMaintainAspectRatio: boolean,
  305. element: NonDeletedExcalidrawElement,
  306. transformHandleDirection: TransformHandleDirection,
  307. shouldResizeFromCenter: boolean,
  308. pointerX: number,
  309. pointerY: number,
  310. ) => {
  311. const stateAtResizeStart = originalElements.get(element.id)!;
  312. // Gets bounds corners
  313. const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
  314. stateAtResizeStart,
  315. stateAtResizeStart.width,
  316. stateAtResizeStart.height,
  317. true,
  318. );
  319. const startTopLeft: Point = [x1, y1];
  320. const startBottomRight: Point = [x2, y2];
  321. const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
  322. // Calculate new dimensions based on cursor position
  323. const rotatedPointer = rotatePoint(
  324. [pointerX, pointerY],
  325. startCenter,
  326. -stateAtResizeStart.angle,
  327. );
  328. // Get bounds corners rendered on screen
  329. const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords(
  330. element,
  331. element.width,
  332. element.height,
  333. true,
  334. );
  335. const boundsCurrentWidth = esx2 - esx1;
  336. const boundsCurrentHeight = esy2 - esy1;
  337. // It's important we set the initial scale value based on the width and height at resize start,
  338. // otherwise previous dimensions affected by modifiers will be taken into account.
  339. const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
  340. const atStartBoundsHeight = startBottomRight[1] - startTopLeft[1];
  341. let scaleX = atStartBoundsWidth / boundsCurrentWidth;
  342. let scaleY = atStartBoundsHeight / boundsCurrentHeight;
  343. let boundTextFont: { fontSize?: number } = {};
  344. const boundTextElement = getBoundTextElement(element);
  345. if (transformHandleDirection.includes("e")) {
  346. scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
  347. }
  348. if (transformHandleDirection.includes("s")) {
  349. scaleY = (rotatedPointer[1] - startTopLeft[1]) / boundsCurrentHeight;
  350. }
  351. if (transformHandleDirection.includes("w")) {
  352. scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
  353. }
  354. if (transformHandleDirection.includes("n")) {
  355. scaleY = (startBottomRight[1] - rotatedPointer[1]) / boundsCurrentHeight;
  356. }
  357. // Linear elements dimensions differ from bounds dimensions
  358. const eleInitialWidth = stateAtResizeStart.width;
  359. const eleInitialHeight = stateAtResizeStart.height;
  360. // We have to use dimensions of element on screen, otherwise the scaling of the
  361. // dimensions won't match the cursor for linear elements.
  362. let eleNewWidth = element.width * scaleX;
  363. let eleNewHeight = element.height * scaleY;
  364. // adjust dimensions for resizing from center
  365. if (shouldResizeFromCenter) {
  366. eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
  367. eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
  368. }
  369. // adjust dimensions to keep sides ratio
  370. if (shouldMaintainAspectRatio) {
  371. const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
  372. const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
  373. if (transformHandleDirection.length === 1) {
  374. eleNewHeight *= widthRatio;
  375. eleNewWidth *= heightRatio;
  376. }
  377. if (transformHandleDirection.length === 2) {
  378. const ratio = Math.max(widthRatio, heightRatio);
  379. eleNewWidth = eleInitialWidth * ratio * Math.sign(eleNewWidth);
  380. eleNewHeight = eleInitialHeight * ratio * Math.sign(eleNewHeight);
  381. }
  382. }
  383. if (boundTextElement) {
  384. const stateOfBoundTextElementAtResize = originalElements.get(
  385. boundTextElement.id,
  386. ) as typeof boundTextElement | undefined;
  387. if (stateOfBoundTextElementAtResize) {
  388. boundTextFont = {
  389. fontSize: stateOfBoundTextElementAtResize.fontSize,
  390. };
  391. }
  392. if (shouldMaintainAspectRatio) {
  393. const updatedElement = {
  394. ...element,
  395. width: eleNewWidth,
  396. height: eleNewHeight,
  397. };
  398. const nextFontSize = measureFontSizeFromWidth(
  399. boundTextElement,
  400. getMaxContainerWidth(updatedElement),
  401. );
  402. if (nextFontSize === null) {
  403. return;
  404. }
  405. boundTextFont = {
  406. fontSize: nextFontSize,
  407. };
  408. } else {
  409. const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
  410. const minHeight = getApproxMinLineHeight(getFontString(boundTextElement));
  411. eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
  412. eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
  413. }
  414. }
  415. const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
  416. getResizedElementAbsoluteCoords(
  417. stateAtResizeStart,
  418. eleNewWidth,
  419. eleNewHeight,
  420. true,
  421. );
  422. const newBoundsWidth = newBoundsX2 - newBoundsX1;
  423. const newBoundsHeight = newBoundsY2 - newBoundsY1;
  424. // Calculate new topLeft based on fixed corner during resize
  425. let newTopLeft = [...startTopLeft] as [number, number];
  426. if (["n", "w", "nw"].includes(transformHandleDirection)) {
  427. newTopLeft = [
  428. startBottomRight[0] - Math.abs(newBoundsWidth),
  429. startBottomRight[1] - Math.abs(newBoundsHeight),
  430. ];
  431. }
  432. if (transformHandleDirection === "ne") {
  433. const bottomLeft = [startTopLeft[0], startBottomRight[1]];
  434. newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
  435. }
  436. if (transformHandleDirection === "sw") {
  437. const topRight = [startBottomRight[0], startTopLeft[1]];
  438. newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
  439. }
  440. // Keeps opposite handle fixed during resize
  441. if (shouldMaintainAspectRatio) {
  442. if (["s", "n"].includes(transformHandleDirection)) {
  443. newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
  444. }
  445. if (["e", "w"].includes(transformHandleDirection)) {
  446. newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
  447. }
  448. }
  449. // Flip horizontally
  450. if (eleNewWidth < 0) {
  451. if (transformHandleDirection.includes("e")) {
  452. newTopLeft[0] -= Math.abs(newBoundsWidth);
  453. }
  454. if (transformHandleDirection.includes("w")) {
  455. newTopLeft[0] += Math.abs(newBoundsWidth);
  456. }
  457. }
  458. // Flip vertically
  459. if (eleNewHeight < 0) {
  460. if (transformHandleDirection.includes("s")) {
  461. newTopLeft[1] -= Math.abs(newBoundsHeight);
  462. }
  463. if (transformHandleDirection.includes("n")) {
  464. newTopLeft[1] += Math.abs(newBoundsHeight);
  465. }
  466. }
  467. if (shouldResizeFromCenter) {
  468. newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2;
  469. newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
  470. }
  471. // adjust topLeft to new rotation point
  472. const angle = stateAtResizeStart.angle;
  473. const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
  474. const newCenter: Point = [
  475. newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
  476. newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
  477. ];
  478. const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
  479. newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
  480. // Readjust points for linear elements
  481. let rescaledElementPointsY;
  482. let rescaledPoints;
  483. if (isLinearElement(element) || isFreeDrawElement(element)) {
  484. rescaledElementPointsY = rescalePoints(
  485. 1,
  486. eleNewHeight,
  487. (stateAtResizeStart as ExcalidrawLinearElement).points,
  488. true,
  489. );
  490. rescaledPoints = rescalePoints(
  491. 0,
  492. eleNewWidth,
  493. rescaledElementPointsY,
  494. true,
  495. );
  496. }
  497. // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
  498. // So we need to readjust (x,y) to be where the first point should be
  499. const newOrigin = [...newTopLeft];
  500. newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
  501. newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
  502. const resizedElement = {
  503. width: Math.abs(eleNewWidth),
  504. height: Math.abs(eleNewHeight),
  505. x: newOrigin[0],
  506. y: newOrigin[1],
  507. points: rescaledPoints,
  508. };
  509. if ("scale" in element && "scale" in stateAtResizeStart) {
  510. mutateElement(element, {
  511. scale: [
  512. // defaulting because scaleX/Y can be 0/-0
  513. (Math.sign(newBoundsX2 - stateAtResizeStart.x) ||
  514. stateAtResizeStart.scale[0]) * stateAtResizeStart.scale[0],
  515. (Math.sign(newBoundsY2 - stateAtResizeStart.y) ||
  516. stateAtResizeStart.scale[1]) * stateAtResizeStart.scale[1],
  517. ],
  518. });
  519. }
  520. if (
  521. resizedElement.width !== 0 &&
  522. resizedElement.height !== 0 &&
  523. Number.isFinite(resizedElement.x) &&
  524. Number.isFinite(resizedElement.y)
  525. ) {
  526. updateBoundElements(element, {
  527. newSize: { width: resizedElement.width, height: resizedElement.height },
  528. });
  529. mutateElement(element, resizedElement);
  530. if (boundTextElement && boundTextFont) {
  531. mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
  532. }
  533. handleBindTextResize(element, transformHandleDirection);
  534. }
  535. };
  536. const resizeMultipleElements = (
  537. pointerDownState: PointerDownState,
  538. selectedElements: readonly NonDeletedExcalidrawElement[],
  539. transformHandleType: "nw" | "ne" | "sw" | "se",
  540. shouldResizeFromCenter: boolean,
  541. pointerX: number,
  542. pointerY: number,
  543. ) => {
  544. // map selected elements to the original elements. While it never should
  545. // happen that pointerDownState.originalElements won't contain the selected
  546. // elements during resize, this coupling isn't guaranteed, so to ensure
  547. // type safety we need to transform only those elements we filter.
  548. const targetElements = selectedElements.reduce(
  549. (
  550. acc: {
  551. /** element at resize start */
  552. orig: NonDeletedExcalidrawElement;
  553. /** latest element */
  554. latest: NonDeletedExcalidrawElement;
  555. }[],
  556. element,
  557. ) => {
  558. const origElement = pointerDownState.originalElements.get(element.id);
  559. if (origElement) {
  560. acc.push({ orig: origElement, latest: element });
  561. }
  562. return acc;
  563. },
  564. [],
  565. );
  566. const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
  567. targetElements.map(({ orig }) => orig),
  568. );
  569. const direction = transformHandleType;
  570. const mapDirectionsToAnchors: Record<typeof direction, Point> = {
  571. ne: [minX, maxY],
  572. se: [minX, minY],
  573. sw: [maxX, minY],
  574. nw: [maxX, maxY],
  575. };
  576. // anchor point must be on the opposite side of the dragged selection handle
  577. // or be the center of the selection if alt is pressed
  578. const [anchorX, anchorY]: Point = shouldResizeFromCenter
  579. ? [midX, midY]
  580. : mapDirectionsToAnchors[direction];
  581. const mapDirectionsToPointerSides: Record<
  582. typeof direction,
  583. [x: boolean, y: boolean]
  584. > = {
  585. ne: [pointerX >= anchorX, pointerY <= anchorY],
  586. se: [pointerX >= anchorX, pointerY >= anchorY],
  587. sw: [pointerX <= anchorX, pointerY >= anchorY],
  588. nw: [pointerX <= anchorX, pointerY <= anchorY],
  589. };
  590. // pointer side relative to anchor
  591. const [pointerSideX, pointerSideY] = mapDirectionsToPointerSides[
  592. direction
  593. ].map((condition) => (condition ? 1 : -1));
  594. // stop resizing if a pointer is on the other side of selection
  595. if (pointerSideX < 0 && pointerSideY < 0) {
  596. return;
  597. }
  598. const scale =
  599. Math.max(
  600. (pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX),
  601. (pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
  602. ) * (shouldResizeFromCenter ? 2 : 1);
  603. if (scale === 0) {
  604. return;
  605. }
  606. targetElements.forEach((element) => {
  607. const width = element.orig.width * scale;
  608. const height = element.orig.height * scale;
  609. const x = anchorX + (element.orig.x - anchorX) * scale;
  610. const y = anchorY + (element.orig.y - anchorY) * scale;
  611. // readjust points for linear & free draw elements
  612. const rescaledPoints = rescalePointsInElement(
  613. element.orig,
  614. width,
  615. height,
  616. false,
  617. );
  618. const update: {
  619. width: number;
  620. height: number;
  621. x: number;
  622. y: number;
  623. points?: Point[];
  624. fontSize?: number;
  625. } = {
  626. width,
  627. height,
  628. x,
  629. y,
  630. ...rescaledPoints,
  631. };
  632. let boundTextUpdates: { fontSize: number } | null = null;
  633. const boundTextElement = getBoundTextElement(element.latest);
  634. if (boundTextElement || isTextElement(element.orig)) {
  635. const updatedElement = {
  636. ...element.latest,
  637. width,
  638. height,
  639. };
  640. const fontSize = measureFontSizeFromWidth(
  641. boundTextElement ?? (element.orig as ExcalidrawTextElement),
  642. getMaxContainerWidth(updatedElement),
  643. );
  644. if (!fontSize) {
  645. return;
  646. }
  647. if (isTextElement(element.orig)) {
  648. update.fontSize = fontSize;
  649. }
  650. if (boundTextElement) {
  651. boundTextUpdates = {
  652. fontSize,
  653. };
  654. }
  655. }
  656. updateBoundElements(element.latest, { newSize: { width, height } });
  657. mutateElement(element.latest, update);
  658. if (boundTextElement && boundTextUpdates) {
  659. mutateElement(boundTextElement, boundTextUpdates);
  660. handleBindTextResize(element.latest, transformHandleType);
  661. }
  662. });
  663. };
  664. const rotateMultipleElements = (
  665. pointerDownState: PointerDownState,
  666. elements: readonly NonDeletedExcalidrawElement[],
  667. pointerX: number,
  668. pointerY: number,
  669. shouldRotateWithDiscreteAngle: boolean,
  670. centerX: number,
  671. centerY: number,
  672. ) => {
  673. let centerAngle =
  674. (5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
  675. if (shouldRotateWithDiscreteAngle) {
  676. centerAngle += SHIFT_LOCKING_ANGLE / 2;
  677. centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
  678. }
  679. elements.forEach((element) => {
  680. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  681. const cx = (x1 + x2) / 2;
  682. const cy = (y1 + y2) / 2;
  683. const origAngle =
  684. pointerDownState.originalElements.get(element.id)?.angle ?? element.angle;
  685. const [rotatedCX, rotatedCY] = rotate(
  686. cx,
  687. cy,
  688. centerX,
  689. centerY,
  690. centerAngle + origAngle - element.angle,
  691. );
  692. mutateElement(element, {
  693. x: element.x + (rotatedCX - cx),
  694. y: element.y + (rotatedCY - cy),
  695. angle: normalizeAngle(centerAngle + origAngle),
  696. });
  697. const boundTextElementId = getBoundTextElementId(element);
  698. if (boundTextElementId) {
  699. const textElement =
  700. Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>(
  701. boundTextElementId,
  702. );
  703. if (textElement && !isArrowElement(element)) {
  704. mutateElement(textElement, {
  705. x: textElement.x + (rotatedCX - cx),
  706. y: textElement.y + (rotatedCY - cy),
  707. angle: normalizeAngle(centerAngle + origAngle),
  708. });
  709. }
  710. }
  711. });
  712. };
  713. export const getResizeOffsetXY = (
  714. transformHandleType: MaybeTransformHandleType,
  715. selectedElements: NonDeletedExcalidrawElement[],
  716. x: number,
  717. y: number,
  718. ): [number, number] => {
  719. const [x1, y1, x2, y2] =
  720. selectedElements.length === 1
  721. ? getElementAbsoluteCoords(selectedElements[0])
  722. : getCommonBounds(selectedElements);
  723. const cx = (x1 + x2) / 2;
  724. const cy = (y1 + y2) / 2;
  725. const angle = selectedElements.length === 1 ? selectedElements[0].angle : 0;
  726. [x, y] = rotate(x, y, cx, cy, -angle);
  727. switch (transformHandleType) {
  728. case "n":
  729. return rotate(x - (x1 + x2) / 2, y - y1, 0, 0, angle);
  730. case "s":
  731. return rotate(x - (x1 + x2) / 2, y - y2, 0, 0, angle);
  732. case "w":
  733. return rotate(x - x1, y - (y1 + y2) / 2, 0, 0, angle);
  734. case "e":
  735. return rotate(x - x2, y - (y1 + y2) / 2, 0, 0, angle);
  736. case "nw":
  737. return rotate(x - x1, y - y1, 0, 0, angle);
  738. case "ne":
  739. return rotate(x - x2, y - y1, 0, 0, angle);
  740. case "sw":
  741. return rotate(x - x1, y - y2, 0, 0, angle);
  742. case "se":
  743. return rotate(x - x2, y - y2, 0, 0, angle);
  744. default:
  745. return [0, 0];
  746. }
  747. };
  748. export const getResizeArrowDirection = (
  749. transformHandleType: MaybeTransformHandleType,
  750. element: NonDeleted<ExcalidrawLinearElement>,
  751. ): "origin" | "end" => {
  752. const [, [px, py]] = element.points;
  753. const isResizeEnd =
  754. (transformHandleType === "nw" && (px < 0 || py < 0)) ||
  755. (transformHandleType === "ne" && px >= 0) ||
  756. (transformHandleType === "sw" && px <= 0) ||
  757. (transformHandleType === "se" && (px > 0 || py > 0));
  758. return isResizeEnd ? "end" : "origin";
  759. };