Compare commits

...

17 Commits

Author SHA1 Message Date
dwelle b92f585ce7 fix(test): attempt to fix test flake in collision.test.tsx 2026-03-25 21:03:20 +01:00
David Luzar 4a5c9e990c fix(editor): ensure font picker font names are not quoted (#11036) 2026-03-25 17:56:48 +01:00
David Luzar c09e170bdd feat(editor): deselect on esc (#11035)
Co-authored-by: Jawahar <jawahars_16@live.in>
Co-authored-by: Andrew Aquino <dawneraq@gmail.com>
2026-03-25 17:14:24 +01:00
David Luzar c1082923ee feat(editor): support mermaid staate diagrams (#11031) 2026-03-24 20:20:28 +01:00
Kundan 1c292e4936 fix(math): correctly validate second point in isLineSegment (#11007)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:01:08 +01:00
Márk Tolmács d6f0f34fe9 fix: Rotated rounded arrow center point (#10962) 2026-03-23 15:54:59 +01:00
Márk Tolmács 75789f620d fix: Other endpoint is not immediately updated on midpoint snap (#10933)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-03-23 14:54:44 +00:00
David Luzar a9ca16eb42 chore(packages/excalidraw): export Fonts helper class (#11008) 2026-03-21 22:44:27 +01:00
Márk Tolmács 987173b52f fix: Arrow point index Out-of-Bounds (#10922)
* fix: Make OOB not fatal

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

* fix: More conservative temp arrow state update

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

* chore: Capture condition variables in binding restoration failure

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

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-21 19:26:47 +01:00
David Luzar 81ab857a6f feat(editor): various text related improvements (#10979) 2026-03-19 16:00:58 +01:00
David Luzar e8b4620a96 feat(editor): put caret at pointer coords when clicking on selected text element (#10970) 2026-03-18 19:14:44 +01:00
David Luzar 2b0e4c9623 fix(editor): remove leftover debug code path (#10954) 2026-03-14 13:12:48 +01:00
David Luzar c9ba7f839c chore(editor): bump @excalidraw/mermaid-to-excalidraw@2.1.1 (#10944) 2026-03-12 17:32:13 +01:00
David Luzar b4ce7c713b fix(editor): arrowhead picker overflowing viewport (#10943) 2026-03-12 16:08:46 +01:00
David Luzar 816c81c12e feat(editor): ERD arrowheads and diagrams (#10940) 2026-03-11 22:00:31 +01:00
David Luzar 92d25446d6 feat(packages/excalidraw): tweak and expose more API around state and lifecycle (#10939) 2026-03-11 16:51:03 +01:00
David Luzar e73a5b0116 docs(packages/excalidraw): improve readme (#10932) 2026-03-11 09:49:12 +01:00
59 changed files with 3469 additions and 761 deletions
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow", type: "arrow",
x: 450, x: 450,
y: 20, y: 20,
startArrowhead: "dot", startArrowhead: "circle",
endArrowhead: "triangle", endArrowhead: "triangle",
strokeColor: "#1971c2", strokeColor: "#1971c2",
strokeWidth: 2, strokeWidth: 2,
@@ -1,4 +1,4 @@
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform"; import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform";
import type { FileId } from "@excalidraw/excalidraw/element/types"; import type { FileId } from "@excalidraw/excalidraw/element/types";
const elements: ExcalidrawElementSkeleton[] = [ const elements: ExcalidrawElementSkeleton[] = [
+32
View File
@@ -0,0 +1,32 @@
import type { Arrowhead, AnyArrowhead } from "./types";
export const normalizeArrowhead = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
switch (arrowhead) {
case undefined:
case null:
return null;
case "dot":
return "circle";
case "crowfoot_one":
return "cardinality_one";
case "crowfoot_many":
return "cardinality_many";
case "crowfoot_one_or_many":
return "cardinality_one_or_many";
default:
return arrowhead;
}
};
export const getArrowheadForPicker = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
const normalizedArrowhead = normalizeArrowhead(arrowhead);
if (normalizedArrowhead === null) {
return null;
}
return normalizedArrowhead;
};
+39 -26
View File
@@ -709,6 +709,9 @@ const getFreeDrawElementAbsoluteCoords = (
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2]; return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
}; };
const CARDINALITY_MARKER_SIZE = 20;
const CROWFOOT_ARROWHEAD_SIZE = 15;
/** @returns number in pixels */ /** @returns number in pixels */
export const getArrowheadSize = (arrowhead: Arrowhead): number => { export const getArrowheadSize = (arrowhead: Arrowhead): number => {
switch (arrowhead) { switch (arrowhead) {
@@ -717,10 +720,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond": case "diamond":
case "diamond_outline": case "diamond_outline":
return 12; return 12;
case "crowfoot_many": case "cardinality_many":
case "crowfoot_one": case "cardinality_one_or_many":
case "crowfoot_one_or_many": case "cardinality_zero_or_many":
return 20; return CROWFOOT_ARROWHEAD_SIZE;
case "cardinality_one":
case "cardinality_exactly_one":
case "cardinality_zero_or_one":
return CARDINALITY_MARKER_SIZE;
default: default:
return 15; return 15;
} }
@@ -743,7 +750,12 @@ export const getArrowheadPoints = (
shape: Drawable[], shape: Drawable[],
position: "start" | "end", position: "start" | "end",
arrowhead: Arrowhead, arrowhead: Arrowhead,
offsetMultiplier = 0,
) => { ) => {
if (arrowhead === null) {
return null;
}
if (shape.length < 1) { if (shape.length < 1) {
return null; return null;
} }
@@ -824,29 +836,30 @@ export const getArrowheadPoints = (
const lengthMultiplier = const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5; arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
const minSize = Math.min(size, length * lengthMultiplier); const minSize = Math.min(size, length * lengthMultiplier);
const xs = x2 - nx * minSize; const tx = x2 - nx * minSize * offsetMultiplier;
const ys = y2 - ny * minSize; const ty = y2 - ny * minSize * offsetMultiplier;
const xs = tx - nx * minSize;
const ys = ty - ny * minSize;
if ( if (arrowhead === "circle" || arrowhead === "circle_outline") {
arrowhead === "dot" || const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2;
arrowhead === "circle" || return [tx, ty, diameter];
arrowhead === "circle_outline"
) {
const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
return [x2, y2, diameter];
} }
const angle = getArrowheadAngle(arrowhead); const angle = getArrowheadAngle(arrowhead);
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") { if (
arrowhead === "cardinality_many" ||
arrowhead === "cardinality_one_or_many"
) {
// swap (xs, ys) with (x2, y2) // swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(x2, y2), pointFrom(tx, ty),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(-angle as Degrees), degreesToRadians(-angle as Degrees),
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(x2, y2), pointFrom(tx, ty),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -856,12 +869,12 @@ export const getArrowheadPoints = (
// Return points // Return points
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
pointFrom(x2, y2), pointFrom(tx, ty),
((-angle * Math.PI) / 180) as Radians, ((-angle * Math.PI) / 180) as Radians,
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
pointFrom(x2, y2), pointFrom(tx, ty),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -874,9 +887,9 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(x2 + minSize * 2, y2), pointFrom(tx + minSize * 2, ty),
pointFrom(x2, y2), pointFrom(tx, ty),
Math.atan2(py - y2, px - x2) as Radians, Math.atan2(py - ty, px - tx) as Radians,
); );
} else { } else {
const [px, py] = const [px, py] =
@@ -885,16 +898,16 @@ export const getArrowheadPoints = (
: [0, 0]; : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(x2 - minSize * 2, y2), pointFrom(tx - minSize * 2, ty),
pointFrom(x2, y2), pointFrom(tx, ty),
Math.atan2(y2 - py, x2 - px) as Radians, Math.atan2(ty - py, tx - px) as Radians,
); );
} }
return [x2, y2, x3, y3, ox, oy, x4, y4]; return [tx, ty, x3, y3, ox, oy, x4, y4];
} }
return [x2, y2, x3, y3, x4, y4]; return [tx, ty, x3, y3, x4, y4];
}; };
// TODO reuse shape.ts // TODO reuse shape.ts
+11 -2
View File
@@ -465,7 +465,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,
);
} }
}; };
@@ -532,11 +537,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) {
+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)),
+1
View File
@@ -99,3 +99,4 @@ export * from "./typeChecks";
export * from "./utils"; export * from "./utils";
export * from "./zindex"; export * from "./zindex";
export * from "./arrows/helpers"; export * from "./arrows/helpers";
export * from "./arrowheads";
+31 -14
View File
@@ -476,16 +476,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 +800,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 +810,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 +898,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 +913,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 +936,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 +949,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 +1867,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;
@@ -2400,7 +2417,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 +2448,7 @@ const pointDraggingUpdates = (
? endLocalPoint ? endLocalPoint
: startBindable : startBindable
? updateBoundPoint( ? updateBoundPoint(
element, nextArrow,
"startBinding", "startBinding",
nextArrow.startBinding, nextArrow.startBinding,
startBindable, startBindable,
+18
View File
@@ -11,6 +11,7 @@ import {
isBoundToContainer, isBoundToContainer,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
@@ -25,6 +26,7 @@ import type {
ElementsMap, ElementsMap,
ElementsMapOrArray, ElementsMapOrArray,
ExcalidrawElement, ExcalidrawElement,
NonDeleted,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
@@ -288,3 +290,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;
};
+230 -102
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";
@@ -69,10 +69,10 @@ import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ElementsMap, ElementsMap,
ExcalidrawLineElement, ExcalidrawLineElement,
Arrowhead,
} from "./types"; } from "./types";
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
@@ -296,6 +296,82 @@ const modifyIframeLikeForRoughOptions = (
return element; return element;
}; };
const generateArrowheadCardinalityOne = (
generator: RoughGenerator,
arrowheadPoints: number[] | null,
lineOptions: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, lineOptions)];
};
const generateArrowheadLinesToTip = (
generator: RoughGenerator,
arrowheadPoints: number[] | null,
lineOptions: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
return [
generator.line(x3, y3, x2, y2, lineOptions),
generator.line(x4, y4, x2, y2, lineOptions),
];
};
const getArrowheadLineOptions = (
element: ExcalidrawLinearElement,
options: Options,
) => {
const lineOptions = { ...options };
if (element.strokeStyle === "dotted") {
// for dotted arrows caps, reduce gap to make it more legible
const dash = getDashArrayDotted(element.strokeWidth - 1);
lineOptions.strokeLineDash = [dash[0], dash[1] - 1];
} else {
// for solid/dashed, keep solid arrow cap
delete lineOptions.strokeLineDash;
}
lineOptions.roughness = Math.min(1, lineOptions.roughness || 0);
return lineOptions;
};
const generateArrowheadOutlineCircle = (
generator: RoughGenerator,
options: Options,
strokeColor: string,
arrowheadPoints: number[] | null,
fill: string,
diameterScale = 1,
) => {
if (arrowheadPoints === null) {
return [];
}
const [x, y, diameter] = arrowheadPoints;
const circleOptions = {
...options,
fill,
fillStyle: "solid" as const,
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
};
delete circleOptions.strokeLineDash;
return [generator.circle(x, y, diameter * diameterScale, circleOptions)];
};
const getArrowheadShapes = ( const getArrowheadShapes = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
shape: Drawable[], shape: Drawable[],
@@ -306,63 +382,54 @@ const getArrowheadShapes = (
canvasBackgroundColor: string, canvasBackgroundColor: string,
isDarkMode: boolean, isDarkMode: boolean,
) => { ) => {
const arrowheadPoints = getArrowheadPoints( if (arrowhead === null) {
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return []; return [];
} }
const generateCrowfootOne = (
arrowheadPoints: number[] | null,
options: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, options)];
};
const strokeColor = isDarkMode const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor) ? applyDarkModeFilter(element.strokeColor)
: element.strokeColor; : element.strokeColor;
const backgroundFillColor = isDarkMode
? applyDarkModeFilter(canvasBackgroundColor)
: canvasBackgroundColor;
const cardinalityOneOrManyOffset = -0.25;
const cardinalityZeroCircleScale = 0.8;
switch (arrowhead) { switch (arrowhead) {
case "dot":
case "circle": case "circle":
case "circle_outline": { case "circle_outline": {
const [x, y, diameter] = arrowheadPoints; return generateArrowheadOutlineCircle(
generator,
// always use solid stroke for arrowhead options,
delete options.strokeLineDash; strokeColor,
getArrowheadPoints(element, shape, position, arrowhead),
return [ arrowhead === "circle_outline" ? backgroundFillColor : strokeColor,
generator.circle(x, y, diameter, { );
...options,
fill:
arrowhead === "circle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
}),
];
} }
case "triangle": case "triangle":
case "triangle_outline": { case "triangle_outline": {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
const [x, y, x2, y2, x3, y3] = arrowheadPoints; const [x, y, x2, y2, x3, y3] = arrowheadPoints;
const triangleOptions = {
...options,
fill:
arrowhead === "triangle_outline" ? backgroundFillColor : strokeColor,
fillStyle: "solid" as const,
roughness: Math.min(1, options.roughness || 0),
};
// always use solid stroke for arrowhead // always use solid stroke for arrowhead
delete options.strokeLineDash; delete triangleOptions.strokeLineDash;
return [ return [
generator.polygon( generator.polygon(
@@ -372,24 +439,34 @@ const getArrowheadShapes = (
[x3, y3], [x3, y3],
[x, y], [x, y],
], ],
{ triangleOptions,
...options,
fill:
arrowhead === "triangle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
), ),
]; ];
} }
case "diamond": case "diamond":
case "diamond_outline": { case "diamond_outline": {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints; const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
const diamondOptions = {
...options,
fill:
arrowhead === "diamond_outline" ? backgroundFillColor : strokeColor,
fillStyle: "solid" as const,
roughness: Math.min(1, options.roughness || 0),
};
// always use solid stroke for arrowhead // always use solid stroke for arrowhead
delete options.strokeLineDash; delete diamondOptions.strokeLineDash;
return [ return [
generator.polygon( generator.polygon(
@@ -400,53 +477,117 @@ const getArrowheadShapes = (
[x4, y4], [x4, y4],
[x, y], [x, y],
], ],
{ diamondOptions,
...options, ),
fill: ];
arrowhead === "diamond_outline" }
? canvasBackgroundColor case "cardinality_one":
: strokeColor, return generateArrowheadCardinalityOne(
fillStyle: "solid", generator,
roughness: Math.min(1, options.roughness || 0), getArrowheadPoints(element, shape, position, arrowhead),
}, getArrowheadLineOptions(element, options),
);
case "cardinality_many":
return generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, arrowhead),
getArrowheadLineOptions(element, options),
);
case "cardinality_one_or_many": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, "cardinality_many"),
lineOptions,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(
element,
shape,
position,
"cardinality_one",
cardinalityOneOrManyOffset,
),
lineOptions,
),
];
}
case "cardinality_exactly_one": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
lineOptions,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one"),
lineOptions,
),
];
}
case "cardinality_zero_or_one": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadOutlineCircle(
generator,
options,
strokeColor,
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
backgroundFillColor,
cardinalityZeroCircleScale,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
lineOptions,
),
];
}
case "cardinality_zero_or_many": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, "cardinality_many"),
lineOptions,
),
...generateArrowheadOutlineCircle(
generator,
options,
strokeColor,
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
backgroundFillColor,
cardinalityZeroCircleScale,
), ),
]; ];
} }
case "crowfoot_one":
return generateCrowfootOne(arrowheadPoints, options);
case "bar": case "bar":
case "arrow": case "arrow":
case "crowfoot_many":
case "crowfoot_one_or_many":
default: { default: {
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints; return generateArrowheadLinesToTip(
generator,
if (element.strokeStyle === "dotted") { getArrowheadPoints(element, shape, position, arrowhead),
// for dotted arrows caps, reduce gap to make it more legible getArrowheadLineOptions(element, options),
const dash = getDashArrayDotted(element.strokeWidth - 1); );
options.strokeLineDash = [dash[0], dash[1] - 1];
} else {
// for solid/dashed, keep solid arrow cap
delete options.strokeLineDash;
}
options.roughness = Math.min(1, options.roughness || 0);
return [
generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options),
...(arrowhead === "crowfoot_one_or_many"
? generateCrowfootOne(
getArrowheadPoints(element, shape, position, "crowfoot_one"),
options,
)
: []),
];
} }
} }
}; };
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,
@@ -455,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),
};
}; };
/** /**
+17 -4
View File
@@ -303,19 +303,32 @@ export type PointsPositionUpdates = Map<
{ point: LocalPoint; isDragging?: boolean } { point: LocalPoint; isDragging?: boolean }
>; >;
export type CardinalityArrowhead =
| "cardinality_one"
| "cardinality_many"
| "cardinality_one_or_many"
| "cardinality_exactly_one"
| "cardinality_zero_or_one"
| "cardinality_zero_or_many";
export type ArrowheadLegacy =
| "dot"
| "crowfoot_one"
| "crowfoot_many"
| "crowfoot_one_or_many";
export type Arrowhead = export type Arrowhead =
| "arrow" | "arrow"
| "bar" | "bar"
| "dot" // legacy. Do not use for new elements.
| "circle" | "circle"
| "circle_outline" | "circle_outline"
| "triangle" | "triangle"
| "triangle_outline" | "triangle_outline"
| "diamond" | "diamond"
| "diamond_outline" | "diamond_outline"
| "crowfoot_one" | CardinalityArrowhead;
| "crowfoot_many"
| "crowfoot_one_or_many"; export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
+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 = [];
+9 -10
View File
@@ -1,8 +1,7 @@
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap, ROUNDNESS } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math"; import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
import { render } from "@excalidraw/excalidraw/tests/test-utils"; import { render } from "@excalidraw/excalidraw/tests/test-utils";
@@ -10,29 +9,29 @@ import * as distance from "../src/distance";
import { hitElementItself } from "../src/collision"; import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => { describe("check rotated elements can be hit:", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("arrow", () => { it("arrow", () => {
UI.createElement("arrow", { const element = API.createElement({
type: "arrow",
x: 0, x: 0,
y: 0, y: 0,
width: 124, width: 124,
height: 302, height: 302,
angle: 1.8700426423973724, angle: 1.8700426423973724,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
endArrowhead: "arrow",
points: [ points: [
[0, 0], [0, 0],
[120, -198], [120, -198],
[-4, -302], [-4, -302],
] as LocalPoint[], ] as LocalPoint[],
}); });
const elementsMap = arrayToMap([element]);
const hit = hitElementItself({ const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68), point: pointFrom<GlobalPoint>(88, -68),
element: window.h.elements[0], element,
threshold: 10, threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(), elementsMap,
}); });
expect(hit).toBe(true); expect(hit).toBe(true);
}); });
+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"
+9 -14
View File
@@ -18,22 +18,13 @@ Please add the latest change on the top under the correct section.
### Breaking changes ### Breaking changes
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`. - Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
- `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
### Features ### Features
- Added `onMount` and `onInitialize` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted, and `onInitialize` fires once the initial scene has loaded. - Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code.
```tsx - Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting.
<Excalidraw
onMount={({ excalidrawAPI, container }) => {
console.log(container);
excalidrawAPI.scrollToContent();
}}
onInitialize={(api) => {
api.refresh();
}}
/>
```
- Same events are also accessible imperatively through `api.onEvent(...)`. - Same events are also accessible imperatively through `api.onEvent(...)`.
@@ -41,7 +32,6 @@ Please add the latest change on the top under the correct section.
<Excalidraw <Excalidraw
onExcalidrawAPI={(api) => { onExcalidrawAPI={(api) => {
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => { api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
excalidrawAPI.scrollToContent();
console.log(container); console.log(container);
}); });
@@ -54,7 +44,9 @@ Please add the latest change on the top under the correct section.
Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`. Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
- Exported `ExcalidrawAPIProvider`, `useExcalidrawAPI`, and `useAppStateValue` from the package entrypoint. The imperative API also now exposes `onStateChange`. - Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`.
- Exported `<ExcalidrawAPIProvider/>`, `useExcalidrawAPI()`, `useAppStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`.
```tsx ```tsx
<ExcalidrawAPIProvider> <ExcalidrawAPIProvider>
@@ -63,6 +55,9 @@ Please add the latest change on the top under the correct section.
</ExcalidrawAPIProvider>; </ExcalidrawAPIProvider>;
function Logger() { function Logger() {
// initially null before the ExcalidrawAPIProvider initializes ater
// <Excalidraw/> renders
// When <Excalidraw/> unmounts, is reset back to null
const api = useExcalidrawAPI(); const api = useExcalidrawAPI();
useAppStateValue("viewModeEnabled", (viewModeEnabled) => { useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
+111 -14
View File
@@ -1,10 +1,10 @@
# Excalidraw # Excalidraw
**Excalidraw** is exported as a component to be directly embedded in your project. **Excalidraw** is exported as a React component that you can embed directly in your app.
## Installation ## Installation
Use `npm` or `yarn` to install the package. Install the package together with its React peer dependencies.
```bash ```bash
npm install react react-dom @excalidraw/excalidraw npm install react react-dom @excalidraw/excalidraw
@@ -12,34 +12,131 @@ npm install react react-dom @excalidraw/excalidraw
yarn add react react-dom @excalidraw/excalidraw yarn add react react-dom @excalidraw/excalidraw
``` ```
> **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`. > **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`.
#### Self-hosting fonts ## Quick start
By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod). The minimum working setup has two easy-to-miss requirements:
For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root: 1. Import the package CSS:
```js ```ts
<script>window.EXCALIDRAW_ASSET_PATH = "/";</script> import "@excalidraw/excalidraw/index.css";
``` ```
### Dimensions of Excalidraw 2. Render Excalidraw inside a container with a non-zero height.
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions. ```tsx
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function App() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
Excalidraw fills `100%` of the width and height of its parent. If the parent has no height, the canvas will not be visible.
## Next.js / SSR frameworks
Excalidraw should be rendered on the client. In SSR frameworks such as Next.js, use a client component and load it dynamically with SSR disabled.
```tsx
// app/components/ExcalidrawClient.tsx
"use client";
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function ExcalidrawClient() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
```tsx
// app/page.tsx
import dynamic from "next/dynamic";
const ExcalidrawClient = dynamic(
() => import("./components/ExcalidrawClient"),
{ ssr: false },
);
export default function Page() {
return <ExcalidrawClient />;
}
```
See the local examples for complete setups:
- [examples/with-nextjs](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs)
- [examples/with-script-in-browser](https://github.com/excalidraw/excalidraw/tree/master/examples/with-script-in-browser)
## LLM / agent tips
If an LLM or coding agent is setting up Excalidraw, these shortcuts usually save more time than re-prompting:
- Start with a plain `<Excalidraw />` in a `100vh` container. Add refs, `initialData`, persistence, or custom UI only after the base embed works.
- If the canvas is blank, check the CSS import and parent height first. Those are the two most common integration failures.
- In Next.js or other SSR frameworks, assume client-only rendering first. Use `"use client"` and `dynamic(..., { ssr: false })` before debugging hydration or `window is not defined` errors.
- If imports or entrypoints are unclear, inspect `node_modules/@excalidraw/excalidraw/package.json`. The installed package exports are the source of truth.
- Do not set `window.EXCALIDRAW_ASSET_PATH` unless you are intentionally self-hosting fonts/assets.
- When docs and generated code drift, copy the nearest working example from this repo, especially `examples/with-nextjs` or `examples/with-script-in-browser`.
## Migrating to `@excalidraw/excalidraw@0.18.x`
Version `0.18.x` removes the old `types/`-prefixed deep import paths. If you were importing types from `@excalidraw/excalidraw/types/...`, switch to the new type-only subpaths below.
| Old path | New path |
| --- | --- |
| `@excalidraw/excalidraw/types/data/transform.js` | `@excalidraw/excalidraw/element/transform` |
| `@excalidraw/excalidraw/types/data/types.js` | `@excalidraw/excalidraw/data/types` |
| `@excalidraw/excalidraw/types/element/types.js` | `@excalidraw/excalidraw/element/types` |
| `@excalidraw/excalidraw/types/utility-types.js` | `@excalidraw/excalidraw/common/utility-types` |
| `@excalidraw/excalidraw/types/types.js` | `@excalidraw/excalidraw/types` |
Drop the `.js` extension. The new package `exports` map resolves these paths without it.
These deep subpaths are for `import type` only. Runtime imports should come from the package root, plus `@excalidraw/excalidraw/index.css` for styles.
For example:
```ts
import { exportToSvg } from "@excalidraw/excalidraw";
```
## Self-hosting fonts
By default, Excalidraw downloads the fonts it needs from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
For self-hosting, copy the contents of `node_modules/@excalidraw/excalidraw/dist/prod/fonts` into the path where your app serves static assets, for example `public/`. Then set `window.EXCALIDRAW_ASSET_PATH` to that same path:
```html
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
</script>
```
## Demo ## Demo
Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example. Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser).
## Integration ## Integration
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration). Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
## API ## API
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api). Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
## Contributing ## Contributing
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing). Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
@@ -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))
);
},
});
+1 -1
View File
@@ -399,7 +399,7 @@ export const actionSaveFileToDisk = register({
appState: { appState: {
openDialog: null, openDialog: null,
fileHandle: savedFileHandle, fileHandle: savedFileHandle,
toast: { message: t("toast.fileSaved") }, toast: { message: t("toast.fileSaved"), duration: 3000 },
}, },
}; };
} catch (error: any) { } catch (error: any) {
@@ -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 }) => (
+157 -90
View File
@@ -36,6 +36,7 @@ import {
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import { getArrowheadForPicker } from "@excalidraw/element";
import { import {
getBoundTextElement, getBoundTextElement,
@@ -124,9 +125,12 @@ import {
sharpArrowIcon, sharpArrowIcon,
roundArrowIcon, roundArrowIcon,
elbowArrowIcon, elbowArrowIcon,
ArrowheadCrowfootIcon, ArrowheadCardinalityExactlyOneIcon,
ArrowheadCrowfootOneIcon, ArrowheadCardinalityManyIcon,
ArrowheadCrowfootOneOrManyIcon, ArrowheadCardinalityOneIcon,
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
} from "../components/icons"; } from "../components/icons";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
@@ -187,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;
@@ -205,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) ??
@@ -726,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"]>(
@@ -1550,80 +1573,117 @@ export const actionChangeRoundness = register<"sharp" | "round">({
}); });
const getArrowheadOptions = (flip: boolean) => { const getArrowheadOptions = (flip: boolean) => {
return [ return {
{ visibleSections: [
value: null, {
text: t("labels.arrowhead_none"), name: "default",
keyBinding: "q", options: [
icon: <ArrowheadNoneIcon flip={flip} />, {
}, value: null,
{ text: t("labels.arrowhead_none"),
value: "arrow", keyBinding: "q",
text: t("labels.arrowhead_arrow"), icon: <ArrowheadNoneIcon flip={flip} />,
keyBinding: "w", },
icon: <ArrowheadArrowIcon flip={flip} />, {
}, value: "arrow",
{ text: t("labels.arrowhead_arrow"),
value: "triangle", keyBinding: "w",
text: t("labels.arrowhead_triangle"), icon: <ArrowheadArrowIcon flip={flip} />,
icon: <ArrowheadTriangleIcon flip={flip} />, },
keyBinding: "e", {
}, value: "triangle",
{ text: t("labels.arrowhead_triangle"),
value: "triangle_outline", icon: <ArrowheadTriangleIcon flip={flip} />,
text: t("labels.arrowhead_triangle_outline"), keyBinding: "e",
icon: <ArrowheadTriangleOutlineIcon flip={flip} />, },
keyBinding: "r", {
}, value: "triangle_outline",
{ text: t("labels.arrowhead_triangle_outline"),
value: "circle", icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
text: t("labels.arrowhead_circle"), keyBinding: "r",
keyBinding: "a", },
icon: <ArrowheadCircleIcon flip={flip} />, ],
}, },
{ ],
value: "circle_outline", hiddenSections: [
text: t("labels.arrowhead_circle_outline"), {
keyBinding: "s", name: "default",
icon: <ArrowheadCircleOutlineIcon flip={flip} />, options: [
}, {
{ value: "circle",
value: "diamond", text: t("labels.arrowhead_circle"),
text: t("labels.arrowhead_diamond"), keyBinding: "a",
icon: <ArrowheadDiamondIcon flip={flip} />, icon: <ArrowheadCircleIcon flip={flip} />,
keyBinding: "d", },
}, {
{ value: "circle_outline",
value: "diamond_outline", text: t("labels.arrowhead_circle_outline"),
text: t("labels.arrowhead_diamond_outline"), keyBinding: "s",
icon: <ArrowheadDiamondOutlineIcon flip={flip} />, icon: <ArrowheadCircleOutlineIcon flip={flip} />,
keyBinding: "f", },
}, {
{ value: "diamond",
value: "bar", text: t("labels.arrowhead_diamond"),
text: t("labels.arrowhead_bar"), icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: "z", keyBinding: "d",
icon: <ArrowheadBarIcon flip={flip} />, },
}, {
{ value: "diamond_outline",
value: "crowfoot_one", text: t("labels.arrowhead_diamond_outline"),
text: t("labels.arrowhead_crowfoot_one"), icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
icon: <ArrowheadCrowfootOneIcon flip={flip} />, keyBinding: "f",
keyBinding: "x", },
}, {
{ value: "bar",
value: "crowfoot_many", text: t("labels.arrowhead_bar"),
text: t("labels.arrowhead_crowfoot_many"), keyBinding: "z",
icon: <ArrowheadCrowfootIcon flip={flip} />, icon: <ArrowheadBarIcon flip={flip} />,
keyBinding: "c", },
}, ],
{ },
value: "crowfoot_one_or_many", {
text: t("labels.arrowhead_crowfoot_one_or_many"), name: t("labels.cardinality"),
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />, options: [
keyBinding: "v", {
}, value: "cardinality_one",
] as const; text: t("labels.arrowhead_cardinality_one"),
icon: <ArrowheadCardinalityOneIcon flip={flip} />,
keyBinding: "x",
},
{
value: "cardinality_many",
text: t("labels.arrowhead_cardinality_many"),
icon: <ArrowheadCardinalityManyIcon flip={flip} />,
keyBinding: "c",
},
{
value: "cardinality_one_or_many",
text: t("labels.arrowhead_cardinality_one_or_many"),
icon: <ArrowheadCardinalityOneOrManyIcon flip={flip} />,
keyBinding: "v",
},
{
value: "cardinality_exactly_one",
text: t("labels.arrowhead_cardinality_exactly_one"),
icon: <ArrowheadCardinalityExactlyOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_one",
text: t("labels.arrowhead_cardinality_zero_or_one"),
icon: <ArrowheadCardinalityZeroOrOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_many",
text: t("labels.arrowhead_cardinality_zero_or_many"),
icon: <ArrowheadCardinalityZeroOrManyIcon flip={flip} />,
keyBinding: null,
},
],
},
],
} as const;
}; };
export const actionChangeArrowhead = register<{ export const actionChangeArrowhead = register<{
@@ -1667,45 +1727,52 @@ export const actionChangeArrowhead = register<{
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
const startArrowheadOptions = useMemo(
() => getArrowheadOptions(!isRTL),
[isRTL],
);
const endArrowheadOptions = useMemo(
() => getArrowheadOptions(!!isRTL),
[isRTL],
);
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.arrowheads")}</legend> <legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList buttonList"> <div className="iconSelectList buttonList">
<IconPicker <IconPicker
visibleSections={startArrowheadOptions.visibleSections}
hiddenSections={startArrowheadOptions.hiddenSections}
label="arrowhead_start" label="arrowhead_start"
options={getArrowheadOptions(!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
app, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead ? getArrowheadForPicker(element.startArrowhead)
: appState.currentItemStartArrowhead, : appState.currentItemStartArrowhead,
true, true,
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemStartArrowhead, hasSelection ? null : appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
<IconPicker <IconPicker
visibleSections={endArrowheadOptions.visibleSections}
hiddenSections={endArrowheadOptions.hiddenSections}
label="arrowhead_end" label="arrowhead_end"
group="arrowheads"
options={getArrowheadOptions(!!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
app, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead ? getArrowheadForPicker(element.endArrowhead)
: appState.currentItemEndArrowhead, : appState.currentItemEndArrowhead,
true, true,
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemEndArrowhead, hasSelection ? null : appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -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"
+406 -87
View File
@@ -257,6 +257,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 +417,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";
@@ -517,6 +519,7 @@ EditorInterfaceContext.displayName = "EditorInterfaceContext";
const editorLifecycleEventBehavior = { const editorLifecycleEventBehavior = {
"editor:mount": { cardinality: "once", replay: "last" }, "editor:mount": { cardinality: "once", replay: "last" },
"editor:initialize": { cardinality: "once", replay: "last" }, "editor:initialize": { cardinality: "once", replay: "last" },
"editor:unmount": { cardinality: "once", replay: "last" },
} as const; } as const;
export const ExcalidrawContainerContext = React.createContext<{ export const ExcalidrawContainerContext = React.createContext<{
@@ -556,7 +559,7 @@ export const ExcalidrawAPIContext =
ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext"; ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext";
export const ExcalidrawAPISetContext = React.createContext< export const ExcalidrawAPISetContext = React.createContext<
((api: ExcalidrawImperativeAPI) => void) | null ((api: ExcalidrawImperativeAPI | null) => void) | null
>(null); >(null);
ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext"; ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext";
@@ -683,9 +686,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 };
@@ -731,6 +740,50 @@ class App extends React.Component<AppProps, AppState> {
api: ExcalidrawImperativeAPI; api: ExcalidrawImperativeAPI;
private createExcalidrawAPI(): ExcalidrawImperativeAPI {
const api: ExcalidrawImperativeAPI = {
isDestroyed: false,
updateScene: this.updateScene,
applyDeltas: this.applyDeltas,
mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles,
resetScene: this.resetScene,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
getSceneElementsMapIncludingDeleted:
this.getSceneElementsMapIncludingDeleted,
history: {
clear: this.resetHistory,
},
scrollToContent: this.scrollToContent,
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
getFiles: () => this.files,
getName: this.getName,
registerAction: (action: Action) => {
this.actionManager.registerAction(action);
},
refresh: this.refresh,
setToast: this.setToast,
id: this.id,
setActiveTool: this.setActiveTool,
setCursor: this.setCursor,
resetCursor: this.resetCursor,
getEditorInterface: () => this.editorInterface,
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb),
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
onStateChange: this.onStateChange,
onEvent: this.onEvent,
};
return api;
}
constructor(props: AppProps) { constructor(props: AppProps) {
super(props); super(props);
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
@@ -791,47 +844,11 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.registerAction(createUndoAction(this.history)); this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history)); this.actionManager.registerAction(createRedoAction(this.history));
this.api = { // in case internal editor APIs call this early, otherwise we need
updateScene: this.updateScene, // to construct this in componentDidMount because componentWillUnmount
applyDeltas: this.applyDeltas, // will invalidate it (so in StrictMode, doing this in constructor alone
mutateElement: this.mutateElement, // would be a problem)
updateLibrary: this.library.updateLibrary, this.api = this.createExcalidrawAPI();
addFiles: this.addFiles,
resetScene: this.resetScene,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
getSceneElementsMapIncludingDeleted:
this.getSceneElementsMapIncludingDeleted,
history: {
clear: this.resetHistory,
},
scrollToContent: this.scrollToContent,
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
getFiles: () => this.files,
getName: this.getName,
registerAction: (action: Action) => {
this.actionManager.registerAction(action);
},
refresh: this.refresh,
setToast: this.setToast,
id: this.id,
setActiveTool: this.setActiveTool,
setCursor: this.setCursor,
resetCursor: this.resetCursor,
getEditorInterface: () => this.editorInterface,
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb),
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
onStateChange: this.onStateChange,
onEvent: this.onEvent,
} as const;
props.onExcalidrawAPI?.(this.api);
} }
updateEditorAtom = <Value, Args extends unknown[], Result>( updateEditorAtom = <Value, Args extends unknown[], Result>(
@@ -1239,6 +1256,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 {
@@ -1429,6 +1466,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,
@@ -2312,6 +2364,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}
@@ -3003,6 +3056,8 @@ class App extends React.Component<AppProps, AppState> {
public async componentDidMount() { public async componentDidMount() {
this.unmounted = false; this.unmounted = false;
this.api = this.createExcalidrawAPI();
this.excalidrawContainerValue.container = this.excalidrawContainerValue.container =
this.excalidrawContainerRef.current; this.excalidrawContainerRef.current;
@@ -3089,10 +3144,35 @@ class App extends React.Component<AppProps, AppState> {
this.editorLifecycleEvents.emit("editor:mount", mountPayload); this.editorLifecycleEvents.emit("editor:mount", mountPayload);
this.props.onMount?.(mountPayload); this.props.onMount?.(mountPayload);
this.props.onExcalidrawAPI?.(this.api);
} }
public componentWillUnmount() { public componentWillUnmount() {
// we're recreating the api object reference so that the
// <ExcalidrawAPIContext.Provider/> picks up on it
this.api = { ...this.api, isDestroyed: true };
for (const key of Object.keys(this.api) as (keyof typeof this.api)[]) {
if (
(key.startsWith("get") ||
key === "onStateChange" ||
key === "onEvent") &&
typeof this.api[key] === "function"
) {
(this.api as any)[key] = () => {
throw new Error(
"ExcalidrawAPI is no longer usable after the editor has been unmounted and will return invalid/empty data. You should check for `ExcalidrawAPI.isDestroyed` before calling get* methods on subscribing to state/event changes.",
);
};
}
}
this.editorLifecycleEvents.emit("editor:unmount");
this.props.onUnmount?.();
this.props.onExcalidrawAPI?.(null);
(window as any).launchQueue?.setConsumer(() => {}); (window as any).launchQueue?.setConsumer(() => {});
this.renderer.destroy(); this.renderer.destroy();
this.scene.destroy(); this.scene.destroy();
this.scene = new Scene(); this.scene = new Scene();
@@ -3538,10 +3618,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;
@@ -3667,7 +3751,7 @@ class App extends React.Component<AppProps, AppState> {
if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) { if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
const api = await import("@excalidraw/mermaid-to-excalidraw"); const api = await import("@excalidraw/mermaid-to-excalidraw");
try { try {
const { elements: skeletonElements, files } = const { elements: skeletonElements, files = {} } =
await api.parseMermaidToExcalidraw(data.text); await api.parseMermaidToExcalidraw(data.text);
const elements = convertToExcalidrawElements(skeletonElements, { const elements = convertToExcalidrawElements(skeletonElements, {
@@ -5581,8 +5665,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();
@@ -5635,13 +5725,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
@@ -5685,6 +5775,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
@@ -5708,6 +5799,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,
@@ -5721,6 +5874,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,
@@ -5933,6 +6138,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;
@@ -5942,6 +6148,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;
@@ -5962,24 +6169,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;
@@ -6026,11 +6218,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,
@@ -6080,6 +6293,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({
@@ -6105,9 +6321,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) {
@@ -6295,6 +6550,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,
@@ -6725,27 +6995,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,
}, },
}, },
}); });
@@ -6831,6 +7097,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 &&
@@ -6975,7 +7247,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 ||
@@ -7196,6 +7470,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";
@@ -7419,6 +7695,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;
} }
@@ -7644,6 +7929,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) {
@@ -8480,6 +8769,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);
@@ -10965,6 +11255,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) {
+15 -3
View File
@@ -6,14 +6,20 @@
padding: 0.5rem; padding: 0.5rem;
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75); border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island-stronger);
border-radius: 4px; border-radius: 4px;
position: absolute;
:root[dir="rtl"] & { :root[dir="rtl"] & {
padding: 0.4rem; padding: 0.4rem;
} }
} }
.picker-sections,
.picker-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.picker-container button, .picker-container button,
.picker button { .picker button {
position: relative; position: relative;
@@ -62,7 +68,13 @@
.picker-collapsible { .picker-collapsible {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.5rem 0; padding: 0;
color: var(--text-primary-color);
}
.picker-section-label {
font-size: 0.75rem;
color: var(--text-primary-color);
} }
.picker-keybinding { .picker-keybinding {
+157 -67
View File
@@ -1,6 +1,6 @@
import { Popover } from "radix-ui"; import { Popover } from "radix-ui";
import clsx from "clsx"; import clsx from "clsx";
import React, { useEffect } from "react"; import React, { useEffect, useMemo } from "react";
import { isArrowKey, KEYS } from "@excalidraw/common"; import { isArrowKey, KEYS } from "@excalidraw/common";
@@ -8,13 +8,15 @@ import { atom, useAtom } from "../editor-jotai";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import Collapsible from "./Stats/Collapsible"; import Collapsible from "./Stats/Collapsible";
import { useEditorInterface, useExcalidrawContainer } from "./App"; import { useExcalidrawContainer } from "./App";
import "./IconPicker.scss"; import "./IconPicker.scss";
import type { JSX } from "react"; import type { JSX } from "react";
const moreOptionsAtom = atom(false); const moreOptionsAtom = atom(false);
const PICKER_COLUMNS = 4;
const DEFAULT_SECTION_NAME = "default";
type Option<T> = { type Option<T> = {
value: T; value: T;
@@ -23,28 +25,73 @@ type Option<T> = {
keyBinding: string | null; keyBinding: string | null;
}; };
type PickerSection<T> = {
name: string;
options: readonly Option<T>[];
};
const flattenOptions = <T,>(sections: readonly PickerSection<T>[]) =>
sections.flatMap((section) => section.options);
const findOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => {
for (const section of sections) {
const option = section.options.find(predicate);
if (option) {
return option;
}
}
return null;
};
const hasOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => sections.some((section) => section.options.some(predicate));
const getNavigationRows = <T,>(sections: readonly PickerSection<T>[]) =>
sections.flatMap((section) =>
Array.from(
{ length: Math.ceil(section.options.length / PICKER_COLUMNS) },
(_, index) =>
section.options.slice(
index * PICKER_COLUMNS,
index * PICKER_COLUMNS + PICKER_COLUMNS,
),
),
);
function Picker<T>({ function Picker<T>({
options, visibleSections,
hiddenSections = [],
value, value,
label, label,
onChange, onChange,
onClose, onClose,
numberOfOptionsToAlwaysShow = options.length,
}: { }: {
label: string; label: string;
value: T; value: T;
options: readonly Option<T>[]; visibleSections: readonly PickerSection<T>[];
hiddenSections?: readonly PickerSection<T>[];
onChange: (value: T) => void; onChange: (value: T) => void;
onClose: () => void; onClose: () => void;
numberOfOptionsToAlwaysShow?: number;
}) { }) {
const editorInterface = useEditorInterface();
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
const allSections = [...visibleSections, ...hiddenSections];
const allOptions = flattenOptions(allSections);
const navigationRows = getNavigationRows([
...visibleSections,
...(showMoreOptions ? hiddenSections : []),
]);
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find( const pressedOption = allOptions.find(
(option) => option.keyBinding === event.key.toLowerCase(), (option) => option.keyBinding === event.key.toLowerCase(),
)!; );
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) { if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation // Keybinding navigation
@@ -52,17 +99,17 @@ function Picker<T>({
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.TAB) { } else if (event.key === KEYS.TAB) {
const index = options.findIndex((option) => option.value === value); const index = allOptions.findIndex((option) => option.value === value);
const nextIndex = event.shiftKey const nextIndex = event.shiftKey
? (options.length + index - 1) % options.length ? (allOptions.length + index - 1) % allOptions.length
: (index + 1) % options.length; : (index + 1) % allOptions.length;
onChange(options[nextIndex].value); onChange(allOptions[nextIndex].value);
} else if (isArrowKey(event.key)) { } else if (isArrowKey(event.key)) {
// Arrow navigation // Arrow navigation
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
const index = options.findIndex((option) => option.value === value); const index = allOptions.findIndex((option) => option.value === value);
if (index !== -1) { if (index !== -1) {
const length = options.length; const length = allOptions.length;
let nextIndex = index; let nextIndex = index;
switch (event.key) { switch (event.key) {
@@ -76,18 +123,60 @@ function Picker<T>({
break; break;
// Go the next row // Go the next row
case KEYS.ARROW_DOWN: { case KEYS.ARROW_DOWN: {
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length; const currentRowIndex = navigationRows.findIndex((row) =>
row.some((option) => option.value === value),
);
const currentRow = navigationRows[currentRowIndex];
if (currentRowIndex !== -1 && currentRow) {
const column = currentRow.findIndex(
(option) => option.value === value,
);
const nextRow =
navigationRows[(currentRowIndex + 1) % navigationRows.length];
const nextOption =
nextRow[Math.min(column, nextRow.length - 1)] ??
allOptions[index];
onChange(nextOption.value);
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
return;
}
break; break;
} }
// Go the previous row // Go the previous row
case KEYS.ARROW_UP: { case KEYS.ARROW_UP: {
nextIndex = const currentRowIndex = navigationRows.findIndex((row) =>
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length; row.some((option) => option.value === value),
);
const currentRow = navigationRows[currentRowIndex];
if (currentRowIndex !== -1 && currentRow) {
const column = currentRow.findIndex(
(option) => option.value === value,
);
const previousRow =
navigationRows[
(navigationRows.length + currentRowIndex - 1) %
navigationRows.length
];
const previousOption =
previousRow[Math.min(column, previousRow.length - 1)] ??
allOptions[index];
onChange(previousOption.value);
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
return;
}
break; break;
} }
} }
onChange(options[nextIndex].value); onChange(allOptions[nextIndex].value);
} }
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -99,38 +188,29 @@ function Picker<T>({
event.stopPropagation(); event.stopPropagation();
}; };
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
const alwaysVisibleOptions = React.useMemo(
() => options.slice(0, numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
const moreOptions = React.useMemo(
() => options.slice(numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
useEffect(() => { useEffect(() => {
if (!alwaysVisibleOptions.some((option) => option.value === value)) { if (hasOption(hiddenSections, (option) => option.value === value)) {
setShowMoreOptions(true); setShowMoreOptions(true);
} }
}, [value, alwaysVisibleOptions, setShowMoreOptions]); }, [value, hiddenSections, setShowMoreOptions]);
const renderOptions = (options: Option<T>[]) => { const renderOptions = (options: readonly Option<T>[]) => {
return ( return (
<div className="picker-content"> <div className="picker-content">
{options.map((option, i) => ( {options.map((option) => (
<button <button
type="button" type="button"
className={clsx("picker-option", { className={clsx("picker-option", {
active: value === option.value, active: value === option.value,
})} })}
onClick={(event) => { onClick={() => {
onChange(option.value); onChange(option.value);
}} }}
title={`${option.text} ${ title={
option.keyBinding && `${option.keyBinding.toUpperCase()}` option.keyBinding
}`} ? `${option.text}${option.keyBinding.toUpperCase()}`
: option.text
}
aria-label={option.text || "none"} aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined} aria-keyshortcuts={option.keyBinding || undefined}
key={option.text} key={option.text}
@@ -153,26 +233,38 @@ function Picker<T>({
); );
}; };
const isMobile = editorInterface.formFactor === "phone"; const renderSections = (sections: readonly PickerSection<T>[]) =>
sections.map((section, index) =>
section.name === DEFAULT_SECTION_NAME ? (
<React.Fragment key={`${section.name}-${index}`}>
{renderOptions(section.options)}
</React.Fragment>
) : (
<div className="picker-section" key={`${section.name}-${index}`}>
<div className="picker-section-label">{section.name}</div>
{renderOptions(section.options)}
</div>
),
);
return ( return (
<Popover.Content <Popover.Content
side={isMobile ? "right" : "bottom"} className="picker"
role="dialog"
aria-modal="true"
aria-label={label}
side={"bottom"}
align="start" align="start"
sideOffset={isMobile ? 8 : 12} sideOffset={12}
alignOffset={12}
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }} style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
collisionBoundary={container ?? undefined} collisionBoundary={container ?? undefined}
> >
<div <div className="picker-sections">
className={`picker`} {renderSections(visibleSections)}
role="dialog"
aria-modal="true"
aria-label={label}
>
{renderOptions(alwaysVisibleOptions)}
{moreOptions.length > 0 && ( {hiddenSections.length > 0 && (
<Collapsible <Collapsible
label={t("labels.more_options")} label={t("labels.more_options")}
open={showMoreOptions} open={showMoreOptions}
@@ -181,7 +273,9 @@ function Picker<T>({
}} }}
className="picker-collapsible" className="picker-collapsible"
> >
{renderOptions(moreOptions)} <div className="picker-sections">
{renderSections(hiddenSections)}
</div>
</Collapsible> </Collapsible>
)} )}
</div> </div>
@@ -192,49 +286,45 @@ function Picker<T>({
export function IconPicker<T>({ export function IconPicker<T>({
value, value,
label, label,
options, visibleSections,
hiddenSections,
onChange, onChange,
group = "",
numberOfOptionsToAlwaysShow,
}: { }: {
label: string; label: string;
value: T; value: T;
options: readonly { visibleSections: readonly PickerSection<T>[];
value: T; hiddenSections?: readonly PickerSection<T>[];
text: string;
icon: JSX.Element;
keyBinding: string | null;
}[];
onChange: (value: T) => void; onChange: (value: T) => void;
numberOfOptionsToAlwaysShow?: number;
group?: string;
}) { }) {
const [isActive, setActive] = React.useState(false); const [isActive, setActive] = React.useState(false);
const rPickerButton = React.useRef<any>(null); const selectedOption = useMemo(
() =>
findOption(visibleSections, (option) => option.value === value) ??
findOption(hiddenSections ?? [], (option) => option.value === value),
[visibleSections, hiddenSections, value],
);
return ( return (
<div> <div>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}> <Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
<Popover.Trigger <Popover.Trigger
name={group}
type="button" type="button"
aria-label={label} aria-label={label}
onClick={() => setActive(!isActive)} onClick={() => setActive(!isActive)}
ref={rPickerButton}
className={isActive ? "active" : ""} className={isActive ? "active" : ""}
> >
{options.find((option) => option.value === value)?.icon} {selectedOption?.icon}
</Popover.Trigger> </Popover.Trigger>
{isActive && ( {isActive && (
<Picker <Picker
options={options} visibleSections={visibleSections}
hiddenSections={hiddenSections}
value={value} value={value}
label={label} label={label}
onChange={onChange} onChange={onChange}
onClose={() => { onClose={() => {
setActive(false); setActive(false);
}} }}
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
/> />
)} )}
</Popover.Root> </Popover.Root>
+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");
@@ -236,15 +236,40 @@ const MermaidToExcalidraw = ({
<Trans <Trans
i18nKey="mermaid.description" i18nKey="mermaid.description"
flowchartLink={(el) => ( flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a> <a
href="https://mermaid.js.org/syntax/flowchart.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)} )}
sequenceLink={(el) => ( sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> <a
href="https://mermaid.js.org/syntax/sequenceDiagram.html"
target="_blank"
rel="noreferrer"
>
{el} {el}
</a> </a>
)} )}
classLink={(el) => ( classLink={(el) => (
<a href="https://mermaid.js.org/syntax/classDiagram.html">{el}</a> <a
href="https://mermaid.js.org/syntax/classDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
erdLink={(el) => (
<a
href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)} )}
/> />
</div> </div>
@@ -94,7 +94,7 @@ export const convertMermaidToExcalidraw = async ({
} }
} }
const { elements, files } = ret; const { elements, files = {} } = ret;
setError(null); setError(null);
data.current = { data.current = {
@@ -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}
+135 -76
View File
@@ -69,6 +69,11 @@ const modifiedTablerIconProps: Opts = {
strokeLinejoin: "round", strokeLinejoin: "round",
} as const; } as const;
const arrowheadPreviewIconProps: Opts = {
width: 40,
height: 20,
} as const;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// tabler-icons: present // tabler-icons: present
@@ -1291,16 +1296,17 @@ export const ArrowheadNoneIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
transform={flip ? "translate(24, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
stroke="currentColor" stroke="currentColor"
opacity={0.3} opacity={0.3}
strokeWidth={2} strokeWidth={2}
fill="none"
strokeLinecap="round"
> >
<path d="M12 12l-9 0" /> <path d="M7,11 H19" />
<path d="M21 9l-6 6" /> <path d="M25,6 L33,16 M33,6 L25,16" />
<path d="M21 15l-6 -6" />
</g>, </g>,
tablerIconProps, arrowheadPreviewIconProps,
), ),
); );
@@ -1312,57 +1318,12 @@ export const ArrowheadArrowIcon = React.memo(
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth={2}
fill="none" fill="none"
strokeLinecap="round"
strokeLinejoin="round"
> >
<path d="M34 10H6M34 10L27 5M34 10L27 15" /> <path d="M7,11 H33 M23,5 L33,11 L23,17" />
<path d="M27.5 5L34.5 10L27.5 15" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
),
);
export const ArrowheadCircleIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M32 10L6 10" strokeWidth={2} />
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCircleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
>
<path d="M26 10L6 10" />
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadBarIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
<path
d="M34 10H5.99996M34 10L34 5M34 10L34 15"
stroke="currentColor"
strokeWidth={2}
fill="none"
/>
</g>,
{ width: 40, height: 20 },
), ),
); );
@@ -1373,11 +1334,12 @@ export const ArrowheadTriangleIcon = React.memo(
stroke="currentColor" stroke="currentColor"
fill="currentColor" fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round"
> >
<path d="M32 10L6 10" strokeWidth={2} /> <path d="M7,11 H23" strokeWidth={2} strokeLinecap="round" />
<path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" /> <path d="M23,5 L35,11 L23,17 Z" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
@@ -1390,12 +1352,43 @@ export const ArrowheadTriangleOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2} strokeWidth={2}
strokeLinejoin="round" strokeLinejoin="round"
strokeLinecap="round"
> >
<path d="M6,9.5H27" /> <path d="M7,11 H23" />
<path d="M27,5L34,10L27,14Z" fill="none" /> <path d="M23,5 L35,11 L23,17 Z" />
</g>, </g>,
arrowheadPreviewIconProps,
),
);
{ width: 40, height: 20 }, export const ArrowheadCircleIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M7,11 H25" strokeWidth={2} strokeLinecap="round" />
<circle cx="29" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCircleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
>
<path d="M7,11 H25" strokeLinecap="round" />
<circle cx="29" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
), ),
); );
@@ -1407,12 +1400,11 @@ export const ArrowheadDiamondIcon = React.memo(
fill="currentColor" fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2}
> >
<path d="M6,9.5H20" /> <path d="M7,11 H21" strokeWidth={2} strokeLinecap="round" />
<path d="M27,5L34,10L27,14L20,9.5Z" /> <path d="M21,11 L28,5 L35,11 L28,17 Z" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
@@ -1425,15 +1417,32 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
strokeLinecap="round"
> >
<path d="M6,9.5H20" /> <path d="M7,11 H21" />
<path d="M27,5L34,10L27,14L20,9.5Z" /> <path d="M21,11 L28,5 L35,11 L28,17 Z" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
export const ArrowheadCrowfootIcon = React.memo( export const ArrowheadBarIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
>
<path d="M11,11 H31 M31,5 V17" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1443,13 +1452,13 @@ export const ArrowheadCrowfootIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" /> <path d="M35,11 H7 M15,5 V17" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
export const ArrowheadCrowfootOneIcon = React.memo( export const ArrowheadCardinalityManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1459,13 +1468,13 @@ export const ArrowheadCrowfootOneIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M34,10 H6 M15,10 L15,15 L15,5" /> <path d="M35,11 H7 M15,11 L7,5 M15,11 L7,17" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
export const ArrowheadCrowfootOneOrManyIcon = React.memo( export const ArrowheadCardinalityOneOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1475,9 +1484,59 @@ export const ArrowheadCrowfootOneOrManyIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" /> <path d="M35,11 H7 M23,5 V17 M15,11 L7,5 M15,11 L7,17" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityExactlyOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H7 M15,5 V17 M7,5 V17" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityZeroOrOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H19 M11,11 H7 M7,5 V17" />
<circle cx="15" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityZeroOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H27 M19,11 H7 M15,11 L7,5 M15,11 L7,17" />
<circle cx="23" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
), ),
); );
+4 -6
View File
@@ -34,9 +34,10 @@
--popup-text-color: #000; --popup-text-color: #000;
--popup-text-inverted-color: #fff; --popup-text-inverted-color: #fff;
--select-highlight-color: #{$color-blue-5}; --select-highlight-color: #{$color-blue-5};
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17), --shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08), 0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
0px 7px 14px 0px rgba(0, 0, 0, 0.05); --shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgb(0 0 0 / 18%);
--button-hover-bg: var(--color-surface-high); --button-hover-bg: var(--color-surface-high);
--button-active-bg: var(--color-surface-high); --button-active-bg: var(--color-surface-high);
@@ -210,9 +211,6 @@
--popup-text-color: #{$color-gray-4}; --popup-text-color: #{$color-gray-4};
--popup-text-inverted-color: #2c2c2c; --popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$color-blue-4}; --select-highlight-color: #{$color-blue-4};
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07), --modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198), 0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
-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",
+11 -3
View File
@@ -22,6 +22,7 @@ import {
import { import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
getNonDeletedElements, getNonDeletedElements,
normalizeArrowhead,
isPointInElement, isPointInElement,
isValidPolygon, isValidPolygon,
projectFixedPointOntoDiagonal, projectFixedPointOntoDiagonal,
@@ -250,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);
} }
@@ -426,7 +429,8 @@ export const restoreElement = (
// @ts-ignore LEGACY type // @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough // eslint-disable-next-line no-fallthrough
case "draw": case "draw":
const { startArrowhead = null, endArrowhead = null } = element; const startArrowhead = normalizeArrowhead(element.startArrowhead);
const endArrowhead = normalizeArrowhead(element.endArrowhead);
let x = element.x; let x = element.x;
let y = element.y; let y = element.y;
let points = // migrate old arrow model to new one let points = // migrate old arrow model to new one
@@ -458,7 +462,11 @@ export const restoreElement = (
...getSizeFromPoints(points), ...getSizeFromPoints(points),
}); });
case "arrow": { case "arrow": {
const { startArrowhead = null, endArrowhead = "arrow" } = element; const startArrowhead = normalizeArrowhead(element.startArrowhead);
const endArrowhead =
element.endArrowhead === undefined
? "arrow"
: normalizeArrowhead(element.endArrowhead);
const x: number | undefined = element.x; const x: number | undefined = element.x;
const y: number | undefined = element.y; const y: number | undefined = element.y;
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
+119 -57
View File
@@ -6,6 +6,51 @@ import { getDefaultAppState } from "../appState";
import type { AppState } from "../types"; import type { AppState } from "../types";
type AppStateSelector =
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown);
const getSelectedValue = (appState: AppState, selector: AppStateSelector) => {
if (typeof selector === "function") {
return selector(appState);
}
if (Array.isArray(selector)) {
return appState;
}
return appState[selector];
};
const getLatestValue = (
api: ReturnType<typeof useExcalidrawAPI>,
selector: AppStateSelector,
_internal: boolean,
) => {
if (api?.isDestroyed) {
return;
}
let appState = api?.getAppState();
if (!appState) {
if (!_internal) {
return undefined;
}
console.warn(
"useAppStateValue: excalidrawAPI not defined yet for internal component while it should always be defined. Are you sure you're rendering inside of <Excalidraw/> component tree?",
);
// fall back in case there's a bug so we don't break the app
// (internal components using this internal useAppStateValue expect
// non-undefined values on init)
appState = Object.assign(
{ width: 0, height: 0, offsetLeft: 0, offsetTop: 0 },
getDefaultAppState(),
);
}
return getSelectedValue(appState, selector);
};
/** /**
* Subscribes to specific appState changes. The component re-renders * Subscribes to specific appState changes. The component re-renders
* only when the specified prop(s) change not on every appState update. * only when the specified prop(s) change not on every appState update.
@@ -19,92 +64,109 @@ import type { AppState } from "../types";
* - `(keyof AppState)[]` whole `AppState` * - `(keyof AppState)[]` whole `AppState`
* - selector function selector's return type `T` * - selector function selector's return type `T`
* *
* Calls the optional callback with the latest value on every change (not called
* on initial render).
*
* If excalidrawAPI is not ready yet (host apps), hook is rerendered with latest * If excalidrawAPI is not ready yet (host apps), hook is rerendered with latest
* value once available. * value once available.
*/ */
export function useAppStateValue<K extends keyof AppState>( export function useAppStateValue<K extends keyof AppState>(
prop: K, prop: K,
callback?: (value: AppState[K], appState: AppState) => void,
_internal?: boolean, _internal?: boolean,
): AppState[K]; ): AppState[K];
export function useAppStateValue( export function useAppStateValue(
props: (keyof AppState)[], props: (keyof AppState)[],
callback?: (props: AppState, appState: AppState) => void,
_internal?: boolean, _internal?: boolean,
): AppState; ): AppState;
export function useAppStateValue<T>( export function useAppStateValue<T>(
selector: (appState: AppState) => T, selector: (appState: AppState) => T,
callback?: (value: T, appState: AppState) => void,
_internal?: boolean, _internal?: boolean,
): T; ): T;
export function useAppStateValue( export function useAppStateValue(
selector: selector: AppStateSelector,
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown),
callback?: (value: any, appState: AppState) => void,
_internal: boolean = true, _internal: boolean = true,
): unknown { ): unknown {
const api = useExcalidrawAPI(); const api = useExcalidrawAPI();
const [, rerender] = useState(0);
const getLatestValue = () => { const stateRef = useRef<{
let appState = api?.getAppState(); selector: AppStateSelector;
if (!appState) { isInitialized: boolean;
if (!_internal) { latestValue: unknown;
return undefined; } | null>(null);
} if (!stateRef.current) {
console.warn( stateRef.current = {
"useAppStateValue: excalidrawAPI not defined yet for internal component in which case it should always be defined. Are you sure you're rendering inside of <Excalidraw/> component tree?", selector,
); isInitialized: !!api,
// fall back in case there's a bug so we don't break the app latestValue: getLatestValue(api, selector, _internal),
// (internal components using this internal useAppStateValue expect };
// non-undefined values on init) }
appState = Object.assign(
{ width: 0, height: 0, offsetLeft: 0, offsetTop: 0 },
getDefaultAppState(),
);
}
if (typeof selector === "function") {
return selector(appState);
}
if (Array.isArray(selector)) {
return appState;
}
return appState[selector];
};
const [value, setValue] = useState<unknown>(getLatestValue);
const stateRef = useRef({
selector,
callback,
isInitialized: !!api,
latestValue: value,
});
stateRef.current.selector = selector; stateRef.current.selector = selector;
stateRef.current.callback = callback; if (!stateRef.current.isInitialized && api && !api.isDestroyed) {
if (!stateRef.current.isInitialized && api) {
stateRef.current.isInitialized = true; stateRef.current.isInitialized = true;
stateRef.current.latestValue = getLatestValue(); stateRef.current.latestValue = getLatestValue(api, selector, _internal);
} }
useEffect(() => { useEffect(() => {
if (!api) { const currentStateRef = stateRef.current;
if (!api || api.isDestroyed || !currentStateRef) {
return; return;
} }
return api.onStateChange( return api.onStateChange(currentStateRef.selector, (newValue: any) => {
stateRef.current.selector, currentStateRef.latestValue = newValue;
(newValue: any, state: AppState) => { rerender((value) => value + 1);
stateRef.current.latestValue = newValue; });
stateRef.current.callback?.(newValue, state);
setValue(newValue);
},
);
}, [api]); }, [api]);
return stateRef.current.latestValue; return stateRef.current.latestValue;
} }
/**
* Subscribes to specific appState changes without causing component rerenders.
*
* The callback is called on every matching change, but also on initial render
* so you can initialize your state.
*/
export function useOnAppStateChange<K extends keyof AppState>(
prop: K,
callback: (value: AppState[K], appState: AppState) => void,
): undefined;
export function useOnAppStateChange(
props: (keyof AppState)[],
callback: (props: AppState, appState: AppState) => void,
): undefined;
export function useOnAppStateChange<T>(
selector: (appState: AppState) => T,
callback: (value: T, appState: AppState) => void,
): undefined;
export function useOnAppStateChange(
selector: AppStateSelector,
callback: (value: any, appState: AppState) => void,
): undefined {
const api = useExcalidrawAPI();
const stateRef = useRef({
selector,
callback,
});
stateRef.current.selector = selector;
stateRef.current.callback = callback;
useEffect(() => {
if (!api || api.isDestroyed) {
return;
}
stateRef.current.callback(
getLatestValue(api, stateRef.current.selector, true),
api.getAppState(),
);
return api.onStateChange(
stateRef.current.selector,
(newValue: any, state: AppState) => {
stateRef.current.callback(newValue, state);
},
);
}, [api]);
return undefined;
}
+32 -17
View File
@@ -1,4 +1,10 @@
import React, { useCallback, useContext, useEffect, useState } from "react"; import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common"; import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
@@ -12,7 +18,10 @@ import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollab
import MainMenu from "./components/main-menu/MainMenu"; import MainMenu from "./components/main-menu/MainMenu";
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen"; import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
import { defaultLang } from "./i18n"; import { defaultLang } from "./i18n";
import { useAppStateValue as _useAppStateValue } from "./hooks/useAppStateValue"; import {
useAppStateValue as _useAppStateValue,
useOnAppStateChange as _useOnAppStateChange,
} from "./hooks/useAppStateValue";
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai"; import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
import polyfill from "./polyfill"; import polyfill from "./polyfill";
@@ -31,8 +40,8 @@ polyfill();
/** /**
* Stateless provider that allows `useExcalidrawAPI()` (and hooks built * Stateless provider that allows `useExcalidrawAPI()` (and hooks built
* on it, such as `useAppStateValue()`) to work outside the <Excalidraw> * on it, such as `useAppStateValue()` and `useOnAppStateChange()`) to work
* component tree. * outside the <Excalidraw> component tree.
*/ */
export const ExcalidrawAPIProvider = ({ export const ExcalidrawAPIProvider = ({
children, children,
@@ -57,6 +66,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
initialData, initialData,
onExcalidrawAPI, onExcalidrawAPI,
onMount, onMount,
onUnmount,
onInitialize, onInitialize,
isCollaborating = false, isCollaborating = false,
onPointerUpdate, onPointerUpdate,
@@ -119,12 +129,16 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
} }
const setExcalidrawAPI = useContext(ExcalidrawAPISetContext); const setExcalidrawAPI = useContext(ExcalidrawAPISetContext);
const onExcalidrawAPIRef = useRef(onExcalidrawAPI);
onExcalidrawAPIRef.current = onExcalidrawAPI;
const handleExcalidrawAPI = useCallback( const handleExcalidrawAPI = useCallback(
(api: ExcalidrawImperativeAPI) => { (api: ExcalidrawImperativeAPI | null) => {
setExcalidrawAPI?.(api); setExcalidrawAPI?.(api);
onExcalidrawAPI?.(api); onExcalidrawAPIRef.current?.(api);
}, },
[onExcalidrawAPI, setExcalidrawAPI], [setExcalidrawAPI],
); );
useEffect(() => { useEffect(() => {
@@ -162,6 +176,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
initialData={initialData} initialData={initialData}
onExcalidrawAPI={handleExcalidrawAPI} onExcalidrawAPI={handleExcalidrawAPI}
onMount={onMount} onMount={onMount}
onUnmount={onUnmount}
onInitialize={onInitialize} onInitialize={onInitialize}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onPointerUpdate={onPointerUpdate} onPointerUpdate={onPointerUpdate}
@@ -367,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";
@@ -378,7 +395,7 @@ export {
} from "./charts"; } from "./charts";
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// useAppStateValue() wrapper for host apps for the return type to reflect the // useExcalidrawStateValue() wrapper for host apps for the return type to reflect the
// the potentially `undefined` value for initial render before the excalidrawAPI // the potentially `undefined` value for initial render before the excalidrawAPI
// is ready. // is ready.
// //
@@ -388,25 +405,23 @@ export {
* @param prop - appState prop(s) to subscribe to, or a selector function. * @param prop - appState prop(s) to subscribe to, or a selector function.
* NOTE `prop/selector` is memoized and will not change after initial render * NOTE `prop/selector` is memoized and will not change after initial render
*/ */
export function useAppStateValue<K extends keyof AppState>( export function useExcalidrawStateValue<K extends keyof AppState>(
prop: K, prop: K,
callback?: (value: AppState[K], appState: AppState) => void,
): AppState[K] | undefined; ): AppState[K] | undefined;
export function useAppStateValue<T extends keyof AppState>( export function useExcalidrawStateValue<T extends keyof AppState>(
props: T[], props: T[],
callback?: (values: AppState, appState: AppState) => void,
): AppState | undefined; ): AppState | undefined;
export function useAppStateValue<T>( export function useExcalidrawStateValue<T>(
selector: (appState: AppState) => T, selector: (appState: AppState) => T,
callback?: (value: T, appState: AppState) => void,
): T | undefined; ): T | undefined;
export function useAppStateValue( export function useExcalidrawStateValue(
selector: selector:
| keyof AppState | keyof AppState
| (keyof AppState)[] | (keyof AppState)[]
| ((appState: AppState) => unknown), | ((appState: AppState) => unknown),
callback?: (value: any, appState: AppState) => void,
) { ) {
return _useAppStateValue(selector as any, callback, false); return _useAppStateValue(selector as any, false);
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export { _useOnAppStateChange as useOnExcalidrawStateChange };
+9 -2
View File
@@ -53,7 +53,14 @@
"arrowhead_crowfoot_many": "Crow's foot (many)", "arrowhead_crowfoot_many": "Crow's foot (many)",
"arrowhead_crowfoot_one": "Crow's foot (one)", "arrowhead_crowfoot_one": "Crow's foot (one)",
"arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)", "arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)",
"arrowhead_cardinality_one": "Cardinality (one)",
"arrowhead_cardinality_many": "Cardinality (many)",
"arrowhead_cardinality_one_or_many": "Cardinality (one or many)",
"arrowhead_cardinality_exactly_one": "Cardinality (exactly one)",
"arrowhead_cardinality_zero_or_one": "Cardinality (zero or one)",
"arrowhead_cardinality_zero_or_many": "Cardinality (zero or many)",
"more_options": "More options", "more_options": "More options",
"cardinality": "Cardinality",
"arrowtypes": "Arrow type", "arrowtypes": "Arrow type",
"arrowtype_sharp": "Sharp arrow", "arrowtype_sharp": "Sharp arrow",
"arrowtype_round": "Curved arrow", "arrowtype_round": "Curved arrow",
@@ -624,7 +631,7 @@
"mermaid": { "mermaid": {
"title": "Mermaid to Excalidraw", "title": "Mermaid to Excalidraw",
"button": "Insert", "button": "Insert",
"description": "Currently only <flowchartLink>Flowchart</flowchartLink>,<sequenceLink> Sequence, </sequenceLink> and <classLink>Class </classLink>Diagrams are supported. The other types will be rendered as image in Excalidraw.", "description": "Currently only <flowchartLink>Flowchart</flowchartLink>, <sequenceLink>Sequence</sequenceLink>, <classLink>Class</classLink>, and <erdLink>Entity Relationship</erdLink> Diagrams are supported. The other types will be rendered as image in Excalidraw.",
"syntax": "Mermaid Syntax", "syntax": "Mermaid Syntax",
"preview": "Preview", "preview": "Preview",
"label": "Mermaid", "label": "Mermaid",
@@ -654,7 +661,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, and Class 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.0.0-rc4", "@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;
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`; exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</a> Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = ` exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
"flowchart TD "flowchart TD
@@ -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,
@@ -0,0 +1,111 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { getDefaultAppState } from "../appState";
import { ExcalidrawAPIContext } from "../components/App";
import { AppStateObserver } from "../components/AppStateObserver";
import {
useAppStateValue,
useOnAppStateChange,
} from "../hooks/useAppStateValue";
import type { AppState, ExcalidrawImperativeAPI } from "../types";
const createAppState = (): AppState => ({
...getDefaultAppState(),
width: 0,
height: 0,
offsetLeft: 0,
offsetTop: 0,
});
const createMockAPI = (initialState: AppState) => {
let state = initialState;
const observer = new AppStateObserver(() => state);
return {
api: {
isDestroyed: false,
getAppState: () => state,
onStateChange: observer.onStateChange,
} as Pick<
ExcalidrawImperativeAPI,
"isDestroyed" | "getAppState" | "onStateChange"
> as ExcalidrawImperativeAPI,
updateAppState: (partial: Partial<AppState>) => {
const prevState = state;
state = { ...state, ...partial };
observer.flush(prevState);
},
};
};
describe("app state hooks", () => {
afterEach(() => {
cleanup();
});
it("useAppStateValue rerenders when the selected value changes", () => {
const renderSpy = vi.fn();
const { api, updateAppState } = createMockAPI(createAppState());
const ValueConsumer = () => {
const value = useAppStateValue("viewModeEnabled");
renderSpy(value);
return <div data-testid="value">{String(value)}</div>;
};
render(
<ExcalidrawAPIContext.Provider value={api}>
<ValueConsumer />
</ExcalidrawAPIContext.Provider>,
);
expect(screen.getByTestId("value").textContent).toBe("false");
expect(renderSpy).toHaveBeenCalledTimes(1);
act(() => {
updateAppState({ viewModeEnabled: true });
});
expect(screen.getByTestId("value").textContent).toBe("true");
expect(renderSpy).toHaveBeenCalledTimes(2);
});
it("useOnAppStateChange notifies without rerendering", () => {
const renderSpy = vi.fn();
const callback = vi.fn();
const { api, updateAppState } = createMockAPI(createAppState());
const ChangeConsumer = () => {
const value = useOnAppStateChange("viewModeEnabled", callback);
renderSpy(value);
return <div data-testid="value">{String(value)}</div>;
};
render(
<ExcalidrawAPIContext.Provider value={api}>
<ChangeConsumer />
</ExcalidrawAPIContext.Provider>,
);
expect(screen.getByTestId("value").textContent).toBe("undefined");
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
false,
expect.objectContaining({ viewModeEnabled: false }),
);
act(() => {
updateAppState({ viewModeEnabled: true });
});
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ viewModeEnabled: true }),
);
});
});
@@ -200,6 +200,26 @@ describe("restoreElements", () => {
}); });
}); });
it("should normalize legacy crowfoot arrowheads on restore", () => {
const arrowElement = API.createElement({
type: "arrow",
});
const restoredArrow = restore.restoreElements(
[
{
...arrowElement,
startArrowhead: "crowfoot_one",
endArrowhead: "crowfoot_one_or_many",
} as any,
],
null,
)[0] as ExcalidrawLinearElement;
expect(restoredArrow.startArrowhead).toBe("cardinality_one");
expect(restoredArrow.endArrowhead).toBe("cardinality_one_or_many");
});
it("should strip element if restore fails", () => { it("should strip element if restore fails", () => {
const rect1 = API.createElement({ const rect1 = API.createElement({
type: "rectangle", type: "rectangle",
+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");
@@ -41,13 +41,15 @@ describe("event callbacks", () => {
await render( await render(
<Excalidraw <Excalidraw
onExcalidrawAPI={(api) => onExcalidrawAPI={(api) => {
lifecyclePromise.resolve({ if (api) {
api: api as ExcalidrawImperativeAPI, lifecyclePromise.resolve({
mount: api.onEvent("editor:mount"), api,
initialize: api.onEvent("editor:initialize"), mount: api.onEvent("editor:mount"),
}) initialize: api.onEvent("editor:initialize"),
} });
}
}}
/>, />,
); );
+60 -5
View File
@@ -326,7 +326,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 +359,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 +392,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 +438,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 +483,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 +558,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
);
};
+12 -5
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 {
@@ -327,7 +328,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
@@ -573,11 +574,15 @@ export interface ExcalidrawProps {
* Invoked as soon as the Excalidraw API is available * Invoked as soon as the Excalidraw API is available
* NOTE editor is not yet mounted, and state is not yet initialized * NOTE editor is not yet mounted, and state is not yet initialized
*/ */
onExcalidrawAPI?: (api: ExcalidrawImperativeAPI) => void; onExcalidrawAPI?: (api: ExcalidrawImperativeAPI | null) => void;
/** /**
* Invoked once the editor root is mounted. * Invoked once the editor root is mounted.
*/ */
onMount?: (payload: ExcalidrawMountPayload) => void; onMount?: (payload: ExcalidrawMountPayload) => void;
/**
* Invoked when the editor root is unmounted.
*/
onUnmount?: () => void;
/** /**
* Invoked once the initial scene is loaded. * Invoked once the initial scene is loaded.
*/ */
@@ -872,9 +877,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
@@ -907,9 +911,12 @@ export type ExcalidrawMountPayload = {
export type ExcalidrawImperativeAPIEventMap = { export type ExcalidrawImperativeAPIEventMap = {
"editor:mount": [payload: ExcalidrawMountPayload]; "editor:mount": [payload: ExcalidrawMountPayload];
"editor:initialize": [api: ExcalidrawImperativeAPI]; "editor:initialize": [api: ExcalidrawImperativeAPI];
"editor:unmount": [];
}; };
export interface ExcalidrawImperativeAPI { export interface ExcalidrawImperativeAPI {
/** Whether the editor has been unmounted and the API is no longer usable. */
isDestroyed: boolean;
updateScene: InstanceType<typeof App>["updateScene"]; updateScene: InstanceType<typeof App>["updateScene"];
applyDeltas: InstanceType<typeof App>["applyDeltas"]; applyDeltas: InstanceType<typeof App>["applyDeltas"];
mutateElement: InstanceType<typeof App>["mutateElement"]; mutateElement: InstanceType<typeof App>["mutateElement"];
@@ -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
+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);
});
});
+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.0.0-rc4": "@excalidraw/mermaid-to-excalidraw@2.2.2":
version "2.0.0-rc4" version "2.2.2"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.0.0-rc4.tgz#9d38568de8e403fefb6a162efa71e024ea1f7e03" resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz#ee6b597a0d95b9a76f7ae41ce0e3733a9b96e4a0"
integrity sha512-92efu7VTYF6appGKgbDJAZEM/YXICpYPYOfl+j1E9SVilIFCJEAg7xH2lt/c44WFtffG+rWKrYwf9KUrPYy1Qw== 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"