MultiDimension.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. import { pointFrom, type GlobalPoint } from "@excalidraw/math";
  2. import { useMemo } from "react";
  3. import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
  4. import {
  5. getElementsInResizingFrame,
  6. isFrameLikeElement,
  7. replaceAllElementsInFrame,
  8. updateBoundElements,
  9. } from "@excalidraw/element";
  10. import {
  11. rescalePointsInElement,
  12. resizeSingleElement,
  13. } from "@excalidraw/element";
  14. import { getBoundTextElement, handleBindTextResize } from "@excalidraw/element";
  15. import { isTextElement } from "@excalidraw/element";
  16. import { getCommonBounds } from "@excalidraw/utils";
  17. import type {
  18. ElementsMap,
  19. ExcalidrawElement,
  20. NonDeletedSceneElementsMap,
  21. } from "@excalidraw/element/types";
  22. import type { Scene } from "@excalidraw/element";
  23. import DragInput from "./DragInput";
  24. import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
  25. import { getElementsInAtomicUnit } from "./utils";
  26. import type {
  27. DragFinishedCallbackType,
  28. DragInputCallbackType,
  29. } from "./DragInput";
  30. import type { AtomicUnit } from "./utils";
  31. import type { AppState } from "../../types";
  32. interface MultiDimensionProps {
  33. property: "width" | "height";
  34. elements: readonly ExcalidrawElement[];
  35. elementsMap: NonDeletedSceneElementsMap;
  36. atomicUnits: AtomicUnit[];
  37. scene: Scene;
  38. appState: AppState;
  39. }
  40. const STEP_SIZE = 10;
  41. const getResizedUpdates = (
  42. anchorX: number,
  43. anchorY: number,
  44. scale: number,
  45. origElement: ExcalidrawElement,
  46. ) => {
  47. const offsetX = origElement.x - anchorX;
  48. const offsetY = origElement.y - anchorY;
  49. const nextWidth = origElement.width * scale;
  50. const nextHeight = origElement.height * scale;
  51. const x = anchorX + offsetX * scale;
  52. const y = anchorY + offsetY * scale;
  53. return {
  54. width: nextWidth,
  55. height: nextHeight,
  56. x,
  57. y,
  58. ...rescalePointsInElement(origElement, nextWidth, nextHeight, false),
  59. ...(isTextElement(origElement)
  60. ? { fontSize: origElement.fontSize * scale }
  61. : {}),
  62. };
  63. };
  64. const resizeElementInGroup = (
  65. anchorX: number,
  66. anchorY: number,
  67. property: MultiDimensionProps["property"],
  68. scale: number,
  69. latestElement: ExcalidrawElement,
  70. origElement: ExcalidrawElement,
  71. originalElementsMap: ElementsMap,
  72. scene: Scene,
  73. ) => {
  74. const elementsMap = scene.getNonDeletedElementsMap();
  75. const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
  76. scene.mutateElement(latestElement, updates);
  77. const boundTextElement = getBoundTextElement(
  78. origElement,
  79. originalElementsMap,
  80. );
  81. if (boundTextElement) {
  82. const newFontSize = boundTextElement.fontSize * scale;
  83. updateBoundElements(latestElement, scene, {
  84. newSize: { width: updates.width, height: updates.height },
  85. });
  86. const latestBoundTextElement = elementsMap.get(boundTextElement.id);
  87. if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
  88. scene.mutateElement(latestBoundTextElement, {
  89. fontSize: newFontSize,
  90. });
  91. handleBindTextResize(
  92. latestElement,
  93. scene,
  94. property === "width" ? "e" : "s",
  95. true,
  96. );
  97. }
  98. }
  99. };
  100. const resizeGroup = (
  101. nextWidth: number,
  102. nextHeight: number,
  103. initialHeight: number,
  104. aspectRatio: number,
  105. anchor: GlobalPoint,
  106. property: MultiDimensionProps["property"],
  107. latestElements: ExcalidrawElement[],
  108. originalElements: ExcalidrawElement[],
  109. originalElementsMap: ElementsMap,
  110. scene: Scene,
  111. ) => {
  112. // keep aspect ratio for groups
  113. if (property === "width") {
  114. nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
  115. } else {
  116. nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
  117. }
  118. const scale = nextHeight / initialHeight;
  119. for (let i = 0; i < originalElements.length; i++) {
  120. const origElement = originalElements[i];
  121. const latestElement = latestElements[i];
  122. resizeElementInGroup(
  123. anchor[0],
  124. anchor[1],
  125. property,
  126. scale,
  127. latestElement,
  128. origElement,
  129. originalElementsMap,
  130. scene,
  131. );
  132. }
  133. };
  134. const handleDimensionChange: DragInputCallbackType<
  135. MultiDimensionProps["property"]
  136. > = ({
  137. accumulatedChange,
  138. originalElements,
  139. originalElementsMap,
  140. originalAppState,
  141. shouldChangeByStepSize,
  142. nextValue,
  143. scene,
  144. property,
  145. setAppState,
  146. app,
  147. }) => {
  148. const elementsMap = scene.getNonDeletedElementsMap();
  149. const atomicUnits = getAtomicUnits(originalElements, originalAppState);
  150. if (nextValue !== undefined) {
  151. for (const atomicUnit of atomicUnits) {
  152. const elementsInUnit = getElementsInAtomicUnit(
  153. atomicUnit,
  154. elementsMap,
  155. originalElementsMap,
  156. );
  157. if (elementsInUnit.length > 1) {
  158. const latestElements = elementsInUnit.map((el) => el.latest!);
  159. const originalElements = elementsInUnit.map((el) => el.original!);
  160. const [x1, y1, x2, y2] = getCommonBounds(originalElements);
  161. const initialWidth = x2 - x1;
  162. const initialHeight = y2 - y1;
  163. const aspectRatio = initialWidth / initialHeight;
  164. const nextWidth = Math.max(
  165. MIN_WIDTH_OR_HEIGHT,
  166. property === "width" ? Math.max(0, nextValue) : initialWidth,
  167. );
  168. const nextHeight = Math.max(
  169. MIN_WIDTH_OR_HEIGHT,
  170. property === "height" ? Math.max(0, nextValue) : initialHeight,
  171. );
  172. resizeGroup(
  173. nextWidth,
  174. nextHeight,
  175. initialHeight,
  176. aspectRatio,
  177. pointFrom(x1, y1),
  178. property,
  179. latestElements,
  180. originalElements,
  181. originalElementsMap,
  182. scene,
  183. );
  184. } else {
  185. const [el] = elementsInUnit;
  186. const latestElement = el?.latest;
  187. const origElement = el?.original;
  188. if (
  189. latestElement &&
  190. origElement &&
  191. isPropertyEditable(latestElement, property)
  192. ) {
  193. let nextWidth =
  194. property === "width" ? Math.max(0, nextValue) : latestElement.width;
  195. if (property === "width") {
  196. if (shouldChangeByStepSize) {
  197. nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
  198. } else {
  199. nextWidth = Math.round(nextWidth);
  200. }
  201. }
  202. let nextHeight =
  203. property === "height"
  204. ? Math.max(0, nextValue)
  205. : latestElement.height;
  206. if (property === "height") {
  207. if (shouldChangeByStepSize) {
  208. nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
  209. } else {
  210. nextHeight = Math.round(nextHeight);
  211. }
  212. }
  213. nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
  214. nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
  215. resizeSingleElement(
  216. nextWidth,
  217. nextHeight,
  218. latestElement,
  219. origElement,
  220. originalElementsMap,
  221. scene,
  222. property === "width" ? "e" : "s",
  223. {
  224. shouldInformMutation: false,
  225. },
  226. );
  227. // Handle frame membership update for resized frames
  228. if (isFrameLikeElement(latestElement)) {
  229. const nextElementsInFrame = getElementsInResizingFrame(
  230. scene.getElementsIncludingDeleted(),
  231. latestElement,
  232. originalAppState,
  233. scene.getNonDeletedElementsMap(),
  234. );
  235. const updatedElements = replaceAllElementsInFrame(
  236. scene.getElementsIncludingDeleted(),
  237. nextElementsInFrame,
  238. latestElement,
  239. app,
  240. );
  241. scene.replaceAllElements(updatedElements);
  242. }
  243. }
  244. }
  245. }
  246. scene.triggerUpdate();
  247. return;
  248. }
  249. const changeInWidth = property === "width" ? accumulatedChange : 0;
  250. const changeInHeight = property === "height" ? accumulatedChange : 0;
  251. const elementsToHighlight: ExcalidrawElement[] = [];
  252. for (const atomicUnit of atomicUnits) {
  253. const elementsInUnit = getElementsInAtomicUnit(
  254. atomicUnit,
  255. elementsMap,
  256. originalElementsMap,
  257. );
  258. if (elementsInUnit.length > 1) {
  259. const latestElements = elementsInUnit.map((el) => el.latest!);
  260. const originalElements = elementsInUnit.map((el) => el.original!);
  261. const [x1, y1, x2, y2] = getCommonBounds(originalElements);
  262. const initialWidth = x2 - x1;
  263. const initialHeight = y2 - y1;
  264. const aspectRatio = initialWidth / initialHeight;
  265. let nextWidth = Math.max(0, initialWidth + changeInWidth);
  266. if (property === "width") {
  267. if (shouldChangeByStepSize) {
  268. nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
  269. } else {
  270. nextWidth = Math.round(nextWidth);
  271. }
  272. }
  273. let nextHeight = Math.max(0, initialHeight + changeInHeight);
  274. if (property === "height") {
  275. if (shouldChangeByStepSize) {
  276. nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
  277. } else {
  278. nextHeight = Math.round(nextHeight);
  279. }
  280. }
  281. nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
  282. nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
  283. resizeGroup(
  284. nextWidth,
  285. nextHeight,
  286. initialHeight,
  287. aspectRatio,
  288. pointFrom(x1, y1),
  289. property,
  290. latestElements,
  291. originalElements,
  292. originalElementsMap,
  293. scene,
  294. );
  295. } else {
  296. const [el] = elementsInUnit;
  297. const latestElement = el?.latest;
  298. const origElement = el?.original;
  299. if (
  300. latestElement &&
  301. origElement &&
  302. isPropertyEditable(latestElement, property)
  303. ) {
  304. let nextWidth = Math.max(0, origElement.width + changeInWidth);
  305. if (property === "width") {
  306. if (shouldChangeByStepSize) {
  307. nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
  308. } else {
  309. nextWidth = Math.round(nextWidth);
  310. }
  311. }
  312. let nextHeight = Math.max(0, origElement.height + changeInHeight);
  313. if (property === "height") {
  314. if (shouldChangeByStepSize) {
  315. nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
  316. } else {
  317. nextHeight = Math.round(nextHeight);
  318. }
  319. }
  320. nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
  321. nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
  322. resizeSingleElement(
  323. nextWidth,
  324. nextHeight,
  325. latestElement,
  326. origElement,
  327. originalElementsMap,
  328. scene,
  329. property === "width" ? "e" : "s",
  330. {
  331. shouldInformMutation: false,
  332. },
  333. );
  334. // Handle highlighting frame element candidates
  335. if (isFrameLikeElement(latestElement)) {
  336. const nextElementsInFrame = getElementsInResizingFrame(
  337. scene.getElementsIncludingDeleted(),
  338. latestElement,
  339. originalAppState,
  340. scene.getNonDeletedElementsMap(),
  341. );
  342. elementsToHighlight.push(...nextElementsInFrame);
  343. }
  344. }
  345. }
  346. }
  347. setAppState({
  348. elementsToHighlight,
  349. });
  350. scene.triggerUpdate();
  351. };
  352. const handleDragFinished: DragFinishedCallbackType = ({
  353. setAppState,
  354. app,
  355. originalElements,
  356. originalAppState,
  357. }) => {
  358. const elementsMap = app.scene.getNonDeletedElementsMap();
  359. const origElement = originalElements?.[0];
  360. const latestElement = origElement && elementsMap.get(origElement.id);
  361. // Handle frame membership update for resized frames
  362. if (latestElement && isFrameLikeElement(latestElement)) {
  363. const nextElementsInFrame = getElementsInResizingFrame(
  364. app.scene.getElementsIncludingDeleted(),
  365. latestElement,
  366. originalAppState,
  367. app.scene.getNonDeletedElementsMap(),
  368. );
  369. const updatedElements = replaceAllElementsInFrame(
  370. app.scene.getElementsIncludingDeleted(),
  371. nextElementsInFrame,
  372. latestElement,
  373. app,
  374. );
  375. app.scene.replaceAllElements(updatedElements);
  376. setAppState({
  377. elementsToHighlight: null,
  378. });
  379. }
  380. };
  381. const MultiDimension = ({
  382. property,
  383. elements,
  384. elementsMap,
  385. atomicUnits,
  386. scene,
  387. appState,
  388. }: MultiDimensionProps) => {
  389. const sizes = useMemo(
  390. () =>
  391. atomicUnits.map((atomicUnit) => {
  392. const elementsInUnit = getElementsInAtomicUnit(atomicUnit, elementsMap);
  393. if (elementsInUnit.length > 1) {
  394. const [x1, y1, x2, y2] = getCommonBounds(
  395. elementsInUnit.map((el) => el.latest),
  396. );
  397. return (
  398. Math.round((property === "width" ? x2 - x1 : y2 - y1) * 100) / 100
  399. );
  400. }
  401. const [el] = elementsInUnit;
  402. return (
  403. Math.round(
  404. (property === "width" ? el.latest.width : el.latest.height) * 100,
  405. ) / 100
  406. );
  407. }),
  408. [elementsMap, atomicUnits, property],
  409. );
  410. const value =
  411. new Set(sizes).size === 1 ? Math.round(sizes[0] * 100) / 100 : "Mixed";
  412. const editable = sizes.length > 0;
  413. return (
  414. <DragInput
  415. label={property === "width" ? "W" : "H"}
  416. elements={elements}
  417. dragInputCallback={handleDimensionChange}
  418. value={value}
  419. editable={editable}
  420. appState={appState}
  421. property={property}
  422. scene={scene}
  423. dragFinishedCallback={handleDragFinished}
  424. />
  425. );
  426. };
  427. export default MultiDimension;