diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/actions/actionDuplicateSelection.tsx | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/actions/actionDuplicateSelection.tsx')
| -rw-r--r-- | packages/excalidraw/actions/actionDuplicateSelection.tsx | 359 |
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, + ), + }, + }; +}; |
