Compare commits

..
25 changed files with 1802 additions and 1279 deletions
+3
View File
@@ -250,6 +250,9 @@ export const duplicateElements = (
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements)); elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
}; };
// main
// ---------------------------------------------------------------------------
const frameIdsToDuplicate = new Set( const frameIdsToDuplicate = new Set(
elements elements
.filter( .filter(
+4 -2
View File
@@ -19,6 +19,7 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
doBoundsIntersect, doBoundsIntersect,
getElementBounds, getElementBounds,
boundsContainBounds,
} from "./bounds"; } from "./bounds";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
@@ -101,8 +102,9 @@ export const isElementContainingFrame = (
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return getElementsWithinSelection([frame], element, elementsMap).some( return boundsContainBounds(
(e) => e.id === frame.id, getElementBounds(element, elementsMap),
getElementBounds(frame, elementsMap),
); );
}; };
-1
View File
@@ -88,7 +88,6 @@ export * from "./selection";
export * from "./shape"; export * from "./shape";
export * from "./showSelectedShapeActions"; export * from "./showSelectedShapeActions";
export * from "./sizeHelpers"; export * from "./sizeHelpers";
export * from "./snapping";
export * from "./sortElements"; export * from "./sortElements";
export * from "./store"; export * from "./store";
export * from "./textElement"; export * from "./textElement";
+93 -352
View File
@@ -7,7 +7,6 @@ import {
type LocalPoint, type LocalPoint,
pointDistance, pointDistance,
vectorFromPoint, vectorFromPoint,
line,
curveLength, curveLength,
curvePointAtLength, curvePointAtLength,
} from "@excalidraw/math"; } from "@excalidraw/math";
@@ -30,9 +29,6 @@ import {
isPathALoop, isPathALoop,
moveArrowAboveBindable, moveArrowAboveBindable,
projectFixedPointOntoDiagonal, projectFixedPointOntoDiagonal,
snapLinearElementPoint,
snapToDiscreteAngle,
type SnapLine,
type Store, type Store,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -52,7 +48,6 @@ import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
getBindingStrategyForDraggingBindingElementEndpoints, getBindingStrategyForDraggingBindingElementEndpoints,
isBindingEnabled, isBindingEnabled,
maxBindingDistance_simple,
snapToMid, snapToMid,
updateBoundPoint, updateBoundPoint,
} from "./binding"; } from "./binding";
@@ -61,7 +56,6 @@ import {
getElementPointsCoords, getElementPointsCoords,
getMinMaxXYFromCurvePathOps, getMinMaxXYFromCurvePathOps,
} from "./bounds"; } from "./bounds";
import { getHoveredElementForBinding } from "./collision";
import { headingIsHorizontal, vectorToHeading } from "./heading"; import { headingIsHorizontal, vectorToHeading } from "./heading";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
@@ -300,10 +294,7 @@ export class LinearElementEditor {
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
): Pick< ): Pick<AppState, "suggestedBinding" | "selectedLinearElement"> | null {
AppState,
"suggestedBinding" | "selectedLinearElement" | "snapLines"
> | null {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const elements = app.scene.getNonDeletedElements(); const elements = app.scene.getNonDeletedElements();
const { elementId } = linearElementEditor; const { elementId } = linearElementEditor;
@@ -320,26 +311,36 @@ export class LinearElementEditor {
linearElementEditor.customLineAngle ?? linearElementEditor.customLineAngle ??
determineCustomLinearAngle(pivotPoint, element.points[idx]); determineCustomLinearAngle(pivotPoint, element.points[idx]);
const { point: newDraggingPointPosition, snapLines } = // Determine if point movement should happen and how much
LinearElementEditor._getSnappedPointForLinearElement({ let deltaX = 0;
app, let deltaY = 0;
event, if (shouldRotateWithDiscreteAngle(event)) {
elements, const [width, height] = LinearElementEditor._getShiftLockedDelta(
elementsMap,
element, element,
pointIndex: idx, elementsMap,
scenePointerX, pivotPoint,
scenePointerY, pointFrom(scenePointerX, scenePointerY),
pointerOffset: linearElementEditor.pointerOffset, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
referencePoint: shouldRotateWithDiscreteAngle(event)
? pivotPoint
: null,
selectedPointsIndices: [idx],
customLineAngle, customLineAngle,
}); );
const target = pointFrom<LocalPoint>(
width + pivotPoint[0],
height + pivotPoint[1],
);
const deltaX = newDraggingPointPosition[0] - point[0]; deltaX = target[0] - point[0];
const deltaY = newDraggingPointPosition[1] - point[1]; 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];
}
// Apply the point movement if needed // Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null; let suggestedBinding: AppState["suggestedBinding"] = null;
@@ -397,8 +398,6 @@ export class LinearElementEditor {
// PERF: Avoid state updates if not absolutely necessary // PERF: Avoid state updates if not absolutely necessary
if ( if (
app.state.selectedLinearElement?.customLineAngle === customLineAngle && app.state.selectedLinearElement?.customLineAngle === customLineAngle &&
app.state.snapLines.length === 0 &&
snapLines.length === 0 &&
linearElementEditor.initialState.altFocusPoint && linearElementEditor.initialState.altFocusPoint &&
(!suggestedBinding || (!suggestedBinding ||
isShallowEqual(app.state.suggestedBinding ?? [], suggestedBinding)) isShallowEqual(app.state.suggestedBinding ?? [], suggestedBinding))
@@ -437,7 +436,6 @@ export class LinearElementEditor {
return { return {
selectedLinearElement: newLinearElementEditor, selectedLinearElement: newLinearElementEditor,
suggestedBinding, suggestedBinding,
snapLines,
}; };
} }
@@ -447,10 +445,7 @@ export class LinearElementEditor {
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
): Pick< ): Pick<AppState, "suggestedBinding" | "selectedLinearElement"> | null {
AppState,
"suggestedBinding" | "selectedLinearElement" | "snapLines"
> | null {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const elements = app.scene.getNonDeletedElements(); const elements = app.scene.getNonDeletedElements();
const { elbowed, elementId, initialState } = linearElementEditor; const { elbowed, elementId, initialState } = linearElementEditor;
@@ -498,6 +493,7 @@ export class LinearElementEditor {
lastClickedPoint = element.points.length - 1; lastClickedPoint = element.points.length - 1;
} }
// point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint]; const draggingPoint = element.points[lastClickedPoint];
// The adjacent point to the one dragged point // The adjacent point to the one dragged point
const pivotPoint = const pivotPoint =
@@ -511,27 +507,35 @@ export class LinearElementEditor {
element.points.length - 1, element.points.length - 1,
); );
const { point: newDraggingPointPosition, snapLines } = // Determine if point movement should happen and how much
LinearElementEditor._getSnappedPointForLinearElement({ let deltaX = 0;
app, let deltaY = 0;
event, if (shouldRotateWithDiscreteAngle(event) && singlePointDragged) {
elements, const [width, height] = LinearElementEditor._getShiftLockedDelta(
elementsMap,
element, element,
pointIndex: lastClickedPoint, elementsMap,
scenePointerX, pivotPoint,
scenePointerY, pointFrom(scenePointerX, scenePointerY),
pointerOffset: linearElementEditor.pointerOffset, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
referencePoint:
shouldRotateWithDiscreteAngle(event) && singlePointDragged
? pivotPoint
: null,
selectedPointsIndices,
customLineAngle, customLineAngle,
}); );
const target = pointFrom<LocalPoint>(
const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; width + pivotPoint[0],
const deltaY = newDraggingPointPosition[1] - draggingPoint[1]; 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];
}
// Apply the point movement if needed // Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null; let suggestedBinding: AppState["suggestedBinding"] = null;
@@ -670,7 +674,6 @@ export class LinearElementEditor {
return { return {
selectedLinearElement: newLinearElementEditor, selectedLinearElement: newLinearElementEditor,
suggestedBinding, suggestedBinding,
snapLines,
}; };
} }
@@ -1175,10 +1178,7 @@ export class LinearElementEditor {
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
app: AppClassProperties, app: AppClassProperties,
): { ): LinearElementEditor | null {
editingLinearElement: LinearElementEditor;
snapLines: readonly SnapLine[];
} | null {
const appState = app.state; const appState = app.state;
if (!appState.selectedLinearElement?.isEditing) { if (!appState.selectedLinearElement?.isEditing) {
return null; return null;
@@ -1187,10 +1187,7 @@ export class LinearElementEditor {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) { if (!element) {
return { return appState.selectedLinearElement;
editingLinearElement: appState.selectedLinearElement,
snapLines: appState.snapLines,
};
} }
const { points } = element; const { points } = element;
@@ -1202,37 +1199,36 @@ export class LinearElementEditor {
} }
return appState.selectedLinearElement?.lastUncommittedPoint return appState.selectedLinearElement?.lastUncommittedPoint
? { ? {
editingLinearElement: { ...appState.selectedLinearElement,
...appState.selectedLinearElement, lastUncommittedPoint: null,
lastUncommittedPoint: null,
},
snapLines: [],
} }
: { : appState.selectedLinearElement;
editingLinearElement: appState.selectedLinearElement,
snapLines: [],
};
} }
const anchor = points[points.length - 2]; let newPoint: LocalPoint;
const elements = app.scene.getNonDeletedElements();
const { point: newPoint, snapLines } = if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
LinearElementEditor._getSnappedPointForLinearElement({ const anchor = points[points.length - 2];
app, const [width, height] = LinearElementEditor._getShiftLockedDelta(
event,
elements,
elementsMap,
element, element,
pointIndex: points.length - 1, elementsMap,
scenePointerX, anchor,
scenePointerY, pointFrom(scenePointerX, scenePointerY),
pointerOffset: appState.selectedLinearElement.pointerOffset, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
referencePoint: );
shouldRotateWithDiscreteAngle(event) && points.length >= 2
? anchor newPoint = pointFrom(width + anchor[0], height + anchor[1]);
: null, } else {
selectedPointsIndices: [points.length - 1], 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(),
);
}
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
@@ -1240,7 +1236,7 @@ export class LinearElementEditor {
app.scene, app.scene,
new Map([ new Map([
[ [
points.length - 1, element.points.length - 1,
{ {
point: newPoint, point: newPoint,
}, },
@@ -1250,13 +1246,9 @@ export class LinearElementEditor {
} else { } else {
LinearElementEditor.addPoints(element, app.scene, [newPoint]); LinearElementEditor.addPoints(element, app.scene, [newPoint]);
} }
return { return {
editingLinearElement: { ...appState.selectedLinearElement,
...appState.selectedLinearElement, lastUncommittedPoint: element.points[element.points.length - 1],
lastUncommittedPoint: element.points[element.points.length - 1],
},
snapLines,
}; };
} }
@@ -1282,53 +1274,18 @@ export class LinearElementEditor {
static getPointsGlobalCoordinates( static getPointsGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
options: {
dragOffset?: { x: number; y: number };
excludePointsIndices?: readonly number[];
} = {},
): GlobalPoint[] { ): GlobalPoint[] {
const { dragOffset, excludePointsIndices } = options;
if (!element.points || element.points.length === 0) {
return [];
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
return element.points.map((p) => {
let elementX = element.x; const { x, y } = element;
let elementY = element.y; return pointRotateRads(
pointFrom(x + p[0], y + p[1]),
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), pointFrom(cx, cy),
element.angle, element.angle,
); );
globalPoints.push(rotated); });
}
return globalPoints;
} }
static getPointAtIndexGlobalCoordinates( static getPointAtIndexGlobalCoordinates(
@@ -1882,222 +1839,6 @@ 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 = ( static getBoundTextElementPosition = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
boundTextElement: ExcalidrawTextElementWithContainer, boundTextElement: ExcalidrawTextElementWithContainer,
+3 -5
View File
@@ -34,7 +34,6 @@ import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
getContainingFrame, getContainingFrame,
getFrameChildren, getFrameChildren,
isElementIntersectingFrame,
} from "./frame"; } from "./frame";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
@@ -170,7 +169,7 @@ export const getElementsWithinSelection = (
const associatedFrame = getContainingFrame(element, elementsMap); const associatedFrame = getContainingFrame(element, elementsMap);
if ( if (
associatedFrame && associatedFrame &&
isElementIntersectingFrame(element, associatedFrame, elementsMap) elementOverlapsWithFrame(element, associatedFrame, elementsMap)
) { ) {
const frameAABB = getElementBounds(associatedFrame, elementsMap); const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [ elementAABB = [
@@ -209,10 +208,9 @@ export const getElementsWithinSelection = (
if (boundsContainBounds(selectionBounds, commonAABB)) { if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) { if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id); 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 // 2. Handle the case where the label is overlapped by the selection box
+63 -65
View File
@@ -1,59 +1,56 @@
import { arrayToMapWithIndex } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => { const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const origElements: ExcalidrawElement[] = elements.slice(); const groupIdAtLevel = (element: ExcalidrawElement, level: number) => {
const sortedElements = new Set<ExcalidrawElement>(); return element.groupIds[element.groupIds.length - level - 1];
const orderInnerGroups = (
elements: readonly ExcalidrawElement[],
): ExcalidrawElement[] => {
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);
}
}
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
}; };
const groupHandledElements = new Map<string, true>(); const orderLevel = (
levelElements: readonly ExcalidrawElement[],
level: number,
): 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)[] = [];
origElements.forEach((element, idx) => { for (const element of levelElements) {
if (groupHandledElements.has(element.id)) { const groupId = groupIdAtLevel(element, level);
return; if (groupId === undefined) {
} slots.push(element);
if (element.groupIds?.length) { continue;
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 { let bucket = buckets.get(groupId);
sortedElements.add(element); 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],
);
};
// `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);
// if there's a bug which resulted in losing some of the elements, return // if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data // original instead as that's better than losing data
if (sortedElements.size !== elements.length) { if (sortedElements.length !== elements.length) {
console.error("normalizeGroupElementOrder: lost some elements... bailing!"); console.error("defragmentGroups: lost some elements... bailing!");
return elements; return elements;
} }
return [...sortedElements]; return sortedElements;
}; };
/** /**
@@ -68,39 +65,40 @@ const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const normalizeBoundElementsOrder = ( const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => { ) => {
const elementsMap = arrayToMapWithIndex(elements); const elementsMap = arrayToMap(elements);
const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>(); const sortedElements = new Set<ExcalidrawElement>();
origElements.forEach((element, idx) => { for (const element of elements) {
if (!element) { if (sortedElements.has(element)) {
return; continue;
} }
if (element.boundElements?.length) { if (element.boundElements?.length) {
sortedElements.add(element); sortedElements.add(element);
origElements[idx] = null; for (const boundElement of element.boundElements) {
element.boundElements.forEach((boundElement) => {
const child = elementsMap.get(boundElement.id); const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") { if (child && boundElement.type === "text") {
sortedElements.add(child[0]); sortedElements.add(child);
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
} }
} else { continue;
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 // if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data // original instead as that's better than losing data
@@ -117,5 +115,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = ( export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => { ) => {
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements)); return normalizeBoundElementsOrder(defragmentGroups(elements));
}; };
+43 -1
View File
@@ -2,6 +2,7 @@ import {
convertToExcalidrawElements, convertToExcalidrawElements,
Excalidraw, Excalidraw,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { arrayToMap } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui"; import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
@@ -10,7 +11,12 @@ import {
render, render,
} from "@excalidraw/excalidraw/tests/test-utils"; } from "@excalidraw/excalidraw/tests/test-utils";
import type { ExcalidrawElement } from "../src/types"; import { elementOverlapsWithFrame } from "../src/frame";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
} from "../src/types";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@@ -125,6 +131,26 @@ 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 ( const commonTestCases = async (
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame, func: typeof resizeFrameOverElement | typeof dragElementIntoFrame,
) => { ) => {
@@ -415,6 +441,22 @@ describe("adding elements to frames", () => {
describe("dragging elements into the frame", async () => { describe("dragging elements into the frame", async () => {
await commonTestCases(dragElementIntoFrame); 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", () => { it.skip("should drag element inside, duplicate it and keep it in frame", () => {
API.setElements([frame, rect2]); API.setElements([frame, rect2]);
@@ -155,24 +155,6 @@ 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) => { const deletePoint = (point: GlobalPoint) => {
fireEvent.pointerDown(interactiveCanvas, { fireEvent.pointerDown(interactiveCanvas, {
clientX: point[0], clientX: point[0],
@@ -276,73 +258,6 @@ describe("Test Linear Elements", () => {
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); 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)", () => { it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
@@ -486,77 +401,6 @@ 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 () => { it("should update the midpoints when element roundness changed", async () => {
createThreePointerLinearElement("line"); createThreePointerLinearElement("line");
+43 -3
View File
@@ -326,19 +326,59 @@ describe("normalizeElementsOrder", () => {
]), ]),
[ [
"BA_rect1", "BA_rect1",
"CBA_rect3",
"CBA_rect7",
"BA_rect5", "BA_rect5",
"BA_rect6", "BA_rect6",
"A_rect2", "A_rect2",
"A_rect5", "A_rect5",
"CBA_rect3",
"CBA_rect7",
"rect4", "rect4",
"X_rect8", "X_rect8",
"X_rect11",
"YX_rect10", "YX_rect10",
"X_rect11",
"rect9", "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 // TODO
@@ -329,7 +329,6 @@ export const actionFinalize = register<FormData>({
selectionElement: null, selectionElement: null,
multiElement: null, multiElement: null,
editingTextElement: null, editingTextElement: null,
startBoundElement: null,
suggestedBinding: null, suggestedBinding: null,
selectedElementIds: selectedElementIds:
element && element &&
-2
View File
@@ -99,7 +99,6 @@ export const getDefaultAppState = (): Omit<
open: false, open: false,
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties, panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
}, },
startBoundElement: null,
suggestedBinding: null, suggestedBinding: null,
frameRendering: { enabled: true, clip: true, name: true, outline: true }, frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null, frameToHighlight: null,
@@ -231,7 +230,6 @@ const APP_STATE_STORAGE_CONF = (<
selectionElement: { browser: false, export: false, server: false }, selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { 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 }, suggestedBinding: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false }, frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false }, frameToHighlight: { browser: false, export: false, server: false },
+88 -114
View File
@@ -239,16 +239,6 @@ import {
hitElementBoundingBox, hitElementBoundingBox,
isLineElement, isLineElement,
isSimpleArrow, isSimpleArrow,
isGridModeEnabled,
SnapCache,
isActiveToolNonLinearSnappable,
getSnapLinesAtPointer,
isSnappingEnabled,
getReferenceSnapPoints,
getVisibleGaps,
snapDraggedElements,
snapNewElement,
snapResizingElements,
StoreDelta, StoreDelta,
type ApplyToOptions, type ApplyToOptions,
positionElementsOnGrid, positionElementsOnGrid,
@@ -406,6 +396,18 @@ import {
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
import { editorJotaiStore, type WritableAtom } from "../editor-jotai"; import { editorJotaiStore, type WritableAtom } from "../editor-jotai";
import { ImageSceneDataError } from "../errors"; import { ImageSceneDataError } from "../errors";
import {
getSnapLinesAtPointer,
snapDraggedElements,
isActiveToolNonLinearSnappable,
snapNewElement,
snapResizingElements,
isSnappingEnabled,
getVisibleGaps,
getReferenceSnapPoints,
SnapCache,
isGridModeEnabled,
} from "../snapping";
import { Renderer } from "../scene/Renderer"; import { Renderer } from "../scene/Renderer";
import { import {
setEraserCursor, setEraserCursor,
@@ -601,6 +603,8 @@ const YOUTUBE_VIDEO_STATES = new Map<
ValueOf<typeof YOUTUBE_STATES> ValueOf<typeof YOUTUBE_STATES>
>(); >();
const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4;
let IS_PLAIN_PASTE = false; let IS_PLAIN_PASTE = false;
let IS_PLAIN_PASTE_TIMER = 0; let IS_PLAIN_PASTE_TIMER = 0;
let PLAIN_PASTE_TOAST_SHOWN = false; let PLAIN_PASTE_TOAST_SHOWN = false;
@@ -783,29 +787,6 @@ class App extends React.Component<AppProps, AppState> {
return api; 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) { constructor(props: AppProps) {
super(props); super(props);
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
@@ -1756,6 +1737,18 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeEmbeddable?.element === el && this.state.activeEmbeddable?.element === el &&
this.state.activeEmbeddable?.state === "hover"; 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 ( return (
<div <div
key={el.id} key={el.id}
@@ -1822,31 +1815,42 @@ class App extends React.Component<AppProps, AppState> {
padding: `${el.strokeWidth}px`, padding: `${el.strokeWidth}px`,
}} }}
> >
{(isEmbeddableElement(el) <div
? this.props.renderEmbeddable?.(el, this.state) className="excalidraw__embeddable__content"
: null) ?? ( style={{
<iframe width: `${embeddableViewportScale * 100}%`,
ref={(ref) => this.cacheEmbeddableRef(el, ref)} height: `${embeddableViewportScale * 100}%`,
className="excalidraw__embeddable" transform: `scale(${1 / embeddableViewportScale})`,
srcDoc={ }}
src?.type === "document" >
? src.srcdoc(this.state.theme) {(isEmbeddableElement(el)
: undefined ? this.props.renderEmbeddable?.(el, this.state)
} : null) ?? (
src={ <iframe
src?.type !== "document" ? src?.link ?? "" : undefined ref={(ref) => this.cacheEmbeddableRef(el, ref)}
} className="excalidraw__embeddable"
// https://stackoverflow.com/q/18470015 srcDoc={
scrolling="no" src?.type === "document"
referrerPolicy="no-referrer-when-downgrade" ? src.srcdoc(this.state.theme)
title="Excalidraw Embedded Content" : undefined
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" }
allowFullScreen={true} src={
sandbox={`${ src?.type !== "document" ? src?.link ?? "" : undefined
src?.sandbox?.allowSameOrigin ? "allow-same-origin" : "" }
} allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads`} // 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>
</div> </div>
</div> </div>
@@ -6807,10 +6811,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
!this.state.newElement && !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( const { originOffset, snapLines } = getSnapLinesAtPointer(
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
@@ -6859,7 +6860,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedLinearElement?.isEditing && this.state.selectedLinearElement?.isEditing &&
!this.state.selectedLinearElement.isDragging !this.state.selectedLinearElement.isDragging
) { ) {
const result = this.state.newElement const editingLinearElement = this.state.newElement
? null ? null
: LinearElementEditor.handlePointerMoveInEditMode( : LinearElementEditor.handlePointerMoveInEditMode(
event, event,
@@ -6868,33 +6869,18 @@ class App extends React.Component<AppProps, AppState> {
this, this,
); );
if (result) { if (
const { editingLinearElement, snapLines } = result; editingLinearElement &&
const nextState = this.withStableSnapLines({ editingLinearElement !== this.state.selectedLinearElement
selectedLinearElement: editingLinearElement, ) {
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.
if ( flushSync(() => {
editingLinearElement && this.setState({
this.shouldUpdateSelectedLinearElementState( selectedLinearElement: editingLinearElement,
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 });
}
} }
} }
@@ -6961,7 +6947,7 @@ class App extends React.Component<AppProps, AppState> {
y: scenePointerY, y: scenePointerY,
}, },
}); });
this.setState({ suggestedBinding: null, startBoundElement: null }); this.setState({ suggestedBinding: null });
if (!this.state.activeTool.locked) { if (!this.state.activeTool.locked) {
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
this.setState((prevState) => ({ this.setState((prevState) => ({
@@ -7605,7 +7591,6 @@ class App extends React.Component<AppProps, AppState> {
appState: { appState: {
newElement: null, newElement: null,
editingTextElement: null, editingTextElement: null,
startBoundElement: null,
suggestedBinding: null, suggestedBinding: null,
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
Object.keys(this.state.selectedElementIds) Object.keys(this.state.selectedElementIds)
@@ -8893,18 +8878,8 @@ 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({ this.setState({
newElement: element, newElement: element,
startBoundElement: boundElement,
suggestedBinding: null, suggestedBinding: null,
}); });
}; };
@@ -9752,27 +9727,25 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y; pointerDownState.lastCoords.y = pointerCoords.y;
pointerDownState.drag.hasOccurred = true; pointerDownState.drag.hasOccurred = true;
const nextState = this.withStableSnapLines(newState);
// NOTE: Optimize setState calls because it // NOTE: Optimize setState calls because it
// affects history and performance // affects history and performance
if ( if (
nextState.suggestedBinding !== this.state.suggestedBinding || newState.suggestedBinding !== this.state.suggestedBinding ||
!isShallowEqual( !isShallowEqual(
nextState.selectedLinearElement?.selectedPointsIndices ?? [], newState.selectedLinearElement?.selectedPointsIndices ?? [],
this.state.selectedLinearElement?.selectedPointsIndices ?? [], this.state.selectedLinearElement?.selectedPointsIndices ?? [],
) || ) ||
nextState.selectedLinearElement?.hoverPointIndex !== newState.selectedLinearElement?.hoverPointIndex !==
this.state.selectedLinearElement?.hoverPointIndex || this.state.selectedLinearElement?.hoverPointIndex ||
nextState.selectedLinearElement?.customLineAngle !== newState.selectedLinearElement?.customLineAngle !==
this.state.selectedLinearElement?.customLineAngle || this.state.selectedLinearElement?.customLineAngle ||
this.state.selectedLinearElement.isDragging !== this.state.selectedLinearElement.isDragging !==
nextState.selectedLinearElement?.isDragging || newState.selectedLinearElement?.isDragging ||
this.state.selectedLinearElement?.initialState?.altFocusPoint !== this.state.selectedLinearElement?.initialState?.altFocusPoint !==
nextState.selectedLinearElement?.initialState?.altFocusPoint || newState.selectedLinearElement?.initialState?.altFocusPoint
nextState.snapLines !== this.state.snapLines
) { ) {
this.setState(nextState); this.setState(newState);
} }
return; return;
@@ -10461,7 +10434,8 @@ class App extends React.Component<AppProps, AppState> {
this.lassoTrail.endPath(); this.lassoTrail.endPath();
this.previousPointerMoveCoords = null; this.previousPointerMoveCoords = null;
SnapCache.destroy(); SnapCache.setReferenceSnapPoints(null);
SnapCache.setVisibleGaps(null);
this.savePointer(childEvent.clientX, childEvent.clientY, "up"); this.savePointer(childEvent.clientX, childEvent.clientY, "up");
@@ -10758,7 +10732,7 @@ class App extends React.Component<AppProps, AppState> {
sceneCoords, sceneCoords,
}); });
} }
this.setState({ suggestedBinding: null, startBoundElement: null }); this.setState({ suggestedBinding: null });
if (!activeTool.locked) { if (!activeTool.locked) {
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
this.setState((prevState) => ({ this.setState((prevState) => ({
@@ -10779,9 +10753,9 @@ class App extends React.Component<AppProps, AppState> {
), ),
})); }));
} else { } else {
this.setState((prevState) => ({ this.setState({
newElement: null, newElement: null,
})); });
} }
// so that the scene gets rendered again to display the newly drawn linear as well // so that the scene gets rendered again to display the newly drawn linear as well
this.scene.triggerUpdate(); this.scene.triggerUpdate();
@@ -3,7 +3,6 @@ import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "@excalidraw/common";
import { import {
isFlowchartNodeElement, isFlowchartNodeElement,
isImageElement, isImageElement,
isGridModeEnabled,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
isTextBindableContainer, isTextBindableContainer,
@@ -17,6 +16,7 @@ import type { EditorInterface } from "@excalidraw/common";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut"; import { getShortcutKey } from "../shortcut";
import { isEraserActive } from "../appState"; import { isEraserActive } from "../appState";
import { isGridModeEnabled } from "../snapping";
import "./HintViewer.scss"; import "./HintViewer.scss";
+1 -2
View File
@@ -650,8 +650,7 @@ const LayerUI = ({
}; };
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => { const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
const { startBoundElement, cursorButton, scrollX, scrollY, ...ret } = const { cursorButton, scrollX, scrollY, ...ret } = appState;
appState;
return ret; return ret;
}; };
@@ -12,11 +12,10 @@ import { frameAndChildrenSelectedTogether } from "@excalidraw/element";
import { elementsAreInSameGroup } from "@excalidraw/element"; import { elementsAreInSameGroup } from "@excalidraw/element";
import { isGridModeEnabled } from "@excalidraw/element";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { isGridModeEnabled } from "../../snapping";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App"; import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { Island } from "../Island"; import { Island } from "../Island";
import { CloseIcon } from "../icons"; import { CloseIcon } from "../icons";
+8
View File
@@ -814,6 +814,14 @@ body.excalidraw-cursor-resize * {
.excalidraw__embeddable__outer { .excalidraw__embeddable__outer {
width: 100%; width: 100%;
height: 100%; height: 100%;
}
.excalidraw__embeddable__content {
width: 100%;
height: 100%;
transform-origin: top left;
&,
& > * { & > * {
border-radius: var(--embeddable-radius); border-radius: var(--embeddable-radius);
} }
+1 -4
View File
@@ -2,8 +2,7 @@ import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
import { THEME } from "@excalidraw/common"; import { THEME } from "@excalidraw/common";
import type { PointSnapLine, PointerSnapLine } from "@excalidraw/element"; import type { PointSnapLine, PointerSnapLine } from "../snapping";
import type { InteractiveCanvasAppState } from "../types"; import type { InteractiveCanvasAppState } from "../types";
const SNAP_COLOR_LIGHT = "#ff6b6b"; const SNAP_COLOR_LIGHT = "#ff6b6b";
@@ -20,8 +19,6 @@ export const renderSnaps = (
} }
// in dark mode, we need to adjust the color to account for color inversion. // 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 = const snapColor =
appState.theme === THEME.LIGHT || appState.zenModeEnabled appState.theme === THEME.LIGHT || appState.zenModeEnabled
? SNAP_COLOR_LIGHT ? SNAP_COLOR_LIGHT
File diff suppressed because it is too large Load Diff
@@ -979,7 +979,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1173,7 +1172,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1388,7 +1386,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1720,7 +1717,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2052,7 +2048,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2265,7 +2260,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2509,7 +2503,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2813,7 +2806,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3181,7 +3173,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3675,7 +3666,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3999,7 +3989,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4326,7 +4315,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5612,7 +5600,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -6832,7 +6819,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7786,7 +7772,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8786,7 +8771,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9781,7 +9765,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1293,7 +1293,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1654,7 +1653,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2017,7 +2015,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2280,7 +2277,40 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null, "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,
},
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2735,7 +2765,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3039,7 +3068,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3359,7 +3387,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3654,7 +3681,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3941,7 +3967,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4177,7 +4202,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4435,7 +4459,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4707,7 +4730,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4937,7 +4959,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5167,7 +5188,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5415,7 +5435,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5672,7 +5691,6 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5930,7 +5948,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -6260,7 +6277,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -6691,7 +6707,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7072,7 +7087,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7667,7 +7681,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7898,7 +7911,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8251,7 +8263,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8610,7 +8621,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9011,7 +9021,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9293,7 +9302,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9558,7 +9566,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9824,7 +9831,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10058,7 +10064,6 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10671,7 +10676,6 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10911,7 +10915,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -11836,7 +11839,6 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -12095,7 +12097,6 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -12333,7 +12334,6 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -12569,7 +12569,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -12966,7 +12965,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13174,7 +13172,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13386,7 +13383,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13685,7 +13681,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13984,7 +13979,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14229,7 +14223,6 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14467,7 +14460,6 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14705,7 +14697,6 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14953,7 +14944,6 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -15286,7 +15276,6 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -15459,7 +15448,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -15742,7 +15730,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -16006,7 +15993,6 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -16161,7 +16147,6 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -16443,7 +16428,6 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -20170,7 +20154,6 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -20653,7 +20636,6 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -21160,7 +21142,6 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -106,7 +106,6 @@ exports[`given element A and group of elements B and given both are selected whe
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -535,7 +534,6 @@ exports[`given element A and group of elements B and given both are selected whe
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -943,7 +941,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1510,7 +1507,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1723,7 +1719,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2105,7 +2100,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2349,7 +2343,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2532,7 +2525,6 @@ exports[`regression tests > can drag element that covers another element, while
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2856,7 +2848,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3114,7 +3105,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3356,7 +3346,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3593,7 +3582,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3853,7 +3841,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4167,7 +4154,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4631,7 +4617,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4887,7 +4872,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5191,7 +5175,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5372,7 +5355,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5573,7 +5555,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5971,7 +5952,6 @@ exports[`regression tests > drags selected elements from point inside common bou
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7057,7 +7037,6 @@ exports[`regression tests > given a group of selected elements with an element t
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7392,7 +7371,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7671,7 +7649,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7907,7 +7884,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8146,7 +8122,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8327,7 +8302,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8508,7 +8482,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8665,14 +8638,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": { "originSnapOffset": null,
"x": 0,
"y": 0,
},
"pasteDialog": {
"data": null,
"shown": false,
},
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"preferredSelectionTool": { "preferredSelectionTool": {
@@ -9158,7 +9124,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9329,14 +9294,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": { "originSnapOffset": null,
"x": 0,
"y": 0,
},
"pasteDialog": {
"data": null,
"shown": false,
},
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"preferredSelectionTool": { "preferredSelectionTool": {
@@ -9593,7 +9551,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10005,7 +9962,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10184,7 +10140,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10379,7 +10334,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10568,7 +10522,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -11094,7 +11047,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -11371,7 +11323,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -11497,7 +11448,6 @@ exports[`regression tests > shift click on selected element should deselect it o
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -11702,7 +11652,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -12024,7 +11973,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -12458,7 +12406,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13090,7 +13037,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13218,7 +13164,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13879,7 +13824,6 @@ exports[`regression tests > switches from group of selected elements to another
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14218,7 +14162,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14451,7 +14394,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14940,7 +14882,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -15067,7 +15008,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -615,6 +615,32 @@ describe("box-selection overlap mode", () => {
assertSelectedElements([]); 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", () => { describe("inner box-selection", () => {
+833
View File
@@ -0,0 +1,833 @@
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);
});
});
+18 -7
View File
@@ -10,8 +10,6 @@ import type { LinearElementEditor } from "@excalidraw/element";
import type { MaybeTransformHandleType } from "@excalidraw/element"; import type { MaybeTransformHandleType } from "@excalidraw/element";
import type { SnapLine } from "@excalidraw/element";
import type { import type {
PointerType, PointerType,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -57,6 +55,7 @@ import type { ClipboardData } from "./clipboard";
import type App from "./components/App"; import type App from "./components/App";
import type Library from "./data/library"; import type Library from "./data/library";
import type { ContextMenuItems } from "./components/ContextMenu"; import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping";
import type { ImportedDataState } from "./data/types"; import type { ImportedDataState } from "./data/types";
import type { Language } from "./i18n"; import type { Language } from "./i18n";
@@ -316,7 +315,10 @@ export interface AppState {
bindingPreference: "enabled" | "disabled"; bindingPreference: "enabled" | "disabled";
/** user preference whether arrow snap to midpoints while binding */ /** user preference whether arrow snap to midpoints while binding */
isMidpointSnappingEnabled: boolean; isMidpointSnappingEnabled: boolean;
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null; /**
* The bindable element the UI highlights for the user when an arrow is
* dragged or otherwise its endpoint being close to said element.
*/
suggestedBinding: { suggestedBinding: {
element: NonDeleted<ExcalidrawBindableElement>; element: NonDeleted<ExcalidrawBindableElement>;
midPoint?: GlobalPoint; midPoint?: GlobalPoint;
@@ -348,8 +350,11 @@ export interface AppState {
type: "selection" | "lasso"; type: "selection" | "lasso";
initialized: boolean; initialized: boolean;
}; };
// Pen handling
penMode: boolean; penMode: boolean;
penDetected: boolean; penDetected: boolean;
exportBackground: boolean; exportBackground: boolean;
exportEmbedScene: boolean; exportEmbedScene: boolean;
exportWithDarkMode: boolean; exportWithDarkMode: boolean;
@@ -473,6 +478,9 @@ export interface AppState {
// as elements are unlocked, we remove the groupId from the elements // as elements are unlocked, we remove the groupId from the elements
// and also remove groupId from this map // and also remove groupId from this map
lockedMultiSelections: { [groupId: string]: true }; 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; bindMode: BindMode;
} }
@@ -488,10 +496,7 @@ export type SearchMatch = {
}[]; }[];
}; };
export type UIAppState = Omit< export type UIAppState = Omit<AppState, "cursorButton" | "scrollX" | "scrollY">;
AppState,
"startBoundElement" | "cursorButton" | "scrollX" | "scrollY"
>;
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" }; export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
@@ -871,8 +876,13 @@ export type PointerDownState = Readonly<{
// Whether selected element(s) were duplicated, might change during the // Whether selected element(s) were duplicated, might change during the
// pointer interaction // pointer interaction
hasBeenDuplicated: boolean; 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; hasHitCommonBoundingBoxOfSelectedElements: boolean;
}; };
// This is determined on the initial pointer down event to
// set various interaction modalities
withCmdOrCtrl: boolean; withCmdOrCtrl: boolean;
drag: { drag: {
// Might change during the pointer interaction // Might change during the pointer interaction
@@ -898,6 +908,7 @@ export type PointerDownState = Readonly<{
onKeyUp: null | ((event: KeyboardEvent) => void); onKeyUp: null | ((event: KeyboardEvent) => void);
}; };
boxSelection: { boxSelection: {
// If the box selection tool is activated on pointer down
hasOccurred: boolean; hasOccurred: boolean;
}; };
}>; }>;
@@ -98,7 +98,6 @@ exports[`exportToSvg > with default arguments 1`] = `
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,