mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
* fix: deselected hit element being duplicated + incorrect re-seeding * snapshots * Fix alt-drag binding Signed-off-by: Mark Tolmacs <mark@lazycat.hu> * Add alt-drag bound arrow test Signed-off-by: Mark Tolmacs <mark@lazycat.hu> --------- Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
496 lines
14 KiB
TypeScript
496 lines
14 KiB
TypeScript
import {
|
|
ORIG_ID,
|
|
randomId,
|
|
randomInteger,
|
|
arrayToMap,
|
|
castArray,
|
|
findLastIndex,
|
|
getUpdatedTimestamp,
|
|
isTestEnv,
|
|
} from "@excalidraw/common";
|
|
|
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
|
|
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
|
|
|
import {
|
|
getElementsInGroup,
|
|
getNewGroupIdsForDuplication,
|
|
getSelectedGroupForElement,
|
|
} from "./groups";
|
|
|
|
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 {
|
|
fixDuplicatedBindingsAfterDuplication,
|
|
fixReversedBindings,
|
|
} from "./binding";
|
|
|
|
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),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fixDuplicatedBindingsAfterDuplication(
|
|
newElements,
|
|
oldIdToDuplicatedId,
|
|
duplicatedElementsMap as NonDeletedSceneElementsMap,
|
|
);
|
|
|
|
if (reverseOrder) {
|
|
fixReversedBindings(
|
|
_idsOfElementsToDuplicate,
|
|
elementsWithClones,
|
|
oldIdToDuplicatedId,
|
|
);
|
|
}
|
|
|
|
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,
|
|
});
|
|
};
|