tweak API of duplicateElements to clarify usage

This commit is contained in:
dwelle 2025-03-23 17:23:37 +01:00
parent 679103e2f8
commit 029aa3456f
6 changed files with 105 additions and 48 deletions

View file

@ -53,7 +53,9 @@ export const actionDuplicateSelection = register({
} }
let { newElements: duplicatedElements, elementsWithClones: nextElements } = let { newElements: duplicatedElements, elementsWithClones: nextElements } =
duplicateElements(elements, { duplicateElements({
type: "in-place",
elements,
idsOfElementsToDuplicate: arrayToMap( idsOfElementsToDuplicate: arrayToMap(
getSelectedElements(elements, appState, { getSelectedElements(elements, appState, {
includeBoundTextElement: true, includeBoundTextElement: true,
@ -66,6 +68,7 @@ export const actionDuplicateSelection = register({
x: element.x + DEFAULT_GRID_SIZE / 2, x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2, y: element.y + DEFAULT_GRID_SIZE / 2,
}), }),
reverseOrder: false,
}); });
if (app.props.onDuplicate && nextElements) { if (app.props.onDuplicate && nextElements) {

View file

@ -3219,17 +3219,16 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(dx, dy, this.getEffectiveGridSize()); const [gridX, gridY] = getGridPoint(dx, dy, this.getEffectiveGridSize());
const { newElements } = duplicateElements( const { newElements } = duplicateElements({
elements.map((element) => { type: "everything",
elements: elements.map((element) => {
return newElementWith(element, { return newElementWith(element, {
x: element.x + gridX - minX, x: element.x + gridX - minX,
y: element.y + gridY - minY, y: element.y + gridY - minY,
}); });
}), }),
{ randomizeSeed: !opts.retainSeed,
randomizeSeed: !opts.retainSeed, });
},
);
const prevElements = this.scene.getElementsIncludingDeleted(); const prevElements = this.scene.getElementsIncludingDeleted();
let nextElements = [...prevElements, ...newElements]; let nextElements = [...prevElements, ...newElements];
@ -8434,7 +8433,9 @@ class App extends React.Component<AppProps, AppState> {
} }
const { newElements: clonedElements, elementsWithClones } = const { newElements: clonedElements, elementsWithClones } =
duplicateElements(elements, { duplicateElements({
type: "in-place",
elements,
appState: this.state, appState: this.state,
randomizeSeed: true, randomizeSeed: true,
idsOfElementsToDuplicate: new Map( idsOfElementsToDuplicate: new Map(

View file

@ -162,8 +162,11 @@ export default function LibraryMenuItems({
...item, ...item,
// duplicate each library item before inserting on canvas to confine // duplicate each library item before inserting on canvas to confine
// ids and bindings to each library item. See #6465 // ids and bindings to each library item. See #6465
elements: duplicateElements(item.elements, { randomizeSeed: true }) elements: duplicateElements({
.newElements, type: "everything",
elements: item.elements,
randomizeSeed: true,
}).newElements,
}; };
}); });
}, },

View file

@ -7,7 +7,12 @@ import { FONT_FAMILY, ORIG_ID, ROUNDNESS } from "../constants";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { isPrimitive } from "../utils"; import { isPrimitive } from "../utils";
import { act, assertElements, render } from "../tests/test-utils"; import {
act,
assertElements,
getCloneByOrigId,
render,
} from "../tests/test-utils";
import { Excalidraw } from ".."; import { Excalidraw } from "..";
import { actionDuplicateSelection } from "../actions"; import { actionDuplicateSelection } from "../actions";
@ -162,7 +167,10 @@ describe("duplicating multiple elements", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const; const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
const { newElements: clonedElements } = duplicateElements(origElements); const { newElements: clonedElements } = duplicateElements({
type: "everything",
elements: origElements,
});
// generic id in-equality checks // generic id in-equality checks
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -313,9 +321,10 @@ describe("duplicating multiple elements", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const; const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
const { newElements: clonedElements } = duplicateElements( const { newElements: clonedElements } = duplicateElements({
origElements, type: "everything",
) as any as { newElements: typeof origElements }; elements: origElements,
}) as any as { newElements: typeof origElements };
const [ const [
clonedRectangle, clonedRectangle,
@ -359,9 +368,10 @@ describe("duplicating multiple elements", () => {
}); });
const origElements = [rectangle1, rectangle2, rectangle3] as const; const origElements = [rectangle1, rectangle2, rectangle3] as const;
const { newElements: clonedElements } = duplicateElements( const { newElements: clonedElements } = duplicateElements({
origElements, type: "everything",
) as any as { newElements: typeof origElements }; elements: origElements,
}) as any as { newElements: typeof origElements };
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] = const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
clonedElements; clonedElements;
@ -384,7 +394,7 @@ describe("duplicating multiple elements", () => {
const { const {
newElements: [clonedRectangle1], newElements: [clonedRectangle1],
} = duplicateElements([rectangle1]); } = duplicateElements({ type: "everything", elements: [rectangle1] });
expect(typeof clonedRectangle1.groupIds[0]).toBe("string"); expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]); expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);

View file

@ -96,20 +96,62 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
}; };
export const duplicateElements = ( export const duplicateElements = (
elements: readonly ExcalidrawElement[], opts: {
opts?: { elements: readonly ExcalidrawElement[];
idsOfElementsToDuplicate?: Map<ExcalidrawElement["id"], ExcalidrawElement>; randomizeSeed?: boolean;
appState?: {
editingGroupId: AppState["editingGroupId"];
selectedGroupIds: AppState["selectedGroupIds"];
};
overrides?: ( overrides?: (
originalElement: ExcalidrawElement, originalElement: ExcalidrawElement,
) => Partial<ExcalidrawElement>; ) => Partial<ExcalidrawElement>;
randomizeSeed?: boolean; } & (
reverseOrder?: boolean; | {
}, /**
* 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 // 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 // into the array twice if we end up backtracking when retrieving
// discontiguous group of elements (can happen due to a bug, or in edge // discontiguous group of elements (can happen due to a bug, or in edge
@ -128,11 +170,12 @@ export const duplicateElements = (
const duplicatedElementsMap = new Map<string, ExcalidrawElement>(); const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const elementsMap = arrayToMap(elements) as ElementsMap; const elementsMap = arrayToMap(elements) as ElementsMap;
const _idsOfElementsToDuplicate = const _idsOfElementsToDuplicate =
opts?.idsOfElementsToDuplicate ?? opts.type === "in-place"
new Map(elements.map((el) => [el.id, el])); ? opts.idsOfElementsToDuplicate
: new Map(elements.map((el) => [el.id, el]));
// For sanity // For sanity
if (opts?.appState?.selectedGroupIds) { if (opts.type === "in-place") {
for (const groupId of Object.keys(opts.appState.selectedGroupIds)) { for (const groupId of Object.keys(opts.appState.selectedGroupIds)) {
elements elements
.filter((el) => el.groupIds?.includes(groupId)) .filter((el) => el.groupIds?.includes(groupId))
@ -165,11 +208,11 @@ export const duplicateElements = (
processedIds.set(element.id, true); processedIds.set(element.id, true);
const newElement = duplicateElement( const newElement = duplicateElement(
opts?.appState?.editingGroupId ?? null, appState.editingGroupId,
groupIdMap, groupIdMap,
element, element,
opts?.overrides?.(element), opts.overrides?.(element),
opts?.randomizeSeed, opts.randomizeSeed,
); );
processedIds.set(newElement.id, true); processedIds.set(newElement.id, true);
@ -204,18 +247,18 @@ export const duplicateElements = (
return; return;
} }
if (opts?.reverseOrder && index < 1) { if (reverseOrder && index < 1) {
elementsWithClones.unshift(...castArray(elements)); elementsWithClones.unshift(...castArray(elements));
return; return;
} }
if (!opts?.reverseOrder && index > elementsWithClones.length - 1) { if (!reverseOrder && index > elementsWithClones.length - 1) {
elementsWithClones.push(...castArray(elements)); elementsWithClones.push(...castArray(elements));
return; return;
} }
elementsWithClones.splice( elementsWithClones.splice(
index + (opts?.reverseOrder ? 0 : 1), index + (reverseOrder ? 0 : 1),
0, 0,
...castArray(elements), ...castArray(elements),
); );
@ -241,13 +284,7 @@ export const duplicateElements = (
// groups // groups
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const groupId = getSelectedGroupForElement( const groupId = getSelectedGroupForElement(appState, element);
(opts?.appState ?? {
editingGroupId: null,
selectedGroupIds: {},
}) as AppState,
element,
);
if (groupId) { if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap( const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) => (element) =>
@ -256,7 +293,7 @@ export const duplicateElements = (
: [element], : [element],
); );
const targetIndex = opts?.reverseOrder const targetIndex = reverseOrder
? elementsWithClones.findIndex((el) => { ? elementsWithClones.findIndex((el) => {
return el.groupIds?.includes(groupId); return el.groupIds?.includes(groupId);
}) })
@ -306,7 +343,7 @@ export const duplicateElements = (
if (boundTextElement) { if (boundTextElement) {
insertBeforeOrAfterIndex( insertBeforeOrAfterIndex(
targetIndex + (opts?.reverseOrder ? -1 : 0), targetIndex + (reverseOrder ? -1 : 0),
copyElements([element, boundTextElement]), copyElements([element, boundTextElement]),
); );
} else { } else {

View file

@ -214,7 +214,10 @@ export const isSelectedViaGroup = (
) => getSelectedGroupForElement(appState, element) != null; ) => getSelectedGroupForElement(appState, element) != null;
export const getSelectedGroupForElement = ( export const getSelectedGroupForElement = (
appState: InteractiveCanvasAppState, appState: Pick<
InteractiveCanvasAppState,
"editingGroupId" | "selectedGroupIds"
>,
element: ExcalidrawElement, element: ExcalidrawElement,
) => ) =>
element.groupIds element.groupIds