Merge branch 'master' into mrazator/separate-element-into-standalone-package

This commit is contained in:
Marcel Mraz 2025-03-24 09:52:29 +01:00
commit 49ee6fbc14
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
30 changed files with 1299 additions and 1093 deletions

View file

@ -3,6 +3,9 @@ import {
arrayToMap,
isBindingFallthroughEnabled,
tupleToCoors,
invariant,
isDevEnv,
isTestEnv,
} from "@excalidraw/common";
import {
@ -20,7 +23,6 @@ import {
vectorCross,
pointsEqual,
lineSegmentIntersectionPoints,
round,
PRECISION,
} from "@excalidraw/math";
@ -32,6 +34,8 @@ import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import {
getCenterForBounds,
getElementBounds,
@ -40,11 +44,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";
@ -54,7 +55,6 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
import {
isArrowElement,
isBindableElement,
isBindingElement,
isBoundToContainer,
isElbowArrow,
isFixedPointBinding,
@ -65,6 +65,7 @@ import {
} from "./typeChecks";
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow";
import type { Bounds } from "./bounds";
import type { ElementUpdate } from "./mutateElement";
@ -841,14 +842,19 @@ export const updateBoundElements = (
}> => update !== null,
);
LinearElementEditor.movePoints(element, updates, {
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
: {}),
...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
: {}),
});
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) {
@ -929,103 +935,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;
const elbowed = isElbowArrow(arrow);
const center = getCenterForBounds(aabb);
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,
);
if (bindableElement && aabb) {
const center = getCenterForBounds(aabb);
const intersection = intersectElementWithLineSegment(
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(
center,
otherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(p, center)),
Math.max(bindableElement.width, bindableElement.height),
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
center,
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;
}
return pointFromVector(
} else {
intersection = intersectElementWithLineSegment(
bindableElement,
lineSegment(
adjacentPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(p, intersection ?? center)),
ratio > 1 ? FIXED_BINDING_DISTANCE : -FIXED_BINDING_DISTANCE,
vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)),
pointDistance(edgePoint, adjacentPoint) +
Math.max(bindableElement.width, bindableElement.height) * 2,
),
intersection ?? center,
);
default:
return headingToMidBindPoint(p, bindableElement, aabb);
}
adjacentPoint,
),
),
FIXED_BINDING_DISTANCE,
).sort(
(g, h) =>
pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint),
)[0];
}
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,
);
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(edgePoint, intersection)),
scalar,
),
intersection,
);
}
return edgePoint;
};
export const avoidRectangularCorner = (
@ -1415,107 +1422,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);
}
duplicatedElementsMap: NonDeletedSceneElementsMap,
) => {
for (const element of newElements) {
if ("boundElements" in element && element.boundElements) {
Object.assign(element, {
boundElements: element.boundElements.reduce(
(
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding,
) => {
const newBindingId = oldIdToDuplicatedId.get(binding.id);
if (newBindingId) {
acc.push({ ...binding, id: newBindingId });
}
return acc;
},
[],
),
});
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)!);
}
if ("containerId" in element && element.containerId) {
Object.assign(element, {
containerId: oldIdToDuplicatedId.get(element.containerId) ?? null,
});
}
});
// Update the linear elements
(
sceneElements.filter(({ id }) =>
allBoundElementIds.has(id),
) as ExcalidrawLinearElement[]
).forEach((element) => {
const { startBinding, endBinding } = element;
mutateElement(element, {
startBinding: newBindingAfterDuplication(
startBinding,
oldIdToDuplicatedId,
),
endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId),
});
});
if ("endBinding" in element && element.endBinding) {
const newEndBindingId = oldIdToDuplicatedId.get(
element.endBinding.elementId,
);
Object.assign(element, {
endBinding: newEndBindingId
? {
...element.endBinding,
elementId: newEndBindingId,
}
: null,
});
}
if ("startBinding" in element && element.startBinding) {
const newEndBindingId = oldIdToDuplicatedId.get(
element.startBinding.elementId,
);
Object.assign(element, {
startBinding: newEndBindingId
? {
...element.startBinding,
elementId: newEndBindingId,
}
: 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)
? {
id: oldIdToDuplicatedId.get(boundElement.id)!,
type: boundElement.type,
}
: boundElement,
),
});
}
});
};
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 = (

View file

@ -0,0 +1,485 @@
import {
ORIG_ID,
randomId,
randomInteger,
arrayToMap,
castArray,
findLastIndex,
getUpdatedTimestamp,
isTestEnv,
} from "@excalidraw/common";
import type { Mutable } from "@excalidraw/common/utility-types";
import type { AppState } from "@excalidraw/excalidraw/types";
import {
getElementsInGroup,
getNewGroupIdsForDuplication,
getSelectedGroupForElement,
} from "./groups";
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 {
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,
});
};

View file

@ -244,16 +244,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,
),
),
) ?? [],
),
@ -347,9 +337,6 @@ const handleSegmentRelease = (
y,
),
],
startBinding &&
getBindableElementForId(startBinding.elementId, elementsMap),
endBinding && getBindableElementForId(endBinding.elementId, elementsMap),
{ isDragging: false },
);
@ -989,6 +976,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) =>
@ -1001,7 +990,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 {
@ -1022,11 +1011,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))
@ -1061,12 +1051,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
@ -1090,7 +1090,7 @@ export const updateElbowArrowPoints = (
arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity),
),
) &&
validateElbowPoints(updatedPoints)
areUpdatedPointsValid
) {
return {};
}
@ -1201,8 +1201,6 @@ const getElbowArrowData = (
},
elementsMap: NonDeletedSceneElementsMap,
nextPoints: readonly LocalPoint[],
startElement: ExcalidrawBindableElement | null,
endElement: ExcalidrawBindableElement | null,
options?: {
isDragging?: boolean;
zoom?: AppState["zoom"];
@ -1217,8 +1215,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 =
@ -1227,39 +1225,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,
);
@ -2205,36 +2211,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;
}

View file

@ -1,6 +1,6 @@
import { KEYS, invariant, toBrandedType } from "@excalidraw/common";
import { pointFrom, type LocalPoint } from "@excalidraw/math";
import { type GlobalPoint, pointFrom, type LocalPoint } from "@excalidraw/math";
import type {
AppState,
@ -96,7 +96,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,

View file

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

View file

@ -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>,

View file

@ -20,12 +20,13 @@ import {
tupleToCoors,
} from "@excalidraw/common";
// TODO: remove the dependency on the scene
import Scene from "@excalidraw/excalidraw/scene/Scene";
import type { Store } from "@excalidraw/excalidraw/store";
import type { Radians } from "@excalidraw/math";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type {
AppState,
PointerCoords,
@ -47,8 +48,11 @@ import {
getElementPointsCoords,
getMinMaxXYFromCurvePathOps,
} from "./bounds";
import { updateElbowArrowPoints } from "./elbowArrow";
import { headingIsHorizontal, vectorToHeading } from "./heading";
import { mutateElement } from "./mutateElement";
import { bumpVersion, mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import {
isBindingElement,
@ -247,15 +251,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 (
@ -263,24 +267,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
@ -289,9 +287,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 (
@ -399,10 +395,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(
@ -1279,6 +1293,7 @@ export class LinearElementEditor {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
},
sceneElementsMap?: NonDeletedSceneElementsMap,
) {
const { points } = element;
@ -1322,6 +1337,7 @@ export class LinearElementEditor {
dragging || targetPoint.isDragging === true,
false,
),
sceneElementsMap,
},
);
}
@ -1435,6 +1451,7 @@ export class LinearElementEditor {
options?: {
isDragging?: boolean;
zoom?: AppState["zoom"];
sceneElementsMap?: NonDeletedSceneElementsMap;
},
) {
if (isElbowArrow(element)) {
@ -1460,9 +1477,28 @@ export class LinearElementEditor {
updates.points = Array.from(nextPoints);
mutateElement(element, updates, true, {
isDragging: options?.isDragging,
});
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);

View file

@ -4,36 +4,26 @@ import {
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
ORIG_ID,
VERTICAL_ALIGN,
randomInteger,
randomId,
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
getLineHeight,
} from "@excalidraw/common";
import type { Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type {
MarkOptional,
Merge,
Mutable,
} from "@excalidraw/common/utility-types";
import type { MarkOptional, Merge } from "@excalidraw/common/utility-types";
import {
getElementAbsoluteCoords,
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";
import { getNewGroupIdsForDuplication } from "./groups";
import type {
ExcalidrawElement,
@ -43,7 +33,6 @@ import type {
ExcalidrawGenericElement,
NonDeleted,
TextAlign,
GroupId,
VerticalAlign,
Arrowhead,
ExcalidrawFreeDrawElement,
@ -544,260 +533,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;
};

View file

@ -6,7 +6,6 @@ import {
TEXT_ALIGN,
VERTICAL_ALIGN,
getFontString,
arrayToMap,
} from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
@ -117,48 +116,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,

View file

@ -1,15 +1,38 @@
import React from "react";
import { pointFrom } from "@excalidraw/math";
import { FONT_FAMILY, ROUNDNESS, isPrimitive } from "@excalidraw/common";
import {
FONT_FAMILY,
ORIG_ID,
ROUNDNESS,
isPrimitive,
} from "@excalidraw/common";
import { Excalidraw } from "@excalidraw/excalidraw";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
assertElements,
getCloneByOrigId,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import type { LocalPoint } from "@excalidraw/math";
import { mutateElement } from "../src/mutateElement";
import { duplicateElement, duplicateElements } from "../src/newElement";
import { duplicateElement, duplicateElements } from "../src/duplicate";
import type { ExcalidrawLinearElement } from "../src/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])) {
@ -44,7 +67,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);
@ -63,6 +86,8 @@ describe("duplicating single elements", () => {
...element,
id: copy.id,
seed: copy.seed,
version: copy.version,
versionNonce: copy.versionNonce,
});
});
@ -148,7 +173,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
// --------------------------------------------------------------------------
@ -205,6 +233,7 @@ describe("duplicating multiple elements", () => {
type: clonedText1.type,
}),
);
expect(clonedRectangle.type).toBe("rectangle");
clonedArrows.forEach((arrow) => {
expect(
@ -280,7 +309,7 @@ describe("duplicating multiple elements", () => {
const arrow3 = API.createElement({
type: "arrow",
id: "arrow2",
id: "arrow3",
startBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
@ -298,9 +327,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,
@ -320,7 +351,6 @@ describe("duplicating multiple elements", () => {
elementId: clonedRectangle.id,
});
expect(clonedArrow2.endBinding).toBe(null);
expect(clonedArrow3.startBinding).toBe(null);
expect(clonedArrow3.endBinding).toEqual({
...arrow3.endBinding,
@ -344,9 +374,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;
@ -367,10 +398,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 },
]);
});
});

View file

@ -360,7 +360,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,
@ -406,8 +406,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],
]);
});

View file

@ -9,7 +9,7 @@ import {
validateFractionalIndices,
} from "@excalidraw/element/fractionalIndex";
import { deepCopyElement } from "@excalidraw/element/newElement";
import { deepCopyElement } from "@excalidraw/element/duplicate";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";