aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/actions/actionDuplicateSelection.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/actions/actionDuplicateSelection.tsx')
-rw-r--r--packages/excalidraw/actions/actionDuplicateSelection.tsx359
1 files changed, 359 insertions, 0 deletions
diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx
new file mode 100644
index 0000000..b28e831
--- /dev/null
+++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx
@@ -0,0 +1,359 @@
+import { KEYS } from "../keys";
+import { register } from "./register";
+import type { ExcalidrawElement } from "../element/types";
+import { duplicateElement, getNonDeletedElements } from "../element";
+import { isSomeElementSelected } from "../scene";
+import { ToolButton } from "../components/ToolButton";
+import { t } from "../i18n";
+import {
+ arrayToMap,
+ castArray,
+ findLastIndex,
+ getShortcutKey,
+ invariant,
+} from "../utils";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import {
+ selectGroupsForSelectedElements,
+ getSelectedGroupForElement,
+ getElementsInGroup,
+} from "../groups";
+import type { AppState } from "../types";
+import { fixBindingsAfterDuplication } from "../element/binding";
+import type { ActionResult } from "./types";
+import { DEFAULT_GRID_SIZE } from "../constants";
+import {
+ bindTextToShapeAfterDuplication,
+ getBoundTextElement,
+ getContainerElement,
+} from "../element/textElement";
+import {
+ hasBoundTextElement,
+ isBoundToContainer,
+ isFrameLikeElement,
+} from "../element/typeChecks";
+import { normalizeElementOrder } from "../element/sortElements";
+import { DuplicateIcon } from "../components/icons";
+import {
+ bindElementsToFramesAfterDuplication,
+ getFrameChildren,
+} from "../frame";
+import {
+ excludeElementsInFramesFromSelection,
+ getSelectedElements,
+} from "../scene/selection";
+import { CaptureUpdateAction } from "../store";
+
+export const actionDuplicateSelection = register({
+ name: "duplicateSelection",
+ label: "labels.duplicateSelection",
+ icon: DuplicateIcon,
+ trackEvent: { category: "element" },
+ perform: (elements, appState, formData, app) => {
+ // 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 nextState = duplicateElements(elements, appState);
+
+ if (app.props.onDuplicate && nextState.elements) {
+ const mappedElements = app.props.onDuplicate(
+ nextState.elements,
+ elements,
+ );
+ if (mappedElements) {
+ nextState.elements = mappedElements;
+ }
+ }
+
+ return {
+ ...nextState,
+ 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,
+ );
+ 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,
+ ),
+ },
+ };
+};