Compare commits

...

14 Commits

Author SHA1 Message Date
Mark Tolmacs f3dba020b0 chore: Introducing RotatedBounds
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-30 14:17:53 +00:00
Mark Tolmacs 7c766bdfc4 fix: Test
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-30 13:15:00 +00:00
dwelle 23959099e1 fix rotated filled box-selection & refactor 2026-03-30 12:27:35 +02:00
dwelle 1e61f1c66a fix(editor): ctrl+alt with lasso over selected elements should keep lassoing 2026-03-27 13:43:34 +01:00
dwelle 22b0f1f918 fix(editor): move cursor not displaying for common bbox 2026-03-27 13:42:52 +01:00
dwelle 379dba47aa feat(editor): implement overlap box selection 2026-03-27 13:41:53 +01:00
David Luzar 4a5c9e990c fix(editor): ensure font picker font names are not quoted (#11036) 2026-03-25 17:56:48 +01:00
David Luzar c09e170bdd feat(editor): deselect on esc (#11035)
Co-authored-by: Jawahar <jawahars_16@live.in>
Co-authored-by: Andrew Aquino <dawneraq@gmail.com>
2026-03-25 17:14:24 +01:00
David Luzar c1082923ee feat(editor): support mermaid staate diagrams (#11031) 2026-03-24 20:20:28 +01:00
Kundan 1c292e4936 fix(math): correctly validate second point in isLineSegment (#11007)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:01:08 +01:00
Márk Tolmács d6f0f34fe9 fix: Rotated rounded arrow center point (#10962) 2026-03-23 15:54:59 +01:00
Márk Tolmács 75789f620d fix: Other endpoint is not immediately updated on midpoint snap (#10933)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-03-23 14:54:44 +00:00
David Luzar a9ca16eb42 chore(packages/excalidraw): export Fonts helper class (#11008) 2026-03-21 22:44:27 +01:00
Márk Tolmács 987173b52f fix: Arrow point index Out-of-Bounds (#10922)
* fix: Make OOB not fatal

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: More conservative temp arrow state update

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Capture condition variables in binding restoration failure

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-21 19:26:47 +01:00
40 changed files with 2274 additions and 289 deletions
+36 -1
View File
@@ -1,3 +1,5 @@
import type { Radians } from "@excalidraw/math";
/**
* x and y position of top left corner, x and y position of bottom right corner
*/
@@ -6,7 +8,31 @@ export type Bounds = readonly [
minY: number,
maxX: number,
maxY: number,
];
] & { _brand: "excalidraw__bounds" };
export type RotatedBounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
angle: Radians,
] & {
_brand_rotated: "excalidraw__rotated_bounds";
};
export const bounds = <T extends Radians | undefined = undefined>(
minX: number,
minY: number,
maxX: number,
maxY: number,
angle: T = undefined as T,
) => {
return (
angle
? ([minX, minY, maxX, maxY, angle] as unknown)
: ([minX, minY, maxX, maxY] as unknown)
) as T extends Radians ? RotatedBounds : Bounds;
};
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
@@ -15,3 +41,12 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
export const isRotatedBounds = (box: unknown): box is RotatedBounds =>
Array.isArray(box) &&
box.length === 5 &&
typeof box[0] === "number" &&
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number" &&
typeof box[4] === "number";
+74 -60
View File
@@ -2,9 +2,11 @@ import rough from "roughjs/bin/rough";
import {
arrayToMap,
bounds,
type Bounds,
invariant,
rescalePoints,
type RotatedBounds,
sizeOf,
} from "@excalidraw/common";
@@ -90,7 +92,7 @@ export class ElementBounds {
private static boundsCache = new WeakMap<
ExcalidrawElement,
{
bounds: Bounds;
bounds: RotatedBounds;
version: ExcalidrawElement["version"];
}
>();
@@ -102,11 +104,11 @@ export class ElementBounds {
}
>();
static getBounds(
static getBounds<T extends boolean = false>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
nonRotated: boolean = false,
) {
nonRotated: T = false as T,
): T extends true ? Bounds : RotatedBounds {
const cachedBounds =
nonRotated && element.angle !== 0
? ElementBounds.nonRotatedBoundsCache.get(element)
@@ -119,40 +121,41 @@ export class ElementBounds {
// which is causing problems down the line. Fix TBA.
!isBoundToContainer(element)
) {
return cachedBounds.bounds;
return cachedBounds.bounds as T extends true ? Bounds : RotatedBounds;
}
if (nonRotated && element.angle !== 0) {
const nonRotatedBounds = ElementBounds.calculateBounds(
const [minX, minY, maxX, maxY] = ElementBounds.calculateBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
const nonRotatedBounds = bounds(minX, minY, maxX, maxY);
ElementBounds.nonRotatedBoundsCache.set(element, {
version: element.version,
bounds: nonRotatedBounds,
});
return nonRotatedBounds;
return nonRotatedBounds as T extends true ? Bounds : RotatedBounds;
}
const bounds = ElementBounds.calculateBounds(element, elementsMap);
const _bounds = ElementBounds.calculateBounds(element, elementsMap);
ElementBounds.boundsCache.set(element, {
version: element.version,
bounds,
bounds: _bounds,
});
return bounds;
return _bounds as T extends true ? Bounds : RotatedBounds;
}
private static calculateBounds(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): Bounds {
let bounds: Bounds;
): RotatedBounds {
let _bounds: RotatedBounds;
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
@@ -169,14 +172,15 @@ export class ElementBounds {
),
);
return [
return bounds(
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
element.angle,
);
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
_bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
} else if (element.type === "diamond") {
const [x11, y11] = pointRotateRads(
pointFrom(cx, y1),
@@ -202,7 +206,7 @@ export class ElementBounds {
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
_bounds = bounds(minX, minY, maxX, maxY, element.angle);
} else if (element.type === "ellipse") {
const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2;
@@ -210,7 +214,7 @@ export class ElementBounds {
const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
_bounds = bounds(cx - ww, cy - hh, cx + ww, cy + hh, element.angle);
} else {
const [x11, y11] = pointRotateRads(
pointFrom(x1, y1),
@@ -236,10 +240,10 @@ export class ElementBounds {
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
_bounds = bounds(minX, minY, maxX, maxY, element.angle);
}
return bounds;
return _bounds;
}
}
@@ -624,7 +628,7 @@ export const getCubicBezierCurveBound = (
minY = Math.min(minY, ...ys);
maxY = Math.max(maxY, ...ys);
}
return [minX, minY, maxX, maxY];
return bounds(minX, minY, maxX, maxY);
};
export const getMinMaxXYFromCurvePathOps = (
@@ -677,7 +681,7 @@ export const getMinMaxXYFromCurvePathOps = (
},
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
return [minX, minY, maxX, maxY];
return bounds(minX, minY, maxX, maxY);
};
export const getBoundsFromPoints = (
@@ -695,7 +699,7 @@ export const getBoundsFromPoints = (
maxY = Math.max(maxY, y);
}
return [minX, minY, maxX, maxY];
return bounds(minX, minY, maxX, maxY);
};
const getFreeDrawElementAbsoluteCoords = (
@@ -938,7 +942,7 @@ const getLinearElementRotatedBounds = (
cx: number,
cy: number,
elementsMap: ElementsMap,
): Bounds => {
): RotatedBounds => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (element.points.length < 2) {
@@ -949,20 +953,21 @@ const getLinearElementRotatedBounds = (
element.angle,
);
let coords: Bounds = [x, y, x, y];
let coords: RotatedBounds = bounds(x, y, x, y, element.angle);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element,
elementsMap,
[x, y, x, y],
bounds(x, y, x, y, element.angle),
boundTextElement,
);
coords = [
coords = bounds(
coordsWithBoundText[0],
coordsWithBoundText[1],
coordsWithBoundText[2],
coordsWithBoundText[3],
];
element.angle,
);
}
return coords;
}
@@ -978,7 +983,13 @@ const getLinearElementRotatedBounds = (
element.angle,
);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: Bounds = [res[0], res[1], res[2], res[3]];
let coords: RotatedBounds = bounds(
res[0],
res[1],
res[2],
res[3],
element.angle,
);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element,
@@ -986,22 +997,23 @@ const getLinearElementRotatedBounds = (
coords,
boundTextElement,
);
coords = [
coords = bounds(
coordsWithBoundText[0],
coordsWithBoundText[1],
coordsWithBoundText[2],
coordsWithBoundText[3],
];
element.angle,
);
}
return coords;
};
export const getElementBounds = (
export const getElementBounds = <T extends boolean = false>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
nonRotated: boolean = false,
): Bounds => {
return ElementBounds.getBounds(element, elementsMap, nonRotated);
nonRotated: T = false as T,
) => {
return ElementBounds.getBounds<T>(element, elementsMap, nonRotated);
};
export const getCommonBounds = (
@@ -1009,7 +1021,7 @@ export const getCommonBounds = (
elementsMap?: ElementsMap,
): Bounds => {
if (!sizeOf(elements)) {
return [0, 0, 0, 0];
return bounds(0, 0, 0, 0);
}
let minX = Infinity;
@@ -1027,7 +1039,7 @@ export const getCommonBounds = (
maxY = Math.max(maxY, y2);
});
return [minX, minY, maxX, maxY];
return bounds(minX, minY, maxX, maxY);
};
export const getDraggedElementsBounds = (
@@ -1050,12 +1062,12 @@ export const getResizedElementAbsoluteCoords = (
normalizePoints: boolean,
): Bounds => {
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
return [
return bounds(
element.x,
element.y,
element.x + nextWidth,
element.y + nextHeight,
];
);
}
const points = rescalePoints(
@@ -1065,11 +1077,11 @@ export const getResizedElementAbsoluteCoords = (
normalizePoints,
);
let bounds: Bounds;
let _bounds: Bounds;
if (isFreeDrawElement(element)) {
// Free Draw
bounds = getBoundsFromPoints(points);
_bounds = getBoundsFromPoints(points);
} else {
// Line
const gen = rough.generator();
@@ -1081,16 +1093,16 @@ export const getResizedElementAbsoluteCoords = (
: gen.curve(points as [number, number][], generateRoughOptions(element));
const ops = getCurvePathOps(curve);
bounds = getMinMaxXYFromCurvePathOps(ops);
_bounds = getMinMaxXYFromCurvePathOps(ops);
}
const [minX, minY, maxX, maxY] = bounds;
return [
const [minX, minY, maxX, maxY] = _bounds;
return bounds(
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
);
};
export const getElementPointsCoords = (
@@ -1108,20 +1120,20 @@ export const getElementPointsCoords = (
: gen.curve(points as [number, number][], generateRoughOptions(element));
const ops = getCurvePathOps(curve);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
return [
return bounds(
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
);
};
export const getClosestElementBounds = (
elements: readonly ExcalidrawElement[],
from: { x: number; y: number },
): Bounds => {
): RotatedBounds => {
if (!elements.length) {
return [0, 0, 0, 0];
return bounds(0, 0, 0, 0, 0 as Radians);
}
let minDistance = Infinity;
@@ -1190,7 +1202,9 @@ export const getVisibleSceneBounds = ({
];
};
export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
export const getCenterForBounds = (
bounds: Bounds | RotatedBounds,
): GlobalPoint =>
pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
@@ -1235,24 +1249,24 @@ export const aabbForElement = (
element.angle,
);
const bounds = [
const _bounds = bounds(
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds;
);
if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [
bounds[0] - leftOffset,
bounds[1] - topOffset,
bounds[2] + rightOffset,
bounds[3] + downOffset,
] as Bounds;
return bounds(
_bounds[0] - leftOffset,
_bounds[1] - topOffset,
_bounds[2] + rightOffset,
_bounds[3] + downOffset,
);
}
return bounds;
return _bounds;
};
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
@@ -1261,7 +1275,7 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
export const doBoundsIntersect = (
export const doNonRotatedBoundsIntersect = (
bounds1: Bounds | null,
bounds2: Bounds | null,
): boolean => {
@@ -1281,7 +1295,7 @@ export const elementCenterPoint = (
xOffset: number = 0,
yOffset: number = 0,
) => {
if (isLinearElement(element)) {
if (isLinearElement(element) || isFreeDrawElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
+137 -31
View File
@@ -1,4 +1,9 @@
import { invariant, isTransparent, type Bounds } from "@excalidraw/common";
import {
bounds,
invariant,
isTransparent,
type Bounds,
} from "@excalidraw/common";
import {
curveIntersectLineSegment,
isPointWithinBounds,
@@ -29,7 +34,7 @@ import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils";
import {
doBoundsIntersect,
doNonRotatedBoundsIntersect,
elementCenterPoint,
getCenterForBounds,
getCubicBezierCurveBound,
@@ -154,14 +159,11 @@ export const hitElementItself = ({
// Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointWithinBounds(
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
pointRotateRads(
point,
getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
const hitBounds = isPointInRotatedBounds(
point,
bounds,
element.angle,
threshold,
);
// PERF: Bail out early if the point is not even in the
@@ -192,18 +194,108 @@ export const hitElementItself = ({
return result;
};
export function getBoundsCorners(
bounds: Bounds,
): readonly [GlobalPoint, GlobalPoint, GlobalPoint, GlobalPoint];
export function getBoundsCorners(
bounds: Bounds,
angle: Radians,
): readonly [GlobalPoint, GlobalPoint, GlobalPoint, GlobalPoint];
export function getBoundsCorners(
bounds: Bounds,
angle: Radians = 0 as Radians,
) {
const [x1, y1, x2, y2] = bounds;
const center = getCenterForBounds(bounds);
const corners = [
pointFrom<GlobalPoint>(x1, y1),
pointFrom<GlobalPoint>(x2, y1),
pointFrom<GlobalPoint>(x2, y2),
pointFrom<GlobalPoint>(x1, y2),
] as const;
if (angle === 0) {
return corners;
}
return corners.map((point) => pointRotateRads(point, center, angle)) as [
GlobalPoint,
GlobalPoint,
GlobalPoint,
GlobalPoint,
];
}
export const getBoundsEdges = (
corners: readonly [GlobalPoint, GlobalPoint, GlobalPoint, GlobalPoint],
) =>
[
lineSegment(corners[0], corners[1]),
lineSegment(corners[1], corners[2]),
lineSegment(corners[2], corners[3]),
lineSegment(corners[3], corners[0]),
] as const;
const isPointInRotatedBounds = (
point: GlobalPoint,
bounds: Bounds,
angle: Radians,
tolerance = 0,
) => {
const adjustedPoint =
angle === 0
? point
: pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians);
return isPointWithinBounds(
pointFrom(bounds[0] - tolerance, bounds[1] - tolerance),
adjustedPoint,
pointFrom(bounds[2] + tolerance, bounds[3] + tolerance),
);
};
export const hitElementBoundingBox = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 0,
) => {
let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
x1 -= tolerance;
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
const bounds = getElementBounds(element, elementsMap, true);
return isPointInRotatedBounds(point, bounds, element.angle, tolerance);
};
export const doBoundsIntersectElementBoundingBox = (
intersectorBounds: Bounds,
element: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
const [x1, y1, x2, y2] = intersectorBounds;
const intersectorCorners = [
pointFrom<GlobalPoint>(x1, y1),
pointFrom<GlobalPoint>(x2, y1),
pointFrom<GlobalPoint>(x2, y2),
pointFrom<GlobalPoint>(x1, y2),
] as const;
const intersectorEdges = getBoundsEdges(intersectorCorners);
const elementBounds = getElementBounds(element, elementsMap, true);
const elementBoundsCorners = getBoundsCorners(elementBounds, element.angle);
const elementBoundsEdges = getBoundsEdges(elementBoundsCorners);
return (
elementBoundsCorners.some((point) =>
isPointWithinBounds(intersectorCorners[0], point, intersectorCorners[2]),
) ||
intersectorCorners.some((point) =>
isPointInRotatedBounds(point, elementBounds, element.angle),
) ||
intersectorEdges.some((selectionEdge) =>
elementBoundsEdges.some(
(elementBoundsEdge) =>
!!lineSegmentIntersectionPoints(selectionEdge, elementBoundsEdge),
),
)
);
};
export const hitElementBoundingBoxOnly = (
@@ -256,9 +348,13 @@ const bindingBorderTest = (
// PERF: Run a cheap test to see if the binding element
// is even close to the element
const t = Math.max(1, tolerance);
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) {
const elementBounds = getElementBounds(element, elementsMap, true);
if (
!doNonRotatedBoundsIntersect(
bounds(x - t, y - t, x + t, y + t),
elementBounds,
)
) {
return false;
}
@@ -269,6 +365,7 @@ const bindingBorderTest = (
const enclosingFrameBounds = getElementBounds(
enclosingFrame,
elementsMap,
true,
);
if (!pointInsideBounds(p, enclosingFrameBounds)) {
return false;
@@ -418,15 +515,15 @@ export const intersectElementWithLineSegment = (
): GlobalPoint[] => {
// First check if the line intersects the element's axis-aligned bounding box
// as it is much faster than checking intersection against the element's shape
const intersectorBounds = [
const intersectorBounds = bounds(
Math.min(line[0][0] - offset, line[1][0] - offset),
Math.min(line[0][1] - offset, line[1][1] - offset),
Math.max(line[0][0] + offset, line[1][0] + offset),
Math.max(line[0][1] + offset, line[1][1] + offset),
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
);
const elementBounds = getElementBounds(element, elementsMap, true);
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
if (!doNonRotatedBoundsIntersect(intersectorBounds, elementBounds)) {
return [];
}
@@ -465,7 +562,12 @@ export const intersectElementWithLineSegment = (
case "line":
case "freedraw":
case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
return intersectLinearOrFreeDrawWithLineSegment(
element,
line,
elementsMap,
onlyFirst,
);
}
};
@@ -480,14 +582,14 @@ const curveIntersections = (
for (const c of curves) {
// Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = [
const b2 = bounds(
Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]),
] as Bounds;
);
if (!doBoundsIntersect(b1, b2)) {
if (!doNonRotatedBoundsIntersect(b1, b2)) {
continue;
}
@@ -532,11 +634,15 @@ const lineIntersections = (
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>,
elementsMap: ElementsMap,
onlyFirst = false,
): GlobalPoint[] => {
// NOTE: This is the only one which return the decomposed elements
// rotated! This is due to taking advantage of roughjs definitions.
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
const intersections: GlobalPoint[] = [];
for (const l of lines) {
@@ -553,14 +659,14 @@ const intersectLinearOrFreeDrawWithLineSegment = (
for (const c of curves) {
// Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = [
const b2 = bounds(
Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]),
] as Bounds;
);
if (!doBoundsIntersect(b1, b2)) {
if (!doNonRotatedBoundsIntersect(b1, b2)) {
continue;
}
+6 -2
View File
@@ -48,7 +48,7 @@ export const distanceToElement = (
case "line":
case "arrow":
case "freedraw":
return distanceToLinearOrFreeDraElement(element, p);
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
}
};
@@ -133,9 +133,13 @@ const distanceToEllipseElement = (
const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)),
+2 -2
View File
@@ -99,7 +99,7 @@ export const isElementContainingFrame = (
element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
): boolean => {
return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id,
);
@@ -140,7 +140,7 @@ export const elementOverlapsWithFrame = (
element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
): boolean => {
return (
elementsAreInFrameBounds([element], frame, elementsMap) ||
isElementIntersectingFrame(element, frame, elementsMap) ||
+35 -17
View File
@@ -21,6 +21,7 @@ import {
invariant,
isShallowEqual,
getFeatureFlag,
bounds,
} from "@excalidraw/common";
import {
@@ -42,7 +43,7 @@ import type {
NullableGridSize,
Zoom,
} from "@excalidraw/excalidraw/types";
import type { Bounds } from "@excalidraw/common";
import type { RotatedBounds } from "@excalidraw/common";
import {
calculateFixedPointForNonElbowArrowBinding,
@@ -476,16 +477,22 @@ export class LinearElementEditor {
});
}
invariant(
lastClickedPoint > -1 &&
selectedPointsIndices.includes(lastClickedPoint) &&
element.points[lastClickedPoint],
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
selectedPointsIndices,
)}) points(0..${
element.points.length - 1
}) lastClickedPoint(${lastClickedPoint})`,
);
if (
lastClickedPoint < 0 ||
!selectedPointsIndices.includes(lastClickedPoint) ||
!element.points[lastClickedPoint]
) {
console.error(
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
selectedPointsIndices,
)}) points(0..${
element.points.length - 1
}) lastClickedPoint(${lastClickedPoint})`,
);
// Fall back to the actual last point as a last resort.
lastClickedPoint = element.points.length - 1;
}
// point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint];
@@ -794,6 +801,7 @@ export class LinearElementEditor {
element.points[index + 1],
index,
appState.zoom,
elementsMap,
)
) {
midpoints.push(null);
@@ -803,6 +811,7 @@ export class LinearElementEditor {
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
elementsMap,
);
midpoints.push(segmentMidPoint);
index++;
@@ -890,6 +899,7 @@ export class LinearElementEditor {
endPoint: P,
index: number,
zoom: Zoom,
elementsMap: ElementsMap,
) {
if (isElbowArrow(element)) {
if (index >= 0 && index < element.points.length) {
@@ -904,7 +914,10 @@ export class LinearElementEditor {
let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
invariant(
lines.length === 0 && curves.length > 0,
@@ -924,6 +937,7 @@ export class LinearElementEditor {
static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>,
index: number,
elementsMap: ElementsMap,
): GlobalPoint {
if (isElbowArrow(element)) {
invariant(
@@ -936,7 +950,10 @@ export class LinearElementEditor {
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
}
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
invariant(
(lines.length === 0 && curves.length > 0) ||
@@ -1851,6 +1868,7 @@ export class LinearElementEditor {
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
elementsMap,
);
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
@@ -1862,7 +1880,7 @@ export class LinearElementEditor {
static getMinMaxXYWithBoundText = (
element: ExcalidrawLinearElement,
elementsMap: ElementsMap,
elementBounds: Bounds,
elementBounds: RotatedBounds,
boundTextElement: ExcalidrawTextElementWithContainer,
): [number, number, number, number, number, number] => {
let [x1, y1, x2, y2] = elementBounds;
@@ -1987,7 +2005,7 @@ export class LinearElementEditor {
return LinearElementEditor.getMinMaxXYWithBoundText(
element,
elementsMap,
[x1, y1, x2, y2],
bounds(x1, y1, x2, y2, element.angle),
boundTextElement,
);
}
@@ -2400,7 +2418,7 @@ const pointDraggingUpdates = (
? nextArrow.points[0]
: endBindable
? updateBoundPoint(
element,
nextArrow,
"endBinding",
nextArrow.endBinding,
endBindable,
@@ -2431,7 +2449,7 @@ const pointDraggingUpdates = (
? endLocalPoint
: startBindable
? updateBoundPoint(
element,
nextArrow,
"startBinding",
nextArrow.startBinding,
startBindable,
+366 -36
View File
@@ -1,15 +1,35 @@
import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
import {
lineSegment,
pointFrom,
type GlobalPoint,
type LineSegment,
} from "@excalidraw/math";
import type {
AppState,
BoxSelectionMode,
InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import {
getElementAbsoluteCoords,
getElementBounds,
getElementLineSegments,
} from "./bounds";
import {
doBoundsIntersectElementBoundingBox,
getBoundsCorners,
getBoundsEdges,
intersectElementWithLineSegment,
isPointInElement,
shouldTestInside,
} from "./collision";
import { isElementInViewport } from "./sizeHelpers";
import {
isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
@@ -30,6 +50,205 @@ import type {
NonDeletedExcalidrawElement,
} from "./types";
// Broad-phase only for overlap mode. Rotated closed shapes should not select
// from the empty corners of their axis-aligned bounds. Linear elements and
// freedraw already rely on the outline-specific path below, so exclude them.
const shouldUseRotatedOverlapBroadPhase = (
element: NonDeletedExcalidrawElement,
) =>
element.angle !== 0 &&
!isLinearElement(element) &&
!isFreeDrawElement(element);
const clipLineSegmentToBounds = (
segment: LineSegment<GlobalPoint>,
bounds: Bounds,
): LineSegment<GlobalPoint> | null => {
const [minX, minY, maxX, maxY] = bounds;
const [[x1, y1], [x2, y2]] = segment;
const deltaX = x2 - x1;
const deltaY = y2 - y1;
let tMin = 0;
let tMax = 1;
const clip = (p: number, q: number) => {
if (p === 0) {
return q >= 0;
}
const ratio = q / p;
if (p < 0) {
if (ratio > tMax) {
return false;
}
tMin = Math.max(tMin, ratio);
return true;
}
if (ratio < tMin) {
return false;
}
tMax = Math.min(tMax, ratio);
return true;
};
if (
!clip(-deltaX, x1 - minX) ||
!clip(deltaX, maxX - x1) ||
!clip(-deltaY, y1 - minY) ||
!clip(deltaY, maxY - y1)
) {
return null;
}
return lineSegment(
pointFrom<GlobalPoint>(x1 + tMin * deltaX, y1 + tMin * deltaY),
pointFrom<GlobalPoint>(x1 + tMax * deltaX, y1 + tMax * deltaY),
);
};
const isPointWithinAabb = (point: GlobalPoint, bounds: Bounds) =>
point[0] >= bounds[0] &&
point[0] <= bounds[2] &&
point[1] >= bounds[1] &&
point[1] <= bounds[3];
const shouldUsePreciseFilledOverlap = (element: NonDeletedExcalidrawElement) =>
element.type === "ellipse" ||
element.type === "diamond" ||
(element.type === "rectangle" && !!element.roundness);
const shouldSkipElementFromSelection = (element: NonDeletedExcalidrawElement) =>
element.locked || element.type === "selection" || isBoundToContainer(element);
const getFrameBoundsForSelection = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
): Bounds | null => {
if (!element.frameId) {
return null;
}
const containingFrame = getContainingFrame(element, elementsMap);
return containingFrame
? (getElementBounds(containingFrame, elementsMap) as Bounds)
: null;
};
const finalizeElementsInSelection = (
elementsInSelection: NonDeletedExcalidrawElement[],
excludeElementsInFrames: boolean,
elementsMap: ElementsMap,
): NonDeletedExcalidrawElement[] => {
elementsInSelection = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
: elementsInSelection;
return elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
}
return true;
});
};
const getVisibleElementOutlineSegments = (
element: NonDeletedExcalidrawElement,
frameBounds: Bounds | null,
elementsMap: ElementsMap,
) =>
frameBounds
? getElementLineSegments(element, elementsMap).flatMap((segment) => {
const clippedSegment = clipLineSegmentToBounds(segment, frameBounds);
return clippedSegment ? [clippedSegment] : [];
})
: getElementLineSegments(element, elementsMap);
const doesSelectionIntersectElementOutline = (
element: NonDeletedExcalidrawElement,
frameBounds: Bounds | null,
selectionEdges: readonly LineSegment<GlobalPoint>[],
elementsMap: ElementsMap,
) =>
selectionEdges.some((selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
0,
true,
).some((point) => !frameBounds || isPointWithinAabb(point, frameBounds)),
);
const doesSelectionContainElementOutline = (
outlineSegments: readonly LineSegment<GlobalPoint>[],
selectionBounds: Bounds,
) =>
outlineSegments.length > 0 &&
outlineSegments.every(
(outlineSegment) =>
isPointWithinAabb(outlineSegment[0], selectionBounds) &&
isPointWithinAabb(outlineSegment[1], selectionBounds),
);
const doesSelectionContainElementInterior = (
element: NonDeletedExcalidrawElement,
frameBounds: Bounds | null,
selectionCorners: readonly GlobalPoint[],
elementsMap: ElementsMap,
) =>
selectionCorners.some(
(selectionCorner) =>
(!frameBounds || isPointWithinAabb(selectionCorner, frameBounds)) &&
isPointInElement(selectionCorner, element, elementsMap),
);
const doesSelectionOverlapFilledElement = (
element: NonDeletedExcalidrawElement,
frameBounds: Bounds | null,
selectionBounds: Bounds,
selectionCorners: readonly GlobalPoint[],
selectionEdges: readonly LineSegment<GlobalPoint>[],
elementsMap: ElementsMap,
) => {
if (
doesSelectionContainElementInterior(
element,
frameBounds,
selectionCorners,
elementsMap,
)
) {
return true;
}
if (
doesSelectionIntersectElementOutline(
element,
frameBounds,
selectionEdges,
elementsMap,
)
) {
return true;
}
const outlineSegments = getVisibleElementOutlineSegments(
element,
frameBounds,
elementsMap,
);
return (
outlineSegments.length > 0 &&
doesSelectionContainElementOutline(outlineSegments, selectionBounds)
);
};
/**
* Frames and their containing elements are not to be selected at the same time.
* Given an array of selected elements, if there are frames and their containing elements
@@ -62,55 +281,166 @@ export const getElementsWithinSelection = (
selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
excludeElementsInFrames: boolean = true,
) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] =
boxSelectionMode: BoxSelectionMode = "contain",
): NonDeletedExcalidrawElement[] => {
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
getElementAbsoluteCoords(selection, elementsMap);
const selectionX1 = Math.min(selectionStartX, selectionEndX);
const selectionY1 = Math.min(selectionStartY, selectionEndY);
const selectionX2 = Math.max(selectionStartX, selectionEndX);
const selectionY2 = Math.max(selectionStartY, selectionEndY);
const selectionBounds = [
selectionX1,
selectionY1,
selectionX2,
selectionY2,
] as Bounds;
let elementsInSelection = elements.filter((element) => {
let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
element,
elementsMap,
);
if (boxSelectionMode !== "overlap") {
const elementsInSelection: NonDeletedExcalidrawElement[] = [];
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
const [fx1, fy1, fx2, fy2] = getElementBounds(
containingFrame,
elementsMap,
);
for (const element of elements) {
if (shouldSkipElementFromSelection(element)) {
continue;
}
elementX1 = Math.max(fx1, elementX1);
elementY1 = Math.max(fy1, elementY1);
elementX2 = Math.min(fx2, elementX2);
elementY2 = Math.min(fy2, elementY2);
const elementBounds = getElementBounds(element, elementsMap) as Bounds;
const frameBounds = getFrameBoundsForSelection(element, elementsMap);
let elementX1 = elementBounds[0];
let elementY1 = elementBounds[1];
let elementX2 = elementBounds[2];
let elementY2 = elementBounds[3];
if (frameBounds) {
elementX1 = Math.max(frameBounds[0], elementX1);
elementY1 = Math.max(frameBounds[1], elementY1);
elementX2 = Math.min(frameBounds[2], elementX2);
elementY2 = Math.min(frameBounds[3], elementY2);
}
if (
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
) {
elementsInSelection.push(element);
}
}
return (
element.locked === false &&
element.type !== "selection" &&
!isBoundToContainer(element) &&
return finalizeElementsInSelection(
elementsInSelection,
excludeElementsInFrames,
elementsMap,
);
}
const selectionCorners = getBoundsCorners(selectionBounds);
const selectionEdges = getBoundsEdges(selectionCorners);
const elementsInSelection: NonDeletedExcalidrawElement[] = [];
for (const element of elements) {
if (shouldSkipElementFromSelection(element)) {
continue;
}
const elementBounds = getElementBounds(element, elementsMap) as Bounds;
const frameBounds = getFrameBoundsForSelection(element, elementsMap);
let elementX1 = elementBounds[0];
let elementY1 = elementBounds[1];
let elementX2 = elementBounds[2];
let elementY2 = elementBounds[3];
if (frameBounds) {
elementX1 = Math.max(frameBounds[0], elementX1);
elementY1 = Math.max(frameBounds[1], elementY1);
elementX2 = Math.min(frameBounds[2], elementX2);
elementY2 = Math.min(frameBounds[3], elementY2);
}
const isSelectionContainingElement =
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
);
});
selectionY2 >= elementY2;
elementsInSelection = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
: elementsInSelection;
const isSelectionOverlappingElementAabb =
selectionX1 <= elementX2 &&
selectionY1 <= elementY2 &&
selectionX2 >= elementX1 &&
selectionY2 >= elementY1;
const isSelectionOverlappingElement = shouldUseRotatedOverlapBroadPhase(
element,
)
? isSelectionOverlappingElementAabb &&
doBoundsIntersectElementBoundingBox(
selectionBounds,
element,
elementsMap,
)
: isSelectionOverlappingElementAabb;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
const shouldSelectFromInside = shouldTestInside(element);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
if (shouldSelectFromInside) {
if (
isSelectionOverlappingElement &&
(!shouldUsePreciseFilledOverlap(element) ||
isSelectionContainingElement ||
doesSelectionOverlapFilledElement(
element,
frameBounds,
selectionBounds,
selectionCorners,
selectionEdges,
elementsMap,
))
) {
elementsInSelection.push(element);
}
continue;
}
return true;
});
if (!isSelectionOverlappingElement) {
continue;
}
return elementsInSelection;
if (isSelectionContainingElement) {
elementsInSelection.push(element);
continue;
}
if (
doesSelectionIntersectElementOutline(
element,
frameBounds,
selectionEdges,
elementsMap,
)
) {
elementsInSelection.push(element);
continue;
}
const outlineSegments = getVisibleElementOutlineSegments(
element,
frameBounds,
elementsMap,
);
if (
outlineSegments.length > 0 &&
doesSelectionContainElementOutline(outlineSegments, selectionBounds)
) {
elementsInSelection.push(element);
}
}
return finalizeElementsInSelection(
elementsInSelection,
excludeElementsInFrames,
elementsMap,
);
};
export const getVisibleAndNonSelectedElements = (
+7 -16
View File
@@ -57,8 +57,8 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons";
import {
elementCenterPoint,
getArrowheadPoints,
getCenterForBounds,
getDiamondPoints,
getElementAbsoluteCoords,
} from "./bounds";
@@ -583,7 +583,11 @@ const getArrowheadShapes = (
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
) => {
elementsMap: ElementsMap,
): {
op: string;
data: number[];
}[] => {
const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
@@ -592,20 +596,7 @@ export const generateLinearCollisionShape = (
roughness: 0,
preserveVertices: true,
};
const center = getCenterForBounds(
// Need a non-rotated center point
element.points.reduce(
(acc, point) => {
return [
Math.min(element.x + point[0], acc[0]),
Math.min(element.y + point[1], acc[1]),
Math.max(element.x + point[0], acc[2]),
Math.max(element.y + point[1], acc[3]),
];
},
[Infinity, Infinity, -Infinity, -Infinity],
),
);
const center = elementCenterPoint(element, elementsMap);
switch (element.type) {
case "line":
+1
View File
@@ -347,6 +347,7 @@ export const getContainerCenter = (
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container,
index + 1,
elementsMap,
);
}
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
+2 -4
View File
@@ -124,6 +124,7 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
*/
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0);
@@ -131,10 +132,7 @@ export function deconstructLinearOrFreeDrawElement(
return cachedShape;
}
const ops = generateLinearCollisionShape(element) as {
op: string;
data: number[];
}[];
const ops = generateLinearCollisionShape(element, elementsMap);
const lines = [];
const curves = [];
+1 -1
View File
@@ -29,7 +29,7 @@ describe("check rotated elements can be hit:", () => {
] as LocalPoint[],
});
const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68),
point: pointFrom<GlobalPoint>(87, -67),
element: window.h.elements[0],
threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(),
@@ -0,0 +1,145 @@
import {
getElementsInGroup,
isSomeElementSelected,
makeNextSelectedElementIds,
selectGroupsForSelectedElements,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { KEYS, isWritableElement, updateActiveTool } from "@excalidraw/common";
import type { GroupId } from "@excalidraw/element/types";
import { register } from "./register";
import type { AppClassProperties, AppState } from "../types";
const getNextActiveTool = (
appState: Readonly<AppState>,
app: AppClassProperties,
) => {
if (appState.activeTool.type === "eraser") {
return updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: app.state.preferredSelectionTool.type,
}),
lastActiveToolBeforeEraser: null,
});
}
return updateActiveTool(appState, {
type: app.state.preferredSelectionTool.type,
});
};
const getParentEditingGroupId = (
appState: Readonly<AppState>,
app: AppClassProperties,
selectedElementIds: AppState["selectedElementIds"],
): GroupId | null => {
if (!appState.editingGroupId) {
return null;
}
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElements = app.scene.getSelectedElements({
selectedElementIds,
elements: nonDeletedElements,
});
const candidateElements = selectedElements.length
? selectedElements
: getElementsInGroup(nonDeletedElements, appState.editingGroupId);
for (const element of candidateElements) {
const editingGroupIndex = element.groupIds.indexOf(appState.editingGroupId);
if (editingGroupIndex !== -1 && element.groupIds[editingGroupIndex + 1]) {
return element.groupIds[editingGroupIndex + 1] as GroupId;
}
}
return null;
};
export const actionDeselect = register({
name: "deselect",
label: "",
trackEvent: false,
perform: (_elements, appState, _, app) => {
const activeTool = getNextActiveTool(appState, app);
if (appState.editingGroupId) {
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElementIds =
Object.keys(appState.selectedElementIds).length > 0
? appState.selectedElementIds
: getElementsInGroup(
nonDeletedElements,
appState.editingGroupId,
).reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<string, true>);
return {
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: getParentEditingGroupId(
appState,
app,
selectedElementIds,
),
selectedElementIds,
},
nonDeletedElements,
appState,
app,
),
activeEmbeddable: null,
activeTool,
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
return {
appState: {
...appState,
activeEmbeddable: null,
activeTool,
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds({}, appState),
selectedGroupIds: {},
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event, appState, _, app) => {
if (event.key !== KEYS.ESCAPE) {
return false;
}
if (isWritableElement(event.target)) {
return false;
}
return (
!appState.newElement &&
appState.multiElement === null &&
!appState.selectedLinearElement?.isEditing &&
(appState.activeEmbeddable !== null ||
appState.activeTool.type !== app.state.preferredSelectionTool.type ||
!!appState.editingGroupId ||
!!appState.selectedLinearElement ||
isSomeElementSelected(app.scene.getNonDeletedElements(), appState))
);
},
});
@@ -348,9 +348,7 @@ export const actionFinalize = register<FormData>({
};
},
keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE &&
(appState.selectedLinearElement?.isEditing ||
(!appState.newElement && appState.multiElement === null))) ||
(event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => (
+1
View File
@@ -34,6 +34,7 @@ export {
export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable";
export { actionFinalize } from "./actionFinalize";
export { actionDeselect } from "./actionDeselect";
export {
actionChangeProjectName,
+1
View File
@@ -114,6 +114,7 @@ export type ActionName =
| "distributeVertically"
| "flipHorizontal"
| "flipVertical"
| "deselect"
| "viewMode"
| "exportWithDarkMode"
| "toggleTheme"
+1
View File
@@ -193,6 +193,7 @@ const APP_STATE_STORAGE_CONF = (<
gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: true, export: false, server: false },
boxSelectionMode: { browser: true, export: false, server: false },
bindingPreference: { browser: true, export: false, server: false },
isMidpointSnappingEnabled: { browser: true, export: false, server: false },
defaultSidebarDockedPreference: {
+52 -37
View File
@@ -27,6 +27,7 @@ import {
KEYS,
APP_NAME,
CURSOR_TYPE,
DEFAULT_TRANSFORM_HANDLE_SPACING,
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD,
@@ -245,7 +246,7 @@ import {
bindOrUnbindBindingElement,
mutateElement,
getElementBounds,
doBoundsIntersect,
doNonRotatedBoundsIntersect,
isPointInElement,
maxBindingDistance_simple,
convertToExcalidrawElements,
@@ -1153,7 +1154,7 @@ class App extends React.Component<AppProps, AppState> {
startBounds &&
endBounds &&
startElement.id !== endElement.id &&
doBoundsIntersect(startBounds, endBounds)
doNonRotatedBoundsIntersect(startBounds, endBounds)
);
}
@@ -5725,13 +5726,13 @@ class App extends React.Component<AppProps, AppState> {
const isDeleted = !nextOriginalText.trim();
updateElement(nextOriginalText, isDeleted);
// select the created text element only if submitting via keyboard
// (when submitting via click it should act as signal to deselect)
if (!isDeleted && viaKeyboard) {
const elementIdToSelect = element.containerId
? element.containerId
: element.id;
// keyboard-submit keeps focus on the edited object. For bound text, keep
// the container selected even if the text becomes empty and is deleted.
const elementIdToSelect = viaKeyboard
? element.containerId || (!isDeleted ? element.id : null)
: null;
if (elementIdToSelect) {
// needed to ensure state is updated before "finalize" action
// that's invoked on keyboard-submit as well
// TODO either move this into finalize as well, or handle all state
@@ -6995,27 +6996,23 @@ class App extends React.Component<AppProps, AppState> {
},
{ informMutation: false, isDragging: false },
);
const newLastIdx = multiElement.points.length - 1;
this.setState({
selectedLinearElement: {
...selectedLinearElement,
selectedPointsIndices:
selectedLinearElement.selectedPointsIndices?.includes(
multiElement.points.length,
)
? [
...selectedLinearElement.selectedPointsIndices.filter(
(idx) =>
idx !== multiElement.points.length &&
idx !== multiElement.points.length - 1,
selectedPointsIndices: selectedLinearElement.selectedPointsIndices
? [
...new Set(
selectedLinearElement.selectedPointsIndices.map((idx) =>
Math.min(idx, newLastIdx),
),
multiElement.points.length - 1,
]
: selectedLinearElement.selectedPointsIndices,
lastCommittedPoint:
multiElement.points[multiElement.points.length - 1],
),
]
: selectedLinearElement.selectedPointsIndices,
lastCommittedPoint: multiElement.points[newLastIdx],
initialState: {
...selectedLinearElement.initialState,
lastClickedPoint: multiElement.points.length - 1,
lastClickedPoint: newLastIdx,
},
},
});
@@ -7243,6 +7240,14 @@ class App extends React.Component<AppProps, AppState> {
this.interactiveCanvas,
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
);
} else if (
!event[KEYS.CTRL_OR_CMD] &&
this.isHittingCommonBoundingBoxOfSelectedElements(
scenePointer,
selectedElements,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} else if (this.state.viewModeEnabled) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (this.state.openDialog?.name === "elementLinkSelector") {
@@ -7734,17 +7739,24 @@ class App extends React.Component<AppProps, AppState> {
const hitSelectedElement =
pointerDownState.hit.element &&
this.isASelectedElement(pointerDownState.hit.element);
const shouldForceLassoReselect =
event.altKey &&
event[KEYS.CTRL_OR_CMD] &&
!pointerDownState.resize.handleType;
const shouldStartLassoSelection =
shouldForceLassoReselect ||
(!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType &&
!hitSelectedElement);
if (
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType &&
!hitSelectedElement
) {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
if (shouldStartLassoSelection) {
if (!this.lassoTrail.hasCurrentTrail) {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
}
// block dragging after lasso selection on PCs until the next pointer down
// (on mobile or tablet, we want to allow user to drag immediately)
@@ -8733,12 +8745,14 @@ class App extends React.Component<AppProps, AppState> {
DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value,
1,
);
const boundsPadding =
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / this.state.zoom.value;
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
return (
point.x > x1 - threshold &&
point.x < x2 + threshold &&
point.y > y1 - threshold &&
point.y < y2 + threshold
point.x > x1 - boundsPadding - threshold &&
point.x < x2 + boundsPadding + threshold &&
point.y > y1 - boundsPadding - threshold &&
point.y < y2 + boundsPadding + threshold
);
}
@@ -10271,6 +10285,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectionElement,
this.scene.getNonDeletedElementsMap(),
false,
this.state.boxSelectionMode,
)
: [];
@@ -46,6 +46,7 @@ import {
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import type { JSX } from "react";
import type { ExcalidrawFontFace } from "../../fonts/ExcalidrawFontFace";
export interface FontDescriptor {
value: number;
@@ -86,6 +87,15 @@ const getFontFamilyIcon = (fontFamily: FontFamilyValues): JSX.Element => {
}
};
const getFontFamilyLabel = (
fontFamily: FontFamilyValues,
fontFaces: ExcalidrawFontFace[],
) =>
// prefer our config as the browser resolved names may be wrapped in quotes and such
Object.entries(FONT_FAMILY).find(([, id]) => id === fontFamily)?.[0] ??
fontFaces[0]?.fontFace?.family ??
"Unknown";
export const FontPickerList = React.memo(
({
selectedFontFamily,
@@ -114,7 +124,7 @@ export const FontPickerList = React.memo(
const fontDescriptor = {
value: familyId,
icon: getFontFamilyIcon(familyId),
text: fontFaces[0]?.fontFace?.family ?? "Unknown",
text: getFontFamilyLabel(familyId, fontFaces),
};
if (metadata.deprecated) {
@@ -26,13 +26,16 @@
background: var(--RadioGroup-background);
border: 1px solid var(--RadioGroup-border);
gap: 2px;
&__choice {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
min-width: 20px;
height: 24px;
padding: 0 0.375rem;
color: var(--RadioGroup-choice-color-off);
background: var(--RadioGroup-choice-background-off);
@@ -2,7 +2,7 @@
.excalidraw {
.dropdown-menu {
max-width: 16rem;
max-width: 20rem;
z-index: 1;
&--placement-top {
@@ -1,4 +1,5 @@
import { useEditorInterface } from "../App";
import { Ellipsify } from "../Ellipsify";
import { RadioGroup } from "../RadioGroup";
type Props<T> = {
@@ -12,6 +13,7 @@ type Props<T> = {
onChange: (value: T) => void;
children: React.ReactNode;
name: string;
icon?: React.ReactNode;
};
const DropdownMenuItemContentRadio = <T,>({
@@ -21,13 +23,17 @@ const DropdownMenuItemContentRadio = <T,>({
choices,
children,
name,
icon,
}: Props<T>) => {
const editorInterface = useEditorInterface();
return (
<>
<div className="dropdown-menu-item-base dropdown-menu-item-bare">
<label className="dropdown-menu-item__text">{children}</label>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<label className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify>
</label>
<RadioGroup
name={name}
value={value}
@@ -39,7 +39,13 @@ import DropdownMenuItemCheckbox from "../dropdownMenu/DropdownMenuItemCheckbox";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons";
import {
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
emptyIcon,
} from "../icons";
import {
boltIcon,
DeviceDesktopIcon,
@@ -427,6 +433,40 @@ const PreferencesToggleToolLockItem = () => {
);
};
const PreferencesBoxSelectionModeItem = () => {
const { t } = useI18n();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const boxSelectionMode = appState.boxSelectionMode ?? "contain";
return (
<DropdownMenuItemContentRadio<"contain" | "overlap">
name="boxSelectionMode"
icon={emptyIcon}
value={boxSelectionMode}
onChange={(value) => {
setAppState({
boxSelectionMode: value === "contain" ? undefined : value,
});
}}
choices={[
{
value: "contain",
label: t("labels.boxSelectionContain"),
ariaLabel: t("labels.boxSelectionContain"),
},
{
value: "overlap",
label: t("labels.boxSelectionOverlap"),
ariaLabel: t("labels.boxSelectionOverlap"),
},
]}
>
{t("labels.boxSelectionMode")}
</DropdownMenuItemContentRadio>
);
};
const PreferencesToggleSnapModeItem = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
@@ -568,6 +608,7 @@ export const Preferences = ({
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
{children || (
<>
<PreferencesBoxSelectionModeItem />
<PreferencesToggleToolLockItem />
<PreferencesToggleSnapModeItem />
<PreferencesToggleGridModeItem />
@@ -585,6 +626,7 @@ export const Preferences = ({
};
Preferences.ToggleToolLock = PreferencesToggleToolLockItem;
Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem;
Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem;
Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem;
Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem;
+9 -1
View File
@@ -251,7 +251,9 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
};
}
console.error(`could not repair binding for element`);
console.error(
`Could not repair binding for element "${boundElement?.id}" out of (${elementsMap?.size}) elements`,
);
} catch (error) {
console.error("Error repairing binding:", error);
}
@@ -934,6 +936,12 @@ export const restoreAppState = (
: defaultValue;
}
const boxSelectionMode =
appState.boxSelectionMode ?? localAppState?.boxSelectionMode;
if (boxSelectionMode !== undefined) {
nextAppState.boxSelectionMode = boxSelectionMode;
}
return {
...nextAppState,
cursorButton: localAppState?.cursorButton || "up",
+2 -2
View File
@@ -2,7 +2,7 @@ import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import {
computeBoundTextPosition,
doBoundsIntersect,
doNonRotatedBoundsIntersect,
getBoundTextElement,
getElementBounds,
getElementLineSegments,
@@ -219,7 +219,7 @@ const eraserTest = (
origElementBounds[3] + threshold,
];
if (!doBoundsIntersect(segmentBounds, elementBounds)) {
if (!doNonRotatedBoundsIntersect(segmentBounds, elementBounds)) {
return false;
}
+2
View File
@@ -382,6 +382,8 @@ export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToC
export { getDataURL } from "./data/blob";
export { isElementLink } from "@excalidraw/element";
export { Fonts } from "./fonts/Fonts";
export { setCustomTextMetricsProvider } from "@excalidraw/element";
export { CommandPalette } from "./components/CommandPalette/CommandPalette";
+2 -2
View File
@@ -10,7 +10,7 @@ import { type Bounds } from "@excalidraw/common";
import {
computeBoundTextPosition,
doBoundsIntersect,
doNonRotatedBoundsIntersect,
getBoundTextElement,
getElementBounds,
intersectElementWithLineSegment,
@@ -66,7 +66,7 @@ export const getLassoSelectedElementIds = (input: {
const elementBounds = getElementBounds(element, elementsMap);
if (
doBoundsIntersect(lassoBounds, elementBounds) &&
doNonRotatedBoundsIntersect(lassoBounds, elementBounds) &&
!intersectedElements.has(element.id) &&
!enclosedElements.has(element.id)
) {
+4 -1
View File
@@ -185,6 +185,9 @@
"shapeSwitch": "Switch shape",
"preferences": "Preferences",
"preferences_toolLock": "Tool lock",
"boxSelectionMode": "Select on",
"boxSelectionContain": "Wrap",
"boxSelectionOverlap": "Overlap",
"arrowBinding": "Arrow binding",
"midpointSnapping": "Snap to midpoints"
},
@@ -661,7 +664,7 @@
"placeholder": {
"title": "Let's design your diagram",
"description": "Describe the diagram you want to create, and we'll generate it for you.",
"hint": "At the moment we know Flowchart, Sequence, Class, and Entity Relationship diagrams."
"hint": "At the moment we know Flowchart, Sequence, Class, State, and Entity Relationship diagrams."
},
"preview": "Preview",
"insert": "Insert",
+1 -1
View File
@@ -88,7 +88,7 @@
"@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "2.1.1",
"@excalidraw/mermaid-to-excalidraw": "2.2.2",
"@excalidraw/random-username": "1.1.0",
"browser-fs-access": "0.38.0",
"canvas-roundrect-polyfill": "0.0.1",
@@ -1156,6 +1156,7 @@ const renderLinearPointHandles = (
points[idx],
idx,
appState.zoom,
elementsMap,
)
) {
renderSingleLinearPoint(
@@ -4358,7 +4358,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"type": "rectangle",
"updated": 1,
"version": 5,
"versionNonce": 760410951,
"versionNonce": 1006504105,
"width": 20,
"x": -10,
"y": 0,
@@ -4383,14 +4383,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 238820263,
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 5,
"versionNonce": 1006504105,
"versionNonce": 289600103,
"width": 20,
"x": 20,
"y": 30,
@@ -6864,7 +6864,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 747212839,
"versionNonce": 1723083209,
"width": 10,
"x": -10,
"y": 0,
@@ -6891,14 +6891,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 238820263,
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1723083209,
"versionNonce": 760410951,
"width": 10,
"x": 12,
"y": 0,
@@ -224,7 +224,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 28,
"version": 22,
"width": "94.00000",
"x": 0,
"y": 0,
@@ -350,8 +350,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
],
"mode": "orbit",
},
"version": 27,
"version": 21,
"width": "88.00000",
"y": "7.20923",
},
"inserted": {
"endBinding": {
@@ -381,8 +382,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
],
"mode": "orbit",
},
"version": 24,
"version": 20,
"width": "88.00000",
"y": "0.01000",
},
},
},
@@ -437,7 +439,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
],
],
"startBinding": null,
"version": 28,
"version": 22,
"width": "94.00000",
"x": 0,
"y": 0,
@@ -462,7 +464,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
],
"mode": "orbit",
},
"version": 27,
"version": 21,
"width": "88.00000",
"x": 6,
"y": "7.20923",
@@ -11220,6 +11222,489 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end of test] undo stack 1`] = `[]`;
exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] appState 1`] = `
{
"activeEmbeddable": null,
"activeLockedId": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
"exportEmbedScene": false,
"exportScale": 1,
"exportWithDarkMode": false,
"fileHandle": null,
"followedBy": Set {},
"frameRendering": {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
"isMidpointSnappingEnabled": true,
"isResizing": false,
"isRotating": false,
"lastPointerDownWith": "mouse",
"lockedMultiSelections": {},
"multiElement": null,
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 0,
"offsetTop": 0,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"penDetected": false,
"penMode": false,
"preferredSelectionTool": {
"initialized": true,
"type": "selection",
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBinding": null,
"theme": "light",
"toast": null,
"userToFollow": null,
"viewBackgroundColor": "#ffffff",
"viewModeEnabled": false,
"width": 0,
"zenModeEnabled": false,
"zoom": {
"value": 1,
},
}
`;
exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] element 0 1`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [
"inner",
"outer",
],
"height": 100,
"id": "id0",
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 2,
"width": 100,
"x": 0,
"y": 0,
}
`;
exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] element 1 1`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [
"outer",
],
"height": 100,
"id": "id1",
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 2,
"width": 100,
"x": 100,
"y": 100,
}
`;
exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] element 2 1`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [
"inner",
"outer",
],
"height": 100,
"id": "id2",
"index": "a2",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 2,
"width": 100,
"x": 200,
"y": 200,
}
`;
exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] number of elements 1`] = `3`;
exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] number of renders 1`] = `16`;
exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] redo stack 1`] = `[]`;
exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] undo stack 1`] = `
[
{
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"selectedElementIds": {
"id0": true,
"id1": true,
"id2": true,
},
"selectedGroupIds": {
"outer": true,
},
},
"inserted": {
"selectedElementIds": {},
"selectedGroupIds": {},
},
},
},
"elements": {
"added": {},
"removed": {
"id0": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [
"inner",
"outer",
],
"height": 100,
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 2,
"width": 100,
"x": 0,
"y": 0,
},
"inserted": {
"isDeleted": true,
"version": 1,
},
},
"id1": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [
"outer",
],
"height": 100,
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 2,
"width": 100,
"x": 100,
"y": 100,
},
"inserted": {
"isDeleted": true,
"version": 1,
},
},
"id2": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [
"inner",
"outer",
],
"height": 100,
"index": "a2",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 2,
"width": 100,
"x": 200,
"y": 200,
},
"inserted": {
"isDeleted": true,
"version": 1,
},
},
},
"updated": {},
},
"id": "id5",
},
{
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"editingGroupId": "outer",
"selectedElementIds": {},
"selectedGroupIds": {
"inner": true,
},
},
"inserted": {
"editingGroupId": null,
"selectedElementIds": {
"id1": true,
},
"selectedGroupIds": {
"outer": true,
},
},
},
},
"elements": {
"added": {},
"removed": {},
"updated": {},
},
"id": "id7",
},
{
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"editingGroupId": "inner",
"selectedElementIds": {},
"selectedGroupIds": {},
},
"inserted": {
"editingGroupId": "outer",
"selectedElementIds": {
"id2": true,
},
"selectedGroupIds": {
"inner": true,
},
},
},
},
"elements": {
"added": {},
"removed": {},
"updated": {},
},
"id": "id9",
},
{
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"editingGroupId": "outer",
"selectedElementIds": {
"id2": true,
},
"selectedGroupIds": {
"inner": true,
},
},
"inserted": {
"editingGroupId": "inner",
"selectedElementIds": {},
"selectedGroupIds": {},
},
},
},
"elements": {
"added": {},
"removed": {},
"updated": {},
},
"id": "id19",
},
{
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"editingGroupId": null,
"selectedElementIds": {
"id1": true,
},
"selectedGroupIds": {
"outer": true,
},
},
"inserted": {
"editingGroupId": "outer",
"selectedElementIds": {},
"selectedGroupIds": {
"inner": true,
},
},
},
},
"elements": {
"added": {},
"removed": {},
"updated": {},
},
"id": "id20",
},
{
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"selectedElementIds": {},
"selectedGroupIds": {},
},
"inserted": {
"selectedElementIds": {
"id0": true,
"id1": true,
"id2": true,
},
"selectedGroupIds": {
"outer": true,
},
},
},
},
"elements": {
"added": {},
"removed": {},
"updated": {},
},
"id": "id21",
},
]
`;
exports[`history > multiplayer undo/redo > should update history entries after remote changes on the same properties > [end of test] appState 1`] = `
{
"activeEmbeddable": null,
@@ -16254,7 +16739,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 11,
"version": 10,
"width": "88.00000",
"x": 6,
"y": "0.01000",
@@ -16307,7 +16792,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
"mode": "orbit",
},
"version": 11,
"version": 10,
},
"inserted": {
"endBinding": {
@@ -16327,7 +16812,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
"mode": "orbit",
},
"version": 9,
"version": 8,
},
},
},
@@ -16636,7 +17121,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "0.00120",
"height": 0,
"index": "a3",
"isDeleted": false,
"link": null,
@@ -16649,7 +17134,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
[
"88.00000",
"0.00120",
0,
],
],
"roughness": 1,
@@ -16669,14 +17154,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"version": 8,
"version": 7,
"width": "88.00000",
"x": 6,
"y": "0.00880",
"y": "0.01000",
},
"inserted": {
"isDeleted": true,
"version": 7,
"version": 6,
},
},
},
@@ -17002,7 +17487,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 11,
"version": 10,
"width": "88.00000",
"x": 6,
"y": "0.01000",
@@ -17307,14 +17792,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"version": 11,
"version": 10,
"width": "88.00000",
"x": 6,
"y": "0.01000",
},
"inserted": {
"isDeleted": true,
"version": 9,
"version": 8,
},
},
},
@@ -17648,7 +18133,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 11,
"version": 10,
"width": "88.00000",
"x": 6,
"y": "0.01000",
@@ -17953,14 +18438,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"version": 11,
"version": 10,
"width": "88.00000",
"x": 6,
"y": "0.01000",
},
"inserted": {
"isDeleted": true,
"version": 9,
"version": 8,
},
},
},
@@ -18292,7 +18777,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 11,
"version": 10,
"width": "88.00000",
"x": 6,
"y": "0.01000",
@@ -18361,7 +18846,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
"mode": "orbit",
},
"version": 11,
"version": 10,
},
"inserted": {
"endBinding": {
@@ -18373,7 +18858,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"mode": "orbit",
},
"startBinding": null,
"version": 9,
"version": 8,
},
},
"id2": {
@@ -18650,7 +19135,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "0.00120",
"height": 0,
"index": "a3",
"isDeleted": false,
"link": null,
@@ -18663,7 +19148,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
[
"88.00000",
"0.00120",
0,
],
],
"roughness": 1,
@@ -18683,14 +19168,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"version": 8,
"version": 7,
"width": "88.00000",
"x": 6,
"y": "0.00880",
"y": "0.01000",
},
"inserted": {
"isDeleted": true,
"version": 7,
"version": 6,
},
},
},
@@ -19044,7 +19529,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 12,
"version": 11,
"width": "88.00000",
"x": 6,
"y": "0.01000",
@@ -19124,12 +19609,12 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
"mode": "orbit",
},
"version": 12,
"version": 11,
},
"inserted": {
"endBinding": null,
"startBinding": null,
"version": 10,
"version": 9,
},
},
},
@@ -19398,7 +19883,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "0.00120",
"height": 0,
"index": "a3",
"isDeleted": false,
"link": null,
@@ -19411,7 +19896,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
[
"88.00000",
"0.00120",
0,
],
],
"roughness": 1,
@@ -19431,14 +19916,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"version": 8,
"version": 7,
"width": "88.00000",
"x": 6,
"y": "0.00880",
"y": "0.01000",
},
"inserted": {
"isDeleted": true,
"version": 7,
"version": 6,
},
},
},
@@ -2971,6 +2971,82 @@ describe("history", () => {
expect(h.state.editingGroupId).toBeNull();
});
// TODO mark with "noncritical" tag once we migrate to vitest 4
it.skip("should support undo and redo when escape unwinds nested group editing", async () => {
const rectA = API.createElement({
type: "rectangle",
groupIds: ["inner", "outer"],
x: 0,
});
const rectB = API.createElement({
type: "rectangle",
groupIds: ["outer"],
x: 100,
});
const rectC = API.createElement({
type: "rectangle",
groupIds: ["inner", "outer"],
x: 200,
});
API.setElements([rectA, rectB, rectC]);
mouse.select(rectA);
mouse.doubleClickOn(rectA);
mouse.doubleClickOn(rectA);
assertSelectedElements([rectA]);
expect(h.state.editingGroupId).toBe("inner");
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
Keyboard.keyPress(KEYS.ESCAPE);
assertSelectedElements([rectA, rectC]);
expect(h.state.editingGroupId).toBe("outer");
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0);
Keyboard.keyPress(KEYS.ESCAPE);
assertSelectedElements([rectA, rectB, rectC]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ outer: true });
expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(0);
Keyboard.keyPress(KEYS.ESCAPE);
expect(API.getSelectedElements()).toEqual([]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({});
expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(0);
Keyboard.undo();
assertSelectedElements([rectA, rectB, rectC]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ outer: true });
Keyboard.undo();
assertSelectedElements([rectA, rectC]);
expect(h.state.editingGroupId).toBe("outer");
Keyboard.undo();
assertSelectedElements([rectA]);
expect(h.state.editingGroupId).toBe("inner");
Keyboard.redo();
assertSelectedElements([rectA, rectC]);
expect(h.state.editingGroupId).toBe("outer");
Keyboard.redo();
assertSelectedElements([rectA, rectB, rectC]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ outer: true });
Keyboard.redo();
expect(API.getSelectedElements()).toEqual([]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({});
});
it("should iterate through the history when selected or editing linear element was remotely deleted", async () => {
// create three point arrow
UI.clickTool("arrow");
+568 -6
View File
@@ -1,7 +1,9 @@
import React from "react";
import { vi } from "vitest";
import { KEYS, reseed } from "@excalidraw/common";
import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common";
import { getElementBounds, getElementLineSegments } from "@excalidraw/element";
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
import { SHAPES } from "../components/shapes";
@@ -12,6 +14,7 @@ import * as StaticScene from "../renderer/staticScene";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import {
act,
render,
fireEvent,
mockBoundingClientRect,
@@ -39,6 +42,19 @@ const { h } = window;
const mouse = new Pointer("mouse");
const getOutlineBounds = (element: ReturnType<typeof API.createElement>) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const points = getElementLineSegments(sceneElement, elementsMap).flat();
return [
Math.min(...points.map((point) => point[0])),
Math.min(...points.map((point) => point[1])),
Math.max(...points.map((point) => point[0])),
Math.max(...points.map((point) => point[1])),
] as const;
};
describe("box-selection", () => {
beforeEach(async () => {
await render(<Excalidraw />);
@@ -108,6 +124,497 @@ describe("box-selection", () => {
assertSelectedElements([]);
});
it("should not select an element when the selection box only partially overlaps it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect1]);
mouse.downAt(25, -20);
mouse.move(-1000, -1000);
mouse.moveTo(75, 70);
mouse.up();
assertSelectedElements([]);
});
});
describe("lasso reselection", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("should allow ctrl+alt lasso reselection when starting inside the active common bounds", () => {
const rectA = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
const rectB = API.createElement({
type: "rectangle",
x: 220,
y: 0,
width: 100,
height: 100,
backgroundColor: "blue",
fillStyle: "solid",
});
API.setElements([rectA, rectB]);
mouse.select([rectA, rectB]);
act(() => {
h.app.setActiveTool({ type: "lasso" });
});
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
mouse.downAt(110, 50);
mouse.moveTo(50, -20);
expect(h.app.lassoTrail.hasCurrentTrail).toBe(true);
mouse.moveTo(-20, 50);
mouse.moveTo(50, 120);
mouse.moveTo(110, 50);
mouse.up();
});
assertSelectedElements([rectA.id]);
});
});
describe("box-selection overlap mode", () => {
const boxSelect = (
startX: number,
startY: number,
endX: number,
endY: number,
) => {
mouse.downAt(startX, startY);
mouse.move(-1000, -1000);
mouse.moveTo(endX, endY);
mouse.up();
};
const boxSelectTopLeftAabbCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1] = getElementBounds(sceneElement, elementsMap);
boxSelect(x1 + 2, y1 + 2, x1 + 12, y1 + 12);
};
const boxSelectTopRightAabbCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [, y1, x2] = getElementBounds(sceneElement, elementsMap);
boxSelect(x2 - 12, y1 + 2, x2 - 2, y1 + 12);
};
const boxSelectTopLeftRotatedLocalBoundsCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2] = getElementBounds(sceneElement, elementsMap, true);
const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2);
const [cornerX, cornerY] = pointRotateRads(
pointFrom(x1, y1),
center,
sceneElement.angle,
);
boxSelect(cornerX - 4, cornerY - 4, cornerX + 4, cornerY + 4);
};
beforeEach(async () => {
await render(
<Excalidraw
initialData={{ appState: { boxSelectionMode: "overlap" } }}
/>,
);
});
it("should select an element when the selection box partially overlaps it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(25, -20, 75, 70);
assertSelectedElements([rect1.id]);
});
it("should not select a transparent rectangle when the selection box stays inside it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(25, 25, 75, 75);
assertSelectedElements([]);
});
it("should select a transparent rectangle when the selection box crosses its outline", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(25, 25, 125, 75);
assertSelectedElements([rect1.id]);
});
it("should not select a rotated transparent rectangle when the selection box stays inside it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(40, 40, 60, 60);
assertSelectedElements([]);
});
it("should select a rotated rounded rectangle when the selection box contains its outline but not its bounds", () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 180,
angle: Math.PI / 6,
backgroundColor: "transparent",
fillStyle: "solid",
roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS },
roughness: 0,
});
API.setElements([rect]);
const sceneRect = API.getElement(rect);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [boundsX1, boundsY1, boundsX2, boundsY2] = getElementBounds(
sceneRect,
elementsMap,
);
const [outlineX1, outlineY1, outlineX2, outlineY2] = getOutlineBounds(rect);
expect(outlineX1).toBeGreaterThan(boundsX1);
expect(outlineY1).toBeGreaterThan(boundsY1);
expect(outlineX2).toBeLessThan(boundsX2);
expect(outlineY2).toBeLessThan(boundsY2);
boxSelect(
outlineX1 - (outlineX1 - boundsX1) / 2,
outlineY1 - (outlineY1 - boundsY1) / 2,
outlineX2 + (boundsX2 - outlineX2) / 2,
outlineY2 + (boundsY2 - outlineY2) / 2,
);
assertSelectedElements([rect.id]);
});
it("should not select a filled rotated rectangle when the selection box only overlaps its axis-aligned bounds", () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect]);
boxSelectTopLeftAabbCorner(rect);
assertSelectedElements([]);
});
it("should not select a filled ellipse when the selection box only overlaps its bounds corner", () => {
const ellipse = API.createElement({
type: "ellipse",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([ellipse]);
boxSelectTopRightAabbCorner(ellipse);
assertSelectedElements([]);
});
it("should not select a filled diamond when the selection box only overlaps its bounds corner", () => {
const diamond = API.createElement({
type: "diamond",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([diamond]);
boxSelectTopRightAabbCorner(diamond);
assertSelectedElements([]);
});
it("should not select a filled rotated ellipse when the selection box only overlaps its axis-aligned bounds", () => {
const ellipse = API.createElement({
type: "ellipse",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([ellipse]);
boxSelectTopLeftRotatedLocalBoundsCorner(ellipse);
assertSelectedElements([]);
});
it("should not select a filled rotated diamond when the selection box only overlaps its rotated local bounds", () => {
const diamond = API.createElement({
type: "diamond",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([diamond]);
boxSelectTopLeftRotatedLocalBoundsCorner(diamond);
assertSelectedElements([]);
});
it("should not select rotated text when the selection box only overlaps its axis-aligned bounds", () => {
const text = API.createElement({
type: "text",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
text: "test",
});
API.setElements([text]);
boxSelect(-18, -18, -8, -8);
assertSelectedElements([]);
});
it("should not select rotated image when the selection box only overlaps its axis-aligned bounds", () => {
const image = API.createElement({
type: "image",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
fileId: "file_A",
status: "saved",
});
API.setElements([image]);
boxSelect(-18, -18, -8, -8);
assertSelectedElements([]);
});
it("should deselect a selected rotated rectangle when clicking in the empty corner of its axis-aligned bounds", () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect]);
mouse.clickAt(50, 50);
assertSelectedElements([rect.id]);
const sceneRect = API.getElement(rect);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1] = getElementBounds(sceneRect, elementsMap);
mouse.clickAt(x1 + 2, y1 + 2);
assertSelectedElements([]);
});
it("should not select a line when the selection box only overlaps its bounds", () => {
const line = API.createElement({
type: "line",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(100, 100)],
});
API.setElements([line]);
boxSelect(20, 50, 30, 60);
assertSelectedElements([]);
});
it("should not click-select rotated freedraw in the corner of its axis-aligned bounds", () => {
const freedraw = API.createElement({
type: "freedraw",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "transparent",
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 0),
pointFrom<LocalPoint>(100, 100),
pointFrom<LocalPoint>(0, 100),
pointFrom<LocalPoint>(0, 0),
],
});
API.setElements([freedraw]);
const sceneFreedraw = API.getElement(freedraw);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1] = getElementBounds(sceneFreedraw, elementsMap);
mouse.clickAt(x1 + 2, y1 + 2);
assertSelectedElements([]);
});
it("should not select a freedraw when the selection box only overlaps its bounds", () => {
const freedraw = API.createElement({
type: "freedraw",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(50, 50),
pointFrom<LocalPoint>(100, 100),
],
});
API.setElements([freedraw]);
boxSelect(20, 50, 30, 60);
assertSelectedElements([]);
});
it("should not select a transparent framed element when the selection box stays inside its clipped bounds", () => {
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
fillStyle: "solid",
});
const rect1 = API.createElement({
type: "rectangle",
x: 50,
y: 10,
width: 100,
height: 80,
frameId: frame.id,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([frame, rect1]);
boxSelect(60, 20, 90, 60);
assertSelectedElements([]);
});
});
describe("inner box-selection", () => {
@@ -326,7 +833,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -359,7 +866,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -392,7 +899,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -438,7 +945,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -483,7 +990,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -558,3 +1065,58 @@ describe("selectedElementIds stability", () => {
expect(h.state.selectedElementIds).toBe(selectedElementIds_2);
});
});
describe("deselecting", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("esc unwinds nested group editing before deselecting", () => {
const rectA = API.createElement({
type: "rectangle",
x: 0,
y: 0,
groupIds: ["inner", "outer"],
});
const rectB = API.createElement({
type: "rectangle",
x: 100,
y: 0,
groupIds: ["outer"],
});
const rectC = API.createElement({
type: "rectangle",
x: 200,
y: 0,
groupIds: ["inner", "outer"],
});
API.setElements([rectA, rectB, rectC]);
mouse.select(rectA);
assertSelectedElements(rectA, rectB, rectC);
expect(h.state.editingGroupId).toBeNull();
mouse.doubleClickOn(rectA);
assertSelectedElements(rectA, rectC);
expect(h.state.editingGroupId).toBe("outer");
mouse.doubleClickOn(rectA);
assertSelectedElements(rectA);
expect(h.state.editingGroupId).toBe("inner");
Keyboard.keyPress(KEYS.ESCAPE);
assertSelectedElements(rectA, rectC);
expect(h.state.editingGroupId).toBe("outer");
Keyboard.keyPress(KEYS.ESCAPE);
assertSelectedElements(rectA, rectB, rectC);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ outer: true });
Keyboard.keyPress(KEYS.ESCAPE);
expect(API.getSelectedElements()).toEqual([]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({});
});
});
+4
View File
@@ -269,6 +269,8 @@ export type ObservedElementsAppState = {
activeLockedId: AppState["activeLockedId"];
};
export type BoxSelectionMode = "contain" | "overlap";
export interface AppState {
contextMenu: {
items: ContextMenuItems;
@@ -307,6 +309,8 @@ export interface AppState {
* `bindingPreference` and keyboard modifiers (ctrl/alt)
*/
isBindingEnabled: boolean;
/** user box selection preference; defaults to "contain" when unset */
boxSelectionMode?: BoxSelectionMode;
/** user arrow binding preference */
bindingPreference: "enabled" | "disabled";
/** user preference whether arrow snap to midpoints while binding */
@@ -45,6 +45,28 @@ unmountComponent();
const tab = " ";
const mouse = new Pointer("mouse");
const exitTextEditorAndAssertSelection = async ({
editor,
selectedIds,
nextText,
}: {
editor: HTMLTextAreaElement;
selectedIds: string[];
nextText?: string;
}) => {
if (nextText !== undefined) {
updateTextEditor(editor, nextText);
}
Keyboard.exitTextEditor(editor);
expect(await getTextEditor({ waitForEditor: false })).toBe(null);
expect(window.h.state.editingTextElement).toBeNull();
expect(API.getSelectedElements().map((element) => element.id)).toEqual(
selectedIds,
);
};
describe("textWysiwyg", () => {
describe("start text editing", () => {
const { h } = window;
@@ -271,6 +293,33 @@ describe("textWysiwyg", () => {
expect(h.elements.length).toBe(1);
});
it("should reselect text after exiting wysiwyg with escape", async () => {
const text = API.createElement({
type: "text",
text: "ola",
x: 60,
y: 0,
width: 100,
height: 100,
});
API.setElements([text]);
API.setSelectedElements([text]);
UI.clickTool("selection");
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor();
expect(editor).not.toBe(null);
expect(h.state.editingTextElement?.id).toBe(text.id);
await exitTextEditorAndAssertSelection({
editor,
selectedIds: [text.id],
});
});
it("should edit selected bound text on single click", async () => {
const container = API.createElement({
type: "rectangle",
@@ -1305,6 +1354,40 @@ describe("textWysiwyg", () => {
);
});
it.each([
{
label: "container",
createElements: () => API.createTextContainer(),
},
{
label: "arrow",
createElements: () => API.createLabeledArrow(),
},
])(
"should reselect $label after deleting bound text with escape",
async ({ createElements }) => {
const [selectedElement, text] = createElements();
API.setElements([selectedElement, text]);
API.setSelectedElements([selectedElement]);
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor();
await exitTextEditorAndAssertSelection({
editor,
nextText: "",
selectedIds: [selectedElement.id],
});
expect(selectedElement.boundElements).toStrictEqual([]);
expect(h.elements[1]).toEqual(
expect.objectContaining({
isDeleted: true,
}),
);
},
);
it("should restore original container height and clear cache once text is unbind", async () => {
const container = API.createElement({
type: "rectangle",
+1 -1
View File
@@ -40,7 +40,7 @@ export const isLineSegment = <Point extends GlobalPoint | LocalPoint>(
Array.isArray(segment) &&
segment.length === 2 &&
isPoint(segment[0]) &&
isPoint(segment[0]);
isPoint(segment[1]);
/**
* Return the coordinates resulting from rotating the given line about an origin by an angle in radians
+11
View File
@@ -1,3 +1,14 @@
//
// Generic markers
//
/**
* Can be used for any type of point-likes to mark them as rotated to enlist
* the type checker to weed out subtle bugs due to rotated and non-rotated
* versions of the same data point.
*/
export type Rotated<T> = T & { _brand_rotated: "excalimath_rotated" };
//
// Measurements
//
+25 -1
View File
@@ -1,5 +1,9 @@
import { pointFrom } from "../src/point";
import { lineSegment, lineSegmentIntersectionPoints } from "../src/segment";
import {
lineSegment,
lineSegmentIntersectionPoints,
isLineSegment,
} from "../src/segment";
describe("line-segment intersections", () => {
it("should correctly detect intersection", () => {
@@ -19,3 +23,23 @@ describe("line-segment intersections", () => {
).toEqual(null);
});
});
describe("isLineSegment validation", () => {
it("should return true for a valid segment", () => {
expect(
isLineSegment([
[0, 0],
[1, 1],
]),
).toBe(true);
});
it("should return false if second element is not a point", () => {
const invalidSegment = [[0, 0], "not-a-point"] as any;
expect(isLineSegment(invalidSegment)).toBe(false);
});
it("should return false for wrong length", () => {
expect(isLineSegment([[0, 0]])).toBe(false);
});
});
+21 -14
View File
@@ -1,4 +1,9 @@
import { arrayToMap, type Bounds } from "@excalidraw/common";
import {
arrayToMap,
bounds,
type RotatedBounds,
type Bounds,
} from "@excalidraw/common";
import { getElementBounds } from "@excalidraw/element";
import {
isArrowElement,
@@ -90,7 +95,7 @@ const getMinMaxPoints = (points: Points) => {
return ret;
};
const getRotatedBBox = (element: Element): Bounds => {
const getRotatedBBox = (element: Element): RotatedBounds => {
const points = getElementRelativePoints(element);
const { cx, cy } = getMinMaxPoints(points);
@@ -101,12 +106,13 @@ const getRotatedBBox = (element: Element): Bounds => {
);
const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints);
return [
return bounds(
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
element.angle,
);
};
export const isElementInsideBBox = (
@@ -160,12 +166,12 @@ export const elementPartiallyOverlapsWithOrContainsBBox = (
export const elementsOverlappingBBox = ({
elements,
bounds,
bounds: _bounds,
type,
errorMargin = 0,
}: {
elements: Elements;
bounds: Bounds | ExcalidrawElement;
bounds: RotatedBounds | ExcalidrawElement;
/** safety offset. Defaults to 0. */
errorMargin?: number;
/**
@@ -175,15 +181,16 @@ export const elementsOverlappingBBox = ({
**/
type: "overlap" | "contain" | "inside";
}) => {
if (isExcalidrawElement(bounds)) {
bounds = getElementBounds(bounds, arrayToMap(elements));
if (isExcalidrawElement(_bounds)) {
_bounds = getElementBounds(_bounds, arrayToMap(elements));
}
const adjustedBBox: Bounds = [
bounds[0] - errorMargin,
bounds[1] - errorMargin,
bounds[2] + errorMargin,
bounds[3] + errorMargin,
];
const adjustedBBox = bounds(
_bounds[0] - errorMargin,
_bounds[1] - errorMargin,
_bounds[2] + errorMargin,
_bounds[3] + errorMargin,
_bounds[4],
);
const includedElementSet = new Set<string>();
+4 -4
View File
@@ -1531,10 +1531,10 @@
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
"@excalidraw/mermaid-to-excalidraw@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.0.tgz#a5b9cf87c3185558cda7f9687d87b9937f452358"
integrity sha512-RMd+c2b7WzzUjhERMpKwp8PhF2/XlHDjr/zK+Gxfp8K9sVlafPYJ5OEa/GkN6edi2rBUXRfW+41WdO6L56b6Kw==
"@excalidraw/mermaid-to-excalidraw@2.2.2":
version "2.2.2"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz#ee6b597a0d95b9a76f7ae41ce0e3733a9b96e4a0"
integrity sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==
dependencies:
"@excalidraw/markdown-to-text" "0.1.2"
"@mermaid-js/parser" "^0.6.3"