Compare 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 LuzarandGitHub 4a5c9e990c fix(editor): ensure font picker font names are not quoted (#11036) 2026-03-25 17:56:48 +01:00
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 LuzarandGitHub c1082923ee feat(editor): support mermaid staate diagrams (#11031) 2026-03-24 20:20:28 +01:00
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ácsandGitHub d6f0f34fe9 fix: Rotated rounded arrow center point (#10962) 2026-03-23 15:54:59 +01:00
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 LuzarandGitHub a9ca16eb42 chore(packages/excalidraw): export Fonts helper class (#11008) 2026-03-21 22:44:27 +01:00
Márk TolmácsandGitHub 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
David LuzarandGitHub 81ab857a6f feat(editor): various text related improvements (#10979) 2026-03-19 16:00:58 +01:00
David LuzarandGitHub e8b4620a96 feat(editor): put caret at pointer coords when clicking on selected text element (#10970) 2026-03-18 19:14:44 +01:00
David LuzarandGitHub 2b0e4c9623 fix(editor): remove leftover debug code path (#10954) 2026-03-14 13:12:48 +01:00
52 changed files with 3423 additions and 412 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 * 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, minY: number,
maxX: number, maxX: number,
maxY: 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 => export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) && Array.isArray(box) &&
@@ -15,3 +41,12 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" && typeof box[1] === "number" &&
typeof box[2] === "number" && typeof box[2] === "number" &&
typeof box[3] === "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 { import {
arrayToMap, arrayToMap,
bounds,
type Bounds, type Bounds,
invariant, invariant,
rescalePoints, rescalePoints,
type RotatedBounds,
sizeOf, sizeOf,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -90,7 +92,7 @@ export class ElementBounds {
private static boundsCache = new WeakMap< private static boundsCache = new WeakMap<
ExcalidrawElement, ExcalidrawElement,
{ {
bounds: Bounds; bounds: RotatedBounds;
version: ExcalidrawElement["version"]; version: ExcalidrawElement["version"];
} }
>(); >();
@@ -102,11 +104,11 @@ export class ElementBounds {
} }
>(); >();
static getBounds( static getBounds<T extends boolean = false>(
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
nonRotated: boolean = false, nonRotated: T = false as T,
) { ): T extends true ? Bounds : RotatedBounds {
const cachedBounds = const cachedBounds =
nonRotated && element.angle !== 0 nonRotated && element.angle !== 0
? ElementBounds.nonRotatedBoundsCache.get(element) ? ElementBounds.nonRotatedBoundsCache.get(element)
@@ -119,40 +121,41 @@ export class ElementBounds {
// which is causing problems down the line. Fix TBA. // which is causing problems down the line. Fix TBA.
!isBoundToContainer(element) !isBoundToContainer(element)
) { ) {
return cachedBounds.bounds; return cachedBounds.bounds as T extends true ? Bounds : RotatedBounds;
} }
if (nonRotated && element.angle !== 0) { if (nonRotated && element.angle !== 0) {
const nonRotatedBounds = ElementBounds.calculateBounds( const [minX, minY, maxX, maxY] = ElementBounds.calculateBounds(
{ {
...element, ...element,
angle: 0 as Radians, angle: 0 as Radians,
}, },
elementsMap, elementsMap,
); );
const nonRotatedBounds = bounds(minX, minY, maxX, maxY);
ElementBounds.nonRotatedBoundsCache.set(element, { ElementBounds.nonRotatedBoundsCache.set(element, {
version: element.version, version: element.version,
bounds: nonRotatedBounds, 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, { ElementBounds.boundsCache.set(element, {
version: element.version, version: element.version,
bounds, bounds: _bounds,
}); });
return bounds; return _bounds as T extends true ? Bounds : RotatedBounds;
} }
private static calculateBounds( private static calculateBounds(
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): Bounds { ): RotatedBounds {
let bounds: Bounds; let _bounds: RotatedBounds;
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element, element,
@@ -169,14 +172,15 @@ export class ElementBounds {
), ),
); );
return [ return bounds(
minX + element.x, minX + element.x,
minY + element.y, minY + element.y,
maxX + element.x, maxX + element.x,
maxY + element.y, maxY + element.y,
]; element.angle,
);
} else if (isLinearElement(element)) { } else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap); _bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
} else if (element.type === "diamond") { } else if (element.type === "diamond") {
const [x11, y11] = pointRotateRads( const [x11, y11] = pointRotateRads(
pointFrom(cx, y1), pointFrom(cx, y1),
@@ -202,7 +206,7 @@ export class ElementBounds {
const minY = Math.min(y11, y12, y22, y21); const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21); const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21); 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") { } else if (element.type === "ellipse") {
const w = (x2 - x1) / 2; const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2; const h = (y2 - y1) / 2;
@@ -210,7 +214,7 @@ export class ElementBounds {
const sin = Math.sin(element.angle); const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin); const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * 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 { } else {
const [x11, y11] = pointRotateRads( const [x11, y11] = pointRotateRads(
pointFrom(x1, y1), pointFrom(x1, y1),
@@ -236,10 +240,10 @@ export class ElementBounds {
const minY = Math.min(y11, y12, y22, y21); const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21); const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21); 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); minY = Math.min(minY, ...ys);
maxY = Math.max(maxY, ...ys); maxY = Math.max(maxY, ...ys);
} }
return [minX, minY, maxX, maxY]; return bounds(minX, minY, maxX, maxY);
}; };
export const getMinMaxXYFromCurvePathOps = ( export const getMinMaxXYFromCurvePathOps = (
@@ -677,7 +681,7 @@ export const getMinMaxXYFromCurvePathOps = (
}, },
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
); );
return [minX, minY, maxX, maxY]; return bounds(minX, minY, maxX, maxY);
}; };
export const getBoundsFromPoints = ( export const getBoundsFromPoints = (
@@ -695,7 +699,7 @@ export const getBoundsFromPoints = (
maxY = Math.max(maxY, y); maxY = Math.max(maxY, y);
} }
return [minX, minY, maxX, maxY]; return bounds(minX, minY, maxX, maxY);
}; };
const getFreeDrawElementAbsoluteCoords = ( const getFreeDrawElementAbsoluteCoords = (
@@ -938,7 +942,7 @@ const getLinearElementRotatedBounds = (
cx: number, cx: number,
cy: number, cy: number,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): Bounds => { ): RotatedBounds => {
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (element.points.length < 2) { if (element.points.length < 2) {
@@ -949,20 +953,21 @@ const getLinearElementRotatedBounds = (
element.angle, element.angle,
); );
let coords: Bounds = [x, y, x, y]; let coords: RotatedBounds = bounds(x, y, x, y, element.angle);
if (boundTextElement) { if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element, element,
elementsMap, elementsMap,
[x, y, x, y], bounds(x, y, x, y, element.angle),
boundTextElement, boundTextElement,
); );
coords = [ coords = bounds(
coordsWithBoundText[0], coordsWithBoundText[0],
coordsWithBoundText[1], coordsWithBoundText[1],
coordsWithBoundText[2], coordsWithBoundText[2],
coordsWithBoundText[3], coordsWithBoundText[3],
]; element.angle,
);
} }
return coords; return coords;
} }
@@ -978,7 +983,13 @@ const getLinearElementRotatedBounds = (
element.angle, element.angle,
); );
const res = getMinMaxXYFromCurvePathOps(ops, transformXY); 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) { if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element, element,
@@ -986,22 +997,23 @@ const getLinearElementRotatedBounds = (
coords, coords,
boundTextElement, boundTextElement,
); );
coords = [ coords = bounds(
coordsWithBoundText[0], coordsWithBoundText[0],
coordsWithBoundText[1], coordsWithBoundText[1],
coordsWithBoundText[2], coordsWithBoundText[2],
coordsWithBoundText[3], coordsWithBoundText[3],
]; element.angle,
);
} }
return coords; return coords;
}; };
export const getElementBounds = ( export const getElementBounds = <T extends boolean = false>(
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
nonRotated: boolean = false, nonRotated: T = false as T,
): Bounds => { ) => {
return ElementBounds.getBounds(element, elementsMap, nonRotated); return ElementBounds.getBounds<T>(element, elementsMap, nonRotated);
}; };
export const getCommonBounds = ( export const getCommonBounds = (
@@ -1009,7 +1021,7 @@ export const getCommonBounds = (
elementsMap?: ElementsMap, elementsMap?: ElementsMap,
): Bounds => { ): Bounds => {
if (!sizeOf(elements)) { if (!sizeOf(elements)) {
return [0, 0, 0, 0]; return bounds(0, 0, 0, 0);
} }
let minX = Infinity; let minX = Infinity;
@@ -1027,7 +1039,7 @@ export const getCommonBounds = (
maxY = Math.max(maxY, y2); maxY = Math.max(maxY, y2);
}); });
return [minX, minY, maxX, maxY]; return bounds(minX, minY, maxX, maxY);
}; };
export const getDraggedElementsBounds = ( export const getDraggedElementsBounds = (
@@ -1050,12 +1062,12 @@ export const getResizedElementAbsoluteCoords = (
normalizePoints: boolean, normalizePoints: boolean,
): Bounds => { ): Bounds => {
if (!(isLinearElement(element) || isFreeDrawElement(element))) { if (!(isLinearElement(element) || isFreeDrawElement(element))) {
return [ return bounds(
element.x, element.x,
element.y, element.y,
element.x + nextWidth, element.x + nextWidth,
element.y + nextHeight, element.y + nextHeight,
]; );
} }
const points = rescalePoints( const points = rescalePoints(
@@ -1065,11 +1077,11 @@ export const getResizedElementAbsoluteCoords = (
normalizePoints, normalizePoints,
); );
let bounds: Bounds; let _bounds: Bounds;
if (isFreeDrawElement(element)) { if (isFreeDrawElement(element)) {
// Free Draw // Free Draw
bounds = getBoundsFromPoints(points); _bounds = getBoundsFromPoints(points);
} else { } else {
// Line // Line
const gen = rough.generator(); const gen = rough.generator();
@@ -1081,16 +1093,16 @@ export const getResizedElementAbsoluteCoords = (
: gen.curve(points as [number, number][], generateRoughOptions(element)); : gen.curve(points as [number, number][], generateRoughOptions(element));
const ops = getCurvePathOps(curve); const ops = getCurvePathOps(curve);
bounds = getMinMaxXYFromCurvePathOps(ops); _bounds = getMinMaxXYFromCurvePathOps(ops);
} }
const [minX, minY, maxX, maxY] = bounds; const [minX, minY, maxX, maxY] = _bounds;
return [ return bounds(
minX + element.x, minX + element.x,
minY + element.y, minY + element.y,
maxX + element.x, maxX + element.x,
maxY + element.y, maxY + element.y,
]; );
}; };
export const getElementPointsCoords = ( export const getElementPointsCoords = (
@@ -1108,20 +1120,20 @@ export const getElementPointsCoords = (
: gen.curve(points as [number, number][], generateRoughOptions(element)); : gen.curve(points as [number, number][], generateRoughOptions(element));
const ops = getCurvePathOps(curve); const ops = getCurvePathOps(curve);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops); const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
return [ return bounds(
minX + element.x, minX + element.x,
minY + element.y, minY + element.y,
maxX + element.x, maxX + element.x,
maxY + element.y, maxY + element.y,
]; );
}; };
export const getClosestElementBounds = ( export const getClosestElementBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
from: { x: number; y: number }, from: { x: number; y: number },
): Bounds => { ): RotatedBounds => {
if (!elements.length) { if (!elements.length) {
return [0, 0, 0, 0]; return bounds(0, 0, 0, 0, 0 as Radians);
} }
let minDistance = Infinity; let minDistance = Infinity;
@@ -1190,7 +1202,9 @@ export const getVisibleSceneBounds = ({
]; ];
}; };
export const getCenterForBounds = (bounds: Bounds): GlobalPoint => export const getCenterForBounds = (
bounds: Bounds | RotatedBounds,
): GlobalPoint =>
pointFrom( pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2, bounds[1] + (bounds[3] - bounds[1]) / 2,
@@ -1235,24 +1249,24 @@ export const aabbForElement = (
element.angle, element.angle,
); );
const bounds = [ const _bounds = bounds(
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX), Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY), Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX), Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY), Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds; );
if (offset) { if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset; const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [ return bounds(
bounds[0] - leftOffset, _bounds[0] - leftOffset,
bounds[1] - topOffset, _bounds[1] - topOffset,
bounds[2] + rightOffset, _bounds[2] + rightOffset,
bounds[3] + downOffset, _bounds[3] + downOffset,
] as Bounds; );
} }
return bounds; return _bounds;
}; };
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>( export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
@@ -1261,7 +1275,7 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
): boolean => ): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; 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, bounds1: Bounds | null,
bounds2: Bounds | null, bounds2: Bounds | null,
): boolean => { ): boolean => {
@@ -1281,7 +1295,7 @@ export const elementCenterPoint = (
xOffset: number = 0, xOffset: number = 0,
yOffset: number = 0, yOffset: number = 0,
) => { ) => {
if (isLinearElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2); 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 { import {
curveIntersectLineSegment, curveIntersectLineSegment,
isPointWithinBounds, isPointWithinBounds,
@@ -29,7 +34,7 @@ import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils"; import { isPathALoop } from "./utils";
import { import {
doBoundsIntersect, doNonRotatedBoundsIntersect,
elementCenterPoint, elementCenterPoint,
getCenterForBounds, getCenterForBounds,
getCubicBezierCurveBound, getCubicBezierCurveBound,
@@ -154,14 +159,11 @@ export const hitElementItself = ({
// Hit test against the extended, rotated bounding box of the element first // Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true); const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointWithinBounds( const hitBounds = isPointInRotatedBounds(
pointFrom(bounds[0] - threshold, bounds[1] - threshold), point,
pointRotateRads( bounds,
point, element.angle,
getCenterForBounds(bounds), threshold,
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
); );
// PERF: Bail out early if the point is not even in the // PERF: Bail out early if the point is not even in the
@@ -192,18 +194,108 @@ export const hitElementItself = ({
return result; 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 = ( export const hitElementBoundingBox = (
point: GlobalPoint, point: GlobalPoint,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
tolerance = 0, tolerance = 0,
) => { ) => {
let [x1, y1, x2, y2] = getElementBounds(element, elementsMap); const bounds = getElementBounds(element, elementsMap, true);
x1 -= tolerance; return isPointInRotatedBounds(point, bounds, element.angle, tolerance);
y1 -= tolerance; };
x2 += tolerance;
y2 += tolerance; export const doBoundsIntersectElementBoundingBox = (
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2)); 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 = ( export const hitElementBoundingBoxOnly = (
@@ -256,9 +348,13 @@ const bindingBorderTest = (
// PERF: Run a cheap test to see if the binding element // PERF: Run a cheap test to see if the binding element
// is even close to the element // is even close to the element
const t = Math.max(1, tolerance); const t = Math.max(1, tolerance);
const bounds = [x - t, y - t, x + t, y + t] as Bounds; const elementBounds = getElementBounds(element, elementsMap, true);
const elementBounds = getElementBounds(element, elementsMap); if (
if (!doBoundsIntersect(bounds, elementBounds)) { !doNonRotatedBoundsIntersect(
bounds(x - t, y - t, x + t, y + t),
elementBounds,
)
) {
return false; return false;
} }
@@ -269,6 +365,7 @@ const bindingBorderTest = (
const enclosingFrameBounds = getElementBounds( const enclosingFrameBounds = getElementBounds(
enclosingFrame, enclosingFrame,
elementsMap, elementsMap,
true,
); );
if (!pointInsideBounds(p, enclosingFrameBounds)) { if (!pointInsideBounds(p, enclosingFrameBounds)) {
return false; return false;
@@ -418,15 +515,15 @@ export const intersectElementWithLineSegment = (
): GlobalPoint[] => { ): GlobalPoint[] => {
// First check if the line intersects the element's axis-aligned bounding box // 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 // 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][0] - offset, line[1][0] - offset),
Math.min(line[0][1] - offset, line[1][1] - 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][0] + offset, line[1][0] + offset),
Math.max(line[0][1] + offset, line[1][1] + 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 []; return [];
} }
@@ -465,7 +562,12 @@ export const intersectElementWithLineSegment = (
case "line": case "line":
case "freedraw": case "freedraw":
case "arrow": case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst); return intersectLinearOrFreeDrawWithLineSegment(
element,
line,
elementsMap,
onlyFirst,
);
} }
}; };
@@ -480,14 +582,14 @@ const curveIntersections = (
for (const c of curves) { for (const c of curves) {
// Optimize by doing a cheap bounding box check first // Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]); 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][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]), Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]), Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]), Math.max(segment[0][1], segment[1][1]),
] as Bounds; );
if (!doBoundsIntersect(b1, b2)) { if (!doNonRotatedBoundsIntersect(b1, b2)) {
continue; continue;
} }
@@ -532,11 +634,15 @@ const lineIntersections = (
const intersectLinearOrFreeDrawWithLineSegment = ( const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>, segment: LineSegment<GlobalPoint>,
elementsMap: ElementsMap,
onlyFirst = false, onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
// NOTE: This is the only one which return the decomposed elements // NOTE: This is the only one which return the decomposed elements
// rotated! This is due to taking advantage of roughjs definitions. // rotated! This is due to taking advantage of roughjs definitions.
const [lines, curves] = deconstructLinearOrFreeDrawElement(element); const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
const intersections: GlobalPoint[] = []; const intersections: GlobalPoint[] = [];
for (const l of lines) { for (const l of lines) {
@@ -553,14 +659,14 @@ const intersectLinearOrFreeDrawWithLineSegment = (
for (const c of curves) { for (const c of curves) {
// Optimize by doing a cheap bounding box check first // Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]); 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][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]), Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]), Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]), Math.max(segment[0][1], segment[1][1]),
] as Bounds; );
if (!doBoundsIntersect(b1, b2)) { if (!doNonRotatedBoundsIntersect(b1, b2)) {
continue; continue;
} }
+6 -2
View File
@@ -48,7 +48,7 @@ export const distanceToElement = (
case "line": case "line":
case "arrow": case "arrow":
case "freedraw": case "freedraw":
return distanceToLinearOrFreeDraElement(element, p); return distanceToLinearOrFreeDraElement(element, elementsMap, p);
} }
}; };
@@ -133,9 +133,13 @@ const distanceToEllipseElement = (
const distanceToLinearOrFreeDraElement = ( const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(element); const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
return Math.min( return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)), ...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)), ...curves.map((a) => curvePointDistance(a, p)),
+2 -2
View File
@@ -99,7 +99,7 @@ export const isElementContainingFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ): boolean => {
return getElementsWithinSelection([frame], element, elementsMap).some( return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id, (e) => e.id === frame.id,
); );
@@ -140,7 +140,7 @@ export const elementOverlapsWithFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ): boolean => {
return ( return (
elementsAreInFrameBounds([element], frame, elementsMap) || elementsAreInFrameBounds([element], frame, elementsMap) ||
isElementIntersectingFrame(element, frame, elementsMap) || isElementIntersectingFrame(element, frame, elementsMap) ||
+35 -17
View File
@@ -21,6 +21,7 @@ import {
invariant, invariant,
isShallowEqual, isShallowEqual,
getFeatureFlag, getFeatureFlag,
bounds,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@@ -42,7 +43,7 @@ import type {
NullableGridSize, NullableGridSize,
Zoom, Zoom,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import type { Bounds } from "@excalidraw/common"; import type { RotatedBounds } from "@excalidraw/common";
import { import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
@@ -476,16 +477,22 @@ export class LinearElementEditor {
}); });
} }
invariant( if (
lastClickedPoint > -1 && lastClickedPoint < 0 ||
selectedPointsIndices.includes(lastClickedPoint) && !selectedPointsIndices.includes(lastClickedPoint) ||
element.points[lastClickedPoint], !element.points[lastClickedPoint]
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify( ) {
selectedPointsIndices, console.error(
)}) points(0..${ `There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
element.points.length - 1 selectedPointsIndices,
}) lastClickedPoint(${lastClickedPoint})`, )}) 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) // point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint]; const draggingPoint = element.points[lastClickedPoint];
@@ -794,6 +801,7 @@ export class LinearElementEditor {
element.points[index + 1], element.points[index + 1],
index, index,
appState.zoom, appState.zoom,
elementsMap,
) )
) { ) {
midpoints.push(null); midpoints.push(null);
@@ -803,6 +811,7 @@ export class LinearElementEditor {
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint( const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element, element,
index + 1, index + 1,
elementsMap,
); );
midpoints.push(segmentMidPoint); midpoints.push(segmentMidPoint);
index++; index++;
@@ -890,6 +899,7 @@ export class LinearElementEditor {
endPoint: P, endPoint: P,
index: number, index: number,
zoom: Zoom, zoom: Zoom,
elementsMap: ElementsMap,
) { ) {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
if (index >= 0 && index < element.points.length) { if (index >= 0 && index < element.points.length) {
@@ -904,7 +914,10 @@ export class LinearElementEditor {
let distance = pointDistance(startPoint, endPoint); let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) { if (element.points.length > 2 && element.roundness) {
const [lines, curves] = deconstructLinearOrFreeDrawElement(element); const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
invariant( invariant(
lines.length === 0 && curves.length > 0, lines.length === 0 && curves.length > 0,
@@ -924,6 +937,7 @@ export class LinearElementEditor {
static getSegmentMidPoint( static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
index: number, index: number,
elementsMap: ElementsMap,
): GlobalPoint { ): GlobalPoint {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
invariant( invariant(
@@ -936,7 +950,10 @@ export class LinearElementEditor {
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]); return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
} }
const [lines, curves] = deconstructLinearOrFreeDrawElement(element); const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
invariant( invariant(
(lines.length === 0 && curves.length > 0) || (lines.length === 0 && curves.length > 0) ||
@@ -1851,6 +1868,7 @@ export class LinearElementEditor {
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element, element,
index + 1, index + 1,
elementsMap,
); );
x = midSegmentMidpoint[0] - boundTextElement.width / 2; x = midSegmentMidpoint[0] - boundTextElement.width / 2;
@@ -1862,7 +1880,7 @@ export class LinearElementEditor {
static getMinMaxXYWithBoundText = ( static getMinMaxXYWithBoundText = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
elementBounds: Bounds, elementBounds: RotatedBounds,
boundTextElement: ExcalidrawTextElementWithContainer, boundTextElement: ExcalidrawTextElementWithContainer,
): [number, number, number, number, number, number] => { ): [number, number, number, number, number, number] => {
let [x1, y1, x2, y2] = elementBounds; let [x1, y1, x2, y2] = elementBounds;
@@ -1987,7 +2005,7 @@ export class LinearElementEditor {
return LinearElementEditor.getMinMaxXYWithBoundText( return LinearElementEditor.getMinMaxXYWithBoundText(
element, element,
elementsMap, elementsMap,
[x1, y1, x2, y2], bounds(x1, y1, x2, y2, element.angle),
boundTextElement, boundTextElement,
); );
} }
@@ -2400,7 +2418,7 @@ const pointDraggingUpdates = (
? nextArrow.points[0] ? nextArrow.points[0]
: endBindable : endBindable
? updateBoundPoint( ? updateBoundPoint(
element, nextArrow,
"endBinding", "endBinding",
nextArrow.endBinding, nextArrow.endBinding,
endBindable, endBindable,
@@ -2431,7 +2449,7 @@ const pointDraggingUpdates = (
? endLocalPoint ? endLocalPoint
: startBindable : startBindable
? updateBoundPoint( ? updateBoundPoint(
element, nextArrow,
"startBinding", "startBinding",
nextArrow.startBinding, nextArrow.startBinding,
startBindable, startBindable,
+384 -36
View File
@@ -1,16 +1,37 @@
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 { import type {
AppState, AppState,
BoxSelectionMode,
InteractiveCanvasAppState, InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types"; } 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 { isElementInViewport } from "./sizeHelpers";
import { import {
isBoundToContainer, isBoundToContainer,
isFrameLikeElement, isFrameLikeElement,
isFreeDrawElement,
isLinearElement, isLinearElement,
isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
@@ -25,9 +46,209 @@ import type {
ElementsMap, ElementsMap,
ElementsMapOrArray, ElementsMapOrArray,
ExcalidrawElement, ExcalidrawElement,
NonDeleted,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } 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. * 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 * Given an array of selected elements, if there are frames and their containing elements
@@ -60,55 +281,166 @@ export const getElementsWithinSelection = (
selection: NonDeletedExcalidrawElement, selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
excludeElementsInFrames: boolean = true, excludeElementsInFrames: boolean = true,
) => { boxSelectionMode: BoxSelectionMode = "contain",
const [selectionX1, selectionY1, selectionX2, selectionY2] = ): NonDeletedExcalidrawElement[] => {
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
getElementAbsoluteCoords(selection, elementsMap); 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) => { if (boxSelectionMode !== "overlap") {
let [elementX1, elementY1, elementX2, elementY2] = getElementBounds( const elementsInSelection: NonDeletedExcalidrawElement[] = [];
element,
elementsMap,
);
const containingFrame = getContainingFrame(element, elementsMap); for (const element of elements) {
if (containingFrame) { if (shouldSkipElementFromSelection(element)) {
const [fx1, fy1, fx2, fy2] = getElementBounds( continue;
containingFrame, }
elementsMap,
);
elementX1 = Math.max(fx1, elementX1); const elementBounds = getElementBounds(element, elementsMap) as Bounds;
elementY1 = Math.max(fy1, elementY1); const frameBounds = getFrameBoundsForSelection(element, elementsMap);
elementX2 = Math.min(fx2, elementX2); let elementX1 = elementBounds[0];
elementY2 = Math.min(fy2, elementY2); 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 ( return finalizeElementsInSelection(
element.locked === false && elementsInSelection,
element.type !== "selection" && excludeElementsInFrames,
!isBoundToContainer(element) && 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 && selectionX1 <= elementX1 &&
selectionY1 <= elementY1 && selectionY1 <= elementY1 &&
selectionX2 >= elementX2 && selectionX2 >= elementX2 &&
selectionY2 >= elementY2 selectionY2 >= elementY2;
);
});
elementsInSelection = excludeElementsInFrames const isSelectionOverlappingElementAabb =
? excludeElementsInFramesFromSelection(elementsInSelection) selectionX1 <= elementX2 &&
: elementsInSelection; selectionY1 <= elementY2 &&
selectionX2 >= elementX1 &&
selectionY2 >= elementY1;
const isSelectionOverlappingElement = shouldUseRotatedOverlapBroadPhase(
element,
)
? isSelectionOverlappingElementAabb &&
doBoundsIntersectElementBoundingBox(
selectionBounds,
element,
elementsMap,
)
: isSelectionOverlappingElementAabb;
elementsInSelection = elementsInSelection.filter((element) => { const shouldSelectFromInside = shouldTestInside(element);
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) { if (shouldSelectFromInside) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap); 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 = ( export const getVisibleAndNonSelectedElements = (
@@ -288,3 +620,19 @@ export const getSelectionStateForElements = (
), ),
}; };
}; };
/**
* Returns editing or single-selected text element, if any.
*/
export const getActiveTextElement = (
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
appState: Pick<AppState, "editingTextElement">,
) => {
const activeTextElement =
appState.editingTextElement ||
(selectedElements.length === 1 &&
isTextElement(selectedElements[0]) &&
selectedElements[0]);
return activeTextElement || null;
};
+7 -16
View File
@@ -57,8 +57,8 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import { import {
elementCenterPoint,
getArrowheadPoints, getArrowheadPoints,
getCenterForBounds,
getDiamondPoints, getDiamondPoints,
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
@@ -583,7 +583,11 @@ const getArrowheadShapes = (
export const generateLinearCollisionShape = ( export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
) => { elementsMap: ElementsMap,
): {
op: string;
data: number[];
}[] => {
const generator = new RoughGenerator(); const generator = new RoughGenerator();
const options: Options = { const options: Options = {
seed: element.seed, seed: element.seed,
@@ -592,20 +596,7 @@ export const generateLinearCollisionShape = (
roughness: 0, roughness: 0,
preserveVertices: true, preserveVertices: true,
}; };
const center = getCenterForBounds( const center = elementCenterPoint(element, elementsMap);
// 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],
),
);
switch (element.type) { switch (element.type) {
case "line": case "line":
+3 -1
View File
@@ -347,6 +347,7 @@ export const getContainerCenter = (
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container, container,
index + 1, index + 1,
elementsMap,
); );
} }
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
@@ -441,7 +442,8 @@ const VALID_CONTAINER_TYPES = new Set([
export const isValidTextContainer = (element: { export const isValidTextContainer = (element: {
type: ExcalidrawElementType; type: ExcalidrawElementType;
}) => VALID_CONTAINER_TYPES.has(element.type); }): element is ExcalidrawTextContainer =>
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = ( export const computeContainerDimensionForBoundText = (
dimension: number, dimension: number,
+207 -37
View File
@@ -4,6 +4,22 @@ import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types"; import type { FontString } from "./types";
/**
* This module approximates browser-like soft wrapping for Excalidraw text.
*
* The flow is:
* 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex.
* 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and
* records where each visual line came from in the source text.
* 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token
* that is wider than the available width.
* 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing
* whitespace so the rendered text stays consistent with what users see on canvas.
*
* Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers
* that need metadata such as mapping visual lines back to `originalText`
* for caret placement or future editor features.
*/
let cachedCjkRegex: RegExp | undefined; let cachedCjkRegex: RegExp | undefined;
let cachedLineBreakRegex: RegExp | undefined; let cachedLineBreakRegex: RegExp | undefined;
let cachedEmojiRegex: RegExp | undefined; let cachedEmojiRegex: RegExp | undefined;
@@ -358,6 +374,10 @@ const Break = {
/** /**
* Breaks the line into the tokens based on the found line break opporutnities. * Breaks the line into the tokens based on the found line break opporutnities.
*
* Note: tokenization normalizes to NFC first so decomposed graphemes are treated as
* their composed variants for wrapping. Any code that needs exact source offsets should
* keep in mind that this assumes the input text is already NFC-normalized.
*/ */
export const parseTokens = (line: string) => { export const parseTokens = (line: string) => {
const breakLineRegex = getLineBreakRegex(); const breakLineRegex = getLineBreakRegex();
@@ -370,56 +390,120 @@ export const parseTokens = (line: string) => {
/** /**
* Wraps the original text into the lines based on the given width. * Wraps the original text into the lines based on the given width.
*
* This is a convenience adapter over `getWrappedTextLines()` for call sites
* that only need the rendered wrapped string and not the source offsets.
*/ */
export const wrapText = ( export const wrapText = (
text: string, text: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): string => { ): string => {
return getWrappedTextLines(text, font, maxWidth)
.map((line) => line.text)
.join("\n");
};
/**
* A single rendered visual line produced from the original text.
*
* `start` and `end` are end-exclusive code-unit offsets into the original text, and do
* not include synthetic soft line breaks inserted by this module. If trailing whitespace
* was trimmed away at a wrap boundary, `end` points to the last rendered character.
*/
export type WrappedTextLine = {
text: string;
start: number;
end: number;
};
/**
* Splits only on existing hard line breaks and preserves original offsets.
*/
const getHardLineBreaks = (text: string): WrappedTextLine[] => {
let offset = 0;
return text.split("\n").map((line) => {
const start = offset;
const end = start + line.length;
offset = end + 1;
return {
text: line,
start,
end,
};
});
};
/**
* Returns the rendered visual lines together with their source offsets.
*
* This is the source-of-truth wrapping pipeline for callers that need more than the
* final wrapped string, for example caret placement or future editor/rich-text mapping.
*/
export const getWrappedTextLines = (
text: string,
font: FontString,
maxWidth: number,
): WrappedTextLine[] => {
// if maxWidth is not finite or NaN which can happen in case of bugs in // if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up // computation, we need to make sure we don't continue as we'll end up
// in an infinite loop // in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) { if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return text; return getHardLineBreaks(text);
} }
const lines: Array<string> = []; const lines: WrappedTextLine[] = [];
const originalLines = text.split("\n"); let offset = 0;
for (const originalLine of originalLines) { for (const originalLine of text.split("\n")) {
const currentLineWidth = getLineWidth(originalLine, font); const originalLineWidth = getLineWidth(originalLine, font);
if (currentLineWidth <= maxWidth) { if (originalLineWidth <= maxWidth) {
lines.push(originalLine); lines.push({
continue; text: originalLine,
start: offset,
end: offset + originalLine.length,
});
} else {
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
} }
const wrappedLine = wrapLine(originalLine, font, maxWidth); offset += originalLine.length + 1;
lines.push(...wrappedLine);
} }
return lines.join("\n"); return lines;
}; };
/** /**
* Wraps the original line into the lines based on the given width. * Wraps a single hard line into one or more visual lines.
*
* The line-local offsets are tracked in original-text code units so
* we can map the visual line back to the source.
*/ */
const wrapLine = ( const wrapLine = (
line: string, line: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): string[] => { lineStart: number,
const lines: Array<string> = []; ): WrappedTextLine[] => {
const lines: WrappedTextLine[] = [];
const tokens = parseTokens(line); const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = ""; let currentLine = "";
let currentLineStart = lineStart;
let currentLineEnd = lineStart;
let currentLineWidth = 0; let currentLineWidth = 0;
// Tracks the next token's code-unit position in the original source string.
let tokenOffset = lineStart;
let tokenIndex = 0;
let iterator = tokenIterator.next(); while (tokenIndex < tokens.length) {
const token = tokens[tokenIndex];
while (!iterator.done) { const tokenStart = tokenOffset;
const token = iterator.value; const tokenEnd = tokenStart + token.length;
const testLine = currentLine + token; const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
@@ -429,37 +513,59 @@ const wrapLine = (
// build up the current line, skipping length check for possibly trailing whitespaces // build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) { if (/\s/.test(token) || testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = tokenStart;
}
currentLine = testLine; currentLine = testLine;
currentLineEnd = tokenEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
iterator = tokenIterator.next(); tokenOffset = tokenEnd;
tokenIndex++;
continue; continue;
} }
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped // current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) { if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth); const wrappedWord = wrapWord(token, font, maxWidth, tokenStart);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? ""; const trailingLine = wrappedWord[wrappedWord.length - 1] ?? {
text: "",
start: tokenStart,
end: tokenStart,
};
const precedingLines = wrappedWord.slice(0, -1); const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines); lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s // trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine; currentLine = trailingLine.text;
currentLineWidth = getLineWidth(trailingLine, font); currentLineStart = trailingLine.start;
iterator = tokenIterator.next(); currentLineEnd = trailingLine.end;
currentLineWidth = getLineWidth(trailingLine.text, font);
tokenOffset = tokenEnd;
tokenIndex++;
} else { } else {
// push & reset, but don't iterate on the next token, as we didn't use it yet! // push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push(currentLine.trimEnd()); lines.push(
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
);
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = ""; currentLine = "";
currentLineStart = tokenStart;
currentLineEnd = tokenStart;
currentLineWidth = 0; currentLineWidth = 0;
} }
} }
// iterator done, push the trailing line if exists // iterator done, push the trailing line if exists
if (currentLine) { if (currentLine) {
const trailingLine = trimLine(currentLine, font, maxWidth); const trailingLine = trimLine(
currentLine,
currentLineStart,
currentLineEnd,
font,
maxWidth,
);
lines.push(trailingLine); lines.push(trailingLine);
} }
@@ -467,59 +573,100 @@ const wrapLine = (
}; };
/** /**
* Wraps the word into the lines based on the given width. * Wraps a single word that could not be placed on an empty line as-is.
*/ */
const wrapWord = ( const wrapWord = (
word: string, word: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): Array<string> => { wordStart: number,
): WrappedTextLine[] => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further // multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) { if (getEmojiRegex().test(word)) {
return [word]; return [
{
text: word,
start: wordStart,
end: wordStart + word.length,
},
];
} }
satisfiesWordInvariant(word); satisfiesWordInvariant(word);
const lines: Array<string> = []; const lines: WrappedTextLine[] = [];
const chars = Array.from(word); const chars = Array.from(word);
let currentLine = ""; let currentLine = "";
let currentLineStart = wordStart;
let currentLineEnd = wordStart;
let currentLineWidth = 0; let currentLineWidth = 0;
let offset = wordStart;
for (const char of chars) { for (const char of chars) {
const charStart = offset;
const charEnd = charStart + char.length;
const _charWidth = charWidth.calculate(char, font); const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth; const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) { if (testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = charStart;
}
currentLine = currentLine + char; currentLine = currentLine + char;
currentLineEnd = charEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
offset = charEnd;
continue; continue;
} }
if (currentLine) { if (currentLine) {
lines.push(currentLine); lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
currentLine = char; currentLine = char;
currentLineStart = charStart;
currentLineEnd = charEnd;
currentLineWidth = _charWidth; currentLineWidth = _charWidth;
offset = charEnd;
} }
if (currentLine) { if (currentLine) {
lines.push(currentLine); lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
return lines; return lines;
}; };
/** /**
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`. * Trims trailing whitespace that is exceeding the `maxWidth`.
*
* Used for the trailing visual line of a hard line, where some trailing
* whitespace may still be visible if it fits into the available width.
*/ */
const trimLine = (line: string, font: FontString, maxWidth: number) => { const trimLine = (
line: string,
start: number,
end: number,
font: FontString,
maxWidth: number,
): WrappedTextLine => {
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth; const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) { if (!shouldTrimWhitespaces) {
return line; return {
text: line,
start,
end,
};
} }
// defensively default to `trimeEnd` in case the regex does not match // defensively default to `trimeEnd` in case the regex does not match
@@ -543,7 +690,30 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
trimmedLineWidth = testLineWidth; trimmedLineWidth = testLineWidth;
} }
return trimmedLine; return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
};
/**
* Used for internal soft-wrap boundaries, where trailing whitespace should not
* survive into the rendered line even though it still exists in the original
* text.
*/
const trimLineEndAtSoftBreak = (
line: string,
start: number,
end: number,
): WrappedTextLine => {
const trimmedLine = line.trimEnd();
return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
}; };
/** /**
+2 -4
View File
@@ -124,6 +124,7 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
*/ */
export function deconstructLinearOrFreeDrawElement( export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0); const cachedShape = getElementShapesCacheEntry(element, 0);
@@ -131,10 +132,7 @@ export function deconstructLinearOrFreeDrawElement(
return cachedShape; return cachedShape;
} }
const ops = generateLinearCollisionShape(element) as { const ops = generateLinearCollisionShape(element, elementsMap);
op: string;
data: number[];
}[];
const lines = []; const lines = [];
const curves = []; const curves = [];
+1 -1
View File
@@ -29,7 +29,7 @@ describe("check rotated elements can be hit:", () => {
] as LocalPoint[], ] as LocalPoint[],
}); });
const hit = hitElementItself({ const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68), point: pointFrom<GlobalPoint>(87, -67),
element: window.h.elements[0], element: window.h.elements[0],
threshold: 10, threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(), elementsMap: window.h.scene.getNonDeletedElementsMap(),
+70 -1
View File
@@ -1,4 +1,8 @@
import { wrapText, parseTokens } from "../src/textWrapping"; import {
getWrappedTextLines,
parseTokens,
wrapText,
} from "../src/textWrapping";
import type { FontString } from "../src/types"; import type { FontString } from "../src/types";
@@ -102,6 +106,71 @@ describe("Test wrapText", () => {
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`); expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
}); });
it("should retain original text offsets for wrapped lines", () => {
expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([
{
text: "Hello",
start: 0,
end: 5,
},
{
text: "World!",
start: 6,
end: 12,
},
]);
});
it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => {
expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([
{
text: " Hello",
start: 0,
end: 7,
},
{
text: "World",
start: 9,
end: 14,
},
]);
});
it("should retain offsets when wrapping a single long token", () => {
expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([
{
text: "Excal",
start: 0,
end: 5,
},
{
text: "idraw",
start: 5,
end: 10,
},
]);
});
it("should preserve empty hard lines in metadata", () => {
expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([
{
text: "A",
start: 0,
end: 1,
},
{
text: "",
start: 2,
end: 2,
},
{
text: "B",
start: 3,
end: 4,
},
]);
});
describe("When text is CJK", () => { describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => { it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
@@ -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) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && (event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) ||
(appState.selectedLinearElement?.isEditing ||
(!appState.newElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => ( PanelComponent: ({ appState, updateData, data }) => (
@@ -191,7 +191,7 @@ export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
app: AppClassProperties, app: AppClassProperties,
getAttribute: (element: ExcalidrawElement) => T, getAttribute: (element: ExcalidrawElement) => T,
isRelevantElement: true | ((element: ExcalidrawElement) => boolean), elementPredicate: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T), defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T { ): T {
const editingTextElement = app.state.editingTextElement; const editingTextElement = app.state.editingTextElement;
@@ -209,9 +209,9 @@ export const getFormValue = function <T extends Primitive>(
if (hasSelection) { if (hasSelection) {
const selectedElements = app.scene.getSelectedElements(app.state); const selectedElements = app.scene.getSelectedElements(app.state);
const targetElements = const targetElements =
isRelevantElement === true elementPredicate === true
? selectedElements ? selectedElements
: selectedElements.filter((el) => isRelevantElement(el)); : selectedElements.filter((el) => elementPredicate(el));
ret = ret =
reduceToCommonValue(targetElements, getAttribute) ?? reduceToCommonValue(targetElements, getAttribute) ??
@@ -730,9 +730,28 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ app, updateData }) => ( PanelComponent: ({ elements, appState, app, updateData }) => {
<Range updateData={updateData} app={app} testId="opacity" /> const opacity = getFormValue(
), elements,
app,
(element) => element.opacity,
true,
(hasSelection) => (hasSelection ? null : appState.currentItemOpacity),
);
return (
<Range
label={t("labels.opacity")}
value={opacity ?? appState.currentItemOpacity}
hasCommonValue={opacity !== null}
onChange={updateData}
min={0}
max={100}
step={10}
testId="opacity"
/>
);
},
}); });
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>( export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
@@ -1,24 +1,24 @@
import { getFontString } from "@excalidraw/common"; import { getFontString } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element"; import { isExcalidrawElement, newElementWith } from "@excalidraw/element";
import { measureText } from "@excalidraw/element"; import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element"; import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties } from "../types";
export const actionTextAutoResize = register({ export const actionTextAutoResize = register({
name: "autoResize", name: "autoResize",
label: "labels.autoResize", label: "labels.autoResize",
icon: null, icon: null,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown, app: AppClassProperties) => { predicate: (elements, appState, _: unknown) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return ( return (
selectedElements.length === 1 && selectedElements.length === 1 &&
@@ -26,13 +26,18 @@ export const actionTextAutoResize = register({
!selectedElements[0].autoResize !selectedElements[0].autoResize
); );
}, },
perform: (elements, appState, _, app) => { perform: (elements, appState, targetElement) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
const targetTextElement =
isExcalidrawElement(targetElement) && isTextElement(targetElement)
? targetElement
: (selectedElements[0] as ExcalidrawElement | undefined);
return { return {
appState, appState,
elements: elements.map((element) => { elements: elements.map((element) => {
if (element.id === selectedElements[0].id && isTextElement(element)) { if (element.id === targetTextElement?.id && isTextElement(element)) {
const metrics = measureText( const metrics = measureText(
element.originalText, element.originalText,
getFontString(element), getFontString(element),
+1
View File
@@ -34,6 +34,7 @@ export {
export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable"; export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable";
export { actionFinalize } from "./actionFinalize"; export { actionFinalize } from "./actionFinalize";
export { actionDeselect } from "./actionDeselect";
export { export {
actionChangeProjectName, actionChangeProjectName,
+1
View File
@@ -114,6 +114,7 @@ export type ActionName =
| "distributeVertically" | "distributeVertically"
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "deselect"
| "viewMode" | "viewMode"
| "exportWithDarkMode" | "exportWithDarkMode"
| "toggleTheme" | "toggleTheme"
+1
View File
@@ -193,6 +193,7 @@ const APP_STATE_STORAGE_CONF = (<
gridModeEnabled: { browser: true, export: true, server: true }, gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: true, 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 }, bindingPreference: { browser: true, export: false, server: false },
isMidpointSnappingEnabled: { browser: true, export: false, server: false }, isMidpointSnappingEnabled: { browser: true, export: false, server: false },
defaultSidebarDockedPreference: { defaultSidebarDockedPreference: {
+362 -60
View File
@@ -27,6 +27,7 @@ import {
KEYS, KEYS,
APP_NAME, APP_NAME,
CURSOR_TYPE, CURSOR_TYPE,
DEFAULT_TRANSFORM_HANDLE_SPACING,
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD, DRAGGING_THRESHOLD,
@@ -245,7 +246,7 @@ import {
bindOrUnbindBindingElement, bindOrUnbindBindingElement,
mutateElement, mutateElement,
getElementBounds, getElementBounds,
doBoundsIntersect, doNonRotatedBoundsIntersect,
isPointInElement, isPointInElement,
maxBindingDistance_simple, maxBindingDistance_simple,
convertToExcalidrawElements, convertToExcalidrawElements,
@@ -257,6 +258,7 @@ import {
handleFocusPointPointerUp, handleFocusPointPointerUp,
maybeHandleArrowPointlikeDrag, maybeHandleArrowPointlikeDrag,
getUncroppedWidthAndHeight, getUncroppedWidthAndHeight,
getActiveTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -416,6 +418,7 @@ import {
import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
import { LaserTrails } from "../laser-trails"; import { LaserTrails } from "../laser-trails";
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle";
import { textWysiwyg } from "../wysiwyg/textWysiwyg"; import { textWysiwyg } from "../wysiwyg/textWysiwyg";
import { isOverScrollBars } from "../scene/scrollbars"; import { isOverScrollBars } from "../scene/scrollbars";
@@ -684,9 +687,15 @@ class App extends React.Component<AppProps, AppState> {
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null; lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null = lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null; null;
// TODO this is a hack and we should ideally unify touch and pointer events
// and implement our own double click handling end-to-end (currently we're
// using a mix of native browser for click events and manual for touch -
// and browser doubleClick sucks to begin with)
lastPointerUpIsDoubleClick: boolean = false;
lastPointerMoveEvent: PointerEvent | null = null; lastPointerMoveEvent: PointerEvent | null = null;
/** current frame pointer cords */ /** current frame pointer cords */
lastPointerMoveCoords: { x: number; y: number } | null = null; lastPointerMoveCoords: { x: number; y: number } | null = null;
private lastCompletedCanvasClicks: { x: number; y: number }[] = [];
/** previous frame pointer coords */ /** previous frame pointer coords */
previousPointerMoveCoords: { x: number; y: number } | null = null; previousPointerMoveCoords: { x: number; y: number } | null = null;
lastViewportPosition = { x: 0, y: 0 }; lastViewportPosition = { x: 0, y: 0 };
@@ -1145,7 +1154,7 @@ class App extends React.Component<AppProps, AppState> {
startBounds && startBounds &&
endBounds && endBounds &&
startElement.id !== endElement.id && startElement.id !== endElement.id &&
doBoundsIntersect(startBounds, endBounds) doNonRotatedBoundsIntersect(startBounds, endBounds)
); );
} }
@@ -1248,6 +1257,26 @@ class App extends React.Component<AppProps, AppState> {
) as NullableGridSize; ) as NullableGridSize;
}; };
private getTextCreationGridPoint = (x: number, y: number) => {
const effectiveGridSize = this.getEffectiveGridSize();
if (effectiveGridSize === null) {
return null;
}
const getTextCreationGridCoordinate = (coordinate: number) => {
const topLeftGridPoint =
Math.floor(coordinate / effectiveGridSize) * effectiveGridSize;
return topLeftGridPoint;
};
return {
x: getTextCreationGridCoordinate(x),
y: getTextCreationGridCoordinate(y),
};
};
private getHTMLIFrameElement( private getHTMLIFrameElement(
element: ExcalidrawIframeLikeElement, element: ExcalidrawIframeLikeElement,
): HTMLIFrameElement | undefined { ): HTMLIFrameElement | undefined {
@@ -1438,6 +1467,21 @@ class App extends React.Component<AppProps, AppState> {
return true; return true;
} }
private isDoubleClick = (
lastPointerEvent:
| PointerEvent
| React.PointerEvent<HTMLElement>
| undefined
| null,
currentPointerEvent: PointerEvent | React.PointerEvent<HTMLElement>,
) => {
return (
lastPointerEvent != null &&
currentPointerEvent.timeStamp - lastPointerEvent.timeStamp <=
TAP_TWICE_TIMEOUT
);
};
private isIframeLikeElementCenter( private isIframeLikeElementCenter(
el: ExcalidrawIframeLikeElement | null, el: ExcalidrawIframeLikeElement | null,
event: React.PointerEvent<HTMLElement> | PointerEvent, event: React.PointerEvent<HTMLElement> | PointerEvent,
@@ -2321,6 +2365,7 @@ class App extends React.Component<AppProps, AppState> {
} }
handleCanvasRef={this.handleInteractiveCanvasRef} handleCanvasRef={this.handleInteractiveCanvasRef}
onContextMenu={this.handleCanvasContextMenu} onContextMenu={this.handleCanvasContextMenu}
onClick={this.handleCanvasClick}
onPointerMove={this.handleCanvasPointerMove} onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.handleCanvasPointerUp} onPointerUp={this.handleCanvasPointerUp}
onPointerCancel={this.removePointer} onPointerCancel={this.removePointer}
@@ -3574,10 +3619,14 @@ class App extends React.Component<AppProps, AppState> {
this.lassoTrail.endPath(); this.lassoTrail.endPath();
this.deselectElements(); this.deselectElements();
// @ts-ignore
this.handleCanvasDoubleClick({ this.handleCanvasDoubleClick({
clientX: touch.clientX, clientX: touch.clientX,
clientY: touch.clientY, clientY: touch.clientY,
type: "touch",
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
}); });
} }
didTapTwice = false; didTapTwice = false;
@@ -5617,8 +5666,14 @@ class App extends React.Component<AppProps, AppState> {
element: ExcalidrawTextElement, element: ExcalidrawTextElement,
{ {
isExistingElement = false, isExistingElement = false,
initialCaretSceneCoords = null,
}: { }: {
isExistingElement?: boolean; isExistingElement?: boolean;
/**
* supply null if no caret positioning is desired, and instead
* text should be auto-selected
*/
initialCaretSceneCoords?: { x: number; y: number } | null;
}, },
) { ) {
const elementsMap = this.scene.getElementsMapIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted();
@@ -5671,13 +5726,13 @@ class App extends React.Component<AppProps, AppState> {
const isDeleted = !nextOriginalText.trim(); const isDeleted = !nextOriginalText.trim();
updateElement(nextOriginalText, isDeleted); updateElement(nextOriginalText, isDeleted);
// select the created text element only if submitting via keyboard // keyboard-submit keeps focus on the edited object. For bound text, keep
// (when submitting via click it should act as signal to deselect) // the container selected even if the text becomes empty and is deleted.
if (!isDeleted && viaKeyboard) { const elementIdToSelect = viaKeyboard
const elementIdToSelect = element.containerId ? element.containerId || (!isDeleted ? element.id : null)
? element.containerId : null;
: element.id;
if (elementIdToSelect) {
// needed to ensure state is updated before "finalize" action // needed to ensure state is updated before "finalize" action
// that's invoked on keyboard-submit as well // that's invoked on keyboard-submit as well
// TODO either move this into finalize as well, or handle all state // TODO either move this into finalize as well, or handle all state
@@ -5721,6 +5776,7 @@ class App extends React.Component<AppProps, AppState> {
element, element,
excalidrawContainer: this.excalidrawContainerRef.current, excalidrawContainer: this.excalidrawContainerRef.current,
app: this, app: this,
initialCaretSceneCoords,
// when text is selected, it's hard (at least on iOS) to re-position the // when text is selected, it's hard (at least on iOS) to re-position the
// caret (i.e. deselect). There's not much use for always selecting // caret (i.e. deselect). There's not much use for always selecting
// the text on edit anyway (and users can select-all from contextmenu // the text on edit anyway (and users can select-all from contextmenu
@@ -5744,6 +5800,68 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
private getSelectedTextElement(
container?: ExcalidrawTextContainer | null,
): NonDeleted<ExcalidrawTextElement> | null {
const selectedElements = this.scene.getSelectedElements(this.state);
if (selectedElements.length !== 1) {
return null;
}
const selectedElement = selectedElements[0]!;
if (isTextElement(selectedElement)) {
return selectedElement;
}
if (!container) {
return null;
}
return getBoundTextElement(
selectedElement,
this.scene.getNonDeletedElementsMap(),
);
}
private getSelectedTextEditingContainerAtPosition(
hitElement: NonDeletedExcalidrawElement | null,
sceneCoords: { x: number; y: number },
): ExcalidrawTextContainer | null | undefined {
const selectedElements = this.scene.getSelectedElements(this.state);
if (
selectedElements.length !== 1 ||
!hitElement ||
hitElement.id !== selectedElements[0]!.id
) {
return null;
}
const selectedElement = selectedElements[0]!;
if (isTextElement(selectedElement)) {
return null;
}
if (!isValidTextContainer(selectedElement)) {
return undefined;
}
const textElement = this.getSelectedTextElement(selectedElement);
const hitTextElement = this.getTextElementAtPosition(
sceneCoords.x,
sceneCoords.y,
);
if (!textElement || hitTextElement?.id !== textElement.id) {
return undefined;
}
return selectedElement;
}
private getTextElementAtPosition( private getTextElementAtPosition(
x: number, x: number,
y: number, y: number,
@@ -5757,6 +5875,58 @@ class App extends React.Component<AppProps, AppState> {
return null; return null;
} }
private isHittingTextAutoResizeHandle = (
selectedElements: NonDeleted<ExcalidrawElement>[],
point: Readonly<{ x: number; y: number }>,
): boolean => {
const activeTextElement = getActiveTextElement(
selectedElements,
this.state,
);
if (
activeTextElement &&
!activeTextElement.isDeleted &&
!activeTextElement.autoResize &&
isPointHittingTextAutoResizeHandle(
point,
activeTextElement,
this.state.zoom.value,
this.editorInterface.formFactor,
)
) {
return true;
}
return false;
};
private handleTextAutoResizeHandlePointerDown = (
selectedElements: NonDeleted<ExcalidrawElement>[],
point: Readonly<{ x: number; y: number }>,
) => {
const activeTextElement = getActiveTextElement(
selectedElements,
this.state,
);
if (
!activeTextElement ||
!this.isHittingTextAutoResizeHandle(selectedElements, point)
) {
return false;
}
this.actionManager.executeAction(
actionTextAutoResize,
"ui",
// we need to pass down the element since it may already be deselected
// due to the pointerdown
activeTextElement,
);
this.resetCursor();
return true;
};
// NOTE: Hot path for hit testing, so avoid unnecessary computations // NOTE: Hot path for hit testing, so avoid unnecessary computations
private getElementAtPosition( private getElementAtPosition(
x: number, x: number,
@@ -5969,6 +6139,7 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter = true, insertAtParentCenter = true,
container, container,
autoEdit = true, autoEdit = true,
initialCaretSceneCoords,
}: { }: {
/** X position to insert text at */ /** X position to insert text at */
sceneX: number; sceneX: number;
@@ -5978,6 +6149,7 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter?: boolean; insertAtParentCenter?: boolean;
container?: ExcalidrawTextContainer | null; container?: ExcalidrawTextContainer | null;
autoEdit?: boolean; autoEdit?: boolean;
initialCaretSceneCoords?: { x: number; y: number };
}) => { }) => {
let shouldBindToContainer = false; let shouldBindToContainer = false;
@@ -5998,24 +6170,9 @@ class App extends React.Component<AppProps, AppState> {
shouldBindToContainer = true; shouldBindToContainer = true;
} }
} }
let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null; const existingTextElement =
this.getSelectedTextElement(container) ||
const selectedElements = this.scene.getSelectedElements(this.state); this.getTextElementAtPosition(sceneX, sceneY);
if (selectedElements.length === 1) {
if (isTextElement(selectedElements[0])) {
existingTextElement = selectedElements[0];
} else if (container) {
existingTextElement = getBoundTextElement(
selectedElements[0],
this.scene.getNonDeletedElementsMap(),
);
} else {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
}
} else {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
}
const fontFamily = const fontFamily =
existingTextElement?.fontFamily || this.state.currentItemFontFamily; existingTextElement?.fontFamily || this.state.currentItemFontFamily;
@@ -6062,11 +6219,32 @@ class App extends React.Component<AppProps, AppState> {
y: sceneY, y: sceneY,
}); });
const textCreationGridPoint = this.getTextCreationGridPoint(sceneX, sceneY);
const newTextElementPosition = parentCenterPosition
? {
x: parentCenterPosition.elementCenterX,
y: parentCenterPosition.elementCenterY,
}
: !existingTextElement
? {
x: textCreationGridPoint?.x ?? sceneX,
y:
textCreationGridPoint === null
? // Free text starts from a point cursor, so center the first line box on it.
sceneY - getLineHeightInPx(fontSize, lineHeight) / 2
: textCreationGridPoint.y,
}
: {
x: sceneX,
y: sceneY,
};
const element = const element =
existingTextElement || existingTextElement ||
newTextElement({ newTextElement({
x: parentCenterPosition ? parentCenterPosition.elementCenterX : sceneX, x: newTextElementPosition.x,
y: parentCenterPosition ? parentCenterPosition.elementCenterY : sceneY, y: newTextElementPosition.y,
strokeColor: this.state.currentItemStrokeColor, strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor, backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle, fillStyle: this.state.currentItemFillStyle,
@@ -6116,6 +6294,9 @@ class App extends React.Component<AppProps, AppState> {
if (autoEdit || existingTextElement || container) { if (autoEdit || existingTextElement || container) {
this.handleTextWysiwyg(element, { this.handleTextWysiwyg(element, {
isExistingElement: !!existingTextElement, isExistingElement: !!existingTextElement,
initialCaretSceneCoords: existingTextElement
? initialCaretSceneCoords
: null,
}); });
} else { } else {
this.setState({ this.setState({
@@ -6141,9 +6322,48 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private shouldHandleBrowserCanvasDoubleClick = (type: string) => {
// TODO remove this once we consolidate double-click logic and handle
// ourselves for all event types together
if (type === "touch") {
return true;
}
if (this.lastCompletedCanvasClicks.length === 0) {
return true;
}
if (this.lastCompletedCanvasClicks.length < 2) {
return false;
}
const [firstClick, secondClick] = this.lastCompletedCanvasClicks;
return (
pointDistance(
pointFrom(firstClick.x, firstClick.y),
pointFrom(secondClick.x, secondClick.y),
) <= DOUBLE_TAP_POSITION_THRESHOLD
);
};
private handleCanvasDoubleClick = ( private handleCanvasDoubleClick = (
event: React.MouseEvent<HTMLCanvasElement>, event: Pick<
React.MouseEvent<HTMLCanvasElement>,
| "type"
| "clientX"
| "clientY"
| "altKey"
| "ctrlKey"
| "metaKey"
| "shiftKey"
>,
) => { ) => {
if (
this.state.editingTextElement ||
!this.shouldHandleBrowserCanvasDoubleClick(event.type)
) {
return;
}
// case: double-clicking with arrow/line tool selected would both create // case: double-clicking with arrow/line tool selected would both create
// text and enter multiElement mode // text and enter multiElement mode
if (this.state.multiElement) { if (this.state.multiElement) {
@@ -6331,6 +6551,21 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
if (event.button !== POINTER_BUTTON.MAIN) {
this.lastCompletedCanvasClicks = [];
return;
}
this.lastCompletedCanvasClicks = [
...this.lastCompletedCanvasClicks.slice(-1),
{
x: event.clientX,
y: event.clientY,
},
];
};
private getElementLinkAtPosition = ( private getElementLinkAtPosition = (
scenePointer: Readonly<{ x: number; y: number }>, scenePointer: Readonly<{ x: number; y: number }>,
hitElementMightBeLocked: NonDeletedExcalidrawElement | null, hitElementMightBeLocked: NonDeletedExcalidrawElement | null,
@@ -6761,27 +6996,23 @@ class App extends React.Component<AppProps, AppState> {
}, },
{ informMutation: false, isDragging: false }, { informMutation: false, isDragging: false },
); );
const newLastIdx = multiElement.points.length - 1;
this.setState({ this.setState({
selectedLinearElement: { selectedLinearElement: {
...selectedLinearElement, ...selectedLinearElement,
selectedPointsIndices: selectedPointsIndices: selectedLinearElement.selectedPointsIndices
selectedLinearElement.selectedPointsIndices?.includes( ? [
multiElement.points.length, ...new Set(
) selectedLinearElement.selectedPointsIndices.map((idx) =>
? [ Math.min(idx, newLastIdx),
...selectedLinearElement.selectedPointsIndices.filter(
(idx) =>
idx !== multiElement.points.length &&
idx !== multiElement.points.length - 1,
), ),
multiElement.points.length - 1, ),
] ]
: selectedLinearElement.selectedPointsIndices, : selectedLinearElement.selectedPointsIndices,
lastCommittedPoint: lastCommittedPoint: multiElement.points[newLastIdx],
multiElement.points[multiElement.points.length - 1],
initialState: { initialState: {
...selectedLinearElement.initialState, ...selectedLinearElement.initialState,
lastClickedPoint: multiElement.points.length - 1, lastClickedPoint: newLastIdx,
}, },
}, },
}); });
@@ -6867,6 +7098,12 @@ class App extends React.Component<AppProps, AppState> {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
if (this.isHittingTextAutoResizeHandle(selectedElements, scenePointer)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
return;
}
if ( if (
selectedElements.length === 1 && selectedElements.length === 1 &&
!isOverScrollBar && !isOverScrollBar &&
@@ -7003,6 +7240,14 @@ class App extends React.Component<AppProps, AppState> {
this.interactiveCanvas, this.interactiveCanvas,
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR, 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) { } else if (this.state.viewModeEnabled) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (this.state.openDialog?.name === "elementLinkSelector") { } else if (this.state.openDialog?.name === "elementLinkSelector") {
@@ -7011,7 +7256,9 @@ class App extends React.Component<AppProps, AppState> {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} else if ( } else if (
// if using cmd/ctrl, we're not dragging // if using cmd/ctrl, we're not dragging
!event[KEYS.CTRL_OR_CMD] !event[KEYS.CTRL_OR_CMD] &&
// editing text -> don't show move cursor when hovering over its bbox
hitElement?.id !== this.state.editingTextElement?.id
) { ) {
if ( if (
(hitElement || (hitElement ||
@@ -7232,6 +7479,8 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = ( private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
) => { ) => {
const selectedElements = this.scene.getSelectedElements(this.state);
// If Ctrl is not held, ensure isBindingEnabled reflects the user preference. // If Ctrl is not held, ensure isBindingEnabled reflects the user preference.
if (!event.ctrlKey) { if (!event.ctrlKey) {
const preferenceEnabled = this.state.bindingPreference === "enabled"; const preferenceEnabled = this.state.bindingPreference === "enabled";
@@ -7455,6 +7704,15 @@ class App extends React.Component<AppProps, AppState> {
selectedElementsAreBeingDragged: false, selectedElementsAreBeingDragged: false,
}); });
if (
this.handleTextAutoResizeHandlePointerDown(
selectedElements,
pointerDownState.origin,
)
) {
return;
}
if (this.handleDraggingScrollBar(event, pointerDownState)) { if (this.handleDraggingScrollBar(event, pointerDownState)) {
return; return;
} }
@@ -7481,17 +7739,24 @@ class App extends React.Component<AppProps, AppState> {
const hitSelectedElement = const hitSelectedElement =
pointerDownState.hit.element && pointerDownState.hit.element &&
this.isASelectedElement(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 ( if (shouldStartLassoSelection) {
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && if (!this.lassoTrail.hasCurrentTrail) {
!pointerDownState.resize.handleType && this.lassoTrail.startPath(
!hitSelectedElement pointerDownState.origin.x,
) { pointerDownState.origin.y,
this.lassoTrail.startPath( event.shiftKey,
pointerDownState.origin.x, );
pointerDownState.origin.y, }
event.shiftKey,
);
// block dragging after lasso selection on PCs until the next pointer down // block dragging after lasso selection on PCs until the next pointer down
// (on mobile or tablet, we want to allow user to drag immediately) // (on mobile or tablet, we want to allow user to drag immediately)
@@ -7680,6 +7945,10 @@ class App extends React.Component<AppProps, AppState> {
} }
this.removePointer(event); this.removePointer(event);
this.lastPointerUpIsDoubleClick = this.isDoubleClick(
this.lastPointerUpEvent,
event,
);
this.lastPointerUpEvent = event; this.lastPointerUpEvent = event;
if (!event.ctrlKey) { if (!event.ctrlKey) {
@@ -8476,12 +8745,14 @@ class App extends React.Component<AppProps, AppState> {
DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value, DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value,
1, 1,
); );
const boundsPadding =
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / this.state.zoom.value;
const [x1, y1, x2, y2] = getCommonBounds(selectedElements); const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
return ( return (
point.x > x1 - threshold && point.x > x1 - boundsPadding - threshold &&
point.x < x2 + threshold && point.x < x2 + boundsPadding + threshold &&
point.y > y1 - threshold && point.y > y1 - boundsPadding - threshold &&
point.y < y2 + threshold point.y < y2 + boundsPadding + threshold
); );
} }
@@ -8516,6 +8787,7 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter: !event.altKey, insertAtParentCenter: !event.altKey,
container, container,
autoEdit: false, autoEdit: false,
initialCaretSceneCoords: { x: sceneX, y: sceneY },
}); });
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
@@ -10013,6 +10285,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectionElement, this.state.selectionElement,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
false, false,
this.state.boxSelectionMode,
) )
: []; : [];
@@ -11001,6 +11274,35 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const selectedTextEditingContainer =
this.getSelectedTextEditingContainerAtPosition(hitElement, sceneCoords);
if (
activeTool.type === this.state.preferredSelectionTool.type &&
!this.state.editingTextElement &&
!pointerDownState.drag.hasOccurred &&
!pointerDownState.hit.wasAddedToSelection &&
!childEvent.shiftKey &&
!childEvent[KEYS.CTRL_OR_CMD] &&
!childEvent.altKey &&
childEvent.pointerType !== "touch" &&
hitElement &&
((isTextElement(hitElement) &&
this.state.selectedElementIds[hitElement.id] &&
this.scene.getSelectedElements(this.state).length === 1) ||
selectedTextEditingContainer)
) {
this.startTextEditing({
sceneX: sceneCoords.x,
sceneY: sceneCoords.y,
container: selectedTextEditingContainer,
initialCaretSceneCoords: this.lastPointerUpIsDoubleClick
? undefined
: sceneCoords,
});
return;
}
if (!activeTool.locked && activeTool.type !== "freedraw" && newElement) { if (!activeTool.locked && activeTool.type !== "freedraw" && newElement) {
this.setState((prevState) => ({ this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
@@ -46,6 +46,7 @@ import {
import { fontPickerKeyHandler } from "./keyboardNavHandlers"; import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import type { JSX } from "react"; import type { JSX } from "react";
import type { ExcalidrawFontFace } from "../../fonts/ExcalidrawFontFace";
export interface FontDescriptor { export interface FontDescriptor {
value: number; 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( export const FontPickerList = React.memo(
({ ({
selectedFontFamily, selectedFontFamily,
@@ -114,7 +124,7 @@ export const FontPickerList = React.memo(
const fontDescriptor = { const fontDescriptor = {
value: familyId, value: familyId,
icon: getFontFamilyIcon(familyId), icon: getFontFamilyIcon(familyId),
text: fontFaces[0]?.fontFace?.family ?? "Unknown", text: getFontFamilyLabel(familyId, fontFaces),
}; };
if (metadata.deprecated) { if (metadata.deprecated) {
@@ -26,13 +26,16 @@
background: var(--RadioGroup-background); background: var(--RadioGroup-background);
border: 1px solid var(--RadioGroup-border); border: 1px solid var(--RadioGroup-border);
gap: 2px;
&__choice { &__choice {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; min-width: 20px;
height: 24px; height: 24px;
padding: 0 0.375rem;
color: var(--RadioGroup-choice-color-off); color: var(--RadioGroup-choice-color-off);
background: var(--RadioGroup-choice-background-off); background: var(--RadioGroup-choice-background-off);
+37 -33
View File
@@ -1,74 +1,78 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { t } from "../i18n";
import "./Range.scss"; import "./Range.scss";
import type { AppClassProperties } from "../types";
export type RangeProps = { export type RangeProps = {
updateData: (value: number) => void; label: React.ReactNode;
app: AppClassProperties; value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
minLabel?: React.ReactNode;
hasCommonValue?: boolean;
testId?: string; testId?: string;
}; };
export const Range = ({ updateData, app, testId }: RangeProps) => { export const Range = ({
label,
value,
onChange,
min = 0,
max = 100,
step = 10,
minLabel = min,
hasCommonValue = true,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null); const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null); const valueRef = React.useRef<HTMLDivElement>(null);
const selectedElements = app.scene.getSelectedElements(app.state);
let hasCommonOpacity = true;
const firstElement = selectedElements.at(0);
const leastCommonOpacity = selectedElements.reduce((acc, element) => {
if (acc != null && acc !== element.opacity) {
hasCommonOpacity = false;
}
if (acc == null || acc > element.opacity) {
return element.opacity;
}
return acc;
}, firstElement?.opacity ?? null);
const value = leastCommonOpacity ?? app.state.currentItemOpacity;
useEffect(() => { useEffect(() => {
if (rangeRef.current && valueRef.current) { if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current; const rangeElement = rangeRef.current;
const valueElement = valueRef.current; const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth; const inputWidth = rangeElement.offsetWidth;
const thumbWidth = 15; // 15 is the width of the thumb const thumbWidth =
parseFloat(
getComputedStyle(rangeElement).getPropertyValue(
"--slider-thumb-size",
),
) || 16;
const progress = ((value - min) / (max - min || 1)) * 100;
const position = const position =
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2; (progress / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`; valueElement.style.left = `${position}px`;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`; rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${progress}%, var(--button-bg) ${progress}%, var(--button-bg) 100%)`;
} }
}, [value]); }, [max, min, value]);
return ( return (
<label className="control-label"> <label className="control-label">
{t("labels.opacity")} {label}
<div className="range-wrapper"> <div className="range-wrapper">
<input <input
style={{ style={{
["--color-slider-track" as string]: hasCommonOpacity ["--color-slider-track" as string]: hasCommonValue
? undefined ? undefined
: "var(--button-bg)", : "var(--button-bg)",
}} }}
ref={rangeRef} ref={rangeRef}
type="range" type="range"
min="0" min={min}
max="100" max={max}
step="10" step={step}
onChange={(event) => { onChange={(event) => {
updateData(+event.target.value); onChange(+event.target.value);
}} }}
value={value} value={value}
className="range-input" className="range-input"
data-testid={testId} data-testid={testId}
/> />
<div className="value-bubble" ref={valueRef}> <div className="value-bubble" ref={valueRef}>
{value !== 0 ? value : null} {value !== min ? value : null}
</div> </div>
<div className="zero-label">0</div> <div className="zero-label">{minLabel}</div>
</div> </div>
</label> </label>
); );
@@ -361,12 +361,10 @@ describe("stats for a non-generic element", () => {
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const editor = await getTextEditor(); const editor = await getTextEditor();
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
act(() => { Keyboard.exitTextEditor(editor);
editor.blur();
});
const text = h.elements[0] as ExcalidrawTextElement; const text = h.elements[0] as ExcalidrawTextElement;
mouse.clickOn(text); API.setSelectedElements([text]);
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
@@ -54,6 +54,7 @@ type InteractiveCanvasProps = {
DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"], DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"],
undefined undefined
>; >;
onClick: Exclude<DOMAttributes<HTMLCanvasElement>["onClick"], undefined>;
onPointerMove: Exclude< onPointerMove: Exclude<
DOMAttributes<HTMLCanvasElement>["onPointerMove"], DOMAttributes<HTMLCanvasElement>["onPointerMove"],
undefined undefined
@@ -213,6 +214,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
height={props.appState.height * props.scale} height={props.appState.height * props.scale}
ref={props.handleCanvasRef} ref={props.handleCanvasRef}
onContextMenu={props.onContextMenu} onContextMenu={props.onContextMenu}
onClick={props.onClick}
onPointerMove={props.onPointerMove} onPointerMove={props.onPointerMove}
onPointerUp={props.onPointerUp} onPointerUp={props.onPointerUp}
onPointerCancel={props.onPointerCancel} onPointerCancel={props.onPointerCancel}
@@ -2,7 +2,7 @@
.excalidraw { .excalidraw {
.dropdown-menu { .dropdown-menu {
max-width: 16rem; max-width: 20rem;
z-index: 1; z-index: 1;
&--placement-top { &--placement-top {
@@ -1,4 +1,5 @@
import { useEditorInterface } from "../App"; import { useEditorInterface } from "../App";
import { Ellipsify } from "../Ellipsify";
import { RadioGroup } from "../RadioGroup"; import { RadioGroup } from "../RadioGroup";
type Props<T> = { type Props<T> = {
@@ -12,6 +13,7 @@ type Props<T> = {
onChange: (value: T) => void; onChange: (value: T) => void;
children: React.ReactNode; children: React.ReactNode;
name: string; name: string;
icon?: React.ReactNode;
}; };
const DropdownMenuItemContentRadio = <T,>({ const DropdownMenuItemContentRadio = <T,>({
@@ -21,13 +23,17 @@ const DropdownMenuItemContentRadio = <T,>({
choices, choices,
children, children,
name, name,
icon,
}: Props<T>) => { }: Props<T>) => {
const editorInterface = useEditorInterface(); const editorInterface = useEditorInterface();
return ( return (
<> <>
<div className="dropdown-menu-item-base dropdown-menu-item-bare"> <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 <RadioGroup
name={name} name={name}
value={value} value={value}
@@ -39,7 +39,13 @@ import DropdownMenuItemCheckbox from "../dropdownMenu/DropdownMenuItemCheckbox";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio"; import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink"; import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub"; import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons"; import {
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
emptyIcon,
} from "../icons";
import { import {
boltIcon, boltIcon,
DeviceDesktopIcon, 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 PreferencesToggleSnapModeItem = () => {
const { t } = useI18n(); const { t } = useI18n();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
@@ -568,6 +608,7 @@ export const Preferences = ({
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu"> <DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
{children || ( {children || (
<> <>
<PreferencesBoxSelectionModeItem />
<PreferencesToggleToolLockItem /> <PreferencesToggleToolLockItem />
<PreferencesToggleSnapModeItem /> <PreferencesToggleSnapModeItem />
<PreferencesToggleGridModeItem /> <PreferencesToggleGridModeItem />
@@ -585,6 +626,7 @@ export const Preferences = ({
}; };
Preferences.ToggleToolLock = PreferencesToggleToolLockItem; Preferences.ToggleToolLock = PreferencesToggleToolLockItem;
Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem;
Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem; Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem;
Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem; Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem;
Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem; Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem;
-4
View File
@@ -20,10 +20,6 @@ export const resaveAsImageWithScene = async (
) => { ) => {
const fileHandleType = getFileHandleType(fileHandle); const fileHandleType = getFileHandleType(fileHandle);
if (Math.random() < 1) {
throw new Error("OLALALALA");
}
if (!isImageFileHandleType(fileHandleType)) { if (!isImageFileHandleType(fileHandleType)) {
throw new Error( throw new Error(
"fileHandle should exist and should be of type svg or png when resaving", "fileHandle should exist and should be of type svg or png when resaving",
+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) { } catch (error) {
console.error("Error repairing binding:", error); console.error("Error repairing binding:", error);
} }
@@ -934,6 +936,12 @@ export const restoreAppState = (
: defaultValue; : defaultValue;
} }
const boxSelectionMode =
appState.boxSelectionMode ?? localAppState?.boxSelectionMode;
if (boxSelectionMode !== undefined) {
nextAppState.boxSelectionMode = boxSelectionMode;
}
return { return {
...nextAppState, ...nextAppState,
cursorButton: localAppState?.cursorButton || "up", cursorButton: localAppState?.cursorButton || "up",
+2 -2
View File
@@ -2,7 +2,7 @@ import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import { import {
computeBoundTextPosition, computeBoundTextPosition,
doBoundsIntersect, doNonRotatedBoundsIntersect,
getBoundTextElement, getBoundTextElement,
getElementBounds, getElementBounds,
getElementLineSegments, getElementLineSegments,
@@ -219,7 +219,7 @@ const eraserTest = (
origElementBounds[3] + threshold, origElementBounds[3] + threshold,
]; ];
if (!doBoundsIntersect(segmentBounds, elementBounds)) { if (!doNonRotatedBoundsIntersect(segmentBounds, elementBounds)) {
return false; return false;
} }
+2
View File
@@ -382,6 +382,8 @@ export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToC
export { getDataURL } from "./data/blob"; export { getDataURL } from "./data/blob";
export { isElementLink } from "@excalidraw/element"; export { isElementLink } from "@excalidraw/element";
export { Fonts } from "./fonts/Fonts";
export { setCustomTextMetricsProvider } from "@excalidraw/element"; export { setCustomTextMetricsProvider } from "@excalidraw/element";
export { CommandPalette } from "./components/CommandPalette/CommandPalette"; export { CommandPalette } from "./components/CommandPalette/CommandPalette";
+2 -2
View File
@@ -10,7 +10,7 @@ import { type Bounds } from "@excalidraw/common";
import { import {
computeBoundTextPosition, computeBoundTextPosition,
doBoundsIntersect, doNonRotatedBoundsIntersect,
getBoundTextElement, getBoundTextElement,
getElementBounds, getElementBounds,
intersectElementWithLineSegment, intersectElementWithLineSegment,
@@ -66,7 +66,7 @@ export const getLassoSelectedElementIds = (input: {
const elementBounds = getElementBounds(element, elementsMap); const elementBounds = getElementBounds(element, elementsMap);
if ( if (
doBoundsIntersect(lassoBounds, elementBounds) && doNonRotatedBoundsIntersect(lassoBounds, elementBounds) &&
!intersectedElements.has(element.id) && !intersectedElements.has(element.id) &&
!enclosedElements.has(element.id) !enclosedElements.has(element.id)
) { ) {
+4 -1
View File
@@ -185,6 +185,9 @@
"shapeSwitch": "Switch shape", "shapeSwitch": "Switch shape",
"preferences": "Preferences", "preferences": "Preferences",
"preferences_toolLock": "Tool lock", "preferences_toolLock": "Tool lock",
"boxSelectionMode": "Select on",
"boxSelectionContain": "Wrap",
"boxSelectionOverlap": "Overlap",
"arrowBinding": "Arrow binding", "arrowBinding": "Arrow binding",
"midpointSnapping": "Snap to midpoints" "midpointSnapping": "Snap to midpoints"
}, },
@@ -661,7 +664,7 @@
"placeholder": { "placeholder": {
"title": "Let's design your diagram", "title": "Let's design your diagram",
"description": "Describe the diagram you want to create, and we'll generate it for you.", "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", "preview": "Preview",
"insert": "Insert", "insert": "Insert",
+1 -1
View File
@@ -88,7 +88,7 @@
"@excalidraw/element": "0.18.0", "@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1", "@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0", "@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", "@excalidraw/random-username": "1.1.0",
"browser-fs-access": "0.38.0", "browser-fs-access": "0.38.0",
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
@@ -41,6 +41,7 @@ import {
maxBindingDistance_simple, maxBindingDistance_simple,
isTextElement, isTextElement,
LinearElementEditor, LinearElementEditor,
getActiveTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { renderSelectionElement } from "@excalidraw/element"; import { renderSelectionElement } from "@excalidraw/element";
@@ -58,6 +59,8 @@ import {
isFocusPointVisible, isFocusPointVisible,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { EditorInterface } from "@excalidraw/common";
import type { import type {
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
@@ -86,6 +89,10 @@ import {
} from "../scene/scrollbars"; } from "../scene/scrollbars";
import { getClientColor, renderRemoteCursors } from "../clients"; import { getClientColor, renderRemoteCursors } from "../clients";
import {
getTextAutoResizeHandle,
getTextBoxPadding,
} from "../textAutoResizeHandle";
import { import {
bootstrapCanvas, bootstrapCanvas,
@@ -1149,6 +1156,7 @@ const renderLinearPointHandles = (
points[idx], points[idx],
idx, idx,
appState.zoom, appState.zoom,
elementsMap,
) )
) { ) {
renderSingleLinearPoint( renderSingleLinearPoint(
@@ -1489,21 +1497,58 @@ const renderTextBox = (
selectionColor: InteractiveCanvasRenderConfig["selectionColor"], selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => { ) => {
context.save(); context.save();
const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; const padding = getTextBoxPadding(appState.zoom.value);
const width = text.width + padding * 2; const width = text.width + padding * 2;
const height = text.height + padding * 2; const height = text.height + padding * 2;
const cx = text.x + width / 2; const cx = text.x + text.width / 2;
const cy = text.y + height / 2; const cy = text.y + text.height / 2;
const shiftX = -(width / 2 + padding); const shiftX = -(text.width / 2 + padding);
const shiftY = -(height / 2 + padding); const shiftY = -(text.height / 2 + padding);
context.translate(cx + appState.scrollX, cy + appState.scrollY); context.translate(cx + appState.scrollX, cy + appState.scrollY);
context.rotate(text.angle); context.rotate(text.angle);
context.lineWidth = 1 / appState.zoom.value; context.lineWidth = 1 / appState.zoom.value;
context.strokeStyle = selectionColor; context.strokeStyle = selectionColor;
context.globalAlpha = 0.5;
context.setLineDash([6 / appState.zoom.value, 4 / appState.zoom.value]);
context.strokeRect(shiftX, shiftY, width, height); context.strokeRect(shiftX, shiftY, width, height);
context.restore(); context.restore();
}; };
const renderResetAutoResizeHandle = (
text: NonDeleted<ExcalidrawTextElement>,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
formFactor: EditorInterface["formFactor"],
) => {
const autoResizeHandle = getTextAutoResizeHandle(
text,
appState.zoom.value,
formFactor,
);
if (!autoResizeHandle) {
return;
}
context.save();
context.globalAlpha = 0.5;
context.lineWidth = 1.5 / appState.zoom.value;
context.lineCap = "round";
context.strokeStyle = selectionColor;
context.beginPath();
context.moveTo(
autoResizeHandle.start[0] + appState.scrollX,
autoResizeHandle.start[1] + appState.scrollY,
);
context.lineTo(
autoResizeHandle.end[0] + appState.scrollX,
autoResizeHandle.end[1] + appState.scrollY,
);
context.stroke();
context.restore();
};
const _renderInteractiveScene = ({ const _renderInteractiveScene = ({
app, app,
canvas, canvas,
@@ -1584,10 +1629,19 @@ const _renderInteractiveScene = ({
} }
} }
if ( const activeTextElement = getActiveTextElement(selectedElements, appState);
appState.editingTextElement &&
isTextElement(appState.editingTextElement) if (activeTextElement && !activeTextElement.autoResize) {
) { renderResetAutoResizeHandle(
activeTextElement,
context,
appState,
renderConfig.selectionColor,
editorInterface.formFactor,
);
}
if (appState.editingTextElement) {
const textElement = allElementsMap.get(appState.editingTextElement.id) as const textElement = allElementsMap.get(appState.editingTextElement.id) as
| ExcalidrawTextElement | ExcalidrawTextElement
| undefined; | undefined;
@@ -4358,7 +4358,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 760410951, "versionNonce": 1006504105,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4383,14 +4383,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 238820263, "seed": 400692809,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 1006504105, "versionNonce": 289600103,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -6864,7 +6864,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 747212839, "versionNonce": 1723083209,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -6891,14 +6891,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 238820263, "seed": 400692809,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 1723083209, "versionNonce": 760410951,
"width": 10, "width": 10,
"x": 12, "x": 12,
"y": 0, "y": 0,
@@ -224,7 +224,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 29, "version": 22,
"width": "94.00000", "width": "94.00000",
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -350,8 +350,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 28, "version": 21,
"width": "88.00000", "width": "88.00000",
"y": "7.20923",
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@@ -381,8 +382,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 25, "version": 20,
"width": "88.00000", "width": "88.00000",
"y": "0.01000",
}, },
}, },
}, },
@@ -437,7 +439,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
], ],
"startBinding": null, "startBinding": null,
"version": 29, "version": 22,
"width": "94.00000", "width": "94.00000",
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -462,7 +464,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 28, "version": 21,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "7.20923", "y": "7.20923",
@@ -1360,9 +1362,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 8, "version": 7,
"width": 88, "width": 88,
"x": 6, "x": "6.00000",
"y": "2.00947", "y": "2.00947",
} }
`; `;
@@ -1537,12 +1539,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 8, "version": 7,
}, },
"inserted": { "inserted": {
"endBinding": null, "endBinding": null,
"startBinding": null, "startBinding": null,
"version": 7, "version": 6,
}, },
}, },
}, },
@@ -1722,7 +1724,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"updated": 1, "updated": 1,
"version": 8, "version": 8,
"width": 88, "width": 88,
"x": 6, "x": "6.00000",
"y": "38.80379", "y": "38.80379",
} }
`; `;
@@ -1867,7 +1869,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"version": 8, "version": 8,
"width": 88, "width": 88,
"x": 6, "x": "6.00000",
"y": "38.80379", "y": "38.80379",
}, },
"inserted": { "inserted": {
@@ -2416,7 +2418,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 11,
"width": 488, "width": 488,
"x": 6, "x": 6,
"y": "-5.39000", "y": "-5.39000",
@@ -2581,7 +2583,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 12, "version": 11,
"width": 488, "width": 488,
"x": 6, "x": 6,
"y": "-5.39000", "y": "-5.39000",
@@ -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 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`] = ` exports[`history > multiplayer undo/redo > should update history entries after remote changes on the same properties > [end of test] appState 1`] = `
{ {
"activeEmbeddable": null, "activeEmbeddable": null,
@@ -16636,7 +17121,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "0.00120", "height": 0,
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -16649,7 +17134,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
[ [
"88.00000", "88.00000",
"0.00120", 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -16672,7 +17157,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 7, "version": 7,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00880", "y": "0.01000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -18650,7 +19135,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "0.00120", "height": 0,
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -18663,7 +19148,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
[ [
"88.00000", "88.00000",
"0.00120", 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -18686,7 +19171,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 7, "version": 7,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00880", "y": "0.01000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -19398,7 +19883,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "0.00120", "height": 0,
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -19411,7 +19896,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
[ [
"88.00000", "88.00000",
"0.00120", 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -19434,7 +19919,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 7, "version": 7,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00880", "y": "0.01000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
+12 -2
View File
@@ -4,6 +4,7 @@ import {
elementCenterPoint, elementCenterPoint,
getCommonBounds, getCommonBounds,
getElementPointsCoords, getElementPointsCoords,
getLineHeightInPx,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { cropElement } from "@excalidraw/element"; import { cropElement } from "@excalidraw/element";
import { import {
@@ -20,7 +21,7 @@ import {
isTextElement, isTextElement,
isFrameLikeElement, isFrameLikeElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, arrayToMap } from "@excalidraw/common"; import { KEYS, arrayToMap, getLineHeight } from "@excalidraw/common";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -516,8 +517,17 @@ export class UI {
UI.clickTool(type); UI.clickTool(type);
if (type === "text") { if (type === "text") {
const clickY = h.state.gridModeEnabled
? y
: y +
getLineHeightInPx(
h.state.currentItemFontSize,
getLineHeight(h.state.currentItemFontFamily),
) /
2;
mouse.reset(); mouse.reset();
mouse.click(x, y); mouse.click(x, clickY);
} else if ((type === "line" || type === "arrow") && points.length > 2) { } else if ((type === "line" || type === "arrow") && points.length > 2) {
points.forEach((point) => { points.forEach((point) => {
mouse.reset(); mouse.reset();
@@ -2971,6 +2971,82 @@ describe("history", () => {
expect(h.state.editingGroupId).toBeNull(); 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 () => { it("should iterate through the history when selected or editing linear element was remotely deleted", async () => {
// create three point arrow // create three point arrow
UI.clickTool("arrow"); UI.clickTool("arrow");
+568 -6
View File
@@ -1,7 +1,9 @@
import React from "react"; import React from "react";
import { vi } from "vitest"; 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"; import { SHAPES } from "../components/shapes";
@@ -12,6 +14,7 @@ import * as StaticScene from "../renderer/staticScene";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { import {
act,
render, render,
fireEvent, fireEvent,
mockBoundingClientRect, mockBoundingClientRect,
@@ -39,6 +42,19 @@ const { h } = window;
const mouse = new Pointer("mouse"); 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", () => { describe("box-selection", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<Excalidraw />); await render(<Excalidraw />);
@@ -108,6 +124,497 @@ describe("box-selection", () => {
assertSelectedElements([]); 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", () => { describe("inner box-selection", () => {
@@ -326,7 +833,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(6); expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -359,7 +866,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(6); expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -392,7 +899,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(6); expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -438,7 +945,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10); expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(8); expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -483,7 +990,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10); expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(8); expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -558,3 +1065,58 @@ describe("selectedElementIds stability", () => {
expect(h.state.selectedElementIds).toBe(selectedElementIds_2); 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({});
});
});
@@ -0,0 +1,88 @@
import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "@excalidraw/common";
import {
pointFrom,
pointRotateRads,
type GlobalPoint,
type Radians,
} from "@excalidraw/math";
import type { EditorInterface } from "@excalidraw/common";
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
const TEXT_AUTO_RESIZE_HANDLE_GAP = 12;
const TEXT_AUTO_RESIZE_HANDLE_LENGTH = 16;
const TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH = 10;
const TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT =
TEXT_AUTO_RESIZE_HANDLE_LENGTH + 2;
const MAX_HANDLE_HEIGHT_RATIO = 0.8;
export const getTextBoxPadding = (zoomValue: number) =>
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / zoomValue;
export const getTextAutoResizeHandle = (
textElement: ExcalidrawTextElement,
zoomValue: number,
formFactor: EditorInterface["formFactor"],
) => {
if (
formFactor !== "desktop" ||
TEXT_AUTO_RESIZE_HANDLE_LENGTH >
textElement.height * zoomValue * MAX_HANDLE_HEIGHT_RATIO
) {
return null;
}
const padding = getTextBoxPadding(zoomValue);
const gap = TEXT_AUTO_RESIZE_HANDLE_GAP / zoomValue;
const length = TEXT_AUTO_RESIZE_HANDLE_LENGTH / zoomValue;
const center = pointFrom(
textElement.x + textElement.width / 2,
textElement.y + textElement.height / 2,
);
const handleCenter = pointRotateRads(
pointFrom(center[0] + textElement.width / 2 + padding + gap, center[1]),
center,
textElement.angle,
);
return {
center: handleCenter,
start: pointRotateRads(
pointFrom(handleCenter[0], handleCenter[1] - length / 2),
handleCenter,
textElement.angle,
) as GlobalPoint,
end: pointRotateRads(
pointFrom(handleCenter[0], handleCenter[1] + length / 2),
handleCenter,
textElement.angle,
) as GlobalPoint,
hitboxWidth: TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH / zoomValue,
hitboxHeight: TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT / zoomValue,
};
};
export const isPointHittingTextAutoResizeHandle = (
point: Readonly<{ x: number; y: number }>,
textElement: ExcalidrawTextElement,
zoomValue: number,
formFactor: EditorInterface["formFactor"],
) => {
const handle = getTextAutoResizeHandle(textElement, zoomValue, formFactor);
if (!handle) {
return false;
}
const unrotatedPoint = pointRotateRads(
pointFrom(point.x, point.y),
handle.center,
-textElement.angle as Radians,
);
return (
Math.abs(unrotatedPoint[0] - handle.center[0]) <= handle.hitboxWidth / 2 &&
Math.abs(unrotatedPoint[1] - handle.center[1]) <= handle.hitboxHeight / 2
);
};
+8 -4
View File
@@ -32,6 +32,7 @@ import type {
OrderedExcalidrawElement, OrderedExcalidrawElement,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
BindMode, BindMode,
ExcalidrawTextElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@@ -268,6 +269,8 @@ export type ObservedElementsAppState = {
activeLockedId: AppState["activeLockedId"]; activeLockedId: AppState["activeLockedId"];
}; };
export type BoxSelectionMode = "contain" | "overlap";
export interface AppState { export interface AppState {
contextMenu: { contextMenu: {
items: ContextMenuItems; items: ContextMenuItems;
@@ -306,6 +309,8 @@ export interface AppState {
* `bindingPreference` and keyboard modifiers (ctrl/alt) * `bindingPreference` and keyboard modifiers (ctrl/alt)
*/ */
isBindingEnabled: boolean; isBindingEnabled: boolean;
/** user box selection preference; defaults to "contain" when unset */
boxSelectionMode?: BoxSelectionMode;
/** user arrow binding preference */ /** user arrow binding preference */
bindingPreference: "enabled" | "disabled"; bindingPreference: "enabled" | "disabled";
/** user preference whether arrow snap to midpoints while binding */ /** user preference whether arrow snap to midpoints while binding */
@@ -327,7 +332,7 @@ export interface AppState {
/** /**
* set when a new text is created or when an existing text is being edited * set when a new text is created or when an existing text is being edited
*/ */
editingTextElement: NonDeletedExcalidrawElement | null; editingTextElement: ExcalidrawTextElement | null;
activeTool: { activeTool: {
/** /**
* indicates a previous tool we should revert back to if we deselect the * indicates a previous tool we should revert back to if we deselect the
@@ -876,9 +881,8 @@ export type PointerDownState = Readonly<{
// by default same as PointerDownState.origin. On alt-duplication, reset // by default same as PointerDownState.origin. On alt-duplication, reset
// to current pointer position at time of duplication. // to current pointer position at time of duplication.
origin: { x: number; y: number }; origin: { x: number; y: number };
// Whether to block drag after lasso selection // explicit flag for specific scenarios such as:
// this is meant to be used to block dragging after lasso selection on PCs // - after lasso selection until the next pointer down
// until the next pointer down
blockDragging: boolean; blockDragging: boolean;
}; };
// We need to have these in the state so that we can unsubscribe them // We need to have these in the state so that we can unsubscribe them
@@ -1,7 +1,10 @@
import { queryByText } from "@testing-library/react"; import { queryByText } from "@testing-library/react";
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { getOriginalContainerHeightFromCache } from "@excalidraw/element"; import {
getLineHeightInPx,
getOriginalContainerHeightFromCache,
} from "@excalidraw/element";
import { import {
CODES, CODES,
@@ -42,6 +45,28 @@ unmountComponent();
const tab = " "; const tab = " ";
const mouse = new Pointer("mouse"); 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("textWysiwyg", () => {
describe("start text editing", () => { describe("start text editing", () => {
const { h } = window; const { h } = window;
@@ -210,6 +235,42 @@ describe("textWysiwyg", () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
}); });
it("should vertically center newly created text on the cursor when clicked with text tool", async () => {
API.setAppState({
currentItemFontFamily: FONT_FAMILY.Cascadia,
currentItemFontSize: 40,
});
UI.clickTool("text");
mouse.clickAt(120, 80);
const editor = await getTextEditor();
const text = h.elements[0] as ExcalidrawTextElement;
const lineHeightPx = getLineHeightInPx(text.fontSize, text.lineHeight);
expect(editor).not.toBe(null);
expect(text.y + lineHeightPx / 2).toBe(80);
});
it("should snap newly created text top-left to the current grid cell when clicked with text tool in grid mode", async () => {
API.setAppState({
currentItemFontFamily: FONT_FAMILY.Cascadia,
currentItemFontSize: 40,
gridModeEnabled: true,
gridSize: 24,
});
UI.clickTool("text");
mouse.clickAt(113, 86);
const editor = await getTextEditor();
const text = h.elements[0] as ExcalidrawTextElement;
expect(editor).not.toBe(null);
expect(text.x).toBe(96);
expect(text.y).toBe(72);
});
it("should edit text under cursor when double-clicked with selection tool", async () => { it("should edit text under cursor when double-clicked with selection tool", async () => {
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
@@ -232,6 +293,94 @@ describe("textWysiwyg", () => {
expect(h.elements.length).toBe(1); 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",
width: 160,
height: 70,
boundElements: [],
});
const text = API.createElement({
type: "text",
text: "Hello World!",
x: container.x + 20,
y: container.y + 20,
width: 120,
height: 25,
containerId: container.id,
});
API.setElements([container, text]);
API.updateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
API.setSelectedElements([container]);
UI.clickTool("selection");
mouse.clickAt(text.x + 26, text.y + 10);
const editor = await getTextEditor();
expect(editor).not.toBe(null);
});
it("should not edit selected bound text container when only the container was single-clicked", async () => {
const container = API.createElement({
type: "rectangle",
width: 160,
height: 70,
boundElements: [],
});
const text = API.createElement({
type: "text",
text: "Hello World!",
x: container.x + 20,
y: container.y + 20,
width: 120,
height: 25,
containerId: container.id,
});
API.setElements([container, text]);
API.updateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
API.setSelectedElements([container]);
UI.clickTool("selection");
mouse.clickAt(container.x + 5, container.y + 10);
expect(h.state.editingTextElement).toBe(null);
expect(await getTextEditor({ waitForEditor: false })).toBe(null);
});
// FIXME too flaky. No one knows why. // FIXME too flaky. No one knows why.
it.skip("should bump the version of a labeled arrow when the label is updated", async () => { it.skip("should bump the version of a labeled arrow when the label is updated", async () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
@@ -1205,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 () => { it("should restore original container height and clear cache once text is unbind", async () => {
const container = API.createElement({ const container = API.createElement({
type: "rectangle", type: "rectangle",
@@ -1511,7 +1694,7 @@ describe("textWysiwyg", () => {
version: 2, version: 2,
width: 610, width: 610,
x: 15, x: 15,
y: 25, y: 12.5,
}), }),
); );
expect(h.elements[2] as ExcalidrawTextElement).toEqual( expect(h.elements[2] as ExcalidrawTextElement).toEqual(
+203 -2
View File
@@ -10,7 +10,9 @@ import {
isTestEnv, isTestEnv,
MIME_TYPES, MIME_TYPES,
applyDarkModeFilter, applyDarkModeFilter,
isRTL,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import { import {
getTextFromElements, getTextFromElements,
@@ -33,8 +35,11 @@ import {
getBoundTextElement, getBoundTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { getTextWidth } from "@excalidraw/element"; import { getTextWidth } from "@excalidraw/element";
import { getLineHeightInPx } from "@excalidraw/element";
import { getLineWidth } from "@excalidraw/element";
import { normalizeText } from "@excalidraw/element"; import { normalizeText } from "@excalidraw/element";
import { wrapText } from "@excalidraw/element"; import { wrapText } from "@excalidraw/element";
import { getWrappedTextLines } from "@excalidraw/element";
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
@@ -91,6 +96,103 @@ const getTransform = (
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
}; };
const getLineDirection = (text: string, offset: number) => {
const hardLineStart = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
const hardLineEnd = text.indexOf("\n", offset);
const hardLineText = text.slice(
hardLineStart,
hardLineEnd === -1 ? text.length : hardLineEnd,
);
return isRTL(hardLineText) ? "rtl" : "ltr";
};
const getCaretBoundaryOffsets = (text: string) => {
const offsets = [0];
let offset = 0;
for (const char of Array.from(text)) {
offset += char.length;
offsets.push(offset);
}
return offsets;
};
const getLineCaretOffsetFromNativeLayout = ({
text,
font,
lineHeightPx,
direction,
targetX,
}: {
text: string;
font: ReturnType<typeof getFontString>;
lineHeightPx: number;
direction: "ltr" | "rtl";
targetX: number;
}) => {
if (!text || !document.body || typeof document.createRange !== "function") {
return null;
}
const offsets = getCaretBoundaryOffsets(text);
const mirror = document.createElement("div");
const textNode = document.createTextNode(text);
const range = document.createRange();
const positions: number[] = [];
mirror.dir = direction;
Object.assign(mirror.style, {
position: "fixed",
top: "0",
left: "0",
margin: 0,
padding: 0,
border: 0,
opacity: "0",
pointerEvents: "none",
whiteSpace: "pre",
font,
lineHeight: `${lineHeightPx}px`,
});
mirror.append(textNode);
document.body.append(mirror);
try {
for (const offset of offsets) {
range.setStart(textNode, offset);
range.setEnd(textNode, offset);
const caretRect = range.getBoundingClientRect();
if (!Number.isFinite(caretRect.left)) {
return null;
}
positions.push(caretRect.left);
}
} catch {
return null;
} finally {
mirror.remove();
}
const leftEdge = Math.min(...positions);
let closestOffset = offsets[0];
let closestDistance = Infinity;
for (let index = 0; index < offsets.length; index++) {
const distance = Math.abs(positions[index] - leftEdge - targetX);
if (distance < closestDistance) {
closestDistance = distance;
closestOffset = offsets[index];
}
}
return closestOffset;
};
type SubmitHandler = () => void; type SubmitHandler = () => void;
export const textWysiwyg = ({ export const textWysiwyg = ({
@@ -103,6 +205,7 @@ export const textWysiwyg = ({
excalidrawContainer, excalidrawContainer,
app, app,
autoSelect = true, autoSelect = true,
initialCaretSceneCoords = null,
}: { }: {
id: ExcalidrawElement["id"]; id: ExcalidrawElement["id"];
/** /**
@@ -119,7 +222,19 @@ export const textWysiwyg = ({
excalidrawContainer: HTMLDivElement | null; excalidrawContainer: HTMLDivElement | null;
app: App; app: App;
autoSelect?: boolean; autoSelect?: boolean;
initialCaretSceneCoords?: { x: number; y: number } | null;
}): SubmitHandler => { }): SubmitHandler => {
let currentTextLayout: {
angle: Radians;
font: ReturnType<typeof getFontString>;
height: number;
lineHeightPx: number;
textAlign: ExcalidrawTextElement["textAlign"];
width: number;
x: number;
y: number;
} | null = null;
const textPropertiesUpdated = ( const textPropertiesUpdated = (
updatedTextElement: ExcalidrawTextElement, updatedTextElement: ExcalidrawTextElement,
editable: HTMLTextAreaElement, editable: HTMLTextAreaElement,
@@ -254,6 +369,7 @@ export const textWysiwyg = ({
height *= 1.05; height *= 1.05;
const font = getFontString(updatedTextElement); const font = getFontString(updatedTextElement);
const angle = getTextElementAngle(updatedTextElement, container);
// Make sure text editor height doesn't go beyond viewport // Make sure text editor height doesn't go beyond viewport
const editorMaxHeight = const editorMaxHeight =
@@ -269,7 +385,7 @@ export const textWysiwyg = ({
transform: getTransform( transform: getTransform(
width, width,
height, height,
getTextElementAngle(updatedTextElement, container), angle,
appState, appState,
maxWidth, maxWidth,
editorMaxHeight, editorMaxHeight,
@@ -283,6 +399,19 @@ export const textWysiwyg = ({
opacity: updatedTextElement.opacity / 100, opacity: updatedTextElement.opacity / 100,
maxHeight: `${editorMaxHeight}px`, maxHeight: `${editorMaxHeight}px`,
}); });
currentTextLayout = {
angle: angle as Radians,
font,
height: updatedTextElement.height,
lineHeightPx: getLineHeightInPx(
updatedTextElement.fontSize,
updatedTextElement.lineHeight,
),
textAlign,
width: updatedTextElement.width,
x: coordX,
y: coordY,
};
editable.scrollTop = 0; editable.scrollTop = 0;
// For some reason updating font attribute doesn't set font family // For some reason updating font attribute doesn't set font family
// hence updating font family explicitly for test environment // hence updating font family explicitly for test environment
@@ -333,6 +462,71 @@ export const textWysiwyg = ({
editable.value = element.originalText; editable.value = element.originalText;
updateWysiwygStyle(); updateWysiwygStyle();
const getCaretIndexFromInitialSceneCoords = () => {
if (!initialCaretSceneCoords || !currentTextLayout) {
return null;
}
const layout = currentTextLayout;
const center = pointFrom(
layout.x + layout.width / 2,
layout.y + layout.height / 2,
);
const [unrotatedX, unrotatedY] = pointRotateRads(
pointFrom(initialCaretSceneCoords.x, initialCaretSceneCoords.y),
center,
-layout.angle as Radians,
);
const localX = unrotatedX - layout.x;
const localY = unrotatedY - layout.y;
const lines = getWrappedTextLines(
editable.value,
layout.font,
whiteSpace === "pre-wrap" ? layout.width : Infinity,
);
const lineIndex = Math.max(
0,
Math.min(lines.length - 1, Math.floor(localY / layout.lineHeightPx)),
);
const line = lines[lineIndex];
const direction = getLineDirection(editable.value, line.start);
const lineWidth = getLineWidth(line.text, layout.font);
const lineStartX =
layout.textAlign === "center"
? (layout.width - lineWidth) / 2
: layout.textAlign === "right"
? layout.width - lineWidth
: 0;
const relativeX = localX - lineStartX;
if (!line.text) {
return line.start;
}
const lineCaretOffset = getLineCaretOffsetFromNativeLayout({
text: line.text,
font: layout.font,
lineHeightPx: layout.lineHeightPx,
direction,
targetX: relativeX,
});
return line.start + (lineCaretOffset || 0);
};
let pendingInitialSelection = (() => {
const caretIndex = getCaretIndexFromInitialSceneCoords();
if (caretIndex === null) {
return null;
}
return {
start: caretIndex,
end: caretIndex,
};
})();
if (onChange) { if (onChange) {
editable.onpaste = async (event) => { editable.onpaste = async (event) => {
// we need to synchronously get the MIME types so we can preventDefault() // we need to synchronously get the MIME types so we can preventDefault()
@@ -696,6 +890,13 @@ export const textWysiwyg = ({
// Otherwise, re-enable submit on blur and refocus the editor. // Otherwise, re-enable submit on blur and refocus the editor.
editable.onblur = handleSubmit; editable.onblur = handleSubmit;
editable.focus(); editable.focus();
if (pendingInitialSelection) {
editable.setSelectionRange(
pendingInitialSelection.start,
pendingInitialSelection.end,
);
pendingInitialSelection = null;
}
}); });
}; };
@@ -786,7 +987,7 @@ export const textWysiwyg = ({
let isDestroyed = false; let isDestroyed = false;
if (autoSelect) { if (autoSelect && !pendingInitialSelection) {
// select on init (focusing is done separately inside the bindBlurEvent() // select on init (focusing is done separately inside the bindBlurEvent()
// because we need it to happen *after* the blur event from `pointerdown`) // because we need it to happen *after* the blur event from `pointerdown`)
editable.select(); editable.select();
+7 -2
View File
@@ -123,10 +123,15 @@ export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
* @returns The rotated point * @returns The rotated point
*/ */
export function pointRotateRads<Point extends GlobalPoint | LocalPoint>( export function pointRotateRads<Point extends GlobalPoint | LocalPoint>(
[x, y]: Point, point: Point,
[cx, cy]: Point, center: Point,
angle: Radians, angle: Radians,
): Point { ): Point {
if (!angle) {
return point;
}
const [x, y] = point;
const [cx, cy] = center;
return pointFrom( return pointFrom(
(x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx, (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
(x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy, (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
+1 -1
View File
@@ -40,7 +40,7 @@ export const isLineSegment = <Point extends GlobalPoint | LocalPoint>(
Array.isArray(segment) && Array.isArray(segment) &&
segment.length === 2 && segment.length === 2 &&
isPoint(segment[0]) && 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 * 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 // Measurements
// //
+25 -1
View File
@@ -1,5 +1,9 @@
import { pointFrom } from "../src/point"; import { pointFrom } from "../src/point";
import { lineSegment, lineSegmentIntersectionPoints } from "../src/segment"; import {
lineSegment,
lineSegmentIntersectionPoints,
isLineSegment,
} from "../src/segment";
describe("line-segment intersections", () => { describe("line-segment intersections", () => {
it("should correctly detect intersection", () => { it("should correctly detect intersection", () => {
@@ -19,3 +23,23 @@ describe("line-segment intersections", () => {
).toEqual(null); ).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 { getElementBounds } from "@excalidraw/element";
import { import {
isArrowElement, isArrowElement,
@@ -90,7 +95,7 @@ const getMinMaxPoints = (points: Points) => {
return ret; return ret;
}; };
const getRotatedBBox = (element: Element): Bounds => { const getRotatedBBox = (element: Element): RotatedBounds => {
const points = getElementRelativePoints(element); const points = getElementRelativePoints(element);
const { cx, cy } = getMinMaxPoints(points); const { cx, cy } = getMinMaxPoints(points);
@@ -101,12 +106,13 @@ const getRotatedBBox = (element: Element): Bounds => {
); );
const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints); const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints);
return [ return bounds(
minX + element.x, minX + element.x,
minY + element.y, minY + element.y,
maxX + element.x, maxX + element.x,
maxY + element.y, maxY + element.y,
]; element.angle,
);
}; };
export const isElementInsideBBox = ( export const isElementInsideBBox = (
@@ -160,12 +166,12 @@ export const elementPartiallyOverlapsWithOrContainsBBox = (
export const elementsOverlappingBBox = ({ export const elementsOverlappingBBox = ({
elements, elements,
bounds, bounds: _bounds,
type, type,
errorMargin = 0, errorMargin = 0,
}: { }: {
elements: Elements; elements: Elements;
bounds: Bounds | ExcalidrawElement; bounds: RotatedBounds | ExcalidrawElement;
/** safety offset. Defaults to 0. */ /** safety offset. Defaults to 0. */
errorMargin?: number; errorMargin?: number;
/** /**
@@ -175,15 +181,16 @@ export const elementsOverlappingBBox = ({
**/ **/
type: "overlap" | "contain" | "inside"; type: "overlap" | "contain" | "inside";
}) => { }) => {
if (isExcalidrawElement(bounds)) { if (isExcalidrawElement(_bounds)) {
bounds = getElementBounds(bounds, arrayToMap(elements)); _bounds = getElementBounds(_bounds, arrayToMap(elements));
} }
const adjustedBBox: Bounds = [ const adjustedBBox = bounds(
bounds[0] - errorMargin, _bounds[0] - errorMargin,
bounds[1] - errorMargin, _bounds[1] - errorMargin,
bounds[2] + errorMargin, _bounds[2] + errorMargin,
bounds[3] + errorMargin, _bounds[3] + errorMargin,
]; _bounds[4],
);
const includedElementSet = new Set<string>(); 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" resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
"@excalidraw/mermaid-to-excalidraw@2.1.0": "@excalidraw/mermaid-to-excalidraw@2.2.2":
version "2.1.0" version "2.2.2"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.0.tgz#a5b9cf87c3185558cda7f9687d87b9937f452358" resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz#ee6b597a0d95b9a76f7ae41ce0e3733a9b96e4a0"
integrity sha512-RMd+c2b7WzzUjhERMpKwp8PhF2/XlHDjr/zK+Gxfp8K9sVlafPYJ5OEa/GkN6edi2rBUXRfW+41WdO6L56b6Kw== integrity sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==
dependencies: dependencies:
"@excalidraw/markdown-to-text" "0.1.2" "@excalidraw/markdown-to-text" "0.1.2"
"@mermaid-js/parser" "^0.6.3" "@mermaid-js/parser" "^0.6.3"