resizeElements.ts 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556
  1. import {
  2. pointCenter,
  3. normalizeRadians,
  4. pointFrom,
  5. pointFromPair,
  6. pointRotateRads,
  7. type Radians,
  8. type LocalPoint,
  9. } from "@excalidraw/math";
  10. import {
  11. MIN_FONT_SIZE,
  12. SHIFT_LOCKING_ANGLE,
  13. rescalePoints,
  14. getFontString,
  15. } from "@excalidraw/common";
  16. import type { GlobalPoint } from "@excalidraw/math";
  17. import type Scene from "@excalidraw/excalidraw/scene/Scene";
  18. import type { PointerDownState } from "@excalidraw/excalidraw/types";
  19. import type { Mutable } from "@excalidraw/common/utility-types";
  20. import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
  21. import {
  22. getElementAbsoluteCoords,
  23. getCommonBounds,
  24. getResizedElementAbsoluteCoords,
  25. getCommonBoundingBox,
  26. getElementBounds,
  27. } from "./bounds";
  28. import { LinearElementEditor } from "./linearElementEditor";
  29. import { mutateElement } from "./mutateElement";
  30. import {
  31. getBoundTextElement,
  32. getBoundTextElementId,
  33. getContainerElement,
  34. handleBindTextResize,
  35. getBoundTextMaxWidth,
  36. } from "./textElement";
  37. import {
  38. getMinTextElementWidth,
  39. measureText,
  40. getApproxMinLineWidth,
  41. getApproxMinLineHeight,
  42. } from "./textMeasurements";
  43. import { wrapText } from "./textWrapping";
  44. import {
  45. isArrowElement,
  46. isBoundToContainer,
  47. isElbowArrow,
  48. isFrameLikeElement,
  49. isFreeDrawElement,
  50. isImageElement,
  51. isLinearElement,
  52. isTextElement,
  53. } from "./typeChecks";
  54. import { isInGroup } from "./groups";
  55. import type { BoundingBox } from "./bounds";
  56. import type {
  57. MaybeTransformHandleType,
  58. TransformHandleDirection,
  59. } from "./transformHandles";
  60. import type {
  61. ExcalidrawLinearElement,
  62. ExcalidrawTextElement,
  63. NonDeletedExcalidrawElement,
  64. NonDeleted,
  65. ExcalidrawElement,
  66. ExcalidrawTextElementWithContainer,
  67. ExcalidrawImageElement,
  68. ElementsMap,
  69. SceneElementsMap,
  70. ExcalidrawElbowArrowElement,
  71. } from "./types";
  72. // Returns true when transform (resizing/rotation) happened
  73. export const transformElements = (
  74. originalElements: PointerDownState["originalElements"],
  75. transformHandleType: MaybeTransformHandleType,
  76. selectedElements: readonly NonDeletedExcalidrawElement[],
  77. elementsMap: SceneElementsMap,
  78. scene: Scene,
  79. shouldRotateWithDiscreteAngle: boolean,
  80. shouldResizeFromCenter: boolean,
  81. shouldMaintainAspectRatio: boolean,
  82. pointerX: number,
  83. pointerY: number,
  84. centerX: number,
  85. centerY: number,
  86. ): boolean => {
  87. if (selectedElements.length === 1) {
  88. const [element] = selectedElements;
  89. if (transformHandleType === "rotation") {
  90. if (!isElbowArrow(element)) {
  91. rotateSingleElement(
  92. element,
  93. elementsMap,
  94. scene,
  95. pointerX,
  96. pointerY,
  97. shouldRotateWithDiscreteAngle,
  98. );
  99. updateBoundElements(element, elementsMap);
  100. }
  101. } else if (isTextElement(element) && transformHandleType) {
  102. resizeSingleTextElement(
  103. originalElements,
  104. element,
  105. elementsMap,
  106. transformHandleType,
  107. shouldResizeFromCenter,
  108. pointerX,
  109. pointerY,
  110. );
  111. updateBoundElements(element, elementsMap);
  112. return true;
  113. } else if (transformHandleType) {
  114. const elementId = selectedElements[0].id;
  115. const latestElement = elementsMap.get(elementId);
  116. const origElement = originalElements.get(elementId);
  117. if (latestElement && origElement) {
  118. const { nextWidth, nextHeight } =
  119. getNextSingleWidthAndHeightFromPointer(
  120. latestElement,
  121. origElement,
  122. elementsMap,
  123. originalElements,
  124. transformHandleType,
  125. pointerX,
  126. pointerY,
  127. {
  128. shouldMaintainAspectRatio,
  129. shouldResizeFromCenter,
  130. },
  131. );
  132. resizeSingleElement(
  133. nextWidth,
  134. nextHeight,
  135. latestElement,
  136. origElement,
  137. elementsMap,
  138. originalElements,
  139. transformHandleType,
  140. {
  141. shouldMaintainAspectRatio,
  142. shouldResizeFromCenter,
  143. },
  144. );
  145. }
  146. }
  147. return true;
  148. } else if (selectedElements.length > 1) {
  149. if (transformHandleType === "rotation") {
  150. rotateMultipleElements(
  151. originalElements,
  152. selectedElements,
  153. elementsMap,
  154. scene,
  155. pointerX,
  156. pointerY,
  157. shouldRotateWithDiscreteAngle,
  158. centerX,
  159. centerY,
  160. );
  161. return true;
  162. } else if (transformHandleType) {
  163. const { nextWidth, nextHeight, flipByX, flipByY, originalBoundingBox } =
  164. getNextMultipleWidthAndHeightFromPointer(
  165. selectedElements,
  166. originalElements,
  167. elementsMap,
  168. transformHandleType,
  169. pointerX,
  170. pointerY,
  171. {
  172. shouldMaintainAspectRatio,
  173. shouldResizeFromCenter,
  174. },
  175. );
  176. resizeMultipleElements(
  177. selectedElements,
  178. elementsMap,
  179. transformHandleType,
  180. scene,
  181. originalElements,
  182. {
  183. shouldResizeFromCenter,
  184. shouldMaintainAspectRatio,
  185. flipByX,
  186. flipByY,
  187. nextWidth,
  188. nextHeight,
  189. originalBoundingBox,
  190. },
  191. );
  192. return true;
  193. }
  194. }
  195. return false;
  196. };
  197. const rotateSingleElement = (
  198. element: NonDeletedExcalidrawElement,
  199. elementsMap: ElementsMap,
  200. scene: Scene,
  201. pointerX: number,
  202. pointerY: number,
  203. shouldRotateWithDiscreteAngle: boolean,
  204. ) => {
  205. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
  206. const cx = (x1 + x2) / 2;
  207. const cy = (y1 + y2) / 2;
  208. let angle: Radians;
  209. if (isFrameLikeElement(element)) {
  210. angle = 0 as Radians;
  211. } else {
  212. angle = ((5 * Math.PI) / 2 +
  213. Math.atan2(pointerY - cy, pointerX - cx)) as Radians;
  214. if (shouldRotateWithDiscreteAngle) {
  215. angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians;
  216. angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians;
  217. }
  218. angle = normalizeRadians(angle as Radians);
  219. }
  220. const boundTextElementId = getBoundTextElementId(element);
  221. mutateElement(element, { angle });
  222. if (boundTextElementId) {
  223. const textElement =
  224. scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
  225. if (textElement && !isArrowElement(element)) {
  226. mutateElement(textElement, { angle });
  227. }
  228. }
  229. };
  230. export const rescalePointsInElement = (
  231. element: NonDeletedExcalidrawElement,
  232. width: number,
  233. height: number,
  234. normalizePoints: boolean,
  235. ) =>
  236. isLinearElement(element) || isFreeDrawElement(element)
  237. ? {
  238. points: rescalePoints(
  239. 0,
  240. width,
  241. rescalePoints(1, height, element.points, normalizePoints),
  242. normalizePoints,
  243. ),
  244. }
  245. : {};
  246. export const measureFontSizeFromWidth = (
  247. element: NonDeleted<ExcalidrawTextElement>,
  248. elementsMap: ElementsMap,
  249. nextWidth: number,
  250. ): { size: number } | null => {
  251. // We only use width to scale font on resize
  252. let width = element.width;
  253. const hasContainer = isBoundToContainer(element);
  254. if (hasContainer) {
  255. const container = getContainerElement(element, elementsMap);
  256. if (container) {
  257. width = getBoundTextMaxWidth(container, element);
  258. }
  259. }
  260. const nextFontSize = element.fontSize * (nextWidth / width);
  261. if (nextFontSize < MIN_FONT_SIZE) {
  262. return null;
  263. }
  264. return {
  265. size: nextFontSize,
  266. };
  267. };
  268. const resizeSingleTextElement = (
  269. originalElements: PointerDownState["originalElements"],
  270. element: NonDeleted<ExcalidrawTextElement>,
  271. elementsMap: ElementsMap,
  272. transformHandleType: TransformHandleDirection,
  273. shouldResizeFromCenter: boolean,
  274. pointerX: number,
  275. pointerY: number,
  276. ) => {
  277. const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
  278. element,
  279. elementsMap,
  280. );
  281. // rotation pointer with reverse angle
  282. const [rotatedX, rotatedY] = pointRotateRads(
  283. pointFrom(pointerX, pointerY),
  284. pointFrom(cx, cy),
  285. -element.angle as Radians,
  286. );
  287. let scaleX = 0;
  288. let scaleY = 0;
  289. if (transformHandleType !== "e" && transformHandleType !== "w") {
  290. if (transformHandleType.includes("e")) {
  291. scaleX = (rotatedX - x1) / (x2 - x1);
  292. }
  293. if (transformHandleType.includes("w")) {
  294. scaleX = (x2 - rotatedX) / (x2 - x1);
  295. }
  296. if (transformHandleType.includes("n")) {
  297. scaleY = (y2 - rotatedY) / (y2 - y1);
  298. }
  299. if (transformHandleType.includes("s")) {
  300. scaleY = (rotatedY - y1) / (y2 - y1);
  301. }
  302. }
  303. const scale = Math.max(scaleX, scaleY);
  304. if (scale > 0) {
  305. const nextWidth = element.width * scale;
  306. const nextHeight = element.height * scale;
  307. const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
  308. if (metrics === null) {
  309. return;
  310. }
  311. const startTopLeft = [x1, y1];
  312. const startBottomRight = [x2, y2];
  313. const startCenter = [cx, cy];
  314. let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
  315. if (["n", "w", "nw"].includes(transformHandleType)) {
  316. newTopLeft = pointFrom<GlobalPoint>(
  317. startBottomRight[0] - Math.abs(nextWidth),
  318. startBottomRight[1] - Math.abs(nextHeight),
  319. );
  320. }
  321. if (transformHandleType === "ne") {
  322. const bottomLeft = [startTopLeft[0], startBottomRight[1]];
  323. newTopLeft = pointFrom<GlobalPoint>(
  324. bottomLeft[0],
  325. bottomLeft[1] - Math.abs(nextHeight),
  326. );
  327. }
  328. if (transformHandleType === "sw") {
  329. const topRight = [startBottomRight[0], startTopLeft[1]];
  330. newTopLeft = pointFrom<GlobalPoint>(
  331. topRight[0] - Math.abs(nextWidth),
  332. topRight[1],
  333. );
  334. }
  335. if (["s", "n"].includes(transformHandleType)) {
  336. newTopLeft[0] = startCenter[0] - nextWidth / 2;
  337. }
  338. if (["e", "w"].includes(transformHandleType)) {
  339. newTopLeft[1] = startCenter[1] - nextHeight / 2;
  340. }
  341. if (shouldResizeFromCenter) {
  342. newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
  343. newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
  344. }
  345. const angle = element.angle;
  346. const rotatedTopLeft = pointRotateRads(
  347. newTopLeft,
  348. pointFrom(cx, cy),
  349. angle,
  350. );
  351. const newCenter = pointFrom<GlobalPoint>(
  352. newTopLeft[0] + Math.abs(nextWidth) / 2,
  353. newTopLeft[1] + Math.abs(nextHeight) / 2,
  354. );
  355. const rotatedNewCenter = pointRotateRads(
  356. newCenter,
  357. pointFrom(cx, cy),
  358. angle,
  359. );
  360. newTopLeft = pointRotateRads(
  361. rotatedTopLeft,
  362. rotatedNewCenter,
  363. -angle as Radians,
  364. );
  365. const [nextX, nextY] = newTopLeft;
  366. mutateElement(element, {
  367. fontSize: metrics.size,
  368. width: nextWidth,
  369. height: nextHeight,
  370. x: nextX,
  371. y: nextY,
  372. });
  373. }
  374. if (transformHandleType === "e" || transformHandleType === "w") {
  375. const stateAtResizeStart = originalElements.get(element.id)!;
  376. const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
  377. stateAtResizeStart,
  378. stateAtResizeStart.width,
  379. stateAtResizeStart.height,
  380. true,
  381. );
  382. const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
  383. const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
  384. const startCenter = pointCenter(startTopLeft, startBottomRight);
  385. const rotatedPointer = pointRotateRads(
  386. pointFrom(pointerX, pointerY),
  387. startCenter,
  388. -stateAtResizeStart.angle as Radians,
  389. );
  390. const [esx1, , esx2] = getResizedElementAbsoluteCoords(
  391. element,
  392. element.width,
  393. element.height,
  394. true,
  395. );
  396. const boundsCurrentWidth = esx2 - esx1;
  397. const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
  398. const minWidth = getMinTextElementWidth(
  399. getFontString({
  400. fontSize: element.fontSize,
  401. fontFamily: element.fontFamily,
  402. }),
  403. element.lineHeight,
  404. );
  405. let scaleX = atStartBoundsWidth / boundsCurrentWidth;
  406. if (transformHandleType.includes("e")) {
  407. scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
  408. }
  409. if (transformHandleType.includes("w")) {
  410. scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
  411. }
  412. const newWidth =
  413. element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
  414. const text = wrapText(
  415. element.originalText,
  416. getFontString(element),
  417. Math.abs(newWidth),
  418. );
  419. const metrics = measureText(
  420. text,
  421. getFontString(element),
  422. element.lineHeight,
  423. );
  424. const eleNewHeight = metrics.height;
  425. const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
  426. getResizedElementAbsoluteCoords(
  427. stateAtResizeStart,
  428. newWidth,
  429. eleNewHeight,
  430. true,
  431. );
  432. const newBoundsWidth = newBoundsX2 - newBoundsX1;
  433. const newBoundsHeight = newBoundsY2 - newBoundsY1;
  434. let newTopLeft = [...startTopLeft] as [number, number];
  435. if (["n", "w", "nw"].includes(transformHandleType)) {
  436. newTopLeft = [
  437. startBottomRight[0] - Math.abs(newBoundsWidth),
  438. startTopLeft[1],
  439. ];
  440. }
  441. // adjust topLeft to new rotation point
  442. const angle = stateAtResizeStart.angle;
  443. const rotatedTopLeft = pointRotateRads(
  444. pointFromPair(newTopLeft),
  445. startCenter,
  446. angle,
  447. );
  448. const newCenter = pointFrom(
  449. newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
  450. newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
  451. );
  452. const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
  453. newTopLeft = pointRotateRads(
  454. rotatedTopLeft,
  455. rotatedNewCenter,
  456. -angle as Radians,
  457. );
  458. const resizedElement: Partial<ExcalidrawTextElement> = {
  459. width: Math.abs(newWidth),
  460. height: Math.abs(metrics.height),
  461. x: newTopLeft[0],
  462. y: newTopLeft[1],
  463. text,
  464. autoResize: false,
  465. };
  466. mutateElement(element, resizedElement);
  467. }
  468. };
  469. const rotateMultipleElements = (
  470. originalElements: PointerDownState["originalElements"],
  471. elements: readonly NonDeletedExcalidrawElement[],
  472. elementsMap: SceneElementsMap,
  473. scene: Scene,
  474. pointerX: number,
  475. pointerY: number,
  476. shouldRotateWithDiscreteAngle: boolean,
  477. centerX: number,
  478. centerY: number,
  479. ) => {
  480. let centerAngle =
  481. (5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
  482. if (shouldRotateWithDiscreteAngle) {
  483. centerAngle += SHIFT_LOCKING_ANGLE / 2;
  484. centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
  485. }
  486. for (const element of elements) {
  487. if (!isFrameLikeElement(element)) {
  488. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
  489. const cx = (x1 + x2) / 2;
  490. const cy = (y1 + y2) / 2;
  491. const origAngle =
  492. originalElements.get(element.id)?.angle ?? element.angle;
  493. const [rotatedCX, rotatedCY] = pointRotateRads(
  494. pointFrom(cx, cy),
  495. pointFrom(centerX, centerY),
  496. (centerAngle + origAngle - element.angle) as Radians,
  497. );
  498. if (isElbowArrow(element)) {
  499. // Needed to re-route the arrow
  500. mutateElement(element, {
  501. points: getArrowLocalFixedPoints(element, elementsMap),
  502. });
  503. } else {
  504. mutateElement(
  505. element,
  506. {
  507. x: element.x + (rotatedCX - cx),
  508. y: element.y + (rotatedCY - cy),
  509. angle: normalizeRadians((centerAngle + origAngle) as Radians),
  510. },
  511. false,
  512. );
  513. }
  514. updateBoundElements(element, elementsMap, {
  515. simultaneouslyUpdated: elements,
  516. });
  517. const boundText = getBoundTextElement(element, elementsMap);
  518. if (boundText && !isArrowElement(element)) {
  519. mutateElement(
  520. boundText,
  521. {
  522. x: boundText.x + (rotatedCX - cx),
  523. y: boundText.y + (rotatedCY - cy),
  524. angle: normalizeRadians((centerAngle + origAngle) as Radians),
  525. },
  526. false,
  527. );
  528. }
  529. }
  530. }
  531. scene.triggerUpdate();
  532. };
  533. export const getResizeOffsetXY = (
  534. transformHandleType: MaybeTransformHandleType,
  535. selectedElements: NonDeletedExcalidrawElement[],
  536. elementsMap: ElementsMap,
  537. x: number,
  538. y: number,
  539. ): [number, number] => {
  540. const [x1, y1, x2, y2] =
  541. selectedElements.length === 1
  542. ? getElementAbsoluteCoords(selectedElements[0], elementsMap)
  543. : getCommonBounds(selectedElements);
  544. const cx = (x1 + x2) / 2;
  545. const cy = (y1 + y2) / 2;
  546. const angle = (
  547. selectedElements.length === 1 ? selectedElements[0].angle : 0
  548. ) as Radians;
  549. [x, y] = pointRotateRads(
  550. pointFrom(x, y),
  551. pointFrom(cx, cy),
  552. -angle as Radians,
  553. );
  554. switch (transformHandleType) {
  555. case "n":
  556. return pointRotateRads(
  557. pointFrom(x - (x1 + x2) / 2, y - y1),
  558. pointFrom(0, 0),
  559. angle,
  560. );
  561. case "s":
  562. return pointRotateRads(
  563. pointFrom(x - (x1 + x2) / 2, y - y2),
  564. pointFrom(0, 0),
  565. angle,
  566. );
  567. case "w":
  568. return pointRotateRads(
  569. pointFrom(x - x1, y - (y1 + y2) / 2),
  570. pointFrom(0, 0),
  571. angle,
  572. );
  573. case "e":
  574. return pointRotateRads(
  575. pointFrom(x - x2, y - (y1 + y2) / 2),
  576. pointFrom(0, 0),
  577. angle,
  578. );
  579. case "nw":
  580. return pointRotateRads(pointFrom(x - x1, y - y1), pointFrom(0, 0), angle);
  581. case "ne":
  582. return pointRotateRads(pointFrom(x - x2, y - y1), pointFrom(0, 0), angle);
  583. case "sw":
  584. return pointRotateRads(pointFrom(x - x1, y - y2), pointFrom(0, 0), angle);
  585. case "se":
  586. return pointRotateRads(pointFrom(x - x2, y - y2), pointFrom(0, 0), angle);
  587. default:
  588. return [0, 0];
  589. }
  590. };
  591. export const getResizeArrowDirection = (
  592. transformHandleType: MaybeTransformHandleType,
  593. element: NonDeleted<ExcalidrawLinearElement>,
  594. ): "origin" | "end" => {
  595. const [, [px, py]] = element.points;
  596. const isResizeEnd =
  597. (transformHandleType === "nw" && (px < 0 || py < 0)) ||
  598. (transformHandleType === "ne" && px >= 0) ||
  599. (transformHandleType === "sw" && px <= 0) ||
  600. (transformHandleType === "se" && (px > 0 || py > 0));
  601. return isResizeEnd ? "end" : "origin";
  602. };
  603. type ResizeAnchor =
  604. | "top-left"
  605. | "top-right"
  606. | "bottom-left"
  607. | "bottom-right"
  608. | "west-side"
  609. | "north-side"
  610. | "east-side"
  611. | "south-side"
  612. | "center";
  613. const getResizeAnchor = (
  614. handleDirection: TransformHandleDirection,
  615. shouldMaintainAspectRatio: boolean,
  616. shouldResizeFromCenter: boolean,
  617. ): ResizeAnchor => {
  618. if (shouldResizeFromCenter) {
  619. return "center";
  620. }
  621. if (shouldMaintainAspectRatio) {
  622. switch (handleDirection) {
  623. case "n":
  624. return "south-side";
  625. case "e": {
  626. return "west-side";
  627. }
  628. case "s":
  629. return "north-side";
  630. case "w":
  631. return "east-side";
  632. case "ne":
  633. return "bottom-left";
  634. case "nw":
  635. return "bottom-right";
  636. case "se":
  637. return "top-left";
  638. case "sw":
  639. return "top-right";
  640. }
  641. }
  642. if (["e", "se", "s"].includes(handleDirection)) {
  643. return "top-left";
  644. } else if (["n", "nw", "w"].includes(handleDirection)) {
  645. return "bottom-right";
  646. } else if (handleDirection === "ne") {
  647. return "bottom-left";
  648. }
  649. return "top-right";
  650. };
  651. const getResizedOrigin = (
  652. prevOrigin: GlobalPoint,
  653. prevWidth: number,
  654. prevHeight: number,
  655. newWidth: number,
  656. newHeight: number,
  657. angle: number,
  658. handleDirection: TransformHandleDirection,
  659. shouldMaintainAspectRatio: boolean,
  660. shouldResizeFromCenter: boolean,
  661. ): { x: number; y: number } => {
  662. const anchor = getResizeAnchor(
  663. handleDirection,
  664. shouldMaintainAspectRatio,
  665. shouldResizeFromCenter,
  666. );
  667. const [x, y] = prevOrigin;
  668. switch (anchor) {
  669. case "top-left":
  670. return {
  671. x:
  672. x +
  673. (prevWidth - newWidth) / 2 +
  674. ((newWidth - prevWidth) / 2) * Math.cos(angle) +
  675. ((prevHeight - newHeight) / 2) * Math.sin(angle),
  676. y:
  677. y +
  678. (prevHeight - newHeight) / 2 +
  679. ((newWidth - prevWidth) / 2) * Math.sin(angle) +
  680. ((newHeight - prevHeight) / 2) * Math.cos(angle),
  681. };
  682. case "top-right":
  683. return {
  684. x:
  685. x +
  686. ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1) +
  687. ((prevHeight - newHeight) / 2) * Math.sin(angle),
  688. y:
  689. y +
  690. (prevHeight - newHeight) / 2 +
  691. ((prevWidth - newWidth) / 2) * Math.sin(angle) +
  692. ((newHeight - prevHeight) / 2) * Math.cos(angle),
  693. };
  694. case "bottom-left":
  695. return {
  696. x:
  697. x +
  698. ((prevWidth - newWidth) / 2) * (1 - Math.cos(angle)) +
  699. ((newHeight - prevHeight) / 2) * Math.sin(angle),
  700. y:
  701. y +
  702. ((prevHeight - newHeight) / 2) * (Math.cos(angle) + 1) +
  703. ((newWidth - prevWidth) / 2) * Math.sin(angle),
  704. };
  705. case "bottom-right":
  706. return {
  707. x:
  708. x +
  709. ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1) +
  710. ((newHeight - prevHeight) / 2) * Math.sin(angle),
  711. y:
  712. y +
  713. ((prevHeight - newHeight) / 2) * (Math.cos(angle) + 1) +
  714. ((prevWidth - newWidth) / 2) * Math.sin(angle),
  715. };
  716. case "center":
  717. return {
  718. x: x - (newWidth - prevWidth) / 2,
  719. y: y - (newHeight - prevHeight) / 2,
  720. };
  721. case "east-side":
  722. return {
  723. x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
  724. y:
  725. y +
  726. ((prevWidth - newWidth) / 2) * Math.sin(angle) +
  727. (prevHeight - newHeight) / 2,
  728. };
  729. case "west-side":
  730. return {
  731. x: x + ((prevWidth - newWidth) / 2) * (1 - Math.cos(angle)),
  732. y:
  733. y +
  734. ((newWidth - prevWidth) / 2) * Math.sin(angle) +
  735. (prevHeight - newHeight) / 2,
  736. };
  737. case "north-side":
  738. return {
  739. x:
  740. x +
  741. (prevWidth - newWidth) / 2 +
  742. ((prevHeight - newHeight) / 2) * Math.sin(angle),
  743. y: y + ((newHeight - prevHeight) / 2) * (Math.cos(angle) - 1),
  744. };
  745. case "south-side":
  746. return {
  747. x:
  748. x +
  749. (prevWidth - newWidth) / 2 +
  750. ((newHeight - prevHeight) / 2) * Math.sin(angle),
  751. y: y + ((prevHeight - newHeight) / 2) * (Math.cos(angle) + 1),
  752. };
  753. }
  754. };
  755. export const resizeSingleElement = (
  756. nextWidth: number,
  757. nextHeight: number,
  758. latestElement: ExcalidrawElement,
  759. origElement: ExcalidrawElement,
  760. elementsMap: ElementsMap,
  761. originalElementsMap: ElementsMap,
  762. handleDirection: TransformHandleDirection,
  763. {
  764. shouldInformMutation = true,
  765. shouldMaintainAspectRatio = false,
  766. shouldResizeFromCenter = false,
  767. }: {
  768. shouldMaintainAspectRatio?: boolean;
  769. shouldResizeFromCenter?: boolean;
  770. shouldInformMutation?: boolean;
  771. } = {},
  772. ) => {
  773. let boundTextFont: { fontSize?: number } = {};
  774. const boundTextElement = getBoundTextElement(latestElement, elementsMap);
  775. if (boundTextElement) {
  776. const stateOfBoundTextElementAtResize = originalElementsMap.get(
  777. boundTextElement.id,
  778. ) as typeof boundTextElement | undefined;
  779. if (stateOfBoundTextElementAtResize) {
  780. boundTextFont = {
  781. fontSize: stateOfBoundTextElementAtResize.fontSize,
  782. };
  783. }
  784. if (shouldMaintainAspectRatio) {
  785. const updatedElement = {
  786. ...latestElement,
  787. width: nextWidth,
  788. height: nextHeight,
  789. };
  790. const nextFont = measureFontSizeFromWidth(
  791. boundTextElement,
  792. elementsMap,
  793. getBoundTextMaxWidth(updatedElement, boundTextElement),
  794. );
  795. if (nextFont === null) {
  796. return;
  797. }
  798. boundTextFont = {
  799. fontSize: nextFont.size,
  800. };
  801. } else {
  802. const minWidth = getApproxMinLineWidth(
  803. getFontString(boundTextElement),
  804. boundTextElement.lineHeight,
  805. );
  806. const minHeight = getApproxMinLineHeight(
  807. boundTextElement.fontSize,
  808. boundTextElement.lineHeight,
  809. );
  810. nextWidth = Math.max(nextWidth, minWidth);
  811. nextHeight = Math.max(nextHeight, minHeight);
  812. }
  813. }
  814. const rescaledPoints = rescalePointsInElement(
  815. origElement,
  816. nextWidth,
  817. nextHeight,
  818. true,
  819. );
  820. let previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
  821. if (isLinearElement(origElement)) {
  822. const [x1, y1] = getElementBounds(origElement, originalElementsMap);
  823. previousOrigin = pointFrom<GlobalPoint>(x1, y1);
  824. }
  825. const newOrigin: {
  826. x: number;
  827. y: number;
  828. } = getResizedOrigin(
  829. previousOrigin,
  830. origElement.width,
  831. origElement.height,
  832. nextWidth,
  833. nextHeight,
  834. origElement.angle,
  835. handleDirection,
  836. shouldMaintainAspectRatio!!,
  837. shouldResizeFromCenter!!,
  838. );
  839. if (isLinearElement(origElement) && rescaledPoints.points) {
  840. const offsetX = origElement.x - previousOrigin[0];
  841. const offsetY = origElement.y - previousOrigin[1];
  842. newOrigin.x += offsetX;
  843. newOrigin.y += offsetY;
  844. const scaledX = rescaledPoints.points[0][0];
  845. const scaledY = rescaledPoints.points[0][1];
  846. newOrigin.x += scaledX;
  847. newOrigin.y += scaledY;
  848. rescaledPoints.points = rescaledPoints.points.map((p) =>
  849. pointFrom<LocalPoint>(p[0] - scaledX, p[1] - scaledY),
  850. );
  851. }
  852. // flipping
  853. if (nextWidth < 0) {
  854. newOrigin.x = newOrigin.x + nextWidth;
  855. }
  856. if (nextHeight < 0) {
  857. newOrigin.y = newOrigin.y + nextHeight;
  858. }
  859. if ("scale" in latestElement && "scale" in origElement) {
  860. mutateElement(latestElement, {
  861. scale: [
  862. // defaulting because scaleX/Y can be 0/-0
  863. (Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0],
  864. (Math.sign(nextHeight) || origElement.scale[1]) * origElement.scale[1],
  865. ],
  866. });
  867. }
  868. if (
  869. isArrowElement(latestElement) &&
  870. boundTextElement &&
  871. shouldMaintainAspectRatio
  872. ) {
  873. const fontSize =
  874. (nextWidth / latestElement.width) * boundTextElement.fontSize;
  875. if (fontSize < MIN_FONT_SIZE) {
  876. return;
  877. }
  878. boundTextFont.fontSize = fontSize;
  879. }
  880. if (
  881. nextWidth !== 0 &&
  882. nextHeight !== 0 &&
  883. Number.isFinite(newOrigin.x) &&
  884. Number.isFinite(newOrigin.y)
  885. ) {
  886. const updates = {
  887. ...newOrigin,
  888. width: Math.abs(nextWidth),
  889. height: Math.abs(nextHeight),
  890. ...rescaledPoints,
  891. };
  892. mutateElement(latestElement, updates, shouldInformMutation);
  893. updateBoundElements(latestElement, elementsMap as SceneElementsMap, {
  894. // TODO: confirm with MARK if this actually makes sense
  895. newSize: { width: nextWidth, height: nextHeight },
  896. });
  897. if (boundTextElement && boundTextFont != null) {
  898. mutateElement(boundTextElement, {
  899. fontSize: boundTextFont.fontSize,
  900. });
  901. }
  902. handleBindTextResize(
  903. latestElement,
  904. elementsMap,
  905. handleDirection,
  906. shouldMaintainAspectRatio,
  907. );
  908. }
  909. };
  910. const getNextSingleWidthAndHeightFromPointer = (
  911. latestElement: ExcalidrawElement,
  912. origElement: ExcalidrawElement,
  913. elementsMap: ElementsMap,
  914. originalElementsMap: ElementsMap,
  915. handleDirection: TransformHandleDirection,
  916. pointerX: number,
  917. pointerY: number,
  918. {
  919. shouldMaintainAspectRatio = false,
  920. shouldResizeFromCenter = false,
  921. }: {
  922. shouldMaintainAspectRatio?: boolean;
  923. shouldResizeFromCenter?: boolean;
  924. } = {},
  925. ) => {
  926. // Gets bounds corners
  927. const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
  928. origElement,
  929. origElement.width,
  930. origElement.height,
  931. true,
  932. );
  933. const startTopLeft = pointFrom(x1, y1);
  934. const startBottomRight = pointFrom(x2, y2);
  935. const startCenter = pointCenter(startTopLeft, startBottomRight);
  936. // Calculate new dimensions based on cursor position
  937. const rotatedPointer = pointRotateRads(
  938. pointFrom(pointerX, pointerY),
  939. startCenter,
  940. -origElement.angle as Radians,
  941. );
  942. // Get bounds corners rendered on screen
  943. const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords(
  944. latestElement,
  945. latestElement.width,
  946. latestElement.height,
  947. true,
  948. );
  949. const boundsCurrentWidth = esx2 - esx1;
  950. const boundsCurrentHeight = esy2 - esy1;
  951. // It's important we set the initial scale value based on the width and height at resize start,
  952. // otherwise previous dimensions affected by modifiers will be taken into account.
  953. const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
  954. const atStartBoundsHeight = startBottomRight[1] - startTopLeft[1];
  955. let scaleX = atStartBoundsWidth / boundsCurrentWidth;
  956. let scaleY = atStartBoundsHeight / boundsCurrentHeight;
  957. if (handleDirection.includes("e")) {
  958. scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
  959. }
  960. if (handleDirection.includes("s")) {
  961. scaleY = (rotatedPointer[1] - startTopLeft[1]) / boundsCurrentHeight;
  962. }
  963. if (handleDirection.includes("w")) {
  964. scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
  965. }
  966. if (handleDirection.includes("n")) {
  967. scaleY = (startBottomRight[1] - rotatedPointer[1]) / boundsCurrentHeight;
  968. }
  969. // We have to use dimensions of element on screen, otherwise the scaling of the
  970. // dimensions won't match the cursor for linear elements.
  971. let nextWidth = latestElement.width * scaleX;
  972. let nextHeight = latestElement.height * scaleY;
  973. if (shouldResizeFromCenter) {
  974. nextWidth = 2 * nextWidth - origElement.width;
  975. nextHeight = 2 * nextHeight - origElement.height;
  976. }
  977. // adjust dimensions to keep sides ratio
  978. if (shouldMaintainAspectRatio) {
  979. const widthRatio = Math.abs(nextWidth) / origElement.width;
  980. const heightRatio = Math.abs(nextHeight) / origElement.height;
  981. if (handleDirection.length === 1) {
  982. nextHeight *= widthRatio;
  983. nextWidth *= heightRatio;
  984. }
  985. if (handleDirection.length === 2) {
  986. const ratio = Math.max(widthRatio, heightRatio);
  987. nextWidth = origElement.width * ratio * Math.sign(nextWidth);
  988. nextHeight = origElement.height * ratio * Math.sign(nextHeight);
  989. }
  990. }
  991. return {
  992. nextWidth,
  993. nextHeight,
  994. };
  995. };
  996. const getNextMultipleWidthAndHeightFromPointer = (
  997. selectedElements: readonly NonDeletedExcalidrawElement[],
  998. originalElementsMap: ElementsMap,
  999. elementsMap: ElementsMap,
  1000. handleDirection: TransformHandleDirection,
  1001. pointerX: number,
  1002. pointerY: number,
  1003. {
  1004. shouldMaintainAspectRatio = false,
  1005. shouldResizeFromCenter = false,
  1006. }: {
  1007. shouldResizeFromCenter?: boolean;
  1008. shouldMaintainAspectRatio?: boolean;
  1009. } = {},
  1010. ) => {
  1011. const originalElementsArray = selectedElements.map(
  1012. (el) => originalElementsMap.get(el.id)!,
  1013. );
  1014. // getCommonBoundingBox() uses getBoundTextElement() which returns null for
  1015. // original elements from pointerDownState, so we have to find and add these
  1016. // bound text elements manually. Additionally, the coordinates of bound text
  1017. // elements aren't always up to date.
  1018. const boundTextElements = originalElementsArray.reduce((acc, orig) => {
  1019. if (!isLinearElement(orig)) {
  1020. return acc;
  1021. }
  1022. const textId = getBoundTextElementId(orig);
  1023. if (!textId) {
  1024. return acc;
  1025. }
  1026. const text = originalElementsMap.get(textId) ?? null;
  1027. if (!isBoundToContainer(text)) {
  1028. return acc;
  1029. }
  1030. return [
  1031. ...acc,
  1032. {
  1033. ...text,
  1034. ...LinearElementEditor.getBoundTextElementPosition(
  1035. orig,
  1036. text,
  1037. elementsMap,
  1038. ),
  1039. },
  1040. ];
  1041. }, [] as ExcalidrawTextElementWithContainer[]);
  1042. const originalBoundingBox = getCommonBoundingBox(
  1043. originalElementsArray.map((orig) => orig).concat(boundTextElements),
  1044. );
  1045. const { minX, minY, maxX, maxY, midX, midY } = originalBoundingBox;
  1046. const width = maxX - minX;
  1047. const height = maxY - minY;
  1048. const anchorsMap = {
  1049. ne: [minX, maxY],
  1050. se: [minX, minY],
  1051. sw: [maxX, minY],
  1052. nw: [maxX, maxY],
  1053. e: [minX, minY + height / 2],
  1054. w: [maxX, minY + height / 2],
  1055. n: [minX + width / 2, maxY],
  1056. s: [minX + width / 2, minY],
  1057. } as Record<TransformHandleDirection, GlobalPoint>;
  1058. // anchor point must be on the opposite side of the dragged selection handle
  1059. // or be the center of the selection if shouldResizeFromCenter
  1060. const [anchorX, anchorY] = shouldResizeFromCenter
  1061. ? [midX, midY]
  1062. : anchorsMap[handleDirection];
  1063. const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
  1064. const scale =
  1065. Math.max(
  1066. Math.abs(pointerX - anchorX) / width || 0,
  1067. Math.abs(pointerY - anchorY) / height || 0,
  1068. ) * resizeFromCenterScale;
  1069. let nextWidth =
  1070. handleDirection.includes("e") || handleDirection.includes("w")
  1071. ? Math.abs(pointerX - anchorX) * resizeFromCenterScale
  1072. : width;
  1073. let nextHeight =
  1074. handleDirection.includes("n") || handleDirection.includes("s")
  1075. ? Math.abs(pointerY - anchorY) * resizeFromCenterScale
  1076. : height;
  1077. if (shouldMaintainAspectRatio) {
  1078. nextWidth = width * scale * Math.sign(pointerX - anchorX);
  1079. nextHeight = height * scale * Math.sign(pointerY - anchorY);
  1080. }
  1081. const flipConditionsMap: Record<
  1082. TransformHandleDirection,
  1083. // Condition for which we should flip or not flip the selected elements
  1084. // - when evaluated to `true`, we flip
  1085. // - therefore, setting it to always `false` means we do not flip (in that direction) at all
  1086. [x: boolean, y: boolean]
  1087. > = {
  1088. ne: [pointerX < anchorX, pointerY > anchorY],
  1089. se: [pointerX < anchorX, pointerY < anchorY],
  1090. sw: [pointerX > anchorX, pointerY < anchorY],
  1091. nw: [pointerX > anchorX, pointerY > anchorY],
  1092. // e.g. when resizing from the "e" side, we do not need to consider changes in the `y` direction
  1093. // and therefore, we do not need to flip in the `y` direction at all
  1094. e: [pointerX < anchorX, false],
  1095. w: [pointerX > anchorX, false],
  1096. n: [false, pointerY > anchorY],
  1097. s: [false, pointerY < anchorY],
  1098. };
  1099. const [flipByX, flipByY] = flipConditionsMap[handleDirection].map(
  1100. (condition) => condition,
  1101. );
  1102. return {
  1103. originalBoundingBox,
  1104. nextWidth,
  1105. nextHeight,
  1106. flipByX,
  1107. flipByY,
  1108. };
  1109. };
  1110. export const resizeMultipleElements = (
  1111. selectedElements: readonly NonDeletedExcalidrawElement[],
  1112. elementsMap: ElementsMap,
  1113. handleDirection: TransformHandleDirection,
  1114. scene: Scene,
  1115. originalElementsMap: ElementsMap,
  1116. {
  1117. shouldMaintainAspectRatio = false,
  1118. shouldResizeFromCenter = false,
  1119. flipByX = false,
  1120. flipByY = false,
  1121. nextHeight,
  1122. nextWidth,
  1123. originalBoundingBox,
  1124. }: {
  1125. nextWidth?: number;
  1126. nextHeight?: number;
  1127. shouldMaintainAspectRatio?: boolean;
  1128. shouldResizeFromCenter?: boolean;
  1129. flipByX?: boolean;
  1130. flipByY?: boolean;
  1131. // added to improve performance
  1132. originalBoundingBox?: BoundingBox;
  1133. } = {},
  1134. ) => {
  1135. // in the case of just flipping, there is no need to specify the next width and height
  1136. if (
  1137. nextWidth === undefined &&
  1138. nextHeight === undefined &&
  1139. flipByX === undefined &&
  1140. flipByY === undefined
  1141. ) {
  1142. return;
  1143. }
  1144. // do not allow next width or height to be 0
  1145. if (nextHeight === 0 || nextWidth === 0) {
  1146. return;
  1147. }
  1148. if (!originalElementsMap) {
  1149. originalElementsMap = elementsMap;
  1150. }
  1151. const targetElements = selectedElements.reduce(
  1152. (
  1153. acc: {
  1154. /** element at resize start */
  1155. orig: NonDeletedExcalidrawElement;
  1156. /** latest element */
  1157. latest: NonDeletedExcalidrawElement;
  1158. }[],
  1159. element,
  1160. ) => {
  1161. const origElement = originalElementsMap!.get(element.id);
  1162. if (origElement) {
  1163. acc.push({ orig: origElement, latest: element });
  1164. }
  1165. return acc;
  1166. },
  1167. [],
  1168. );
  1169. let boundingBox: BoundingBox;
  1170. if (originalBoundingBox) {
  1171. boundingBox = originalBoundingBox;
  1172. } else {
  1173. const boundTextElements = targetElements.reduce((acc, { orig }) => {
  1174. if (!isLinearElement(orig)) {
  1175. return acc;
  1176. }
  1177. const textId = getBoundTextElementId(orig);
  1178. if (!textId) {
  1179. return acc;
  1180. }
  1181. const text = originalElementsMap!.get(textId) ?? null;
  1182. if (!isBoundToContainer(text)) {
  1183. return acc;
  1184. }
  1185. return [
  1186. ...acc,
  1187. {
  1188. ...text,
  1189. ...LinearElementEditor.getBoundTextElementPosition(
  1190. orig,
  1191. text,
  1192. elementsMap,
  1193. ),
  1194. },
  1195. ];
  1196. }, [] as ExcalidrawTextElementWithContainer[]);
  1197. boundingBox = getCommonBoundingBox(
  1198. targetElements.map(({ orig }) => orig).concat(boundTextElements),
  1199. );
  1200. }
  1201. const { minX, minY, maxX, maxY, midX, midY } = boundingBox;
  1202. const width = maxX - minX;
  1203. const height = maxY - minY;
  1204. if (nextWidth === undefined && nextHeight === undefined) {
  1205. nextWidth = width;
  1206. nextHeight = height;
  1207. }
  1208. if (shouldMaintainAspectRatio) {
  1209. if (nextWidth === undefined) {
  1210. nextWidth = nextHeight! * (width / height);
  1211. } else if (nextHeight === undefined) {
  1212. nextHeight = nextWidth! * (height / width);
  1213. } else if (Math.abs(nextWidth / nextHeight - width / height) > 0.001) {
  1214. nextWidth = nextHeight * (width / height);
  1215. }
  1216. }
  1217. if (nextWidth && nextHeight) {
  1218. let scaleX =
  1219. handleDirection.includes("e") || handleDirection.includes("w")
  1220. ? Math.abs(nextWidth) / width
  1221. : 1;
  1222. let scaleY =
  1223. handleDirection.includes("n") || handleDirection.includes("s")
  1224. ? Math.abs(nextHeight) / height
  1225. : 1;
  1226. let scale: number;
  1227. if (handleDirection.length === 1) {
  1228. scale =
  1229. handleDirection.includes("e") || handleDirection.includes("w")
  1230. ? scaleX
  1231. : scaleY;
  1232. } else {
  1233. scale = Math.max(
  1234. Math.abs(nextWidth) / width || 0,
  1235. Math.abs(nextHeight) / height || 0,
  1236. );
  1237. }
  1238. const anchorsMap = {
  1239. ne: [minX, maxY],
  1240. se: [minX, minY],
  1241. sw: [maxX, minY],
  1242. nw: [maxX, maxY],
  1243. e: [minX, minY + height / 2],
  1244. w: [maxX, minY + height / 2],
  1245. n: [minX + width / 2, maxY],
  1246. s: [minX + width / 2, minY],
  1247. } as Record<TransformHandleDirection, GlobalPoint>;
  1248. // anchor point must be on the opposite side of the dragged selection handle
  1249. // or be the center of the selection if shouldResizeFromCenter
  1250. const [anchorX, anchorY] = shouldResizeFromCenter
  1251. ? [midX, midY]
  1252. : anchorsMap[handleDirection];
  1253. const keepAspectRatio =
  1254. shouldMaintainAspectRatio ||
  1255. targetElements.some(
  1256. (item) =>
  1257. item.latest.angle !== 0 ||
  1258. isTextElement(item.latest) ||
  1259. isInGroup(item.latest),
  1260. );
  1261. if (keepAspectRatio) {
  1262. scaleX = scale;
  1263. scaleY = scale;
  1264. }
  1265. /**
  1266. * to flip an element:
  1267. * 1. determine over which axis is the element being flipped
  1268. * (could be x, y, or both) indicated by `flipFactorX` & `flipFactorY`
  1269. * 2. shift element's position by the amount of width or height (or both) or
  1270. * mirror points in the case of linear & freedraw elemenets
  1271. * 3. adjust element angle
  1272. */
  1273. const [flipFactorX, flipFactorY] = [flipByX ? -1 : 1, flipByY ? -1 : 1];
  1274. const elementsAndUpdates: {
  1275. element: NonDeletedExcalidrawElement;
  1276. update: Mutable<
  1277. Pick<ExcalidrawElement, "x" | "y" | "width" | "height" | "angle">
  1278. > & {
  1279. points?: ExcalidrawLinearElement["points"];
  1280. fontSize?: ExcalidrawTextElement["fontSize"];
  1281. scale?: ExcalidrawImageElement["scale"];
  1282. boundTextFontSize?: ExcalidrawTextElement["fontSize"];
  1283. startBinding?: ExcalidrawElbowArrowElement["startBinding"];
  1284. endBinding?: ExcalidrawElbowArrowElement["endBinding"];
  1285. fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"];
  1286. };
  1287. }[] = [];
  1288. for (const { orig, latest } of targetElements) {
  1289. // bounded text elements are updated along with their container elements
  1290. if (isTextElement(orig) && isBoundToContainer(orig)) {
  1291. continue;
  1292. }
  1293. const width = orig.width * scaleX;
  1294. const height = orig.height * scaleY;
  1295. const angle = normalizeRadians(
  1296. (orig.angle * flipFactorX * flipFactorY) as Radians,
  1297. );
  1298. const isLinearOrFreeDraw =
  1299. isLinearElement(orig) || isFreeDrawElement(orig);
  1300. const offsetX = orig.x - anchorX;
  1301. const offsetY = orig.y - anchorY;
  1302. const shiftX = flipByX && !isLinearOrFreeDraw ? width : 0;
  1303. const shiftY = flipByY && !isLinearOrFreeDraw ? height : 0;
  1304. const x = anchorX + flipFactorX * (offsetX * scaleX + shiftX);
  1305. const y = anchorY + flipFactorY * (offsetY * scaleY + shiftY);
  1306. const rescaledPoints = rescalePointsInElement(
  1307. orig,
  1308. width * flipFactorX,
  1309. height * flipFactorY,
  1310. false,
  1311. );
  1312. const update: typeof elementsAndUpdates[0]["update"] = {
  1313. x,
  1314. y,
  1315. width,
  1316. height,
  1317. angle,
  1318. ...rescaledPoints,
  1319. };
  1320. if (isElbowArrow(orig)) {
  1321. // Mirror fixed point binding for elbow arrows
  1322. // when resize goes into the negative direction
  1323. if (orig.startBinding) {
  1324. update.startBinding = {
  1325. ...orig.startBinding,
  1326. fixedPoint: [
  1327. flipByX
  1328. ? -orig.startBinding.fixedPoint[0] + 1
  1329. : orig.startBinding.fixedPoint[0],
  1330. flipByY
  1331. ? -orig.startBinding.fixedPoint[1] + 1
  1332. : orig.startBinding.fixedPoint[1],
  1333. ],
  1334. };
  1335. }
  1336. if (orig.endBinding) {
  1337. update.endBinding = {
  1338. ...orig.endBinding,
  1339. fixedPoint: [
  1340. flipByX
  1341. ? -orig.endBinding.fixedPoint[0] + 1
  1342. : orig.endBinding.fixedPoint[0],
  1343. flipByY
  1344. ? -orig.endBinding.fixedPoint[1] + 1
  1345. : orig.endBinding.fixedPoint[1],
  1346. ],
  1347. };
  1348. }
  1349. if (orig.fixedSegments && rescaledPoints.points) {
  1350. update.fixedSegments = orig.fixedSegments.map((segment) => ({
  1351. ...segment,
  1352. start: rescaledPoints.points[segment.index - 1],
  1353. end: rescaledPoints.points[segment.index],
  1354. }));
  1355. }
  1356. }
  1357. if (isImageElement(orig)) {
  1358. update.scale = [
  1359. orig.scale[0] * flipFactorX,
  1360. orig.scale[1] * flipFactorY,
  1361. ];
  1362. }
  1363. if (isTextElement(orig)) {
  1364. const metrics = measureFontSizeFromWidth(orig, elementsMap, width);
  1365. if (!metrics) {
  1366. return;
  1367. }
  1368. update.fontSize = metrics.size;
  1369. }
  1370. const boundTextElement = originalElementsMap.get(
  1371. getBoundTextElementId(orig) ?? "",
  1372. ) as ExcalidrawTextElementWithContainer | undefined;
  1373. if (boundTextElement) {
  1374. if (keepAspectRatio) {
  1375. const newFontSize = boundTextElement.fontSize * scale;
  1376. if (newFontSize < MIN_FONT_SIZE) {
  1377. return;
  1378. }
  1379. update.boundTextFontSize = newFontSize;
  1380. } else {
  1381. update.boundTextFontSize = boundTextElement.fontSize;
  1382. }
  1383. }
  1384. elementsAndUpdates.push({
  1385. element: latest,
  1386. update,
  1387. });
  1388. }
  1389. const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
  1390. for (const {
  1391. element,
  1392. update: { boundTextFontSize, ...update },
  1393. } of elementsAndUpdates) {
  1394. const { width, height, angle } = update;
  1395. mutateElement(element, update, false, {
  1396. // needed for the fixed binding point udpate to take effect
  1397. isDragging: true,
  1398. });
  1399. updateBoundElements(element, elementsMap as SceneElementsMap, {
  1400. simultaneouslyUpdated: elementsToUpdate,
  1401. newSize: { width, height },
  1402. });
  1403. const boundTextElement = getBoundTextElement(element, elementsMap);
  1404. if (boundTextElement && boundTextFontSize) {
  1405. mutateElement(
  1406. boundTextElement,
  1407. {
  1408. fontSize: boundTextFontSize,
  1409. angle: isLinearElement(element) ? undefined : angle,
  1410. },
  1411. false,
  1412. );
  1413. handleBindTextResize(element, elementsMap, handleDirection, true);
  1414. }
  1415. }
  1416. scene.triggerUpdate();
  1417. }
  1418. };