newElement.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import {
  2. ExcalidrawElement,
  3. ExcalidrawTextElement,
  4. ExcalidrawLinearElement,
  5. ExcalidrawGenericElement,
  6. NonDeleted,
  7. TextAlign,
  8. FontFamily,
  9. GroupId,
  10. VerticalAlign,
  11. } from "../element/types";
  12. import { measureText, getFontString } from "../utils";
  13. import { randomInteger, randomId } from "../random";
  14. import { newElementWith } from "./mutateElement";
  15. import { getNewGroupIdsForDuplication } from "../groups";
  16. import { AppState } from "../types";
  17. import { getElementAbsoluteCoords } from ".";
  18. import { adjustXYWithRotation } from "../math";
  19. import { getResizedElementAbsoluteCoords } from "./bounds";
  20. type ElementConstructorOpts = MarkOptional<
  21. Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted">,
  22. | "width"
  23. | "height"
  24. | "angle"
  25. | "groupIds"
  26. | "boundElementIds"
  27. | "seed"
  28. | "version"
  29. | "versionNonce"
  30. >;
  31. const _newElementBase = <T extends ExcalidrawElement>(
  32. type: T["type"],
  33. {
  34. x,
  35. y,
  36. strokeColor,
  37. backgroundColor,
  38. fillStyle,
  39. strokeWidth,
  40. strokeStyle,
  41. roughness,
  42. opacity,
  43. width = 0,
  44. height = 0,
  45. angle = 0,
  46. groupIds = [],
  47. strokeSharpness,
  48. boundElementIds = null,
  49. ...rest
  50. }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
  51. ) => ({
  52. id: rest.id || randomId(),
  53. type,
  54. x,
  55. y,
  56. width,
  57. height,
  58. angle,
  59. strokeColor,
  60. backgroundColor,
  61. fillStyle,
  62. strokeWidth,
  63. strokeStyle,
  64. roughness,
  65. opacity,
  66. groupIds,
  67. strokeSharpness,
  68. seed: rest.seed ?? randomInteger(),
  69. version: rest.version || 1,
  70. versionNonce: rest.versionNonce ?? 0,
  71. isDeleted: false as false,
  72. boundElementIds,
  73. });
  74. export const newElement = (
  75. opts: {
  76. type: ExcalidrawGenericElement["type"];
  77. } & ElementConstructorOpts,
  78. ): NonDeleted<ExcalidrawGenericElement> =>
  79. _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
  80. /** computes element x/y offset based on textAlign/verticalAlign */
  81. function getTextElementPositionOffsets(
  82. opts: {
  83. textAlign: ExcalidrawTextElement["textAlign"];
  84. verticalAlign: ExcalidrawTextElement["verticalAlign"];
  85. },
  86. metrics: {
  87. width: number;
  88. height: number;
  89. },
  90. ) {
  91. return {
  92. x:
  93. opts.textAlign === "center"
  94. ? metrics.width / 2
  95. : opts.textAlign === "right"
  96. ? metrics.width
  97. : 0,
  98. y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0,
  99. };
  100. }
  101. export const newTextElement = (
  102. opts: {
  103. text: string;
  104. fontSize: number;
  105. fontFamily: FontFamily;
  106. textAlign: TextAlign;
  107. verticalAlign: VerticalAlign;
  108. } & ElementConstructorOpts,
  109. ): NonDeleted<ExcalidrawTextElement> => {
  110. const metrics = measureText(opts.text, getFontString(opts));
  111. const offsets = getTextElementPositionOffsets(opts, metrics);
  112. const textElement = newElementWith(
  113. {
  114. ..._newElementBase<ExcalidrawTextElement>("text", opts),
  115. text: opts.text,
  116. fontSize: opts.fontSize,
  117. fontFamily: opts.fontFamily,
  118. textAlign: opts.textAlign,
  119. verticalAlign: opts.verticalAlign,
  120. x: opts.x - offsets.x,
  121. y: opts.y - offsets.y,
  122. width: metrics.width,
  123. height: metrics.height,
  124. baseline: metrics.baseline,
  125. },
  126. {},
  127. );
  128. return textElement;
  129. };
  130. const getAdjustedDimensions = (
  131. element: ExcalidrawTextElement,
  132. nextText: string,
  133. ): {
  134. x: number;
  135. y: number;
  136. width: number;
  137. height: number;
  138. baseline: number;
  139. } => {
  140. const {
  141. width: nextWidth,
  142. height: nextHeight,
  143. baseline: nextBaseline,
  144. } = measureText(nextText, getFontString(element));
  145. const { textAlign, verticalAlign } = element;
  146. let x, y;
  147. if (textAlign === "center" && verticalAlign === "middle") {
  148. const prevMetrics = measureText(element.text, getFontString(element));
  149. const offsets = getTextElementPositionOffsets(element, {
  150. width: nextWidth - prevMetrics.width,
  151. height: nextHeight - prevMetrics.height,
  152. });
  153. x = element.x - offsets.x;
  154. y = element.y - offsets.y;
  155. } else {
  156. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  157. const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
  158. element,
  159. nextWidth,
  160. nextHeight,
  161. );
  162. const deltaX1 = (x1 - nextX1) / 2;
  163. const deltaY1 = (y1 - nextY1) / 2;
  164. const deltaX2 = (x2 - nextX2) / 2;
  165. const deltaY2 = (y2 - nextY2) / 2;
  166. [x, y] = adjustXYWithRotation(
  167. {
  168. s: true,
  169. e: textAlign === "center" || textAlign === "left",
  170. w: textAlign === "center" || textAlign === "right",
  171. },
  172. element.x,
  173. element.y,
  174. element.angle,
  175. deltaX1,
  176. deltaY1,
  177. deltaX2,
  178. deltaY2,
  179. );
  180. }
  181. return {
  182. width: nextWidth,
  183. height: nextHeight,
  184. x: Number.isFinite(x) ? x : element.x,
  185. y: Number.isFinite(y) ? y : element.y,
  186. baseline: nextBaseline,
  187. };
  188. };
  189. export const updateTextElement = (
  190. element: ExcalidrawTextElement,
  191. { text, isDeleted }: { text: string; isDeleted?: boolean },
  192. ): ExcalidrawTextElement => {
  193. return newElementWith(element, {
  194. text,
  195. isDeleted: isDeleted ?? element.isDeleted,
  196. ...getAdjustedDimensions(element, text),
  197. });
  198. };
  199. export const newLinearElement = (
  200. opts: {
  201. type: ExcalidrawLinearElement["type"];
  202. } & ElementConstructorOpts,
  203. ): NonDeleted<ExcalidrawLinearElement> => {
  204. return {
  205. ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
  206. points: [],
  207. lastCommittedPoint: null,
  208. startBinding: null,
  209. endBinding: null,
  210. };
  211. };
  212. // Simplified deep clone for the purpose of cloning ExcalidrawElement only
  213. // (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
  214. //
  215. // Adapted from https://github.com/lukeed/klona
  216. export const deepCopyElement = (val: any, depth: number = 0) => {
  217. if (val == null || typeof val !== "object") {
  218. return val;
  219. }
  220. if (Object.prototype.toString.call(val) === "[object Object]") {
  221. const tmp =
  222. typeof val.constructor === "function"
  223. ? Object.create(Object.getPrototypeOf(val))
  224. : {};
  225. for (const key in val) {
  226. if (val.hasOwnProperty(key)) {
  227. // don't copy top-level shape property, which we want to regenerate
  228. if (depth === 0 && (key === "shape" || key === "canvas")) {
  229. continue;
  230. }
  231. tmp[key] = deepCopyElement(val[key], depth + 1);
  232. }
  233. }
  234. return tmp;
  235. }
  236. if (Array.isArray(val)) {
  237. let k = val.length;
  238. const arr = new Array(k);
  239. while (k--) {
  240. arr[k] = deepCopyElement(val[k], depth + 1);
  241. }
  242. return arr;
  243. }
  244. return val;
  245. };
  246. /**
  247. * Duplicate an element, often used in the alt-drag operation.
  248. * Note that this method has gotten a bit complicated since the
  249. * introduction of gruoping/ungrouping elements.
  250. * @param editingGroupId The current group being edited. The new
  251. * element will inherit this group and its
  252. * parents.
  253. * @param groupIdMapForOperation A Map that maps old group IDs to
  254. * duplicated ones. If you are duplicating
  255. * multiple elements at once, share this map
  256. * amongst all of them
  257. * @param element Element to duplicate
  258. * @param overrides Any element properties to override
  259. */
  260. export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
  261. editingGroupId: AppState["editingGroupId"],
  262. groupIdMapForOperation: Map<GroupId, GroupId>,
  263. element: TElement,
  264. overrides?: Partial<TElement>,
  265. ): TElement => {
  266. let copy: TElement = deepCopyElement(element);
  267. copy.id = process.env.NODE_ENV === "test" ? `${copy.id}_copy` : randomId();
  268. copy.seed = randomInteger();
  269. copy.groupIds = getNewGroupIdsForDuplication(
  270. copy.groupIds,
  271. editingGroupId,
  272. (groupId) => {
  273. if (!groupIdMapForOperation.has(groupId)) {
  274. groupIdMapForOperation.set(groupId, randomId());
  275. }
  276. return groupIdMapForOperation.get(groupId)!;
  277. },
  278. );
  279. if (overrides) {
  280. copy = Object.assign(copy, overrides);
  281. }
  282. return copy;
  283. };