diff --git a/packages/excalidraw/actions/actionAddToLibrary.ts b/packages/excalidraw/actions/actionAddToLibrary.ts index b246918a0..b79353308 100644 --- a/packages/excalidraw/actions/actionAddToLibrary.ts +++ b/packages/excalidraw/actions/actionAddToLibrary.ts @@ -1,5 +1,5 @@ import { LIBRARY_DISABLED_TYPES } from "../constants"; -import { deepCopyElement } from "../element/newElement"; +import { deepCopyElement } from "../element/duplicate"; import { t } from "../i18n"; import { randomId } from "../random"; import { CaptureUpdateAction } from "../store"; diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index bfc7fb721..6c91445a3 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -6,7 +6,6 @@ import { } from "../element/binding"; import { getCommonBoundingBox } from "../element/bounds"; import { mutateElement, newElementWith } from "../element/mutateElement"; -import { deepCopyElement } from "../element/newElement"; import { resizeMultipleElements } from "../element/resizeElements"; import { isArrowElement, @@ -19,6 +18,8 @@ import { getSelectedElements } from "../scene"; import { CaptureUpdateAction } from "../store"; import { arrayToMap } from "../utils"; +import { deepCopyElement } from "../element/duplicate"; + import { register } from "./register"; import type { diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 1b0e9942a..4ca2f4c8f 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -6,7 +6,6 @@ import { } from "./constants"; import { createFile, isSupportedImageFileType } from "./data/blob"; import { mutateElement } from "./element/mutateElement"; -import { deepCopyElement } from "./element/newElement"; import { isFrameLikeElement, isInitializedImageElement, @@ -15,11 +14,14 @@ import { ExcalidrawError } from "./errors"; import { getContainingFrame } from "./frame"; import { arrayToMap, isMemberOf, isPromiseLike } from "./utils"; +import { deepCopyElement } from "./element/duplicate"; + import type { Spreadsheet } from "./charts"; import type { ExcalidrawElement, NonDeletedExcalidrawElement, } from "./element/types"; + import type { BinaryFiles } from "./types"; type ElementsClipboard = { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 6017b73b7..77a823d9a 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -163,9 +163,8 @@ import { } from "../element/binding"; import { LinearElementEditor } from "../element/linearElementEditor"; import { mutateElement, newElementWith } from "../element/mutateElement"; +import { deepCopyElement, duplicateElements } from "../element/duplicate"; import { - deepCopyElement, - duplicateElements, newFrameElement, newFreeDrawElement, newEmbeddableElement, diff --git a/packages/excalidraw/components/LibraryMenuItems.tsx b/packages/excalidraw/components/LibraryMenuItems.tsx index af5b9d3e6..7c8b5877f 100644 --- a/packages/excalidraw/components/LibraryMenuItems.tsx +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -8,18 +8,20 @@ import React, { import { MIME_TYPES } from "../constants"; import { serializeLibraryAsJSON } from "../data/json"; -import { duplicateElements } from "../element/newElement"; import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import { useScrollPosition } from "../hooks/useScrollPosition"; import { t } from "../i18n"; import { arrayToMap } from "../utils"; +import { duplicateElements } from "../element/duplicate"; + import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent"; import { LibraryMenuSection, LibraryMenuSectionGrid, } from "./LibraryMenuSection"; + import Spinner from "./Spinner"; import Stack from "./Stack"; diff --git a/packages/excalidraw/components/Stats/DragInput.tsx b/packages/excalidraw/components/Stats/DragInput.tsx index fbea33e55..4ee8c7580 100644 --- a/packages/excalidraw/components/Stats/DragInput.tsx +++ b/packages/excalidraw/components/Stats/DragInput.tsx @@ -1,8 +1,9 @@ import clsx from "clsx"; import { useEffect, useRef, useState } from "react"; +import { deepCopyElement } from "@excalidraw/excalidraw/element/duplicate"; + import { EVENT } from "../../constants"; -import { deepCopyElement } from "../../element/newElement"; import { KEYS } from "../../keys"; import { CaptureUpdateAction } from "../../store"; import { cloneJSON } from "../../utils"; diff --git a/packages/excalidraw/element/newElement.test.ts b/packages/excalidraw/element/duplicate.test.ts similarity index 99% rename from packages/excalidraw/element/newElement.test.ts rename to packages/excalidraw/element/duplicate.test.ts index 418ede1be..cd5290fbc 100644 --- a/packages/excalidraw/element/newElement.test.ts +++ b/packages/excalidraw/element/duplicate.test.ts @@ -7,7 +7,8 @@ import { API } from "../tests/helpers/api"; import { isPrimitive } from "../utils"; import { mutateElement } from "./mutateElement"; -import { duplicateElement, duplicateElements } from "./newElement"; + +import { duplicateElement, duplicateElements } from "./duplicate"; import type { ExcalidrawLinearElement } from "./types"; diff --git a/packages/excalidraw/element/duplicate.ts b/packages/excalidraw/element/duplicate.ts new file mode 100644 index 000000000..a0243953f --- /dev/null +++ b/packages/excalidraw/element/duplicate.ts @@ -0,0 +1,257 @@ +import { ORIG_ID } from "../constants"; +import { getNewGroupIdsForDuplication } from "../groups"; +import { randomId, randomInteger } from "../random"; +import type { AppState } from "../types"; +import type { Mutable } from "../utility-types"; +import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils"; +import { bumpVersion } from "./mutateElement"; +import type { ExcalidrawElement, GroupId } from "./types"; + +/** + * Duplicate an element, often used in the alt-drag operation. + * Note that this method has gotten a bit complicated since the + * introduction of gruoping/ungrouping elements. + * @param editingGroupId The current group being edited. The new + * element will inherit this group and its + * parents. + * @param groupIdMapForOperation A Map that maps old group IDs to + * duplicated ones. If you are duplicating + * multiple elements at once, share this map + * amongst all of them + * @param element Element to duplicate + * @param overrides Any element properties to override + */ +export const duplicateElement = ( + editingGroupId: AppState["editingGroupId"], + groupIdMapForOperation: Map, + element: TElement, + overrides?: Partial, +): Readonly => { + let copy = deepCopyElement(element); + + if (isTestEnv()) { + __test__defineOrigId(copy, element.id); + } + + copy.id = randomId(); + copy.updated = getUpdatedTimestamp(); + copy.seed = randomInteger(); + copy.groupIds = getNewGroupIdsForDuplication( + copy.groupIds, + editingGroupId, + (groupId) => { + if (!groupIdMapForOperation.has(groupId)) { + groupIdMapForOperation.set(groupId, randomId()); + } + return groupIdMapForOperation.get(groupId)!; + }, + ); + if (overrides) { + copy = Object.assign(copy, overrides); + } + return copy; +}; + +/** + * Clones elements, regenerating their ids (including bindings) and group ids. + * + * If bindings don't exist in the elements array, they are removed. Therefore, + * it's advised to supply the whole elements array, or sets of elements that + * are encapsulated (such as library items), if the purpose is to retain + * bindings to the cloned elements intact. + * + * NOTE by default does not randomize or regenerate anything except the id. + */ +export const duplicateElements = ( + elements: readonly ExcalidrawElement[], + opts?: { + /** NOTE also updates version flags and `updated` */ + randomizeSeed: boolean; + }, +) => { + const clonedElements: ExcalidrawElement[] = []; + + const origElementsMap = arrayToMap(elements); + + // used for for migrating old ids to new ids + const elementNewIdsMap = new Map< + /* orig */ ExcalidrawElement["id"], + /* new */ ExcalidrawElement["id"] + >(); + + const maybeGetNewId = (id: ExcalidrawElement["id"]) => { + // if we've already migrated the element id, return the new one directly + if (elementNewIdsMap.has(id)) { + return elementNewIdsMap.get(id)!; + } + // if we haven't migrated the element id, but an old element with the same + // id exists, generate a new id for it and return it + if (origElementsMap.has(id)) { + const newId = randomId(); + elementNewIdsMap.set(id, newId); + return newId; + } + // if old element doesn't exist, return null to mark it for removal + return null; + }; + + const groupNewIdsMap = new Map(); + + for (const element of elements) { + const clonedElement: Mutable = _deepCopyElement(element); + + clonedElement.id = maybeGetNewId(element.id)!; + if (isTestEnv()) { + __test__defineOrigId(clonedElement, element.id); + } + + if (opts?.randomizeSeed) { + clonedElement.seed = randomInteger(); + bumpVersion(clonedElement); + } + + if (clonedElement.groupIds) { + clonedElement.groupIds = clonedElement.groupIds.map((groupId) => { + if (!groupNewIdsMap.has(groupId)) { + groupNewIdsMap.set(groupId, randomId()); + } + return groupNewIdsMap.get(groupId)!; + }); + } + + if ("containerId" in clonedElement && clonedElement.containerId) { + const newContainerId = maybeGetNewId(clonedElement.containerId); + clonedElement.containerId = newContainerId; + } + + if ("boundElements" in clonedElement && clonedElement.boundElements) { + clonedElement.boundElements = clonedElement.boundElements.reduce( + ( + acc: Mutable>, + binding, + ) => { + const newBindingId = maybeGetNewId(binding.id); + if (newBindingId) { + acc.push({ ...binding, id: newBindingId }); + } + return acc; + }, + [], + ); + } + + if ("endBinding" in clonedElement && clonedElement.endBinding) { + const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId); + clonedElement.endBinding = newEndBindingId + ? { + ...clonedElement.endBinding, + elementId: newEndBindingId, + } + : null; + } + if ("startBinding" in clonedElement && clonedElement.startBinding) { + const newEndBindingId = maybeGetNewId( + clonedElement.startBinding.elementId, + ); + clonedElement.startBinding = newEndBindingId + ? { + ...clonedElement.startBinding, + elementId: newEndBindingId, + } + : null; + } + + if (clonedElement.frameId) { + clonedElement.frameId = maybeGetNewId(clonedElement.frameId); + } + + clonedElements.push(clonedElement); + } + + return clonedElements; +}; + +// Simplified deep clone for the purpose of cloning ExcalidrawElement. +// +// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set, +// Typed arrays and other non-null objects. +// +// Adapted from https://github.com/lukeed/klona +// +// The reason for `deepCopyElement()` wrapper is type safety (only allow +// passing ExcalidrawElement as the top-level argument). +const _deepCopyElement = (val: any, depth: number = 0) => { + // only clone non-primitives + if (val == null || typeof val !== "object") { + return val; + } + + const objectType = Object.prototype.toString.call(val); + + if (objectType === "[object Object]") { + const tmp = + typeof val.constructor === "function" + ? Object.create(Object.getPrototypeOf(val)) + : {}; + for (const key in val) { + if (val.hasOwnProperty(key)) { + // don't copy non-serializable objects like these caches. They'll be + // populated when the element is rendered. + if (depth === 0 && (key === "shape" || key === "canvas")) { + continue; + } + tmp[key] = _deepCopyElement(val[key], depth + 1); + } + } + return tmp; + } + + if (Array.isArray(val)) { + let k = val.length; + const arr = new Array(k); + while (k--) { + arr[k] = _deepCopyElement(val[k], depth + 1); + } + return arr; + } + + // we're not cloning non-array & non-plain-object objects because we + // don't support them on excalidraw elements yet. If we do, we need to make + // sure we start cloning them, so let's warn about it. + if (import.meta.env.DEV) { + if ( + objectType !== "[object Object]" && + objectType !== "[object Array]" && + objectType.startsWith("[object ") + ) { + console.warn( + `_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`, + ); + } + } + + return val; +}; + +/** + * Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or + * any value. The purpose is to to break object references for immutability + * reasons, whenever we want to keep the original element, but ensure it's not + * mutated. + * + * Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set, + * Typed arrays and other non-null objects. + */ +export const deepCopyElement = ( + val: T, +): Mutable => { + return _deepCopyElement(val); +}; + +const __test__defineOrigId = (clonedObj: object, origId: string) => { + Object.defineProperty(clonedObj, ORIG_ID, { + value: origId, + writable: false, + enumerable: false, + }); +}; diff --git a/packages/excalidraw/element/index.ts b/packages/excalidraw/element/index.ts index abe84e031..6244e2740 100644 --- a/packages/excalidraw/element/index.ts +++ b/packages/excalidraw/element/index.ts @@ -14,8 +14,8 @@ export { newLinearElement, newArrowElement, newImageElement, - duplicateElement, } from "./newElement"; +export { duplicateElement } from "./duplicate"; export { getElementAbsoluteCoords, getElementBounds, diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index deab51bc7..8d0a9bbd8 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -6,21 +6,14 @@ import { DEFAULT_FONT_SIZE, DEFAULT_TEXT_ALIGN, DEFAULT_VERTICAL_ALIGN, - ORIG_ID, VERTICAL_ALIGN, } from "../constants"; import { getLineHeight } from "../fonts"; -import { getNewGroupIdsForDuplication } from "../groups"; import { randomInteger, randomId } from "../random"; -import { - arrayToMap, - getFontString, - getUpdatedTimestamp, - isTestEnv, -} from "../utils"; +import { getFontString, getUpdatedTimestamp } from "../utils"; import { getResizedElementAbsoluteCoords } from "./bounds"; -import { bumpVersion, newElementWith } from "./mutateElement"; +import { newElementWith } from "./mutateElement"; import { getBoundTextMaxWidth } from "./textElement"; import { normalizeText, measureText } from "./textMeasurements"; import { wrapText } from "./textWrapping"; @@ -35,7 +28,6 @@ import type { ExcalidrawGenericElement, NonDeleted, TextAlign, - GroupId, VerticalAlign, Arrowhead, ExcalidrawFreeDrawElement, @@ -50,8 +42,7 @@ import type { FixedSegment, ExcalidrawElbowArrowElement, } from "./types"; -import type { AppState } from "../types"; -import type { MarkOptional, Merge, Mutable } from "../utility-types"; +import type { MarkOptional, Merge } from "../utility-types"; export type ElementConstructorOpts = MarkOptional< Omit, @@ -538,259 +529,3 @@ export const newImageElement = ( crop: opts.crop ?? null, }; }; - -// Simplified deep clone for the purpose of cloning ExcalidrawElement. -// -// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set, -// Typed arrays and other non-null objects. -// -// Adapted from https://github.com/lukeed/klona -// -// The reason for `deepCopyElement()` wrapper is type safety (only allow -// passing ExcalidrawElement as the top-level argument). -const _deepCopyElement = (val: any, depth: number = 0) => { - // only clone non-primitives - if (val == null || typeof val !== "object") { - return val; - } - - const objectType = Object.prototype.toString.call(val); - - if (objectType === "[object Object]") { - const tmp = - typeof val.constructor === "function" - ? Object.create(Object.getPrototypeOf(val)) - : {}; - for (const key in val) { - if (val.hasOwnProperty(key)) { - // don't copy non-serializable objects like these caches. They'll be - // populated when the element is rendered. - if (depth === 0 && (key === "shape" || key === "canvas")) { - continue; - } - tmp[key] = _deepCopyElement(val[key], depth + 1); - } - } - return tmp; - } - - if (Array.isArray(val)) { - let k = val.length; - const arr = new Array(k); - while (k--) { - arr[k] = _deepCopyElement(val[k], depth + 1); - } - return arr; - } - - // we're not cloning non-array & non-plain-object objects because we - // don't support them on excalidraw elements yet. If we do, we need to make - // sure we start cloning them, so let's warn about it. - if (import.meta.env.DEV) { - if ( - objectType !== "[object Object]" && - objectType !== "[object Array]" && - objectType.startsWith("[object ") - ) { - console.warn( - `_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`, - ); - } - } - - return val; -}; - -/** - * Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or - * any value. The purpose is to to break object references for immutability - * reasons, whenever we want to keep the original element, but ensure it's not - * mutated. - * - * Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set, - * Typed arrays and other non-null objects. - */ -export const deepCopyElement = ( - val: T, -): Mutable => { - return _deepCopyElement(val); -}; - -const __test__defineOrigId = (clonedObj: object, origId: string) => { - Object.defineProperty(clonedObj, ORIG_ID, { - value: origId, - writable: false, - enumerable: false, - }); -}; - -/** - * utility wrapper to generate new id. - */ -const regenerateId = () => { - return randomId(); -}; - -/** - * Duplicate an element, often used in the alt-drag operation. - * Note that this method has gotten a bit complicated since the - * introduction of gruoping/ungrouping elements. - * @param editingGroupId The current group being edited. The new - * element will inherit this group and its - * parents. - * @param groupIdMapForOperation A Map that maps old group IDs to - * duplicated ones. If you are duplicating - * multiple elements at once, share this map - * amongst all of them - * @param element Element to duplicate - * @param overrides Any element properties to override - */ -export const duplicateElement = ( - editingGroupId: AppState["editingGroupId"], - groupIdMapForOperation: Map, - element: TElement, - overrides?: Partial, -): Readonly => { - let copy = deepCopyElement(element); - - if (isTestEnv()) { - __test__defineOrigId(copy, element.id); - } - - copy.id = regenerateId(); - copy.updated = getUpdatedTimestamp(); - copy.seed = randomInteger(); - copy.groupIds = getNewGroupIdsForDuplication( - copy.groupIds, - editingGroupId, - (groupId) => { - if (!groupIdMapForOperation.has(groupId)) { - groupIdMapForOperation.set(groupId, regenerateId()); - } - return groupIdMapForOperation.get(groupId)!; - }, - ); - if (overrides) { - copy = Object.assign(copy, overrides); - } - return copy; -}; - -/** - * Clones elements, regenerating their ids (including bindings) and group ids. - * - * If bindings don't exist in the elements array, they are removed. Therefore, - * it's advised to supply the whole elements array, or sets of elements that - * are encapsulated (such as library items), if the purpose is to retain - * bindings to the cloned elements intact. - * - * NOTE by default does not randomize or regenerate anything except the id. - */ -export const duplicateElements = ( - elements: readonly ExcalidrawElement[], - opts?: { - /** NOTE also updates version flags and `updated` */ - randomizeSeed: boolean; - }, -) => { - const clonedElements: ExcalidrawElement[] = []; - - const origElementsMap = arrayToMap(elements); - - // used for for migrating old ids to new ids - const elementNewIdsMap = new Map< - /* orig */ ExcalidrawElement["id"], - /* new */ ExcalidrawElement["id"] - >(); - - const maybeGetNewId = (id: ExcalidrawElement["id"]) => { - // if we've already migrated the element id, return the new one directly - if (elementNewIdsMap.has(id)) { - return elementNewIdsMap.get(id)!; - } - // if we haven't migrated the element id, but an old element with the same - // id exists, generate a new id for it and return it - if (origElementsMap.has(id)) { - const newId = regenerateId(); - elementNewIdsMap.set(id, newId); - return newId; - } - // if old element doesn't exist, return null to mark it for removal - return null; - }; - - const groupNewIdsMap = new Map(); - - for (const element of elements) { - const clonedElement: Mutable = _deepCopyElement(element); - - clonedElement.id = maybeGetNewId(element.id)!; - if (isTestEnv()) { - __test__defineOrigId(clonedElement, element.id); - } - - if (opts?.randomizeSeed) { - clonedElement.seed = randomInteger(); - bumpVersion(clonedElement); - } - - if (clonedElement.groupIds) { - clonedElement.groupIds = clonedElement.groupIds.map((groupId) => { - if (!groupNewIdsMap.has(groupId)) { - groupNewIdsMap.set(groupId, regenerateId()); - } - return groupNewIdsMap.get(groupId)!; - }); - } - - if ("containerId" in clonedElement && clonedElement.containerId) { - const newContainerId = maybeGetNewId(clonedElement.containerId); - clonedElement.containerId = newContainerId; - } - - if ("boundElements" in clonedElement && clonedElement.boundElements) { - clonedElement.boundElements = clonedElement.boundElements.reduce( - ( - acc: Mutable>, - binding, - ) => { - const newBindingId = maybeGetNewId(binding.id); - if (newBindingId) { - acc.push({ ...binding, id: newBindingId }); - } - return acc; - }, - [], - ); - } - - if ("endBinding" in clonedElement && clonedElement.endBinding) { - const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId); - clonedElement.endBinding = newEndBindingId - ? { - ...clonedElement.endBinding, - elementId: newEndBindingId, - } - : null; - } - if ("startBinding" in clonedElement && clonedElement.startBinding) { - const newEndBindingId = maybeGetNewId( - clonedElement.startBinding.elementId, - ); - clonedElement.startBinding = newEndBindingId - ? { - ...clonedElement.startBinding, - elementId: newEndBindingId, - } - : null; - } - - if (clonedElement.frameId) { - clonedElement.frameId = maybeGetNewId(clonedElement.frameId); - } - - clonedElements.push(clonedElement); - } - - return clonedElements; -}; diff --git a/packages/excalidraw/store.ts b/packages/excalidraw/store.ts index 8b0065884..1723d0aa1 100644 --- a/packages/excalidraw/store.ts +++ b/packages/excalidraw/store.ts @@ -2,10 +2,11 @@ import { getDefaultAppState } from "./appState"; import { AppStateChange, ElementsChange } from "./change"; import { ENV } from "./constants"; import { newElementWith } from "./element/mutateElement"; -import { deepCopyElement } from "./element/newElement"; import { Emitter } from "./emitter"; import { isShallowEqual } from "./utils"; +import { deepCopyElement } from "./element/duplicate"; + import type { OrderedExcalidrawElement } from "./element/types"; import type { AppState, ObservedAppState } from "./types"; import type { ValueOf } from "./utility-types"; diff --git a/packages/excalidraw/tests/fractionalIndex.test.ts b/packages/excalidraw/tests/fractionalIndex.test.ts index dbd55bd92..e9eb576e7 100644 --- a/packages/excalidraw/tests/fractionalIndex.test.ts +++ b/packages/excalidraw/tests/fractionalIndex.test.ts @@ -1,7 +1,6 @@ /* eslint-disable no-lone-blocks */ import { generateKeyBetween } from "fractional-indexing"; -import { deepCopyElement } from "../element/newElement"; import { InvalidFractionalIndexError } from "../errors"; import { syncInvalidIndices, @@ -10,6 +9,8 @@ import { } from "../fractionalIndex"; import { arrayToMap } from "../utils"; +import { deepCopyElement } from "../element/duplicate"; + import { API } from "./helpers/api"; import type { ExcalidrawElement, FractionalIndex } from "../element/types";