Compare commits

..

24 Commits

Author SHA1 Message Date
Ryan Di e98e2cccb8 lint 2026-04-28 21:21:55 +10:00
Ryan Di 8424d78254 fix(line-snap): prefer bindable targets over external snaps 2026-04-28 21:16:32 +10:00
Ryan Di 8763bebb59 perf(line-snap): reuse scene elements while snapping linear points 2026-04-28 21:07:11 +10:00
Ryan Di 483a225eac refactor(line-snap): rename axis snap helpers 2026-04-28 21:06:46 +10:00
Ryan Di 79e802d9ed perf(snapping): reduce line point snap scanning 2026-04-28 17:06:54 +10:00
Ryan Di 309849925d Revert "refactor(linear): split point snapping helpers"
This reverts commit 702e029755.
2026-04-28 16:22:49 +10:00
Ryan Di 971237c0df Revert "refactor(snapping): clarify linear point reference options"
This reverts commit 79beed3f5c.
2026-04-28 16:22:49 +10:00
Ryan Di 79beed3f5c refactor(snapping): clarify linear point reference options 2026-04-28 16:16:10 +10:00
Ryan Di 702e029755 refactor(linear): split point snapping helpers 2026-04-28 16:14:19 +10:00
Ryan Di 0bbaf34187 refactor(app): centralize line snapline state sync 2026-04-28 16:11:43 +10:00
Ryan Di 18febfeaf2 test(linear): cover line snapping interactions 2026-04-28 16:02:13 +10:00
Ryan Di 53557919dd Merge branch 'master' into ryan-di/line-snapping 2026-04-28 15:52:10 +10:00
Ryan Di 60a459b135 refactor: remove points function from snapping and move to linear editor 2025-08-04 18:04:59 +10:00
Ryan Di 7332e76d56 refactor: simplify code 2025-08-04 13:46:33 +10:00
Ryan Di dceaa53b0c fix: do not snap to pointer when creating 2025-08-04 12:33:49 +10:00
Ryan Di 6e968324fb fix snapshots 2025-08-04 12:09:06 +10:00
dwelle 09b18cacec Merge branch 'master' into ryan-di/line-snapping
# Conflicts:
#	packages/element/src/linearElementEditor.ts
#	packages/element/src/snapping.ts
#	packages/excalidraw/components/App.tsx
2025-07-31 22:42:52 +02:00
Ryan Di 0e197ef5c4 fix: do not snap to each other when moving multiple points together 2025-06-26 17:22:42 +10:00
Ryan Di a0f7edadec test: update snapshots 2025-06-24 21:02:48 +10:00
Ryan Di 58c9bb4712 merge: with master 2025-06-24 21:00:06 +10:00
Ryan Di d1c6304d42 test: update snapshots 2025-06-24 20:41:27 +10:00
Ryan Di c1a54455bb feat: add snapping on top of angle locking when both enabled 2025-06-24 18:37:07 +10:00
Ryan Di 07640dd756 feat: extend line snapping to creation 2025-06-16 20:55:27 +10:00
Ryan Di 5403fa8a0d feat: line snapping 2025-06-13 17:50:06 +10:00
25 changed files with 1280 additions and 1803 deletions
-3
View File
@@ -250,9 +250,6 @@ export const duplicateElements = (
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
};
// main
// ---------------------------------------------------------------------------
const frameIdsToDuplicate = new Set(
elements
.filter(
+2 -4
View File
@@ -19,7 +19,6 @@ import {
getElementAbsoluteCoords,
doBoundsIntersect,
getElementBounds,
boundsContainBounds,
} from "./bounds";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement";
@@ -102,9 +101,8 @@ export const isElementContainingFrame = (
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
return boundsContainBounds(
getElementBounds(element, elementsMap),
getElementBounds(frame, elementsMap),
return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id,
);
};
+1
View File
@@ -88,6 +88,7 @@ export * from "./selection";
export * from "./shape";
export * from "./showSelectedShapeActions";
export * from "./sizeHelpers";
export * from "./snapping";
export * from "./sortElements";
export * from "./store";
export * from "./textElement";
+351 -92
View File
@@ -7,6 +7,7 @@ import {
type LocalPoint,
pointDistance,
vectorFromPoint,
line,
curveLength,
curvePointAtLength,
} from "@excalidraw/math";
@@ -29,6 +30,9 @@ import {
isPathALoop,
moveArrowAboveBindable,
projectFixedPointOntoDiagonal,
snapLinearElementPoint,
snapToDiscreteAngle,
type SnapLine,
type Store,
} from "@excalidraw/element";
@@ -48,6 +52,7 @@ import {
calculateFixedPointForNonElbowArrowBinding,
getBindingStrategyForDraggingBindingElementEndpoints,
isBindingEnabled,
maxBindingDistance_simple,
snapToMid,
updateBoundPoint,
} from "./binding";
@@ -56,6 +61,7 @@ import {
getElementPointsCoords,
getMinMaxXYFromCurvePathOps,
} from "./bounds";
import { getHoveredElementForBinding } from "./collision";
import { headingIsHorizontal, vectorToHeading } from "./heading";
import { mutateElement } from "./mutateElement";
@@ -294,7 +300,10 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
linearElementEditor: LinearElementEditor,
): Pick<AppState, "suggestedBinding" | "selectedLinearElement"> | null {
): Pick<
AppState,
"suggestedBinding" | "selectedLinearElement" | "snapLines"
> | null {
const elementsMap = app.scene.getNonDeletedElementsMap();
const elements = app.scene.getNonDeletedElements();
const { elementId } = linearElementEditor;
@@ -311,36 +320,26 @@ export class LinearElementEditor {
linearElementEditor.customLineAngle ??
determineCustomLinearAngle(pivotPoint, element.points[idx]);
// Determine if point movement should happen and how much
let deltaX = 0;
let deltaY = 0;
if (shouldRotateWithDiscreteAngle(event)) {
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
const { point: newDraggingPointPosition, snapLines } =
LinearElementEditor._getSnappedPointForLinearElement({
app,
event,
elements,
elementsMap,
pivotPoint,
pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
element,
pointIndex: idx,
scenePointerX,
scenePointerY,
pointerOffset: linearElementEditor.pointerOffset,
referencePoint: shouldRotateWithDiscreteAngle(event)
? pivotPoint
: null,
selectedPointsIndices: [idx],
customLineAngle,
);
const target = pointFrom<LocalPoint>(
width + pivotPoint[0],
height + pivotPoint[1],
);
});
deltaX = target[0] - point[0];
deltaY = target[1] - point[1];
} else {
const newDraggingPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
deltaX = newDraggingPointPosition[0] - point[0];
deltaY = newDraggingPointPosition[1] - point[1];
}
const deltaX = newDraggingPointPosition[0] - point[0];
const deltaY = newDraggingPointPosition[1] - point[1];
// Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null;
@@ -398,6 +397,8 @@ export class LinearElementEditor {
// PERF: Avoid state updates if not absolutely necessary
if (
app.state.selectedLinearElement?.customLineAngle === customLineAngle &&
app.state.snapLines.length === 0 &&
snapLines.length === 0 &&
linearElementEditor.initialState.altFocusPoint &&
(!suggestedBinding ||
isShallowEqual(app.state.suggestedBinding ?? [], suggestedBinding))
@@ -436,6 +437,7 @@ export class LinearElementEditor {
return {
selectedLinearElement: newLinearElementEditor,
suggestedBinding,
snapLines,
};
}
@@ -445,7 +447,10 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
linearElementEditor: LinearElementEditor,
): Pick<AppState, "suggestedBinding" | "selectedLinearElement"> | null {
): Pick<
AppState,
"suggestedBinding" | "selectedLinearElement" | "snapLines"
> | null {
const elementsMap = app.scene.getNonDeletedElementsMap();
const elements = app.scene.getNonDeletedElements();
const { elbowed, elementId, initialState } = linearElementEditor;
@@ -493,7 +498,6 @@ export class LinearElementEditor {
lastClickedPoint = element.points.length - 1;
}
// point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint];
// The adjacent point to the one dragged point
const pivotPoint =
@@ -507,35 +511,27 @@ export class LinearElementEditor {
element.points.length - 1,
);
// Determine if point movement should happen and how much
let deltaX = 0;
let deltaY = 0;
if (shouldRotateWithDiscreteAngle(event) && singlePointDragged) {
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
const { point: newDraggingPointPosition, snapLines } =
LinearElementEditor._getSnappedPointForLinearElement({
app,
event,
elements,
elementsMap,
pivotPoint,
pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
element,
pointIndex: lastClickedPoint,
scenePointerX,
scenePointerY,
pointerOffset: linearElementEditor.pointerOffset,
referencePoint:
shouldRotateWithDiscreteAngle(event) && singlePointDragged
? pivotPoint
: null,
selectedPointsIndices,
customLineAngle,
);
const target = pointFrom<LocalPoint>(
width + pivotPoint[0],
height + pivotPoint[1],
);
deltaX = target[0] - draggingPoint[0];
deltaY = target[1] - draggingPoint[1];
} else {
const newDraggingPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
deltaX = newDraggingPointPosition[0] - draggingPoint[0];
deltaY = newDraggingPointPosition[1] - draggingPoint[1];
}
});
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
// Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null;
@@ -674,6 +670,7 @@ export class LinearElementEditor {
return {
selectedLinearElement: newLinearElementEditor,
suggestedBinding,
snapLines,
};
}
@@ -1178,7 +1175,10 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
app: AppClassProperties,
): LinearElementEditor | null {
): {
editingLinearElement: LinearElementEditor;
snapLines: readonly SnapLine[];
} | null {
const appState = app.state;
if (!appState.selectedLinearElement?.isEditing) {
return null;
@@ -1187,7 +1187,10 @@ export class LinearElementEditor {
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return appState.selectedLinearElement;
return {
editingLinearElement: appState.selectedLinearElement,
snapLines: appState.snapLines,
};
}
const { points } = element;
@@ -1199,36 +1202,37 @@ export class LinearElementEditor {
}
return appState.selectedLinearElement?.lastUncommittedPoint
? {
...appState.selectedLinearElement,
lastUncommittedPoint: null,
editingLinearElement: {
...appState.selectedLinearElement,
lastUncommittedPoint: null,
},
snapLines: [],
}
: appState.selectedLinearElement;
: {
editingLinearElement: appState.selectedLinearElement,
snapLines: [],
};
}
let newPoint: LocalPoint;
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
const anchor = points[points.length - 2];
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
const anchor = points[points.length - 2];
const elements = app.scene.getNonDeletedElements();
const { point: newPoint, snapLines } =
LinearElementEditor._getSnappedPointForLinearElement({
app,
event,
elements,
elementsMap,
anchor,
pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
newPoint = pointFrom(width + anchor[0], height + anchor[1]);
} else {
newPoint = LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - appState.selectedLinearElement.pointerOffset.x,
scenePointerY - appState.selectedLinearElement.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null
: app.getEffectiveGridSize(),
);
}
pointIndex: points.length - 1,
scenePointerX,
scenePointerY,
pointerOffset: appState.selectedLinearElement.pointerOffset,
referencePoint:
shouldRotateWithDiscreteAngle(event) && points.length >= 2
? anchor
: null,
selectedPointsIndices: [points.length - 1],
});
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(
@@ -1236,7 +1240,7 @@ export class LinearElementEditor {
app.scene,
new Map([
[
element.points.length - 1,
points.length - 1,
{
point: newPoint,
},
@@ -1246,9 +1250,13 @@ export class LinearElementEditor {
} else {
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
}
return {
...appState.selectedLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1],
editingLinearElement: {
...appState.selectedLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1],
},
snapLines,
};
}
@@ -1274,18 +1282,53 @@ export class LinearElementEditor {
static getPointsGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
options: {
dragOffset?: { x: number; y: number };
excludePointsIndices?: readonly number[];
} = {},
): GlobalPoint[] {
const { dragOffset, excludePointsIndices } = options;
if (!element.points || element.points.length === 0) {
return [];
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
return element.points.map((p) => {
const { x, y } = element;
return pointRotateRads(
pointFrom(x + p[0], y + p[1]),
let elementX = element.x;
let elementY = element.y;
if (dragOffset) {
elementX += dragOffset.x;
elementY += dragOffset.y;
}
const globalPoints: GlobalPoint[] = [];
for (let i = 0; i < element.points.length; i++) {
// Skip the point being edited if specified
if (
excludePointsIndices?.length &&
excludePointsIndices.find((index) => index === i) !== undefined
) {
continue;
}
const p = element.points[i];
const globalX = elementX + p[0];
const globalY = elementY + p[1];
const rotated = pointRotateRads<GlobalPoint>(
pointFrom(globalX, globalY),
pointFrom(cx, cy),
element.angle,
);
});
globalPoints.push(rotated);
}
return globalPoints;
}
static getPointAtIndexGlobalCoordinates(
@@ -1839,6 +1882,222 @@ export class LinearElementEditor {
);
}
private static _getPointPlacementGridSize(
element: NonDeleted<ExcalidrawLinearElement>,
app: AppClassProperties,
event: Pick<KeyboardEvent | PointerEvent, typeof KEYS.CTRL_OR_CMD>,
): NullableGridSize {
return event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null
: app.getEffectiveGridSize();
}
private static _shouldSkipExternalSnapForBindableTarget({
appState,
elements,
elementsMap,
element,
pointIndex,
scenePoint,
selectedPointsIndices,
}: {
appState: AppState;
elements: readonly Ordered<NonDeletedExcalidrawElement>[];
elementsMap: NonDeletedSceneElementsMap;
element: NonDeleted<ExcalidrawLinearElement>;
pointIndex: number;
scenePoint: GlobalPoint;
selectedPointsIndices?: readonly number[];
}) {
if (
isElbowArrow(element) ||
!isBindingElement(element) ||
!isBindingEnabled(appState) ||
selectedPointsIndices?.length !== 1
) {
return false;
}
if (pointIndex !== 0 && pointIndex !== element.points.length - 1) {
return false;
}
return !!getHoveredElementForBinding(
scenePoint,
elements,
elementsMap,
maxBindingDistance_simple(appState.zoom),
);
}
private static _getSnappedPointForLinearElement({
app,
event,
elements,
elementsMap,
element,
pointIndex,
scenePointerX,
scenePointerY,
pointerOffset,
referencePoint,
selectedPointsIndices,
customLineAngle,
}: {
app: AppClassProperties;
event: PointerEvent | React.PointerEvent<HTMLCanvasElement>;
elements: readonly Ordered<NonDeletedExcalidrawElement>[];
elementsMap: NonDeletedSceneElementsMap;
element: NonDeleted<ExcalidrawLinearElement>;
pointIndex: number;
scenePointerX: number;
scenePointerY: number;
pointerOffset: Readonly<{ x: number; y: number }>;
referencePoint?: LocalPoint | null;
selectedPointsIndices?: readonly number[];
customLineAngle?: number | null;
}): {
point: LocalPoint;
snapLines: SnapLine[];
} {
const gridSize = LinearElementEditor._getPointPlacementGridSize(
element,
app,
event,
);
if (referencePoint) {
const referencePointCoords =
LinearElementEditor.getPointGlobalCoordinates(
element,
referencePoint,
elementsMap,
);
const [gridX, gridY] = getGridPoint(
scenePointerX,
scenePointerY,
gridSize,
);
let { width: dxFromReference, height: dyFromReference } =
getLockedLinearCursorAlignSize(
referencePointCoords[0],
referencePointCoords[1],
gridX,
gridY,
customLineAngle ?? undefined,
);
const effectiveGridX = referencePointCoords[0] + dxFromReference;
const effectiveGridY = referencePointCoords[1] + dyFromReference;
let snapLines: SnapLine[] = [];
const shouldSkipExternalSnap =
LinearElementEditor._shouldSkipExternalSnapForBindableTarget({
appState: app.state,
elements,
elementsMap,
element,
pointIndex,
scenePoint: pointFrom<GlobalPoint>(effectiveGridX, effectiveGridY),
selectedPointsIndices,
});
if (!isElbowArrow(element)) {
const { snapOffset, snapLines: nextSnapLines } = snapLinearElementPoint(
elements,
element,
pointFrom<GlobalPoint>(effectiveGridX, effectiveGridY),
app,
event,
elementsMap,
{
includeExternalPoints: !shouldSkipExternalSnap,
includeSelfPoints: true,
selectedPointsIndices,
},
);
snapLines = nextSnapLines;
if (nextSnapLines.length > 0) {
const result = snapToDiscreteAngle(
nextSnapLines,
line(
pointFrom(effectiveGridX, effectiveGridY),
pointFrom(referencePointCoords[0], referencePointCoords[1]),
),
pointFrom(gridX, gridY),
referencePointCoords,
);
if (result.snapLines.length > 0) {
dxFromReference = result.dxFromReference;
dyFromReference = result.dyFromReference;
snapLines = result.snapLines;
} else {
dxFromReference =
effectiveGridX + snapOffset.x - referencePointCoords[0];
dyFromReference =
effectiveGridY + snapOffset.y - referencePointCoords[1];
}
}
}
const [rotatedX, rotatedY] = pointRotateRads(
pointFrom(dxFromReference, dyFromReference),
pointFrom(0, 0),
-element.angle as Radians,
);
return {
point: pointFrom(
referencePoint[0] + rotatedX,
referencePoint[1] + rotatedY,
),
snapLines,
};
}
const originalPointerX = scenePointerX - pointerOffset.x;
const originalPointerY = scenePointerY - pointerOffset.y;
const shouldSkipExternalSnap =
LinearElementEditor._shouldSkipExternalSnapForBindableTarget({
appState: app.state,
elements,
elementsMap,
element,
pointIndex,
scenePoint: pointFrom<GlobalPoint>(originalPointerX, originalPointerY),
selectedPointsIndices,
});
const { snapOffset, snapLines } = snapLinearElementPoint(
elements,
element,
pointFrom(originalPointerX, originalPointerY),
app,
event,
elementsMap,
{
includeExternalPoints: !shouldSkipExternalSnap,
includeSelfPoints: true,
selectedPointsIndices,
},
);
return {
point: LinearElementEditor.createPointAt(
element,
elementsMap,
originalPointerX + snapOffset.x,
originalPointerY + snapOffset.y,
gridSize,
),
snapLines,
};
}
static getBoundTextElementPosition = (
element: ExcalidrawLinearElement,
boundTextElement: ExcalidrawTextElementWithContainer,
+5 -3
View File
@@ -34,6 +34,7 @@ import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameChildren,
isElementIntersectingFrame,
} from "./frame";
import { LinearElementEditor } from "./linearElementEditor";
@@ -169,7 +170,7 @@ export const getElementsWithinSelection = (
const associatedFrame = getContainingFrame(element, elementsMap);
if (
associatedFrame &&
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
isElementIntersectingFrame(element, associatedFrame, elementsMap)
) {
const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [
@@ -208,9 +209,10 @@ export const getElementsWithinSelection = (
if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
} else {
elementsInSelection.push(element);
continue;
}
elementsInSelection.push(element);
continue;
}
// 2. Handle the case where the label is overlapped by the selection box
File diff suppressed because it is too large Load Diff
+64 -62
View File
@@ -1,56 +1,59 @@
import { arrayToMap } from "@excalidraw/common";
import { arrayToMapWithIndex } from "@excalidraw/common";
import type { ExcalidrawElement } from "./types";
const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const groupIdAtLevel = (element: ExcalidrawElement, level: number) => {
return element.groupIds[element.groupIds.length - level - 1];
};
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const origElements: ExcalidrawElement[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
const orderLevel = (
levelElements: readonly ExcalidrawElement[],
level: number,
const orderInnerGroups = (
elements: readonly ExcalidrawElement[],
): ExcalidrawElement[] => {
const buckets = new Map<string, ExcalidrawElement[]>();
// Slots preserve first-occurrence order: a groupId reserves its slot
// the first time one of its members is seen; loose elements occupy
// their own slot. Groups are then expanded (and recursed into) in place.
const slots: (ExcalidrawElement | string)[] = [];
for (const element of levelElements) {
const groupId = groupIdAtLevel(element, level);
if (groupId === undefined) {
slots.push(element);
continue;
const firstGroupSig = elements[0]?.groupIds?.join("");
const aGroup: ExcalidrawElement[] = [elements[0]];
const bGroup: ExcalidrawElement[] = [];
for (const element of elements.slice(1)) {
if (element.groupIds?.join("") === firstGroupSig) {
aGroup.push(element);
} else {
bGroup.push(element);
}
let bucket = buckets.get(groupId);
if (!bucket) {
bucket = [];
buckets.set(groupId, bucket);
slots.push(groupId);
}
bucket.push(element);
}
return slots.flatMap((slot) =>
typeof slot === "string"
? orderLevel(buckets.get(slot)!, level + 1)
: [slot],
);
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
};
// `groupIds` is stored innermost-first, so the outermost group is the
// last entry. We recurse from level 0 (outermost) inward.
const sortedElements = orderLevel(elements, 0);
const groupHandledElements = new Map<string, true>();
origElements.forEach((element, idx) => {
if (groupHandledElements.has(element.id)) {
return;
}
if (element.groupIds?.length) {
const topGroup = element.groupIds[element.groupIds.length - 1];
const groupElements = origElements.slice(idx).filter((element) => {
const ret = element?.groupIds?.some((id) => id === topGroup);
if (ret) {
groupHandledElements.set(element!.id, true);
}
return ret;
});
for (const elem of orderInnerGroups(groupElements)) {
sortedElements.add(elem);
}
} else {
sortedElements.add(element);
}
});
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
if (sortedElements.length !== elements.length) {
console.error("defragmentGroups: lost some elements... bailing!");
if (sortedElements.size !== elements.length) {
console.error("normalizeGroupElementOrder: lost some elements... bailing!");
return elements;
}
return sortedElements;
return [...sortedElements];
};
/**
@@ -65,40 +68,39 @@ const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[],
) => {
const elementsMap = arrayToMap(elements);
const elementsMap = arrayToMapWithIndex(elements);
const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
for (const element of elements) {
if (sortedElements.has(element)) {
continue;
origElements.forEach((element, idx) => {
if (!element) {
return;
}
if (element.boundElements?.length) {
sortedElements.add(element);
for (const boundElement of element.boundElements) {
origElements[idx] = null;
element.boundElements.forEach((boundElement) => {
const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") {
sortedElements.add(child);
sortedElements.add(child[0]);
origElements[child[1]] = null;
}
});
} else if (element.type === "text" && element.containerId) {
const parent = elementsMap.get(element.containerId);
if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) {
sortedElements.add(element);
origElements[idx] = null;
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
}
continue;
} else {
sortedElements.add(element);
origElements[idx] = null;
}
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
if (
element.type === "text" &&
element.containerId &&
elementsMap
.get(element.containerId)
?.boundElements?.some((el) => el.id === element.id)
) {
continue;
}
sortedElements.add(element);
}
});
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
@@ -115,5 +117,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[],
) => {
return normalizeBoundElementsOrder(defragmentGroups(elements));
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
};
+1 -43
View File
@@ -2,7 +2,6 @@ import {
convertToExcalidrawElements,
Excalidraw,
} from "@excalidraw/excalidraw";
import { arrayToMap } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
@@ -11,12 +10,7 @@ import {
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import { elementOverlapsWithFrame } from "../src/frame";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
} from "../src/types";
import type { ExcalidrawElement } from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
@@ -131,26 +125,6 @@ describe("adding elements to frames", () => {
});
});
it("should treat an element fully containing a frame as overlapping the frame", () => {
const containingRect = API.createElement({
type: "rectangle",
x: -50,
y: -50,
width: 250,
height: 250,
});
API.setElements([containingRect, frame]);
expect(
elementOverlapsWithFrame(
containingRect,
frame as ExcalidrawFrameLikeElement,
arrayToMap(h.elements),
),
).toBe(true);
});
const commonTestCases = async (
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame,
) => {
@@ -441,22 +415,6 @@ describe("adding elements to frames", () => {
describe("dragging elements into the frame", async () => {
await commonTestCases(dragElementIntoFrame);
it("should add a dragged element fully containing the frame", () => {
const containingRect = API.createElement({
type: "rectangle",
x: 220,
y: 20,
width: 300,
height: 300,
});
API.setElements([frame, containingRect]);
dragElementIntoFrame(frame, containingRect);
expect(API.getElement(containingRect).frameId).toBe(frame.id);
});
it.skip("should drag element inside, duplicate it and keep it in frame", () => {
API.setElements([frame, rect2]);
@@ -155,6 +155,24 @@ describe("Test Linear Elements", () => {
});
};
const dragMove = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
fireEvent.pointerDown(interactiveCanvas, {
clientX: startPoint[0],
clientY: startPoint[1],
});
fireEvent.pointerMove(interactiveCanvas, {
clientX: endPoint[0],
clientY: endPoint[1],
});
};
const dragEnd = (endPoint: GlobalPoint) => {
fireEvent.pointerUp(interactiveCanvas, {
clientX: endPoint[0],
clientY: endPoint[1],
});
};
const deletePoint = (point: GlobalPoint) => {
fireEvent.pointerDown(interactiveCanvas, {
clientX: point[0],
@@ -258,6 +276,73 @@ describe("Test Linear Elements", () => {
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("shows snap lines and snaps the endpoint when creating a line", () => {
const rect = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 40,
height: 40,
});
API.setElements([rect]);
API.setAppState({ objectsSnapModeEnabled: true });
UI.clickTool("line");
const startPoint = pointFrom<GlobalPoint>(20, 20);
const pointerNearCorner = pointFrom<GlobalPoint>(95, 95);
dragMove(startPoint, pointerNearCorner);
expect(h.state.snapLines.length).toBeGreaterThan(0);
dragEnd(pointerNearCorner);
const line = h.elements.find(
(element): element is ExcalidrawLinearElement => element.type === "line",
);
expect(line).toBeDefined();
const endpoint = LinearElementEditor.getPointGlobalCoordinates(
line!,
line!.points[line!.points.length - 1],
h.app.scene.getNonDeletedElementsMap(),
);
expect(endpoint).toEqual(pointFrom<GlobalPoint>(100, 100));
});
it("prefers binding over external snaps when creating an arrow endpoint", () => {
const rect = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 40,
height: 40,
});
API.setElements([rect]);
API.setAppState({ objectsSnapModeEnabled: true });
UI.clickTool("arrow");
const startPoint = pointFrom<GlobalPoint>(20, 20);
const pointerNearBindable = pointFrom<GlobalPoint>(96, 118);
dragMove(startPoint, pointerNearBindable);
expect(h.state.suggestedBinding?.element.id).toBe(rect.id);
expect(h.state.snapLines).toEqual([]);
dragEnd(pointerNearBindable);
const arrow = h.elements.find(
(element): element is ExcalidrawLinearElement => element.type === "arrow",
);
expect(arrow?.endBinding?.elementId).toBe(rect.id);
});
it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
@@ -401,6 +486,77 @@ describe("Test Linear Elements", () => {
`);
});
it("shows snap lines when dragging a point to another line point axis", () => {
const line = API.createElement({
type: "line",
x: 20,
y: 20,
width: 100,
height: 50,
roughness: 0,
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(50, 50),
pointFrom<LocalPoint>(100, 0),
],
});
API.setElements([line]);
API.setAppState({ objectsSnapModeEnabled: true });
enterLineEditingMode(line);
const middlePoint = pointFrom<GlobalPoint>(70, 70);
const pointerNearEndPointX = pointFrom<GlobalPoint>(117, 65);
dragMove(middlePoint, pointerNearEndPointX);
expect(h.state.snapLines.length).toBeGreaterThan(0);
dragEnd(pointerNearEndPointX);
expect(API.getElement(line).points[1]).toEqual(
pointFrom<LocalPoint>(100, 45),
);
});
it("prefers binding over external snaps when dragging an existing arrow endpoint", () => {
const rect = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 40,
height: 40,
});
const arrow = API.createElement({
type: "arrow",
x: 20,
y: 20,
width: 40,
height: 0,
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(40, 0)],
});
API.setElements([rect, arrow]);
API.setAppState({ objectsSnapModeEnabled: true });
enterLineEditingMode(arrow);
const endPoint = LinearElementEditor.getPointGlobalCoordinates(
arrow,
arrow.points[arrow.points.length - 1],
h.app.scene.getNonDeletedElementsMap(),
);
const pointerNearBindable = pointFrom<GlobalPoint>(96, 118);
dragMove(endPoint, pointerNearBindable);
expect(h.state.suggestedBinding?.element.id).toBe(rect.id);
expect(h.state.snapLines).toEqual([]);
dragEnd(pointerNearBindable);
expect(API.getElement(arrow).endBinding?.elementId).toBe(rect.id);
});
it("should update the midpoints when element roundness changed", async () => {
createThreePointerLinearElement("line");
+3 -43
View File
@@ -326,59 +326,19 @@ describe("normalizeElementsOrder", () => {
]),
[
"BA_rect1",
"CBA_rect3",
"CBA_rect7",
"BA_rect5",
"BA_rect6",
"A_rect2",
"A_rect5",
"CBA_rect3",
"CBA_rect7",
"rect4",
"X_rect8",
"YX_rect10",
"X_rect11",
"YX_rect10",
"rect9",
],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "CBA_rect2",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "A_rect3",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "CBA_rect2", "A_rect3"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "abcT_rect1",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
API.createElement({
id: "abcT_rect2",
type: "rectangle",
groupIds: ["a", "bc", "T"],
}),
API.createElement({
id: "abcT_rect3",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
]),
["abcT_rect1", "abcT_rect3", "abcT_rect2"],
);
});
// TODO
@@ -329,6 +329,7 @@ export const actionFinalize = register<FormData>({
selectionElement: null,
multiElement: null,
editingTextElement: null,
startBoundElement: null,
suggestedBinding: null,
selectedElementIds:
element &&
+2
View File
@@ -99,6 +99,7 @@ export const getDefaultAppState = (): Omit<
open: false,
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
},
startBoundElement: null,
suggestedBinding: null,
frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null,
@@ -230,6 +231,7 @@ const APP_STATE_STORAGE_CONF = (<
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBinding: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false },
+114 -88
View File
@@ -239,6 +239,16 @@ import {
hitElementBoundingBox,
isLineElement,
isSimpleArrow,
isGridModeEnabled,
SnapCache,
isActiveToolNonLinearSnappable,
getSnapLinesAtPointer,
isSnappingEnabled,
getReferenceSnapPoints,
getVisibleGaps,
snapDraggedElements,
snapNewElement,
snapResizingElements,
StoreDelta,
type ApplyToOptions,
positionElementsOnGrid,
@@ -396,18 +406,6 @@ import {
import { Fonts } from "../fonts";
import { editorJotaiStore, type WritableAtom } from "../editor-jotai";
import { ImageSceneDataError } from "../errors";
import {
getSnapLinesAtPointer,
snapDraggedElements,
isActiveToolNonLinearSnappable,
snapNewElement,
snapResizingElements,
isSnappingEnabled,
getVisibleGaps,
getReferenceSnapPoints,
SnapCache,
isGridModeEnabled,
} from "../snapping";
import { Renderer } from "../scene/Renderer";
import {
setEraserCursor,
@@ -603,8 +601,6 @@ const YOUTUBE_VIDEO_STATES = new Map<
ValueOf<typeof YOUTUBE_STATES>
>();
const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4;
let IS_PLAIN_PASTE = false;
let IS_PLAIN_PASTE_TIMER = 0;
let PLAIN_PASTE_TOAST_SHOWN = false;
@@ -787,6 +783,29 @@ class App extends React.Component<AppProps, AppState> {
return api;
}
private withStableSnapLines<T extends { snapLines: AppState["snapLines"] }>(
state: T,
): T {
const snapLines = updateStable(this.state.snapLines, state.snapLines);
return snapLines === state.snapLines
? state
: {
...state,
snapLines,
};
}
private shouldUpdateSelectedLinearElementState(
selectedLinearElement: AppState["selectedLinearElement"],
snapLines: AppState["snapLines"],
) {
return (
selectedLinearElement !== this.state.selectedLinearElement ||
snapLines !== this.state.snapLines
);
}
constructor(props: AppProps) {
super(props);
const defaultAppState = getDefaultAppState();
@@ -1737,18 +1756,6 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeEmbeddable?.element === el &&
this.state.activeEmbeddable?.state === "hover";
// scale video embeds based on zoom (capped) so that smaller embeds
// on canvas when zoomed are still of legible quality
// (note: for some embed types like gdrive, the quality is poor when
// scaling mid playback and works only when you initially start the
// playback at the higher zoom level)
const shouldScaleEmbeddableViewport = src?.type === "video";
const embeddableViewportScale = clamp(
shouldScaleEmbeddableViewport ? scale : 1,
0.75,
MAX_EMBEDDABLE_VIEWPORT_SCALE,
);
return (
<div
key={el.id}
@@ -1815,42 +1822,31 @@ class App extends React.Component<AppProps, AppState> {
padding: `${el.strokeWidth}px`,
}}
>
<div
className="excalidraw__embeddable__content"
style={{
width: `${embeddableViewportScale * 100}%`,
height: `${embeddableViewportScale * 100}%`,
transform: `scale(${1 / embeddableViewportScale})`,
}}
>
{(isEmbeddableElement(el)
? this.props.renderEmbeddable?.(el, this.state)
: null) ?? (
<iframe
ref={(ref) => this.cacheEmbeddableRef(el, ref)}
className="excalidraw__embeddable"
srcDoc={
src?.type === "document"
? src.srcdoc(this.state.theme)
: undefined
}
src={
src?.type !== "document" ? src?.link ?? "" : undefined
}
// https://stackoverflow.com/q/18470015
scrolling="no"
referrerPolicy="no-referrer-when-downgrade"
title="Excalidraw Embedded Content"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
sandbox={`${
src?.sandbox?.allowSameOrigin
? "allow-same-origin"
: ""
} allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads`}
/>
)}
</div>
{(isEmbeddableElement(el)
? this.props.renderEmbeddable?.(el, this.state)
: null) ?? (
<iframe
ref={(ref) => this.cacheEmbeddableRef(el, ref)}
className="excalidraw__embeddable"
srcDoc={
src?.type === "document"
? src.srcdoc(this.state.theme)
: undefined
}
src={
src?.type !== "document" ? src?.link ?? "" : undefined
}
// https://stackoverflow.com/q/18470015
scrolling="no"
referrerPolicy="no-referrer-when-downgrade"
title="Excalidraw Embedded Content"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
sandbox={`${
src?.sandbox?.allowSameOrigin ? "allow-same-origin" : ""
} allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads`}
/>
)}
</div>
</div>
</div>
@@ -6811,7 +6807,10 @@ class App extends React.Component<AppProps, AppState> {
if (
!this.state.newElement &&
isActiveToolNonLinearSnappable(this.state.activeTool.type)
(isActiveToolNonLinearSnappable(this.state.activeTool.type) ||
((this.state.activeTool.type === "line" ||
this.state.activeTool.type === "arrow") &&
this.state.currentItemArrowType !== ARROW_TYPE.elbow))
) {
const { originOffset, snapLines } = getSnapLinesAtPointer(
this.scene.getNonDeletedElements(),
@@ -6860,7 +6859,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedLinearElement?.isEditing &&
!this.state.selectedLinearElement.isDragging
) {
const editingLinearElement = this.state.newElement
const result = this.state.newElement
? null
: LinearElementEditor.handlePointerMoveInEditMode(
event,
@@ -6869,18 +6868,33 @@ class App extends React.Component<AppProps, AppState> {
this,
);
if (
editingLinearElement &&
editingLinearElement !== this.state.selectedLinearElement
) {
// Since we are reading from previous state which is not possible with
// automatic batching in React 18 hence using flush sync to synchronously
// update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
flushSync(() => {
this.setState({
selectedLinearElement: editingLinearElement,
});
if (result) {
const { editingLinearElement, snapLines } = result;
const nextState = this.withStableSnapLines({
selectedLinearElement: editingLinearElement,
snapLines,
});
if (
editingLinearElement &&
this.shouldUpdateSelectedLinearElementState(
nextState.selectedLinearElement,
nextState.snapLines,
)
) {
// Since we are reading from previous state which is not possible with
// automatic batching in React 18 hence using flush sync to synchronously
// update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
flushSync(() => {
this.setState(nextState);
});
}
if (
editingLinearElement.lastUncommittedPoint == null &&
this.state.suggestedBinding
) {
this.setState({ suggestedBinding: null });
}
}
}
@@ -6947,7 +6961,7 @@ class App extends React.Component<AppProps, AppState> {
y: scenePointerY,
},
});
this.setState({ suggestedBinding: null });
this.setState({ suggestedBinding: null, startBoundElement: null });
if (!this.state.activeTool.locked) {
resetCursor(this.interactiveCanvas);
this.setState((prevState) => ({
@@ -7591,6 +7605,7 @@ class App extends React.Component<AppProps, AppState> {
appState: {
newElement: null,
editingTextElement: null,
startBoundElement: null,
suggestedBinding: null,
selectedElementIds: makeNextSelectedElementIds(
Object.keys(this.state.selectedElementIds)
@@ -8878,8 +8893,18 @@ class App extends React.Component<AppProps, AppState> {
};
});
const boundElement = getHoveredElementForBinding(
pointFrom<GlobalPoint>(
pointerDownState.origin.x,
pointerDownState.origin.y,
),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
);
this.setState({
newElement: element,
startBoundElement: boundElement,
suggestedBinding: null,
});
};
@@ -9727,25 +9752,27 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
pointerDownState.drag.hasOccurred = true;
const nextState = this.withStableSnapLines(newState);
// NOTE: Optimize setState calls because it
// affects history and performance
if (
newState.suggestedBinding !== this.state.suggestedBinding ||
nextState.suggestedBinding !== this.state.suggestedBinding ||
!isShallowEqual(
newState.selectedLinearElement?.selectedPointsIndices ?? [],
nextState.selectedLinearElement?.selectedPointsIndices ?? [],
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
) ||
newState.selectedLinearElement?.hoverPointIndex !==
nextState.selectedLinearElement?.hoverPointIndex !==
this.state.selectedLinearElement?.hoverPointIndex ||
newState.selectedLinearElement?.customLineAngle !==
nextState.selectedLinearElement?.customLineAngle !==
this.state.selectedLinearElement?.customLineAngle ||
this.state.selectedLinearElement.isDragging !==
newState.selectedLinearElement?.isDragging ||
nextState.selectedLinearElement?.isDragging ||
this.state.selectedLinearElement?.initialState?.altFocusPoint !==
newState.selectedLinearElement?.initialState?.altFocusPoint
nextState.selectedLinearElement?.initialState?.altFocusPoint ||
nextState.snapLines !== this.state.snapLines
) {
this.setState(newState);
this.setState(nextState);
}
return;
@@ -10434,8 +10461,7 @@ class App extends React.Component<AppProps, AppState> {
this.lassoTrail.endPath();
this.previousPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null);
SnapCache.setVisibleGaps(null);
SnapCache.destroy();
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
@@ -10732,7 +10758,7 @@ class App extends React.Component<AppProps, AppState> {
sceneCoords,
});
}
this.setState({ suggestedBinding: null });
this.setState({ suggestedBinding: null, startBoundElement: null });
if (!activeTool.locked) {
resetCursor(this.interactiveCanvas);
this.setState((prevState) => ({
@@ -10753,9 +10779,9 @@ class App extends React.Component<AppProps, AppState> {
),
}));
} else {
this.setState({
this.setState((prevState) => ({
newElement: null,
});
}));
}
// so that the scene gets rendered again to display the newly drawn linear as well
this.scene.triggerUpdate();
@@ -3,6 +3,7 @@ import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "@excalidraw/common";
import {
isFlowchartNodeElement,
isImageElement,
isGridModeEnabled,
isLinearElement,
isLineElement,
isTextBindableContainer,
@@ -16,7 +17,6 @@ import type { EditorInterface } from "@excalidraw/common";
import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { isEraserActive } from "../appState";
import { isGridModeEnabled } from "../snapping";
import "./HintViewer.scss";
+2 -1
View File
@@ -650,7 +650,8 @@ const LayerUI = ({
};
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
const { cursorButton, scrollX, scrollY, ...ret } = appState;
const { startBoundElement, cursorButton, scrollX, scrollY, ...ret } =
appState;
return ret;
};
@@ -12,10 +12,11 @@ import { frameAndChildrenSelectedTogether } from "@excalidraw/element";
import { elementsAreInSameGroup } from "@excalidraw/element";
import { isGridModeEnabled } from "@excalidraw/element";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { t } from "../../i18n";
import { isGridModeEnabled } from "../../snapping";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { Island } from "../Island";
import { CloseIcon } from "../icons";
-8
View File
@@ -814,14 +814,6 @@ body.excalidraw-cursor-resize * {
.excalidraw__embeddable__outer {
width: 100%;
height: 100%;
}
.excalidraw__embeddable__content {
width: 100%;
height: 100%;
transform-origin: top left;
&,
& > * {
border-radius: var(--embeddable-radius);
}
+4 -1
View File
@@ -2,7 +2,8 @@ import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
import { THEME } from "@excalidraw/common";
import type { PointSnapLine, PointerSnapLine } from "../snapping";
import type { PointSnapLine, PointerSnapLine } from "@excalidraw/element";
import type { InteractiveCanvasAppState } from "../types";
const SNAP_COLOR_LIGHT = "#ff6b6b";
@@ -19,6 +20,8 @@ export const renderSnaps = (
}
// in dark mode, we need to adjust the color to account for color inversion.
// Don't change if zen mode, because we draw only crosses, we want the
// colors to be more visible
const snapColor =
appState.theme === THEME.LIGHT || appState.zenModeEnabled
? SNAP_COLOR_LIGHT
@@ -979,6 +979,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1172,6 +1173,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1386,6 +1388,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1717,6 +1720,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2048,6 +2052,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2260,6 +2265,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2503,6 +2509,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2806,6 +2813,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3173,6 +3181,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3666,6 +3675,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3989,6 +3999,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4315,6 +4326,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5600,6 +5612,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -6819,6 +6832,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7772,6 +7786,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8771,6 +8786,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9765,6 +9781,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1293,6 +1293,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1653,6 +1654,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2015,6 +2017,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2277,40 +2280,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id4",
"type": "arrow",
},
],
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 100,
"id": "id0",
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 493213705,
"width": 100,
"x": -100,
"y": -50,
},
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2765,6 +2735,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3068,6 +3039,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3387,6 +3359,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3681,6 +3654,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3967,6 +3941,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4202,6 +4177,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4459,6 +4435,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4730,6 +4707,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4959,6 +4937,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5188,6 +5167,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5435,6 +5415,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5691,6 +5672,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5948,6 +5930,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -6277,6 +6260,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -6707,6 +6691,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7087,6 +7072,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7681,6 +7667,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7911,6 +7898,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8263,6 +8251,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8621,6 +8610,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9021,6 +9011,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9302,6 +9293,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9566,6 +9558,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9831,6 +9824,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10064,6 +10058,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10676,6 +10671,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10915,6 +10911,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11839,6 +11836,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -12097,6 +12095,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"showHyperlinkPopup": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -12334,6 +12333,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -12569,6 +12569,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -12965,6 +12966,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13172,6 +13174,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13383,6 +13386,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13681,6 +13685,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13979,6 +13984,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"showHyperlinkPopup": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14223,6 +14229,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"showHyperlinkPopup": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14460,6 +14467,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"showHyperlinkPopup": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14697,6 +14705,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14944,6 +14953,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -15276,6 +15286,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -15448,6 +15459,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -15730,6 +15742,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -15993,6 +16006,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
"showHyperlinkPopup": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -16147,6 +16161,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -16428,6 +16443,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -20154,6 +20170,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -20636,6 +20653,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -21142,6 +21160,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -106,6 +106,7 @@ exports[`given element A and group of elements B and given both are selected whe
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -534,6 +535,7 @@ exports[`given element A and group of elements B and given both are selected whe
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -941,6 +943,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1507,6 +1510,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1719,6 +1723,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2100,6 +2105,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2343,6 +2349,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2525,6 +2532,7 @@ exports[`regression tests > can drag element that covers another element, while
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2848,6 +2856,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3105,6 +3114,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3346,6 +3356,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3582,6 +3593,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3841,6 +3853,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4154,6 +4167,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4617,6 +4631,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4872,6 +4887,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5175,6 +5191,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5355,6 +5372,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5555,6 +5573,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5952,6 +5971,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7037,6 +7057,7 @@ exports[`regression tests > given a group of selected elements with an element t
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7371,6 +7392,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7649,6 +7671,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7884,6 +7907,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8122,6 +8146,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8302,6 +8327,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8482,6 +8508,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8638,7 +8665,14 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": {
"data": null,
"shown": false,
},
"penDetected": false,
"penMode": false,
"preferredSelectionTool": {
@@ -9124,6 +9158,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9294,7 +9329,14 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": {
"data": null,
"shown": false,
},
"penDetected": false,
"penMode": false,
"preferredSelectionTool": {
@@ -9551,6 +9593,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9962,6 +10005,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10140,6 +10184,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10334,6 +10379,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10522,6 +10568,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11047,6 +11094,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11323,6 +11371,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11448,6 +11497,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11652,6 +11702,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11973,6 +12024,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -12406,6 +12458,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13037,6 +13090,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13164,6 +13218,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13824,6 +13879,7 @@ exports[`regression tests > switches from group of selected elements to another
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14162,6 +14218,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14394,6 +14451,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14882,6 +14940,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -15008,6 +15067,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -615,32 +615,6 @@ describe("box-selection overlap mode", () => {
assertSelectedElements([]);
});
it("should not select a framed element when selection only overlaps its clipped-out outline", () => {
const frame = API.createElement({
type: "frame",
x: 100,
y: 100,
width: 100,
height: 100,
});
const rect1 = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 200,
height: 200,
frameId: frame.id,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([frame, rect1]);
boxSelect(40, 170, 70, 220);
assertSelectedElements([]);
});
});
describe("inner box-selection", () => {
-833
View File
@@ -1,833 +0,0 @@
import { arrayToMap } from "@excalidraw/common";
import {
pointFrom,
rangeInclusive,
type GlobalPoint,
type Radians,
} from "@excalidraw/math";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getDefaultAppState } from "../appState";
import {
getElementsCorners,
getVisibleGaps,
getReferenceSnapPoints,
SnapCache,
snapDraggedElements,
} from "../snapping";
import { API } from "./helpers/api";
import type {
AppClassProperties,
AppState,
NormalizedZoomValue,
} from "../types";
type ReferenceSnapPoints = NonNullable<
ReturnType<typeof SnapCache.getReferenceSnapPoints>
>;
const NO_MODIFIER_KEYS = {
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
};
const createSnappingApp = (appState: Partial<AppState> = {}) =>
({
props: {},
state: {
...getDefaultAppState(),
objectsSnapModeEnabled: true,
width: 1000,
height: 1000,
offsetLeft: 0,
offsetTop: 0,
...appState,
},
} as AppClassProperties);
const getHorizontalPointSnapLineCoordinates = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
return snapLines
.filter((snapLine) => snapLine.type === "points")
.filter((snapLine) => {
const [firstPoint, lastPoint] = snapLine.points;
return firstPoint[1] === lastPoint[1];
})
.map((snapLine) => {
return snapLine.points[0][1];
})
.sort((a, b) => a - b);
};
const getVerticalPointSnapLineCoordinates = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
return snapLines
.filter((snapLine) => snapLine.type === "points")
.filter((snapLine) => {
const [firstPoint, lastPoint] = snapLine.points;
return firstPoint[0] === lastPoint[0];
})
.map((snapLine) => {
return snapLine.points[0][0];
})
.sort((a, b) => a - b);
};
const getHorizontalPointSnapLineMaxX = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
const horizontalSnapLine = snapLines
.filter((snapLine) => snapLine.type === "points")
.find((snapLine) => {
const [firstPoint, lastPoint] = snapLine.points;
return firstPoint[1] === lastPoint[1];
});
if (!horizontalSnapLine) {
return null;
}
return horizontalSnapLine.points[horizontalSnapLine.points.length - 1][0];
};
const getHorizontalPointSnapLineXRange = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
const horizontalSnapLine = snapLines
.filter((snapLine) => snapLine.type === "points")
.find((snapLine) => {
const [firstPoint, lastPoint] = snapLine.points;
return firstPoint[1] === lastPoint[1];
});
if (!horizontalSnapLine) {
return null;
}
return [
horizontalSnapLine.points[0][0],
horizontalSnapLine.points[horizontalSnapLine.points.length - 1][0],
] as const;
};
const getHorizontalGapSnapLines = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
return snapLines.filter(
(snapLine) =>
snapLine.type === "gap" && snapLine.direction === "horizontal",
);
};
const getVerticalGapSnapLines = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
return snapLines.filter(
(snapLine) => snapLine.type === "gap" && snapLine.direction === "vertical",
);
};
const getPointKeys = (points: ReturnType<typeof getElementsCorners>) => {
return points.map((point) => point.join(","));
};
const getReferenceSnapPointKeys = (
elements: ExcalidrawElement[],
selectedElements: ExcalidrawElement[],
app: AppClassProperties,
) => {
return new Set(
getReferenceSnapPoints(
elements,
selectedElements,
app.state,
arrayToMap(elements),
).map((snapPoint) => snapPoint.point.join(",")),
);
};
const primeReferenceSnapPoints = (
elements: ExcalidrawElement[],
selectedElements: ExcalidrawElement[],
) => {
const selectedElementIds = new Set(
selectedElements.map((element) => element.id),
);
const elementsMap = arrayToMap(elements);
SnapCache.setReferenceSnapPoints(
elements
.filter((element) => !selectedElementIds.has(element.id))
.flatMap((element) => {
const corners = getElementsCorners([element], elementsMap);
return corners.map((point, index) => ({
point,
type: index === corners.length - 1 ? "center" : "outer",
snapSourceId: element.id,
}));
}) as Parameters<typeof SnapCache.setReferenceSnapPoints>[0],
);
};
describe("snapping", () => {
afterEach(() => {
SnapCache.destroy();
});
it("does not use frame children as references when snapping outside elements", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 300,
height: 300,
});
const frameChild = API.createElement({
type: "rectangle",
id: "frameChild",
x: 37,
y: 53,
width: 71,
height: 83,
frameId: frame.id,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 400,
y: 50,
width: 100,
height: 100,
});
const elements = [frame, frameChild, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
const referenceSnapPointKeys = getReferenceSnapPointKeys(
elements,
[selected],
app,
);
const frameChildPointKeys = getPointKeys(
getElementsCorners([frameChild], arrayToMap(elements)),
);
expect(
frameChildPointKeys.some((pointKey) =>
referenceSnapPointKeys.has(pointKey),
),
).toBe(false);
});
it("uses frame siblings as references when snapping elements in the same frame", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 300,
height: 300,
});
const sibling = API.createElement({
type: "rectangle",
id: "sibling",
x: 37,
y: 53,
width: 71,
height: 83,
frameId: frame.id,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 150,
y: 50,
width: 100,
height: 100,
frameId: frame.id,
});
const elements = [frame, sibling, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
const referenceSnapPointKeys = getReferenceSnapPointKeys(
elements,
[selected],
app,
);
const siblingPointKeys = getPointKeys(
getElementsCorners([sibling], arrayToMap(elements)),
);
expect(
siblingPointKeys.some((pointKey) => referenceSnapPointKeys.has(pointKey)),
).toBe(true);
});
it("does not use frame children as references when snapping the frame itself", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 300,
height: 300,
});
const frameChild = API.createElement({
type: "rectangle",
id: "frameChild",
x: 37,
y: 53,
width: 71,
height: 83,
frameId: frame.id,
});
const elements = [frame, frameChild];
const app = createSnappingApp({
selectedElementIds: { [frame.id]: true },
});
const referenceSnapPointKeys = getReferenceSnapPointKeys(
elements,
[frame],
app,
);
const frameChildPointKeys = getPointKeys(
getElementsCorners([frameChild], arrayToMap(elements)),
);
expect(
frameChildPointKeys.some((pointKey) =>
referenceSnapPointKeys.has(pointKey),
),
).toBe(false);
});
it("does not use frame children as visible gap references when snapping outside elements", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 500,
height: 300,
});
const frameChildA = API.createElement({
type: "rectangle",
id: "frameChildA",
x: 50,
y: 50,
width: 100,
height: 100,
frameId: frame.id,
});
const frameChildB = API.createElement({
type: "rectangle",
id: "frameChildB",
x: 250,
y: 50,
width: 100,
height: 100,
frameId: frame.id,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 700,
y: 50,
width: 100,
height: 100,
});
const elements = [frame, frameChildA, frameChildB, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
const visibleGaps = getVisibleGaps(
elements,
[selected],
app.state,
arrayToMap(elements),
);
expect(visibleGaps.horizontalGaps).toHaveLength(0);
expect(visibleGaps.verticalGaps).toHaveLength(0);
});
it("filters center and inner outer point snaplines for the same reference", () => {
const angle = 0.68 as Radians;
const reference = API.createElement({
type: "rectangle",
id: "reference",
x: 0,
y: 0,
width: 140,
height: 140,
angle,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 200,
y: 0,
width: 140,
height: 140,
angle,
});
const elements = [reference, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
primeReferenceSnapPoints(elements, [selected]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getHorizontalPointSnapLineCoordinates(snapLines)).toHaveLength(2);
});
it("keeps a snapline that is redundant for one reference but needed for another", () => {
const angle = 0.68 as Radians;
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 200,
y: 0,
width: 140,
height: 140,
angle,
});
const elements = [selected];
const elementsMap = arrayToMap(elements);
const selectedSnapPoints = getElementsCorners([selected], elementsMap);
const outerSnapPoints = selectedSnapPoints.slice(0, -1);
const centerSnapPoint = selectedSnapPoints[selectedSnapPoints.length - 1];
const innerOuterSnapPoint = [...outerSnapPoints].sort(
(a, b) => a[1] - b[1],
)[1];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
const referenceSnapPoints: ReferenceSnapPoints = [
...outerSnapPoints.map((point) => ({
point: pointFrom<GlobalPoint>(point[0] - 200, point[1]),
type: "outer" as const,
snapSourceId: "referenceA",
})),
{
point: pointFrom<GlobalPoint>(
centerSnapPoint[0] - 200,
centerSnapPoint[1],
),
type: "center" as const,
snapSourceId: "referenceA",
},
{
point: pointFrom<GlobalPoint>(
innerOuterSnapPoint[0] - 300,
innerOuterSnapPoint[1],
),
type: "outer" as const,
snapSourceId: "referenceB",
},
];
SnapCache.setReferenceSnapPoints(referenceSnapPoints);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
elementsMap,
);
expect(getHorizontalPointSnapLineCoordinates(snapLines)).toHaveLength(3);
});
it("keeps a center snapline when no outer snaplines imply it", () => {
const reference = API.createElement({
type: "rectangle",
id: "reference",
x: 0,
y: 0,
width: 100,
height: 100,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 200,
y: 25,
width: 50,
height: 50,
});
const elements = [reference, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
primeReferenceSnapPoints(elements, [selected]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getHorizontalPointSnapLineCoordinates(snapLines)).toEqual([50]);
});
it("filters center snaplines when matching outer offsets differ by rounding precision", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 2532.227563984471,
y: -1553.9657067952232,
width: 140.1015625,
height: 140.1015625,
});
const reference = API.createElement({
type: "rectangle",
id: "reference",
x: 2532.2275640966914,
y: -1299.4323092037737,
width: 140.1015625,
height: 140.1015625,
});
const elements = [reference, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
primeReferenceSnapPoints(elements, [selected]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getVerticalPointSnapLineCoordinates(snapLines)).toEqual([
2532.227564, 2672.329126,
]);
});
it("keeps outer snaplines stable while dragging a snapped element through rounding-equivalent offsets", () => {
const referenceMiddle = API.createElement({
type: "rectangle",
id: "referenceMiddle",
x: 2532.22756398447,
y: -1553.9657067952237,
width: 140.1015625,
height: 140.1015625,
});
const referenceAbove = API.createElement({
type: "rectangle",
id: "referenceAbove",
x: 2532.2275637826165,
y: -1779.7363232531268,
width: 140.1015625,
height: 140.1015625,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 2532.227563096691,
y: -1328.1950902037736,
width: 140.1015625,
height: 140.1015625,
});
const elements = [referenceAbove, referenceMiddle, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
primeReferenceSnapPoints(elements, [selected]);
for (const dragOffsetX of [-4, -1, -0.1, 0, 0.1, 1, 4]) {
const { snapLines } = snapDraggedElements(
elements,
{ x: dragOffsetX, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
const coordinates = getVerticalPointSnapLineCoordinates(snapLines);
expect(coordinates).toHaveLength(2);
expect(coordinates[1] - coordinates[0]).toBeCloseTo(selected.width, 5);
}
});
it("keeps same-offset point snaps even across distant references", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 0,
y: 0,
width: 100,
height: 100,
});
const elements = [selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
SnapCache.setReferenceSnapPoints([
{
point: pointFrom<GlobalPoint>(220, 50),
type: "center",
snapSourceId: "near",
},
{
point: pointFrom<GlobalPoint>(900, 50),
type: "center",
snapSourceId: "far",
},
]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(900);
});
it("prefers a nearby point snap over a slightly better far offset", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 0,
y: 0,
width: 100,
height: 100,
});
const elements = [selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
SnapCache.setReferenceSnapPoints([
{
point: pointFrom<GlobalPoint>(220, 54),
type: "center",
snapSourceId: "near",
},
{
point: pointFrom<GlobalPoint>(900, 50),
type: "center",
snapSourceId: "far",
},
]);
const { snapOffset, snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(snapOffset.y).toBe(4);
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(220);
});
it("keeps same-offset point snaps when references form a continuous cluster", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 0,
y: 0,
width: 100,
height: 100,
});
const elements = [selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
SnapCache.setReferenceSnapPoints([
{
point: pointFrom<GlobalPoint>(200, 50),
type: "center",
snapSourceId: "referenceA",
},
{
point: pointFrom<GlobalPoint>(350, 50),
type: "center",
snapSourceId: "referenceB",
},
{
point: pointFrom<GlobalPoint>(500, 50),
type: "center",
snapSourceId: "referenceC",
},
]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(500);
});
it("keeps same-source same-offset point snaps across zoom-scaled cluster breaks", () => {
const reference = API.createElement({
type: "rectangle",
id: "reference",
x: 0,
y: 0,
width: 140.1015625,
height: 140.1015625,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 338.608871112217,
y: 0,
width: 140.1015625,
height: 140.1015625,
});
const elements = [reference, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
zoom: { value: 1.5 as NormalizedZoomValue },
});
primeReferenceSnapPoints(elements, [selected]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
const range = getHorizontalPointSnapLineXRange(snapLines);
expect(range).not.toBe(null);
expect(range![0]).toBeCloseTo(reference.x, 6);
expect(range![1]).toBeCloseTo(selected.x + selected.width, 6);
});
it("renders gap snaplines when rounded bounds touch the reference gap overlap", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 0,
y: 0.0000004,
width: 100,
height: 100,
});
const elements = [selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
SnapCache.setVisibleGaps({
horizontalGaps: [
{
startBounds: [200, -100, 300, 0],
endBounds: [400, -100, 500, 0],
startSide: [pointFrom(300, -100), pointFrom(300, 0)],
endSide: [pointFrom(400, -100), pointFrom(400, 0)],
overlap: rangeInclusive(-100, 0),
length: 100,
},
],
verticalGaps: [],
});
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getHorizontalGapSnapLines(snapLines)).toHaveLength(2);
});
it("renders gap snaplines when the winning gap offset only differs by rounding precision", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 0,
y: 399.999999,
width: 100,
height: 100,
});
const elements = [selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
SnapCache.setReferenceSnapPoints([
{
point: pointFrom<GlobalPoint>(0, 399.999999),
type: "outer",
snapSourceId: "reference",
},
]);
SnapCache.setVisibleGaps({
horizontalGaps: [],
verticalGaps: [
{
startBounds: [0, 100, 100, 200],
endBounds: [0, 250, 100, 350],
startSide: [pointFrom(0, 200), pointFrom(100, 200)],
endSide: [pointFrom(0, 250), pointFrom(100, 250)],
overlap: rangeInclusive(0, 100),
length: 50,
},
],
});
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getVerticalGapSnapLines(snapLines)).toHaveLength(2);
});
});
+7 -18
View File
@@ -10,6 +10,8 @@ import type { LinearElementEditor } from "@excalidraw/element";
import type { MaybeTransformHandleType } from "@excalidraw/element";
import type { SnapLine } from "@excalidraw/element";
import type {
PointerType,
ExcalidrawLinearElement,
@@ -55,7 +57,6 @@ import type { ClipboardData } from "./clipboard";
import type App from "./components/App";
import type Library from "./data/library";
import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping";
import type { ImportedDataState } from "./data/types";
import type { Language } from "./i18n";
@@ -315,10 +316,7 @@ export interface AppState {
bindingPreference: "enabled" | "disabled";
/** user preference whether arrow snap to midpoints while binding */
isMidpointSnappingEnabled: boolean;
/**
* The bindable element the UI highlights for the user when an arrow is
* dragged or otherwise its endpoint being close to said element.
*/
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBinding: {
element: NonDeleted<ExcalidrawBindableElement>;
midPoint?: GlobalPoint;
@@ -350,11 +348,8 @@ export interface AppState {
type: "selection" | "lasso";
initialized: boolean;
};
// Pen handling
penMode: boolean;
penDetected: boolean;
exportBackground: boolean;
exportEmbedScene: boolean;
exportWithDarkMode: boolean;
@@ -478,9 +473,6 @@ export interface AppState {
// as elements are unlocked, we remove the groupId from the elements
// and also remove groupId from this map
lockedMultiSelections: { [groupId: string]: true };
// Stores the current bind mode which is detemined at various points during
// a drag operation (like pointer position vs bindable element) but needed
// globally for calculating the binding strategy
bindMode: BindMode;
}
@@ -496,7 +488,10 @@ export type SearchMatch = {
}[];
};
export type UIAppState = Omit<AppState, "cursorButton" | "scrollX" | "scrollY">;
export type UIAppState = Omit<
AppState,
"startBoundElement" | "cursorButton" | "scrollX" | "scrollY"
>;
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
@@ -876,13 +871,8 @@ export type PointerDownState = Readonly<{
// Whether selected element(s) were duplicated, might change during the
// pointer interaction
hasBeenDuplicated: boolean;
// Whether the pointer is hitting the common bounding box of selected
// elements, which is useful for discriminating between selecitng
// the entire selection vs a specific element
hasHitCommonBoundingBoxOfSelectedElements: boolean;
};
// This is determined on the initial pointer down event to
// set various interaction modalities
withCmdOrCtrl: boolean;
drag: {
// Might change during the pointer interaction
@@ -908,7 +898,6 @@ export type PointerDownState = Readonly<{
onKeyUp: null | ((event: KeyboardEvent) => void);
};
boxSelection: {
// If the box selection tool is activated on pointer down
hasOccurred: boolean;
};
}>;
@@ -98,6 +98,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"showHyperlinkPopup": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,