29 changed files with 1290 additions and 1082 deletions
@ -0,0 +1,484 @@ |
|||||||
|
import { ORIG_ID } from "../constants"; |
||||||
|
import { |
||||||
|
getElementsInGroup, |
||||||
|
getNewGroupIdsForDuplication, |
||||||
|
getSelectedGroupForElement, |
||||||
|
} from "../groups"; |
||||||
|
|
||||||
|
import { randomId, randomInteger } from "../random"; |
||||||
|
|
||||||
|
import { |
||||||
|
arrayToMap, |
||||||
|
castArray, |
||||||
|
findLastIndex, |
||||||
|
getUpdatedTimestamp, |
||||||
|
isTestEnv, |
||||||
|
} from "../utils"; |
||||||
|
|
||||||
|
import { |
||||||
|
bindElementsToFramesAfterDuplication, |
||||||
|
getFrameChildren, |
||||||
|
} from "../frame"; |
||||||
|
|
||||||
|
import { normalizeElementOrder } from "./sortElements"; |
||||||
|
|
||||||
|
import { bumpVersion } from "./mutateElement"; |
||||||
|
|
||||||
|
import { |
||||||
|
hasBoundTextElement, |
||||||
|
isBoundToContainer, |
||||||
|
isFrameLikeElement, |
||||||
|
} from "./typeChecks"; |
||||||
|
|
||||||
|
import { getBoundTextElement, getContainerElement } from "./textElement"; |
||||||
|
|
||||||
|
import { fixBindingsAfterDuplication } from "./binding"; |
||||||
|
|
||||||
|
import type { AppState } from "../types"; |
||||||
|
import type { Mutable } from "../utility-types"; |
||||||
|
|
||||||
|
import type { |
||||||
|
ElementsMap, |
||||||
|
ExcalidrawElement, |
||||||
|
GroupId, |
||||||
|
NonDeletedSceneElementsMap, |
||||||
|
} 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 = <TElement extends ExcalidrawElement>( |
||||||
|
editingGroupId: AppState["editingGroupId"], |
||||||
|
groupIdMapForOperation: Map<GroupId, GroupId>, |
||||||
|
element: TElement, |
||||||
|
overrides?: Partial<TElement>, |
||||||
|
randomizeSeed?: boolean, |
||||||
|
): Readonly<TElement> => { |
||||||
|
let copy = deepCopyElement(element); |
||||||
|
|
||||||
|
if (isTestEnv()) { |
||||||
|
__test__defineOrigId(copy, element.id); |
||||||
|
} |
||||||
|
|
||||||
|
copy.id = randomId(); |
||||||
|
copy.updated = getUpdatedTimestamp(); |
||||||
|
if (randomizeSeed) { |
||||||
|
copy.seed = randomInteger(); |
||||||
|
bumpVersion(copy); |
||||||
|
} |
||||||
|
|
||||||
|
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; |
||||||
|
}; |
||||||
|
|
||||||
|
export const duplicateElements = ( |
||||||
|
opts: { |
||||||
|
elements: readonly ExcalidrawElement[]; |
||||||
|
randomizeSeed?: boolean; |
||||||
|
overrides?: ( |
||||||
|
originalElement: ExcalidrawElement, |
||||||
|
) => Partial<ExcalidrawElement>; |
||||||
|
} & ( |
||||||
|
| { |
||||||
|
/** |
||||||
|
* Duplicates all elements in array. |
||||||
|
* |
||||||
|
* Use this when programmaticaly duplicating elements, without direct |
||||||
|
* user interaction. |
||||||
|
*/ |
||||||
|
type: "everything"; |
||||||
|
} |
||||||
|
| { |
||||||
|
/** |
||||||
|
* Duplicates specified elements and inserts them back into the array |
||||||
|
* in specified order. |
||||||
|
* |
||||||
|
* Use this when duplicating Scene elements, during user interaction |
||||||
|
* such as alt-drag or on duplicate action. |
||||||
|
*/ |
||||||
|
type: "in-place"; |
||||||
|
idsOfElementsToDuplicate: Map< |
||||||
|
ExcalidrawElement["id"], |
||||||
|
ExcalidrawElement |
||||||
|
>; |
||||||
|
appState: { |
||||||
|
editingGroupId: AppState["editingGroupId"]; |
||||||
|
selectedGroupIds: AppState["selectedGroupIds"]; |
||||||
|
}; |
||||||
|
/** |
||||||
|
* If true, duplicated elements are inserted _before_ specified |
||||||
|
* elements. Case: alt-dragging elements to duplicate them. |
||||||
|
* |
||||||
|
* TODO: remove this once (if) we stop replacing the original element |
||||||
|
* with the duplicated one in the scene array. |
||||||
|
*/ |
||||||
|
reverseOrder: boolean; |
||||||
|
} |
||||||
|
), |
||||||
|
) => { |
||||||
|
let { elements } = opts; |
||||||
|
|
||||||
|
const appState = |
||||||
|
"appState" in opts |
||||||
|
? opts.appState |
||||||
|
: ({ |
||||||
|
editingGroupId: null, |
||||||
|
selectedGroupIds: {}, |
||||||
|
} as const); |
||||||
|
|
||||||
|
const reverseOrder = opts.type === "in-place" ? opts.reverseOrder : false; |
||||||
|
|
||||||
|
// Ids of elements that have already been processed so we don't push them
|
||||||
|
// into the array twice if we end up backtracking when retrieving
|
||||||
|
// discontiguous group of elements (can happen due to a bug, or in edge
|
||||||
|
// cases such as a group containing deleted elements which were not selected).
|
||||||
|
//
|
||||||
|
// This is not enough to prevent duplicates, so we do a second loop afterwards
|
||||||
|
// to remove them.
|
||||||
|
//
|
||||||
|
// For convenience we mark even the newly created ones even though we don't
|
||||||
|
// loop over them.
|
||||||
|
const processedIds = new Map<ExcalidrawElement["id"], true>(); |
||||||
|
const groupIdMap = new Map(); |
||||||
|
const newElements: ExcalidrawElement[] = []; |
||||||
|
const oldElements: ExcalidrawElement[] = []; |
||||||
|
const oldIdToDuplicatedId = new Map(); |
||||||
|
const duplicatedElementsMap = new Map<string, ExcalidrawElement>(); |
||||||
|
const elementsMap = arrayToMap(elements) as ElementsMap; |
||||||
|
const _idsOfElementsToDuplicate = |
||||||
|
opts.type === "in-place" |
||||||
|
? opts.idsOfElementsToDuplicate |
||||||
|
: new Map(elements.map((el) => [el.id, el])); |
||||||
|
|
||||||
|
// For sanity
|
||||||
|
if (opts.type === "in-place") { |
||||||
|
for (const groupId of Object.keys(opts.appState.selectedGroupIds)) { |
||||||
|
elements |
||||||
|
.filter((el) => el.groupIds?.includes(groupId)) |
||||||
|
.forEach((el) => _idsOfElementsToDuplicate.set(el.id, el)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
elements = normalizeElementOrder(elements); |
||||||
|
|
||||||
|
const elementsWithClones: ExcalidrawElement[] = elements.slice(); |
||||||
|
|
||||||
|
// helper functions
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Used for the heavy lifing of copying a single element, a group of elements
|
||||||
|
// an element with bound text etc.
|
||||||
|
const copyElements = <T extends ExcalidrawElement | ExcalidrawElement[]>( |
||||||
|
element: T, |
||||||
|
): T extends ExcalidrawElement[] |
||||||
|
? ExcalidrawElement[] |
||||||
|
: ExcalidrawElement | null => { |
||||||
|
const elements = castArray(element); |
||||||
|
|
||||||
|
const _newElements = elements.reduce( |
||||||
|
(acc: ExcalidrawElement[], element) => { |
||||||
|
if (processedIds.has(element.id)) { |
||||||
|
return acc; |
||||||
|
} |
||||||
|
|
||||||
|
processedIds.set(element.id, true); |
||||||
|
|
||||||
|
const newElement = duplicateElement( |
||||||
|
appState.editingGroupId, |
||||||
|
groupIdMap, |
||||||
|
element, |
||||||
|
opts.overrides?.(element), |
||||||
|
opts.randomizeSeed, |
||||||
|
); |
||||||
|
|
||||||
|
processedIds.set(newElement.id, true); |
||||||
|
|
||||||
|
duplicatedElementsMap.set(newElement.id, newElement); |
||||||
|
oldIdToDuplicatedId.set(element.id, newElement.id); |
||||||
|
|
||||||
|
oldElements.push(element); |
||||||
|
newElements.push(newElement); |
||||||
|
|
||||||
|
acc.push(newElement); |
||||||
|
return acc; |
||||||
|
}, |
||||||
|
[], |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
Array.isArray(element) ? _newElements : _newElements[0] || null |
||||||
|
) as T extends ExcalidrawElement[] |
||||||
|
? ExcalidrawElement[] |
||||||
|
: ExcalidrawElement | null; |
||||||
|
}; |
||||||
|
|
||||||
|
// Helper to position cloned elements in the Z-order the product needs it
|
||||||
|
const insertBeforeOrAfterIndex = ( |
||||||
|
index: number, |
||||||
|
elements: ExcalidrawElement | null | ExcalidrawElement[], |
||||||
|
) => { |
||||||
|
if (!elements) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (reverseOrder && index < 1) { |
||||||
|
elementsWithClones.unshift(...castArray(elements)); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!reverseOrder && index > elementsWithClones.length - 1) { |
||||||
|
elementsWithClones.push(...castArray(elements)); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
elementsWithClones.splice( |
||||||
|
index + (reverseOrder ? 0 : 1), |
||||||
|
0, |
||||||
|
...castArray(elements), |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const frameIdsToDuplicate = new Set( |
||||||
|
elements |
||||||
|
.filter( |
||||||
|
(el) => _idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el), |
||||||
|
) |
||||||
|
.map((el) => el.id), |
||||||
|
); |
||||||
|
|
||||||
|
for (const element of elements) { |
||||||
|
if (processedIds.has(element.id)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (!_idsOfElementsToDuplicate.has(element.id)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// groups
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const groupId = getSelectedGroupForElement(appState, element); |
||||||
|
if (groupId) { |
||||||
|
const groupElements = getElementsInGroup(elements, groupId).flatMap( |
||||||
|
(element) => |
||||||
|
isFrameLikeElement(element) |
||||||
|
? [...getFrameChildren(elements, element.id), element] |
||||||
|
: [element], |
||||||
|
); |
||||||
|
|
||||||
|
const targetIndex = reverseOrder |
||||||
|
? elementsWithClones.findIndex((el) => { |
||||||
|
return el.groupIds?.includes(groupId); |
||||||
|
}) |
||||||
|
: findLastIndex(elementsWithClones, (el) => { |
||||||
|
return el.groupIds?.includes(groupId); |
||||||
|
}); |
||||||
|
|
||||||
|
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements)); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// frame duplication
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (isFrameLikeElement(element)) { |
||||||
|
const frameId = element.id; |
||||||
|
|
||||||
|
const frameChildren = getFrameChildren(elements, frameId); |
||||||
|
|
||||||
|
const targetIndex = findLastIndex(elementsWithClones, (el) => { |
||||||
|
return el.frameId === frameId || el.id === frameId; |
||||||
|
}); |
||||||
|
|
||||||
|
insertBeforeOrAfterIndex( |
||||||
|
targetIndex, |
||||||
|
copyElements([...frameChildren, element]), |
||||||
|
); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// text container
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (hasBoundTextElement(element)) { |
||||||
|
const boundTextElement = getBoundTextElement(element, elementsMap); |
||||||
|
|
||||||
|
const targetIndex = findLastIndex(elementsWithClones, (el) => { |
||||||
|
return ( |
||||||
|
el.id === element.id || |
||||||
|
("containerId" in el && el.containerId === element.id) |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
if (boundTextElement) { |
||||||
|
insertBeforeOrAfterIndex( |
||||||
|
targetIndex + (reverseOrder ? -1 : 0), |
||||||
|
copyElements([element, boundTextElement]), |
||||||
|
); |
||||||
|
} else { |
||||||
|
insertBeforeOrAfterIndex(targetIndex, copyElements(element)); |
||||||
|
} |
||||||
|
|
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (isBoundToContainer(element)) { |
||||||
|
const container = getContainerElement(element, elementsMap); |
||||||
|
|
||||||
|
const targetIndex = findLastIndex(elementsWithClones, (el) => { |
||||||
|
return el.id === element.id || el.id === container?.id; |
||||||
|
}); |
||||||
|
|
||||||
|
if (container) { |
||||||
|
insertBeforeOrAfterIndex( |
||||||
|
targetIndex, |
||||||
|
copyElements([container, element]), |
||||||
|
); |
||||||
|
} else { |
||||||
|
insertBeforeOrAfterIndex(targetIndex, copyElements(element)); |
||||||
|
} |
||||||
|
|
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// default duplication (regular elements)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
insertBeforeOrAfterIndex( |
||||||
|
findLastIndex(elementsWithClones, (el) => el.id === element.id), |
||||||
|
copyElements(element), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fixBindingsAfterDuplication( |
||||||
|
newElements, |
||||||
|
oldIdToDuplicatedId, |
||||||
|
duplicatedElementsMap as NonDeletedSceneElementsMap, |
||||||
|
); |
||||||
|
|
||||||
|
bindElementsToFramesAfterDuplication( |
||||||
|
elementsWithClones, |
||||||
|
oldElements, |
||||||
|
oldIdToDuplicatedId, |
||||||
|
); |
||||||
|
|
||||||
|
return { |
||||||
|
newElements, |
||||||
|
elementsWithClones, |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
// 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 = <T extends ExcalidrawElement>( |
||||||
|
val: T, |
||||||
|
): Mutable<T> => { |
||||||
|
return _deepCopyElement(val); |
||||||
|
}; |
||||||
|
|
||||||
|
const __test__defineOrigId = (clonedObj: object, origId: string) => { |
||||||
|
Object.defineProperty(clonedObj, ORIG_ID, { |
||||||
|
value: origId, |
||||||
|
writable: false, |
||||||
|
enumerable: false, |
||||||
|
}); |
||||||
|
}; |
||||||
Loading…
Reference in new issue