utils.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import type { Radians } from "../../../math";
  2. import { pointFrom, pointRotateRads } from "../../../math";
  3. import {
  4. bindOrUnbindLinearElements,
  5. updateBoundElements,
  6. } from "../../element/binding";
  7. import { mutateElement } from "../../element/mutateElement";
  8. import {
  9. measureFontSizeFromWidth,
  10. rescalePointsInElement,
  11. } from "../../element/resizeElements";
  12. import {
  13. getApproxMinLineHeight,
  14. getApproxMinLineWidth,
  15. getBoundTextElement,
  16. getBoundTextMaxWidth,
  17. handleBindTextResize,
  18. } from "../../element/textElement";
  19. import {
  20. isFrameLikeElement,
  21. isLinearElement,
  22. isTextElement,
  23. } from "../../element/typeChecks";
  24. import type {
  25. ElementsMap,
  26. ExcalidrawElement,
  27. NonDeletedExcalidrawElement,
  28. NonDeletedSceneElementsMap,
  29. } from "../../element/types";
  30. import {
  31. getSelectedGroupIds,
  32. getElementsInGroup,
  33. isInGroup,
  34. } from "../../groups";
  35. import type Scene from "../../scene/Scene";
  36. import type { AppState } from "../../types";
  37. import { getFontString } from "../../utils";
  38. export type StatsInputProperty =
  39. | "x"
  40. | "y"
  41. | "width"
  42. | "height"
  43. | "angle"
  44. | "fontSize"
  45. | "gridStep";
  46. export const SMALLEST_DELTA = 0.01;
  47. export const isPropertyEditable = (
  48. element: ExcalidrawElement,
  49. property: keyof ExcalidrawElement,
  50. ) => {
  51. if (property === "height" && isTextElement(element)) {
  52. return false;
  53. }
  54. if (property === "width" && isTextElement(element)) {
  55. return false;
  56. }
  57. if (property === "angle" && isFrameLikeElement(element)) {
  58. return false;
  59. }
  60. return true;
  61. };
  62. export const getStepSizedValue = (value: number, stepSize: number) => {
  63. const v = value + stepSize / 2;
  64. return v - (v % stepSize);
  65. };
  66. export type AtomicUnit = Record<string, true>;
  67. export const getElementsInAtomicUnit = (
  68. atomicUnit: AtomicUnit,
  69. elementsMap: ElementsMap,
  70. originalElementsMap?: ElementsMap,
  71. ) => {
  72. return Object.keys(atomicUnit)
  73. .map((id) => ({
  74. original: (originalElementsMap ?? elementsMap).get(id),
  75. latest: elementsMap.get(id),
  76. }))
  77. .filter((el) => el.original !== undefined && el.latest !== undefined) as {
  78. original: NonDeletedExcalidrawElement;
  79. latest: NonDeletedExcalidrawElement;
  80. }[];
  81. };
  82. export const newOrigin = (
  83. x1: number,
  84. y1: number,
  85. w1: number,
  86. h1: number,
  87. w2: number,
  88. h2: number,
  89. angle: number,
  90. ) => {
  91. /**
  92. * The formula below is the result of solving
  93. * rotate(x1, y1, cx1, cy1, angle) = rotate(x2, y2, cx2, cy2, angle)
  94. * where rotate is the function defined in math.ts
  95. *
  96. * This is so that the new origin (x2, y2),
  97. * when rotated against the new center (cx2, cy2),
  98. * coincides with (x1, y1) rotated against (cx1, cy1)
  99. *
  100. * The reason for doing this computation is so the element's top left corner
  101. * on the canvas remains fixed after any changes in its dimension.
  102. */
  103. return {
  104. x:
  105. x1 +
  106. (w1 - w2) / 2 +
  107. ((w2 - w1) / 2) * Math.cos(angle) +
  108. ((h1 - h2) / 2) * Math.sin(angle),
  109. y:
  110. y1 +
  111. (h1 - h2) / 2 +
  112. ((w2 - w1) / 2) * Math.sin(angle) +
  113. ((h2 - h1) / 2) * Math.cos(angle),
  114. };
  115. };
  116. export const resizeElement = (
  117. nextWidth: number,
  118. nextHeight: number,
  119. keepAspectRatio: boolean,
  120. origElement: ExcalidrawElement,
  121. elementsMap: NonDeletedSceneElementsMap,
  122. elements: readonly NonDeletedExcalidrawElement[],
  123. scene: Scene,
  124. shouldInformMutation = true,
  125. ) => {
  126. const latestElement = elementsMap.get(origElement.id);
  127. if (!latestElement) {
  128. return;
  129. }
  130. let boundTextFont: { fontSize?: number } = {};
  131. const boundTextElement = getBoundTextElement(latestElement, elementsMap);
  132. if (boundTextElement) {
  133. const minWidth = getApproxMinLineWidth(
  134. getFontString(boundTextElement),
  135. boundTextElement.lineHeight,
  136. );
  137. const minHeight = getApproxMinLineHeight(
  138. boundTextElement.fontSize,
  139. boundTextElement.lineHeight,
  140. );
  141. nextWidth = Math.max(nextWidth, minWidth);
  142. nextHeight = Math.max(nextHeight, minHeight);
  143. }
  144. mutateElement(
  145. latestElement,
  146. {
  147. ...newOrigin(
  148. latestElement.x,
  149. latestElement.y,
  150. latestElement.width,
  151. latestElement.height,
  152. nextWidth,
  153. nextHeight,
  154. latestElement.angle,
  155. ),
  156. width: nextWidth,
  157. height: nextHeight,
  158. ...rescalePointsInElement(origElement, nextWidth, nextHeight, true),
  159. },
  160. shouldInformMutation,
  161. );
  162. updateBindings(latestElement, elementsMap, elements, scene, {
  163. newSize: {
  164. width: nextWidth,
  165. height: nextHeight,
  166. },
  167. });
  168. if (boundTextElement) {
  169. boundTextFont = {
  170. fontSize: boundTextElement.fontSize,
  171. };
  172. if (keepAspectRatio) {
  173. const updatedElement = {
  174. ...latestElement,
  175. width: nextWidth,
  176. height: nextHeight,
  177. };
  178. const nextFont = measureFontSizeFromWidth(
  179. boundTextElement,
  180. elementsMap,
  181. getBoundTextMaxWidth(updatedElement, boundTextElement),
  182. );
  183. boundTextFont = {
  184. fontSize: nextFont?.size ?? boundTextElement.fontSize,
  185. };
  186. }
  187. }
  188. updateBoundElements(latestElement, elementsMap, {
  189. newSize: { width: nextWidth, height: nextHeight },
  190. });
  191. if (boundTextElement && boundTextFont) {
  192. mutateElement(boundTextElement, {
  193. fontSize: boundTextFont.fontSize,
  194. });
  195. }
  196. handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
  197. };
  198. export const moveElement = (
  199. newTopLeftX: number,
  200. newTopLeftY: number,
  201. originalElement: ExcalidrawElement,
  202. elementsMap: NonDeletedSceneElementsMap,
  203. elements: readonly NonDeletedExcalidrawElement[],
  204. scene: Scene,
  205. originalElementsMap: ElementsMap,
  206. shouldInformMutation = true,
  207. ) => {
  208. const latestElement = elementsMap.get(originalElement.id);
  209. if (!latestElement) {
  210. return;
  211. }
  212. const [cx, cy] = [
  213. originalElement.x + originalElement.width / 2,
  214. originalElement.y + originalElement.height / 2,
  215. ];
  216. const [topLeftX, topLeftY] = pointRotateRads(
  217. pointFrom(originalElement.x, originalElement.y),
  218. pointFrom(cx, cy),
  219. originalElement.angle,
  220. );
  221. const changeInX = newTopLeftX - topLeftX;
  222. const changeInY = newTopLeftY - topLeftY;
  223. const [x, y] = pointRotateRads(
  224. pointFrom(newTopLeftX, newTopLeftY),
  225. pointFrom(cx + changeInX, cy + changeInY),
  226. -originalElement.angle as Radians,
  227. );
  228. mutateElement(
  229. latestElement,
  230. {
  231. x,
  232. y,
  233. },
  234. shouldInformMutation,
  235. );
  236. updateBindings(latestElement, elementsMap, elements, scene);
  237. const boundTextElement = getBoundTextElement(
  238. originalElement,
  239. originalElementsMap,
  240. );
  241. if (boundTextElement) {
  242. const latestBoundTextElement = elementsMap.get(boundTextElement.id);
  243. latestBoundTextElement &&
  244. mutateElement(
  245. latestBoundTextElement,
  246. {
  247. x: boundTextElement.x + changeInX,
  248. y: boundTextElement.y + changeInY,
  249. },
  250. shouldInformMutation,
  251. );
  252. }
  253. };
  254. export const getAtomicUnits = (
  255. targetElements: readonly ExcalidrawElement[],
  256. appState: AppState,
  257. ) => {
  258. const selectedGroupIds = getSelectedGroupIds(appState);
  259. const _atomicUnits = selectedGroupIds.map((gid) => {
  260. return getElementsInGroup(targetElements, gid).reduce((acc, el) => {
  261. acc[el.id] = true;
  262. return acc;
  263. }, {} as AtomicUnit);
  264. });
  265. targetElements
  266. .filter((el) => !isInGroup(el))
  267. .forEach((el) => {
  268. _atomicUnits.push({
  269. [el.id]: true,
  270. });
  271. });
  272. return _atomicUnits;
  273. };
  274. export const updateBindings = (
  275. latestElement: ExcalidrawElement,
  276. elementsMap: NonDeletedSceneElementsMap,
  277. elements: readonly NonDeletedExcalidrawElement[],
  278. scene: Scene,
  279. options?: {
  280. simultaneouslyUpdated?: readonly ExcalidrawElement[];
  281. newSize?: { width: number; height: number };
  282. zoom?: AppState["zoom"];
  283. },
  284. ) => {
  285. if (isLinearElement(latestElement)) {
  286. bindOrUnbindLinearElements(
  287. [latestElement],
  288. elementsMap,
  289. elements,
  290. scene,
  291. true,
  292. [],
  293. options?.zoom,
  294. );
  295. } else {
  296. updateBoundElements(latestElement, elementsMap, options);
  297. }
  298. };