excalidraw/packages/excalidraw/actions/actionDuplicateSelection.tsx
2025-03-16 19:54:29 +01:00

489 lines
14 KiB
TypeScript

import { ToolButton } from "../components/ToolButton";
import { DuplicateIcon } from "../components/icons";
import { DEFAULT_GRID_SIZE } from "../constants";
import { duplicateElement, getNonDeletedElements } from "../element";
import {
bindTextToShapeAfterDuplication,
getBoundTextElement,
getContainerElement,
} from "../element/textElement";
import {
hasBoundTextElement,
isBoundToContainer,
isFrameLikeElement,
} from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
bindElementsToFramesAfterDuplication,
getFrameChildren,
} from "../frame";
import {
selectGroupsForSelectedElements,
getSelectedGroupForElement,
getElementsInGroup,
} from "../groups";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import {
excludeElementsInFramesFromSelection,
getSelectedElements,
} from "../scene/selection";
import { CaptureUpdateAction } from "../store";
import {
arrayToMap,
castArray,
findLastIndex,
getShortcutKey,
invariant,
} from "../utils";
import { duplicateElements } from "../element/duplicate";
import { register } from "./register";
import type { ActionResult } from "./types";
import type { ExcalidrawElement } from "../element/types";
import type { AppState } from "../types";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
label: "labels.duplicateSelection",
icon: DuplicateIcon,
trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => {
if (appState.selectedElementsAreBeingDragged) {
return false;
}
// duplicate selected point(s) if editing a line
if (appState.editingLinearElement) {
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
try {
const newAppState = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene.getNonDeletedElementsMap(),
);
return {
elements,
appState: newAppState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
} catch {
return false;
}
}
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,
}),
});
let nextElements = origElements.concat(clonedElements);
if (app.props.onDuplicate && nextElements) {
const mappedElements = app.props.onDuplicate(nextElements, elements);
if (mappedElements) {
nextElements = mappedElements;
}
}
//nextElements = syncMovedIndices(nextElements, arrayToMap(clonedElements));
const nextElementsToSelect =
excludeElementsInFramesFromSelection(clonedElements);
return {
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,
};
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
type="button"
icon={DuplicateIcon}
title={`${t("labels.duplicateSelection")}${getShortcutKey(
"CtrlOrCmd+D",
)}`}
aria-label={t("labels.duplicateSelection")}
onClick={() => updateData(null)}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
const _duplicateElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
): Partial<Exclude<ActionResult, false>> => {
// ---------------------------------------------------------------------------
const groupIdMap = new Map();
const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map();
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const elementsMap = arrayToMap(elements);
const duplicateAndOffsetElement = <
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,
{
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
},
);
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;
};
elements = normalizeElementOrder(elements);
const idsOfElementsToDuplicate = arrayToMap(
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
);
// 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 elementsWithClones: ExcalidrawElement[] = elements.slice();
const insertAfterIndex = (
index: number,
elements: ExcalidrawElement | null | ExcalidrawElement[],
) => {
invariant(index !== -1, "targetIndex === -1 ");
if (!Array.isArray(elements) && !elements) {
return;
}
elementsWithClones.splice(index + 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 = findLastIndex(elementsWithClones, (el) => {
return el.groupIds?.includes(groupId);
});
insertAfterIndex(targetIndex, duplicateAndOffsetElement(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;
});
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([...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) {
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([element, boundTextElement]),
);
} else {
insertAfterIndex(targetIndex, duplicateAndOffsetElement(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) {
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([container, element]),
);
} else {
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
}
continue;
}
// default duplication (regular elements)
// -------------------------------------------------------------------------
insertAfterIndex(
findLastIndex(elementsWithClones, (el) => el.id === element.id),
duplicateAndOffsetElement(element),
);
}
// ---------------------------------------------------------------------------
bindTextToShapeAfterDuplication(
elementsWithClones,
oldElements,
oldIdToDuplicatedId,
);
// fixBindingsAfterDuplication(
// elementsWithClones,
// oldElements,
// oldIdToDuplicatedId,
// );
// newElements
// .map((element) => {
// oldElements.includes(element) && console.error("oldElements", element);
// if (isArrowElement(element)) {
// const updates: Mutable<ElementUpdate<ExcalidrawArrowElement>> = {};
// if (element.startBinding) {
// const startCloneElementId = oldIdToDuplicatedId.get(
// element.startBinding.elementId,
// );
// if (startCloneElementId) {
// // The connected element was duplicated, so we need to update the binding
// updates.startBinding = {
// ...element.startBinding,
// elementId: startCloneElementId,
// };
// } else {
// // The connected element was not duplicated, so we need to remove the binding
// updates.startBinding = null;
// }
// }
// if (element.endBinding) {
// const endCloneElementId = oldIdToDuplicatedId.get(
// element.endBinding.elementId,
// );
// if (endCloneElementId) {
// // The connected element was duplicated, so we need to update the binding
// updates.endBinding = {
// ...element.endBinding,
// elementId: endCloneElementId,
// };
// } else {
// // The connected element was not duplicated, so we need to remove the binding
// updates.endBinding = null;
// }
// }
// if (Object.keys(updates).length > 0) {
// // Only update the element if there are updates to apply
// return {
// element,
// updates,
// };
// }
// } else if (isBindableElement(element)) {
// if (element.boundElements?.length) {
// const clonedBoundElements = element.boundElements
// ?.map((definition) => {
// const clonedBoundElementId = oldIdToDuplicatedId.get(
// definition.id,
// );
// if (clonedBoundElementId) {
// // The connected element was duplicated, so we need to update the binding
// return {
// ...definition,
// id: clonedBoundElementId,
// };
// }
// // The connected element was not duplicated, so we need to remove the binding
// return null;
// })
// .filter(
// (definition): definition is BoundElement => definition !== null,
// );
// if (clonedBoundElements?.length) {
// return {
// element,
// updates: {
// boundElements: clonedBoundElements,
// },
// };
// }
// }
// }
// return null;
// })
// .forEach((change) => {
// if (!change) {
// return;
// }
// mutateElement(change.element, change.updates);
// });
bindElementsToFramesAfterDuplication(
elementsWithClones,
oldElements,
oldIdToDuplicatedId,
);
const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements);
return {
elements: elementsWithClones,
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(elementsWithClones),
appState,
null,
),
},
};
};