diff --git a/src/element/newElement.test.ts b/src/element/newElement.test.ts new file mode 100644 index 00000000..74c1cf1b --- /dev/null +++ b/src/element/newElement.test.ts @@ -0,0 +1,78 @@ +import { newElement, newTextElement, duplicateElement } from "./newElement"; + +function isPrimitive(val: any) { + const type = typeof val; + return val == null || (type !== "object" && type !== "function"); +} + +function assertCloneObjects(source: any, clone: any) { + for (const key in clone) { + if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) { + expect(clone[key]).not.toBe(source[key]); + if (source[key]) { + assertCloneObjects(source[key], clone[key]); + } + } + } +} + +it("clones arrow element", () => { + const element = newElement( + "arrow", + 0, + 0, + "#000000", + "transparent", + "hachure", + 1, + 1, + 100, + ); + + // @ts-ignore + element.__proto__ = { hello: "world" }; + + element.points = [ + [1, 2], + [3, 4], + ]; + + const copy = duplicateElement(element); + + assertCloneObjects(element, copy); + + expect(copy.__proto__).toEqual({ hello: "world" }); + expect(copy.hasOwnProperty("hello")).toBe(false); + + expect(copy.points).not.toBe(element.points); + expect(copy).not.toHaveProperty("shape"); + expect(copy.id).not.toBe(element.id); + expect(typeof copy.id).toBe("string"); + expect(copy.seed).not.toBe(element.seed); + expect(typeof copy.seed).toBe("number"); + expect(copy).toEqual({ + ...element, + id: copy.id, + seed: copy.seed, + shape: undefined, + canvas: undefined, + }); +}); + +it("clones text element", () => { + const element = newTextElement( + newElement("text", 0, 0, "#000000", "transparent", "hachure", 1, 1, 100), + "hello", + "Arial 20px", + ); + + const copy = duplicateElement(element); + + assertCloneObjects(element, copy); + + expect(copy.points).not.toBe(element.points); + expect(copy).not.toHaveProperty("shape"); + expect(copy.id).not.toBe(element.id); + expect(typeof copy.id).toBe("string"); + expect(typeof copy.seed).toBe("number"); +}); diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 3cd31066..acd0a1fb 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -67,17 +67,46 @@ export function newTextElement( return textElement; } -export function duplicateElement(element: ReturnType) { - const copy = { - ...element, - }; - if ("points" in copy) { - copy.points = Array.isArray(element.points) - ? JSON.parse(JSON.stringify(element.points)) - : element.points; +// Simplified deep clone for the purpose of cloning ExcalidrawElement only +// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.) +// +// Adapted from https://github.com/lukeed/klona +function _duplicateElement(val: any, depth: number = 0) { + if (val == null || typeof val !== "object") { + return val; } - delete copy.shape; + if (Object.prototype.toString.call(val) === "[object Object]") { + const tmp = + typeof val.constructor === "function" + ? Object.create(Object.getPrototypeOf(val)) + : {}; + for (const k in val) { + if (val.hasOwnProperty(k)) { + // don't copy top-level shape property, which we want to regenerate + if (depth === 0 && (k === "shape" || k === "canvas")) { + continue; + } + tmp[k] = _duplicateElement(val[k], depth + 1); + } + } + return tmp; + } + + if (Array.isArray(val)) { + let k = val.length; + const arr = new Array(k); + while (k--) { + arr[k] = _duplicateElement(val[k], depth + 1); + } + return arr; + } + + return val; +} + +export function duplicateElement(element: ReturnType) { + const copy = _duplicateElement(element); copy.id = nanoid(); copy.seed = randomSeed(); return copy; diff --git a/src/history.ts b/src/history.ts index 8aa2e870..918d475a 100644 --- a/src/history.ts +++ b/src/history.ts @@ -13,7 +13,7 @@ class SceneHistory { ) { return JSON.stringify({ appState: clearAppStatePropertiesForHistory(appState), - elements: elements.map(({ shape, ...element }) => ({ + elements: elements.map(({ shape, canvas, ...element }) => ({ ...element, shape: null, canvas: null, diff --git a/src/scene/data.ts b/src/scene/data.ts index 19c2430d..c86d277c 100644 --- a/src/scene/data.ts +++ b/src/scene/data.ts @@ -51,7 +51,7 @@ export function serializeAsJSON( type: "excalidraw", version: 1, source: window.location.origin, - elements: elements.map(({ shape, isSelected, ...el }) => el), + elements: elements.map(({ shape, canvas, isSelected, ...el }) => el), appState: cleanAppStateForExport(appState), }, null,