Compare commits

..
Author SHA1 Message Date
dwelle 1625edeb07 wip 2026-03-14 12:38:38 +01:00
69 changed files with 1526 additions and 4052 deletions
+12 -8
View File
@@ -25,15 +25,19 @@ export const AIComponents = ({
const appState = excalidrawAPI.getAppState(); const appState = excalidrawAPI.getAppState();
const blob = await exportToBlob({ const blob = await exportToBlob({
elements: children, data: {
appState: { elements: children,
...appState, appState: {
exportBackground: true, ...appState,
viewBackgroundColor: appState.viewBackgroundColor, exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
files: excalidrawAPI.getFiles(),
},
config: {
exportingFrame: frame,
mimeType: MIME_TYPES.jpg,
}, },
exportingFrame: frame,
files: excalidrawAPI.getFiles(),
mimeType: MIME_TYPES.jpg,
}); });
const dataURL = await getDataURL(blob); const dataURL = await getDataURL(blob);
+1 -36
View File
@@ -1,5 +1,3 @@
import type { Radians } from "@excalidraw/math";
/** /**
* x and y position of top left corner, x and y position of bottom right corner * x and y position of top left corner, x and y position of bottom right corner
*/ */
@@ -8,31 +6,7 @@ export type Bounds = readonly [
minY: number, minY: number,
maxX: number, maxX: number,
maxY: number, maxY: number,
] & { _brand: "excalidraw__bounds" }; ];
export type RotatedBounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
angle: Radians,
] & {
_brand_rotated: "excalidraw__rotated_bounds";
};
export const bounds = <T extends Radians | undefined = undefined>(
minX: number,
minY: number,
maxX: number,
maxY: number,
angle: T = undefined as T,
) => {
return (
angle
? ([minX, minY, maxX, maxY, angle] as unknown)
: ([minX, minY, maxX, maxY] as unknown)
) as T extends Radians ? RotatedBounds : Bounds;
};
export const isBounds = (box: unknown): box is Bounds => export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) && Array.isArray(box) &&
@@ -41,12 +15,3 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" && typeof box[1] === "number" &&
typeof box[2] === "number" && typeof box[2] === "number" &&
typeof box[3] === "number"; typeof box[3] === "number";
export const isRotatedBounds = (box: unknown): box is RotatedBounds =>
Array.isArray(box) &&
box.length === 5 &&
typeof box[0] === "number" &&
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number" &&
typeof box[4] === "number";
+60 -74
View File
@@ -2,11 +2,9 @@ import rough from "roughjs/bin/rough";
import { import {
arrayToMap, arrayToMap,
bounds,
type Bounds, type Bounds,
invariant, invariant,
rescalePoints, rescalePoints,
type RotatedBounds,
sizeOf, sizeOf,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -92,7 +90,7 @@ export class ElementBounds {
private static boundsCache = new WeakMap< private static boundsCache = new WeakMap<
ExcalidrawElement, ExcalidrawElement,
{ {
bounds: RotatedBounds; bounds: Bounds;
version: ExcalidrawElement["version"]; version: ExcalidrawElement["version"];
} }
>(); >();
@@ -104,11 +102,11 @@ export class ElementBounds {
} }
>(); >();
static getBounds<T extends boolean = false>( static getBounds(
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
nonRotated: T = false as T, nonRotated: boolean = false,
): T extends true ? Bounds : RotatedBounds { ) {
const cachedBounds = const cachedBounds =
nonRotated && element.angle !== 0 nonRotated && element.angle !== 0
? ElementBounds.nonRotatedBoundsCache.get(element) ? ElementBounds.nonRotatedBoundsCache.get(element)
@@ -121,41 +119,40 @@ export class ElementBounds {
// which is causing problems down the line. Fix TBA. // which is causing problems down the line. Fix TBA.
!isBoundToContainer(element) !isBoundToContainer(element)
) { ) {
return cachedBounds.bounds as T extends true ? Bounds : RotatedBounds; return cachedBounds.bounds;
} }
if (nonRotated && element.angle !== 0) { if (nonRotated && element.angle !== 0) {
const [minX, minY, maxX, maxY] = ElementBounds.calculateBounds( const nonRotatedBounds = ElementBounds.calculateBounds(
{ {
...element, ...element,
angle: 0 as Radians, angle: 0 as Radians,
}, },
elementsMap, elementsMap,
); );
const nonRotatedBounds = bounds(minX, minY, maxX, maxY);
ElementBounds.nonRotatedBoundsCache.set(element, { ElementBounds.nonRotatedBoundsCache.set(element, {
version: element.version, version: element.version,
bounds: nonRotatedBounds, bounds: nonRotatedBounds,
}); });
return nonRotatedBounds as T extends true ? Bounds : RotatedBounds; return nonRotatedBounds;
} }
const _bounds = ElementBounds.calculateBounds(element, elementsMap); const bounds = ElementBounds.calculateBounds(element, elementsMap);
ElementBounds.boundsCache.set(element, { ElementBounds.boundsCache.set(element, {
version: element.version, version: element.version,
bounds: _bounds, bounds,
}); });
return _bounds as T extends true ? Bounds : RotatedBounds; return bounds;
} }
private static calculateBounds( private static calculateBounds(
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): RotatedBounds { ): Bounds {
let _bounds: RotatedBounds; let bounds: Bounds;
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element, element,
@@ -172,15 +169,14 @@ export class ElementBounds {
), ),
); );
return bounds( return [
minX + element.x, minX + element.x,
minY + element.y, minY + element.y,
maxX + element.x, maxX + element.x,
maxY + element.y, maxY + element.y,
element.angle, ];
);
} else if (isLinearElement(element)) { } else if (isLinearElement(element)) {
_bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap); bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
} else if (element.type === "diamond") { } else if (element.type === "diamond") {
const [x11, y11] = pointRotateRads( const [x11, y11] = pointRotateRads(
pointFrom(cx, y1), pointFrom(cx, y1),
@@ -206,7 +202,7 @@ export class ElementBounds {
const minY = Math.min(y11, y12, y22, y21); const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21); const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21); const maxY = Math.max(y11, y12, y22, y21);
_bounds = bounds(minX, minY, maxX, maxY, element.angle); bounds = [minX, minY, maxX, maxY];
} else if (element.type === "ellipse") { } else if (element.type === "ellipse") {
const w = (x2 - x1) / 2; const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2; const h = (y2 - y1) / 2;
@@ -214,7 +210,7 @@ export class ElementBounds {
const sin = Math.sin(element.angle); const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin); const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * sin); const hh = Math.hypot(h * cos, w * sin);
_bounds = bounds(cx - ww, cy - hh, cx + ww, cy + hh, element.angle); bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else { } else {
const [x11, y11] = pointRotateRads( const [x11, y11] = pointRotateRads(
pointFrom(x1, y1), pointFrom(x1, y1),
@@ -240,10 +236,10 @@ export class ElementBounds {
const minY = Math.min(y11, y12, y22, y21); const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21); const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21); const maxY = Math.max(y11, y12, y22, y21);
_bounds = bounds(minX, minY, maxX, maxY, element.angle); bounds = [minX, minY, maxX, maxY];
} }
return _bounds; return bounds;
} }
} }
@@ -628,7 +624,7 @@ export const getCubicBezierCurveBound = (
minY = Math.min(minY, ...ys); minY = Math.min(minY, ...ys);
maxY = Math.max(maxY, ...ys); maxY = Math.max(maxY, ...ys);
} }
return bounds(minX, minY, maxX, maxY); return [minX, minY, maxX, maxY];
}; };
export const getMinMaxXYFromCurvePathOps = ( export const getMinMaxXYFromCurvePathOps = (
@@ -681,7 +677,7 @@ export const getMinMaxXYFromCurvePathOps = (
}, },
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
); );
return bounds(minX, minY, maxX, maxY); return [minX, minY, maxX, maxY];
}; };
export const getBoundsFromPoints = ( export const getBoundsFromPoints = (
@@ -699,7 +695,7 @@ export const getBoundsFromPoints = (
maxY = Math.max(maxY, y); maxY = Math.max(maxY, y);
} }
return bounds(minX, minY, maxX, maxY); return [minX, minY, maxX, maxY];
}; };
const getFreeDrawElementAbsoluteCoords = ( const getFreeDrawElementAbsoluteCoords = (
@@ -942,7 +938,7 @@ const getLinearElementRotatedBounds = (
cx: number, cx: number,
cy: number, cy: number,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): RotatedBounds => { ): Bounds => {
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (element.points.length < 2) { if (element.points.length < 2) {
@@ -953,21 +949,20 @@ const getLinearElementRotatedBounds = (
element.angle, element.angle,
); );
let coords: RotatedBounds = bounds(x, y, x, y, element.angle); let coords: Bounds = [x, y, x, y];
if (boundTextElement) { if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element, element,
elementsMap, elementsMap,
bounds(x, y, x, y, element.angle), [x, y, x, y],
boundTextElement, boundTextElement,
); );
coords = bounds( coords = [
coordsWithBoundText[0], coordsWithBoundText[0],
coordsWithBoundText[1], coordsWithBoundText[1],
coordsWithBoundText[2], coordsWithBoundText[2],
coordsWithBoundText[3], coordsWithBoundText[3],
element.angle, ];
);
} }
return coords; return coords;
} }
@@ -983,13 +978,7 @@ const getLinearElementRotatedBounds = (
element.angle, element.angle,
); );
const res = getMinMaxXYFromCurvePathOps(ops, transformXY); const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: RotatedBounds = bounds( let coords: Bounds = [res[0], res[1], res[2], res[3]];
res[0],
res[1],
res[2],
res[3],
element.angle,
);
if (boundTextElement) { if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element, element,
@@ -997,23 +986,22 @@ const getLinearElementRotatedBounds = (
coords, coords,
boundTextElement, boundTextElement,
); );
coords = bounds( coords = [
coordsWithBoundText[0], coordsWithBoundText[0],
coordsWithBoundText[1], coordsWithBoundText[1],
coordsWithBoundText[2], coordsWithBoundText[2],
coordsWithBoundText[3], coordsWithBoundText[3],
element.angle, ];
);
} }
return coords; return coords;
}; };
export const getElementBounds = <T extends boolean = false>( export const getElementBounds = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
nonRotated: T = false as T, nonRotated: boolean = false,
) => { ): Bounds => {
return ElementBounds.getBounds<T>(element, elementsMap, nonRotated); return ElementBounds.getBounds(element, elementsMap, nonRotated);
}; };
export const getCommonBounds = ( export const getCommonBounds = (
@@ -1021,7 +1009,7 @@ export const getCommonBounds = (
elementsMap?: ElementsMap, elementsMap?: ElementsMap,
): Bounds => { ): Bounds => {
if (!sizeOf(elements)) { if (!sizeOf(elements)) {
return bounds(0, 0, 0, 0); return [0, 0, 0, 0];
} }
let minX = Infinity; let minX = Infinity;
@@ -1039,7 +1027,7 @@ export const getCommonBounds = (
maxY = Math.max(maxY, y2); maxY = Math.max(maxY, y2);
}); });
return bounds(minX, minY, maxX, maxY); return [minX, minY, maxX, maxY];
}; };
export const getDraggedElementsBounds = ( export const getDraggedElementsBounds = (
@@ -1062,12 +1050,12 @@ export const getResizedElementAbsoluteCoords = (
normalizePoints: boolean, normalizePoints: boolean,
): Bounds => { ): Bounds => {
if (!(isLinearElement(element) || isFreeDrawElement(element))) { if (!(isLinearElement(element) || isFreeDrawElement(element))) {
return bounds( return [
element.x, element.x,
element.y, element.y,
element.x + nextWidth, element.x + nextWidth,
element.y + nextHeight, element.y + nextHeight,
); ];
} }
const points = rescalePoints( const points = rescalePoints(
@@ -1077,11 +1065,11 @@ export const getResizedElementAbsoluteCoords = (
normalizePoints, normalizePoints,
); );
let _bounds: Bounds; let bounds: Bounds;
if (isFreeDrawElement(element)) { if (isFreeDrawElement(element)) {
// Free Draw // Free Draw
_bounds = getBoundsFromPoints(points); bounds = getBoundsFromPoints(points);
} else { } else {
// Line // Line
const gen = rough.generator(); const gen = rough.generator();
@@ -1093,16 +1081,16 @@ export const getResizedElementAbsoluteCoords = (
: gen.curve(points as [number, number][], generateRoughOptions(element)); : gen.curve(points as [number, number][], generateRoughOptions(element));
const ops = getCurvePathOps(curve); const ops = getCurvePathOps(curve);
_bounds = getMinMaxXYFromCurvePathOps(ops); bounds = getMinMaxXYFromCurvePathOps(ops);
} }
const [minX, minY, maxX, maxY] = _bounds; const [minX, minY, maxX, maxY] = bounds;
return bounds( return [
minX + element.x, minX + element.x,
minY + element.y, minY + element.y,
maxX + element.x, maxX + element.x,
maxY + element.y, maxY + element.y,
); ];
}; };
export const getElementPointsCoords = ( export const getElementPointsCoords = (
@@ -1120,20 +1108,20 @@ export const getElementPointsCoords = (
: gen.curve(points as [number, number][], generateRoughOptions(element)); : gen.curve(points as [number, number][], generateRoughOptions(element));
const ops = getCurvePathOps(curve); const ops = getCurvePathOps(curve);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops); const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
return bounds( return [
minX + element.x, minX + element.x,
minY + element.y, minY + element.y,
maxX + element.x, maxX + element.x,
maxY + element.y, maxY + element.y,
); ];
}; };
export const getClosestElementBounds = ( export const getClosestElementBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
from: { x: number; y: number }, from: { x: number; y: number },
): RotatedBounds => { ): Bounds => {
if (!elements.length) { if (!elements.length) {
return bounds(0, 0, 0, 0, 0 as Radians); return [0, 0, 0, 0];
} }
let minDistance = Infinity; let minDistance = Infinity;
@@ -1202,9 +1190,7 @@ export const getVisibleSceneBounds = ({
]; ];
}; };
export const getCenterForBounds = ( export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
bounds: Bounds | RotatedBounds,
): GlobalPoint =>
pointFrom( pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2, bounds[1] + (bounds[3] - bounds[1]) / 2,
@@ -1249,24 +1235,24 @@ export const aabbForElement = (
element.angle, element.angle,
); );
const _bounds = bounds( const bounds = [
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX), Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY), Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX), Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY), Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
); ] as Bounds;
if (offset) { if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset; const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return bounds( return [
_bounds[0] - leftOffset, bounds[0] - leftOffset,
_bounds[1] - topOffset, bounds[1] - topOffset,
_bounds[2] + rightOffset, bounds[2] + rightOffset,
_bounds[3] + downOffset, bounds[3] + downOffset,
); ] as Bounds;
} }
return _bounds; return bounds;
}; };
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>( export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
@@ -1275,7 +1261,7 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
): boolean => ): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
export const doNonRotatedBoundsIntersect = ( export const doBoundsIntersect = (
bounds1: Bounds | null, bounds1: Bounds | null,
bounds2: Bounds | null, bounds2: Bounds | null,
): boolean => { ): boolean => {
@@ -1295,7 +1281,7 @@ export const elementCenterPoint = (
xOffset: number = 0, xOffset: number = 0,
yOffset: number = 0, yOffset: number = 0,
) => { ) => {
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2); const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
+31 -137
View File
@@ -1,9 +1,4 @@
import { import { invariant, isTransparent, type Bounds } from "@excalidraw/common";
bounds,
invariant,
isTransparent,
type Bounds,
} from "@excalidraw/common";
import { import {
curveIntersectLineSegment, curveIntersectLineSegment,
isPointWithinBounds, isPointWithinBounds,
@@ -34,7 +29,7 @@ import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils"; import { isPathALoop } from "./utils";
import { import {
doNonRotatedBoundsIntersect, doBoundsIntersect,
elementCenterPoint, elementCenterPoint,
getCenterForBounds, getCenterForBounds,
getCubicBezierCurveBound, getCubicBezierCurveBound,
@@ -159,11 +154,14 @@ export const hitElementItself = ({
// Hit test against the extended, rotated bounding box of the element first // Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true); const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointInRotatedBounds( const hitBounds = isPointWithinBounds(
point, pointFrom(bounds[0] - threshold, bounds[1] - threshold),
bounds, pointRotateRads(
element.angle, point,
threshold, getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
); );
// PERF: Bail out early if the point is not even in the // PERF: Bail out early if the point is not even in the
@@ -194,108 +192,18 @@ export const hitElementItself = ({
return result; return result;
}; };
export function getBoundsCorners(
bounds: Bounds,
): readonly [GlobalPoint, GlobalPoint, GlobalPoint, GlobalPoint];
export function getBoundsCorners(
bounds: Bounds,
angle: Radians,
): readonly [GlobalPoint, GlobalPoint, GlobalPoint, GlobalPoint];
export function getBoundsCorners(
bounds: Bounds,
angle: Radians = 0 as Radians,
) {
const [x1, y1, x2, y2] = bounds;
const center = getCenterForBounds(bounds);
const corners = [
pointFrom<GlobalPoint>(x1, y1),
pointFrom<GlobalPoint>(x2, y1),
pointFrom<GlobalPoint>(x2, y2),
pointFrom<GlobalPoint>(x1, y2),
] as const;
if (angle === 0) {
return corners;
}
return corners.map((point) => pointRotateRads(point, center, angle)) as [
GlobalPoint,
GlobalPoint,
GlobalPoint,
GlobalPoint,
];
}
export const getBoundsEdges = (
corners: readonly [GlobalPoint, GlobalPoint, GlobalPoint, GlobalPoint],
) =>
[
lineSegment(corners[0], corners[1]),
lineSegment(corners[1], corners[2]),
lineSegment(corners[2], corners[3]),
lineSegment(corners[3], corners[0]),
] as const;
const isPointInRotatedBounds = (
point: GlobalPoint,
bounds: Bounds,
angle: Radians,
tolerance = 0,
) => {
const adjustedPoint =
angle === 0
? point
: pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians);
return isPointWithinBounds(
pointFrom(bounds[0] - tolerance, bounds[1] - tolerance),
adjustedPoint,
pointFrom(bounds[2] + tolerance, bounds[3] + tolerance),
);
};
export const hitElementBoundingBox = ( export const hitElementBoundingBox = (
point: GlobalPoint, point: GlobalPoint,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
tolerance = 0, tolerance = 0,
) => { ) => {
const bounds = getElementBounds(element, elementsMap, true); let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
return isPointInRotatedBounds(point, bounds, element.angle, tolerance); x1 -= tolerance;
}; y1 -= tolerance;
x2 += tolerance;
export const doBoundsIntersectElementBoundingBox = ( y2 += tolerance;
intersectorBounds: Bounds, return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
element: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
const [x1, y1, x2, y2] = intersectorBounds;
const intersectorCorners = [
pointFrom<GlobalPoint>(x1, y1),
pointFrom<GlobalPoint>(x2, y1),
pointFrom<GlobalPoint>(x2, y2),
pointFrom<GlobalPoint>(x1, y2),
] as const;
const intersectorEdges = getBoundsEdges(intersectorCorners);
const elementBounds = getElementBounds(element, elementsMap, true);
const elementBoundsCorners = getBoundsCorners(elementBounds, element.angle);
const elementBoundsEdges = getBoundsEdges(elementBoundsCorners);
return (
elementBoundsCorners.some((point) =>
isPointWithinBounds(intersectorCorners[0], point, intersectorCorners[2]),
) ||
intersectorCorners.some((point) =>
isPointInRotatedBounds(point, elementBounds, element.angle),
) ||
intersectorEdges.some((selectionEdge) =>
elementBoundsEdges.some(
(elementBoundsEdge) =>
!!lineSegmentIntersectionPoints(selectionEdge, elementBoundsEdge),
),
)
);
}; };
export const hitElementBoundingBoxOnly = ( export const hitElementBoundingBoxOnly = (
@@ -348,13 +256,9 @@ const bindingBorderTest = (
// PERF: Run a cheap test to see if the binding element // PERF: Run a cheap test to see if the binding element
// is even close to the element // is even close to the element
const t = Math.max(1, tolerance); const t = Math.max(1, tolerance);
const elementBounds = getElementBounds(element, elementsMap, true); const bounds = [x - t, y - t, x + t, y + t] as Bounds;
if ( const elementBounds = getElementBounds(element, elementsMap);
!doNonRotatedBoundsIntersect( if (!doBoundsIntersect(bounds, elementBounds)) {
bounds(x - t, y - t, x + t, y + t),
elementBounds,
)
) {
return false; return false;
} }
@@ -365,7 +269,6 @@ const bindingBorderTest = (
const enclosingFrameBounds = getElementBounds( const enclosingFrameBounds = getElementBounds(
enclosingFrame, enclosingFrame,
elementsMap, elementsMap,
true,
); );
if (!pointInsideBounds(p, enclosingFrameBounds)) { if (!pointInsideBounds(p, enclosingFrameBounds)) {
return false; return false;
@@ -515,15 +418,15 @@ export const intersectElementWithLineSegment = (
): GlobalPoint[] => { ): GlobalPoint[] => {
// First check if the line intersects the element's axis-aligned bounding box // First check if the line intersects the element's axis-aligned bounding box
// as it is much faster than checking intersection against the element's shape // as it is much faster than checking intersection against the element's shape
const intersectorBounds = bounds( const intersectorBounds = [
Math.min(line[0][0] - offset, line[1][0] - offset), Math.min(line[0][0] - offset, line[1][0] - offset),
Math.min(line[0][1] - offset, line[1][1] - offset), Math.min(line[0][1] - offset, line[1][1] - offset),
Math.max(line[0][0] + offset, line[1][0] + offset), Math.max(line[0][0] + offset, line[1][0] + offset),
Math.max(line[0][1] + offset, line[1][1] + offset), Math.max(line[0][1] + offset, line[1][1] + offset),
); ] as Bounds;
const elementBounds = getElementBounds(element, elementsMap, true); const elementBounds = getElementBounds(element, elementsMap);
if (!doNonRotatedBoundsIntersect(intersectorBounds, elementBounds)) { if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
return []; return [];
} }
@@ -562,12 +465,7 @@ export const intersectElementWithLineSegment = (
case "line": case "line":
case "freedraw": case "freedraw":
case "arrow": case "arrow":
return intersectLinearOrFreeDrawWithLineSegment( return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
element,
line,
elementsMap,
onlyFirst,
);
} }
}; };
@@ -582,14 +480,14 @@ const curveIntersections = (
for (const c of curves) { for (const c of curves) {
// Optimize by doing a cheap bounding box check first // Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]); const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = bounds( const b2 = [
Math.min(segment[0][0], segment[1][0]), Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]), Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]), Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]), Math.max(segment[0][1], segment[1][1]),
); ] as Bounds;
if (!doNonRotatedBoundsIntersect(b1, b2)) { if (!doBoundsIntersect(b1, b2)) {
continue; continue;
} }
@@ -634,15 +532,11 @@ 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( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
const intersections: GlobalPoint[] = []; const intersections: GlobalPoint[] = [];
for (const l of lines) { for (const l of lines) {
@@ -659,14 +553,14 @@ const intersectLinearOrFreeDrawWithLineSegment = (
for (const c of curves) { for (const c of curves) {
// Optimize by doing a cheap bounding box check first // Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]); const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = bounds( const b2 = [
Math.min(segment[0][0], segment[1][0]), Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]), Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]), Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]), Math.max(segment[0][1], segment[1][1]),
); ] as Bounds;
if (!doNonRotatedBoundsIntersect(b1, b2)) { if (!doBoundsIntersect(b1, b2)) {
continue; continue;
} }
+2 -6
View File
@@ -48,7 +48,7 @@ export const distanceToElement = (
case "line": case "line":
case "arrow": case "arrow":
case "freedraw": case "freedraw":
return distanceToLinearOrFreeDraElement(element, elementsMap, p); return distanceToLinearOrFreeDraElement(element, p);
} }
}; };
@@ -133,13 +133,9 @@ const distanceToEllipseElement = (
const distanceToLinearOrFreeDraElement = ( const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
return Math.min( return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)), ...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)), ...curves.map((a) => curvePointDistance(a, p)),
+2 -2
View File
@@ -99,7 +99,7 @@ export const isElementContainingFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): boolean => { ) => {
return getElementsWithinSelection([frame], element, elementsMap).some( return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id, (e) => e.id === frame.id,
); );
@@ -140,7 +140,7 @@ export const elementOverlapsWithFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): boolean => { ) => {
return ( return (
elementsAreInFrameBounds([element], frame, elementsMap) || elementsAreInFrameBounds([element], frame, elementsMap) ||
isElementIntersectingFrame(element, frame, elementsMap) || isElementIntersectingFrame(element, frame, elementsMap) ||
+17 -35
View File
@@ -21,7 +21,6 @@ import {
invariant, invariant,
isShallowEqual, isShallowEqual,
getFeatureFlag, getFeatureFlag,
bounds,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@@ -43,7 +42,7 @@ import type {
NullableGridSize, NullableGridSize,
Zoom, Zoom,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import type { RotatedBounds } from "@excalidraw/common"; import type { Bounds } from "@excalidraw/common";
import { import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
@@ -477,22 +476,16 @@ export class LinearElementEditor {
}); });
} }
if ( invariant(
lastClickedPoint < 0 || lastClickedPoint > -1 &&
!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(
console.error( selectedPointsIndices,
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify( )}) points(0..${
selectedPointsIndices, element.points.length - 1
)}) points(0..${ }) lastClickedPoint(${lastClickedPoint})`,
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];
@@ -801,7 +794,6 @@ export class LinearElementEditor {
element.points[index + 1], element.points[index + 1],
index, index,
appState.zoom, appState.zoom,
elementsMap,
) )
) { ) {
midpoints.push(null); midpoints.push(null);
@@ -811,7 +803,6 @@ 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++;
@@ -899,7 +890,6 @@ 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) {
@@ -914,10 +904,7 @@ 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( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
invariant( invariant(
lines.length === 0 && curves.length > 0, lines.length === 0 && curves.length > 0,
@@ -937,7 +924,6 @@ 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(
@@ -950,10 +936,7 @@ 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( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
invariant( invariant(
(lines.length === 0 && curves.length > 0) || (lines.length === 0 && curves.length > 0) ||
@@ -1868,7 +1851,6 @@ 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;
@@ -1880,7 +1862,7 @@ export class LinearElementEditor {
static getMinMaxXYWithBoundText = ( static getMinMaxXYWithBoundText = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
elementBounds: RotatedBounds, elementBounds: Bounds,
boundTextElement: ExcalidrawTextElementWithContainer, boundTextElement: ExcalidrawTextElementWithContainer,
): [number, number, number, number, number, number] => { ): [number, number, number, number, number, number] => {
let [x1, y1, x2, y2] = elementBounds; let [x1, y1, x2, y2] = elementBounds;
@@ -2005,7 +1987,7 @@ export class LinearElementEditor {
return LinearElementEditor.getMinMaxXYWithBoundText( return LinearElementEditor.getMinMaxXYWithBoundText(
element, element,
elementsMap, elementsMap,
bounds(x1, y1, x2, y2, element.angle), [x1, y1, x2, y2],
boundTextElement, boundTextElement,
); );
} }
@@ -2418,7 +2400,7 @@ const pointDraggingUpdates = (
? nextArrow.points[0] ? nextArrow.points[0]
: endBindable : endBindable
? updateBoundPoint( ? updateBoundPoint(
nextArrow, element,
"endBinding", "endBinding",
nextArrow.endBinding, nextArrow.endBinding,
endBindable, endBindable,
@@ -2449,7 +2431,7 @@ const pointDraggingUpdates = (
? endLocalPoint ? endLocalPoint
: startBindable : startBindable
? updateBoundPoint( ? updateBoundPoint(
nextArrow, element,
"startBinding", "startBinding",
nextArrow.startBinding, nextArrow.startBinding,
startBindable, startBindable,
+36 -384
View File
@@ -1,37 +1,16 @@
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common"; import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import {
lineSegment,
pointFrom,
type GlobalPoint,
type LineSegment,
} from "@excalidraw/math";
import type { import type {
AppState, AppState,
BoxSelectionMode,
InteractiveCanvasAppState, InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
getElementAbsoluteCoords,
getElementBounds,
getElementLineSegments,
} from "./bounds";
import {
doBoundsIntersectElementBoundingBox,
getBoundsCorners,
getBoundsEdges,
intersectElementWithLineSegment,
isPointInElement,
shouldTestInside,
} from "./collision";
import { isElementInViewport } from "./sizeHelpers"; import { isElementInViewport } from "./sizeHelpers";
import { import {
isBoundToContainer, isBoundToContainer,
isFrameLikeElement, isFrameLikeElement,
isFreeDrawElement,
isLinearElement, isLinearElement,
isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
@@ -46,209 +25,9 @@ import type {
ElementsMap, ElementsMap,
ElementsMapOrArray, ElementsMapOrArray,
ExcalidrawElement, ExcalidrawElement,
NonDeleted,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
// Broad-phase only for overlap mode. Rotated closed shapes should not select
// from the empty corners of their axis-aligned bounds. Linear elements and
// freedraw already rely on the outline-specific path below, so exclude them.
const shouldUseRotatedOverlapBroadPhase = (
element: NonDeletedExcalidrawElement,
) =>
element.angle !== 0 &&
!isLinearElement(element) &&
!isFreeDrawElement(element);
const clipLineSegmentToBounds = (
segment: LineSegment<GlobalPoint>,
bounds: Bounds,
): LineSegment<GlobalPoint> | null => {
const [minX, minY, maxX, maxY] = bounds;
const [[x1, y1], [x2, y2]] = segment;
const deltaX = x2 - x1;
const deltaY = y2 - y1;
let tMin = 0;
let tMax = 1;
const clip = (p: number, q: number) => {
if (p === 0) {
return q >= 0;
}
const ratio = q / p;
if (p < 0) {
if (ratio > tMax) {
return false;
}
tMin = Math.max(tMin, ratio);
return true;
}
if (ratio < tMin) {
return false;
}
tMax = Math.min(tMax, ratio);
return true;
};
if (
!clip(-deltaX, x1 - minX) ||
!clip(deltaX, maxX - x1) ||
!clip(-deltaY, y1 - minY) ||
!clip(deltaY, maxY - y1)
) {
return null;
}
return lineSegment(
pointFrom<GlobalPoint>(x1 + tMin * deltaX, y1 + tMin * deltaY),
pointFrom<GlobalPoint>(x1 + tMax * deltaX, y1 + tMax * deltaY),
);
};
const isPointWithinAabb = (point: GlobalPoint, bounds: Bounds) =>
point[0] >= bounds[0] &&
point[0] <= bounds[2] &&
point[1] >= bounds[1] &&
point[1] <= bounds[3];
const shouldUsePreciseFilledOverlap = (element: NonDeletedExcalidrawElement) =>
element.type === "ellipse" ||
element.type === "diamond" ||
(element.type === "rectangle" && !!element.roundness);
const shouldSkipElementFromSelection = (element: NonDeletedExcalidrawElement) =>
element.locked || element.type === "selection" || isBoundToContainer(element);
const getFrameBoundsForSelection = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
): Bounds | null => {
if (!element.frameId) {
return null;
}
const containingFrame = getContainingFrame(element, elementsMap);
return containingFrame
? (getElementBounds(containingFrame, elementsMap) as Bounds)
: null;
};
const finalizeElementsInSelection = (
elementsInSelection: NonDeletedExcalidrawElement[],
excludeElementsInFrames: boolean,
elementsMap: ElementsMap,
): NonDeletedExcalidrawElement[] => {
elementsInSelection = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
: elementsInSelection;
return elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
}
return true;
});
};
const getVisibleElementOutlineSegments = (
element: NonDeletedExcalidrawElement,
frameBounds: Bounds | null,
elementsMap: ElementsMap,
) =>
frameBounds
? getElementLineSegments(element, elementsMap).flatMap((segment) => {
const clippedSegment = clipLineSegmentToBounds(segment, frameBounds);
return clippedSegment ? [clippedSegment] : [];
})
: getElementLineSegments(element, elementsMap);
const doesSelectionIntersectElementOutline = (
element: NonDeletedExcalidrawElement,
frameBounds: Bounds | null,
selectionEdges: readonly LineSegment<GlobalPoint>[],
elementsMap: ElementsMap,
) =>
selectionEdges.some((selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
0,
true,
).some((point) => !frameBounds || isPointWithinAabb(point, frameBounds)),
);
const doesSelectionContainElementOutline = (
outlineSegments: readonly LineSegment<GlobalPoint>[],
selectionBounds: Bounds,
) =>
outlineSegments.length > 0 &&
outlineSegments.every(
(outlineSegment) =>
isPointWithinAabb(outlineSegment[0], selectionBounds) &&
isPointWithinAabb(outlineSegment[1], selectionBounds),
);
const doesSelectionContainElementInterior = (
element: NonDeletedExcalidrawElement,
frameBounds: Bounds | null,
selectionCorners: readonly GlobalPoint[],
elementsMap: ElementsMap,
) =>
selectionCorners.some(
(selectionCorner) =>
(!frameBounds || isPointWithinAabb(selectionCorner, frameBounds)) &&
isPointInElement(selectionCorner, element, elementsMap),
);
const doesSelectionOverlapFilledElement = (
element: NonDeletedExcalidrawElement,
frameBounds: Bounds | null,
selectionBounds: Bounds,
selectionCorners: readonly GlobalPoint[],
selectionEdges: readonly LineSegment<GlobalPoint>[],
elementsMap: ElementsMap,
) => {
if (
doesSelectionContainElementInterior(
element,
frameBounds,
selectionCorners,
elementsMap,
)
) {
return true;
}
if (
doesSelectionIntersectElementOutline(
element,
frameBounds,
selectionEdges,
elementsMap,
)
) {
return true;
}
const outlineSegments = getVisibleElementOutlineSegments(
element,
frameBounds,
elementsMap,
);
return (
outlineSegments.length > 0 &&
doesSelectionContainElementOutline(outlineSegments, selectionBounds)
);
};
/** /**
* Frames and their containing elements are not to be selected at the same time. * Frames and their containing elements are not to be selected at the same time.
* Given an array of selected elements, if there are frames and their containing elements * Given an array of selected elements, if there are frames and their containing elements
@@ -281,166 +60,55 @@ export const getElementsWithinSelection = (
selection: NonDeletedExcalidrawElement, selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
excludeElementsInFrames: boolean = true, excludeElementsInFrames: boolean = true,
boxSelectionMode: BoxSelectionMode = "contain", ) => {
): NonDeletedExcalidrawElement[] => { const [selectionX1, selectionY1, selectionX2, selectionY2] =
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
getElementAbsoluteCoords(selection, elementsMap); getElementAbsoluteCoords(selection, elementsMap);
const selectionX1 = Math.min(selectionStartX, selectionEndX);
const selectionY1 = Math.min(selectionStartY, selectionEndY);
const selectionX2 = Math.max(selectionStartX, selectionEndX);
const selectionY2 = Math.max(selectionStartY, selectionEndY);
const selectionBounds = [
selectionX1,
selectionY1,
selectionX2,
selectionY2,
] as Bounds;
if (boxSelectionMode !== "overlap") { let elementsInSelection = elements.filter((element) => {
const elementsInSelection: NonDeletedExcalidrawElement[] = []; let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
element,
for (const element of elements) {
if (shouldSkipElementFromSelection(element)) {
continue;
}
const elementBounds = getElementBounds(element, elementsMap) as Bounds;
const frameBounds = getFrameBoundsForSelection(element, elementsMap);
let elementX1 = elementBounds[0];
let elementY1 = elementBounds[1];
let elementX2 = elementBounds[2];
let elementY2 = elementBounds[3];
if (frameBounds) {
elementX1 = Math.max(frameBounds[0], elementX1);
elementY1 = Math.max(frameBounds[1], elementY1);
elementX2 = Math.min(frameBounds[2], elementX2);
elementY2 = Math.min(frameBounds[3], elementY2);
}
if (
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
) {
elementsInSelection.push(element);
}
}
return finalizeElementsInSelection(
elementsInSelection,
excludeElementsInFrames,
elementsMap, elementsMap,
); );
}
const selectionCorners = getBoundsCorners(selectionBounds); const containingFrame = getContainingFrame(element, elementsMap);
const selectionEdges = getBoundsEdges(selectionCorners); if (containingFrame) {
const elementsInSelection: NonDeletedExcalidrawElement[] = []; const [fx1, fy1, fx2, fy2] = getElementBounds(
containingFrame,
elementsMap,
);
for (const element of elements) { elementX1 = Math.max(fx1, elementX1);
if (shouldSkipElementFromSelection(element)) { elementY1 = Math.max(fy1, elementY1);
continue; elementX2 = Math.min(fx2, elementX2);
elementY2 = Math.min(fy2, elementY2);
} }
const elementBounds = getElementBounds(element, elementsMap) as Bounds; return (
const frameBounds = getFrameBoundsForSelection(element, elementsMap); element.locked === false &&
let elementX1 = elementBounds[0]; element.type !== "selection" &&
let elementY1 = elementBounds[1]; !isBoundToContainer(element) &&
let elementX2 = elementBounds[2];
let elementY2 = elementBounds[3];
if (frameBounds) {
elementX1 = Math.max(frameBounds[0], elementX1);
elementY1 = Math.max(frameBounds[1], elementY1);
elementX2 = Math.min(frameBounds[2], elementX2);
elementY2 = Math.min(frameBounds[3], elementY2);
}
const isSelectionContainingElement =
selectionX1 <= elementX1 && selectionX1 <= elementX1 &&
selectionY1 <= elementY1 && selectionY1 <= elementY1 &&
selectionX2 >= elementX2 && selectionX2 >= elementX2 &&
selectionY2 >= elementY2; selectionY2 >= elementY2
const isSelectionOverlappingElementAabb =
selectionX1 <= elementX2 &&
selectionY1 <= elementY2 &&
selectionX2 >= elementX1 &&
selectionY2 >= elementY1;
const isSelectionOverlappingElement = shouldUseRotatedOverlapBroadPhase(
element,
)
? isSelectionOverlappingElementAabb &&
doBoundsIntersectElementBoundingBox(
selectionBounds,
element,
elementsMap,
)
: isSelectionOverlappingElementAabb;
const shouldSelectFromInside = shouldTestInside(element);
if (shouldSelectFromInside) {
if (
isSelectionOverlappingElement &&
(!shouldUsePreciseFilledOverlap(element) ||
isSelectionContainingElement ||
doesSelectionOverlapFilledElement(
element,
frameBounds,
selectionBounds,
selectionCorners,
selectionEdges,
elementsMap,
))
) {
elementsInSelection.push(element);
}
continue;
}
if (!isSelectionOverlappingElement) {
continue;
}
if (isSelectionContainingElement) {
elementsInSelection.push(element);
continue;
}
if (
doesSelectionIntersectElementOutline(
element,
frameBounds,
selectionEdges,
elementsMap,
)
) {
elementsInSelection.push(element);
continue;
}
const outlineSegments = getVisibleElementOutlineSegments(
element,
frameBounds,
elementsMap,
); );
});
if ( elementsInSelection = excludeElementsInFrames
outlineSegments.length > 0 && ? excludeElementsInFramesFromSelection(elementsInSelection)
doesSelectionContainElementOutline(outlineSegments, selectionBounds) : elementsInSelection;
) {
elementsInSelection.push(element); elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
} }
}
return finalizeElementsInSelection( return true;
elementsInSelection, });
excludeElementsInFrames,
elementsMap, return elementsInSelection;
);
}; };
export const getVisibleAndNonSelectedElements = ( export const getVisibleAndNonSelectedElements = (
@@ -620,19 +288,3 @@ 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;
};
+16 -7
View File
@@ -57,8 +57,8 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import { import {
elementCenterPoint,
getArrowheadPoints, getArrowheadPoints,
getCenterForBounds,
getDiamondPoints, getDiamondPoints,
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
@@ -583,11 +583,7 @@ const getArrowheadShapes = (
export const generateLinearCollisionShape = ( export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap, ) => {
): {
op: string;
data: number[];
}[] => {
const generator = new RoughGenerator(); const generator = new RoughGenerator();
const options: Options = { const options: Options = {
seed: element.seed, seed: element.seed,
@@ -596,7 +592,20 @@ export const generateLinearCollisionShape = (
roughness: 0, roughness: 0,
preserveVertices: true, preserveVertices: true,
}; };
const center = elementCenterPoint(element, elementsMap); const center = getCenterForBounds(
// Need a non-rotated center point
element.points.reduce(
(acc, point) => {
return [
Math.min(element.x + point[0], acc[0]),
Math.min(element.y + point[1], acc[1]),
Math.max(element.x + point[0], acc[2]),
Math.max(element.y + point[1], acc[3]),
];
},
[Infinity, Infinity, -Infinity, -Infinity],
),
);
switch (element.type) { switch (element.type) {
case "line": case "line":
+1 -3
View File
@@ -347,7 +347,6 @@ 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] };
@@ -442,8 +441,7 @@ const VALID_CONTAINER_TYPES = new Set([
export const isValidTextContainer = (element: { export const isValidTextContainer = (element: {
type: ExcalidrawElementType; type: ExcalidrawElementType;
}): element is ExcalidrawTextContainer => }) => VALID_CONTAINER_TYPES.has(element.type);
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = ( export const computeContainerDimensionForBoundText = (
dimension: number, dimension: number,
+37 -207
View File
@@ -4,22 +4,6 @@ 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;
@@ -374,10 +358,6 @@ 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();
@@ -390,120 +370,56 @@ 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 getHardLineBreaks(text); return text;
} }
const lines: WrappedTextLine[] = []; const lines: Array<string> = [];
let offset = 0; const originalLines = text.split("\n");
for (const originalLine of text.split("\n")) { for (const originalLine of originalLines) {
const originalLineWidth = getLineWidth(originalLine, font); const currentLineWidth = getLineWidth(originalLine, font);
if (originalLineWidth <= maxWidth) { if (currentLineWidth <= maxWidth) {
lines.push({ lines.push(originalLine);
text: originalLine, continue;
start: offset,
end: offset + originalLine.length,
});
} else {
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
} }
offset += originalLine.length + 1; const wrappedLine = wrapLine(originalLine, font, maxWidth);
lines.push(...wrappedLine);
} }
return lines; return lines.join("\n");
}; };
/** /**
* Wraps a single hard line into one or more visual lines. * Wraps the original line into the lines based on the given width.
*
* 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,
lineStart: number, ): string[] => {
): WrappedTextLine[] => { const lines: Array<string> = [];
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;
while (tokenIndex < tokens.length) { let iterator = tokenIterator.next();
const token = tokens[tokenIndex];
const tokenStart = tokenOffset; while (!iterator.done) {
const tokenEnd = tokenStart + token.length; const token = iterator.value;
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
@@ -513,59 +429,37 @@ 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;
tokenOffset = tokenEnd; iterator = tokenIterator.next();
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, tokenStart); const wrappedWord = wrapWord(token, font, maxWidth);
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.text; currentLine = trailingLine;
currentLineStart = trailingLine.start; currentLineWidth = getLineWidth(trailingLine, font);
currentLineEnd = trailingLine.end; iterator = tokenIterator.next();
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( lines.push(currentLine.trimEnd());
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( const trailingLine = trimLine(currentLine, font, maxWidth);
currentLine,
currentLineStart,
currentLineEnd,
font,
maxWidth,
);
lines.push(trailingLine); lines.push(trailingLine);
} }
@@ -573,100 +467,59 @@ const wrapLine = (
}; };
/** /**
* Wraps a single word that could not be placed on an empty line as-is. * Wraps the word into the lines based on the given width.
*/ */
const wrapWord = ( const wrapWord = (
word: string, word: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
wordStart: number, ): Array<string> => {
): 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 [ return [word];
{
text: word,
start: wordStart,
end: wordStart + word.length,
},
];
} }
satisfiesWordInvariant(word); satisfiesWordInvariant(word);
const lines: WrappedTextLine[] = []; const lines: Array<string> = [];
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({ lines.push(currentLine);
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({ lines.push(currentLine);
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
return lines; return lines;
}; };
/** /**
* Trims trailing whitespace that is exceeding the `maxWidth`. * Similarly to browsers, does not trim all trailing whitespaces, but only those 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 = ( const trimLine = (line: string, font: FontString, maxWidth: number) => {
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 { return line;
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
@@ -690,30 +543,7 @@ const trimLine = (
trimmedLineWidth = testLineWidth; trimmedLineWidth = testLineWidth;
} }
return { return trimmedLine;
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),
};
}; };
/** /**
+4 -2
View File
@@ -124,7 +124,6 @@ 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);
@@ -132,7 +131,10 @@ export function deconstructLinearOrFreeDrawElement(
return cachedShape; return cachedShape;
} }
const ops = generateLinearCollisionShape(element, elementsMap); const ops = generateLinearCollisionShape(element) as {
op: string;
data: number[];
}[];
const lines = []; const lines = [];
const curves = []; const curves = [];
+1 -1
View File
@@ -29,7 +29,7 @@ describe("check rotated elements can be hit:", () => {
] as LocalPoint[], ] as LocalPoint[],
}); });
const hit = hitElementItself({ const hit = hitElementItself({
point: pointFrom<GlobalPoint>(87, -67), point: pointFrom<GlobalPoint>(88, -68),
element: window.h.elements[0], element: window.h.elements[0],
threshold: 10, threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(), elementsMap: window.h.scene.getNonDeletedElementsMap(),
+18 -10
View File
@@ -330,22 +330,30 @@ describe("Cropping and other features", async () => {
const widthToHeightRatio = image.width / image.height; const widthToHeightRatio = image.width / image.height;
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements: [image], data: {
// @ts-ignore elements: [image],
appState: h.state, // @ts-ignore
files: h.app.files, appState: h.state,
exportPadding: 0, files: h.app.files,
},
config: {
padding: 0,
},
}); });
const exportedCanvasRatio = canvas.width / canvas.height; const exportedCanvasRatio = canvas.width / canvas.height;
expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio); expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: [image], data: {
// @ts-ignore elements: [image],
appState: h.state, // @ts-ignore
files: h.app.files, appState: h.state,
exportPadding: 0, files: h.app.files,
},
config: {
padding: 0,
},
}); });
const svgWidth = svg.getAttribute("width"); const svgWidth = svg.getAttribute("width");
const svgHeight = svg.getAttribute("height"); const svgHeight = svg.getAttribute("height");
+1 -70
View File
@@ -1,8 +1,4 @@
import { import { wrapText, parseTokens } from "../src/textWrapping";
getWrappedTextLines,
parseTokens,
wrapText,
} from "../src/textWrapping";
import type { FontString } from "../src/types"; import type { FontString } from "../src/types";
@@ -106,71 +102,6 @@ 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"
@@ -1,145 +0,0 @@
import {
getElementsInGroup,
isSomeElementSelected,
makeNextSelectedElementIds,
selectGroupsForSelectedElements,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { KEYS, isWritableElement, updateActiveTool } from "@excalidraw/common";
import type { GroupId } from "@excalidraw/element/types";
import { register } from "./register";
import type { AppClassProperties, AppState } from "../types";
const getNextActiveTool = (
appState: Readonly<AppState>,
app: AppClassProperties,
) => {
if (appState.activeTool.type === "eraser") {
return updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: app.state.preferredSelectionTool.type,
}),
lastActiveToolBeforeEraser: null,
});
}
return updateActiveTool(appState, {
type: app.state.preferredSelectionTool.type,
});
};
const getParentEditingGroupId = (
appState: Readonly<AppState>,
app: AppClassProperties,
selectedElementIds: AppState["selectedElementIds"],
): GroupId | null => {
if (!appState.editingGroupId) {
return null;
}
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElements = app.scene.getSelectedElements({
selectedElementIds,
elements: nonDeletedElements,
});
const candidateElements = selectedElements.length
? selectedElements
: getElementsInGroup(nonDeletedElements, appState.editingGroupId);
for (const element of candidateElements) {
const editingGroupIndex = element.groupIds.indexOf(appState.editingGroupId);
if (editingGroupIndex !== -1 && element.groupIds[editingGroupIndex + 1]) {
return element.groupIds[editingGroupIndex + 1] as GroupId;
}
}
return null;
};
export const actionDeselect = register({
name: "deselect",
label: "",
trackEvent: false,
perform: (_elements, appState, _, app) => {
const activeTool = getNextActiveTool(appState, app);
if (appState.editingGroupId) {
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElementIds =
Object.keys(appState.selectedElementIds).length > 0
? appState.selectedElementIds
: getElementsInGroup(
nonDeletedElements,
appState.editingGroupId,
).reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<string, true>);
return {
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: getParentEditingGroupId(
appState,
app,
selectedElementIds,
),
selectedElementIds,
},
nonDeletedElements,
appState,
app,
),
activeEmbeddable: null,
activeTool,
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
return {
appState: {
...appState,
activeEmbeddable: null,
activeTool,
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds({}, appState),
selectedGroupIds: {},
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event, appState, _, app) => {
if (event.key !== KEYS.ESCAPE) {
return false;
}
if (isWritableElement(event.target)) {
return false;
}
return (
!appState.newElement &&
appState.multiElement === null &&
!appState.selectedLinearElement?.isEditing &&
(appState.activeEmbeddable !== null ||
appState.activeTool.type !== app.state.preferredSelectionTool.type ||
!!appState.editingGroupId ||
!!appState.selectedLinearElement ||
isSomeElementSelected(app.scene.getNonDeletedElements(), appState))
);
},
});
@@ -348,7 +348,9 @@ export const actionFinalize = register<FormData>({
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) || (event.key === KEYS.ESCAPE &&
(appState.selectedLinearElement?.isEditing ||
(!appState.newElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => ( PanelComponent: ({ appState, updateData, data }) => (
@@ -191,7 +191,7 @@ export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
app: AppClassProperties, app: AppClassProperties,
getAttribute: (element: ExcalidrawElement) => T, getAttribute: (element: ExcalidrawElement) => T,
elementPredicate: true | ((element: ExcalidrawElement) => boolean), isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T), defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T { ): T {
const editingTextElement = app.state.editingTextElement; const editingTextElement = app.state.editingTextElement;
@@ -209,9 +209,9 @@ export const getFormValue = function <T extends Primitive>(
if (hasSelection) { if (hasSelection) {
const selectedElements = app.scene.getSelectedElements(app.state); const selectedElements = app.scene.getSelectedElements(app.state);
const targetElements = const targetElements =
elementPredicate === true isRelevantElement === true
? selectedElements ? selectedElements
: selectedElements.filter((el) => elementPredicate(el)); : selectedElements.filter((el) => isRelevantElement(el));
ret = ret =
reduceToCommonValue(targetElements, getAttribute) ?? reduceToCommonValue(targetElements, getAttribute) ??
@@ -730,28 +730,9 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, app, updateData }) => { PanelComponent: ({ app, updateData }) => (
const opacity = getFormValue( <Range updateData={updateData} app={app} testId="opacity" />
elements, ),
app,
(element) => element.opacity,
true,
(hasSelection) => (hasSelection ? null : appState.currentItemOpacity),
);
return (
<Range
label={t("labels.opacity")}
value={opacity ?? appState.currentItemOpacity}
hasCommonValue={opacity !== null}
onChange={updateData}
min={0}
max={100}
step={10}
testId="opacity"
/>
);
},
}); });
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>( export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
@@ -1,24 +1,24 @@
import { getFontString } from "@excalidraw/common"; import { getFontString } from "@excalidraw/common";
import { isExcalidrawElement, newElementWith } from "@excalidraw/element"; import { 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) => { predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return ( return (
selectedElements.length === 1 && selectedElements.length === 1 &&
@@ -26,18 +26,13 @@ export const actionTextAutoResize = register({
!selectedElements[0].autoResize !selectedElements[0].autoResize
); );
}, },
perform: (elements, appState, targetElement) => { perform: (elements, appState, _, app) => {
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 === targetTextElement?.id && isTextElement(element)) { if (element.id === selectedElements[0].id && isTextElement(element)) {
const metrics = measureText( const metrics = measureText(
element.originalText, element.originalText,
getFontString(element), getFontString(element),
-1
View File
@@ -34,7 +34,6 @@ 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,7 +114,6 @@ export type ActionName =
| "distributeVertically" | "distributeVertically"
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "deselect"
| "viewMode" | "viewMode"
| "exportWithDarkMode" | "exportWithDarkMode"
| "toggleTheme" | "toggleTheme"
-1
View File
@@ -193,7 +193,6 @@ const APP_STATE_STORAGE_CONF = (<
gridModeEnabled: { browser: true, export: true, server: true }, gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: true, export: false, server: false }, isBindingEnabled: { browser: true, export: false, server: false },
boxSelectionMode: { browser: true, export: false, server: false },
bindingPreference: { browser: true, export: false, server: false }, bindingPreference: { browser: true, export: false, server: false },
isMidpointSnappingEnabled: { browser: true, export: false, server: false }, isMidpointSnappingEnabled: { browser: true, export: false, server: false },
defaultSidebarDockedPreference: { defaultSidebarDockedPreference: {
+60 -362
View File
@@ -27,7 +27,6 @@ import {
KEYS, KEYS,
APP_NAME, APP_NAME,
CURSOR_TYPE, CURSOR_TYPE,
DEFAULT_TRANSFORM_HANDLE_SPACING,
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD, DRAGGING_THRESHOLD,
@@ -246,7 +245,7 @@ import {
bindOrUnbindBindingElement, bindOrUnbindBindingElement,
mutateElement, mutateElement,
getElementBounds, getElementBounds,
doNonRotatedBoundsIntersect, doBoundsIntersect,
isPointInElement, isPointInElement,
maxBindingDistance_simple, maxBindingDistance_simple,
convertToExcalidrawElements, convertToExcalidrawElements,
@@ -258,7 +257,6 @@ 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";
@@ -418,7 +416,6 @@ 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";
@@ -687,15 +684,9 @@ 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 };
@@ -1154,7 +1145,7 @@ class App extends React.Component<AppProps, AppState> {
startBounds && startBounds &&
endBounds && endBounds &&
startElement.id !== endElement.id && startElement.id !== endElement.id &&
doNonRotatedBoundsIntersect(startBounds, endBounds) doBoundsIntersect(startBounds, endBounds)
); );
} }
@@ -1257,26 +1248,6 @@ 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 {
@@ -1467,21 +1438,6 @@ 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,
@@ -2365,7 +2321,6 @@ 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}
@@ -3619,14 +3574,10 @@ 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;
@@ -5666,14 +5617,8 @@ 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();
@@ -5726,13 +5671,13 @@ class App extends React.Component<AppProps, AppState> {
const isDeleted = !nextOriginalText.trim(); const isDeleted = !nextOriginalText.trim();
updateElement(nextOriginalText, isDeleted); updateElement(nextOriginalText, isDeleted);
// keyboard-submit keeps focus on the edited object. For bound text, keep // select the created text element only if submitting via keyboard
// the container selected even if the text becomes empty and is deleted. // (when submitting via click it should act as signal to deselect)
const elementIdToSelect = viaKeyboard if (!isDeleted && viaKeyboard) {
? element.containerId || (!isDeleted ? element.id : null) const elementIdToSelect = element.containerId
: null; ? element.containerId
: 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
@@ -5776,7 +5721,6 @@ 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
@@ -5800,68 +5744,6 @@ 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,
@@ -5875,58 +5757,6 @@ 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,
@@ -6139,7 +5969,6 @@ 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;
@@ -6149,7 +5978,6 @@ 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;
@@ -6170,9 +5998,24 @@ class App extends React.Component<AppProps, AppState> {
shouldBindToContainer = true; shouldBindToContainer = true;
} }
} }
const existingTextElement = let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null;
this.getSelectedTextElement(container) ||
this.getTextElementAtPosition(sceneX, sceneY); const selectedElements = this.scene.getSelectedElements(this.state);
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;
@@ -6219,32 +6062,11 @@ 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: newTextElementPosition.x, x: parentCenterPosition ? parentCenterPosition.elementCenterX : sceneX,
y: newTextElementPosition.y, y: parentCenterPosition ? parentCenterPosition.elementCenterY : sceneY,
strokeColor: this.state.currentItemStrokeColor, strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor, backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle, fillStyle: this.state.currentItemFillStyle,
@@ -6294,9 +6116,6 @@ 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({
@@ -6322,48 +6141,9 @@ 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: Pick< event: React.MouseEvent<HTMLCanvasElement>,
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) {
@@ -6551,21 +6331,6 @@ 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,
@@ -6996,23 +6761,27 @@ 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: selectedLinearElement.selectedPointsIndices selectedPointsIndices:
? [ selectedLinearElement.selectedPointsIndices?.includes(
...new Set( multiElement.points.length,
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: multiElement.points[newLastIdx], lastCommittedPoint:
multiElement.points[multiElement.points.length - 1],
initialState: { initialState: {
...selectedLinearElement.initialState, ...selectedLinearElement.initialState,
lastClickedPoint: newLastIdx, lastClickedPoint: multiElement.points.length - 1,
}, },
}, },
}); });
@@ -7098,12 +6867,6 @@ 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 &&
@@ -7240,14 +7003,6 @@ class App extends React.Component<AppProps, AppState> {
this.interactiveCanvas, this.interactiveCanvas,
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR, isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
); );
} else if (
!event[KEYS.CTRL_OR_CMD] &&
this.isHittingCommonBoundingBoxOfSelectedElements(
scenePointer,
selectedElements,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} else if (this.state.viewModeEnabled) { } else if (this.state.viewModeEnabled) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (this.state.openDialog?.name === "elementLinkSelector") { } else if (this.state.openDialog?.name === "elementLinkSelector") {
@@ -7256,9 +7011,7 @@ 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 ||
@@ -7479,8 +7232,6 @@ 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";
@@ -7704,15 +7455,6 @@ 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;
} }
@@ -7739,24 +7481,17 @@ class App extends React.Component<AppProps, AppState> {
const hitSelectedElement = const hitSelectedElement =
pointerDownState.hit.element && pointerDownState.hit.element &&
this.isASelectedElement(pointerDownState.hit.element); this.isASelectedElement(pointerDownState.hit.element);
const shouldForceLassoReselect =
event.altKey &&
event[KEYS.CTRL_OR_CMD] &&
!pointerDownState.resize.handleType;
const shouldStartLassoSelection =
shouldForceLassoReselect ||
(!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType &&
!hitSelectedElement);
if (shouldStartLassoSelection) { if (
if (!this.lassoTrail.hasCurrentTrail) { !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
this.lassoTrail.startPath( !pointerDownState.resize.handleType &&
pointerDownState.origin.x, !hitSelectedElement
pointerDownState.origin.y, ) {
event.shiftKey, this.lassoTrail.startPath(
); pointerDownState.origin.x,
} pointerDownState.origin.y,
event.shiftKey,
);
// block dragging after lasso selection on PCs until the next pointer down // block dragging after lasso selection on PCs until the next pointer down
// (on mobile or tablet, we want to allow user to drag immediately) // (on mobile or tablet, we want to allow user to drag immediately)
@@ -7945,10 +7680,6 @@ 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) {
@@ -8745,14 +8476,12 @@ class App extends React.Component<AppProps, AppState> {
DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value, DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value,
1, 1,
); );
const boundsPadding =
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / this.state.zoom.value;
const [x1, y1, x2, y2] = getCommonBounds(selectedElements); const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
return ( return (
point.x > x1 - boundsPadding - threshold && point.x > x1 - threshold &&
point.x < x2 + boundsPadding + threshold && point.x < x2 + threshold &&
point.y > y1 - boundsPadding - threshold && point.y > y1 - threshold &&
point.y < y2 + boundsPadding + threshold point.y < y2 + threshold
); );
} }
@@ -8787,7 +8516,6 @@ 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);
@@ -10285,7 +10013,6 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectionElement, this.state.selectionElement,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
false, false,
this.state.boxSelectionMode,
) )
: []; : [];
@@ -11274,35 +11001,6 @@ 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,7 +46,6 @@ 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;
@@ -87,15 +86,6 @@ 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,
@@ -124,7 +114,7 @@ export const FontPickerList = React.memo(
const fontDescriptor = { const fontDescriptor = {
value: familyId, value: familyId,
icon: getFontFamilyIcon(familyId), icon: getFontFamilyIcon(familyId),
text: getFontFamilyLabel(familyId, fontFaces), text: fontFaces[0]?.fontFace?.family ?? "Unknown",
}; };
if (metadata.deprecated) { if (metadata.deprecated) {
@@ -1,4 +1,3 @@
import { exportToCanvas } from "@excalidraw/utils/export";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { import {
@@ -6,6 +5,7 @@ import {
EXPORT_IMAGE_TYPES, EXPORT_IMAGE_TYPES,
isFirefox, isFirefox,
EXPORT_SCALES, EXPORT_SCALES,
THEME,
cloneJSON, cloneJSON,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -26,6 +26,7 @@ import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { copyIcon, downloadIcon, helpIcon } from "./icons"; import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
@@ -128,19 +129,26 @@ const ImageExportModal = ({
}; };
exportToCanvas({ exportToCanvas({
elements: exportedElements, data: {
appState: { elements: exportedElements,
...appStateSnapshot, appState: {
name: projectName, ...appStateSnapshot,
exportBackground: exportWithBackground, name: projectName,
exportWithDarkMode, exportBackground: exportWithBackground,
exportScale, exportScale,
exportEmbedScene: embedScene, exportEmbedScene: embedScene,
},
files,
},
config: {
padding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
theme: exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
canvasBackgroundColor: exportWithBackground
? appStateSnapshot.viewBackgroundColor
: false,
}, },
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
}) })
.then(async (canvas) => { .then(async (canvas) => {
if (isStaleRequest()) { if (isStaleRequest()) {
@@ -72,18 +72,20 @@ const ChartPreviewBtn = (props: {
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { (async () => {
svg = await exportToSvg( svg = await exportToSvg({
elements, data: {
{ elements,
exportBackground: false, appState: {
viewBackgroundColor: "#fff", exportBackground: false,
exportWithDarkMode: theme === "dark", viewBackgroundColor: "#fff",
},
files: null,
}, },
null, // files config: {
{
skipInliningFonts: true, skipInliningFonts: true,
theme,
}, },
); });
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren(); previewNode.replaceChildren();
previewNode.appendChild(svg); previewNode.appendChild(svg);
@@ -134,18 +136,20 @@ const PlainTextPreviewBtn = (props: {
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { (async () => {
const svg = await exportToSvg( const svg = await exportToSvg({
[textElement], data: {
{ elements: [textElement],
exportBackground: false, appState: {
viewBackgroundColor: "#fff", exportBackground: false,
exportWithDarkMode: theme === "dark", viewBackgroundColor: "#fff",
},
files: null,
}, },
null, config: {
{
skipInliningFonts: true, skipInliningFonts: true,
theme,
}, },
); });
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren(); previewNode.replaceChildren();
previewNode.appendChild(svg); previewNode.appendChild(svg);
@@ -1,4 +1,3 @@
import { exportToCanvas, exportToSvg } from "@excalidraw/utils/export";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
@@ -13,6 +12,7 @@ import {
import { EditorLocalStorage } from "../data/EditorLocalStorage"; import { EditorLocalStorage } from "../data/EditorLocalStorage";
import { canvasToBlob, resizeImageFile } from "../data/blob"; import { canvasToBlob, resizeImageFile } from "../data/blob";
import { t } from "../i18n"; import { t } from "../i18n";
import { exportToCanvas, exportToSvg } from "../scene/export";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
@@ -63,9 +63,14 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) { for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({ const itemCanvas = await exportToCanvas({
elements: item.elements, data: {
files: null, elements: item.elements,
maxWidthOrHeight: BOX_SIZE, files: null,
appState: {},
},
config: {
maxWidthOrHeight: BOX_SIZE,
},
}); });
const { width, height } = itemCanvas; const { width, height } = itemCanvas;
@@ -127,14 +132,18 @@ const SingleLibraryItem = ({
} }
(async () => { (async () => {
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: libItem.elements, data: {
appState: { elements: libItem.elements,
...appState, appState: {
viewBackgroundColor: "#fff", ...appState,
exportBackground: true, viewBackgroundColor: "#fff",
exportBackground: true,
},
files: null,
},
config: {
skipInliningFonts: true,
}, },
files: null,
skipInliningFonts: true,
}); });
node.innerHTML = svg.outerHTML; node.innerHTML = svg.outerHTML;
})(); })();
@@ -26,16 +26,13 @@
background: var(--RadioGroup-background); background: var(--RadioGroup-background);
border: 1px solid var(--RadioGroup-border); border: 1px solid var(--RadioGroup-border);
gap: 2px;
&__choice { &__choice {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 20px; width: 32px;
height: 24px; height: 24px;
padding: 0 0.375rem;
color: var(--RadioGroup-choice-color-off); color: var(--RadioGroup-choice-color-off);
background: var(--RadioGroup-choice-background-off); background: var(--RadioGroup-choice-background-off);
+33 -37
View File
@@ -1,78 +1,74 @@
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 = {
label: React.ReactNode; updateData: (value: number) => void;
value: number; app: AppClassProperties;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
minLabel?: React.ReactNode;
hasCommonValue?: boolean;
testId?: string; testId?: string;
}; };
export const Range = ({ export const Range = ({ updateData, app, testId }: RangeProps) => {
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 = const thumbWidth = 15; // 15 is the width of the thumb
parseFloat(
getComputedStyle(rangeElement).getPropertyValue(
"--slider-thumb-size",
),
) || 16;
const progress = ((value - min) / (max - min || 1)) * 100;
const position = const position =
(progress / 100) * (inputWidth - thumbWidth) + thumbWidth / 2; (value / 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) ${progress}%, var(--button-bg) ${progress}%, var(--button-bg) 100%)`; 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%)`;
} }
}, [max, min, value]); }, [value]);
return ( return (
<label className="control-label"> <label className="control-label">
{label} {t("labels.opacity")}
<div className="range-wrapper"> <div className="range-wrapper">
<input <input
style={{ style={{
["--color-slider-track" as string]: hasCommonValue ["--color-slider-track" as string]: hasCommonOpacity
? undefined ? undefined
: "var(--button-bg)", : "var(--button-bg)",
}} }}
ref={rangeRef} ref={rangeRef}
type="range" type="range"
min={min} min="0"
max={max} max="100"
step={step} step="10"
onChange={(event) => { onChange={(event) => {
onChange(+event.target.value); updateData(+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 !== min ? value : null} {value !== 0 ? value : null}
</div> </div>
<div className="zero-label">{minLabel}</div> <div className="zero-label">0</div>
</div> </div>
</label> </label>
); );
@@ -361,10 +361,12 @@ 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!");
Keyboard.exitTextEditor(editor); act(() => {
editor.blur();
});
const text = h.elements[0] as ExcalidrawTextElement; const text = h.elements[0] as ExcalidrawTextElement;
API.setSelectedElements([text]); mouse.clickOn(text);
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
@@ -1,8 +1,4 @@
import { import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common";
DEFAULT_EXPORT_PADDING,
EDITOR_LS_KEYS,
THEME,
} from "@excalidraw/common";
import { convertToExcalidrawElements } from "@excalidraw/element"; import { convertToExcalidrawElements } from "@excalidraw/element";
@@ -105,14 +101,16 @@ export const convertMermaidToExcalidraw = async ({
}; };
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements: data.current.elements, data: {
files: data.current.files, elements: data.current.elements,
exportPadding: DEFAULT_EXPORT_PADDING, files: data.current.files,
maxWidthOrHeight: },
Math.max(parent.offsetWidth, parent.offsetHeight) * config: {
window.devicePixelRatio, padding: DEFAULT_EXPORT_PADDING,
appState: { maxWidthOrHeight:
exportWithDarkMode: theme === THEME.DARK, Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
theme,
}, },
}); });
@@ -54,7 +54,6 @@ 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
@@ -214,7 +213,6 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
height={props.appState.height * props.scale} height={props.appState.height * props.scale}
ref={props.handleCanvasRef} ref={props.handleCanvasRef}
onContextMenu={props.onContextMenu} onContextMenu={props.onContextMenu}
onClick={props.onClick}
onPointerMove={props.onPointerMove} onPointerMove={props.onPointerMove}
onPointerUp={props.onPointerUp} onPointerUp={props.onPointerUp}
onPointerCancel={props.onPointerCancel} onPointerCancel={props.onPointerCancel}
@@ -2,7 +2,7 @@
.excalidraw { .excalidraw {
.dropdown-menu { .dropdown-menu {
max-width: 20rem; max-width: 16rem;
z-index: 1; z-index: 1;
&--placement-top { &--placement-top {
@@ -1,5 +1,4 @@
import { useEditorInterface } from "../App"; import { useEditorInterface } from "../App";
import { Ellipsify } from "../Ellipsify";
import { RadioGroup } from "../RadioGroup"; import { RadioGroup } from "../RadioGroup";
type Props<T> = { type Props<T> = {
@@ -13,7 +12,6 @@ type Props<T> = {
onChange: (value: T) => void; onChange: (value: T) => void;
children: React.ReactNode; children: React.ReactNode;
name: string; name: string;
icon?: React.ReactNode;
}; };
const DropdownMenuItemContentRadio = <T,>({ const DropdownMenuItemContentRadio = <T,>({
@@ -23,17 +21,13 @@ const DropdownMenuItemContentRadio = <T,>({
choices, choices,
children, children,
name, name,
icon,
}: Props<T>) => { }: Props<T>) => {
const editorInterface = useEditorInterface(); const editorInterface = useEditorInterface();
return ( return (
<> <>
<div className="dropdown-menu-item-base dropdown-menu-item-bare"> <div className="dropdown-menu-item-base dropdown-menu-item-bare">
{icon && <div className="dropdown-menu-item__icon">{icon}</div>} <label className="dropdown-menu-item__text">{children}</label>
<label className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify>
</label>
<RadioGroup <RadioGroup
name={name} name={name}
value={value} value={value}
@@ -39,13 +39,7 @@ import DropdownMenuItemCheckbox from "../dropdownMenu/DropdownMenuItemCheckbox";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio"; import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink"; import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub"; import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { import { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons";
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
emptyIcon,
} from "../icons";
import { import {
boltIcon, boltIcon,
DeviceDesktopIcon, DeviceDesktopIcon,
@@ -433,40 +427,6 @@ const PreferencesToggleToolLockItem = () => {
); );
}; };
const PreferencesBoxSelectionModeItem = () => {
const { t } = useI18n();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const boxSelectionMode = appState.boxSelectionMode ?? "contain";
return (
<DropdownMenuItemContentRadio<"contain" | "overlap">
name="boxSelectionMode"
icon={emptyIcon}
value={boxSelectionMode}
onChange={(value) => {
setAppState({
boxSelectionMode: value === "contain" ? undefined : value,
});
}}
choices={[
{
value: "contain",
label: t("labels.boxSelectionContain"),
ariaLabel: t("labels.boxSelectionContain"),
},
{
value: "overlap",
label: t("labels.boxSelectionOverlap"),
ariaLabel: t("labels.boxSelectionOverlap"),
},
]}
>
{t("labels.boxSelectionMode")}
</DropdownMenuItemContentRadio>
);
};
const PreferencesToggleSnapModeItem = () => { const PreferencesToggleSnapModeItem = () => {
const { t } = useI18n(); const { t } = useI18n();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
@@ -608,7 +568,6 @@ export const Preferences = ({
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu"> <DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
{children || ( {children || (
<> <>
<PreferencesBoxSelectionModeItem />
<PreferencesToggleToolLockItem /> <PreferencesToggleToolLockItem />
<PreferencesToggleSnapModeItem /> <PreferencesToggleSnapModeItem />
<PreferencesToggleGridModeItem /> <PreferencesToggleGridModeItem />
@@ -626,7 +585,6 @@ export const Preferences = ({
}; };
Preferences.ToggleToolLock = PreferencesToggleToolLockItem; Preferences.ToggleToolLock = PreferencesToggleToolLockItem;
Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem;
Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem; Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem;
Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem; Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem;
Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem; Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem;
+35 -17
View File
@@ -4,6 +4,7 @@ import {
IMAGE_MIME_TYPES, IMAGE_MIME_TYPES,
isFirefox, isFirefox,
MIME_TYPES, MIME_TYPES,
THEME,
cloneJSON, cloneJSON,
SVG_DOCUMENT_PREAMBLE, SVG_DOCUMENT_PREAMBLE,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -115,20 +116,29 @@ export const exportCanvas = async (
if (elements.length === 0) { if (elements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas")); throw new Error(t("alerts.cannotExportEmptyCanvas"));
} }
const theme = appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT;
if (type === "svg" || type === "clipboard-svg") { if (type === "svg" || type === "clipboard-svg") {
const svgPromise = exportToSvg( const svgPromise = exportToSvg({
elements, data: {
{ elements,
exportBackground, appState: {
exportWithDarkMode: appState.exportWithDarkMode, ...appState,
viewBackgroundColor, exportBackground,
exportPadding, exportEmbedScene: appState.exportEmbedScene && type === "svg",
exportScale: appState.exportScale, },
exportEmbedScene: appState.exportEmbedScene && type === "svg", files,
}, },
files, config: {
{ exportingFrame }, padding: exportPadding,
); exportingFrame,
theme,
canvasBackgroundColor: exportBackground
? viewBackgroundColor
: "transparent",
},
});
if (type === "svg") { if (type === "svg") {
return fileSave( return fileSave(
@@ -158,11 +168,19 @@ export const exportCanvas = async (
} }
} }
const tempCanvas = exportToCanvas(elements, appState, files, { const tempCanvas = exportToCanvas({
exportBackground, data: {
viewBackgroundColor, elements,
exportPadding, appState,
exportingFrame, files,
},
config: {
canvasBackgroundColor: exportBackground ? viewBackgroundColor : false,
padding: exportPadding,
theme,
scale: appState.exportScale,
exportingFrame,
},
}); });
if (type === "png") { if (type === "png") {
+4
View File
@@ -20,6 +20,10 @@ 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",
+1 -9
View File
@@ -251,9 +251,7 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
}; };
} }
console.error( console.error(`could not repair binding for element`);
`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);
} }
@@ -936,12 +934,6 @@ export const restoreAppState = (
: defaultValue; : defaultValue;
} }
const boxSelectionMode =
appState.boxSelectionMode ?? localAppState?.boxSelectionMode;
if (boxSelectionMode !== undefined) {
nextAppState.boxSelectionMode = boxSelectionMode;
}
return { return {
...nextAppState, ...nextAppState,
cursorButton: localAppState?.cursorButton || "up", cursorButton: localAppState?.cursorButton || "up",
+2 -2
View File
@@ -2,7 +2,7 @@ import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import { import {
computeBoundTextPosition, computeBoundTextPosition,
doNonRotatedBoundsIntersect, doBoundsIntersect,
getBoundTextElement, getBoundTextElement,
getElementBounds, getElementBounds,
getElementLineSegments, getElementLineSegments,
@@ -219,7 +219,7 @@ const eraserTest = (
origElementBounds[3] + threshold, origElementBounds[3] + threshold,
]; ];
if (!doNonRotatedBoundsIntersect(segmentBounds, elementBounds)) { if (!doBoundsIntersect(segmentBounds, elementBounds)) {
return false; return false;
} }
+12 -10
View File
@@ -1,8 +1,9 @@
import { exportToSvg } from "@excalidraw/utils/export";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { COLOR_PALETTE } from "@excalidraw/common"; import { COLOR_PALETTE } from "@excalidraw/common";
import { exportToSvg } from "../scene/export";
import { atom, useAtom } from "../editor-jotai"; import { atom, useAtom } from "../editor-jotai";
import type { LibraryItem } from "../types"; import type { LibraryItem } from "../types";
@@ -12,17 +13,18 @@ export type SvgCache = Map<LibraryItem["id"], SVGSVGElement>;
export const libraryItemSvgsCache = atom<SvgCache>(new Map()); export const libraryItemSvgsCache = atom<SvgCache>(new Map());
const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => { const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
// TODO should pass theme (appState.exportWithDark) - we're still using
// CSS filter here
return await exportToSvg({ return await exportToSvg({
elements, data: {
appState: { elements,
exportBackground: false, appState: {
viewBackgroundColor: COLOR_PALETTE.white, exportBackground: false,
viewBackgroundColor: COLOR_PALETTE.white,
},
files: null,
},
config: {
skipInliningFonts: true,
}, },
files: null,
renderEmbeddables: false,
skipInliningFonts: true,
}); });
}; };
+19 -23
View File
@@ -1,4 +1,3 @@
import { getDefaultAppState } from "./appState";
import { exportToCanvas } from "./scene/export"; import { exportToCanvas } from "./scene/export";
const fs = require("fs"); const fs = require("fs");
@@ -59,26 +58,23 @@ const elements = [
registerFont("./public/Virgil.woff2", { family: "Virgil" }); registerFont("./public/Virgil.woff2", { family: "Virgil" });
registerFont("./public/Cascadia.woff2", { family: "Cascadia" }); registerFont("./public/Cascadia.woff2", { family: "Cascadia" });
const canvas = exportToCanvas( (async () => {
elements as any, const canvas = await exportToCanvas({
{ data: {
...getDefaultAppState(), elements: elements as any,
offsetTop: 0, appState: {},
offsetLeft: 0, files: {},
width: 0, },
height: 0, config: {
}, canvasBackgroundColor: "#ffffff",
{}, // files createCanvas,
{ },
exportBackground: true, });
viewBackgroundColor: "#ffffff",
},
createCanvas,
);
const out = fs.createWriteStream("test.png"); const out = fs.createWriteStream("test.png");
const stream = (canvas as any).createPNGStream(); const stream = (canvas as any).createPNGStream();
stream.pipe(out); stream.pipe(out);
out.on("finish", () => { out.on("finish", () => {
console.info("test.png was created."); console.info("test.png was created.");
}); });
})();
+3 -3
View File
@@ -304,7 +304,9 @@ export {
exportToBlob, exportToBlob,
exportToSvg, exportToSvg,
exportToClipboard, exportToClipboard,
} from "@excalidraw/utils/export"; } from "./scene/export";
export type { ExportSceneData, ExportSceneConfig } from "./scene/export";
export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json"; export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json";
export { export {
@@ -382,8 +384,6 @@ export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToC
export { getDataURL } from "./data/blob"; export { getDataURL } from "./data/blob";
export { isElementLink } from "@excalidraw/element"; export { isElementLink } from "@excalidraw/element";
export { Fonts } from "./fonts/Fonts";
export { setCustomTextMetricsProvider } from "@excalidraw/element"; export { setCustomTextMetricsProvider } from "@excalidraw/element";
export { CommandPalette } from "./components/CommandPalette/CommandPalette"; export { CommandPalette } from "./components/CommandPalette/CommandPalette";
+2 -2
View File
@@ -10,7 +10,7 @@ import { type Bounds } from "@excalidraw/common";
import { import {
computeBoundTextPosition, computeBoundTextPosition,
doNonRotatedBoundsIntersect, doBoundsIntersect,
getBoundTextElement, getBoundTextElement,
getElementBounds, getElementBounds,
intersectElementWithLineSegment, intersectElementWithLineSegment,
@@ -66,7 +66,7 @@ export const getLassoSelectedElementIds = (input: {
const elementBounds = getElementBounds(element, elementsMap); const elementBounds = getElementBounds(element, elementsMap);
if ( if (
doNonRotatedBoundsIntersect(lassoBounds, elementBounds) && doBoundsIntersect(lassoBounds, elementBounds) &&
!intersectedElements.has(element.id) && !intersectedElements.has(element.id) &&
!enclosedElements.has(element.id) !enclosedElements.has(element.id)
) { ) {
+1 -4
View File
@@ -185,9 +185,6 @@
"shapeSwitch": "Switch shape", "shapeSwitch": "Switch shape",
"preferences": "Preferences", "preferences": "Preferences",
"preferences_toolLock": "Tool lock", "preferences_toolLock": "Tool lock",
"boxSelectionMode": "Select on",
"boxSelectionContain": "Wrap",
"boxSelectionOverlap": "Overlap",
"arrowBinding": "Arrow binding", "arrowBinding": "Arrow binding",
"midpointSnapping": "Snap to midpoints" "midpointSnapping": "Snap to midpoints"
}, },
@@ -664,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, Class, State, and Entity Relationship diagrams." "hint": "At the moment we know Flowchart, Sequence, Class, 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.2.2", "@excalidraw/mermaid-to-excalidraw": "2.1.1",
"@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,7 +41,6 @@ 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";
@@ -59,8 +58,6 @@ import {
isFocusPointVisible, isFocusPointVisible,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { EditorInterface } from "@excalidraw/common";
import type { import type {
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
@@ -89,10 +86,6 @@ 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,
@@ -1156,7 +1149,6 @@ const renderLinearPointHandles = (
points[idx], points[idx],
idx, idx,
appState.zoom, appState.zoom,
elementsMap,
) )
) { ) {
renderSingleLinearPoint( renderSingleLinearPoint(
@@ -1497,58 +1489,21 @@ const renderTextBox = (
selectionColor: InteractiveCanvasRenderConfig["selectionColor"], selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => { ) => {
context.save(); context.save();
const padding = getTextBoxPadding(appState.zoom.value); const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / 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 + text.width / 2; const cx = text.x + width / 2;
const cy = text.y + text.height / 2; const cy = text.y + height / 2;
const shiftX = -(text.width / 2 + padding); const shiftX = -(width / 2 + padding);
const shiftY = -(text.height / 2 + padding); const shiftY = -(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,
@@ -1629,19 +1584,10 @@ const _renderInteractiveScene = ({
} }
} }
const activeTextElement = getActiveTextElement(selectedElements, appState); if (
appState.editingTextElement &&
if (activeTextElement && !activeTextElement.autoResize) { isTextElement(appState.editingTextElement)
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;
+672 -123
View File
@@ -1,13 +1,13 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import {
DEFAULT_EXPORT_PADDING,
FRAME_STYLE, FRAME_STYLE,
FONT_FAMILY, FONT_FAMILY,
SVG_NS, SVG_NS,
THEME, THEME,
MIME_TYPES, MIME_TYPES,
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
COLOR_WHITE,
arrayToMap, arrayToMap,
distance, distance,
getFontString, getFontString,
@@ -47,20 +47,33 @@ import type {
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
Theme,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { base64ToString, decode, encode, stringToBase64 } from "../data/encode"; import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
import { serializeAsJSON } from "../data/json"; import { serializeAsJSON } from "../data/json";
import { restoreAppState } from "../data/restore";
import { encodePngMetadata } from "../data/image";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
import { renderStaticScene } from "../renderer/staticScene"; import { renderStaticScene } from "../renderer/staticScene";
import { renderSceneToSvg } from "../renderer/staticSvgScene"; import { renderSceneToSvg } from "../renderer/staticSvgScene";
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
copyToClipboard,
} from "../clipboard";
import type { RenderableElementsMap } from "./types"; import type { RenderableElementsMap } from "./types";
import type { AppState, BinaryFiles } from "../types"; import type { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
// Default minimum export size in pixels
const DEFAULT_SMALLEST_EXPORT_SIZE = 20;
const DEFAULT_ZOOM_VALUE = 1 as NormalizedZoomValue;
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => { const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
if (element.width <= maxWidth) { if (element.width <= maxWidth) {
@@ -169,36 +182,205 @@ const prepareElementsForRender = ({
return nextElements; return nextElements;
}; };
export const exportToCanvas = async ( // ---------------------------------------------------------------------------
elements: readonly NonDeletedExcalidrawElement[], // Types for the new API
appState: AppState, // ---------------------------------------------------------------------------
files: BinaryFiles,
{ export type ExportSceneData = {
exportBackground, elements: readonly NonDeletedExcalidrawElement[];
exportPadding = DEFAULT_EXPORT_PADDING, appState?: Partial<
viewBackgroundColor, Omit<AppState, "offsetTop" | "offsetLeft" | "exportWithDarkMode">
exportingFrame, >;
}: { files: BinaryFiles | null;
exportBackground: boolean; };
exportPadding?: number;
viewBackgroundColor: string; export type ExportSceneConfig = {
exportingFrame?: ExcalidrawFrameLikeElement | null; theme?: Theme;
}, /**
createCanvas: ( * Canvas background. Valid values are:
*
* - `undefined` - the background of "appState.viewBackgroundColor" is used.
* - `false` - no background is used (set to "transparent").
* - `string` - should be a valid CSS color.
*
* @default undefined
*/
canvasBackgroundColor?: string | false;
/**
* Canvas padding in pixels. Affected by `scale`.
*
* When `fit` is set to `none`, padding is added to the content bounding box
* (including if you set `width` or `height` or `maxWidthOrHeight` or
* `widthOrHeight`).
*
* When `fit` set to `contain`, padding is subtracted from the content
* bounding box (ensuring the size doesn't exceed the supplied values, with
* the exeception of using alongside `scale` as noted above), and the padding
* serves as a minimum distance between the content and the canvas edges, as
* it may exceed the supplied padding value from one side or the other in
* order to maintain the aspect ratio. It is recommended to set `position`
* to `center` when using `fit=contain`.
*
* When `fit` is set to `none` and either `width` or `height` or
* `maxWidthOrHeight` is set, padding is simply adding to the bounding box
* and the content may overflow the canvas, thus right or bottom padding
* may be ignored.
*
* @default 0
*/
padding?: number;
// -------------------------------------------------------------------------
/**
* Makes sure the canvas content fits into a frame of width/height no larger
* than this value, while maintaining the aspect ratio.
*
* Final dimensions can get smaller/larger if used in conjunction with
* `scale`.
*/
maxWidthOrHeight?: number;
/**
* Scale the canvas content to be excatly this many pixels wide/tall,
* maintaining the aspect ratio.
*
* Cannot be used in conjunction with `maxWidthOrHeight`.
*
* Final dimensions can get smaller/larger if used in conjunction with
* `scale`.
*/
widthOrHeight?: number;
// -------------------------------------------------------------------------
/**
* Width of the frame. Supply `x` or `y` if you want to ofsset the canvas
* content.
*
* If `width` omitted but `height` supplied, `width` is calculated from the
* the content's bounding box to preserve the aspect ratio.
*
* Defaults to the content bounding box width when both `width` and `height`
* are omitted.
*/
width?: number;
/**
* Height of the frame.
*
* If `height` omitted but `width` supplied, `height` is calculated from the
* content's bounding box to preserve the aspect ratio.
*
* Defaults to the content bounding box height when both `width` and `height`
* are omitted.
*/
height?: number;
/**
* Left canvas offset. By default the coordinate is relative to the canvas.
* You can switch to content coordinates by setting `origin` to `content`.
*
* Defaults to the `x` postion of the content bounding box.
*/
x?: number;
/**
* Top canvas offset. By default the coordinate is relative to the canvas.
* You can switch to content coordinates by setting `origin` to `content`.
*
* Defaults to the `y` postion of the content bounding box.
*/
y?: number;
/**
* Indicates the coordinate system of the `x` and `y` values.
*
* - `canvas` - `x` and `y` are relative to the canvas [0, 0] position.
* - `content` - `x` and `y` are relative to the content bounding box.
*
* @default "canvas"
*/
origin?: "canvas" | "content";
/**
* If dimensions specified and `x` and `y` are not specified, this indicates
* how the canvas should be scaled.
*
* Behavior aligns with the `object-fit` CSS property.
*
* - `none` - no scaling.
* - `contain` - scale to fit the frame. Includes `padding`.
*
* If `maxWidthOrHeight` or `widthOrHeight` is set, `fit` is ignored.
*
* @default "contain" unless `width`, `height`, `maxWidthOrHeight`, or
* `widthOrHeight` is specified in which case `none` is the default (can be
* changed). If `x` or `y` are specified, `none` is forced.
*/
fit?: "none" | "contain";
/**
* When either `x` or `y` are not specified, indicates how the canvas should
* be aligned on the respective axis.
*
* - `none` - canvas aligned to top left.
* - `center` - canvas is centered on the axis which is not specified
* (or both).
*
* If `maxWidthOrHeight` or `widthOrHeight` is set, `position` is ignored.
*
* @default "center"
*/
position?: "center" | "topLeft";
// -------------------------------------------------------------------------
/**
* A multiplier to increase/decrease the frame dimensions
* (content resolution).
*
* For example, if your canvas is 300x150 and you set scale to 2, the
* resulting size will be 600x300.
*
* @default 1
*/
scale?: number;
/**
* If you need to suply your own canvas, e.g. in test environments or in
* Node.js.
*
* Do not set `canvas.width/height` or modify the canvas context as that's
* handled by Excalidraw.
*
* Defaults to `document.createElement("canvas")`.
*/
createCanvas?: () => HTMLCanvasElement;
/**
* If you want to supply `width`/`height` dynamically (or derive from the
* content bounding box), you can use this function.
*
* Ignored if `maxWidthOrHeight`, `width`, or `height` is set.
*/
getDimensions?: (
width: number, width: number,
height: number, height: number,
) => { canvas: HTMLCanvasElement; scale: number } = (width, height) => { ) => { width: number; height: number; scale?: number };
const canvas = document.createElement("canvas");
canvas.width = width * appState.exportScale; exportingFrame?: ExcalidrawFrameLikeElement | null;
canvas.height = height * appState.exportScale;
return { canvas, scale: appState.exportScale }; loadFonts?: () => Promise<void>;
}, };
loadFonts: () => Promise<void> = async () => {
await Fonts.loadElementsFonts(elements); // ---------------------------------------------------------------------------
}, // Internal helper to configure export dimensions
) => { // ---------------------------------------------------------------------------
// load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
await loadFonts(); const configExportDimension = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
// clone
const cfg = Object.assign({}, config);
const { exportingFrame } = cfg;
const elements = data.elements;
// initialize defaults
// ---------------------------------------------------------------------------
const appState = restoreAppState(data.appState, null);
const frameRendering = getFrameRenderingConfig( const frameRendering = getFrameRenderingConfig(
exportingFrame ?? null, exportingFrame ?? null,
@@ -218,26 +400,255 @@ export const exportToCanvas = async (
}); });
if (exportingFrame) { if (exportingFrame) {
exportPadding = 0; cfg.padding = 0;
} }
const [minX, minY, width, height] = getCanvasSize( cfg.fit =
cfg.fit ??
(cfg.width != null ||
cfg.height != null ||
cfg.maxWidthOrHeight != null ||
cfg.widthOrHeight != null
? "contain"
: "none");
cfg.padding = cfg.padding ?? 0;
cfg.scale = cfg.scale ?? 1;
cfg.origin = cfg.origin ?? "canvas";
cfg.position = cfg.position ?? "center";
if (cfg.maxWidthOrHeight != null && cfg.widthOrHeight != null) {
if (!import.meta.env.PROD) {
console.warn("`maxWidthOrHeight` is ignored when `widthOrHeight` is set");
}
cfg.maxWidthOrHeight = undefined;
}
if (
(cfg.maxWidthOrHeight != null || cfg.width != null || cfg.height != null) &&
cfg.getDimensions
) {
if (!import.meta.env.PROD) {
console.warn(
"`getDimensions` is ignored when `width`, `height`, or `maxWidthOrHeight` is set",
);
}
cfg.getDimensions = undefined;
}
// ---------------------------------------------------------------------------
// load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
if (cfg.loadFonts) {
await cfg.loadFonts();
} else {
await Fonts.loadElementsFonts(elements);
}
// value used to scale the canvas context. By default, we use this to
// make the canvas fit into the frame (e.g. for `cfg.fit` set to `contain`).
// If `cfg.scale` is set, we multiply the resulting canvasScale by it to
// scale the output further.
let exportScale = 1;
const origCanvasSize = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender), exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding,
); );
const { canvas, scale = 1 } = createCanvas(width, height); // variables for original content bounding box
const [origX, origY, origWidth, origHeight] = origCanvasSize;
// variables for target bounding box
let [x, y, width, height] = origCanvasSize;
const defaultAppState = getDefaultAppState(); x = cfg.x ?? x;
y = cfg.y ?? y;
width = cfg.width ?? width;
height = cfg.height ?? height;
if (cfg.fit === "contain" || cfg.widthOrHeight || cfg.maxWidthOrHeight) {
cfg.padding =
cfg.padding && cfg.padding > 0
? Math.min(
cfg.padding,
(width - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
(height - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
)
: 0;
if (cfg.getDimensions != null) {
const ret = cfg.getDimensions(width, height);
width = ret.width;
height = ret.height;
cfg.padding = Math.min(
cfg.padding,
(width - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
(height - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
} else if (cfg.widthOrHeight != null) {
cfg.padding = Math.min(
cfg.padding,
(cfg.widthOrHeight - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
} else if (cfg.maxWidthOrHeight != null) {
cfg.padding = Math.min(
cfg.padding,
(cfg.maxWidthOrHeight - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
}
}
if (cfg.maxWidthOrHeight != null || cfg.widthOrHeight != null) {
if (cfg.padding) {
if (cfg.maxWidthOrHeight != null) {
cfg.maxWidthOrHeight -= cfg.padding * 2;
} else if (cfg.widthOrHeight != null) {
cfg.widthOrHeight -= cfg.padding * 2;
}
}
const max = Math.max(width, height);
if (cfg.widthOrHeight != null) {
// calculate by how much do we need to scale the canvas to fit into the
// target dimension (e.g. target: max 50px, actual: 70x100px => scale: 0.5)
exportScale = cfg.widthOrHeight / max;
} else if (cfg.maxWidthOrHeight != null) {
exportScale = cfg.maxWidthOrHeight < max ? cfg.maxWidthOrHeight / max : 1;
}
width *= exportScale;
height *= exportScale;
} else if (cfg.getDimensions) {
const ret = cfg.getDimensions(width, height);
width = ret.width;
height = ret.height;
cfg.scale = ret.scale ?? cfg.scale;
} else if (cfg.fit === "contain") {
width -= cfg.padding * 2;
height -= cfg.padding * 2;
const wRatio = width / origWidth;
const hRatio = height / origHeight;
// scale the orig canvas to fit in the target region
exportScale = Math.min(wRatio, hRatio);
}
x = cfg.x ?? origX;
y = cfg.y ?? origY;
// if we switch to "content" coords, we need to offset cfg-supplied
// coords by the x/y of content bounding box
if (cfg.origin === "content") {
if (cfg.x != null) {
x += origX;
}
if (cfg.y != null) {
y += origY;
}
}
// Centering the content to the frame.
// We divide width/height by canvasScale so that we calculate in the original
// aspect ratio dimensions.
if (cfg.position === "center") {
x -=
width / exportScale / 2 -
(cfg.x == null ? origWidth : width + cfg.padding * 2) / 2;
y -=
height / exportScale / 2 -
(cfg.y == null ? origHeight : height + cfg.padding * 2) / 2;
}
// rescale padding based on current canvasScale factor so that the resulting
// padding is kept the same as supplied by user (with the exception of
// `cfg.scale` being set, which also scales the padding)
const normalizedPadding = cfg.padding / exportScale;
// scale the whole frame by cfg.scale (on top of whatever canvasScale we
// calculated above)
exportScale *= cfg.scale;
width *= cfg.scale;
height *= cfg.scale;
const exportWidth = width + cfg.padding * 2 * cfg.scale;
const exportHeight = height + cfg.padding * 2 * cfg.scale;
return {
config: cfg,
normalizedPadding,
contentWidth: width,
contentHeight: height,
exportWidth,
exportHeight,
exportScale,
x,
y,
elementsForRender,
appState,
frameRendering,
};
};
// ---------------------------------------------------------------------------
// exportToCanvas
// ---------------------------------------------------------------------------
/**
* This API is usually used as a precursor to searializing to Blob or PNG,
* but can also be used to create a canvas for other purposes.
*/
export const exportToCanvas = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
const {
config: cfg,
normalizedPadding,
contentWidth: width,
contentHeight: height,
exportWidth,
exportHeight,
exportScale,
x,
y,
elementsForRender,
appState,
frameRendering,
} = await configExportDimension({ data, config });
const canvas = cfg.createCanvas
? cfg.createCanvas()
: document.createElement("canvas");
canvas.width = exportWidth;
canvas.height = exportHeight;
const { imageCache } = await updateImageCache({ const { imageCache } = await updateImageCache({
imageCache: new Map(), imageCache: new Map(),
fileIds: getInitializedImageElements(elementsForRender).map( fileIds: getInitializedImageElements(elementsForRender).map(
(element) => element.fileId, (element) => element.fileId,
), ),
files, files: data.files || {},
}); });
const theme =
cfg.theme ?? (appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT);
// Determine the background color for the canvas
const viewBackgroundColor =
cfg.canvasBackgroundColor === false
? // "transparent" triggers clearRect in bootstrapCanvas
"transparent"
: cfg.canvasBackgroundColor ||
appState.viewBackgroundColor ||
COLOR_WHITE;
renderStaticScene({ renderStaticScene({
canvas, canvas,
rc: rough.canvas(canvas), rc: rough.canvas(canvas),
@@ -245,19 +656,23 @@ export const exportToCanvas = async (
arrayToMap(elementsForRender), arrayToMap(elementsForRender),
), ),
allElementsMap: toBrandedType<NonDeletedSceneElementsMap>( allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
arrayToMap(syncInvalidIndices(elements)), arrayToMap(syncInvalidIndices(data.elements)),
), ),
visibleElements: elementsForRender, visibleElements: elementsForRender,
scale, scale: exportScale,
appState: { appState: {
...appState, ...appState,
frameRendering, frameRendering,
viewBackgroundColor: exportBackground ? viewBackgroundColor : null, width,
scrollX: -minX + exportPadding, height,
scrollY: -minY + exportPadding, offsetLeft: 0,
zoom: defaultAppState.zoom, offsetTop: 0,
scrollX: -x + normalizedPadding,
scrollY: -y + normalizedPadding,
zoom: { value: DEFAULT_ZOOM_VALUE },
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, theme,
viewBackgroundColor,
}, },
renderConfig: { renderConfig: {
canvasBackgroundColor: viewBackgroundColor, canvasBackgroundColor: viewBackgroundColor,
@@ -268,13 +683,44 @@ export const exportToCanvas = async (
embedsValidationStatus: new Map(), embedsValidationStatus: new Map(),
elementsPendingErasure: new Set(), elementsPendingErasure: new Set(),
pendingFlowchartNodes: null, pendingFlowchartNodes: null,
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, theme,
}, },
}); });
return canvas; return canvas;
}; };
// ---------------------------------------------------------------------------
// exportToSvg
// ---------------------------------------------------------------------------
type ExportToSvgConfig = Pick<
ExportSceneConfig,
| "canvasBackgroundColor"
| "padding"
| "theme"
| "exportingFrame"
| "scale"
| "width"
| "height"
| "x"
| "y"
| "origin"
| "fit"
| "position"
| "maxWidthOrHeight"
| "widthOrHeight"
| "getDimensions"
| "loadFonts"
> & {
/**
* if true, all embeddables passed in will be rendered when possible.
*/
renderEmbeddables?: boolean;
skipInliningFonts?: true;
reuseImages?: boolean;
};
const createHTMLComment = (text: string) => { const createHTMLComment = (text: string) => {
// surrounding with spaces to maintain prettified consistency with previous // surrounding with spaces to maintain prettified consistency with previous
// iterations // iterations
@@ -282,61 +728,34 @@ const createHTMLComment = (text: string) => {
return document.createComment(` ${text} `); return document.createComment(` ${text} `);
}; };
export const exportToSvg = async ( export const exportToSvg = async ({
elements: readonly NonDeletedExcalidrawElement[], data,
appState: { config,
exportBackground: boolean; }: {
exportPadding?: number; data: ExportSceneData;
exportScale?: number; config?: ExportToSvgConfig;
viewBackgroundColor: string; }) => {
exportWithDarkMode?: boolean; const {
exportEmbedScene?: boolean; config: cfg,
frameRendering?: AppState["frameRendering"]; normalizedPadding,
}, exportWidth,
files: BinaryFiles | null, exportHeight,
opts?: { exportScale,
/** x,
* if true, all embeddables passed in will be rendered when possible. y,
*/ elementsForRender,
renderEmbeddables?: boolean; appState,
exportingFrame?: ExcalidrawFrameLikeElement | null;
skipInliningFonts?: true;
reuseImages?: boolean;
},
): Promise<SVGSVGElement> => {
const frameRendering = getFrameRenderingConfig(
opts?.exportingFrame ?? null,
appState.frameRendering ?? null,
);
let {
exportPadding = DEFAULT_EXPORT_PADDING,
exportWithDarkMode = false,
viewBackgroundColor,
exportScale = 1,
exportEmbedScene,
} = appState;
const { exportingFrame = null } = opts || {};
const elementsForRender = prepareElementsForRender({
elements,
exportingFrame,
exportWithDarkMode,
frameRendering, frameRendering,
}); } = await configExportDimension({ data, config });
if (exportingFrame) { const offsetX = -(x - normalizedPadding);
exportPadding = 0; const offsetY = -(y - normalizedPadding);
}
const [minX, minY, width, height] = getCanvasSize( const { elements } = data;
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding,
);
const offsetX = -minX + exportPadding; const theme =
const offsetY = -minY + exportPadding; cfg.theme ?? (appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT);
const exportWithDarkMode = theme === THEME.DARK;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// initialize SVG root element // initialize SVG root element
@@ -346,9 +765,12 @@ export const exportToSvg = async (
svgRoot.setAttribute("version", "1.1"); svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("xmlns", SVG_NS); svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`); svgRoot.setAttribute(
svgRoot.setAttribute("width", `${width * exportScale}`); "viewBox",
svgRoot.setAttribute("height", `${height * exportScale}`); `0 0 ${exportWidth / exportScale} ${exportHeight / exportScale}`,
);
svgRoot.setAttribute("width", `${exportWidth}`);
svgRoot.setAttribute("height", `${exportHeight}`);
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs"); const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
@@ -367,7 +789,7 @@ export const exportToSvg = async (
// we need to serialize the "original" elements before we put them through // we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids // the tempScene hack which duplicates and regenerates ids
if (exportEmbedScene) { if (appState.exportEmbedScene) {
try { try {
encodeSvgBase64Payload({ encodeSvgBase64Payload({
metadataElement, metadataElement,
@@ -375,7 +797,7 @@ export const exportToSvg = async (
// elements which don't contain the temp frame labels. // elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with // But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra. // only the elements that we're exporting, and no extra.
payload: serializeAsJSON(elements, appState, files || {}, "local"), payload: serializeAsJSON(elements, appState, data.files || {}, "local"),
}); });
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
@@ -413,7 +835,7 @@ export const exportToSvg = async (
rect.setAttribute("width", `${frame.width}`); rect.setAttribute("width", `${frame.width}`);
rect.setAttribute("height", `${frame.height}`); rect.setAttribute("height", `${frame.height}`);
if (!exportingFrame) { if (!cfg.exportingFrame) {
rect.setAttribute("rx", `${FRAME_STYLE.radius}`); rect.setAttribute("rx", `${FRAME_STYLE.radius}`);
rect.setAttribute("ry", `${FRAME_STYLE.radius}`); rect.setAttribute("ry", `${FRAME_STYLE.radius}`);
} }
@@ -428,9 +850,10 @@ export const exportToSvg = async (
// inline font faces // inline font faces
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const fontFaces = !opts?.skipInliningFonts const fontFaces =
? await Fonts.generateFontFaceDeclarations(elements) config?.skipInliningFonts !== true
: []; ? await Fonts.generateFontFaceDeclarations(elements)
: [];
const delimiter = "\n "; // 6 spaces const delimiter = "\n "; // 6 spaces
@@ -447,17 +870,16 @@ export const exportToSvg = async (
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// render background rect // render background rect
if (appState.exportBackground && viewBackgroundColor) { if (appState.exportBackground && appState.viewBackgroundColor) {
const bgColor = cfg.canvasBackgroundColor || appState.viewBackgroundColor;
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect"); const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", "0"); rect.setAttribute("x", "0");
rect.setAttribute("y", "0"); rect.setAttribute("y", "0");
rect.setAttribute("width", `${width}`); rect.setAttribute("width", `${exportWidth / exportScale}`);
rect.setAttribute("height", `${height}`); rect.setAttribute("height", `${exportHeight / exportScale}`);
rect.setAttribute( rect.setAttribute(
"fill", "fill",
exportWithDarkMode exportWithDarkMode ? applyDarkModeFilter(bgColor) : bgColor,
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor,
); );
svgRoot.appendChild(rect); svgRoot.appendChild(rect);
} }
@@ -468,14 +890,14 @@ export const exportToSvg = async (
const rsvg = rough.svg(svgRoot); const rsvg = rough.svg(svgRoot);
const renderEmbeddables = opts?.renderEmbeddables ?? false; const renderEmbeddables = config?.renderEmbeddables ?? false;
renderSceneToSvg( renderSceneToSvg(
elementsForRender, elementsForRender,
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)), toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
rsvg, rsvg,
svgRoot, svgRoot,
files || {}, data.files || {},
{ {
offsetX, offsetX,
offsetY, offsetY,
@@ -483,7 +905,7 @@ export const exportToSvg = async (
exportWithDarkMode, exportWithDarkMode,
renderEmbeddables, renderEmbeddables,
frameRendering, frameRendering,
canvasBackgroundColor: viewBackgroundColor, canvasBackgroundColor: appState.viewBackgroundColor,
embedsValidationStatus: renderEmbeddables embedsValidationStatus: renderEmbeddables
? new Map( ? new Map(
elementsForRender elementsForRender
@@ -491,8 +913,8 @@ export const exportToSvg = async (
.map((element) => [element.id, true]), .map((element) => [element.id, true]),
) )
: new Map(), : new Map(),
reuseImages: opts?.reuseImages ?? true, reuseImages: config?.reuseImages ?? true,
theme: exportWithDarkMode ? THEME.DARK : THEME.LIGHT, theme,
}, },
); );
@@ -501,6 +923,10 @@ export const exportToSvg = async (
return svgRoot; return svgRoot;
}; };
// ---------------------------------------------------------------------------
// SVG payload encoding/decoding
// ---------------------------------------------------------------------------
export const encodeSvgBase64Payload = ({ export const encodeSvgBase64Payload = ({
payload, payload,
metadataElement, metadataElement,
@@ -556,26 +982,149 @@ export const decodeSvgBase64Payload = ({ svg }: { svg: string }) => {
throw new Error("INVALID"); throw new Error("INVALID");
}; };
// ---------------------------------------------------------------------------
// getCanvasSize
// ---------------------------------------------------------------------------
// calculate smallest area to fit the contents in // calculate smallest area to fit the contents in
const getCanvasSize = ( export const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number,
): Bounds => { ): Bounds => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const width = distance(minX, maxX) + exportPadding * 2; const width = distance(minX, maxX);
const height = distance(minY, maxY) + exportPadding * 2; const height = distance(minY, maxY);
return [minX, minY, width, height]; return [minX, minY, width, height];
}; };
/**
* Gets the export dimensions for a set of elements.
*
* @param elements - Elements to calculate size for
* @param exportPadding - Padding to add around the elements
* @param scale - Scale factor
* @returns [width, height] tuple
*/
export const getExportSize = ( export const getExportSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number, exportPadding: number,
scale: number, scale: number,
): [number, number] => { ): [number, number] => {
const [, , width, height] = getCanvasSize(elements, exportPadding).map( const [, , width, height] = getCanvasSize(elements);
(dimension) => Math.trunc(dimension * scale),
);
return [width, height]; return [
Math.trunc((width + exportPadding * 2) * scale),
Math.trunc((height + exportPadding * 2) * scale),
];
};
// ---------------------------------------------------------------------------
// exportToBlob
// ---------------------------------------------------------------------------
export { MIME_TYPES };
type ExportToBlobConfig = ExportSceneConfig & {
mimeType?: string;
quality?: number;
};
export const exportToBlob = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportToBlobConfig;
}): Promise<Blob> => {
let { mimeType = MIME_TYPES.png, quality } = config || {};
if (mimeType === MIME_TYPES.png && typeof quality === "number") {
console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
}
// typo in MIME type (should be "jpeg")
if (mimeType === "image/jpg") {
mimeType = MIME_TYPES.jpg;
}
if (mimeType === MIME_TYPES.jpg && config?.canvasBackgroundColor !== false) {
if (config?.canvasBackgroundColor === undefined) {
console.warn(
`Defaulting "canvasBackgroundColor" for "${MIME_TYPES.jpg}" mimeType`,
);
config = {
...config,
canvasBackgroundColor:
data.appState?.viewBackgroundColor || COLOR_WHITE,
};
}
}
const canvas = await exportToCanvas({ data, config });
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
return new Promise((resolve, reject) => {
canvas.toBlob(
async (blob) => {
if (!blob) {
return reject(new Error("couldn't export to blob"));
}
if (
blob &&
mimeType === MIME_TYPES.png &&
data.appState?.exportEmbedScene
) {
blob = await encodePngMetadata({
blob,
metadata: serializeAsJSON(
// NOTE as long as we're using the Scene hack, we need to ensure
// we pass the original, uncloned elements when serializing
// so that we keep ids stable
data.elements,
data.appState,
data.files || {},
"local",
),
});
}
resolve(blob);
},
mimeType,
quality,
);
});
};
// ---------------------------------------------------------------------------
// exportToClipboard
// ---------------------------------------------------------------------------
export const exportToClipboard = async ({
type,
data,
config,
}: {
data: ExportSceneData;
} & (
| { type: "png"; config?: ExportToBlobConfig }
| { type: "svg"; config?: ExportToSvgConfig }
| { type: "json"; config?: never }
)) => {
if (type === "svg") {
const svg = await exportToSvg({
data: {
...data,
appState: restoreAppState(data.appState, null),
},
config,
});
await copyTextToSystemClipboard(svg.outerHTML);
} else if (type === "png") {
await copyBlobToClipboardAsPng(exportToBlob({ data, config }));
} else if (type === "json") {
await copyToClipboard(data.elements, data.files);
} else {
throw new Error("Invalid export type");
}
}; };
@@ -4358,7 +4358,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 1006504105, "versionNonce": 760410951,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4383,14 +4383,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 400692809, "seed": 238820263,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 289600103, "versionNonce": 1006504105,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -6864,7 +6864,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 1723083209, "versionNonce": 747212839,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -6891,14 +6891,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 400692809, "seed": 238820263,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 760410951, "versionNonce": 1723083209,
"width": 10, "width": 10,
"x": 12, "x": 12,
"y": 0, "y": 0,
@@ -224,7 +224,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 22, "version": 29,
"width": "94.00000", "width": "94.00000",
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -350,9 +350,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 21, "version": 28,
"width": "88.00000", "width": "88.00000",
"y": "7.20923",
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@@ -382,9 +381,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 20, "version": 25,
"width": "88.00000", "width": "88.00000",
"y": "0.01000",
}, },
}, },
}, },
@@ -439,7 +437,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
], ],
"startBinding": null, "startBinding": null,
"version": 22, "version": 29,
"width": "94.00000", "width": "94.00000",
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -464,7 +462,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 21, "version": 28,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "7.20923", "y": "7.20923",
@@ -1362,9 +1360,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": 7, "version": 8,
"width": 88, "width": 88,
"x": "6.00000", "x": 6,
"y": "2.00947", "y": "2.00947",
} }
`; `;
@@ -1539,12 +1537,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 7, "version": 8,
}, },
"inserted": { "inserted": {
"endBinding": null, "endBinding": null,
"startBinding": null, "startBinding": null,
"version": 6, "version": 7,
}, },
}, },
}, },
@@ -1724,7 +1722,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.00000", "x": 6,
"y": "38.80379", "y": "38.80379",
} }
`; `;
@@ -1869,7 +1867,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.00000", "x": 6,
"y": "38.80379", "y": "38.80379",
}, },
"inserted": { "inserted": {
@@ -2418,7 +2416,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": 11, "version": 12,
"width": 488, "width": 488,
"x": 6, "x": 6,
"y": "-5.39000", "y": "-5.39000",
@@ -2583,7 +2581,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": 11, "version": 12,
"width": 488, "width": 488,
"x": 6, "x": 6,
"y": "-5.39000", "y": "-5.39000",
@@ -11222,489 +11220,6 @@ 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,
@@ -17121,7 +16636,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": "0.00120",
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -17134,7 +16649,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
[ [
"88.00000", "88.00000",
0, "0.00120",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -17157,7 +16672,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.01000", "y": "0.00880",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -19135,7 +18650,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": "0.00120",
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -19148,7 +18663,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
[ [
"88.00000", "88.00000",
0, "0.00120",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -19171,7 +18686,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.01000", "y": "0.00880",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -19883,7 +19398,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": "0.00120",
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -19896,7 +19411,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
[ [
"88.00000", "88.00000",
0, "0.00120",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -19919,7 +19434,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.01000", "y": "0.00880",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
+14 -6
View File
@@ -83,11 +83,13 @@ describe("export", () => {
}); });
it("export svg-embedded scene", async () => { it("export svg-embedded scene", async () => {
const svg = await exportToSvg( const svg = await exportToSvg({
testElements, data: {
{ ...getDefaultAppState(), exportEmbedScene: true }, elements: testElements,
{}, appState: { ...getDefaultAppState(), exportEmbedScene: true },
); files: {},
},
});
const svgText = svg.outerHTML; const svgText = svg.outerHTML;
expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`); expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`);
@@ -205,7 +207,13 @@ describe("export", () => {
}, },
} as const; } as const;
const svg = await exportToSvg(elements, appState, files); const svg = await exportToSvg({
data: {
elements,
appState,
files,
},
});
const svgText = svg.outerHTML; const svgText = svg.outerHTML;
+2 -12
View File
@@ -4,7 +4,6 @@ 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 {
@@ -21,7 +20,7 @@ import {
isTextElement, isTextElement,
isFrameLikeElement, isFrameLikeElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, arrayToMap, getLineHeight } from "@excalidraw/common"; import { KEYS, arrayToMap } from "@excalidraw/common";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -517,17 +516,8 @@ 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, clickY); mouse.click(x, y);
} 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,82 +2971,6 @@ 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");
File diff suppressed because one or more lines are too long
+146 -93
View File
@@ -1,9 +1,10 @@
import { exportToCanvas, exportToSvg } from "@excalidraw/utils"; import { exportToCanvas, exportToSvg } from "../../scene/export";
import { import {
applyDarkModeFilter, applyDarkModeFilter,
FONT_FAMILY, FONT_FAMILY,
FRAME_STYLE, FRAME_STYLE,
THEME,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -53,39 +54,42 @@ describe("exportToSvg", () => {
}, },
] as NonDeletedExcalidrawElement[]; ] as NonDeletedExcalidrawElement[];
const DEFAULT_OPTIONS = { const DEFAULT_APP_STATE = {
exportBackground: false, exportBackground: false,
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
files: {},
}; };
it("with default arguments", async () => { it("with default arguments", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
DEFAULT_OPTIONS, elements: ELEMENTS,
null, appState: DEFAULT_APP_STATE,
); files: {},
},
});
expect(svgElement).toMatchSnapshot(); expect(svgElement).toMatchSnapshot();
}); });
it("with a CJK font", async () => { it("with a CJK font", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
[ data: {
...ELEMENTS, elements: [
{ ...ELEMENTS,
...textFixture, {
height: ELEMENT_HEIGHT, ...textFixture,
width: ELEMENT_WIDTH, height: ELEMENT_HEIGHT,
text: "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.", width: ELEMENT_WIDTH,
originalText: text: "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
"中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.", originalText:
index: "a4" as FractionalIndex, "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
} as ExcalidrawTextElement, index: "a4" as FractionalIndex,
], } as ExcalidrawTextElement,
DEFAULT_OPTIONS, ],
null, appState: DEFAULT_APP_STATE,
); files: {},
},
});
expect(svgElement).toMatchSnapshot(); expect(svgElement).toMatchSnapshot();
// extend the timeout, as it needs to first load the fonts from disk and then perform whole woff2 decode, subset and encode (without workers) // extend the timeout, as it needs to first load the fonts from disk and then perform whole woff2 decode, subset and encode (without workers)
@@ -94,15 +98,17 @@ describe("exportToSvg", () => {
it("with background color", async () => { it("with background color", async () => {
const BACKGROUND_COLOR = "#abcdef"; const BACKGROUND_COLOR = "#abcdef";
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: {
exportBackground: true, ...DEFAULT_APP_STATE,
viewBackgroundColor: BACKGROUND_COLOR, exportBackground: true,
viewBackgroundColor: BACKGROUND_COLOR,
},
files: {},
}, },
null, });
);
expect(svgElement.querySelector("rect")).toHaveAttribute( expect(svgElement.querySelector("rect")).toHaveAttribute(
"fill", "fill",
@@ -111,14 +117,18 @@ describe("exportToSvg", () => {
}); });
it("with dark mode", async () => { it("with dark mode", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: {
exportWithDarkMode: true, ...DEFAULT_APP_STATE,
},
files: {},
}, },
null, config: {
); theme: THEME.DARK,
},
});
const textElements = svgElement.querySelectorAll("text"); const textElements = svgElement.querySelectorAll("text");
expect(textElements.length).toBeGreaterThan(0); expect(textElements.length).toBeGreaterThan(0);
@@ -132,14 +142,16 @@ describe("exportToSvg", () => {
}); });
it("with exportPadding", async () => { it("with exportPadding", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: DEFAULT_APP_STATE,
exportPadding: 0, files: {},
}, },
null, config: {
); padding: 0,
},
});
expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString()); expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString());
expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString()); expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString());
@@ -152,15 +164,17 @@ describe("exportToSvg", () => {
it("with scale", async () => { it("with scale", async () => {
const SCALE = 2; const SCALE = 2;
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: DEFAULT_APP_STATE,
exportPadding: 0, files: {},
exportScale: SCALE,
}, },
null, config: {
); padding: 0,
scale: SCALE,
},
});
expect(svgElement).toHaveAttribute( expect(svgElement).toHaveAttribute(
"height", "height",
@@ -173,23 +187,27 @@ describe("exportToSvg", () => {
}); });
it("with exportEmbedScene", async () => { it("with exportEmbedScene", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: {
exportEmbedScene: true, ...DEFAULT_APP_STATE,
exportEmbedScene: true,
},
files: {},
}, },
null, });
);
expect(svgElement.innerHTML).toMatchSnapshot(); expect(svgElement.innerHTML).toMatchSnapshot();
}); });
it("with elements that have a link", async () => { it("with elements that have a link", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
[rectangleWithLinkFixture], data: {
DEFAULT_OPTIONS, elements: [rectangleWithLinkFixture],
null, appState: DEFAULT_APP_STATE,
); files: {},
},
});
expect(svgElement.innerHTML).toMatchSnapshot(); expect(svgElement.innerHTML).toMatchSnapshot();
}); });
}); });
@@ -229,9 +247,14 @@ describe("exporting frames", () => {
]; ];
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements, data: {
files: null, elements,
exportPadding: 0, appState: {},
files: null,
},
config: {
padding: 0,
},
}); });
expect(canvas.width).toEqual(200); expect(canvas.width).toEqual(200);
@@ -258,10 +281,15 @@ describe("exporting frames", () => {
]; ];
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements, data: {
files: null, elements,
exportPadding: 0, appState: {},
exportingFrame: frame, files: null,
},
config: {
padding: 0,
exportingFrame: frame,
},
}); });
expect(canvas.width).toEqual(frame.width); expect(canvas.width).toEqual(frame.width);
@@ -297,10 +325,15 @@ describe("exporting frames", () => {
}); });
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: [rectOverlapping, frame, frameChild], data: {
files: null, elements: [rectOverlapping, frame, frameChild],
exportPadding: 0, appState: {},
exportingFrame: frame, files: null,
},
config: {
padding: 0,
exportingFrame: frame,
},
}); });
// frame itself isn't exported // frame itself isn't exported
@@ -341,10 +374,15 @@ describe("exporting frames", () => {
}); });
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: [frameChild, frame, elementOutside], data: {
files: null, elements: [frameChild, frame, elementOutside],
exportPadding: 0, appState: {},
exportingFrame: frame, files: null,
},
config: {
padding: 0,
exportingFrame: frame,
},
}); });
// frame itself isn't exported // frame itself isn't exported
@@ -409,10 +447,15 @@ describe("exporting frames", () => {
); );
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: exportedElements, data: {
files: null, elements: exportedElements,
exportPadding: 0, appState: {},
exportingFrame, files: null,
},
config: {
padding: 0,
exportingFrame,
},
}); });
// frames themselves should be exported when multiple frames selected // frames themselves should be exported when multiple frames selected
@@ -454,10 +497,15 @@ describe("exporting frames", () => {
); );
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: exportedElements, data: {
files: null, elements: exportedElements,
exportPadding: 0, appState: {},
exportingFrame, files: null,
},
config: {
padding: 0,
exportingFrame,
},
}); });
// frame itself isn't exported // frame itself isn't exported
@@ -513,10 +561,15 @@ describe("exporting frames", () => {
); );
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: exportedElements, data: {
files: null, elements: exportedElements,
exportPadding: 0, appState: {},
exportingFrame, files: null,
},
config: {
padding: 0,
exportingFrame,
},
}); });
// frame shouldn't be exported // frame shouldn't be exported
+6 -568
View File
@@ -1,9 +1,7 @@
import React from "react"; import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common"; import { KEYS, reseed } from "@excalidraw/common";
import { getElementBounds, getElementLineSegments } from "@excalidraw/element";
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
import { SHAPES } from "../components/shapes"; import { SHAPES } from "../components/shapes";
@@ -14,7 +12,6 @@ import * as StaticScene from "../renderer/staticScene";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { import {
act,
render, render,
fireEvent, fireEvent,
mockBoundingClientRect, mockBoundingClientRect,
@@ -42,19 +39,6 @@ const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
const getOutlineBounds = (element: ReturnType<typeof API.createElement>) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const points = getElementLineSegments(sceneElement, elementsMap).flat();
return [
Math.min(...points.map((point) => point[0])),
Math.min(...points.map((point) => point[1])),
Math.max(...points.map((point) => point[0])),
Math.max(...points.map((point) => point[1])),
] as const;
};
describe("box-selection", () => { describe("box-selection", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<Excalidraw />); await render(<Excalidraw />);
@@ -124,497 +108,6 @@ describe("box-selection", () => {
assertSelectedElements([]); assertSelectedElements([]);
}); });
it("should not select an element when the selection box only partially overlaps it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect1]);
mouse.downAt(25, -20);
mouse.move(-1000, -1000);
mouse.moveTo(75, 70);
mouse.up();
assertSelectedElements([]);
});
});
describe("lasso reselection", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("should allow ctrl+alt lasso reselection when starting inside the active common bounds", () => {
const rectA = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
const rectB = API.createElement({
type: "rectangle",
x: 220,
y: 0,
width: 100,
height: 100,
backgroundColor: "blue",
fillStyle: "solid",
});
API.setElements([rectA, rectB]);
mouse.select([rectA, rectB]);
act(() => {
h.app.setActiveTool({ type: "lasso" });
});
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
mouse.downAt(110, 50);
mouse.moveTo(50, -20);
expect(h.app.lassoTrail.hasCurrentTrail).toBe(true);
mouse.moveTo(-20, 50);
mouse.moveTo(50, 120);
mouse.moveTo(110, 50);
mouse.up();
});
assertSelectedElements([rectA.id]);
});
});
describe("box-selection overlap mode", () => {
const boxSelect = (
startX: number,
startY: number,
endX: number,
endY: number,
) => {
mouse.downAt(startX, startY);
mouse.move(-1000, -1000);
mouse.moveTo(endX, endY);
mouse.up();
};
const boxSelectTopLeftAabbCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1] = getElementBounds(sceneElement, elementsMap);
boxSelect(x1 + 2, y1 + 2, x1 + 12, y1 + 12);
};
const boxSelectTopRightAabbCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [, y1, x2] = getElementBounds(sceneElement, elementsMap);
boxSelect(x2 - 12, y1 + 2, x2 - 2, y1 + 12);
};
const boxSelectTopLeftRotatedLocalBoundsCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2] = getElementBounds(sceneElement, elementsMap, true);
const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2);
const [cornerX, cornerY] = pointRotateRads(
pointFrom(x1, y1),
center,
sceneElement.angle,
);
boxSelect(cornerX - 4, cornerY - 4, cornerX + 4, cornerY + 4);
};
beforeEach(async () => {
await render(
<Excalidraw
initialData={{ appState: { boxSelectionMode: "overlap" } }}
/>,
);
});
it("should select an element when the selection box partially overlaps it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(25, -20, 75, 70);
assertSelectedElements([rect1.id]);
});
it("should not select a transparent rectangle when the selection box stays inside it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(25, 25, 75, 75);
assertSelectedElements([]);
});
it("should select a transparent rectangle when the selection box crosses its outline", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(25, 25, 125, 75);
assertSelectedElements([rect1.id]);
});
it("should not select a rotated transparent rectangle when the selection box stays inside it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(40, 40, 60, 60);
assertSelectedElements([]);
});
it("should select a rotated rounded rectangle when the selection box contains its outline but not its bounds", () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 180,
angle: Math.PI / 6,
backgroundColor: "transparent",
fillStyle: "solid",
roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS },
roughness: 0,
});
API.setElements([rect]);
const sceneRect = API.getElement(rect);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [boundsX1, boundsY1, boundsX2, boundsY2] = getElementBounds(
sceneRect,
elementsMap,
);
const [outlineX1, outlineY1, outlineX2, outlineY2] = getOutlineBounds(rect);
expect(outlineX1).toBeGreaterThan(boundsX1);
expect(outlineY1).toBeGreaterThan(boundsY1);
expect(outlineX2).toBeLessThan(boundsX2);
expect(outlineY2).toBeLessThan(boundsY2);
boxSelect(
outlineX1 - (outlineX1 - boundsX1) / 2,
outlineY1 - (outlineY1 - boundsY1) / 2,
outlineX2 + (boundsX2 - outlineX2) / 2,
outlineY2 + (boundsY2 - outlineY2) / 2,
);
assertSelectedElements([rect.id]);
});
it("should not select a filled rotated rectangle when the selection box only overlaps its axis-aligned bounds", () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect]);
boxSelectTopLeftAabbCorner(rect);
assertSelectedElements([]);
});
it("should not select a filled ellipse when the selection box only overlaps its bounds corner", () => {
const ellipse = API.createElement({
type: "ellipse",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([ellipse]);
boxSelectTopRightAabbCorner(ellipse);
assertSelectedElements([]);
});
it("should not select a filled diamond when the selection box only overlaps its bounds corner", () => {
const diamond = API.createElement({
type: "diamond",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([diamond]);
boxSelectTopRightAabbCorner(diamond);
assertSelectedElements([]);
});
it("should not select a filled rotated ellipse when the selection box only overlaps its axis-aligned bounds", () => {
const ellipse = API.createElement({
type: "ellipse",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([ellipse]);
boxSelectTopLeftRotatedLocalBoundsCorner(ellipse);
assertSelectedElements([]);
});
it("should not select a filled rotated diamond when the selection box only overlaps its rotated local bounds", () => {
const diamond = API.createElement({
type: "diamond",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([diamond]);
boxSelectTopLeftRotatedLocalBoundsCorner(diamond);
assertSelectedElements([]);
});
it("should not select rotated text when the selection box only overlaps its axis-aligned bounds", () => {
const text = API.createElement({
type: "text",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
text: "test",
});
API.setElements([text]);
boxSelect(-18, -18, -8, -8);
assertSelectedElements([]);
});
it("should not select rotated image when the selection box only overlaps its axis-aligned bounds", () => {
const image = API.createElement({
type: "image",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
fileId: "file_A",
status: "saved",
});
API.setElements([image]);
boxSelect(-18, -18, -8, -8);
assertSelectedElements([]);
});
it("should deselect a selected rotated rectangle when clicking in the empty corner of its axis-aligned bounds", () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect]);
mouse.clickAt(50, 50);
assertSelectedElements([rect.id]);
const sceneRect = API.getElement(rect);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1] = getElementBounds(sceneRect, elementsMap);
mouse.clickAt(x1 + 2, y1 + 2);
assertSelectedElements([]);
});
it("should not select a line when the selection box only overlaps its bounds", () => {
const line = API.createElement({
type: "line",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(100, 100)],
});
API.setElements([line]);
boxSelect(20, 50, 30, 60);
assertSelectedElements([]);
});
it("should not click-select rotated freedraw in the corner of its axis-aligned bounds", () => {
const freedraw = API.createElement({
type: "freedraw",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "transparent",
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 0),
pointFrom<LocalPoint>(100, 100),
pointFrom<LocalPoint>(0, 100),
pointFrom<LocalPoint>(0, 0),
],
});
API.setElements([freedraw]);
const sceneFreedraw = API.getElement(freedraw);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1] = getElementBounds(sceneFreedraw, elementsMap);
mouse.clickAt(x1 + 2, y1 + 2);
assertSelectedElements([]);
});
it("should not select a freedraw when the selection box only overlaps its bounds", () => {
const freedraw = API.createElement({
type: "freedraw",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(50, 50),
pointFrom<LocalPoint>(100, 100),
],
});
API.setElements([freedraw]);
boxSelect(20, 50, 30, 60);
assertSelectedElements([]);
});
it("should not select a transparent framed element when the selection box stays inside its clipped bounds", () => {
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
fillStyle: "solid",
});
const rect1 = API.createElement({
type: "rectangle",
x: 50,
y: 10,
width: 100,
height: 80,
frameId: frame.id,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([frame, rect1]);
boxSelect(60, 20, 90, 60);
assertSelectedElements([]);
});
}); });
describe("inner box-selection", () => { describe("inner box-selection", () => {
@@ -833,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(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
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();
@@ -866,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(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
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();
@@ -899,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(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
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();
@@ -945,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(9); expect(renderStaticScene).toHaveBeenCalledTimes(8);
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();
@@ -990,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(9); expect(renderStaticScene).toHaveBeenCalledTimes(8);
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();
@@ -1065,58 +558,3 @@ 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({});
});
});
@@ -1,88 +0,0 @@
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
);
};
+4 -8
View File
@@ -32,7 +32,6 @@ import type {
OrderedExcalidrawElement, OrderedExcalidrawElement,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
BindMode, BindMode,
ExcalidrawTextElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@@ -269,8 +268,6 @@ export type ObservedElementsAppState = {
activeLockedId: AppState["activeLockedId"]; activeLockedId: AppState["activeLockedId"];
}; };
export type BoxSelectionMode = "contain" | "overlap";
export interface AppState { export interface AppState {
contextMenu: { contextMenu: {
items: ContextMenuItems; items: ContextMenuItems;
@@ -309,8 +306,6 @@ export interface AppState {
* `bindingPreference` and keyboard modifiers (ctrl/alt) * `bindingPreference` and keyboard modifiers (ctrl/alt)
*/ */
isBindingEnabled: boolean; isBindingEnabled: boolean;
/** user box selection preference; defaults to "contain" when unset */
boxSelectionMode?: BoxSelectionMode;
/** user arrow binding preference */ /** user arrow binding preference */
bindingPreference: "enabled" | "disabled"; bindingPreference: "enabled" | "disabled";
/** user preference whether arrow snap to midpoints while binding */ /** user preference whether arrow snap to midpoints while binding */
@@ -332,7 +327,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: ExcalidrawTextElement | null; editingTextElement: NonDeletedExcalidrawElement | 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
@@ -881,8 +876,9 @@ 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 };
// explicit flag for specific scenarios such as: // Whether to block drag after lasso selection
// - after lasso selection until the next pointer down // this is meant to be used to block dragging after lasso selection on PCs
// until the next pointer down
blockDragging: boolean; blockDragging: boolean;
}; };
// We need to have these in the state so that we can unsubscribe them // We need to have these in the state so that we can unsubscribe them
@@ -1,10 +1,7 @@
import { queryByText } from "@testing-library/react"; import { queryByText } from "@testing-library/react";
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { import { getOriginalContainerHeightFromCache } from "@excalidraw/element";
getLineHeightInPx,
getOriginalContainerHeightFromCache,
} from "@excalidraw/element";
import { import {
CODES, CODES,
@@ -45,28 +42,6 @@ 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;
@@ -235,42 +210,6 @@ 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",
@@ -293,94 +232,6 @@ 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", {
@@ -1354,40 +1205,6 @@ 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",
@@ -1694,7 +1511,7 @@ describe("textWysiwyg", () => {
version: 2, version: 2,
width: 610, width: 610,
x: 15, x: 15,
y: 12.5, y: 25,
}), }),
); );
expect(h.elements[2] as ExcalidrawTextElement).toEqual( expect(h.elements[2] as ExcalidrawTextElement).toEqual(
+2 -203
View File
@@ -10,9 +10,7 @@ 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,
@@ -35,11 +33,8 @@ 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,
@@ -96,103 +91,6 @@ 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 = ({
@@ -205,7 +103,6 @@ export const textWysiwyg = ({
excalidrawContainer, excalidrawContainer,
app, app,
autoSelect = true, autoSelect = true,
initialCaretSceneCoords = null,
}: { }: {
id: ExcalidrawElement["id"]; id: ExcalidrawElement["id"];
/** /**
@@ -222,19 +119,7 @@ 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,
@@ -369,7 +254,6 @@ 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 =
@@ -385,7 +269,7 @@ export const textWysiwyg = ({
transform: getTransform( transform: getTransform(
width, width,
height, height,
angle, getTextElementAngle(updatedTextElement, container),
appState, appState,
maxWidth, maxWidth,
editorMaxHeight, editorMaxHeight,
@@ -399,19 +283,6 @@ 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
@@ -462,71 +333,6 @@ 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()
@@ -890,13 +696,6 @@ 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;
}
}); });
}; };
@@ -987,7 +786,7 @@ export const textWysiwyg = ({
let isDestroyed = false; let isDestroyed = false;
if (autoSelect && !pendingInitialSelection) { if (autoSelect) {
// 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();
+2 -7
View File
@@ -123,15 +123,10 @@ 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>(
point: Point, [x, y]: Point,
center: Point, [cx, cy]: 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[1]); isPoint(segment[0]);
/** /**
* Return the coordinates resulting from rotating the given line about an origin by an angle in radians * Return the coordinates resulting from rotating the given line about an origin by an angle in radians
-11
View File
@@ -1,14 +1,3 @@
//
// Generic markers
//
/**
* Can be used for any type of point-likes to mark them as rotated to enlist
* the type checker to weed out subtle bugs due to rotated and non-rotated
* versions of the same data point.
*/
export type Rotated<T> = T & { _brand_rotated: "excalimath_rotated" };
// //
// Measurements // Measurements
// //
+1 -25
View File
@@ -1,9 +1,5 @@
import { pointFrom } from "../src/point"; import { pointFrom } from "../src/point";
import { import { lineSegment, lineSegmentIntersectionPoints } from "../src/segment";
lineSegment,
lineSegmentIntersectionPoints,
isLineSegment,
} from "../src/segment";
describe("line-segment intersections", () => { describe("line-segment intersections", () => {
it("should correctly detect intersection", () => { it("should correctly detect intersection", () => {
@@ -23,23 +19,3 @@ 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);
});
});
-216
View File
@@ -1,216 +0,0 @@
import { MIME_TYPES } from "@excalidraw/common";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
copyToClipboard,
} from "@excalidraw/excalidraw/clipboard";
import { encodePngMetadata } from "@excalidraw/excalidraw/data/image";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import {
restoreAppState,
restoreElements,
} from "@excalidraw/excalidraw/data/restore";
import {
exportToCanvas as _exportToCanvas,
exportToSvg as _exportToSvg,
} from "@excalidraw/excalidraw/scene/export";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
} from "@excalidraw/element/types";
import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types";
export { MIME_TYPES };
type ExportOpts = {
elements: readonly NonDeleted<ExcalidrawElement>[];
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
files: BinaryFiles | null;
maxWidthOrHeight?: number;
exportingFrame?: ExcalidrawFrameLikeElement | null;
getDimensions?: (
width: number,
height: number,
) => { width: number; height: number; scale?: number };
};
export const exportToCanvas = ({
elements,
appState,
files,
maxWidthOrHeight,
getDimensions,
exportPadding,
exportingFrame,
}: ExportOpts & {
exportPadding?: number;
}) => {
const restoredElements = restoreElements(elements, null, {
deleteInvisibleElements: true,
});
const restoredAppState = restoreAppState(appState, null);
const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas(
restoredElements,
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
files || {},
{ exportBackground, exportPadding, viewBackgroundColor, exportingFrame },
(width: number, height: number) => {
const canvas = document.createElement("canvas");
if (maxWidthOrHeight) {
if (typeof getDimensions === "function") {
console.warn(
"`getDimensions()` is ignored when `maxWidthOrHeight` is supplied.",
);
}
const max = Math.max(width, height);
// if content is less then maxWidthOrHeight, fallback on supplied scale
const scale =
maxWidthOrHeight < max
? maxWidthOrHeight / max
: appState?.exportScale ?? 1;
canvas.width = width * scale;
canvas.height = height * scale;
return {
canvas,
scale,
};
}
const ret = getDimensions?.(width, height) || { width, height };
canvas.width = ret.width;
canvas.height = ret.height;
return {
canvas,
scale: ret.scale ?? 1,
};
},
);
};
export const exportToBlob = async (
opts: ExportOpts & {
mimeType?: string;
quality?: number;
exportPadding?: number;
},
): Promise<Blob> => {
let { mimeType = MIME_TYPES.png, quality } = opts;
if (mimeType === MIME_TYPES.png && typeof quality === "number") {
console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
}
// typo in MIME type (should be "jpeg")
if (mimeType === "image/jpg") {
mimeType = MIME_TYPES.jpg;
}
if (mimeType === MIME_TYPES.jpg && !opts.appState?.exportBackground) {
console.warn(
`Defaulting "exportBackground" to "true" for "${MIME_TYPES.jpg}" mimeType`,
);
opts = {
...opts,
appState: { ...opts.appState, exportBackground: true },
};
}
const canvas = await exportToCanvas(opts);
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
return new Promise((resolve, reject) => {
canvas.toBlob(
async (blob) => {
if (!blob) {
return reject(new Error("couldn't export to blob"));
}
if (
blob &&
mimeType === MIME_TYPES.png &&
opts.appState?.exportEmbedScene
) {
blob = await encodePngMetadata({
blob,
metadata: serializeAsJSON(
// NOTE as long as we're using the Scene hack, we need to ensure
// we pass the original, uncloned elements when serializing
// so that we keep ids stable
opts.elements,
opts.appState,
opts.files || {},
"local",
),
});
}
resolve(blob);
},
mimeType,
quality,
);
});
};
export const exportToSvg = async ({
elements,
appState = getDefaultAppState(),
files = {},
exportPadding,
renderEmbeddables,
exportingFrame,
skipInliningFonts,
reuseImages,
}: Omit<ExportOpts, "getDimensions"> & {
exportPadding?: number;
renderEmbeddables?: boolean;
skipInliningFonts?: true;
reuseImages?: boolean;
}): Promise<SVGSVGElement> => {
const restoredElements = restoreElements(elements, null, {
deleteInvisibleElements: true,
});
const restoredAppState = restoreAppState(appState, null);
const exportAppState = {
...restoredAppState,
exportPadding,
};
return _exportToSvg(restoredElements, exportAppState, files, {
exportingFrame,
renderEmbeddables,
skipInliningFonts,
reuseImages,
});
};
export const exportToClipboard = async (
opts: ExportOpts & {
mimeType?: string;
quality?: number;
type: "png" | "svg" | "json";
},
) => {
if (opts.type === "svg") {
const svg = await exportToSvg(opts);
await copyTextToSystemClipboard(svg.outerHTML);
} else if (opts.type === "png") {
await copyBlobToClipboardAsPng(exportToBlob(opts));
} else if (opts.type === "json") {
await copyToClipboard(opts.elements, opts.files);
} else {
throw new Error("Invalid export type");
}
};
+14 -1
View File
@@ -1,4 +1,17 @@
export * from "./export"; // Re-export from @excalidraw/excalidraw for backwards compatibility
export {
exportToCanvas,
exportToBlob,
exportToSvg,
exportToClipboard,
MIME_TYPES,
} from "@excalidraw/excalidraw/scene/export";
export type {
ExportSceneData,
ExportSceneConfig,
} from "@excalidraw/excalidraw/scene/export";
export * from "./withinBounds"; export * from "./withinBounds";
export * from "./bbox"; export * from "./bbox";
export { getCommonBounds } from "@excalidraw/element"; export { getCommonBounds } from "@excalidraw/element";
+14 -21
View File
@@ -1,9 +1,4 @@
import { import { arrayToMap, type Bounds } from "@excalidraw/common";
arrayToMap,
bounds,
type RotatedBounds,
type Bounds,
} from "@excalidraw/common";
import { getElementBounds } from "@excalidraw/element"; import { getElementBounds } from "@excalidraw/element";
import { import {
isArrowElement, isArrowElement,
@@ -95,7 +90,7 @@ const getMinMaxPoints = (points: Points) => {
return ret; return ret;
}; };
const getRotatedBBox = (element: Element): RotatedBounds => { const getRotatedBBox = (element: Element): Bounds => {
const points = getElementRelativePoints(element); const points = getElementRelativePoints(element);
const { cx, cy } = getMinMaxPoints(points); const { cx, cy } = getMinMaxPoints(points);
@@ -106,13 +101,12 @@ const getRotatedBBox = (element: Element): RotatedBounds => {
); );
const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints); const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints);
return bounds( return [
minX + element.x, minX + element.x,
minY + element.y, minY + element.y,
maxX + element.x, maxX + element.x,
maxY + element.y, maxY + element.y,
element.angle, ];
);
}; };
export const isElementInsideBBox = ( export const isElementInsideBBox = (
@@ -166,12 +160,12 @@ export const elementPartiallyOverlapsWithOrContainsBBox = (
export const elementsOverlappingBBox = ({ export const elementsOverlappingBBox = ({
elements, elements,
bounds: _bounds, bounds,
type, type,
errorMargin = 0, errorMargin = 0,
}: { }: {
elements: Elements; elements: Elements;
bounds: RotatedBounds | ExcalidrawElement; bounds: Bounds | ExcalidrawElement;
/** safety offset. Defaults to 0. */ /** safety offset. Defaults to 0. */
errorMargin?: number; errorMargin?: number;
/** /**
@@ -181,16 +175,15 @@ export const elementsOverlappingBBox = ({
**/ **/
type: "overlap" | "contain" | "inside"; type: "overlap" | "contain" | "inside";
}) => { }) => {
if (isExcalidrawElement(_bounds)) { if (isExcalidrawElement(bounds)) {
_bounds = getElementBounds(_bounds, arrayToMap(elements)); bounds = getElementBounds(bounds, arrayToMap(elements));
} }
const adjustedBBox = bounds( const adjustedBBox: Bounds = [
_bounds[0] - errorMargin, bounds[0] - errorMargin,
_bounds[1] - errorMargin, bounds[1] - errorMargin,
_bounds[2] + errorMargin, bounds[2] + errorMargin,
_bounds[3] + errorMargin, bounds[3] + errorMargin,
_bounds[4], ];
);
const includedElementSet = new Set<string>(); const includedElementSet = new Set<string>();
+58 -35
View File
@@ -10,9 +10,22 @@ const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
describe("exportToCanvas", async () => { describe("exportToCanvas", async () => {
const EXPORT_PADDING = 10; const EXPORT_PADDING = 10;
it("with default arguments", async () => { it("with default arguments (no padding)", async () => {
const canvas = await utils.exportToCanvas({ const canvas = await utils.exportToCanvas({
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }), data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
});
// New API has no default padding - call sites must explicitly set it
expect(canvas.width).toBe(100);
expect(canvas.height).toBe(100);
});
it("with padding", async () => {
const canvas = await utils.exportToCanvas({
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
config: {
padding: EXPORT_PADDING,
},
}); });
expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING); expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING);
@@ -21,8 +34,10 @@ describe("exportToCanvas", async () => {
it("when custom width and height", async () => { it("when custom width and height", async () => {
const canvas = await utils.exportToCanvas({ const canvas = await utils.exportToCanvas({
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }), data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
getDimensions: () => ({ width: 200, height: 200, scale: 1 }), config: {
getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
},
}); });
expect(canvas.width).toBe(200); expect(canvas.width).toBe(200);
@@ -33,20 +48,27 @@ describe("exportToCanvas", async () => {
describe("exportToBlob", async () => { describe("exportToBlob", async () => {
describe("mime type", () => { describe("mime type", () => {
it("should change image/jpg to image/jpeg", async () => { it("should change image/jpg to image/jpeg", async () => {
const diagramData = diagramFactory();
const blob = await utils.exportToBlob({ const blob = await utils.exportToBlob({
...diagramFactory(), data: {
getDimensions: (width, height) => ({ width, height, scale: 1 }), elements: diagramData.elements,
// testing typo in MIME type (jpg → jpeg) appState: {
mimeType: "image/jpg", ...diagramData.appState,
appState: { exportBackground: true,
exportBackground: true, },
files: diagramData.files,
},
config: {
getDimensions: (width, height) => ({ width, height, scale: 1 }),
// testing typo in MIME type (jpg → jpeg)
mimeType: "image/jpg",
}, },
}); });
expect(blob?.type).toBe(MIME_TYPES.jpg); expect(blob?.type).toBe(MIME_TYPES.jpg);
}); });
it("should default to image/png", async () => { it("should default to image/png", async () => {
const blob = await utils.exportToBlob({ const blob = await utils.exportToBlob({
...diagramFactory(), data: diagramFactory(),
}); });
expect(blob?.type).toBe(MIME_TYPES.png); expect(blob?.type).toBe(MIME_TYPES.png);
}); });
@@ -56,9 +78,11 @@ describe("exportToBlob", async () => {
.spyOn(console, "warn") .spyOn(console, "warn")
.mockImplementationOnce(() => void 0); .mockImplementationOnce(() => void 0);
await utils.exportToBlob({ await utils.exportToBlob({
...diagramFactory(), data: diagramFactory(),
mimeType: MIME_TYPES.png, config: {
quality: 1, mimeType: MIME_TYPES.png,
quality: 1,
},
}); });
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(
`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`, `"quality" will be ignored for "${MIME_TYPES.png}" mimeType`,
@@ -68,8 +92,9 @@ describe("exportToBlob", async () => {
}); });
describe("exportToSvg", () => { describe("exportToSvg", () => {
const passedElements = () => exportToSvgSpy.mock.calls[0][0]; const getPassedArg = () => exportToSvgSpy.mock.calls[0][0];
const passedOptions = () => exportToSvgSpy.mock.calls[0][1]; const passedData = () => getPassedArg().data;
const passedConfig = () => getPassedArg().config;
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -77,18 +102,14 @@ describe("exportToSvg", () => {
it("with default arguments", async () => { it("with default arguments", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
...diagramFactory({ data: diagramFactory({
overrides: { appState: void 0 }, overrides: { appState: void 0 },
}), }),
}); });
const passedOptionsWhenDefault = { const data = passedData();
...passedOptions(), expect(data.elements.length).toBe(3);
// To avoid varying snapshots expect(passedConfig()).toMatchSnapshot();
name: "name",
};
expect(passedElements().length).toBe(3);
expect(passedOptionsWhenDefault).toMatchSnapshot();
}); });
// FIXME the utils.exportToSvg no longer filters out deleted elements. // FIXME the utils.exportToSvg no longer filters out deleted elements.
@@ -96,37 +117,39 @@ describe("exportToSvg", () => {
// type-checking for it correctly. // type-checking for it correctly.
it.skip("with deleted elements", async () => { it.skip("with deleted elements", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
...diagramFactory({ data: diagramFactory({
overrides: { appState: void 0 }, overrides: { appState: void 0 },
elementOverrides: { isDeleted: true }, elementOverrides: { isDeleted: true },
}), }),
}); });
expect(passedElements().length).toBe(0); expect(passedData().elements.length).toBe(0);
}); });
it("with exportPadding", async () => { it("with padding", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
...diagramFactory({ overrides: { appState: { name: "diagram name" } } }), data: diagramFactory({ overrides: { appState: { name: "diagram name" } } }),
exportPadding: 0, config: {
padding: 0,
},
}); });
expect(passedElements().length).toBe(3); expect(passedData().elements.length).toBe(3);
expect(passedOptions()).toEqual( expect(passedConfig()).toEqual(
expect.objectContaining({ exportPadding: 0 }), expect.objectContaining({ padding: 0 }),
); );
}); });
it("with exportEmbedScene", async () => { it("with exportEmbedScene", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
...diagramFactory({ data: diagramFactory({
overrides: { overrides: {
appState: { name: "diagram name", exportEmbedScene: true }, appState: { name: "diagram name", exportEmbedScene: true },
}, },
}), }),
}); });
expect(passedElements().length).toBe(3); expect(passedData().elements.length).toBe(3);
expect(passedOptions().exportEmbedScene).toBe(true); expect(passedData().appState?.exportEmbedScene).toBe(true);
}); });
}); });
+19 -13
View File
@@ -19,13 +19,15 @@ describe("embedding scene data", () => {
const sourceElements = [rectangle, ellipse]; const sourceElements = [rectangle, ellipse];
const svgNode = await utils.exportToSvg({ const svgNode = await utils.exportToSvg({
elements: sourceElements, data: {
appState: { elements: sourceElements,
viewBackgroundColor: "#ffffff", appState: {
gridModeEnabled: false, viewBackgroundColor: "#ffffff",
exportEmbedScene: true, gridModeEnabled: false,
exportEmbedScene: true,
},
files: null,
}, },
files: null,
}); });
const svg = svgNode.outerHTML; const svg = svgNode.outerHTML;
@@ -49,14 +51,18 @@ describe("embedding scene data", () => {
const sourceElements = [rectangle, ellipse]; const sourceElements = [rectangle, ellipse];
const blob = await utils.exportToBlob({ const blob = await utils.exportToBlob({
mimeType: "image/png", data: {
elements: sourceElements, elements: sourceElements,
appState: { appState: {
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
gridModeEnabled: false, gridModeEnabled: false,
exportEmbedScene: true, exportEmbedScene: true,
},
files: null,
},
config: {
mimeType: "image/png",
}, },
files: null,
}); });
const parsedString = await decodePngMetadata(blob); const parsedString = await decodePngMetadata(blob);
+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.2.2": "@excalidraw/mermaid-to-excalidraw@2.1.1":
version "2.2.2" version "2.1.1"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz#ee6b597a0d95b9a76f7ae41ce0e3733a9b96e4a0" resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.1.tgz#659c934a607dd2cf57f2a69282588ee2b0722959"
integrity sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg== integrity sha512-jU+frqcxazsY+t5yOBf2mgrQy+WUrbrzA36if3SQB/Vwaf2qOJjnWxucNafgZZk/3+9xGmRotUeOviSOJG+wYA==
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"