Duplication action refactor

This commit is contained in:
Mark Tolmacs 2025-03-11 12:35:35 +01:00
parent 4cfcd4b353
commit c4767a3144
3 changed files with 142 additions and 125 deletions

View file

@ -9,8 +9,6 @@ import {
} from "../element/textElement"; } from "../element/textElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
isArrowElement,
isBindableElement,
isBoundToContainer, isBoundToContainer,
isFrameLikeElement, isFrameLikeElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
@ -41,18 +39,13 @@ import {
invariant, invariant,
} from "../utils"; } from "../utils";
import { type ElementUpdate, mutateElement } from "../element/mutateElement"; import { duplicateElements } from "../element/duplicate";
import { register } from "./register"; import { register } from "./register";
import type { ActionResult } from "./types"; import type { ActionResult } from "./types";
import type { import type { ExcalidrawElement } from "../element/types";
BoundElement,
ExcalidrawArrowElement,
ExcalidrawElement,
} from "../element/types";
import type { AppState } from "../types"; import type { AppState } from "../types";
import type { Mutable } from "../utility-types";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", name: "duplicateSelection",
@ -83,20 +76,51 @@ export const actionDuplicateSelection = register({
} }
} }
const nextState = duplicateElements(elements, appState); const origElements: ExcalidrawElement[] = elements.slice();
const clonedElements = duplicateElements(elements, {
randomizeSeed: true,
overrides: (element) => ({
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
}),
});
if (app.props.onDuplicate && nextState.elements) { let nextElements = origElements.concat(clonedElements);
const mappedElements = app.props.onDuplicate(
nextState.elements, if (app.props.onDuplicate && nextElements) {
elements, const mappedElements = app.props.onDuplicate(nextElements, elements);
);
if (mappedElements) { if (mappedElements) {
nextState.elements = mappedElements; nextElements = mappedElements;
} }
} }
//nextElements = syncMovedIndices(nextElements, arrayToMap(clonedElements));
const nextElementsToSelect =
excludeElementsInFramesFromSelection(clonedElements);
return { return {
...nextState, elements: nextElements,
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: appState.editingGroupId,
selectedElementIds: nextElementsToSelect.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
},
{},
),
},
getNonDeletedElements(nextElements),
appState,
null,
),
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
@ -115,7 +139,7 @@ export const actionDuplicateSelection = register({
), ),
}); });
const duplicateElements = ( const _duplicateElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
): Partial<Exclude<ActionResult, false>> => { ): Partial<Exclude<ActionResult, false>> => {
@ -339,96 +363,96 @@ const duplicateElements = (
// oldIdToDuplicatedId, // oldIdToDuplicatedId,
// ); // );
newElements // newElements
.map((element) => { // .map((element) => {
oldElements.includes(element) && console.error("oldElements", element); // oldElements.includes(element) && console.error("oldElements", element);
if (isArrowElement(element)) { // if (isArrowElement(element)) {
const updates: Mutable<ElementUpdate<ExcalidrawArrowElement>> = {}; // const updates: Mutable<ElementUpdate<ExcalidrawArrowElement>> = {};
if (element.startBinding) { // if (element.startBinding) {
const startCloneElementId = oldIdToDuplicatedId.get( // const startCloneElementId = oldIdToDuplicatedId.get(
element.startBinding.elementId, // element.startBinding.elementId,
); // );
if (startCloneElementId) { // if (startCloneElementId) {
// The connected element was duplicated, so we need to update the binding // // The connected element was duplicated, so we need to update the binding
updates.startBinding = { // updates.startBinding = {
...element.startBinding, // ...element.startBinding,
elementId: startCloneElementId, // elementId: startCloneElementId,
}; // };
} else { // } else {
// The connected element was not duplicated, so we need to remove the binding // // The connected element was not duplicated, so we need to remove the binding
updates.startBinding = null; // updates.startBinding = null;
} // }
} // }
if (element.endBinding) { // if (element.endBinding) {
const endCloneElementId = oldIdToDuplicatedId.get( // const endCloneElementId = oldIdToDuplicatedId.get(
element.endBinding.elementId, // element.endBinding.elementId,
); // );
if (endCloneElementId) { // if (endCloneElementId) {
// The connected element was duplicated, so we need to update the binding // // The connected element was duplicated, so we need to update the binding
updates.endBinding = { // updates.endBinding = {
...element.endBinding, // ...element.endBinding,
elementId: endCloneElementId, // elementId: endCloneElementId,
}; // };
} else { // } else {
// The connected element was not duplicated, so we need to remove the binding // // The connected element was not duplicated, so we need to remove the binding
updates.endBinding = null; // updates.endBinding = null;
} // }
} // }
if (Object.keys(updates).length > 0) { // if (Object.keys(updates).length > 0) {
// Only update the element if there are updates to apply // // Only update the element if there are updates to apply
return { // return {
element, // element,
updates, // updates,
}; // };
} // }
} else if (isBindableElement(element)) { // } else if (isBindableElement(element)) {
if (element.boundElements?.length) { // if (element.boundElements?.length) {
const clonedBoundElements = element.boundElements // const clonedBoundElements = element.boundElements
?.map((definition) => { // ?.map((definition) => {
const clonedBoundElementId = oldIdToDuplicatedId.get( // const clonedBoundElementId = oldIdToDuplicatedId.get(
definition.id, // definition.id,
); // );
if (clonedBoundElementId) { // if (clonedBoundElementId) {
// The connected element was duplicated, so we need to update the binding // // The connected element was duplicated, so we need to update the binding
return { // return {
...definition, // ...definition,
id: clonedBoundElementId, // id: clonedBoundElementId,
}; // };
} // }
// The connected element was not duplicated, so we need to remove the binding // // The connected element was not duplicated, so we need to remove the binding
return null; // return null;
}) // })
.filter( // .filter(
(definition): definition is BoundElement => definition !== null, // (definition): definition is BoundElement => definition !== null,
); // );
if (clonedBoundElements?.length) { // if (clonedBoundElements?.length) {
return { // return {
element, // element,
updates: { // updates: {
boundElements: clonedBoundElements, // boundElements: clonedBoundElements,
}, // },
}; // };
} // }
} // }
} // }
return null; // return null;
}) // })
.forEach((change) => { // .forEach((change) => {
if (!change) { // if (!change) {
return; // return;
} // }
mutateElement(change.element, change.updates); // mutateElement(change.element, change.updates);
}); // });
bindElementsToFramesAfterDuplication( bindElementsToFramesAfterDuplication(
elementsWithClones, elementsWithClones,

View file

@ -8445,24 +8445,6 @@ class App extends React.Component<AppProps, AppState> {
// updated yet by the time this mousemove event is fired // updated yet by the time this mousemove event is fired
(element.id === hitElement?.id && (element.id === hitElement?.id &&
pointerDownState.hit.wasAddedToSelection); pointerDownState.hit.wasAddedToSelection);
// NOTE (mtolmacs): This is a temporary fix for very large scenes
if (
Math.abs(element.x) > 1e7 ||
Math.abs(element.x) > 1e7 ||
Math.abs(element.width) > 1e7 ||
Math.abs(element.height) > 1e7
) {
console.error(
`Alt+dragging element in scene with invalid dimensions`,
element.x,
element.y,
element.width,
element.height,
isInSelection,
);
return;
}
if (isInSelection) { if (isInSelection) {
const duplicatedElement = duplicateElement( const duplicatedElement = duplicateElement(

View file

@ -5,6 +5,7 @@ import type { AppState } from "../types";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils"; import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
import { bumpVersion } from "./mutateElement"; import { bumpVersion } from "./mutateElement";
import { isArrowElement } from "./typeChecks";
import type { ExcalidrawElement, GroupId } from "./types"; import type { ExcalidrawElement, GroupId } from "./types";
/** /**
@ -66,7 +67,8 @@ export const duplicateElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
opts?: { opts?: {
/** NOTE also updates version flags and `updated` */ /** NOTE also updates version flags and `updated` */
randomizeSeed: boolean; randomizeSeed?: boolean;
overrides?: (element: ExcalidrawElement) => Partial<ExcalidrawElement>;
}, },
) => { ) => {
const clonedElements: ExcalidrawElement[] = []; const clonedElements: ExcalidrawElement[] = [];
@ -79,7 +81,7 @@ export const duplicateElements = (
/* new */ ExcalidrawElement["id"] /* new */ ExcalidrawElement["id"]
>(); >();
const maybeGetNewId = (id: ExcalidrawElement["id"]) => { const maybeGetNewIdFor = (id: ExcalidrawElement["id"]) => {
// if we've already migrated the element id, return the new one directly // if we've already migrated the element id, return the new one directly
if (elementNewIdsMap.has(id)) { if (elementNewIdsMap.has(id)) {
return elementNewIdsMap.get(id)!; return elementNewIdsMap.get(id)!;
@ -98,9 +100,16 @@ export const duplicateElements = (
const groupNewIdsMap = new Map</* orig */ GroupId, /* new */ GroupId>(); const groupNewIdsMap = new Map</* orig */ GroupId, /* new */ GroupId>();
for (const element of elements) { for (const element of elements) {
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element); let clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
clonedElement.id = maybeGetNewId(element.id)!; if (opts?.overrides) {
clonedElement = Object.assign(
clonedElement,
opts.overrides(clonedElement),
);
}
clonedElement.id = maybeGetNewIdFor(element.id)!;
if (isTestEnv()) { if (isTestEnv()) {
__test__defineOrigId(clonedElement, element.id); __test__defineOrigId(clonedElement, element.id);
} }
@ -120,7 +129,7 @@ export const duplicateElements = (
} }
if ("containerId" in clonedElement && clonedElement.containerId) { if ("containerId" in clonedElement && clonedElement.containerId) {
const newContainerId = maybeGetNewId(clonedElement.containerId); const newContainerId = maybeGetNewIdFor(clonedElement.containerId);
clonedElement.containerId = newContainerId; clonedElement.containerId = newContainerId;
} }
@ -130,7 +139,7 @@ export const duplicateElements = (
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>, acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding, binding,
) => { ) => {
const newBindingId = maybeGetNewId(binding.id); const newBindingId = maybeGetNewIdFor(binding.id);
if (newBindingId) { if (newBindingId) {
acc.push({ ...binding, id: newBindingId }); acc.push({ ...binding, id: newBindingId });
} }
@ -141,7 +150,9 @@ export const duplicateElements = (
} }
if ("endBinding" in clonedElement && clonedElement.endBinding) { if ("endBinding" in clonedElement && clonedElement.endBinding) {
const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId); const newEndBindingId = maybeGetNewIdFor(
clonedElement.endBinding.elementId,
);
clonedElement.endBinding = newEndBindingId clonedElement.endBinding = newEndBindingId
? { ? {
...clonedElement.endBinding, ...clonedElement.endBinding,
@ -150,7 +161,7 @@ export const duplicateElements = (
: null; : null;
} }
if ("startBinding" in clonedElement && clonedElement.startBinding) { if ("startBinding" in clonedElement && clonedElement.startBinding) {
const newEndBindingId = maybeGetNewId( const newEndBindingId = maybeGetNewIdFor(
clonedElement.startBinding.elementId, clonedElement.startBinding.elementId,
); );
clonedElement.startBinding = newEndBindingId clonedElement.startBinding = newEndBindingId
@ -162,7 +173,7 @@ export const duplicateElements = (
} }
if (clonedElement.frameId) { if (clonedElement.frameId) {
clonedElement.frameId = maybeGetNewId(clonedElement.frameId); clonedElement.frameId = maybeGetNewIdFor(clonedElement.frameId);
} }
clonedElements.push(clonedElement); clonedElements.push(clonedElement);