mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
fix: Refactor and merge duplication and binding (#9246)
All checks were successful
Tests / test (push) Successful in 4m52s
All checks were successful
Tests / test (push) Successful in 4m52s
This commit is contained in:
parent
58990b41ae
commit
77aca48c84
29 changed files with 1293 additions and 1085 deletions
|
@ -1,5 +1,5 @@
|
||||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||||
import { deepCopyElement } from "../element/newElement";
|
import { deepCopyElement } from "../element/duplicate";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import { CaptureUpdateAction } from "../store";
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
|
@ -288,6 +288,7 @@ export const actionDeleteSelected = register({
|
||||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
|
selectedLinearElement: null,
|
||||||
},
|
},
|
||||||
captureUpdate: isSomeElementSelected(
|
captureUpdate: isSomeElementSelected(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
|
|
|
@ -258,7 +258,7 @@ describe("actionDuplicateSelection", () => {
|
||||||
assertElements(h.elements, [
|
assertElements(h.elements, [
|
||||||
{ id: frame.id },
|
{ id: frame.id },
|
||||||
{ id: text.id, frameId: frame.id },
|
{ id: text.id, frameId: frame.id },
|
||||||
{ [ORIG_ID]: text.id, frameId: frame.id },
|
{ [ORIG_ID]: text.id, frameId: frame.id, selected: true },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,29 +1,10 @@
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { DuplicateIcon } from "../components/icons";
|
import { DuplicateIcon } from "../components/icons";
|
||||||
import { DEFAULT_GRID_SIZE } from "../constants";
|
import { DEFAULT_GRID_SIZE } from "../constants";
|
||||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
import { isBoundToContainer, isLinearElement } from "../element/typeChecks";
|
||||||
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 { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import {
|
import { selectGroupsForSelectedElements } from "../groups";
|
||||||
bindElementsToFramesAfterDuplication,
|
|
||||||
getFrameChildren,
|
|
||||||
} from "../frame";
|
|
||||||
import {
|
|
||||||
selectGroupsForSelectedElements,
|
|
||||||
getSelectedGroupForElement,
|
|
||||||
getElementsInGroup,
|
|
||||||
} from "../groups";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
@ -32,19 +13,15 @@ import {
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
} from "../scene/selection";
|
} from "../scene/selection";
|
||||||
import { CaptureUpdateAction } from "../store";
|
import { CaptureUpdateAction } from "../store";
|
||||||
import {
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
arrayToMap,
|
|
||||||
castArray,
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
findLastIndex,
|
|
||||||
getShortcutKey,
|
import { duplicateElements } from "../element/duplicate";
|
||||||
invariant,
|
|
||||||
} from "../utils";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { ActionResult } from "./types";
|
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import type { AppState } from "../types";
|
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
|
@ -75,20 +52,54 @@ export const actionDuplicateSelection = register({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextState = duplicateElements(elements, appState);
|
let { newElements: duplicatedElements, elementsWithClones: nextElements } =
|
||||||
|
duplicateElements({
|
||||||
if (app.props.onDuplicate && nextState.elements) {
|
type: "in-place",
|
||||||
const mappedElements = app.props.onDuplicate(
|
|
||||||
nextState.elements,
|
|
||||||
elements,
|
elements,
|
||||||
);
|
idsOfElementsToDuplicate: arrayToMap(
|
||||||
|
getSelectedElements(elements, appState, {
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
includeElementsInFrames: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
appState,
|
||||||
|
randomizeSeed: true,
|
||||||
|
overrides: (element) => ({
|
||||||
|
x: element.x + DEFAULT_GRID_SIZE / 2,
|
||||||
|
y: element.y + DEFAULT_GRID_SIZE / 2,
|
||||||
|
}),
|
||||||
|
reverseOrder: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (app.props.onDuplicate && nextElements) {
|
||||||
|
const mappedElements = app.props.onDuplicate(nextElements, elements);
|
||||||
if (mappedElements) {
|
if (mappedElements) {
|
||||||
nextState.elements = mappedElements;
|
nextElements = mappedElements;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...nextState,
|
elements: syncMovedIndices(nextElements, arrayToMap(duplicatedElements)),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
...updateLinearElementEditors(duplicatedElements),
|
||||||
|
...selectGroupsForSelectedElements(
|
||||||
|
{
|
||||||
|
editingGroupId: appState.editingGroupId,
|
||||||
|
selectedElementIds: excludeElementsInFramesFromSelection(
|
||||||
|
duplicatedElements,
|
||||||
|
).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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -107,259 +118,23 @@ export const actionDuplicateSelection = register({
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const duplicateElements = (
|
const updateLinearElementEditors = (clonedElements: ExcalidrawElement[]) => {
|
||||||
elements: readonly ExcalidrawElement[],
|
const linears = clonedElements.filter(isLinearElement);
|
||||||
appState: AppState,
|
if (linears.length === 1) {
|
||||||
): Partial<Exclude<ActionResult, false>> => {
|
const linear = linears[0];
|
||||||
// ---------------------------------------------------------------------------
|
const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
|
||||||
|
const onlySingleLinearSelected = clonedElements.every(
|
||||||
const groupIdMap = new Map();
|
(el) => el.id === linear.id || boundElements.includes(el.id),
|
||||||
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);
|
if (onlySingleLinearSelected) {
|
||||||
|
return {
|
||||||
duplicatedElementsMap.set(newElement.id, newElement);
|
selectedLinearElement: new LinearElementEditor(linear),
|
||||||
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 {
|
return {
|
||||||
elements: elementsWithClones,
|
selectedLinearElement: null,
|
||||||
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,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,7 +6,6 @@ import {
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { getCommonBoundingBox } from "../element/bounds";
|
import { getCommonBoundingBox } from "../element/bounds";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import { deepCopyElement } from "../element/newElement";
|
|
||||||
import { resizeMultipleElements } from "../element/resizeElements";
|
import { resizeMultipleElements } from "../element/resizeElements";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
|
@ -19,6 +18,8 @@ import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
import { CaptureUpdateAction } from "../store";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
|
import { deepCopyElement } from "../element/duplicate";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
|
@ -6,7 +6,6 @@ import {
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { createFile, isSupportedImageFileType } from "./data/blob";
|
import { createFile, isSupportedImageFileType } from "./data/blob";
|
||||||
import { mutateElement } from "./element/mutateElement";
|
import { mutateElement } from "./element/mutateElement";
|
||||||
import { deepCopyElement } from "./element/newElement";
|
|
||||||
import {
|
import {
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
isInitializedImageElement,
|
isInitializedImageElement,
|
||||||
|
@ -15,6 +14,8 @@ import { ExcalidrawError } from "./errors";
|
||||||
import { getContainingFrame } from "./frame";
|
import { getContainingFrame } from "./frame";
|
||||||
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
|
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
|
||||||
|
|
||||||
|
import { deepCopyElement } from "./element/duplicate";
|
||||||
|
|
||||||
import type { Spreadsheet } from "./charts";
|
import type { Spreadsheet } from "./charts";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
|
|
@ -126,7 +126,6 @@ import { restore, restoreElements } from "../data/restore";
|
||||||
import {
|
import {
|
||||||
dragNewElement,
|
dragNewElement,
|
||||||
dragSelectedElements,
|
dragSelectedElements,
|
||||||
duplicateElement,
|
|
||||||
getCommonBounds,
|
getCommonBounds,
|
||||||
getCursorForResizingElement,
|
getCursorForResizingElement,
|
||||||
getDragOffsetXY,
|
getDragOffsetXY,
|
||||||
|
@ -152,7 +151,6 @@ import {
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
bindOrUnbindLinearElements,
|
bindOrUnbindLinearElements,
|
||||||
fixBindingsAfterDeletion,
|
fixBindingsAfterDeletion,
|
||||||
fixBindingsAfterDuplication,
|
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
isLinearElementSimpleAndAlreadyBound,
|
isLinearElementSimpleAndAlreadyBound,
|
||||||
|
@ -163,9 +161,8 @@ import {
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
|
import { deepCopyElement, duplicateElements } from "../element/duplicate";
|
||||||
import {
|
import {
|
||||||
deepCopyElement,
|
|
||||||
duplicateElements,
|
|
||||||
newFrameElement,
|
newFrameElement,
|
||||||
newFreeDrawElement,
|
newFreeDrawElement,
|
||||||
newEmbeddableElement,
|
newEmbeddableElement,
|
||||||
|
@ -292,7 +289,6 @@ import {
|
||||||
} from "../element/image";
|
} from "../element/image";
|
||||||
import { fileOpen } from "../data/filesystem";
|
import { fileOpen } from "../data/filesystem";
|
||||||
import {
|
import {
|
||||||
bindTextToShapeAfterDuplication,
|
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getContainerCenter,
|
getContainerCenter,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
|
@ -309,7 +305,6 @@ import { Fonts, getLineHeight } from "../fonts";
|
||||||
import {
|
import {
|
||||||
getFrameChildren,
|
getFrameChildren,
|
||||||
isCursorInFrame,
|
isCursorInFrame,
|
||||||
bindElementsToFramesAfterDuplication,
|
|
||||||
addElementsToFrame,
|
addElementsToFrame,
|
||||||
replaceAllElementsInFrame,
|
replaceAllElementsInFrame,
|
||||||
removeElementsFromFrame,
|
removeElementsFromFrame,
|
||||||
|
@ -3224,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];
|
||||||
|
@ -6095,7 +6089,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.setState({
|
this.setState({
|
||||||
activeEmbeddable: { element: hitElement, state: "hover" },
|
activeEmbeddable: { element: hitElement, state: "hover" },
|
||||||
});
|
});
|
||||||
} else if (!hitElement || !isElbowArrow(hitElement)) {
|
} else if (
|
||||||
|
!hitElement ||
|
||||||
|
// Ebow arrows can only be moved when unconnected
|
||||||
|
!isElbowArrow(hitElement) ||
|
||||||
|
!(hitElement.startBinding || hitElement.endBinding)
|
||||||
|
) {
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||||
if (this.state.activeEmbeddable?.state === "hover") {
|
if (this.state.activeEmbeddable?.state === "hover") {
|
||||||
this.setState({ activeEmbeddable: null });
|
this.setState({ activeEmbeddable: null });
|
||||||
|
@ -6288,8 +6287,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
|
if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||||
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||||
|
if (
|
||||||
|
// Ebow arrows can only be moved when unconnected
|
||||||
|
!isElbowArrow(element) ||
|
||||||
|
!(element.startBinding || element.endBinding)
|
||||||
|
) {
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||||
if (
|
if (
|
||||||
// Ebow arrows can only be moved when unconnected
|
// Ebow arrows can only be moved when unconnected
|
||||||
|
@ -8138,6 +8143,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
...this.state.selectedLinearElement,
|
...this.state.selectedLinearElement,
|
||||||
pointerDownState: ret.pointerDownState,
|
pointerDownState: ret.pointerDownState,
|
||||||
selectedPointsIndices: ret.selectedPointsIndices,
|
selectedPointsIndices: ret.selectedPointsIndices,
|
||||||
|
segmentMidPointHoveredCoords: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -8147,6 +8153,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
...this.state.editingLinearElement,
|
...this.state.editingLinearElement,
|
||||||
pointerDownState: ret.pointerDownState,
|
pointerDownState: ret.pointerDownState,
|
||||||
selectedPointsIndices: ret.selectedPointsIndices,
|
selectedPointsIndices: ret.selectedPointsIndices,
|
||||||
|
segmentMidPointHoveredCoords: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -8160,7 +8167,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const didDrag = LinearElementEditor.handlePointDragging(
|
const newLinearElementEditor = LinearElementEditor.handlePointDragging(
|
||||||
event,
|
event,
|
||||||
this,
|
this,
|
||||||
pointerCoords.x,
|
pointerCoords.x,
|
||||||
|
@ -8174,29 +8181,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
linearElementEditor,
|
linearElementEditor,
|
||||||
this.scene,
|
this.scene,
|
||||||
);
|
);
|
||||||
if (didDrag) {
|
if (newLinearElementEditor) {
|
||||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||||
pointerDownState.lastCoords.y = pointerCoords.y;
|
pointerDownState.lastCoords.y = pointerCoords.y;
|
||||||
pointerDownState.drag.hasOccurred = true;
|
pointerDownState.drag.hasOccurred = true;
|
||||||
if (
|
|
||||||
this.state.editingLinearElement &&
|
|
||||||
!this.state.editingLinearElement.isDragging
|
|
||||||
) {
|
|
||||||
this.setState({
|
this.setState({
|
||||||
editingLinearElement: {
|
editingLinearElement: this.state.editingLinearElement
|
||||||
...this.state.editingLinearElement,
|
? newLinearElementEditor
|
||||||
isDragging: true,
|
: null,
|
||||||
},
|
selectedLinearElement: newLinearElementEditor,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
if (!this.state.selectedLinearElement.isDragging) {
|
|
||||||
this.setState({
|
|
||||||
selectedLinearElement: {
|
|
||||||
...this.state.selectedLinearElement,
|
|
||||||
isDragging: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8422,145 +8418,55 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
|
|
||||||
pointerDownState.hit.hasBeenDuplicated = true;
|
pointerDownState.hit.hasBeenDuplicated = true;
|
||||||
|
|
||||||
const nextElements = [];
|
const elements = this.scene.getElementsIncludingDeleted();
|
||||||
const elementsToAppend = [];
|
|
||||||
const groupIdMap = new Map();
|
|
||||||
const oldIdToDuplicatedId = new Map();
|
|
||||||
const hitElement = pointerDownState.hit.element;
|
const hitElement = pointerDownState.hit.element;
|
||||||
const selectedElementIds = new Set(
|
const selectedElements = this.scene.getSelectedElements({
|
||||||
this.scene
|
|
||||||
.getSelectedElements({
|
|
||||||
selectedElementIds: this.state.selectedElementIds,
|
selectedElementIds: this.state.selectedElementIds,
|
||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
includeElementsInFrames: true,
|
includeElementsInFrames: true,
|
||||||
})
|
});
|
||||||
.map((element) => element.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
const elements = this.scene.getElementsIncludingDeleted();
|
|
||||||
|
|
||||||
for (const element of elements) {
|
|
||||||
const isInSelection =
|
|
||||||
selectedElementIds.has(element.id) ||
|
|
||||||
// case: the state.selectedElementIds might not have been
|
|
||||||
// updated yet by the time this mousemove event is fired
|
|
||||||
(element.id === hitElement?.id &&
|
|
||||||
pointerDownState.hit.wasAddedToSelection);
|
|
||||||
// NOTE (mtolmacs): This is a temporary fix for very large scenes
|
|
||||||
if (
|
if (
|
||||||
Math.abs(element.x) > 1e7 ||
|
hitElement &&
|
||||||
Math.abs(element.x) > 1e7 ||
|
!selectedElements.find((el) => el.id === hitElement.id)
|
||||||
Math.abs(element.width) > 1e7 ||
|
|
||||||
Math.abs(element.height) > 1e7
|
|
||||||
) {
|
) {
|
||||||
console.error(
|
selectedElements.push(hitElement);
|
||||||
`Alt+dragging element in scene with invalid dimensions`,
|
|
||||||
element.x,
|
|
||||||
element.y,
|
|
||||||
element.width,
|
|
||||||
element.height,
|
|
||||||
isInSelection,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInSelection) {
|
const { newElements: clonedElements, elementsWithClones } =
|
||||||
const duplicatedElement = duplicateElement(
|
duplicateElements({
|
||||||
this.state.editingGroupId,
|
type: "in-place",
|
||||||
groupIdMap,
|
elements,
|
||||||
element,
|
appState: this.state,
|
||||||
);
|
randomizeSeed: true,
|
||||||
|
idsOfElementsToDuplicate: new Map(
|
||||||
|
selectedElements.map((el) => [el.id, el]),
|
||||||
|
),
|
||||||
|
overrides: (el) => {
|
||||||
|
const origEl = pointerDownState.originalElements.get(el.id);
|
||||||
|
|
||||||
// NOTE (mtolmacs): This is a temporary fix for very large scenes
|
if (origEl) {
|
||||||
if (
|
return {
|
||||||
Math.abs(duplicatedElement.x) > 1e7 ||
|
x: origEl.x,
|
||||||
Math.abs(duplicatedElement.x) > 1e7 ||
|
y: origEl.y,
|
||||||
Math.abs(duplicatedElement.width) > 1e7 ||
|
};
|
||||||
Math.abs(duplicatedElement.height) > 1e7
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
`Alt+dragging duplicated element with invalid dimensions`,
|
|
||||||
duplicatedElement.x,
|
|
||||||
duplicatedElement.y,
|
|
||||||
duplicatedElement.width,
|
|
||||||
duplicatedElement.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const origElement = pointerDownState.originalElements.get(
|
return {};
|
||||||
element.id,
|
},
|
||||||
)!;
|
reverseOrder: true,
|
||||||
|
});
|
||||||
// NOTE (mtolmacs): This is a temporary fix for very large scenes
|
clonedElements.forEach((element) => {
|
||||||
if (
|
pointerDownState.originalElements.set(element.id, element);
|
||||||
Math.abs(origElement.x) > 1e7 ||
|
|
||||||
Math.abs(origElement.x) > 1e7 ||
|
|
||||||
Math.abs(origElement.width) > 1e7 ||
|
|
||||||
Math.abs(origElement.height) > 1e7
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
`Alt+dragging duplicated element with invalid dimensions`,
|
|
||||||
origElement.x,
|
|
||||||
origElement.y,
|
|
||||||
origElement.width,
|
|
||||||
origElement.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
mutateElement(duplicatedElement, {
|
|
||||||
x: origElement.x,
|
|
||||||
y: origElement.y,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// put duplicated element to pointerDownState.originalElements
|
|
||||||
// so that we can snap to the duplicated element without releasing
|
|
||||||
pointerDownState.originalElements.set(
|
|
||||||
duplicatedElement.id,
|
|
||||||
duplicatedElement,
|
|
||||||
);
|
|
||||||
|
|
||||||
nextElements.push(duplicatedElement);
|
|
||||||
elementsToAppend.push(element);
|
|
||||||
oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
|
|
||||||
} else {
|
|
||||||
nextElements.push(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextSceneElements: ExcalidrawElement[] = [
|
|
||||||
...nextElements,
|
|
||||||
...elementsToAppend,
|
|
||||||
];
|
|
||||||
|
|
||||||
const mappedNewSceneElements = this.props.onDuplicate?.(
|
const mappedNewSceneElements = this.props.onDuplicate?.(
|
||||||
nextSceneElements,
|
elementsWithClones,
|
||||||
elements,
|
elements,
|
||||||
);
|
);
|
||||||
|
|
||||||
nextSceneElements = mappedNewSceneElements || nextSceneElements;
|
const nextSceneElements = syncMovedIndices(
|
||||||
|
mappedNewSceneElements || elementsWithClones,
|
||||||
syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
|
arrayToMap(clonedElements),
|
||||||
|
|
||||||
bindTextToShapeAfterDuplication(
|
|
||||||
nextElements,
|
|
||||||
elementsToAppend,
|
|
||||||
oldIdToDuplicatedId,
|
|
||||||
);
|
|
||||||
fixBindingsAfterDuplication(
|
|
||||||
nextSceneElements,
|
|
||||||
elementsToAppend,
|
|
||||||
oldIdToDuplicatedId,
|
|
||||||
"duplicatesServeAsOld",
|
|
||||||
);
|
|
||||||
bindElementsToFramesAfterDuplication(
|
|
||||||
nextSceneElements,
|
|
||||||
elementsToAppend,
|
|
||||||
oldIdToDuplicatedId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.scene.replaceAllElements(nextSceneElements);
|
this.scene.replaceAllElements(nextSceneElements);
|
||||||
|
|
|
@ -8,18 +8,20 @@ import React, {
|
||||||
|
|
||||||
import { MIME_TYPES } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import { serializeLibraryAsJSON } from "../data/json";
|
import { serializeLibraryAsJSON } from "../data/json";
|
||||||
import { duplicateElements } from "../element/newElement";
|
|
||||||
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
|
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
|
||||||
import { useScrollPosition } from "../hooks/useScrollPosition";
|
import { useScrollPosition } from "../hooks/useScrollPosition";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
|
import { duplicateElements } from "../element/duplicate";
|
||||||
|
|
||||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||||
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
|
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
|
||||||
import {
|
import {
|
||||||
LibraryMenuSection,
|
LibraryMenuSection,
|
||||||
LibraryMenuSectionGrid,
|
LibraryMenuSectionGrid,
|
||||||
} from "./LibraryMenuSection";
|
} from "./LibraryMenuSection";
|
||||||
|
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
|
|
||||||
|
@ -160,7 +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({
|
||||||
|
type: "everything",
|
||||||
|
elements: item.elements,
|
||||||
|
randomizeSeed: true,
|
||||||
|
}).newElements,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { deepCopyElement } from "@excalidraw/excalidraw/element/duplicate";
|
||||||
|
|
||||||
import { EVENT } from "../../constants";
|
import { EVENT } from "../../constants";
|
||||||
import { deepCopyElement } from "../../element/newElement";
|
|
||||||
import { KEYS } from "../../keys";
|
import { KEYS } from "../../keys";
|
||||||
import { CaptureUpdateAction } from "../../store";
|
import { CaptureUpdateAction } from "../../store";
|
||||||
import { cloneJSON } from "../../utils";
|
import { cloneJSON } from "../../utils";
|
||||||
|
|
|
@ -13,7 +13,6 @@ import {
|
||||||
vectorCross,
|
vectorCross,
|
||||||
pointsEqual,
|
pointsEqual,
|
||||||
lineSegmentIntersectionPoints,
|
lineSegmentIntersectionPoints,
|
||||||
round,
|
|
||||||
PRECISION,
|
PRECISION,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { isPointOnShape } from "@excalidraw/utils/collision";
|
import { isPointOnShape } from "@excalidraw/utils/collision";
|
||||||
|
@ -24,7 +23,10 @@ import { KEYS } from "../keys";
|
||||||
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
|
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
|
||||||
import {
|
import {
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
|
invariant,
|
||||||
isBindingFallthroughEnabled,
|
isBindingFallthroughEnabled,
|
||||||
|
isDevEnv,
|
||||||
|
isTestEnv,
|
||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
|
||||||
|
@ -36,11 +38,8 @@ import {
|
||||||
import { intersectElementWithLineSegment } from "./collision";
|
import { intersectElementWithLineSegment } from "./collision";
|
||||||
import { distanceToBindableElement } from "./distance";
|
import { distanceToBindableElement } from "./distance";
|
||||||
import {
|
import {
|
||||||
compareHeading,
|
|
||||||
HEADING_DOWN,
|
|
||||||
HEADING_RIGHT,
|
|
||||||
HEADING_UP,
|
|
||||||
headingForPointFromElement,
|
headingForPointFromElement,
|
||||||
|
headingIsHorizontal,
|
||||||
vectorToHeading,
|
vectorToHeading,
|
||||||
type Heading,
|
type Heading,
|
||||||
} from "./heading";
|
} from "./heading";
|
||||||
|
@ -50,7 +49,6 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isBindableElement,
|
isBindableElement,
|
||||||
isBindingElement,
|
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFixedPointBinding,
|
isFixedPointBinding,
|
||||||
|
@ -60,6 +58,10 @@ import {
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
|
|
||||||
|
import type { Mutable } from "../utility-types";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import type { ElementUpdate } from "./mutateElement";
|
import type { ElementUpdate } from "./mutateElement";
|
||||||
import type {
|
import type {
|
||||||
|
@ -837,14 +839,19 @@ export const updateBoundElements = (
|
||||||
}> => update !== null,
|
}> => update !== null,
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(element, updates, {
|
LinearElementEditor.movePoints(
|
||||||
|
element,
|
||||||
|
updates,
|
||||||
|
{
|
||||||
...(changedElement.id === element.startBinding?.elementId
|
...(changedElement.id === element.startBinding?.elementId
|
||||||
? { startBinding: bindings.startBinding }
|
? { startBinding: bindings.startBinding }
|
||||||
: {}),
|
: {}),
|
||||||
...(changedElement.id === element.endBinding?.elementId
|
...(changedElement.id === element.endBinding?.elementId
|
||||||
? { endBinding: bindings.endBinding }
|
? { endBinding: bindings.endBinding }
|
||||||
: {}),
|
: {}),
|
||||||
});
|
},
|
||||||
|
elementsMap as NonDeletedSceneElementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
const boundText = getBoundTextElement(element, elementsMap);
|
const boundText = getBoundTextElement(element, elementsMap);
|
||||||
if (boundText && !boundText.isDeleted) {
|
if (boundText && !boundText.isDeleted) {
|
||||||
|
@ -925,103 +932,104 @@ const getDistanceForBinding = (
|
||||||
|
|
||||||
export const bindPointToSnapToElementOutline = (
|
export const bindPointToSnapToElementOutline = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
bindableElement: ExcalidrawBindableElement | undefined,
|
bindableElement: ExcalidrawBindableElement,
|
||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
const aabb = bindableElement && aabbForElement(bindableElement);
|
if (isDevEnv() || isTestEnv()) {
|
||||||
|
invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
|
||||||
|
}
|
||||||
|
|
||||||
|
const aabb = aabbForElement(bindableElement);
|
||||||
const localP =
|
const localP =
|
||||||
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
|
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
|
||||||
const globalP = pointFrom<GlobalPoint>(
|
const globalP = pointFrom<GlobalPoint>(
|
||||||
arrow.x + localP[0],
|
arrow.x + localP[0],
|
||||||
arrow.y + localP[1],
|
arrow.y + localP[1],
|
||||||
);
|
);
|
||||||
const p = isRectanguloidElement(bindableElement)
|
const edgePoint = isRectanguloidElement(bindableElement)
|
||||||
? avoidRectangularCorner(bindableElement, globalP)
|
? avoidRectangularCorner(bindableElement, globalP)
|
||||||
: globalP;
|
: globalP;
|
||||||
|
const elbowed = isElbowArrow(arrow);
|
||||||
if (bindableElement && aabb) {
|
|
||||||
const center = getCenterForBounds(aabb);
|
const center = getCenterForBounds(aabb);
|
||||||
|
const adjacentPointIdx = startOrEnd === "start" ? 1 : arrow.points.length - 2;
|
||||||
const intersection = intersectElementWithLineSegment(
|
const adjacentPoint = pointRotateRads(
|
||||||
bindableElement,
|
pointFrom<GlobalPoint>(
|
||||||
lineSegment(
|
arrow.x + arrow.points[adjacentPointIdx][0],
|
||||||
center,
|
arrow.y + arrow.points[adjacentPointIdx][1],
|
||||||
pointFromVector(
|
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(vectorFromPoint(p, center)),
|
|
||||||
Math.max(bindableElement.width, bindableElement.height),
|
|
||||||
),
|
),
|
||||||
center,
|
center,
|
||||||
|
arrow.angle ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
let intersection: GlobalPoint | null = null;
|
||||||
|
if (elbowed) {
|
||||||
|
const isHorizontal = headingIsHorizontal(
|
||||||
|
headingForPointFromElement(bindableElement, aabb, globalP),
|
||||||
|
);
|
||||||
|
const otherPoint = pointFrom<GlobalPoint>(
|
||||||
|
isHorizontal ? center[0] : edgePoint[0],
|
||||||
|
!isHorizontal ? center[1] : edgePoint[1],
|
||||||
|
);
|
||||||
|
intersection = intersectElementWithLineSegment(
|
||||||
|
bindableElement,
|
||||||
|
lineSegment(
|
||||||
|
otherPoint,
|
||||||
|
pointFromVector(
|
||||||
|
vectorScale(
|
||||||
|
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
|
||||||
|
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||||
|
),
|
||||||
|
otherPoint,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)[0];
|
)[0];
|
||||||
const currentDistance = pointDistance(p, center);
|
} else {
|
||||||
const fullDistance = Math.max(
|
intersection = intersectElementWithLineSegment(
|
||||||
pointDistance(intersection ?? p, center),
|
bindableElement,
|
||||||
PRECISION,
|
lineSegment(
|
||||||
);
|
adjacentPoint,
|
||||||
const ratio = round(currentDistance / fullDistance, 6);
|
pointFromVector(
|
||||||
|
vectorScale(
|
||||||
switch (true) {
|
vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)),
|
||||||
case ratio > 0.9:
|
pointDistance(edgePoint, adjacentPoint) +
|
||||||
if (
|
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||||
currentDistance - fullDistance > FIXED_BINDING_DISTANCE ||
|
),
|
||||||
// Too close to determine vector from intersection to p
|
adjacentPoint,
|
||||||
pointDistanceSq(p, intersection) < PRECISION
|
),
|
||||||
) {
|
),
|
||||||
return p;
|
FIXED_BINDING_DISTANCE,
|
||||||
|
).sort(
|
||||||
|
(g, h) =>
|
||||||
|
pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint),
|
||||||
|
)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!intersection ||
|
||||||
|
// Too close to determine vector from intersection to edgePoint
|
||||||
|
pointDistanceSq(edgePoint, intersection) < PRECISION
|
||||||
|
) {
|
||||||
|
return edgePoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elbowed) {
|
||||||
|
const scalar =
|
||||||
|
pointDistanceSq(edgePoint, center) -
|
||||||
|
pointDistanceSq(intersection, center) >
|
||||||
|
0
|
||||||
|
? FIXED_BINDING_DISTANCE
|
||||||
|
: -FIXED_BINDING_DISTANCE;
|
||||||
|
|
||||||
return pointFromVector(
|
return pointFromVector(
|
||||||
vectorScale(
|
vectorScale(
|
||||||
vectorNormalize(vectorFromPoint(p, intersection ?? center)),
|
vectorNormalize(vectorFromPoint(edgePoint, intersection)),
|
||||||
ratio > 1 ? FIXED_BINDING_DISTANCE : -FIXED_BINDING_DISTANCE,
|
scalar,
|
||||||
),
|
),
|
||||||
intersection ?? center,
|
intersection,
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return headingToMidBindPoint(p, bindableElement, aabb);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return p;
|
|
||||||
};
|
|
||||||
|
|
||||||
const headingToMidBindPoint = (
|
|
||||||
p: GlobalPoint,
|
|
||||||
bindableElement: ExcalidrawBindableElement,
|
|
||||||
aabb: Bounds,
|
|
||||||
): GlobalPoint => {
|
|
||||||
const center = getCenterForBounds(aabb);
|
|
||||||
const heading = vectorToHeading(vectorFromPoint(p, center));
|
|
||||||
|
|
||||||
switch (true) {
|
|
||||||
case compareHeading(heading, HEADING_UP):
|
|
||||||
return pointRotateRads(
|
|
||||||
pointFrom((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
|
|
||||||
center,
|
|
||||||
bindableElement.angle,
|
|
||||||
);
|
|
||||||
case compareHeading(heading, HEADING_RIGHT):
|
|
||||||
return pointRotateRads(
|
|
||||||
pointFrom(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
|
|
||||||
center,
|
|
||||||
bindableElement.angle,
|
|
||||||
);
|
|
||||||
case compareHeading(heading, HEADING_DOWN):
|
|
||||||
return pointRotateRads(
|
|
||||||
pointFrom((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
|
|
||||||
center,
|
|
||||||
bindableElement.angle,
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return pointRotateRads(
|
|
||||||
pointFrom(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
|
|
||||||
center,
|
|
||||||
bindableElement.angle,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return edgePoint;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const avoidRectangularCorner = (
|
export const avoidRectangularCorner = (
|
||||||
|
@ -1411,107 +1419,75 @@ const getLinearElementEdgeCoors = (
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// We need to:
|
|
||||||
// 1: Update elements not selected to point to duplicated elements
|
|
||||||
// 2: Update duplicated elements to point to other duplicated elements
|
|
||||||
export const fixBindingsAfterDuplication = (
|
export const fixBindingsAfterDuplication = (
|
||||||
sceneElements: readonly ExcalidrawElement[],
|
newElements: ExcalidrawElement[],
|
||||||
oldElements: readonly ExcalidrawElement[],
|
|
||||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||||
// There are three copying mechanisms: Copy-paste, duplication and alt-drag.
|
duplicatedElementsMap: NonDeletedSceneElementsMap,
|
||||||
// Only when alt-dragging the new "duplicates" act as the "old", while
|
) => {
|
||||||
// the "old" elements act as the "new copy" - essentially working reverse
|
for (const element of newElements) {
|
||||||
// to the other two.
|
if ("boundElements" in element && element.boundElements) {
|
||||||
duplicatesServeAsOld?: "duplicatesServeAsOld" | undefined,
|
Object.assign(element, {
|
||||||
): void => {
|
boundElements: element.boundElements.reduce(
|
||||||
// First collect all the binding/bindable elements, so we only update
|
|
||||||
// each once, regardless of whether they were duplicated or not.
|
|
||||||
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
|
||||||
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
|
||||||
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
|
|
||||||
const duplicateIdToOldId = new Map(
|
|
||||||
[...oldIdToDuplicatedId].map(([key, value]) => [value, key]),
|
|
||||||
);
|
|
||||||
oldElements.forEach((oldElement) => {
|
|
||||||
const { boundElements } = oldElement;
|
|
||||||
if (boundElements != null && boundElements.length > 0) {
|
|
||||||
boundElements.forEach((boundElement) => {
|
|
||||||
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElement.id)) {
|
|
||||||
allBoundElementIds.add(boundElement.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
|
|
||||||
}
|
|
||||||
if (isBindingElement(oldElement)) {
|
|
||||||
if (oldElement.startBinding != null) {
|
|
||||||
const { elementId } = oldElement.startBinding;
|
|
||||||
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
|
|
||||||
allBindableElementIds.add(elementId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (oldElement.endBinding != null) {
|
|
||||||
const { elementId } = oldElement.endBinding;
|
|
||||||
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
|
|
||||||
allBindableElementIds.add(elementId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (oldElement.startBinding != null || oldElement.endBinding != null) {
|
|
||||||
allBoundElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the linear elements
|
|
||||||
(
|
(
|
||||||
sceneElements.filter(({ id }) =>
|
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
|
||||||
allBoundElementIds.has(id),
|
binding,
|
||||||
) as ExcalidrawLinearElement[]
|
) => {
|
||||||
).forEach((element) => {
|
const newBindingId = oldIdToDuplicatedId.get(binding.id);
|
||||||
const { startBinding, endBinding } = element;
|
if (newBindingId) {
|
||||||
mutateElement(element, {
|
acc.push({ ...binding, id: newBindingId });
|
||||||
startBinding: newBindingAfterDuplication(
|
}
|
||||||
startBinding,
|
return acc;
|
||||||
oldIdToDuplicatedId,
|
},
|
||||||
|
[],
|
||||||
),
|
),
|
||||||
endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId),
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("containerId" in element && element.containerId) {
|
||||||
|
Object.assign(element, {
|
||||||
|
containerId: oldIdToDuplicatedId.get(element.containerId) ?? null,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update the bindable shapes
|
if ("endBinding" in element && element.endBinding) {
|
||||||
sceneElements
|
const newEndBindingId = oldIdToDuplicatedId.get(
|
||||||
.filter(({ id }) => allBindableElementIds.has(id))
|
element.endBinding.elementId,
|
||||||
.forEach((bindableElement) => {
|
);
|
||||||
const oldElementId = duplicateIdToOldId.get(bindableElement.id);
|
Object.assign(element, {
|
||||||
const boundElements = sceneElements.find(
|
endBinding: newEndBindingId
|
||||||
({ id }) => id === oldElementId,
|
|
||||||
)?.boundElements;
|
|
||||||
|
|
||||||
if (boundElements && boundElements.length > 0) {
|
|
||||||
mutateElement(bindableElement, {
|
|
||||||
boundElements: boundElements.map((boundElement) =>
|
|
||||||
oldIdToDuplicatedId.has(boundElement.id)
|
|
||||||
? {
|
? {
|
||||||
id: oldIdToDuplicatedId.get(boundElement.id)!,
|
...element.endBinding,
|
||||||
type: boundElement.type,
|
elementId: newEndBindingId,
|
||||||
}
|
}
|
||||||
: boundElement,
|
: null,
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if ("startBinding" in element && element.startBinding) {
|
||||||
|
const newEndBindingId = oldIdToDuplicatedId.get(
|
||||||
|
element.startBinding.elementId,
|
||||||
|
);
|
||||||
|
Object.assign(element, {
|
||||||
|
startBinding: newEndBindingId
|
||||||
|
? {
|
||||||
|
...element.startBinding,
|
||||||
|
elementId: newEndBindingId,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
const newBindingAfterDuplication = (
|
if (isElbowArrow(element)) {
|
||||||
binding: PointBinding | null,
|
Object.assign(
|
||||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
element,
|
||||||
): PointBinding | null => {
|
updateElbowArrowPoints(element, duplicatedElementsMap, {
|
||||||
if (binding == null) {
|
points: [
|
||||||
return null;
|
element.points[0],
|
||||||
|
element.points[element.points.length - 1],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
...binding,
|
|
||||||
elementId: oldIdToDuplicatedId.get(binding.elementId) ?? binding.elementId,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fixBindingsAfterDeletion = (
|
export const fixBindingsAfterDeletion = (
|
||||||
|
|
|
@ -1,16 +1,32 @@
|
||||||
|
import React from "react";
|
||||||
import { pointFrom } from "@excalidraw/math";
|
import { pointFrom } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import { FONT_FAMILY, ROUNDNESS } from "../constants";
|
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,
|
||||||
|
getCloneByOrigId,
|
||||||
|
render,
|
||||||
|
} from "../tests/test-utils";
|
||||||
|
import { Excalidraw } from "..";
|
||||||
|
import { actionDuplicateSelection } from "../actions";
|
||||||
|
|
||||||
|
import { Keyboard, Pointer } from "../tests/helpers/ui";
|
||||||
|
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { duplicateElement, duplicateElements } from "./newElement";
|
|
||||||
|
import { duplicateElement, duplicateElements } from "./duplicate";
|
||||||
|
|
||||||
import type { ExcalidrawLinearElement } from "./types";
|
import type { ExcalidrawLinearElement } from "./types";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
const mouse = new Pointer("mouse");
|
||||||
|
|
||||||
const assertCloneObjects = (source: any, clone: any) => {
|
const assertCloneObjects = (source: any, clone: any) => {
|
||||||
for (const key in clone) {
|
for (const key in clone) {
|
||||||
if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) {
|
if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) {
|
||||||
|
@ -45,7 +61,7 @@ describe("duplicating single elements", () => {
|
||||||
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
|
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
|
||||||
});
|
});
|
||||||
|
|
||||||
const copy = duplicateElement(null, new Map(), element);
|
const copy = duplicateElement(null, new Map(), element, undefined, true);
|
||||||
|
|
||||||
assertCloneObjects(element, copy);
|
assertCloneObjects(element, copy);
|
||||||
|
|
||||||
|
@ -64,6 +80,8 @@ describe("duplicating single elements", () => {
|
||||||
...element,
|
...element,
|
||||||
id: copy.id,
|
id: copy.id,
|
||||||
seed: copy.seed,
|
seed: copy.seed,
|
||||||
|
version: copy.version,
|
||||||
|
versionNonce: copy.versionNonce,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -149,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 clonedElements = duplicateElements(origElements);
|
const { newElements: clonedElements } = duplicateElements({
|
||||||
|
type: "everything",
|
||||||
|
elements: origElements,
|
||||||
|
});
|
||||||
|
|
||||||
// generic id in-equality checks
|
// generic id in-equality checks
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
@ -206,6 +227,7 @@ describe("duplicating multiple elements", () => {
|
||||||
type: clonedText1.type,
|
type: clonedText1.type,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(clonedRectangle.type).toBe("rectangle");
|
||||||
|
|
||||||
clonedArrows.forEach((arrow) => {
|
clonedArrows.forEach((arrow) => {
|
||||||
expect(
|
expect(
|
||||||
|
@ -281,7 +303,7 @@ describe("duplicating multiple elements", () => {
|
||||||
|
|
||||||
const arrow3 = API.createElement({
|
const arrow3 = API.createElement({
|
||||||
type: "arrow",
|
type: "arrow",
|
||||||
id: "arrow2",
|
id: "arrow3",
|
||||||
startBinding: {
|
startBinding: {
|
||||||
elementId: "rectangle-not-exists",
|
elementId: "rectangle-not-exists",
|
||||||
focus: 0.2,
|
focus: 0.2,
|
||||||
|
@ -299,9 +321,11 @@ describe("duplicating multiple elements", () => {
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
|
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
|
||||||
const clonedElements = duplicateElements(
|
const { newElements: clonedElements } = duplicateElements({
|
||||||
origElements,
|
type: "everything",
|
||||||
) as any as typeof origElements;
|
elements: origElements,
|
||||||
|
}) as any as { newElements: typeof origElements };
|
||||||
|
|
||||||
const [
|
const [
|
||||||
clonedRectangle,
|
clonedRectangle,
|
||||||
clonedText1,
|
clonedText1,
|
||||||
|
@ -321,7 +345,6 @@ describe("duplicating multiple elements", () => {
|
||||||
elementId: clonedRectangle.id,
|
elementId: clonedRectangle.id,
|
||||||
});
|
});
|
||||||
expect(clonedArrow2.endBinding).toBe(null);
|
expect(clonedArrow2.endBinding).toBe(null);
|
||||||
|
|
||||||
expect(clonedArrow3.startBinding).toBe(null);
|
expect(clonedArrow3.startBinding).toBe(null);
|
||||||
expect(clonedArrow3.endBinding).toEqual({
|
expect(clonedArrow3.endBinding).toEqual({
|
||||||
...arrow3.endBinding,
|
...arrow3.endBinding,
|
||||||
|
@ -345,9 +368,10 @@ describe("duplicating multiple elements", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const origElements = [rectangle1, rectangle2, rectangle3] as const;
|
const origElements = [rectangle1, rectangle2, rectangle3] as const;
|
||||||
const clonedElements = duplicateElements(
|
const { newElements: clonedElements } = duplicateElements({
|
||||||
origElements,
|
type: "everything",
|
||||||
) as any as typeof origElements;
|
elements: origElements,
|
||||||
|
}) as any as { newElements: typeof origElements };
|
||||||
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
|
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
|
||||||
clonedElements;
|
clonedElements;
|
||||||
|
|
||||||
|
@ -368,10 +392,305 @@ describe("duplicating multiple elements", () => {
|
||||||
groupIds: ["g1"],
|
groupIds: ["g1"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [clonedRectangle1] = duplicateElements([rectangle1]);
|
const {
|
||||||
|
newElements: [clonedRectangle1],
|
||||||
|
} = 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]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("duplication z-order", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await render(<Excalidraw />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplication z order with Cmd+D for the lowest z-ordered element should be +1 for the clone", () => {
|
||||||
|
const rectangle1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const rectangle2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
});
|
||||||
|
const rectangle3 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 20,
|
||||||
|
y: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle1, rectangle2, rectangle3]);
|
||||||
|
API.setSelectedElements([rectangle1]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: rectangle1.id },
|
||||||
|
{ [ORIG_ID]: rectangle1.id, selected: true },
|
||||||
|
{ id: rectangle2.id },
|
||||||
|
{ id: rectangle3.id },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplication z order with Cmd+D for the highest z-ordered element should be +1 for the clone", () => {
|
||||||
|
const rectangle1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const rectangle2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
});
|
||||||
|
const rectangle3 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 20,
|
||||||
|
y: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle1, rectangle2, rectangle3]);
|
||||||
|
API.setSelectedElements([rectangle3]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: rectangle1.id },
|
||||||
|
{ id: rectangle2.id },
|
||||||
|
{ id: rectangle3.id },
|
||||||
|
{ [ORIG_ID]: rectangle3.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplication z order with alt+drag for the lowest z-ordered element should be +1 for the clone", () => {
|
||||||
|
const rectangle1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const rectangle2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
});
|
||||||
|
const rectangle3 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 20,
|
||||||
|
y: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle1, rectangle2, rectangle3]);
|
||||||
|
|
||||||
|
mouse.select(rectangle1);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
|
||||||
|
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ [ORIG_ID]: rectangle1.id },
|
||||||
|
{ id: rectangle1.id, selected: true },
|
||||||
|
{ id: rectangle2.id },
|
||||||
|
{ id: rectangle3.id },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplication z order with alt+drag for the highest z-ordered element should be +1 for the clone", () => {
|
||||||
|
const rectangle1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const rectangle2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
});
|
||||||
|
const rectangle3 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 20,
|
||||||
|
y: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle1, rectangle2, rectangle3]);
|
||||||
|
|
||||||
|
mouse.select(rectangle3);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
mouse.down(rectangle3.x + 5, rectangle3.y + 5);
|
||||||
|
mouse.up(rectangle3.x + 5, rectangle3.y + 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: rectangle1.id },
|
||||||
|
{ id: rectangle2.id },
|
||||||
|
{ [ORIG_ID]: rectangle3.id },
|
||||||
|
{ id: rectangle3.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplication z order with alt+drag for the lowest z-ordered element should be +1 for the clone", () => {
|
||||||
|
const rectangle1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const rectangle2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
});
|
||||||
|
const rectangle3 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 20,
|
||||||
|
y: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle1, rectangle2, rectangle3]);
|
||||||
|
|
||||||
|
mouse.select(rectangle1);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
|
||||||
|
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ [ORIG_ID]: rectangle1.id },
|
||||||
|
{ id: rectangle1.id, selected: true },
|
||||||
|
{ id: rectangle2.id },
|
||||||
|
{ id: rectangle3.id },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplication z order with alt+drag with grouped elements should consider the group together when determining z-index", () => {
|
||||||
|
const rectangle1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
groupIds: ["group1"],
|
||||||
|
});
|
||||||
|
const rectangle2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
groupIds: ["group1"],
|
||||||
|
});
|
||||||
|
const rectangle3 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 20,
|
||||||
|
y: 20,
|
||||||
|
groupIds: ["group1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle1, rectangle2, rectangle3]);
|
||||||
|
|
||||||
|
mouse.select(rectangle1);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
|
||||||
|
mouse.up(rectangle1.x + 15, rectangle1.y + 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ [ORIG_ID]: rectangle1.id },
|
||||||
|
{ [ORIG_ID]: rectangle2.id },
|
||||||
|
{ [ORIG_ID]: rectangle3.id },
|
||||||
|
{ id: rectangle1.id, selected: true },
|
||||||
|
{ id: rectangle2.id, selected: true },
|
||||||
|
{ id: rectangle3.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverse-duplicating text container (in-order)", async () => {
|
||||||
|
const [rectangle, text] = API.createTextContainer();
|
||||||
|
API.setElements([rectangle, text]);
|
||||||
|
API.setSelectedElements([rectangle, text]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
||||||
|
mouse.up(rectangle.x + 15, rectangle.y + 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ [ORIG_ID]: rectangle.id },
|
||||||
|
{
|
||||||
|
[ORIG_ID]: text.id,
|
||||||
|
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||||
|
},
|
||||||
|
{ id: rectangle.id, selected: true },
|
||||||
|
{ id: text.id, containerId: rectangle.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverse-duplicating text container (out-of-order)", async () => {
|
||||||
|
const [rectangle, text] = API.createTextContainer();
|
||||||
|
API.setElements([text, rectangle]);
|
||||||
|
API.setSelectedElements([rectangle, text]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
||||||
|
mouse.up(rectangle.x + 15, rectangle.y + 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ [ORIG_ID]: rectangle.id },
|
||||||
|
{
|
||||||
|
[ORIG_ID]: text.id,
|
||||||
|
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||||
|
},
|
||||||
|
{ id: rectangle.id, selected: true },
|
||||||
|
{ id: text.id, containerId: rectangle.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverse-duplicating labeled arrows (in-order)", async () => {
|
||||||
|
const [arrow, text] = API.createLabeledArrow();
|
||||||
|
|
||||||
|
API.setElements([arrow, text]);
|
||||||
|
API.setSelectedElements([arrow, text]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
mouse.down(arrow.x + 5, arrow.y + 5);
|
||||||
|
mouse.up(arrow.x + 15, arrow.y + 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ [ORIG_ID]: arrow.id },
|
||||||
|
{
|
||||||
|
[ORIG_ID]: text.id,
|
||||||
|
containerId: getCloneByOrigId(arrow.id)?.id,
|
||||||
|
},
|
||||||
|
{ id: arrow.id, selected: true },
|
||||||
|
{ id: text.id, containerId: arrow.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverse-duplicating labeled arrows (out-of-order)", async () => {
|
||||||
|
const [arrow, text] = API.createLabeledArrow();
|
||||||
|
|
||||||
|
API.setElements([text, arrow]);
|
||||||
|
API.setSelectedElements([arrow, text]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
mouse.down(arrow.x + 5, arrow.y + 5);
|
||||||
|
mouse.up(arrow.x + 15, arrow.y + 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ [ORIG_ID]: arrow.id },
|
||||||
|
{
|
||||||
|
[ORIG_ID]: text.id,
|
||||||
|
containerId: getCloneByOrigId(arrow.id)?.id,
|
||||||
|
},
|
||||||
|
{ id: arrow.id, selected: true },
|
||||||
|
{ id: text.id, containerId: arrow.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
484
packages/excalidraw/element/duplicate.ts
Normal file
484
packages/excalidraw/element/duplicate.ts
Normal file
|
@ -0,0 +1,484 @@
|
||||||
|
import { ORIG_ID } from "../constants";
|
||||||
|
import {
|
||||||
|
getElementsInGroup,
|
||||||
|
getNewGroupIdsForDuplication,
|
||||||
|
getSelectedGroupForElement,
|
||||||
|
} from "../groups";
|
||||||
|
|
||||||
|
import { randomId, randomInteger } from "../random";
|
||||||
|
|
||||||
|
import {
|
||||||
|
arrayToMap,
|
||||||
|
castArray,
|
||||||
|
findLastIndex,
|
||||||
|
getUpdatedTimestamp,
|
||||||
|
isTestEnv,
|
||||||
|
} from "../utils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
bindElementsToFramesAfterDuplication,
|
||||||
|
getFrameChildren,
|
||||||
|
} from "../frame";
|
||||||
|
|
||||||
|
import { normalizeElementOrder } from "./sortElements";
|
||||||
|
|
||||||
|
import { bumpVersion } from "./mutateElement";
|
||||||
|
|
||||||
|
import {
|
||||||
|
hasBoundTextElement,
|
||||||
|
isBoundToContainer,
|
||||||
|
isFrameLikeElement,
|
||||||
|
} from "./typeChecks";
|
||||||
|
|
||||||
|
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||||
|
|
||||||
|
import { fixBindingsAfterDuplication } from "./binding";
|
||||||
|
|
||||||
|
import type { AppState } from "../types";
|
||||||
|
import type { Mutable } from "../utility-types";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawElement,
|
||||||
|
GroupId,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate an element, often used in the alt-drag operation.
|
||||||
|
* Note that this method has gotten a bit complicated since the
|
||||||
|
* introduction of gruoping/ungrouping elements.
|
||||||
|
* @param editingGroupId The current group being edited. The new
|
||||||
|
* element will inherit this group and its
|
||||||
|
* parents.
|
||||||
|
* @param groupIdMapForOperation A Map that maps old group IDs to
|
||||||
|
* duplicated ones. If you are duplicating
|
||||||
|
* multiple elements at once, share this map
|
||||||
|
* amongst all of them
|
||||||
|
* @param element Element to duplicate
|
||||||
|
* @param overrides Any element properties to override
|
||||||
|
*/
|
||||||
|
export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||||
|
editingGroupId: AppState["editingGroupId"],
|
||||||
|
groupIdMapForOperation: Map<GroupId, GroupId>,
|
||||||
|
element: TElement,
|
||||||
|
overrides?: Partial<TElement>,
|
||||||
|
randomizeSeed?: boolean,
|
||||||
|
): Readonly<TElement> => {
|
||||||
|
let copy = deepCopyElement(element);
|
||||||
|
|
||||||
|
if (isTestEnv()) {
|
||||||
|
__test__defineOrigId(copy, element.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
copy.id = randomId();
|
||||||
|
copy.updated = getUpdatedTimestamp();
|
||||||
|
if (randomizeSeed) {
|
||||||
|
copy.seed = randomInteger();
|
||||||
|
bumpVersion(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
copy.groupIds = getNewGroupIdsForDuplication(
|
||||||
|
copy.groupIds,
|
||||||
|
editingGroupId,
|
||||||
|
(groupId) => {
|
||||||
|
if (!groupIdMapForOperation.has(groupId)) {
|
||||||
|
groupIdMapForOperation.set(groupId, randomId());
|
||||||
|
}
|
||||||
|
return groupIdMapForOperation.get(groupId)!;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (overrides) {
|
||||||
|
copy = Object.assign(copy, overrides);
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const duplicateElements = (
|
||||||
|
opts: {
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
randomizeSeed?: boolean;
|
||||||
|
overrides?: (
|
||||||
|
originalElement: ExcalidrawElement,
|
||||||
|
) => Partial<ExcalidrawElement>;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
// 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 groupIdMap = new Map();
|
||||||
|
const newElements: ExcalidrawElement[] = [];
|
||||||
|
const oldElements: ExcalidrawElement[] = [];
|
||||||
|
const oldIdToDuplicatedId = new Map();
|
||||||
|
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||||
|
const elementsMap = arrayToMap(elements) as ElementsMap;
|
||||||
|
const _idsOfElementsToDuplicate =
|
||||||
|
opts.type === "in-place"
|
||||||
|
? opts.idsOfElementsToDuplicate
|
||||||
|
: new Map(elements.map((el) => [el.id, el]));
|
||||||
|
|
||||||
|
// For sanity
|
||||||
|
if (opts.type === "in-place") {
|
||||||
|
for (const groupId of Object.keys(opts.appState.selectedGroupIds)) {
|
||||||
|
elements
|
||||||
|
.filter((el) => el.groupIds?.includes(groupId))
|
||||||
|
.forEach((el) => _idsOfElementsToDuplicate.set(el.id, el));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elements = normalizeElementOrder(elements);
|
||||||
|
|
||||||
|
const elementsWithClones: ExcalidrawElement[] = elements.slice();
|
||||||
|
|
||||||
|
// helper functions
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Used for the heavy lifing of copying a single element, a group of elements
|
||||||
|
// an element with bound text etc.
|
||||||
|
const copyElements = <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,
|
||||||
|
opts.overrides?.(element),
|
||||||
|
opts.randomizeSeed,
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to position cloned elements in the Z-order the product needs it
|
||||||
|
const insertBeforeOrAfterIndex = (
|
||||||
|
index: number,
|
||||||
|
elements: ExcalidrawElement | null | ExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
if (!elements) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reverseOrder && index < 1) {
|
||||||
|
elementsWithClones.unshift(...castArray(elements));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reverseOrder && index > elementsWithClones.length - 1) {
|
||||||
|
elementsWithClones.push(...castArray(elements));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
elementsWithClones.splice(
|
||||||
|
index + (reverseOrder ? 0 : 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 = reverseOrder
|
||||||
|
? elementsWithClones.findIndex((el) => {
|
||||||
|
return el.groupIds?.includes(groupId);
|
||||||
|
})
|
||||||
|
: findLastIndex(elementsWithClones, (el) => {
|
||||||
|
return el.groupIds?.includes(groupId);
|
||||||
|
});
|
||||||
|
|
||||||
|
insertBeforeOrAfterIndex(targetIndex, copyElements(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;
|
||||||
|
});
|
||||||
|
|
||||||
|
insertBeforeOrAfterIndex(
|
||||||
|
targetIndex,
|
||||||
|
copyElements([...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) {
|
||||||
|
insertBeforeOrAfterIndex(
|
||||||
|
targetIndex + (reverseOrder ? -1 : 0),
|
||||||
|
copyElements([element, boundTextElement]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
insertBeforeOrAfterIndex(targetIndex, copyElements(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) {
|
||||||
|
insertBeforeOrAfterIndex(
|
||||||
|
targetIndex,
|
||||||
|
copyElements([container, element]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// default duplication (regular elements)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
insertBeforeOrAfterIndex(
|
||||||
|
findLastIndex(elementsWithClones, (el) => el.id === element.id),
|
||||||
|
copyElements(element),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fixBindingsAfterDuplication(
|
||||||
|
newElements,
|
||||||
|
oldIdToDuplicatedId,
|
||||||
|
duplicatedElementsMap as NonDeletedSceneElementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
bindElementsToFramesAfterDuplication(
|
||||||
|
elementsWithClones,
|
||||||
|
oldElements,
|
||||||
|
oldIdToDuplicatedId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
newElements,
|
||||||
|
elementsWithClones,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
|
||||||
|
//
|
||||||
|
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
||||||
|
// Typed arrays and other non-null objects.
|
||||||
|
//
|
||||||
|
// Adapted from https://github.com/lukeed/klona
|
||||||
|
//
|
||||||
|
// The reason for `deepCopyElement()` wrapper is type safety (only allow
|
||||||
|
// passing ExcalidrawElement as the top-level argument).
|
||||||
|
const _deepCopyElement = (val: any, depth: number = 0) => {
|
||||||
|
// only clone non-primitives
|
||||||
|
if (val == null || typeof val !== "object") {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectType = Object.prototype.toString.call(val);
|
||||||
|
|
||||||
|
if (objectType === "[object Object]") {
|
||||||
|
const tmp =
|
||||||
|
typeof val.constructor === "function"
|
||||||
|
? Object.create(Object.getPrototypeOf(val))
|
||||||
|
: {};
|
||||||
|
for (const key in val) {
|
||||||
|
if (val.hasOwnProperty(key)) {
|
||||||
|
// don't copy non-serializable objects like these caches. They'll be
|
||||||
|
// populated when the element is rendered.
|
||||||
|
if (depth === 0 && (key === "shape" || key === "canvas")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tmp[key] = _deepCopyElement(val[key], depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
let k = val.length;
|
||||||
|
const arr = new Array(k);
|
||||||
|
while (k--) {
|
||||||
|
arr[k] = _deepCopyElement(val[k], depth + 1);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// we're not cloning non-array & non-plain-object objects because we
|
||||||
|
// don't support them on excalidraw elements yet. If we do, we need to make
|
||||||
|
// sure we start cloning them, so let's warn about it.
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
if (
|
||||||
|
objectType !== "[object Object]" &&
|
||||||
|
objectType !== "[object Array]" &&
|
||||||
|
objectType.startsWith("[object ")
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return val;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
|
||||||
|
* any value. The purpose is to to break object references for immutability
|
||||||
|
* reasons, whenever we want to keep the original element, but ensure it's not
|
||||||
|
* mutated.
|
||||||
|
*
|
||||||
|
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
||||||
|
* Typed arrays and other non-null objects.
|
||||||
|
*/
|
||||||
|
export const deepCopyElement = <T extends ExcalidrawElement>(
|
||||||
|
val: T,
|
||||||
|
): Mutable<T> => {
|
||||||
|
return _deepCopyElement(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const __test__defineOrigId = (clonedObj: object, origId: string) => {
|
||||||
|
Object.defineProperty(clonedObj, ORIG_ID, {
|
||||||
|
value: origId,
|
||||||
|
writable: false,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
};
|
|
@ -358,7 +358,7 @@ describe("elbow arrow ui", () => {
|
||||||
expect(arrow.endBinding).not.toBe(null);
|
expect(arrow.endBinding).not.toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps arrow shape when only the bound arrow is duplicated", async () => {
|
it("changes arrow shape to unbind variant if only the connected elbow arrow is duplicated", async () => {
|
||||||
UI.createElement("rectangle", {
|
UI.createElement("rectangle", {
|
||||||
x: -150,
|
x: -150,
|
||||||
y: -150,
|
y: -150,
|
||||||
|
@ -404,8 +404,8 @@ describe("elbow arrow ui", () => {
|
||||||
expect(duplicatedArrow.elbowed).toBe(true);
|
expect(duplicatedArrow.elbowed).toBe(true);
|
||||||
expect(duplicatedArrow.points).toEqual([
|
expect(duplicatedArrow.points).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[45, 0],
|
[0, 100],
|
||||||
[45, 200],
|
[90, 100],
|
||||||
[90, 200],
|
[90, 200],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
|
@ -238,16 +238,6 @@ const handleSegmentRenormalization = (
|
||||||
nextPoints.map((p) =>
|
nextPoints.map((p) =>
|
||||||
pointFrom<LocalPoint>(p[0] - arrow.x, p[1] - arrow.y),
|
pointFrom<LocalPoint>(p[0] - arrow.x, p[1] - arrow.y),
|
||||||
),
|
),
|
||||||
arrow.startBinding &&
|
|
||||||
getBindableElementForId(
|
|
||||||
arrow.startBinding.elementId,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
arrow.endBinding &&
|
|
||||||
getBindableElementForId(
|
|
||||||
arrow.endBinding.elementId,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
) ?? [],
|
) ?? [],
|
||||||
),
|
),
|
||||||
|
@ -341,9 +331,6 @@ const handleSegmentRelease = (
|
||||||
y,
|
y,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
startBinding &&
|
|
||||||
getBindableElementForId(startBinding.elementId, elementsMap),
|
|
||||||
endBinding && getBindableElementForId(endBinding.elementId, elementsMap),
|
|
||||||
{ isDragging: false },
|
{ isDragging: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -983,6 +970,8 @@ export const updateElbowArrowPoints = (
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
|
||||||
|
|
||||||
const updatedPoints: readonly LocalPoint[] = updates.points
|
const updatedPoints: readonly LocalPoint[] = updates.points
|
||||||
? updates.points && updates.points.length === 2
|
? updates.points && updates.points.length === 2
|
||||||
? arrow.points.map((p, idx) =>
|
? arrow.points.map((p, idx) =>
|
||||||
|
@ -995,7 +984,7 @@ export const updateElbowArrowPoints = (
|
||||||
: updates.points.slice()
|
: updates.points.slice()
|
||||||
: arrow.points.slice();
|
: arrow.points.slice();
|
||||||
|
|
||||||
// 0. During all element replacement in the scene, we just need to renormalize
|
// During all element replacement in the scene, we just need to renormalize
|
||||||
// the arrow
|
// the arrow
|
||||||
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
|
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
|
||||||
const {
|
const {
|
||||||
|
@ -1016,11 +1005,12 @@ export const updateElbowArrowPoints = (
|
||||||
getBindableElementForId(startBinding.elementId, elementsMap);
|
getBindableElementForId(startBinding.elementId, elementsMap);
|
||||||
const endElement =
|
const endElement =
|
||||||
endBinding && getBindableElementForId(endBinding.elementId, elementsMap);
|
endBinding && getBindableElementForId(endBinding.elementId, elementsMap);
|
||||||
|
const areUpdatedPointsValid = validateElbowPoints(updatedPoints);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(startBinding && !startElement) ||
|
(startBinding && !startElement && areUpdatedPointsValid) ||
|
||||||
(endBinding && !endElement) ||
|
(endBinding && !endElement && areUpdatedPointsValid) ||
|
||||||
(elementsMap.size === 0 && validateElbowPoints(updatedPoints)) ||
|
(elementsMap.size === 0 && areUpdatedPointsValid) ||
|
||||||
(Object.keys(restOfTheUpdates).length === 0 &&
|
(Object.keys(restOfTheUpdates).length === 0 &&
|
||||||
(startElement?.id !== startBinding?.elementId ||
|
(startElement?.id !== startBinding?.elementId ||
|
||||||
endElement?.id !== endBinding?.elementId))
|
endElement?.id !== endBinding?.elementId))
|
||||||
|
@ -1055,12 +1045,22 @@ export const updateElbowArrowPoints = (
|
||||||
},
|
},
|
||||||
elementsMap,
|
elementsMap,
|
||||||
updatedPoints,
|
updatedPoints,
|
||||||
startElement,
|
|
||||||
endElement,
|
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
|
// 0. During all element replacement in the scene, we just need to renormalize
|
||||||
|
// the arrow
|
||||||
|
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
|
||||||
|
if (elementsMap.size === 0 && areUpdatedPointsValid) {
|
||||||
|
return normalizeArrowElementUpdate(
|
||||||
|
updatedPoints.map((p) =>
|
||||||
|
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
|
||||||
|
),
|
||||||
|
arrow.fixedSegments,
|
||||||
|
arrow.startIsSpecial,
|
||||||
|
arrow.endIsSpecial,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
////
|
////
|
||||||
// 1. Renormalize the arrow
|
// 1. Renormalize the arrow
|
||||||
|
@ -1084,7 +1084,7 @@ export const updateElbowArrowPoints = (
|
||||||
arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity),
|
arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity),
|
||||||
),
|
),
|
||||||
) &&
|
) &&
|
||||||
validateElbowPoints(updatedPoints)
|
areUpdatedPointsValid
|
||||||
) {
|
) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
@ -1195,8 +1195,6 @@ const getElbowArrowData = (
|
||||||
},
|
},
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
nextPoints: readonly LocalPoint[],
|
nextPoints: readonly LocalPoint[],
|
||||||
startElement: ExcalidrawBindableElement | null,
|
|
||||||
endElement: ExcalidrawBindableElement | null,
|
|
||||||
options?: {
|
options?: {
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
zoom?: AppState["zoom"];
|
zoom?: AppState["zoom"];
|
||||||
|
@ -1211,8 +1209,8 @@ const getElbowArrowData = (
|
||||||
GlobalPoint
|
GlobalPoint
|
||||||
>(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y));
|
>(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y));
|
||||||
|
|
||||||
let hoveredStartElement = startElement;
|
let hoveredStartElement = null;
|
||||||
let hoveredEndElement = endElement;
|
let hoveredEndElement = null;
|
||||||
if (options?.isDragging) {
|
if (options?.isDragging) {
|
||||||
const elements = Array.from(elementsMap.values());
|
const elements = Array.from(elementsMap.values());
|
||||||
hoveredStartElement =
|
hoveredStartElement =
|
||||||
|
@ -1221,39 +1219,47 @@ const getElbowArrowData = (
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
elements,
|
||||||
options?.zoom,
|
options?.zoom,
|
||||||
) || startElement;
|
) || null;
|
||||||
hoveredEndElement =
|
hoveredEndElement =
|
||||||
getHoveredElement(
|
getHoveredElement(
|
||||||
origEndGlobalPoint,
|
origEndGlobalPoint,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
elements,
|
||||||
options?.zoom,
|
options?.zoom,
|
||||||
) || endElement;
|
) || null;
|
||||||
|
} else {
|
||||||
|
hoveredStartElement = arrow.startBinding
|
||||||
|
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
|
||||||
|
null
|
||||||
|
: null;
|
||||||
|
hoveredEndElement = arrow.endBinding
|
||||||
|
? getBindableElementForId(arrow.endBinding.elementId, elementsMap) || null
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startGlobalPoint = getGlobalPoint(
|
const startGlobalPoint = getGlobalPoint(
|
||||||
{
|
{
|
||||||
...arrow,
|
...arrow,
|
||||||
|
type: "arrow",
|
||||||
elbowed: true,
|
elbowed: true,
|
||||||
points: nextPoints,
|
points: nextPoints,
|
||||||
} as ExcalidrawElbowArrowElement,
|
} as ExcalidrawElbowArrowElement,
|
||||||
"start",
|
"start",
|
||||||
arrow.startBinding?.fixedPoint,
|
arrow.startBinding?.fixedPoint,
|
||||||
origStartGlobalPoint,
|
origStartGlobalPoint,
|
||||||
startElement,
|
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
options?.isDragging,
|
options?.isDragging,
|
||||||
);
|
);
|
||||||
const endGlobalPoint = getGlobalPoint(
|
const endGlobalPoint = getGlobalPoint(
|
||||||
{
|
{
|
||||||
...arrow,
|
...arrow,
|
||||||
|
type: "arrow",
|
||||||
elbowed: true,
|
elbowed: true,
|
||||||
points: nextPoints,
|
points: nextPoints,
|
||||||
} as ExcalidrawElbowArrowElement,
|
} as ExcalidrawElbowArrowElement,
|
||||||
"end",
|
"end",
|
||||||
arrow.endBinding?.fixedPoint,
|
arrow.endBinding?.fixedPoint,
|
||||||
origEndGlobalPoint,
|
origEndGlobalPoint,
|
||||||
endElement,
|
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
options?.isDragging,
|
options?.isDragging,
|
||||||
);
|
);
|
||||||
|
@ -2199,36 +2205,35 @@ const getGlobalPoint = (
|
||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
fixedPointRatio: [number, number] | undefined | null,
|
fixedPointRatio: [number, number] | undefined | null,
|
||||||
initialPoint: GlobalPoint,
|
initialPoint: GlobalPoint,
|
||||||
boundElement?: ExcalidrawBindableElement | null,
|
element?: ExcalidrawBindableElement | null,
|
||||||
hoveredElement?: ExcalidrawBindableElement | null,
|
|
||||||
isDragging?: boolean,
|
isDragging?: boolean,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
if (hoveredElement) {
|
if (element) {
|
||||||
const snapPoint = bindPointToSnapToElementOutline(
|
const snapPoint = bindPointToSnapToElementOutline(
|
||||||
arrow,
|
arrow,
|
||||||
hoveredElement,
|
element,
|
||||||
startOrEnd,
|
startOrEnd,
|
||||||
);
|
);
|
||||||
|
|
||||||
return snapToMid(hoveredElement, snapPoint);
|
return snapToMid(element, snapPoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
return initialPoint;
|
return initialPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (boundElement) {
|
if (element) {
|
||||||
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
|
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
|
||||||
fixedPointRatio || [0, 0],
|
fixedPointRatio || [0, 0],
|
||||||
boundElement,
|
element,
|
||||||
);
|
);
|
||||||
|
|
||||||
// NOTE: Resize scales the binding position point too, so we need to update it
|
// NOTE: Resize scales the binding position point too, so we need to update it
|
||||||
return Math.abs(
|
return Math.abs(
|
||||||
distanceToBindableElement(boundElement, fixedGlobalPoint) -
|
distanceToBindableElement(element, fixedGlobalPoint) -
|
||||||
FIXED_BINDING_DISTANCE,
|
FIXED_BINDING_DISTANCE,
|
||||||
) > 0.01
|
) > 0.01
|
||||||
? bindPointToSnapToElementOutline(arrow, boundElement, startOrEnd)
|
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
|
||||||
: fixedGlobalPoint;
|
: fixedGlobalPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { pointFrom, type LocalPoint } from "@excalidraw/math";
|
import { type GlobalPoint, pointFrom, type LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
|
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
@ -94,7 +94,7 @@ const getNodeRelatives = (
|
||||||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
||||||
edgePoint[0] + el.x,
|
edgePoint[0] + el.x,
|
||||||
edgePoint[1] + el.y,
|
edgePoint[1] + el.y,
|
||||||
] as Readonly<LocalPoint>);
|
] as Readonly<GlobalPoint>);
|
||||||
|
|
||||||
acc.push({
|
acc.push({
|
||||||
relative,
|
relative,
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
normalizeRadians,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
pointScaleFromOrigin,
|
pointScaleFromOrigin,
|
||||||
|
@ -30,8 +31,9 @@ export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
|
||||||
b: Point,
|
b: Point,
|
||||||
) => {
|
) => {
|
||||||
const angle = radiansToDegrees(
|
const angle = radiansToDegrees(
|
||||||
Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians,
|
normalizeRadians(Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (angle >= 315 || angle < 45) {
|
if (angle >= 315 || angle < 45) {
|
||||||
return HEADING_UP;
|
return HEADING_UP;
|
||||||
} else if (angle >= 45 && angle < 135) {
|
} else if (angle >= 45 && angle < 135) {
|
||||||
|
@ -77,9 +79,7 @@ export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
|
||||||
// Gets the heading for the point by creating a bounding box around the rotated
|
// Gets the heading for the point by creating a bounding box around the rotated
|
||||||
// close fitting bounding box, then creating 4 search cones around the center of
|
// close fitting bounding box, then creating 4 search cones around the center of
|
||||||
// the external bbox.
|
// the external bbox.
|
||||||
export const headingForPointFromElement = <
|
export const headingForPointFromElement = <Point extends GlobalPoint>(
|
||||||
Point extends GlobalPoint | LocalPoint,
|
|
||||||
>(
|
|
||||||
element: Readonly<ExcalidrawBindableElement>,
|
element: Readonly<ExcalidrawBindableElement>,
|
||||||
aabb: Readonly<Bounds>,
|
aabb: Readonly<Bounds>,
|
||||||
p: Readonly<Point>,
|
p: Readonly<Point>,
|
||||||
|
|
|
@ -14,8 +14,8 @@ export {
|
||||||
newLinearElement,
|
newLinearElement,
|
||||||
newArrowElement,
|
newArrowElement,
|
||||||
newImageElement,
|
newImageElement,
|
||||||
duplicateElement,
|
|
||||||
} from "./newElement";
|
} from "./newElement";
|
||||||
|
export { duplicateElement } from "./duplicate";
|
||||||
export {
|
export {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getElementBounds,
|
getElementBounds,
|
||||||
|
|
|
@ -25,14 +25,19 @@ import {
|
||||||
import { getGridPoint } from "../snapping";
|
import { getGridPoint } from "../snapping";
|
||||||
import { invariant, tupleToCoors } from "../utils";
|
import { invariant, tupleToCoors } from "../utils";
|
||||||
|
|
||||||
|
import Scene from "../scene/Scene";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
|
|
||||||
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
|
|
||||||
import { getElementPointsCoords, getMinMaxXYFromCurvePathOps } from "./bounds";
|
import { getElementPointsCoords, getMinMaxXYFromCurvePathOps } from "./bounds";
|
||||||
import { headingIsHorizontal, vectorToHeading } from "./heading";
|
import { headingIsHorizontal, vectorToHeading } from "./heading";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { bumpVersion, mutateElement } from "./mutateElement";
|
||||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||||
import {
|
import {
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
|
@ -57,7 +62,6 @@ import type {
|
||||||
FixedSegment,
|
FixedSegment,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type Scene from "../scene/Scene";
|
|
||||||
import type { Store } from "../store";
|
import type { Store } from "../store";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
|
@ -67,6 +71,7 @@ import type {
|
||||||
NullableGridSize,
|
NullableGridSize,
|
||||||
Zoom,
|
Zoom,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
import type { Mutable } from "../utility-types";
|
import type { Mutable } from "../utility-types";
|
||||||
|
|
||||||
const editorMidPointsCache: {
|
const editorMidPointsCache: {
|
||||||
|
@ -232,15 +237,15 @@ export class LinearElementEditor {
|
||||||
) => void,
|
) => void,
|
||||||
linearElementEditor: LinearElementEditor,
|
linearElementEditor: LinearElementEditor,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
): boolean {
|
): LinearElementEditor | null {
|
||||||
if (!linearElementEditor) {
|
if (!linearElementEditor) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
const { elementId } = linearElementEditor;
|
const { elementId } = linearElementEditor;
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -248,24 +253,18 @@ export class LinearElementEditor {
|
||||||
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
|
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
|
||||||
linearElementEditor.pointerDownState.lastClickedPoint !== 0
|
linearElementEditor.pointerDownState.lastClickedPoint !== 0
|
||||||
) {
|
) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedPointsIndices = isElbowArrow(element)
|
const selectedPointsIndices = isElbowArrow(element)
|
||||||
? linearElementEditor.selectedPointsIndices
|
? [
|
||||||
?.reduce(
|
!!linearElementEditor.selectedPointsIndices?.includes(0)
|
||||||
(startEnd, index) =>
|
? 0
|
||||||
(index === 0
|
: undefined,
|
||||||
? [0, startEnd[1]]
|
!!linearElementEditor.selectedPointsIndices?.find((idx) => idx > 0)
|
||||||
: [startEnd[0], element.points.length - 1]) as [
|
? element.points.length - 1
|
||||||
boolean | number,
|
: undefined,
|
||||||
boolean | number,
|
].filter((idx): idx is number => idx !== undefined)
|
||||||
],
|
|
||||||
[false, false] as [number | boolean, number | boolean],
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
(idx: number | boolean): idx is number => typeof idx === "number",
|
|
||||||
)
|
|
||||||
: linearElementEditor.selectedPointsIndices;
|
: linearElementEditor.selectedPointsIndices;
|
||||||
const lastClickedPoint = isElbowArrow(element)
|
const lastClickedPoint = isElbowArrow(element)
|
||||||
? linearElementEditor.pointerDownState.lastClickedPoint > 0
|
? linearElementEditor.pointerDownState.lastClickedPoint > 0
|
||||||
|
@ -274,9 +273,7 @@ export class LinearElementEditor {
|
||||||
: linearElementEditor.pointerDownState.lastClickedPoint;
|
: linearElementEditor.pointerDownState.lastClickedPoint;
|
||||||
|
|
||||||
// point that's being dragged (out of all selected points)
|
// point that's being dragged (out of all selected points)
|
||||||
const draggingPoint = element.points[lastClickedPoint] as
|
const draggingPoint = element.points[lastClickedPoint];
|
||||||
| [number, number]
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (selectedPointsIndices && draggingPoint) {
|
if (selectedPointsIndices && draggingPoint) {
|
||||||
if (
|
if (
|
||||||
|
@ -384,10 +381,28 @@ export class LinearElementEditor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return {
|
||||||
|
...linearElementEditor,
|
||||||
|
selectedPointsIndices,
|
||||||
|
segmentMidPointHoveredCoords:
|
||||||
|
lastClickedPoint !== 0 &&
|
||||||
|
lastClickedPoint !== element.points.length - 1
|
||||||
|
? this.getPointGlobalCoordinates(
|
||||||
|
element,
|
||||||
|
draggingPoint,
|
||||||
|
elementsMap,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
hoverPointIndex:
|
||||||
|
lastClickedPoint === 0 ||
|
||||||
|
lastClickedPoint === element.points.length - 1
|
||||||
|
? lastClickedPoint
|
||||||
|
: -1,
|
||||||
|
isDragging: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static handlePointerUp(
|
static handlePointerUp(
|
||||||
|
@ -1264,6 +1279,7 @@ export class LinearElementEditor {
|
||||||
startBinding?: PointBinding | null;
|
startBinding?: PointBinding | null;
|
||||||
endBinding?: PointBinding | null;
|
endBinding?: PointBinding | null;
|
||||||
},
|
},
|
||||||
|
sceneElementsMap?: NonDeletedSceneElementsMap,
|
||||||
) {
|
) {
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
|
|
||||||
|
@ -1307,6 +1323,7 @@ export class LinearElementEditor {
|
||||||
dragging || targetPoint.isDragging === true,
|
dragging || targetPoint.isDragging === true,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
sceneElementsMap,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1420,6 +1437,7 @@ export class LinearElementEditor {
|
||||||
options?: {
|
options?: {
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
zoom?: AppState["zoom"];
|
zoom?: AppState["zoom"];
|
||||||
|
sceneElementsMap?: NonDeletedSceneElementsMap;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (isElbowArrow(element)) {
|
if (isElbowArrow(element)) {
|
||||||
|
@ -1445,9 +1463,28 @@ export class LinearElementEditor {
|
||||||
|
|
||||||
updates.points = Array.from(nextPoints);
|
updates.points = Array.from(nextPoints);
|
||||||
|
|
||||||
|
if (!options?.sceneElementsMap || Scene.getScene(element)) {
|
||||||
mutateElement(element, updates, true, {
|
mutateElement(element, updates, true, {
|
||||||
isDragging: options?.isDragging,
|
isDragging: options?.isDragging,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// The element is not in the scene, so we need to use the provided
|
||||||
|
// scene map.
|
||||||
|
Object.assign(element, {
|
||||||
|
...updates,
|
||||||
|
angle: 0 as Radians,
|
||||||
|
|
||||||
|
...updateElbowArrowPoints(
|
||||||
|
element,
|
||||||
|
options.sceneElementsMap,
|
||||||
|
updates,
|
||||||
|
{
|
||||||
|
isDragging: options?.isDragging,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bumpVersion(element);
|
||||||
} else {
|
} else {
|
||||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||||
const prevCoords = getElementPointsCoords(element, element.points);
|
const prevCoords = getElementPointsCoords(element, element.points);
|
||||||
|
|
|
@ -6,21 +6,14 @@ import {
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
ORIG_ID,
|
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getLineHeight } from "../fonts";
|
import { getLineHeight } from "../fonts";
|
||||||
import { getNewGroupIdsForDuplication } from "../groups";
|
|
||||||
import { randomInteger, randomId } from "../random";
|
import { randomInteger, randomId } from "../random";
|
||||||
import {
|
import { getFontString, getUpdatedTimestamp } from "../utils";
|
||||||
arrayToMap,
|
|
||||||
getFontString,
|
|
||||||
getUpdatedTimestamp,
|
|
||||||
isTestEnv,
|
|
||||||
} from "../utils";
|
|
||||||
|
|
||||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||||
import { bumpVersion, newElementWith } from "./mutateElement";
|
import { newElementWith } from "./mutateElement";
|
||||||
import { getBoundTextMaxWidth } from "./textElement";
|
import { getBoundTextMaxWidth } from "./textElement";
|
||||||
import { normalizeText, measureText } from "./textMeasurements";
|
import { normalizeText, measureText } from "./textMeasurements";
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
|
@ -35,7 +28,6 @@ import type {
|
||||||
ExcalidrawGenericElement,
|
ExcalidrawGenericElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
TextAlign,
|
TextAlign,
|
||||||
GroupId,
|
|
||||||
VerticalAlign,
|
VerticalAlign,
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
|
@ -50,8 +42,7 @@ import type {
|
||||||
FixedSegment,
|
FixedSegment,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { AppState } from "../types";
|
import type { MarkOptional, Merge } from "../utility-types";
|
||||||
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
|
||||||
|
|
||||||
export type ElementConstructorOpts = MarkOptional<
|
export type ElementConstructorOpts = MarkOptional<
|
||||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||||
|
@ -538,260 +529,3 @@ export const newImageElement = (
|
||||||
crop: opts.crop ?? null,
|
crop: opts.crop ?? null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
|
|
||||||
//
|
|
||||||
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
|
||||||
// Typed arrays and other non-null objects.
|
|
||||||
//
|
|
||||||
// Adapted from https://github.com/lukeed/klona
|
|
||||||
//
|
|
||||||
// The reason for `deepCopyElement()` wrapper is type safety (only allow
|
|
||||||
// passing ExcalidrawElement as the top-level argument).
|
|
||||||
const _deepCopyElement = (val: any, depth: number = 0) => {
|
|
||||||
// only clone non-primitives
|
|
||||||
if (val == null || typeof val !== "object") {
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
|
|
||||||
const objectType = Object.prototype.toString.call(val);
|
|
||||||
|
|
||||||
if (objectType === "[object Object]") {
|
|
||||||
const tmp =
|
|
||||||
typeof val.constructor === "function"
|
|
||||||
? Object.create(Object.getPrototypeOf(val))
|
|
||||||
: {};
|
|
||||||
for (const key in val) {
|
|
||||||
if (val.hasOwnProperty(key)) {
|
|
||||||
// don't copy non-serializable objects like these caches. They'll be
|
|
||||||
// populated when the element is rendered.
|
|
||||||
if (depth === 0 && (key === "shape" || key === "canvas")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
tmp[key] = _deepCopyElement(val[key], depth + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(val)) {
|
|
||||||
let k = val.length;
|
|
||||||
const arr = new Array(k);
|
|
||||||
while (k--) {
|
|
||||||
arr[k] = _deepCopyElement(val[k], depth + 1);
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// we're not cloning non-array & non-plain-object objects because we
|
|
||||||
// don't support them on excalidraw elements yet. If we do, we need to make
|
|
||||||
// sure we start cloning them, so let's warn about it.
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
if (
|
|
||||||
objectType !== "[object Object]" &&
|
|
||||||
objectType !== "[object Array]" &&
|
|
||||||
objectType.startsWith("[object ")
|
|
||||||
) {
|
|
||||||
console.warn(
|
|
||||||
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return val;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
|
|
||||||
* any value. The purpose is to to break object references for immutability
|
|
||||||
* reasons, whenever we want to keep the original element, but ensure it's not
|
|
||||||
* mutated.
|
|
||||||
*
|
|
||||||
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
|
||||||
* Typed arrays and other non-null objects.
|
|
||||||
*/
|
|
||||||
export const deepCopyElement = <T extends ExcalidrawElement>(
|
|
||||||
val: T,
|
|
||||||
): Mutable<T> => {
|
|
||||||
return _deepCopyElement(val);
|
|
||||||
};
|
|
||||||
|
|
||||||
const __test__defineOrigId = (clonedObj: object, origId: string) => {
|
|
||||||
Object.defineProperty(clonedObj, ORIG_ID, {
|
|
||||||
value: origId,
|
|
||||||
writable: false,
|
|
||||||
enumerable: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* utility wrapper to generate new id.
|
|
||||||
*/
|
|
||||||
const regenerateId = () => {
|
|
||||||
return randomId();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Duplicate an element, often used in the alt-drag operation.
|
|
||||||
* Note that this method has gotten a bit complicated since the
|
|
||||||
* introduction of gruoping/ungrouping elements.
|
|
||||||
* @param editingGroupId The current group being edited. The new
|
|
||||||
* element will inherit this group and its
|
|
||||||
* parents.
|
|
||||||
* @param groupIdMapForOperation A Map that maps old group IDs to
|
|
||||||
* duplicated ones. If you are duplicating
|
|
||||||
* multiple elements at once, share this map
|
|
||||||
* amongst all of them
|
|
||||||
* @param element Element to duplicate
|
|
||||||
* @param overrides Any element properties to override
|
|
||||||
*/
|
|
||||||
export const duplicateElement = <TElement extends ExcalidrawElement>(
|
|
||||||
editingGroupId: AppState["editingGroupId"],
|
|
||||||
groupIdMapForOperation: Map<GroupId, GroupId>,
|
|
||||||
element: TElement,
|
|
||||||
overrides?: Partial<TElement>,
|
|
||||||
): Readonly<TElement> => {
|
|
||||||
let copy = deepCopyElement(element);
|
|
||||||
|
|
||||||
if (isTestEnv()) {
|
|
||||||
__test__defineOrigId(copy, element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
copy.id = regenerateId();
|
|
||||||
copy.boundElements = null;
|
|
||||||
copy.updated = getUpdatedTimestamp();
|
|
||||||
copy.seed = randomInteger();
|
|
||||||
copy.groupIds = getNewGroupIdsForDuplication(
|
|
||||||
copy.groupIds,
|
|
||||||
editingGroupId,
|
|
||||||
(groupId) => {
|
|
||||||
if (!groupIdMapForOperation.has(groupId)) {
|
|
||||||
groupIdMapForOperation.set(groupId, regenerateId());
|
|
||||||
}
|
|
||||||
return groupIdMapForOperation.get(groupId)!;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (overrides) {
|
|
||||||
copy = Object.assign(copy, overrides);
|
|
||||||
}
|
|
||||||
return copy;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clones elements, regenerating their ids (including bindings) and group ids.
|
|
||||||
*
|
|
||||||
* If bindings don't exist in the elements array, they are removed. Therefore,
|
|
||||||
* it's advised to supply the whole elements array, or sets of elements that
|
|
||||||
* are encapsulated (such as library items), if the purpose is to retain
|
|
||||||
* bindings to the cloned elements intact.
|
|
||||||
*
|
|
||||||
* NOTE by default does not randomize or regenerate anything except the id.
|
|
||||||
*/
|
|
||||||
export const duplicateElements = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
opts?: {
|
|
||||||
/** NOTE also updates version flags and `updated` */
|
|
||||||
randomizeSeed: boolean;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const clonedElements: ExcalidrawElement[] = [];
|
|
||||||
|
|
||||||
const origElementsMap = arrayToMap(elements);
|
|
||||||
|
|
||||||
// used for for migrating old ids to new ids
|
|
||||||
const elementNewIdsMap = new Map<
|
|
||||||
/* orig */ ExcalidrawElement["id"],
|
|
||||||
/* new */ ExcalidrawElement["id"]
|
|
||||||
>();
|
|
||||||
|
|
||||||
const maybeGetNewId = (id: ExcalidrawElement["id"]) => {
|
|
||||||
// if we've already migrated the element id, return the new one directly
|
|
||||||
if (elementNewIdsMap.has(id)) {
|
|
||||||
return elementNewIdsMap.get(id)!;
|
|
||||||
}
|
|
||||||
// if we haven't migrated the element id, but an old element with the same
|
|
||||||
// id exists, generate a new id for it and return it
|
|
||||||
if (origElementsMap.has(id)) {
|
|
||||||
const newId = regenerateId();
|
|
||||||
elementNewIdsMap.set(id, newId);
|
|
||||||
return newId;
|
|
||||||
}
|
|
||||||
// if old element doesn't exist, return null to mark it for removal
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupNewIdsMap = new Map</* orig */ GroupId, /* new */ GroupId>();
|
|
||||||
|
|
||||||
for (const element of elements) {
|
|
||||||
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
|
|
||||||
|
|
||||||
clonedElement.id = maybeGetNewId(element.id)!;
|
|
||||||
if (isTestEnv()) {
|
|
||||||
__test__defineOrigId(clonedElement, element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts?.randomizeSeed) {
|
|
||||||
clonedElement.seed = randomInteger();
|
|
||||||
bumpVersion(clonedElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clonedElement.groupIds) {
|
|
||||||
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
|
||||||
if (!groupNewIdsMap.has(groupId)) {
|
|
||||||
groupNewIdsMap.set(groupId, regenerateId());
|
|
||||||
}
|
|
||||||
return groupNewIdsMap.get(groupId)!;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("containerId" in clonedElement && clonedElement.containerId) {
|
|
||||||
const newContainerId = maybeGetNewId(clonedElement.containerId);
|
|
||||||
clonedElement.containerId = newContainerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("boundElements" in clonedElement && clonedElement.boundElements) {
|
|
||||||
clonedElement.boundElements = clonedElement.boundElements.reduce(
|
|
||||||
(
|
|
||||||
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
|
|
||||||
binding,
|
|
||||||
) => {
|
|
||||||
const newBindingId = maybeGetNewId(binding.id);
|
|
||||||
if (newBindingId) {
|
|
||||||
acc.push({ ...binding, id: newBindingId });
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("endBinding" in clonedElement && clonedElement.endBinding) {
|
|
||||||
const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId);
|
|
||||||
clonedElement.endBinding = newEndBindingId
|
|
||||||
? {
|
|
||||||
...clonedElement.endBinding,
|
|
||||||
elementId: newEndBindingId,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
if ("startBinding" in clonedElement && clonedElement.startBinding) {
|
|
||||||
const newEndBindingId = maybeGetNewId(
|
|
||||||
clonedElement.startBinding.elementId,
|
|
||||||
);
|
|
||||||
clonedElement.startBinding = newEndBindingId
|
|
||||||
? {
|
|
||||||
...clonedElement.startBinding,
|
|
||||||
elementId: newEndBindingId,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clonedElement.frameId) {
|
|
||||||
clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
|
|
||||||
}
|
|
||||||
|
|
||||||
clonedElements.push(clonedElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
return clonedElements;
|
|
||||||
};
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import {
|
||||||
TEXT_ALIGN,
|
TEXT_ALIGN,
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getFontString, arrayToMap } from "../utils";
|
import { getFontString } from "../utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
|
@ -112,48 +112,6 @@ export const redrawTextBoundingBox = (
|
||||||
mutateElement(textElement, boundTextUpdates, informMutation);
|
mutateElement(textElement, boundTextUpdates, informMutation);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const bindTextToShapeAfterDuplication = (
|
|
||||||
newElements: ExcalidrawElement[],
|
|
||||||
oldElements: ExcalidrawElement[],
|
|
||||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
|
||||||
): void => {
|
|
||||||
const newElementsMap = arrayToMap(newElements) as Map<
|
|
||||||
ExcalidrawElement["id"],
|
|
||||||
ExcalidrawElement
|
|
||||||
>;
|
|
||||||
oldElements.forEach((element) => {
|
|
||||||
const newElementId = oldIdToDuplicatedId.get(element.id) as string;
|
|
||||||
const boundTextElementId = getBoundTextElementId(element);
|
|
||||||
|
|
||||||
if (boundTextElementId) {
|
|
||||||
const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId);
|
|
||||||
if (newTextElementId) {
|
|
||||||
const newContainer = newElementsMap.get(newElementId);
|
|
||||||
if (newContainer) {
|
|
||||||
mutateElement(newContainer, {
|
|
||||||
boundElements: (element.boundElements || [])
|
|
||||||
.filter(
|
|
||||||
(boundElement) =>
|
|
||||||
boundElement.id !== newTextElementId &&
|
|
||||||
boundElement.id !== boundTextElementId,
|
|
||||||
)
|
|
||||||
.concat({
|
|
||||||
type: "text",
|
|
||||||
id: newTextElementId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const newTextElement = newElementsMap.get(newTextElementId);
|
|
||||||
if (newTextElement && isTextElement(newTextElement)) {
|
|
||||||
mutateElement(newTextElement, {
|
|
||||||
containerId: newContainer ? newElementId : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const handleBindTextResize = (
|
export const handleBindTextResize = (
|
||||||
container: NonDeletedExcalidrawElement,
|
container: NonDeletedExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -886,24 +886,25 @@ const _renderInteractiveScene = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
// Arrows have a different highlight behavior when
|
||||||
isElbowArrow(selectedElements[0]) &&
|
// they are the only selected element
|
||||||
appState.selectedLinearElement &&
|
if (appState.selectedLinearElement) {
|
||||||
appState.selectedLinearElement.segmentMidPointHoveredCoords
|
const editor = appState.selectedLinearElement;
|
||||||
) {
|
const firstSelectedLinear = selectedElements.find(
|
||||||
|
(el) => el.id === editor.elementId, // Don't forget bound text elements!
|
||||||
|
);
|
||||||
|
|
||||||
|
if (editor.segmentMidPointHoveredCoords) {
|
||||||
renderElbowArrowMidPointHighlight(context, appState);
|
renderElbowArrowMidPointHighlight(context, appState);
|
||||||
} else if (
|
} else if (
|
||||||
appState.selectedLinearElement &&
|
isElbowArrow(firstSelectedLinear)
|
||||||
appState.selectedLinearElement.hoverPointIndex >= 0 &&
|
? editor.hoverPointIndex === 0 ||
|
||||||
!(
|
editor.hoverPointIndex === firstSelectedLinear.points.length - 1
|
||||||
isElbowArrow(selectedElements[0]) &&
|
: editor.hoverPointIndex >= 0
|
||||||
appState.selectedLinearElement.hoverPointIndex > 0 &&
|
|
||||||
appState.selectedLinearElement.hoverPointIndex <
|
|
||||||
selectedElements[0].points.length - 1
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
renderLinearElementPointHighlight(context, appState, elementsMap);
|
renderLinearElementPointHighlight(context, appState, elementsMap);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Paint selected elements
|
// Paint selected elements
|
||||||
if (!appState.multiElement && !appState.editingLinearElement) {
|
if (!appState.multiElement && !appState.editingLinearElement) {
|
||||||
|
@ -1073,7 +1074,7 @@ const _renderInteractiveScene = ({
|
||||||
const dashedLinePadding =
|
const dashedLinePadding =
|
||||||
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
|
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
|
||||||
context.fillStyle = oc.white;
|
context.fillStyle = oc.white;
|
||||||
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
|
const [x1, y1, x2, y2] = getCommonBounds(selectedElements, elementsMap);
|
||||||
const initialLineDash = context.getLineDash();
|
const initialLineDash = context.getLineDash();
|
||||||
context.setLineDash([2 / appState.zoom.value]);
|
context.setLineDash([2 / appState.zoom.value]);
|
||||||
const lineWidth = context.lineWidth;
|
const lineWidth = context.lineWidth;
|
||||||
|
|
|
@ -2,10 +2,11 @@ import { getDefaultAppState } from "./appState";
|
||||||
import { AppStateChange, ElementsChange } from "./change";
|
import { AppStateChange, ElementsChange } from "./change";
|
||||||
import { ENV } from "./constants";
|
import { ENV } from "./constants";
|
||||||
import { newElementWith } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import { deepCopyElement } from "./element/newElement";
|
|
||||||
import { Emitter } from "./emitter";
|
import { Emitter } from "./emitter";
|
||||||
import { isShallowEqual } from "./utils";
|
import { isShallowEqual } from "./utils";
|
||||||
|
|
||||||
|
import { deepCopyElement } from "./element/duplicate";
|
||||||
|
|
||||||
import type { OrderedExcalidrawElement } from "./element/types";
|
import type { OrderedExcalidrawElement } from "./element/types";
|
||||||
import type { AppState, ObservedAppState } from "./types";
|
import type { AppState, ObservedAppState } from "./types";
|
||||||
import type { ValueOf } from "./utility-types";
|
import type { ValueOf } from "./utility-types";
|
||||||
|
|
|
@ -2606,8 +2606,8 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 4,
|
"version": 5,
|
||||||
"versionNonce": 238820263,
|
"versionNonce": 400692809,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
|
|
|
@ -15172,9 +15172,11 @@ History {
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id61": true,
|
"id61": true,
|
||||||
},
|
},
|
||||||
|
"selectedLinearElementId": "id61",
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedElementIds": {},
|
"selectedElementIds": {},
|
||||||
|
"selectedLinearElementId": null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -18946,7 +18948,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
|
@ -18980,7 +18982,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 110,
|
"x": 110,
|
||||||
"y": 110,
|
"y": 110,
|
||||||
|
@ -19014,7 +19016,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 6,
|
"version": 7,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
|
@ -19048,7 +19050,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 6,
|
"version": 7,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 110,
|
"x": 110,
|
||||||
"y": 110,
|
"y": 110,
|
||||||
|
|
|
@ -11,7 +11,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id2",
|
"id": "id2",
|
||||||
"index": "a0",
|
"index": "Zz",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -26,8 +26,8 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 5,
|
"version": 6,
|
||||||
"versionNonce": 400692809,
|
"versionNonce": 1604849351,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 30,
|
"x": 30,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
|
@ -45,7 +45,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
"index": "a1",
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -60,7 +60,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 6,
|
"version": 5,
|
||||||
"versionNonce": 23633383,
|
"versionNonce": 23633383,
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
|
|
|
@ -2139,7 +2139,7 @@ History {
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 10,
|
"height": 10,
|
||||||
"index": "a0",
|
"index": "Zz",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -2164,12 +2164,10 @@ History {
|
||||||
"updated": Map {
|
"updated": Map {
|
||||||
"id0" => Delta {
|
"id0" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"index": "a1",
|
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"index": "a0",
|
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
},
|
},
|
||||||
|
@ -10631,7 +10629,7 @@ History {
|
||||||
"id7",
|
"id7",
|
||||||
],
|
],
|
||||||
"height": 10,
|
"height": 10,
|
||||||
"index": "a0",
|
"index": "Zx",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -10664,7 +10662,7 @@ History {
|
||||||
"id7",
|
"id7",
|
||||||
],
|
],
|
||||||
"height": 10,
|
"height": 10,
|
||||||
"index": "a1",
|
"index": "Zy",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -10697,7 +10695,7 @@ History {
|
||||||
"id7",
|
"id7",
|
||||||
],
|
],
|
||||||
"height": 10,
|
"height": 10,
|
||||||
"index": "a2",
|
"index": "Zz",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
|
@ -10722,36 +10720,30 @@ History {
|
||||||
"updated": Map {
|
"updated": Map {
|
||||||
"id0" => Delta {
|
"id0" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"index": "a3",
|
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"index": "a0",
|
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"id1" => Delta {
|
"id1" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"index": "a4",
|
|
||||||
"x": 40,
|
"x": 40,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"index": "a1",
|
|
||||||
"x": 30,
|
"x": 30,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"id2" => Delta {
|
"id2" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"index": "a5",
|
|
||||||
"x": 60,
|
"x": 60,
|
||||||
"y": 20,
|
"y": 20,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"index": "a2",
|
|
||||||
"x": 50,
|
"x": 50,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
/* eslint-disable no-lone-blocks */
|
/* eslint-disable no-lone-blocks */
|
||||||
import { generateKeyBetween } from "fractional-indexing";
|
import { generateKeyBetween } from "fractional-indexing";
|
||||||
|
|
||||||
import { deepCopyElement } from "../element/newElement";
|
|
||||||
import { InvalidFractionalIndexError } from "../errors";
|
import { InvalidFractionalIndexError } from "../errors";
|
||||||
import {
|
import {
|
||||||
syncInvalidIndices,
|
syncInvalidIndices,
|
||||||
|
@ -10,6 +9,8 @@ import {
|
||||||
} from "../fractionalIndex";
|
} from "../fractionalIndex";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
|
import { deepCopyElement } from "../element/duplicate";
|
||||||
|
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
|
|
||||||
import type { ExcalidrawElement, FractionalIndex } from "../element/types";
|
import type { ExcalidrawElement, FractionalIndex } from "../element/types";
|
||||||
|
|
|
@ -1184,3 +1184,7 @@ it(
|
||||||
expect(API.getSelectedElements().length).toBe(1);
|
expect(API.getSelectedElements().length).toBe(1);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
//
|
||||||
|
// DEPRECATED: DO NOT ADD TESTS HERE
|
||||||
|
//
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue