newElement.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. import {
  2. ExcalidrawElement,
  3. ExcalidrawImageElement,
  4. ExcalidrawTextElement,
  5. ExcalidrawLinearElement,
  6. ExcalidrawGenericElement,
  7. NonDeleted,
  8. TextAlign,
  9. GroupId,
  10. VerticalAlign,
  11. Arrowhead,
  12. ExcalidrawFreeDrawElement,
  13. FontFamilyValues,
  14. ExcalidrawTextContainer,
  15. } from "../element/types";
  16. import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
  17. import { randomInteger, randomId } from "../random";
  18. import { mutateElement, newElementWith } from "./mutateElement";
  19. import { getNewGroupIdsForDuplication } from "../groups";
  20. import { AppState } from "../types";
  21. import { getElementAbsoluteCoords } from ".";
  22. import { adjustXYWithRotation } from "../math";
  23. import { getResizedElementAbsoluteCoords } from "./bounds";
  24. import {
  25. getBoundTextElementOffset,
  26. getContainerDims,
  27. getContainerElement,
  28. measureText,
  29. normalizeText,
  30. wrapText,
  31. getMaxContainerWidth,
  32. } from "./textElement";
  33. import { VERTICAL_ALIGN } from "../constants";
  34. import { isArrowElement } from "./typeChecks";
  35. type ElementConstructorOpts = MarkOptional<
  36. Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
  37. | "width"
  38. | "height"
  39. | "angle"
  40. | "groupIds"
  41. | "boundElements"
  42. | "seed"
  43. | "version"
  44. | "versionNonce"
  45. | "link"
  46. >;
  47. const _newElementBase = <T extends ExcalidrawElement>(
  48. type: T["type"],
  49. {
  50. x,
  51. y,
  52. strokeColor,
  53. backgroundColor,
  54. fillStyle,
  55. strokeWidth,
  56. strokeStyle,
  57. roughness,
  58. opacity,
  59. width = 0,
  60. height = 0,
  61. angle = 0,
  62. groupIds = [],
  63. roundness = null,
  64. boundElements = null,
  65. link = null,
  66. locked,
  67. ...rest
  68. }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
  69. ) => {
  70. // assign type to guard against excess properties
  71. const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
  72. id: rest.id || randomId(),
  73. type,
  74. x,
  75. y,
  76. width,
  77. height,
  78. angle,
  79. strokeColor,
  80. backgroundColor,
  81. fillStyle,
  82. strokeWidth,
  83. strokeStyle,
  84. roughness,
  85. opacity,
  86. groupIds,
  87. roundness,
  88. seed: rest.seed ?? randomInteger(),
  89. version: rest.version || 1,
  90. versionNonce: rest.versionNonce ?? 0,
  91. isDeleted: false as false,
  92. boundElements,
  93. updated: getUpdatedTimestamp(),
  94. link,
  95. locked,
  96. };
  97. return element;
  98. };
  99. export const newElement = (
  100. opts: {
  101. type: ExcalidrawGenericElement["type"];
  102. } & ElementConstructorOpts,
  103. ): NonDeleted<ExcalidrawGenericElement> =>
  104. _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
  105. /** computes element x/y offset based on textAlign/verticalAlign */
  106. const getTextElementPositionOffsets = (
  107. opts: {
  108. textAlign: ExcalidrawTextElement["textAlign"];
  109. verticalAlign: ExcalidrawTextElement["verticalAlign"];
  110. },
  111. metrics: {
  112. width: number;
  113. height: number;
  114. },
  115. ) => {
  116. return {
  117. x:
  118. opts.textAlign === "center"
  119. ? metrics.width / 2
  120. : opts.textAlign === "right"
  121. ? metrics.width
  122. : 0,
  123. y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0,
  124. };
  125. };
  126. export const newTextElement = (
  127. opts: {
  128. text: string;
  129. fontSize: number;
  130. fontFamily: FontFamilyValues;
  131. textAlign: TextAlign;
  132. verticalAlign: VerticalAlign;
  133. containerId?: ExcalidrawTextContainer["id"];
  134. } & ElementConstructorOpts,
  135. ): NonDeleted<ExcalidrawTextElement> => {
  136. const text = normalizeText(opts.text);
  137. const metrics = measureText(text, getFontString(opts));
  138. const offsets = getTextElementPositionOffsets(opts, metrics);
  139. const textElement = newElementWith(
  140. {
  141. ..._newElementBase<ExcalidrawTextElement>("text", opts),
  142. text,
  143. fontSize: opts.fontSize,
  144. fontFamily: opts.fontFamily,
  145. textAlign: opts.textAlign,
  146. verticalAlign: opts.verticalAlign,
  147. x: opts.x - offsets.x,
  148. y: opts.y - offsets.y,
  149. width: metrics.width,
  150. height: metrics.height,
  151. baseline: metrics.baseline,
  152. containerId: opts.containerId || null,
  153. originalText: text,
  154. },
  155. {},
  156. );
  157. return textElement;
  158. };
  159. const getAdjustedDimensions = (
  160. element: ExcalidrawTextElement,
  161. nextText: string,
  162. ): {
  163. x: number;
  164. y: number;
  165. width: number;
  166. height: number;
  167. baseline: number;
  168. } => {
  169. let maxWidth = null;
  170. const container = getContainerElement(element);
  171. if (container) {
  172. maxWidth = getMaxContainerWidth(container);
  173. }
  174. const {
  175. width: nextWidth,
  176. height: nextHeight,
  177. baseline: nextBaseline,
  178. } = measureText(nextText, getFontString(element), maxWidth);
  179. const { textAlign, verticalAlign } = element;
  180. let x: number;
  181. let y: number;
  182. if (
  183. textAlign === "center" &&
  184. verticalAlign === VERTICAL_ALIGN.MIDDLE &&
  185. !element.containerId
  186. ) {
  187. const prevMetrics = measureText(
  188. element.text,
  189. getFontString(element),
  190. maxWidth,
  191. );
  192. const offsets = getTextElementPositionOffsets(element, {
  193. width: nextWidth - prevMetrics.width,
  194. height: nextHeight - prevMetrics.height,
  195. });
  196. x = element.x - offsets.x;
  197. y = element.y - offsets.y;
  198. } else {
  199. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  200. const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
  201. element,
  202. nextWidth,
  203. nextHeight,
  204. false,
  205. );
  206. const deltaX1 = (x1 - nextX1) / 2;
  207. const deltaY1 = (y1 - nextY1) / 2;
  208. const deltaX2 = (x2 - nextX2) / 2;
  209. const deltaY2 = (y2 - nextY2) / 2;
  210. [x, y] = adjustXYWithRotation(
  211. {
  212. s: true,
  213. e: textAlign === "center" || textAlign === "left",
  214. w: textAlign === "center" || textAlign === "right",
  215. },
  216. element.x,
  217. element.y,
  218. element.angle,
  219. deltaX1,
  220. deltaY1,
  221. deltaX2,
  222. deltaY2,
  223. );
  224. }
  225. // make sure container dimensions are set properly when
  226. // text editor overflows beyond viewport dimensions
  227. if (container) {
  228. const boundTextElementPadding = getBoundTextElementOffset(element);
  229. const containerDims = getContainerDims(container);
  230. let height = containerDims.height;
  231. let width = containerDims.width;
  232. if (nextHeight > height - boundTextElementPadding * 2) {
  233. height = nextHeight + boundTextElementPadding * 2;
  234. }
  235. if (nextWidth > width - boundTextElementPadding * 2) {
  236. width = nextWidth + boundTextElementPadding * 2;
  237. }
  238. if (
  239. !isArrowElement(container) &&
  240. (height !== containerDims.height || width !== containerDims.width)
  241. ) {
  242. mutateElement(container, { height, width });
  243. }
  244. }
  245. return {
  246. width: nextWidth,
  247. height: nextHeight,
  248. x: Number.isFinite(x) ? x : element.x,
  249. y: Number.isFinite(y) ? y : element.y,
  250. baseline: nextBaseline,
  251. };
  252. };
  253. export const refreshTextDimensions = (
  254. textElement: ExcalidrawTextElement,
  255. text = textElement.text,
  256. ) => {
  257. const container = getContainerElement(textElement);
  258. if (container) {
  259. text = wrapText(
  260. text,
  261. getFontString(textElement),
  262. getMaxContainerWidth(container),
  263. );
  264. }
  265. const dimensions = getAdjustedDimensions(textElement, text);
  266. return { text, ...dimensions };
  267. };
  268. export const updateTextElement = (
  269. textElement: ExcalidrawTextElement,
  270. {
  271. text,
  272. isDeleted,
  273. originalText,
  274. }: {
  275. text: string;
  276. isDeleted?: boolean;
  277. originalText: string;
  278. },
  279. ): ExcalidrawTextElement => {
  280. return newElementWith(textElement, {
  281. originalText,
  282. isDeleted: isDeleted ?? textElement.isDeleted,
  283. ...refreshTextDimensions(textElement, originalText),
  284. });
  285. };
  286. export const newFreeDrawElement = (
  287. opts: {
  288. type: "freedraw";
  289. points?: ExcalidrawFreeDrawElement["points"];
  290. simulatePressure: boolean;
  291. } & ElementConstructorOpts,
  292. ): NonDeleted<ExcalidrawFreeDrawElement> => {
  293. return {
  294. ..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
  295. points: opts.points || [],
  296. pressures: [],
  297. simulatePressure: opts.simulatePressure,
  298. lastCommittedPoint: null,
  299. };
  300. };
  301. export const newLinearElement = (
  302. opts: {
  303. type: ExcalidrawLinearElement["type"];
  304. startArrowhead: Arrowhead | null;
  305. endArrowhead: Arrowhead | null;
  306. points?: ExcalidrawLinearElement["points"];
  307. } & ElementConstructorOpts,
  308. ): NonDeleted<ExcalidrawLinearElement> => {
  309. return {
  310. ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
  311. points: opts.points || [],
  312. lastCommittedPoint: null,
  313. startBinding: null,
  314. endBinding: null,
  315. startArrowhead: opts.startArrowhead,
  316. endArrowhead: opts.endArrowhead,
  317. };
  318. };
  319. export const newImageElement = (
  320. opts: {
  321. type: ExcalidrawImageElement["type"];
  322. status?: ExcalidrawImageElement["status"];
  323. fileId?: ExcalidrawImageElement["fileId"];
  324. scale?: ExcalidrawImageElement["scale"];
  325. } & ElementConstructorOpts,
  326. ): NonDeleted<ExcalidrawImageElement> => {
  327. return {
  328. ..._newElementBase<ExcalidrawImageElement>("image", opts),
  329. // in the future we'll support changing stroke color for some SVG elements,
  330. // and `transparent` will likely mean "use original colors of the image"
  331. strokeColor: "transparent",
  332. status: opts.status ?? "pending",
  333. fileId: opts.fileId ?? null,
  334. scale: opts.scale ?? [1, 1],
  335. };
  336. };
  337. // Simplified deep clone for the purpose of cloning ExcalidrawElement only
  338. // (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
  339. //
  340. // Adapted from https://github.com/lukeed/klona
  341. export const deepCopyElement = (val: any, depth: number = 0) => {
  342. if (val == null || typeof val !== "object") {
  343. return val;
  344. }
  345. if (Object.prototype.toString.call(val) === "[object Object]") {
  346. const tmp =
  347. typeof val.constructor === "function"
  348. ? Object.create(Object.getPrototypeOf(val))
  349. : {};
  350. for (const key in val) {
  351. if (val.hasOwnProperty(key)) {
  352. // don't copy non-serializable objects like these caches. They'll be
  353. // populated when the element is rendered.
  354. if (depth === 0 && (key === "shape" || key === "canvas")) {
  355. continue;
  356. }
  357. tmp[key] = deepCopyElement(val[key], depth + 1);
  358. }
  359. }
  360. return tmp;
  361. }
  362. if (Array.isArray(val)) {
  363. let k = val.length;
  364. const arr = new Array(k);
  365. while (k--) {
  366. arr[k] = deepCopyElement(val[k], depth + 1);
  367. }
  368. return arr;
  369. }
  370. return val;
  371. };
  372. /**
  373. * Duplicate an element, often used in the alt-drag operation.
  374. * Note that this method has gotten a bit complicated since the
  375. * introduction of gruoping/ungrouping elements.
  376. * @param editingGroupId The current group being edited. The new
  377. * element will inherit this group and its
  378. * parents.
  379. * @param groupIdMapForOperation A Map that maps old group IDs to
  380. * duplicated ones. If you are duplicating
  381. * multiple elements at once, share this map
  382. * amongst all of them
  383. * @param element Element to duplicate
  384. * @param overrides Any element properties to override
  385. */
  386. export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
  387. editingGroupId: AppState["editingGroupId"],
  388. groupIdMapForOperation: Map<GroupId, GroupId>,
  389. element: TElement,
  390. overrides?: Partial<TElement>,
  391. ): TElement => {
  392. let copy: TElement = deepCopyElement(element);
  393. if (isTestEnv()) {
  394. copy.id = `${copy.id}_copy`;
  395. // `window.h` may not be defined in some unit tests
  396. if (
  397. window.h?.app
  398. ?.getSceneElementsIncludingDeleted()
  399. .find((el) => el.id === copy.id)
  400. ) {
  401. copy.id += "_copy";
  402. }
  403. } else {
  404. copy.id = randomId();
  405. }
  406. copy.boundElements = null;
  407. copy.updated = getUpdatedTimestamp();
  408. copy.seed = randomInteger();
  409. copy.groupIds = getNewGroupIdsForDuplication(
  410. copy.groupIds,
  411. editingGroupId,
  412. (groupId) => {
  413. if (!groupIdMapForOperation.has(groupId)) {
  414. groupIdMapForOperation.set(groupId, randomId());
  415. }
  416. return groupIdMapForOperation.get(groupId)!;
  417. },
  418. );
  419. if (overrides) {
  420. copy = Object.assign(copy, overrides);
  421. }
  422. return copy;
  423. };