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 { deepCopyElement } from "../element/newElement";
|
||||
import { deepCopyElement } from "../element/duplicate";
|
||||
import { t } from "../i18n";
|
||||
import { randomId } from "../random";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
|
|
@ -288,6 +288,7 @@ export const actionDeleteSelected = register({
|
|||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
selectedLinearElement: null,
|
||||
},
|
||||
captureUpdate: isSomeElementSelected(
|
||||
getNonDeletedElements(elements),
|
||||
|
|
|
@ -258,7 +258,7 @@ describe("actionDuplicateSelection", () => {
|
|||
assertElements(h.elements, [
|
||||
{ id: 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 { DuplicateIcon } from "../components/icons";
|
||||
import { DEFAULT_GRID_SIZE } from "../constants";
|
||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
} from "../element/typeChecks";
|
||||
import { normalizeElementOrder } from "../element/sortElements";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isBoundToContainer, isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import {
|
||||
bindElementsToFramesAfterDuplication,
|
||||
getFrameChildren,
|
||||
} from "../frame";
|
||||
import {
|
||||
selectGroupsForSelectedElements,
|
||||
getSelectedGroupForElement,
|
||||
getElementsInGroup,
|
||||
} from "../groups";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
|
@ -32,19 +13,15 @@ import {
|
|||
getSelectedElements,
|
||||
} from "../scene/selection";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
import {
|
||||
arrayToMap,
|
||||
castArray,
|
||||
findLastIndex,
|
||||
getShortcutKey,
|
||||
invariant,
|
||||
} from "../utils";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
|
||||
import { duplicateElements } from "../element/duplicate";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { ActionResult } from "./types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import type { AppState } from "../types";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
|
@ -75,20 +52,54 @@ export const actionDuplicateSelection = register({
|
|||
}
|
||||
}
|
||||
|
||||
const nextState = duplicateElements(elements, appState);
|
||||
|
||||
if (app.props.onDuplicate && nextState.elements) {
|
||||
const mappedElements = app.props.onDuplicate(
|
||||
nextState.elements,
|
||||
let { newElements: duplicatedElements, elementsWithClones: nextElements } =
|
||||
duplicateElements({
|
||||
type: "in-place",
|
||||
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) {
|
||||
nextState.elements = mappedElements;
|
||||
nextElements = mappedElements;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
|
@ -107,259 +118,23 @@ export const actionDuplicateSelection = register({
|
|||
),
|
||||
});
|
||||
|
||||
const duplicateElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
): Partial<Exclude<ActionResult, false>> => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const groupIdMap = new Map();
|
||||
const newElements: ExcalidrawElement[] = [];
|
||||
const oldElements: ExcalidrawElement[] = [];
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
const duplicateAndOffsetElement = <
|
||||
T extends ExcalidrawElement | ExcalidrawElement[],
|
||||
>(
|
||||
element: T,
|
||||
): T extends ExcalidrawElement[]
|
||||
? ExcalidrawElement[]
|
||||
: ExcalidrawElement | null => {
|
||||
const elements = castArray(element);
|
||||
|
||||
const _newElements = elements.reduce(
|
||||
(acc: ExcalidrawElement[], element) => {
|
||||
if (processedIds.has(element.id)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
processedIds.set(element.id, true);
|
||||
|
||||
const newElement = duplicateElement(
|
||||
appState.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
{
|
||||
x: element.x + DEFAULT_GRID_SIZE / 2,
|
||||
y: element.y + DEFAULT_GRID_SIZE / 2,
|
||||
},
|
||||
const updateLinearElementEditors = (clonedElements: ExcalidrawElement[]) => {
|
||||
const linears = clonedElements.filter(isLinearElement);
|
||||
if (linears.length === 1) {
|
||||
const linear = linears[0];
|
||||
const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
|
||||
const onlySingleLinearSelected = clonedElements.every(
|
||||
(el) => el.id === linear.id || boundElements.includes(el.id),
|
||||
);
|
||||
|
||||
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;
|
||||
if (onlySingleLinearSelected) {
|
||||
return {
|
||||
selectedLinearElement: new LinearElementEditor(linear),
|
||||
};
|
||||
|
||||
elements = normalizeElementOrder(elements);
|
||||
|
||||
const idsOfElementsToDuplicate = arrayToMap(
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Ids of elements that have already been processed so we don't push them
|
||||
// into the array twice if we end up backtracking when retrieving
|
||||
// discontiguous group of elements (can happen due to a bug, or in edge
|
||||
// cases such as a group containing deleted elements which were not selected).
|
||||
//
|
||||
// This is not enough to prevent duplicates, so we do a second loop afterwards
|
||||
// to remove them.
|
||||
//
|
||||
// For convenience we mark even the newly created ones even though we don't
|
||||
// loop over them.
|
||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
||||
|
||||
const elementsWithClones: ExcalidrawElement[] = elements.slice();
|
||||
|
||||
const insertAfterIndex = (
|
||||
index: number,
|
||||
elements: ExcalidrawElement | null | ExcalidrawElement[],
|
||||
) => {
|
||||
invariant(index !== -1, "targetIndex === -1 ");
|
||||
|
||||
if (!Array.isArray(elements) && !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
elementsWithClones.splice(index + 1, 0, ...castArray(elements));
|
||||
};
|
||||
|
||||
const frameIdsToDuplicate = new Set(
|
||||
elements
|
||||
.filter(
|
||||
(el) => idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
|
||||
)
|
||||
.map((el) => el.id),
|
||||
);
|
||||
|
||||
for (const element of elements) {
|
||||
if (processedIds.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!idsOfElementsToDuplicate.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// groups
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const groupId = getSelectedGroupForElement(appState, element);
|
||||
if (groupId) {
|
||||
const groupElements = getElementsInGroup(elements, groupId).flatMap(
|
||||
(element) =>
|
||||
isFrameLikeElement(element)
|
||||
? [...getFrameChildren(elements, element.id), element]
|
||||
: [element],
|
||||
);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return el.groupIds?.includes(groupId);
|
||||
});
|
||||
|
||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements));
|
||||
continue;
|
||||
}
|
||||
|
||||
// frame duplication
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFrameLikeElement(element)) {
|
||||
const frameId = element.id;
|
||||
|
||||
const frameChildren = getFrameChildren(elements, frameId);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return el.frameId === frameId || el.id === frameId;
|
||||
});
|
||||
|
||||
insertAfterIndex(
|
||||
targetIndex,
|
||||
duplicateAndOffsetElement([...frameChildren, element]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// text container
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if (hasBoundTextElement(element)) {
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return (
|
||||
el.id === element.id ||
|
||||
("containerId" in el && el.containerId === element.id)
|
||||
);
|
||||
});
|
||||
|
||||
if (boundTextElement) {
|
||||
insertAfterIndex(
|
||||
targetIndex,
|
||||
duplicateAndOffsetElement([element, boundTextElement]),
|
||||
);
|
||||
} else {
|
||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element, elementsMap);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return el.id === element.id || el.id === container?.id;
|
||||
});
|
||||
|
||||
if (container) {
|
||||
insertAfterIndex(
|
||||
targetIndex,
|
||||
duplicateAndOffsetElement([container, element]),
|
||||
);
|
||||
} else {
|
||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// default duplication (regular elements)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
insertAfterIndex(
|
||||
findLastIndex(elementsWithClones, (el) => el.id === element.id),
|
||||
duplicateAndOffsetElement(element),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bindTextToShapeAfterDuplication(
|
||||
elementsWithClones,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
fixBindingsAfterDuplication(
|
||||
elementsWithClones,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
bindElementsToFramesAfterDuplication(
|
||||
elementsWithClones,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
|
||||
const nextElementsToSelect =
|
||||
excludeElementsInFramesFromSelection(newElements);
|
||||
|
||||
return {
|
||||
elements: elementsWithClones,
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: appState.editingGroupId,
|
||||
selectedElementIds: nextElementsToSelect.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
},
|
||||
getNonDeletedElements(elementsWithClones),
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
},
|
||||
selectedLinearElement: null,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
} from "../element/binding";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { resizeMultipleElements } from "../element/resizeElements";
|
||||
import {
|
||||
isArrowElement,
|
||||
|
@ -19,6 +18,8 @@ import { getSelectedElements } from "../scene";
|
|||
import { CaptureUpdateAction } from "../store";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
import { deepCopyElement } from "../element/duplicate";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type {
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
} from "./constants";
|
||||
import { createFile, isSupportedImageFileType } from "./data/blob";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import {
|
||||
isFrameLikeElement,
|
||||
isInitializedImageElement,
|
||||
|
@ -15,6 +14,8 @@ import { ExcalidrawError } from "./errors";
|
|||
import { getContainingFrame } from "./frame";
|
||||
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
|
||||
|
||||
import { deepCopyElement } from "./element/duplicate";
|
||||
|
||||
import type { Spreadsheet } from "./charts";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
|
|
|
@ -126,7 +126,6 @@ import { restore, restoreElements } from "../data/restore";
|
|||
import {
|
||||
dragNewElement,
|
||||
dragSelectedElements,
|
||||
duplicateElement,
|
||||
getCommonBounds,
|
||||
getCursorForResizingElement,
|
||||
getDragOffsetXY,
|
||||
|
@ -152,7 +151,6 @@ import {
|
|||
bindOrUnbindLinearElement,
|
||||
bindOrUnbindLinearElements,
|
||||
fixBindingsAfterDeletion,
|
||||
fixBindingsAfterDuplication,
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
isLinearElementSimpleAndAlreadyBound,
|
||||
|
@ -163,9 +161,8 @@ import {
|
|||
} from "../element/binding";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { deepCopyElement, duplicateElements } from "../element/duplicate";
|
||||
import {
|
||||
deepCopyElement,
|
||||
duplicateElements,
|
||||
newFrameElement,
|
||||
newFreeDrawElement,
|
||||
newEmbeddableElement,
|
||||
|
@ -292,7 +289,6 @@ import {
|
|||
} from "../element/image";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerElement,
|
||||
|
@ -309,7 +305,6 @@ import { Fonts, getLineHeight } from "../fonts";
|
|||
import {
|
||||
getFrameChildren,
|
||||
isCursorInFrame,
|
||||
bindElementsToFramesAfterDuplication,
|
||||
addElementsToFrame,
|
||||
replaceAllElementsInFrame,
|
||||
removeElementsFromFrame,
|
||||
|
@ -3224,17 +3219,16 @@ class App extends React.Component<AppProps, AppState> {
|
|||
|
||||
const [gridX, gridY] = getGridPoint(dx, dy, this.getEffectiveGridSize());
|
||||
|
||||
const newElements = duplicateElements(
|
||||
elements.map((element) => {
|
||||
const { newElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: elements.map((element) => {
|
||||
return newElementWith(element, {
|
||||
x: element.x + gridX - minX,
|
||||
y: element.y + gridY - minY,
|
||||
});
|
||||
}),
|
||||
{
|
||||
randomizeSeed: !opts.retainSeed,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const prevElements = this.scene.getElementsIncludingDeleted();
|
||||
let nextElements = [...prevElements, ...newElements];
|
||||
|
@ -6095,7 +6089,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.setState({
|
||||
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);
|
||||
if (this.state.activeEmbeddable?.state === "hover") {
|
||||
this.setState({ activeEmbeddable: null });
|
||||
|
@ -6288,8 +6287,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||
if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||
if (
|
||||
// Ebow arrows can only be moved when unconnected
|
||||
|
@ -8138,6 +8143,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
...this.state.selectedLinearElement,
|
||||
pointerDownState: ret.pointerDownState,
|
||||
selectedPointsIndices: ret.selectedPointsIndices,
|
||||
segmentMidPointHoveredCoords: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -8147,6 +8153,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
...this.state.editingLinearElement,
|
||||
pointerDownState: ret.pointerDownState,
|
||||
selectedPointsIndices: ret.selectedPointsIndices,
|
||||
segmentMidPointHoveredCoords: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -8160,7 +8167,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
return;
|
||||
}
|
||||
|
||||
const didDrag = LinearElementEditor.handlePointDragging(
|
||||
const newLinearElementEditor = LinearElementEditor.handlePointDragging(
|
||||
event,
|
||||
this,
|
||||
pointerCoords.x,
|
||||
|
@ -8174,29 +8181,18 @@ class App extends React.Component<AppProps, AppState> {
|
|||
linearElementEditor,
|
||||
this.scene,
|
||||
);
|
||||
if (didDrag) {
|
||||
if (newLinearElementEditor) {
|
||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||
pointerDownState.lastCoords.y = pointerCoords.y;
|
||||
pointerDownState.drag.hasOccurred = true;
|
||||
if (
|
||||
this.state.editingLinearElement &&
|
||||
!this.state.editingLinearElement.isDragging
|
||||
) {
|
||||
|
||||
this.setState({
|
||||
editingLinearElement: {
|
||||
...this.state.editingLinearElement,
|
||||
isDragging: true,
|
||||
},
|
||||
editingLinearElement: this.state.editingLinearElement
|
||||
? newLinearElementEditor
|
||||
: null,
|
||||
selectedLinearElement: newLinearElementEditor,
|
||||
});
|
||||
}
|
||||
if (!this.state.selectedLinearElement.isDragging) {
|
||||
this.setState({
|
||||
selectedLinearElement: {
|
||||
...this.state.selectedLinearElement,
|
||||
isDragging: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -8422,145 +8418,55 @@ class App extends React.Component<AppProps, AppState> {
|
|||
|
||||
pointerDownState.hit.hasBeenDuplicated = true;
|
||||
|
||||
const nextElements = [];
|
||||
const elementsToAppend = [];
|
||||
const groupIdMap = new Map();
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const elements = this.scene.getElementsIncludingDeleted();
|
||||
const hitElement = pointerDownState.hit.element;
|
||||
const selectedElementIds = new Set(
|
||||
this.scene
|
||||
.getSelectedElements({
|
||||
const selectedElements = this.scene.getSelectedElements({
|
||||
selectedElementIds: this.state.selectedElementIds,
|
||||
includeBoundTextElement: 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 (
|
||||
Math.abs(element.x) > 1e7 ||
|
||||
Math.abs(element.x) > 1e7 ||
|
||||
Math.abs(element.width) > 1e7 ||
|
||||
Math.abs(element.height) > 1e7
|
||||
hitElement &&
|
||||
!selectedElements.find((el) => el.id === hitElement.id)
|
||||
) {
|
||||
console.error(
|
||||
`Alt+dragging element in scene with invalid dimensions`,
|
||||
element.x,
|
||||
element.y,
|
||||
element.width,
|
||||
element.height,
|
||||
isInSelection,
|
||||
);
|
||||
|
||||
return;
|
||||
selectedElements.push(hitElement);
|
||||
}
|
||||
|
||||
if (isInSelection) {
|
||||
const duplicatedElement = duplicateElement(
|
||||
this.state.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
);
|
||||
const { newElements: clonedElements, elementsWithClones } =
|
||||
duplicateElements({
|
||||
type: "in-place",
|
||||
elements,
|
||||
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 (
|
||||
Math.abs(duplicatedElement.x) > 1e7 ||
|
||||
Math.abs(duplicatedElement.x) > 1e7 ||
|
||||
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;
|
||||
if (origEl) {
|
||||
return {
|
||||
x: origEl.x,
|
||||
y: origEl.y,
|
||||
};
|
||||
}
|
||||
|
||||
const origElement = pointerDownState.originalElements.get(
|
||||
element.id,
|
||||
)!;
|
||||
|
||||
// NOTE (mtolmacs): This is a temporary fix for very large scenes
|
||||
if (
|
||||
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,
|
||||
return {};
|
||||
},
|
||||
reverseOrder: true,
|
||||
});
|
||||
clonedElements.forEach((element) => {
|
||||
pointerDownState.originalElements.set(element.id, element);
|
||||
});
|
||||
|
||||
// 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?.(
|
||||
nextSceneElements,
|
||||
elementsWithClones,
|
||||
elements,
|
||||
);
|
||||
|
||||
nextSceneElements = mappedNewSceneElements || nextSceneElements;
|
||||
|
||||
syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
|
||||
|
||||
bindTextToShapeAfterDuplication(
|
||||
nextElements,
|
||||
elementsToAppend,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
fixBindingsAfterDuplication(
|
||||
nextSceneElements,
|
||||
elementsToAppend,
|
||||
oldIdToDuplicatedId,
|
||||
"duplicatesServeAsOld",
|
||||
);
|
||||
bindElementsToFramesAfterDuplication(
|
||||
nextSceneElements,
|
||||
elementsToAppend,
|
||||
oldIdToDuplicatedId,
|
||||
const nextSceneElements = syncMovedIndices(
|
||||
mappedNewSceneElements || elementsWithClones,
|
||||
arrayToMap(clonedElements),
|
||||
);
|
||||
|
||||
this.scene.replaceAllElements(nextSceneElements);
|
||||
|
|
|
@ -8,18 +8,20 @@ import React, {
|
|||
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { serializeLibraryAsJSON } from "../data/json";
|
||||
import { duplicateElements } from "../element/newElement";
|
||||
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
|
||||
import { useScrollPosition } from "../hooks/useScrollPosition";
|
||||
import { t } from "../i18n";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
import { duplicateElements } from "../element/duplicate";
|
||||
|
||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
|
||||
import {
|
||||
LibraryMenuSection,
|
||||
LibraryMenuSectionGrid,
|
||||
} from "./LibraryMenuSection";
|
||||
|
||||
import Spinner from "./Spinner";
|
||||
import Stack from "./Stack";
|
||||
|
||||
|
@ -160,7 +162,11 @@ export default function LibraryMenuItems({
|
|||
...item,
|
||||
// duplicate each library item before inserting on canvas to confine
|
||||
// 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 { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { deepCopyElement } from "@excalidraw/excalidraw/element/duplicate";
|
||||
|
||||
import { EVENT } from "../../constants";
|
||||
import { deepCopyElement } from "../../element/newElement";
|
||||
import { KEYS } from "../../keys";
|
||||
import { CaptureUpdateAction } from "../../store";
|
||||
import { cloneJSON } from "../../utils";
|
||||
|
|
|
@ -13,7 +13,6 @@ import {
|
|||
vectorCross,
|
||||
pointsEqual,
|
||||
lineSegmentIntersectionPoints,
|
||||
round,
|
||||
PRECISION,
|
||||
} from "@excalidraw/math";
|
||||
import { isPointOnShape } from "@excalidraw/utils/collision";
|
||||
|
@ -24,7 +23,10 @@ import { KEYS } from "../keys";
|
|||
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
|
||||
import {
|
||||
arrayToMap,
|
||||
invariant,
|
||||
isBindingFallthroughEnabled,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
tupleToCoors,
|
||||
} from "../utils";
|
||||
|
||||
|
@ -36,11 +38,8 @@ import {
|
|||
import { intersectElementWithLineSegment } from "./collision";
|
||||
import { distanceToBindableElement } from "./distance";
|
||||
import {
|
||||
compareHeading,
|
||||
HEADING_DOWN,
|
||||
HEADING_RIGHT,
|
||||
HEADING_UP,
|
||||
headingForPointFromElement,
|
||||
headingIsHorizontal,
|
||||
vectorToHeading,
|
||||
type Heading,
|
||||
} from "./heading";
|
||||
|
@ -50,7 +49,6 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
|||
import {
|
||||
isArrowElement,
|
||||
isBindableElement,
|
||||
isBindingElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
|
@ -60,6 +58,10 @@ import {
|
|||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
import type { Mutable } from "../utility-types";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
import type {
|
||||
|
@ -837,14 +839,19 @@ export const updateBoundElements = (
|
|||
}> => update !== null,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(element, updates, {
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
updates,
|
||||
{
|
||||
...(changedElement.id === element.startBinding?.elementId
|
||||
? { startBinding: bindings.startBinding }
|
||||
: {}),
|
||||
...(changedElement.id === element.endBinding?.elementId
|
||||
? { endBinding: bindings.endBinding }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
elementsMap as NonDeletedSceneElementsMap,
|
||||
);
|
||||
|
||||
const boundText = getBoundTextElement(element, elementsMap);
|
||||
if (boundText && !boundText.isDeleted) {
|
||||
|
@ -925,103 +932,104 @@ const getDistanceForBinding = (
|
|||
|
||||
export const bindPointToSnapToElementOutline = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
bindableElement: ExcalidrawBindableElement | undefined,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
): 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 =
|
||||
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
|
||||
const globalP = pointFrom<GlobalPoint>(
|
||||
arrow.x + localP[0],
|
||||
arrow.y + localP[1],
|
||||
);
|
||||
const p = isRectanguloidElement(bindableElement)
|
||||
const edgePoint = isRectanguloidElement(bindableElement)
|
||||
? avoidRectangularCorner(bindableElement, globalP)
|
||||
: globalP;
|
||||
|
||||
if (bindableElement && aabb) {
|
||||
const elbowed = isElbowArrow(arrow);
|
||||
const center = getCenterForBounds(aabb);
|
||||
|
||||
const intersection = intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
lineSegment(
|
||||
center,
|
||||
pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(p, center)),
|
||||
Math.max(bindableElement.width, bindableElement.height),
|
||||
const adjacentPointIdx = startOrEnd === "start" ? 1 : arrow.points.length - 2;
|
||||
const adjacentPoint = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
arrow.x + arrow.points[adjacentPointIdx][0],
|
||||
arrow.y + arrow.points[adjacentPointIdx][1],
|
||||
),
|
||||
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];
|
||||
const currentDistance = pointDistance(p, center);
|
||||
const fullDistance = Math.max(
|
||||
pointDistance(intersection ?? p, center),
|
||||
PRECISION,
|
||||
);
|
||||
const ratio = round(currentDistance / fullDistance, 6);
|
||||
|
||||
switch (true) {
|
||||
case ratio > 0.9:
|
||||
if (
|
||||
currentDistance - fullDistance > FIXED_BINDING_DISTANCE ||
|
||||
// Too close to determine vector from intersection to p
|
||||
pointDistanceSq(p, intersection) < PRECISION
|
||||
) {
|
||||
return p;
|
||||
} else {
|
||||
intersection = intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
lineSegment(
|
||||
adjacentPoint,
|
||||
pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)),
|
||||
pointDistance(edgePoint, adjacentPoint) +
|
||||
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||
),
|
||||
adjacentPoint,
|
||||
),
|
||||
),
|
||||
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(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(p, intersection ?? center)),
|
||||
ratio > 1 ? FIXED_BINDING_DISTANCE : -FIXED_BINDING_DISTANCE,
|
||||
vectorNormalize(vectorFromPoint(edgePoint, intersection)),
|
||||
scalar,
|
||||
),
|
||||
intersection ?? center,
|
||||
);
|
||||
|
||||
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,
|
||||
intersection,
|
||||
);
|
||||
}
|
||||
|
||||
return edgePoint;
|
||||
};
|
||||
|
||||
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 = (
|
||||
sceneElements: readonly ExcalidrawElement[],
|
||||
oldElements: readonly ExcalidrawElement[],
|
||||
newElements: ExcalidrawElement[],
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
// There are three copying mechanisms: Copy-paste, duplication and alt-drag.
|
||||
// Only when alt-dragging the new "duplicates" act as the "old", while
|
||||
// the "old" elements act as the "new copy" - essentially working reverse
|
||||
// to the other two.
|
||||
duplicatesServeAsOld?: "duplicatesServeAsOld" | undefined,
|
||||
): void => {
|
||||
// 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
|
||||
duplicatedElementsMap: NonDeletedSceneElementsMap,
|
||||
) => {
|
||||
for (const element of newElements) {
|
||||
if ("boundElements" in element && element.boundElements) {
|
||||
Object.assign(element, {
|
||||
boundElements: element.boundElements.reduce(
|
||||
(
|
||||
sceneElements.filter(({ id }) =>
|
||||
allBoundElementIds.has(id),
|
||||
) as ExcalidrawLinearElement[]
|
||||
).forEach((element) => {
|
||||
const { startBinding, endBinding } = element;
|
||||
mutateElement(element, {
|
||||
startBinding: newBindingAfterDuplication(
|
||||
startBinding,
|
||||
oldIdToDuplicatedId,
|
||||
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
|
||||
binding,
|
||||
) => {
|
||||
const newBindingId = oldIdToDuplicatedId.get(binding.id);
|
||||
if (newBindingId) {
|
||||
acc.push({ ...binding, id: newBindingId });
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
),
|
||||
endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId),
|
||||
});
|
||||
}
|
||||
|
||||
if ("containerId" in element && element.containerId) {
|
||||
Object.assign(element, {
|
||||
containerId: oldIdToDuplicatedId.get(element.containerId) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Update the bindable shapes
|
||||
sceneElements
|
||||
.filter(({ id }) => allBindableElementIds.has(id))
|
||||
.forEach((bindableElement) => {
|
||||
const oldElementId = duplicateIdToOldId.get(bindableElement.id);
|
||||
const boundElements = sceneElements.find(
|
||||
({ id }) => id === oldElementId,
|
||||
)?.boundElements;
|
||||
|
||||
if (boundElements && boundElements.length > 0) {
|
||||
mutateElement(bindableElement, {
|
||||
boundElements: boundElements.map((boundElement) =>
|
||||
oldIdToDuplicatedId.has(boundElement.id)
|
||||
if ("endBinding" in element && element.endBinding) {
|
||||
const newEndBindingId = oldIdToDuplicatedId.get(
|
||||
element.endBinding.elementId,
|
||||
);
|
||||
Object.assign(element, {
|
||||
endBinding: newEndBindingId
|
||||
? {
|
||||
id: oldIdToDuplicatedId.get(boundElement.id)!,
|
||||
type: boundElement.type,
|
||||
...element.endBinding,
|
||||
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 = (
|
||||
binding: PointBinding | null,
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
): PointBinding | null => {
|
||||
if (binding == null) {
|
||||
return null;
|
||||
if (isElbowArrow(element)) {
|
||||
Object.assign(
|
||||
element,
|
||||
updateElbowArrowPoints(element, duplicatedElementsMap, {
|
||||
points: [
|
||||
element.points[0],
|
||||
element.points[element.points.length - 1],
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...binding,
|
||||
elementId: oldIdToDuplicatedId.get(binding.elementId) ?? binding.elementId,
|
||||
};
|
||||
};
|
||||
|
||||
export const fixBindingsAfterDeletion = (
|
||||
|
|
|
@ -1,16 +1,32 @@
|
|||
import React from "react";
|
||||
import { pointFrom } 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 { 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 { duplicateElement, duplicateElements } from "./newElement";
|
||||
|
||||
import { duplicateElement, duplicateElements } from "./duplicate";
|
||||
|
||||
import type { ExcalidrawLinearElement } from "./types";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const assertCloneObjects = (source: any, clone: any) => {
|
||||
for (const key in clone) {
|
||||
if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) {
|
||||
|
@ -45,7 +61,7 @@ describe("duplicating single elements", () => {
|
|||
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);
|
||||
|
||||
|
@ -64,6 +80,8 @@ describe("duplicating single elements", () => {
|
|||
...element,
|
||||
id: copy.id,
|
||||
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 clonedElements = duplicateElements(origElements);
|
||||
const { newElements: clonedElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
});
|
||||
|
||||
// generic id in-equality checks
|
||||
// --------------------------------------------------------------------------
|
||||
|
@ -206,6 +227,7 @@ describe("duplicating multiple elements", () => {
|
|||
type: clonedText1.type,
|
||||
}),
|
||||
);
|
||||
expect(clonedRectangle.type).toBe("rectangle");
|
||||
|
||||
clonedArrows.forEach((arrow) => {
|
||||
expect(
|
||||
|
@ -281,7 +303,7 @@ describe("duplicating multiple elements", () => {
|
|||
|
||||
const arrow3 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
id: "arrow3",
|
||||
startBinding: {
|
||||
elementId: "rectangle-not-exists",
|
||||
focus: 0.2,
|
||||
|
@ -299,9 +321,11 @@ describe("duplicating multiple elements", () => {
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
|
||||
const clonedElements = duplicateElements(
|
||||
origElements,
|
||||
) as any as typeof origElements;
|
||||
const { newElements: clonedElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
}) as any as { newElements: typeof origElements };
|
||||
|
||||
const [
|
||||
clonedRectangle,
|
||||
clonedText1,
|
||||
|
@ -321,7 +345,6 @@ describe("duplicating multiple elements", () => {
|
|||
elementId: clonedRectangle.id,
|
||||
});
|
||||
expect(clonedArrow2.endBinding).toBe(null);
|
||||
|
||||
expect(clonedArrow3.startBinding).toBe(null);
|
||||
expect(clonedArrow3.endBinding).toEqual({
|
||||
...arrow3.endBinding,
|
||||
|
@ -345,9 +368,10 @@ describe("duplicating multiple elements", () => {
|
|||
});
|
||||
|
||||
const origElements = [rectangle1, rectangle2, rectangle3] as const;
|
||||
const clonedElements = duplicateElements(
|
||||
origElements,
|
||||
) as any as typeof origElements;
|
||||
const { newElements: clonedElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
}) as any as { newElements: typeof origElements };
|
||||
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
|
||||
clonedElements;
|
||||
|
||||
|
@ -368,10 +392,305 @@ describe("duplicating multiple elements", () => {
|
|||
groupIds: ["g1"],
|
||||
});
|
||||
|
||||
const [clonedRectangle1] = duplicateElements([rectangle1]);
|
||||
const {
|
||||
newElements: [clonedRectangle1],
|
||||
} = duplicateElements({ type: "everything", elements: [rectangle1] });
|
||||
|
||||
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
|
||||
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);
|
||||
});
|
||||
|
||||
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", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
|
@ -404,8 +404,8 @@ describe("elbow arrow ui", () => {
|
|||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[0, 100],
|
||||
[90, 100],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -238,16 +238,6 @@ const handleSegmentRenormalization = (
|
|||
nextPoints.map((p) =>
|
||||
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,
|
||||
),
|
||||
],
|
||||
startBinding &&
|
||||
getBindableElementForId(startBinding.elementId, elementsMap),
|
||||
endBinding && getBindableElementForId(endBinding.elementId, elementsMap),
|
||||
{ isDragging: false },
|
||||
);
|
||||
|
||||
|
@ -983,6 +970,8 @@ export const updateElbowArrowPoints = (
|
|||
);
|
||||
}
|
||||
|
||||
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
|
||||
|
||||
const updatedPoints: readonly LocalPoint[] = updates.points
|
||||
? updates.points && updates.points.length === 2
|
||||
? arrow.points.map((p, idx) =>
|
||||
|
@ -995,7 +984,7 @@ export const updateElbowArrowPoints = (
|
|||
: updates.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
|
||||
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
|
||||
const {
|
||||
|
@ -1016,11 +1005,12 @@ export const updateElbowArrowPoints = (
|
|||
getBindableElementForId(startBinding.elementId, elementsMap);
|
||||
const endElement =
|
||||
endBinding && getBindableElementForId(endBinding.elementId, elementsMap);
|
||||
const areUpdatedPointsValid = validateElbowPoints(updatedPoints);
|
||||
|
||||
if (
|
||||
(startBinding && !startElement) ||
|
||||
(endBinding && !endElement) ||
|
||||
(elementsMap.size === 0 && validateElbowPoints(updatedPoints)) ||
|
||||
(startBinding && !startElement && areUpdatedPointsValid) ||
|
||||
(endBinding && !endElement && areUpdatedPointsValid) ||
|
||||
(elementsMap.size === 0 && areUpdatedPointsValid) ||
|
||||
(Object.keys(restOfTheUpdates).length === 0 &&
|
||||
(startElement?.id !== startBinding?.elementId ||
|
||||
endElement?.id !== endBinding?.elementId))
|
||||
|
@ -1055,12 +1045,22 @@ export const updateElbowArrowPoints = (
|
|||
},
|
||||
elementsMap,
|
||||
updatedPoints,
|
||||
startElement,
|
||||
endElement,
|
||||
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
|
||||
|
@ -1084,7 +1084,7 @@ export const updateElbowArrowPoints = (
|
|||
arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity),
|
||||
),
|
||||
) &&
|
||||
validateElbowPoints(updatedPoints)
|
||||
areUpdatedPointsValid
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
@ -1195,8 +1195,6 @@ const getElbowArrowData = (
|
|||
},
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
nextPoints: readonly LocalPoint[],
|
||||
startElement: ExcalidrawBindableElement | null,
|
||||
endElement: ExcalidrawBindableElement | null,
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
|
@ -1211,8 +1209,8 @@ const getElbowArrowData = (
|
|||
GlobalPoint
|
||||
>(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y));
|
||||
|
||||
let hoveredStartElement = startElement;
|
||||
let hoveredEndElement = endElement;
|
||||
let hoveredStartElement = null;
|
||||
let hoveredEndElement = null;
|
||||
if (options?.isDragging) {
|
||||
const elements = Array.from(elementsMap.values());
|
||||
hoveredStartElement =
|
||||
|
@ -1221,39 +1219,47 @@ const getElbowArrowData = (
|
|||
elementsMap,
|
||||
elements,
|
||||
options?.zoom,
|
||||
) || startElement;
|
||||
) || null;
|
||||
hoveredEndElement =
|
||||
getHoveredElement(
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
elements,
|
||||
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(
|
||||
{
|
||||
...arrow,
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
points: nextPoints,
|
||||
} as ExcalidrawElbowArrowElement,
|
||||
"start",
|
||||
arrow.startBinding?.fixedPoint,
|
||||
origStartGlobalPoint,
|
||||
startElement,
|
||||
hoveredStartElement,
|
||||
options?.isDragging,
|
||||
);
|
||||
const endGlobalPoint = getGlobalPoint(
|
||||
{
|
||||
...arrow,
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
points: nextPoints,
|
||||
} as ExcalidrawElbowArrowElement,
|
||||
"end",
|
||||
arrow.endBinding?.fixedPoint,
|
||||
origEndGlobalPoint,
|
||||
endElement,
|
||||
hoveredEndElement,
|
||||
options?.isDragging,
|
||||
);
|
||||
|
@ -2199,36 +2205,35 @@ const getGlobalPoint = (
|
|||
startOrEnd: "start" | "end",
|
||||
fixedPointRatio: [number, number] | undefined | null,
|
||||
initialPoint: GlobalPoint,
|
||||
boundElement?: ExcalidrawBindableElement | null,
|
||||
hoveredElement?: ExcalidrawBindableElement | null,
|
||||
element?: ExcalidrawBindableElement | null,
|
||||
isDragging?: boolean,
|
||||
): GlobalPoint => {
|
||||
if (isDragging) {
|
||||
if (hoveredElement) {
|
||||
if (element) {
|
||||
const snapPoint = bindPointToSnapToElementOutline(
|
||||
arrow,
|
||||
hoveredElement,
|
||||
element,
|
||||
startOrEnd,
|
||||
);
|
||||
|
||||
return snapToMid(hoveredElement, snapPoint);
|
||||
return snapToMid(element, snapPoint);
|
||||
}
|
||||
|
||||
return initialPoint;
|
||||
}
|
||||
|
||||
if (boundElement) {
|
||||
if (element) {
|
||||
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
|
||||
fixedPointRatio || [0, 0],
|
||||
boundElement,
|
||||
element,
|
||||
);
|
||||
|
||||
// NOTE: Resize scales the binding position point too, so we need to update it
|
||||
return Math.abs(
|
||||
distanceToBindableElement(boundElement, fixedGlobalPoint) -
|
||||
distanceToBindableElement(element, fixedGlobalPoint) -
|
||||
FIXED_BINDING_DISTANCE,
|
||||
) > 0.01
|
||||
? bindPointToSnapToElementOutline(arrow, boundElement, startOrEnd)
|
||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
|
||||
: 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 { KEYS } from "../keys";
|
||||
|
@ -94,7 +94,7 @@ const getNodeRelatives = (
|
|||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
||||
edgePoint[0] + el.x,
|
||||
edgePoint[1] + el.y,
|
||||
] as Readonly<LocalPoint>);
|
||||
] as Readonly<GlobalPoint>);
|
||||
|
||||
acc.push({
|
||||
relative,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
normalizeRadians,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
pointScaleFromOrigin,
|
||||
|
@ -30,8 +31,9 @@ export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
|
|||
b: Point,
|
||||
) => {
|
||||
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) {
|
||||
return HEADING_UP;
|
||||
} 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
|
||||
// close fitting bounding box, then creating 4 search cones around the center of
|
||||
// the external bbox.
|
||||
export const headingForPointFromElement = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
export const headingForPointFromElement = <Point extends GlobalPoint>(
|
||||
element: Readonly<ExcalidrawBindableElement>,
|
||||
aabb: Readonly<Bounds>,
|
||||
p: Readonly<Point>,
|
||||
|
|
|
@ -14,8 +14,8 @@ export {
|
|||
newLinearElement,
|
||||
newArrowElement,
|
||||
newImageElement,
|
||||
duplicateElement,
|
||||
} from "./newElement";
|
||||
export { duplicateElement } from "./duplicate";
|
||||
export {
|
||||
getElementAbsoluteCoords,
|
||||
getElementBounds,
|
||||
|
|
|
@ -25,14 +25,19 @@ import {
|
|||
import { getGridPoint } from "../snapping";
|
||||
import { invariant, tupleToCoors } from "../utils";
|
||||
|
||||
import Scene from "../scene/Scene";
|
||||
|
||||
import {
|
||||
bindOrUnbindLinearElement,
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
} from "./binding";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
import { getElementPointsCoords, getMinMaxXYFromCurvePathOps } from "./bounds";
|
||||
import { headingIsHorizontal, vectorToHeading } from "./heading";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { bumpVersion, mutateElement } from "./mutateElement";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import {
|
||||
isBindingElement,
|
||||
|
@ -57,7 +62,6 @@ import type {
|
|||
FixedSegment,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import type Scene from "../scene/Scene";
|
||||
import type { Store } from "../store";
|
||||
import type {
|
||||
AppState,
|
||||
|
@ -67,6 +71,7 @@ import type {
|
|||
NullableGridSize,
|
||||
Zoom,
|
||||
} from "../types";
|
||||
|
||||
import type { Mutable } from "../utility-types";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
|
@ -232,15 +237,15 @@ export class LinearElementEditor {
|
|||
) => void,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scene: Scene,
|
||||
): boolean {
|
||||
): LinearElementEditor | null {
|
||||
if (!linearElementEditor) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
const { elementId } = linearElementEditor;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -248,24 +253,18 @@ export class LinearElementEditor {
|
|||
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
|
||||
linearElementEditor.pointerDownState.lastClickedPoint !== 0
|
||||
) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedPointsIndices = isElbowArrow(element)
|
||||
? linearElementEditor.selectedPointsIndices
|
||||
?.reduce(
|
||||
(startEnd, index) =>
|
||||
(index === 0
|
||||
? [0, startEnd[1]]
|
||||
: [startEnd[0], element.points.length - 1]) as [
|
||||
boolean | number,
|
||||
boolean | number,
|
||||
],
|
||||
[false, false] as [number | boolean, number | boolean],
|
||||
)
|
||||
.filter(
|
||||
(idx: number | boolean): idx is number => typeof idx === "number",
|
||||
)
|
||||
? [
|
||||
!!linearElementEditor.selectedPointsIndices?.includes(0)
|
||||
? 0
|
||||
: undefined,
|
||||
!!linearElementEditor.selectedPointsIndices?.find((idx) => idx > 0)
|
||||
? element.points.length - 1
|
||||
: undefined,
|
||||
].filter((idx): idx is number => idx !== undefined)
|
||||
: linearElementEditor.selectedPointsIndices;
|
||||
const lastClickedPoint = isElbowArrow(element)
|
||||
? linearElementEditor.pointerDownState.lastClickedPoint > 0
|
||||
|
@ -274,9 +273,7 @@ export class LinearElementEditor {
|
|||
: linearElementEditor.pointerDownState.lastClickedPoint;
|
||||
|
||||
// point that's being dragged (out of all selected points)
|
||||
const draggingPoint = element.points[lastClickedPoint] as
|
||||
| [number, number]
|
||||
| undefined;
|
||||
const draggingPoint = element.points[lastClickedPoint];
|
||||
|
||||
if (selectedPointsIndices && draggingPoint) {
|
||||
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(
|
||||
|
@ -1264,6 +1279,7 @@ export class LinearElementEditor {
|
|||
startBinding?: PointBinding | null;
|
||||
endBinding?: PointBinding | null;
|
||||
},
|
||||
sceneElementsMap?: NonDeletedSceneElementsMap,
|
||||
) {
|
||||
const { points } = element;
|
||||
|
||||
|
@ -1307,6 +1323,7 @@ export class LinearElementEditor {
|
|||
dragging || targetPoint.isDragging === true,
|
||||
false,
|
||||
),
|
||||
sceneElementsMap,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -1420,6 +1437,7 @@ export class LinearElementEditor {
|
|||
options?: {
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
sceneElementsMap?: NonDeletedSceneElementsMap;
|
||||
},
|
||||
) {
|
||||
if (isElbowArrow(element)) {
|
||||
|
@ -1445,9 +1463,28 @@ export class LinearElementEditor {
|
|||
|
||||
updates.points = Array.from(nextPoints);
|
||||
|
||||
if (!options?.sceneElementsMap || Scene.getScene(element)) {
|
||||
mutateElement(element, updates, true, {
|
||||
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 {
|
||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||
const prevCoords = getElementPointsCoords(element, element.points);
|
||||
|
|
|
@ -6,21 +6,14 @@ import {
|
|||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
ORIG_ID,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import {
|
||||
arrayToMap,
|
||||
getFontString,
|
||||
getUpdatedTimestamp,
|
||||
isTestEnv,
|
||||
} from "../utils";
|
||||
import { getFontString, getUpdatedTimestamp } from "../utils";
|
||||
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import { bumpVersion, newElementWith } from "./mutateElement";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
import { getBoundTextMaxWidth } from "./textElement";
|
||||
import { normalizeText, measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
|
@ -35,7 +28,6 @@ import type {
|
|||
ExcalidrawGenericElement,
|
||||
NonDeleted,
|
||||
TextAlign,
|
||||
GroupId,
|
||||
VerticalAlign,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
|
@ -50,8 +42,7 @@ import type {
|
|||
FixedSegment,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import type { AppState } from "../types";
|
||||
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
import type { MarkOptional, Merge } from "../utility-types";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
|
@ -538,260 +529,3 @@ export const newImageElement = (
|
|||
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,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { getFontString, arrayToMap } from "../utils";
|
||||
import { getFontString } from "../utils";
|
||||
|
||||
import {
|
||||
resetOriginalContainerCache,
|
||||
|
@ -112,48 +112,6 @@ export const redrawTextBoundingBox = (
|
|||
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 = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
|
|
|
@ -214,7 +214,10 @@ export const isSelectedViaGroup = (
|
|||
) => getSelectedGroupForElement(appState, element) != null;
|
||||
|
||||
export const getSelectedGroupForElement = (
|
||||
appState: InteractiveCanvasAppState,
|
||||
appState: Pick<
|
||||
InteractiveCanvasAppState,
|
||||
"editingGroupId" | "selectedGroupIds"
|
||||
>,
|
||||
element: ExcalidrawElement,
|
||||
) =>
|
||||
element.groupIds
|
||||
|
|
|
@ -886,24 +886,25 @@ const _renderInteractiveScene = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isElbowArrow(selectedElements[0]) &&
|
||||
appState.selectedLinearElement &&
|
||||
appState.selectedLinearElement.segmentMidPointHoveredCoords
|
||||
) {
|
||||
// Arrows have a different highlight behavior when
|
||||
// they are the only selected element
|
||||
if (appState.selectedLinearElement) {
|
||||
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);
|
||||
} else if (
|
||||
appState.selectedLinearElement &&
|
||||
appState.selectedLinearElement.hoverPointIndex >= 0 &&
|
||||
!(
|
||||
isElbowArrow(selectedElements[0]) &&
|
||||
appState.selectedLinearElement.hoverPointIndex > 0 &&
|
||||
appState.selectedLinearElement.hoverPointIndex <
|
||||
selectedElements[0].points.length - 1
|
||||
)
|
||||
isElbowArrow(firstSelectedLinear)
|
||||
? editor.hoverPointIndex === 0 ||
|
||||
editor.hoverPointIndex === firstSelectedLinear.points.length - 1
|
||||
: editor.hoverPointIndex >= 0
|
||||
) {
|
||||
renderLinearElementPointHighlight(context, appState, elementsMap);
|
||||
}
|
||||
}
|
||||
|
||||
// Paint selected elements
|
||||
if (!appState.multiElement && !appState.editingLinearElement) {
|
||||
|
@ -1073,7 +1074,7 @@ const _renderInteractiveScene = ({
|
|||
const dashedLinePadding =
|
||||
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
|
||||
context.fillStyle = oc.white;
|
||||
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
|
||||
const [x1, y1, x2, y2] = getCommonBounds(selectedElements, elementsMap);
|
||||
const initialLineDash = context.getLineDash();
|
||||
context.setLineDash([2 / appState.zoom.value]);
|
||||
const lineWidth = context.lineWidth;
|
||||
|
|
|
@ -2,10 +2,11 @@ import { getDefaultAppState } from "./appState";
|
|||
import { AppStateChange, ElementsChange } from "./change";
|
||||
import { ENV } from "./constants";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import { Emitter } from "./emitter";
|
||||
import { isShallowEqual } from "./utils";
|
||||
|
||||
import { deepCopyElement } from "./element/duplicate";
|
||||
|
||||
import type { OrderedExcalidrawElement } from "./element/types";
|
||||
import type { AppState, ObservedAppState } from "./types";
|
||||
import type { ValueOf } from "./utility-types";
|
||||
|
|
|
@ -2606,8 +2606,8 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
|||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 238820263,
|
||||
"version": 5,
|
||||
"versionNonce": 400692809,
|
||||
"width": 20,
|
||||
"x": 0,
|
||||
"y": 10,
|
||||
|
|
|
@ -15172,9 +15172,11 @@ History {
|
|||
"selectedElementIds": {
|
||||
"id61": true,
|
||||
},
|
||||
"selectedLinearElementId": "id61",
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedLinearElementId": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -18946,7 +18948,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
|||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"version": 4,
|
||||
"width": 100,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
|
@ -18980,7 +18982,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
|||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"version": 4,
|
||||
"width": 100,
|
||||
"x": 110,
|
||||
"y": 110,
|
||||
|
@ -19014,7 +19016,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
|||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"version": 7,
|
||||
"width": 100,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
|
@ -19048,7 +19050,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
|||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"version": 7,
|
||||
"width": 100,
|
||||
"x": 110,
|
||||
"y": 110,
|
||||
|
|
|
@ -11,7 +11,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
|
|||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id2",
|
||||
"index": "a0",
|
||||
"index": "Zz",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
|
@ -26,8 +26,8 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
|
|||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 5,
|
||||
"versionNonce": 400692809,
|
||||
"version": 6,
|
||||
"versionNonce": 1604849351,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
|
@ -45,7 +45,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
|
|||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"index": "a1",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
|
@ -60,7 +60,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
|
|||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"version": 5,
|
||||
"versionNonce": 23633383,
|
||||
"width": 30,
|
||||
"x": -10,
|
||||
|
|
|
@ -2139,7 +2139,7 @@ History {
|
|||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 10,
|
||||
"index": "a0",
|
||||
"index": "Zz",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
|
@ -2164,12 +2164,10 @@ History {
|
|||
"updated": Map {
|
||||
"id0" => Delta {
|
||||
"deleted": {
|
||||
"index": "a1",
|
||||
"x": 20,
|
||||
"y": 20,
|
||||
},
|
||||
"inserted": {
|
||||
"index": "a0",
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
|
@ -10631,7 +10629,7 @@ History {
|
|||
"id7",
|
||||
],
|
||||
"height": 10,
|
||||
"index": "a0",
|
||||
"index": "Zx",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
|
@ -10664,7 +10662,7 @@ History {
|
|||
"id7",
|
||||
],
|
||||
"height": 10,
|
||||
"index": "a1",
|
||||
"index": "Zy",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
|
@ -10697,7 +10695,7 @@ History {
|
|||
"id7",
|
||||
],
|
||||
"height": 10,
|
||||
"index": "a2",
|
||||
"index": "Zz",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
|
@ -10722,36 +10720,30 @@ History {
|
|||
"updated": Map {
|
||||
"id0" => Delta {
|
||||
"deleted": {
|
||||
"index": "a3",
|
||||
"x": 20,
|
||||
"y": 20,
|
||||
},
|
||||
"inserted": {
|
||||
"index": "a0",
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
},
|
||||
"id1" => Delta {
|
||||
"deleted": {
|
||||
"index": "a4",
|
||||
"x": 40,
|
||||
"y": 20,
|
||||
},
|
||||
"inserted": {
|
||||
"index": "a1",
|
||||
"x": 30,
|
||||
"y": 10,
|
||||
},
|
||||
},
|
||||
"id2" => Delta {
|
||||
"deleted": {
|
||||
"index": "a5",
|
||||
"x": 60,
|
||||
"y": 20,
|
||||
},
|
||||
"inserted": {
|
||||
"index": "a2",
|
||||
"x": 50,
|
||||
"y": 10,
|
||||
},
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
/* eslint-disable no-lone-blocks */
|
||||
import { generateKeyBetween } from "fractional-indexing";
|
||||
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { InvalidFractionalIndexError } from "../errors";
|
||||
import {
|
||||
syncInvalidIndices,
|
||||
|
@ -10,6 +9,8 @@ import {
|
|||
} from "../fractionalIndex";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
import { deepCopyElement } from "../element/duplicate";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
|
||||
import type { ExcalidrawElement, FractionalIndex } from "../element/types";
|
||||
|
|
|
@ -1184,3 +1184,7 @@ it(
|
|||
expect(API.getSelectedElements().length).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
//
|
||||
// DEPRECATED: DO NOT ADD TESTS HERE
|
||||
//
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue