snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
# Changelog
## [Unreleased]
First release of `@excalidraw/utils` to provide utilities functions.
- Added `exportToBlob` and `exportToSvg` to export an Excalidraw diagram definition, respectively, to a [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) and to a [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) ([#2246](https://github.com/excalidraw/excalidraw/pull/2246))
### Features
- Flip single elements horizontally or vertically [#2520](https://github.com/excalidraw/excalidraw/pull/2520)

99
packages/utils/README.md Normal file
View File

@@ -0,0 +1,99 @@
# @excalidraw/utils
## Install
```bash
npm install @excalidraw/utils
```
If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
```bash
yarn add @excalidraw/utils
```
## API
### `serializeAsJSON`
See [`serializeAsJSON`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#serializeAsJSON) for API and description.
### `exportToBlob` (async)
Export an Excalidraw diagram to a [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob).
### `exportToSvg`
Export an Excalidraw diagram to a [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement).
## Usage
Excalidraw utils is published as a UMD (Universal Module Definition). If you are using a module bundler (for instance, Webpack), you can import it as an ES6 module:
```js
import { exportToSvg, exportToBlob } from "@excalidraw/utils";
```
To use it in a browser directly:
```html
<script src="https://unpkg.com/@excalidraw/utils@0.1.0/dist/excalidraw-utils.min.js"></script>
<script>
// ExcalidrawUtils is a global variable defined by excalidraw.min.js
const { exportToSvg, exportToBlob } = ExcalidrawUtils;
</script>
```
Here's the `exportToBlob` and `exportToSvg` functions in action:
```js
const excalidrawDiagram = {
type: "excalidraw",
version: 2,
source: "https://excalidraw.com",
elements: [
{
id: "vWrqOAfkind2qcm7LDAGZ",
type: "ellipse",
x: 414,
y: 237,
width: 214,
height: 214,
angle: 0,
strokeColor: "#000000",
backgroundColor: "#15aabf",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
roundness: null,
seed: 1041657908,
version: 120,
versionNonce: 1188004276,
isDeleted: false,
boundElementIds: null,
},
],
appState: {
viewBackgroundColor: "#ffffff",
gridSize: null,
},
};
// Export the Excalidraw diagram as SVG string
const svg = exportToSvg(excalidrawDiagram);
console.log(svg.outerHTML);
// Export the Excalidraw diagram as PNG Blob URL
(async () => {
const blob = await exportToBlob({
...excalidrawDiagram,
mimeType: "image/png",
});
const urlCreator = window.URL || window.webkitURL;
console.log(urlCreator.createObjectURL(blob));
})();
```

3
packages/utils/global.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
import "@excalidraw/excalidraw/global";
import "@excalidraw/excalidraw/css";

View File

@@ -0,0 +1,74 @@
{
"name": "@excalidraw/utils",
"version": "0.1.2",
"type": "module",
"types": "./dist/types/utils/src/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"exports": {
".": {
"types": "./dist/types/utils/src/index.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/utils/src/*.d.ts"
}
},
"files": [
"dist/*"
],
"description": "Excalidraw utility functions",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"keywords": [
"excalidraw",
"excalidraw-utils"
],
"browserslist": {
"production": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all",
"not safari < 12",
"not kaios <= 2.5",
"not edge < 79",
"not chrome < 70",
"not and_uc < 13",
"not samsung < 10"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.3.1",
"browser-fs-access": "0.38.0",
"pako": "2.0.3",
"perfect-freehand": "1.2.0",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
"roughjs": "4.6.4"
},
"devDependencies": {
"cross-env": "7.0.3",
"fonteditor-core": "2.4.0",
"typescript": "5.9.3",
"wawoff2": "2.0.1",
"which": "4.0.0"
},
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildUtils.js && yarn gen:types"
}
}

View File

@@ -0,0 +1,225 @@
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 { getNonDeletedElements } from "@excalidraw/element";
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 {
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types";
export { MIME_TYPES };
type ExportOpts = {
/** restored before exporting */
elements: readonly NonDeletedExcalidrawElement[];
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
files: BinaryFiles | null;
maxWidthOrHeight?: number;
exportingFrame?: NonDeleted<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 = getNonDeletedElements(
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 (restoring keeps the ids)
restoreElements(opts.elements, null),
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 = getNonDeletedElements(
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(
getNonDeletedElements(restoreElements(opts.elements, null)),
opts.files,
);
} else {
throw new Error("Invalid export type");
}
};

View File

@@ -0,0 +1,3 @@
export * from "./export";
export { elementsOverlappingBBox } from "@excalidraw/element";
export { getCommonBounds } from "@excalidraw/element";

546
packages/utils/src/shape.ts Normal file
View File

@@ -0,0 +1,546 @@
/**
* this file defines pure geometric shapes
*
* for instance, a cubic bezier curve is specified by its four control points and
* an ellipse is defined by its center, angle, semi major axis and semi minor axis
* (but in semi-width and semi-height so it's more relevant to Excalidraw)
*
* the idea with pure shapes is so that we can provide collision and other geoemtric methods not depending on
* the specifics of roughjs or elements in Excalidraw; instead, we can focus on the pure shapes themselves
*
* also included in this file are methods for converting an Excalidraw element or a Drawable from roughjs
* to pure shapes
*/
import { pointsOnBezierCurves } from "points-on-curve";
import { invariant } from "@excalidraw/common";
import {
curve,
lineSegment,
pointFrom,
pointDistance,
pointFromArray,
pointFromVector,
pointRotateRads,
polygon,
polygonFromPoints,
PRECISION,
segmentsIntersectAt,
vector,
vectorAdd,
vectorFromPoint,
vectorScale,
type GlobalPoint,
type LocalPoint,
} from "@excalidraw/math";
import { getElementAbsoluteCoords } from "@excalidraw/element";
import type {
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawEmbeddableElement,
ExcalidrawFrameLikeElement,
ExcalidrawFreeDrawElement,
ExcalidrawIframeElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawRectangleElement,
ExcalidrawSelectionElement,
ExcalidrawStickyNoteElement,
ExcalidrawTextElement,
} from "@excalidraw/element/types";
import type { Curve, LineSegment, Polygon, Radians } from "@excalidraw/math";
import type { Drawable, Op } from "roughjs/bin/core";
// a polyline (made up term here) is a line consisting of other line segments
// this corresponds to a straight line element in the editor but it could also
// be used to model other elements
export type Polyline<Point extends GlobalPoint | LocalPoint> =
LineSegment<Point>[];
// a polycurve is a curve consisting of ther curves, this corresponds to a complex
// curve on the canvas
export type Polycurve<Point extends GlobalPoint | LocalPoint> = Curve<Point>[];
// an ellipse is specified by its center, angle, and its major and minor axes
// but for the sake of simplicity, we've used halfWidth and halfHeight instead
// in replace of semi major and semi minor axes
export type Ellipse<Point extends GlobalPoint | LocalPoint> = {
center: Point;
angle: Radians;
halfWidth: number;
halfHeight: number;
};
export type GeometricShape<Point extends GlobalPoint | LocalPoint> =
| {
type: "line";
data: LineSegment<Point>;
}
| {
type: "polygon";
data: Polygon<Point>;
}
| {
type: "curve";
data: Curve<Point>;
}
| {
type: "ellipse";
data: Ellipse<Point>;
}
| {
type: "polyline";
data: Polyline<Point>;
}
| {
type: "polycurve";
data: Polycurve<Point>;
};
type RectangularElement =
| ExcalidrawRectangleElement
| ExcalidrawStickyNoteElement
| ExcalidrawDiamondElement
| ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement
| ExcalidrawImageElement
| ExcalidrawIframeElement
| ExcalidrawTextElement
| ExcalidrawSelectionElement;
// polygon
export const getPolygonShape = <Point extends GlobalPoint | LocalPoint>(
element: RectangularElement,
): GeometricShape<Point> => {
const { angle, width, height, x, y } = element;
const cx = x + width / 2;
const cy = y + height / 2;
const center: Point = pointFrom(cx, cy);
let data: Polygon<Point>;
if (element.type === "diamond") {
data = polygon(
pointRotateRads(pointFrom(cx, y), center, angle),
pointRotateRads(pointFrom(x + width, cy), center, angle),
pointRotateRads(pointFrom(cx, y + height), center, angle),
pointRotateRads(pointFrom(x, cy), center, angle),
);
} else {
data = polygon(
pointRotateRads(pointFrom(x, y), center, angle),
pointRotateRads(pointFrom(x + width, y), center, angle),
pointRotateRads(pointFrom(x + width, y + height), center, angle),
pointRotateRads(pointFrom(x, y + height), center, angle),
);
}
return {
type: "polygon",
data,
};
};
// return the selection box for an element, possibly rotated as well
export const getSelectionBoxShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
padding = 10,
) => {
let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
true,
);
x1 -= padding;
x2 += padding;
y1 -= padding;
y2 += padding;
//const angleInDegrees = angleToDegrees(element.angle);
const center = pointFrom(cx, cy);
const topLeft = pointRotateRads(pointFrom(x1, y1), center, element.angle);
const topRight = pointRotateRads(pointFrom(x2, y1), center, element.angle);
const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, element.angle);
const bottomRight = pointRotateRads(pointFrom(x2, y2), center, element.angle);
return {
type: "polygon",
data: [topLeft, topRight, bottomRight, bottomLeft],
} as GeometricShape<Point>;
};
// ellipse
export const getEllipseShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawEllipseElement,
): GeometricShape<Point> => {
const { width, height, angle, x, y } = element;
return {
type: "ellipse",
data: {
center: pointFrom(x + width / 2, y + height / 2),
angle,
halfWidth: width / 2,
halfHeight: height / 2,
},
};
};
export const getCurvePathOps = (shape: Drawable): Op[] => {
// NOTE (mtolmacs): Temporary fix for extremely large elements
if (!shape) {
return [];
}
for (const set of shape.sets) {
if (set.type === "path") {
return set.ops;
}
}
return shape.sets[0].ops;
};
// linear
export const getCurveShape = <Point extends GlobalPoint | LocalPoint>(
roughShape: Drawable,
startingPoint: Point = pointFrom(0, 0),
angleInRadian: Radians,
center: Point,
): GeometricShape<Point> => {
const transform = (p: Point): Point =>
pointRotateRads(
pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]),
center,
angleInRadian,
);
const ops = getCurvePathOps(roughShape);
const polycurve: Polycurve<Point> = [];
let p0 = pointFrom<Point>(0, 0);
for (const op of ops) {
if (op.op === "move") {
const p = pointFromArray<Point>(op.data);
invariant(p != null, "Ops data is not a point");
p0 = transform(p);
}
if (op.op === "bcurveTo") {
const p1 = transform(pointFrom<Point>(op.data[0], op.data[1]));
const p2 = transform(pointFrom<Point>(op.data[2], op.data[3]));
const p3 = transform(pointFrom<Point>(op.data[4], op.data[5]));
polycurve.push(curve<Point>(p0, p1, p2, p3));
p0 = p3;
}
}
return {
type: "polycurve",
data: polycurve,
};
};
const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>(
points: Point[],
): Polyline<Point> => {
let previousPoint: Point = points[0];
const polyline: LineSegment<Point>[] = [];
for (let i = 1; i < points.length; i++) {
const nextPoint = points[i];
polyline.push(lineSegment<Point>(previousPoint, nextPoint));
previousPoint = nextPoint;
}
return polyline;
};
export const getFreedrawShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawFreeDrawElement,
center: Point,
isClosed: boolean = false,
): GeometricShape<Point> => {
const transform = (p: Point) =>
pointRotateRads(
pointFromVector(
vectorAdd(vectorFromPoint(p), vector(element.x, element.y)),
),
center,
element.angle,
);
const polyline = polylineFromPoints(
element.points.map((p) => transform(p as Point)),
);
return (
isClosed
? {
type: "polygon",
data: polygonFromPoints(polyline.flat()),
}
: {
type: "polyline",
data: polyline,
}
) as GeometricShape<Point>;
};
export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawLinearElement,
roughShape: Drawable,
startingPoint: Point = pointFrom<Point>(0, 0),
angleInRadian: Radians,
center: Point,
): GeometricShape<Point> => {
const transform = (p: Point) =>
pointRotateRads(
pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]),
center,
angleInRadian,
);
if (element.roundness === null) {
return {
type: "polygon",
data: polygonFromPoints(
element.points.map((p) => transform(p as Point)) as Point[],
),
};
}
const ops = getCurvePathOps(roughShape);
const points: Point[] = [];
let odd = false;
for (const operation of ops) {
if (operation.op === "move") {
odd = !odd;
if (odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
}
} else if (operation.op === "bcurveTo") {
if (odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
points.push(pointFrom(operation.data[2], operation.data[3]));
points.push(pointFrom(operation.data[4], operation.data[5]));
}
} else if (operation.op === "lineTo") {
if (odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
}
}
}
const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) =>
transform(p as Point),
) as Point[];
return {
type: "polygon",
data: polygonFromPoints<Point>(polygonPoints),
};
};
/**
* Determine intersection of a rectangular shaped element and a
* line segment.
*
* @param element The rectangular element to test against
* @param segment The segment intersecting the element
* @param gap Optional value to inflate the shape before testing
* @returns An array of intersections
*/
// TODO: Replace with final rounded rectangle code
export const segmentIntersectRectangleElement = <
Point extends LocalPoint | GlobalPoint,
>(
element: ExcalidrawBindableElement,
segment: LineSegment<Point>,
gap: number = 0,
): Point[] => {
const bounds = [
element.x - gap,
element.y - gap,
element.x + element.width + gap,
element.y + element.height + gap,
];
const center = pointFrom(
(bounds[0] + bounds[2]) / 2,
(bounds[1] + bounds[3]) / 2,
);
return [
lineSegment(
pointRotateRads(pointFrom(bounds[0], bounds[1]), center, element.angle),
pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle),
),
lineSegment(
pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle),
pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle),
),
lineSegment(
pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle),
pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle),
),
lineSegment(
pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle),
pointRotateRads(pointFrom(bounds[0], bounds[1]), center, element.angle),
),
]
.map((s) => segmentsIntersectAt(segment, s))
.filter((i): i is Point => !!i);
};
const distanceToEllipse = <Point extends LocalPoint | GlobalPoint>(
p: Point,
ellipse: Ellipse<Point>,
) => {
const { angle, halfWidth, halfHeight, center } = ellipse;
const a = halfWidth;
const b = halfHeight;
const translatedPoint = vectorAdd(
vectorFromPoint(p),
vectorScale(vectorFromPoint(center), -1),
);
const [rotatedPointX, rotatedPointY] = pointRotateRads(
pointFromVector(translatedPoint),
pointFrom(0, 0),
-angle as Radians,
);
const px = Math.abs(rotatedPointX);
const py = Math.abs(rotatedPointY);
let tx = 0.707;
let ty = 0.707;
for (let i = 0; i < 3; i++) {
const x = a * tx;
const y = b * ty;
const ex = ((a * a - b * b) * tx ** 3) / a;
const ey = ((b * b - a * a) * ty ** 3) / b;
const rx = x - ex;
const ry = y - ey;
const qx = px - ex;
const qy = py - ey;
const r = Math.hypot(ry, rx);
const q = Math.hypot(qy, qx);
tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
const t = Math.hypot(ty, tx);
tx /= t;
ty /= t;
}
const [minX, minY] = [
a * tx * Math.sign(rotatedPointX),
b * ty * Math.sign(rotatedPointY),
];
return pointDistance(
pointFrom(rotatedPointX, rotatedPointY),
pointFrom(minX, minY),
);
};
export const pointOnEllipse = <Point extends LocalPoint | GlobalPoint>(
point: Point,
ellipse: Ellipse<Point>,
threshold = PRECISION,
) => {
return distanceToEllipse(point, ellipse) <= threshold;
};
export const pointInEllipse = <Point extends LocalPoint | GlobalPoint>(
p: Point,
ellipse: Ellipse<Point>,
) => {
const { center, angle, halfWidth, halfHeight } = ellipse;
const translatedPoint = vectorAdd(
vectorFromPoint(p),
vectorScale(vectorFromPoint(center), -1),
);
const [rotatedPointX, rotatedPointY] = pointRotateRads(
pointFromVector(translatedPoint),
pointFrom(0, 0),
-angle as Radians,
);
return (
(rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) +
(rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <=
1
);
};
export const ellipseAxes = <Point extends LocalPoint | GlobalPoint>(
ellipse: Ellipse<Point>,
) => {
const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight;
const majorAxis = widthGreaterThanHeight
? ellipse.halfWidth * 2
: ellipse.halfHeight * 2;
const minorAxis = widthGreaterThanHeight
? ellipse.halfHeight * 2
: ellipse.halfWidth * 2;
return {
majorAxis,
minorAxis,
};
};
export const ellipseFocusToCenter = <Point extends LocalPoint | GlobalPoint>(
ellipse: Ellipse<Point>,
) => {
const { majorAxis, minorAxis } = ellipseAxes(ellipse);
return Math.sqrt(majorAxis ** 2 - minorAxis ** 2);
};
export const ellipseExtremes = <Point extends LocalPoint | GlobalPoint>(
ellipse: Ellipse<Point>,
) => {
const { center, angle } = ellipse;
const { majorAxis, minorAxis } = ellipseAxes(ellipse);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const sqSum = majorAxis ** 2 + minorAxis ** 2;
const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle);
const yMax = Math.sqrt((sqSum - sqDiff) / 2);
const xAtYMax =
(yMax * sqSum * sin * cos) /
(majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2);
const xMax = Math.sqrt((sqSum + sqDiff) / 2);
const yAtXMax =
(xMax * sqSum * sin * cos) /
(majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2);
const centerVector = vectorFromPoint(center);
return [
vectorAdd(vector(xAtYMax, yMax), centerVector),
vectorAdd(vectorScale(vector(xAtYMax, yMax), -1), centerVector),
vectorAdd(vector(xMax, yAtXMax), centerVector),
vectorAdd(vector(xMax, yAtXMax), centerVector),
];
};

View File

@@ -0,0 +1,33 @@
import { diffStringsUnified } from "jest-diff";
expect.extend({
toCloselyEqualPoints(received, expected, precision) {
if (!Array.isArray(received) || !Array.isArray(expected)) {
throw new Error("expected and received are not point arrays");
}
const COMPARE = 1 / precision === 0 ? 1 : Math.pow(10, precision ?? 2);
const pass = expected.every(
(point, idx) =>
Math.abs(received[idx][0] - point[0]) < COMPARE &&
Math.abs(received[idx][1] - point[1]) < COMPARE,
);
if (!pass) {
return {
message: () => ` The provided array of points are not close enough.
${diffStringsUnified(
JSON.stringify(expected, undefined, 2),
JSON.stringify(received, undefined, 2),
)}`,
pass: false,
};
}
return {
message: () => `expected ${received} to not be close to ${expected}`,
pass: true,
};
},
});

View File

@@ -0,0 +1,127 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`exportToSvg > with default arguments 1`] = `
{
"activeEmbeddable": null,
"activeLockedId": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"colorTopPicks": {
"bucketFill": null,
"elementBackground": null,
"elementStroke": null,
"stickyNoteBackground": null,
"stickyNoteStroke": null,
},
"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,
"currentItemStickynoteBackgroundColor": "#ffdf6b",
"currentItemStickynoteStrokeColor": "#1e1e1e",
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
"exportEmbedScene": false,
"exportPadding": undefined,
"exportScale": 1,
"exportWithDarkMode": false,
"fileHandle": null,
"frameRendering": {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"hoveredArrowTextAnchor": null,
"hoveredElementIds": {},
"inputDevice": "auto",
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
"isMidpointSnappingEnabled": true,
"isResizing": false,
"isRotating": false,
"lastPointerDownWith": "mouse",
"lockedMultiSelections": {},
"multiElement": null,
"name": "name",
"newElement": null,
"objectsSnapModeEnabled": false,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"penDetected": false,
"penMode": false,
"preferredSelectionTool": {
"initialized": false,
"type": "selection",
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": null,
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
"selectedLinearElement": null,
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showWelcomeScreen": false,
"snapLines": [],
"stats": {
"open": false,
"panels": 3,
},
"suggestedBinding": null,
"theme": "light",
"toast": null,
"viewBackgroundColor": "#ffffff",
"viewModeEnabled": false,
"zenModeEnabled": false,
"zoom": {
"value": 1,
},
}
`;

View File

@@ -0,0 +1,102 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`exportToSvg > with default arguments 1`] = `
{
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"lastActiveTool": null,
"locked": false,
"type": "selection",
},
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
"currentItemFontFamily": 1,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "round",
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
"exportEmbedScene": false,
"exportPadding": undefined,
"exportScale": 1,
"exportWithDarkMode": false,
"fileHandle": null,
"followedBy": Set {},
"frameRendering": {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"isBindingEnabled": true,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "name",
"objectsSnapModeEnabled": false,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": {
"data": null,
"shown": false,
},
"penDetected": false,
"penMode": false,
"pendingImageElementId": null,
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
"selectedLinearElement": null,
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
"toast": null,
"userToFollow": null,
"viewBackgroundColor": "#ffffff",
"viewModeEnabled": false,
"zenModeEnabled": false,
"zoom": {
"value": 1,
},
}
`;

View File

@@ -0,0 +1,219 @@
import { MIME_TYPES } from "@excalidraw/common";
import * as clipboardModule from "@excalidraw/excalidraw/clipboard";
import * as imageModule from "@excalidraw/excalidraw/data/image";
import * as jsonModule from "@excalidraw/excalidraw/data/json";
import * as mockedSceneExportUtils from "@excalidraw/excalidraw/scene/export";
import { diagramFactory } from "@excalidraw/excalidraw/tests/fixtures/diagramFixture";
import { vi } from "vitest";
import * as utils from "../src";
const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
describe("exportToCanvas", async () => {
const EXPORT_PADDING = 10;
it("with default arguments", async () => {
const canvas = await utils.exportToCanvas({
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
});
expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING);
expect(canvas.height).toBe(100 + 2 * EXPORT_PADDING);
});
it("when custom width and height", async () => {
const canvas = await utils.exportToCanvas({
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
});
expect(canvas.width).toBe(200);
expect(canvas.height).toBe(200);
});
});
describe("exportToBlob", async () => {
describe("mime type", () => {
it("should change image/jpg to image/jpeg", async () => {
const blob = await utils.exportToBlob({
...diagramFactory(),
getDimensions: (width, height) => ({ width, height, scale: 1 }),
// testing typo in MIME type (jpg → jpeg)
mimeType: "image/jpg",
appState: {
exportBackground: true,
},
});
expect(blob?.type).toBe(MIME_TYPES.jpg);
});
it("should default to image/png", async () => {
const blob = await utils.exportToBlob({
...diagramFactory(),
});
expect(blob?.type).toBe(MIME_TYPES.png);
});
it("should warn when using quality with image/png", async () => {
const consoleSpy = vi
.spyOn(console, "warn")
.mockImplementationOnce(() => void 0);
await utils.exportToBlob({
...diagramFactory(),
mimeType: MIME_TYPES.png,
quality: 1,
});
expect(consoleSpy).toHaveBeenCalledWith(
`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`,
);
});
});
});
describe("exportToSvg", () => {
const passedElements = () => exportToSvgSpy.mock.calls[0][0];
const passedOptions = () => exportToSvgSpy.mock.calls[0][1];
afterEach(() => {
vi.clearAllMocks();
});
it("with default arguments", async () => {
await utils.exportToSvg({
...diagramFactory({
overrides: { appState: void 0 },
}),
});
const passedOptionsWhenDefault = {
...passedOptions(),
// To avoid varying snapshots
name: "name",
};
expect(passedElements().length).toBe(3);
expect(passedOptionsWhenDefault).toMatchSnapshot();
});
// Regression test: when all elements are deleted, exportToSvg should pass
// zero elements to the lower-level export (line 184-188 of export.ts calls
// getNonDeletedElements which filters them out).
it("with deleted elements", async () => {
await utils.exportToSvg({
...diagramFactory({
overrides: { appState: void 0 },
elementOverrides: { isDeleted: true },
}),
});
expect(passedElements().length).toBe(0);
});
it("with exportPadding", async () => {
await utils.exportToSvg({
...diagramFactory({
overrides: { appState: { name: "diagram name" } },
}),
exportPadding: 0,
});
expect(passedElements().length).toBe(3);
expect(passedOptions()).toEqual(
expect.objectContaining({ exportPadding: 0 }),
);
});
it("with exportEmbedScene", async () => {
await utils.exportToSvg({
...diagramFactory({
overrides: {
appState: { name: "diagram name", exportEmbedScene: true },
},
}),
});
expect(passedElements().length).toBe(3);
expect(passedOptions().exportEmbedScene).toBe(true);
});
});
// Elements persisted before `created` existed omit it (modeled with the cast
// below, as the declared input type is a complete element). Every public
// export entry point restores its input, so such elements must reach the
// lower-level code restored, with `created: null`.
describe("elements lacking creation metadata", () => {
const legacyDiagram = () => {
const { elements, ...rest } = diagramFactory({
overrides: { appState: void 0 },
});
return {
...rest,
elements: elements.map(
({ created, ...element }) => element,
) as unknown as typeof elements,
};
};
const createdOf = (elements: readonly { created: number | null }[]) =>
elements.map((element) => element.created);
afterEach(() => {
vi.restoreAllMocks();
});
it("exportToSvg and exportToCanvas restore the elements", async () => {
const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
const diagram = legacyDiagram();
await utils.exportToSvg(diagram);
expect(createdOf(exportToSvgSpy.mock.calls[0][0])).toEqual([
null,
null,
null,
]);
expect(diagram.elements[0]).not.toHaveProperty("created");
const exportToCanvasSpy = vi.spyOn(
mockedSceneExportUtils,
"exportToCanvas",
);
const canvas = await utils.exportToCanvas(diagram);
expect(createdOf(exportToCanvasSpy.mock.calls[0][0])).toEqual([
null,
null,
null,
]);
expect(canvas.width).toBeGreaterThan(0);
});
it("exportToBlob embeds the restored scene into png metadata", async () => {
const serializeSpy = vi.spyOn(jsonModule, "serializeAsJSON");
vi.spyOn(imageModule, "encodePngMetadata").mockImplementation(
async ({ blob }) => blob,
);
await utils.exportToBlob({
...legacyDiagram(),
mimeType: MIME_TYPES.png,
appState: { exportEmbedScene: true },
});
expect(serializeSpy).toHaveBeenCalledTimes(1);
expect(createdOf(serializeSpy.mock.calls[0][0])).toEqual([
null,
null,
null,
]);
});
it("exportToClipboard copies the restored elements as json", async () => {
const copySpy = vi
.spyOn(clipboardModule, "copyToClipboard")
.mockResolvedValue(undefined);
await utils.exportToClipboard({ ...legacyDiagram(), type: "json" });
expect(copySpy).toHaveBeenCalledTimes(1);
expect(createdOf(copySpy.mock.calls[0][0])).toEqual([null, null, null]);
});
});

View File

@@ -0,0 +1,146 @@
import {
pointFrom,
lineSegment,
polygon,
pointOnLineSegment,
polygonIncludesPoint,
segmentsIntersectAt,
} from "@excalidraw/math";
import type {
GlobalPoint,
LineSegment,
Polygon,
Radians,
} from "@excalidraw/math";
import { pointInEllipse, pointOnEllipse, type Ellipse } from "../src/shape";
describe("point and line", () => {
// const l: Line<GlobalPoint> = line(point(1, 0), point(1, 2));
// it("point on left or right of line", () => {
// expect(pointLeftofLine(point(0, 1), l)).toBe(true);
// expect(pointLeftofLine(point(1, 1), l)).toBe(false);
// expect(pointLeftofLine(point(2, 1), l)).toBe(false);
// expect(pointRightofLine(point(0, 1), l)).toBe(false);
// expect(pointRightofLine(point(1, 1), l)).toBe(false);
// expect(pointRightofLine(point(2, 1), l)).toBe(true);
// });
const s: LineSegment<GlobalPoint> = lineSegment(
pointFrom(1, 0),
pointFrom(1, 2),
);
it("point on the line", () => {
expect(pointOnLineSegment(pointFrom(0, 1), s)).toBe(false);
expect(pointOnLineSegment(pointFrom(1, 1), s, 0)).toBe(true);
expect(pointOnLineSegment(pointFrom(2, 1), s)).toBe(false);
});
});
describe("point and polygon", () => {
it("point in polygon", () => {
const poly: Polygon<GlobalPoint> = polygon(
pointFrom(0, 0),
pointFrom(2, 0),
pointFrom(2, 2),
pointFrom(0, 2),
);
expect(polygonIncludesPoint(pointFrom(1, 1), poly)).toBe(true);
expect(polygonIncludesPoint(pointFrom(3, 3), poly)).toBe(false);
});
});
describe("point and ellipse", () => {
const ellipse: Ellipse<GlobalPoint> = {
center: pointFrom(0, 0),
angle: 0 as Radians,
halfWidth: 2,
halfHeight: 1,
};
it("point on ellipse", () => {
[
pointFrom(0, 1),
pointFrom(0, -1),
pointFrom(2, 0),
pointFrom(-2, 0),
].forEach((p) => {
expect(pointOnEllipse(p, ellipse)).toBe(true);
});
expect(pointOnEllipse(pointFrom(-1.4, 0.7), ellipse, 0.1)).toBe(true);
expect(pointOnEllipse(pointFrom(-1.4, 0.71), ellipse, 0.01)).toBe(true);
expect(pointOnEllipse(pointFrom(1.4, 0.7), ellipse, 0.1)).toBe(true);
expect(pointOnEllipse(pointFrom(1.4, 0.71), ellipse, 0.01)).toBe(true);
expect(pointOnEllipse(pointFrom(1, -0.86), ellipse, 0.1)).toBe(true);
expect(pointOnEllipse(pointFrom(1, -0.86), ellipse, 0.01)).toBe(true);
expect(pointOnEllipse(pointFrom(-1, -0.86), ellipse, 0.1)).toBe(true);
expect(pointOnEllipse(pointFrom(-1, -0.86), ellipse, 0.01)).toBe(true);
expect(pointOnEllipse(pointFrom(-1, 0.8), ellipse)).toBe(false);
expect(pointOnEllipse(pointFrom(1, -0.8), ellipse)).toBe(false);
});
it("point in ellipse", () => {
[
pointFrom(0, 1),
pointFrom(0, -1),
pointFrom(2, 0),
pointFrom(-2, 0),
].forEach((p) => {
expect(pointInEllipse(p, ellipse)).toBe(true);
});
expect(pointInEllipse(pointFrom(-1, 0.8), ellipse)).toBe(true);
expect(pointInEllipse(pointFrom(1, -0.8), ellipse)).toBe(true);
expect(pointInEllipse(pointFrom(-1, 1), ellipse)).toBe(false);
expect(pointInEllipse(pointFrom(-1.4, 0.8), ellipse)).toBe(false);
});
});
describe("line and line", () => {
const lineA: LineSegment<GlobalPoint> = lineSegment(
pointFrom(1, 4),
pointFrom(3, 4),
);
const lineB: LineSegment<GlobalPoint> = lineSegment(
pointFrom(2, 1),
pointFrom(2, 7),
);
const lineC: LineSegment<GlobalPoint> = lineSegment(
pointFrom(1, 8),
pointFrom(3, 8),
);
const lineD: LineSegment<GlobalPoint> = lineSegment(
pointFrom(1, 8),
pointFrom(3, 8),
);
const lineE: LineSegment<GlobalPoint> = lineSegment(
pointFrom(1, 9),
pointFrom(3, 9),
);
const lineF: LineSegment<GlobalPoint> = lineSegment(
pointFrom(1, 2),
pointFrom(3, 4),
);
const lineG: LineSegment<GlobalPoint> = lineSegment(
pointFrom(0, 1),
pointFrom(2, 3),
);
it("intersection", () => {
expect(segmentsIntersectAt(lineA, lineB)).toEqual([2, 4]);
expect(segmentsIntersectAt(lineA, lineC)).toBe(null);
expect(segmentsIntersectAt(lineB, lineC)).toBe(null);
expect(segmentsIntersectAt(lineC, lineD)).toBe(null); // Line overlapping line is not intersection!
expect(segmentsIntersectAt(lineE, lineD)).toBe(null);
expect(segmentsIntersectAt(lineF, lineG)).toBe(null);
});
});

View File

@@ -0,0 +1,70 @@
import { decodePngMetadata } from "@excalidraw/excalidraw/data/image";
import { decodeSvgBase64Payload } from "@excalidraw/excalidraw/scene/export";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import * as utils from "../src";
// NOTE this test file is using the actual API, unmocked. Hence splitting it
// from the other test file, because I couldn't figure out how to test
// mocked and unmocked API in the same file.
describe("embedding scene data", () => {
describe("exportToSvg", () => {
it("embedding scene data shouldn't modify them", async () => {
const rectangle = API.createElement({ type: "rectangle" });
const ellipse = API.createElement({ type: "ellipse" });
const sourceElements = [rectangle, ellipse];
const svgNode = await utils.exportToSvg({
elements: sourceElements,
appState: {
viewBackgroundColor: "#ffffff",
gridModeEnabled: false,
exportEmbedScene: true,
},
files: null,
});
const svg = svgNode.outerHTML;
const parsedString = decodeSvgBase64Payload({ svg });
const importedData: ImportedDataState = JSON.parse(parsedString);
expect(sourceElements.map((x) => x.id)).toEqual(
importedData.elements?.map((el) => el.id),
);
});
});
// skipped because we can't test png encoding right now
// (canvas.toBlob not supported in jsdom)
describe.skip("exportToBlob", () => {
it("embedding scene data shouldn't modify them", async () => {
const rectangle = API.createElement({ type: "rectangle" });
const ellipse = API.createElement({ type: "ellipse" });
const sourceElements = [rectangle, ellipse];
const blob = await utils.exportToBlob({
mimeType: "image/png",
elements: sourceElements,
appState: {
viewBackgroundColor: "#ffffff",
gridModeEnabled: false,
exportEmbedScene: true,
},
files: null,
});
const parsedString = await decodePngMetadata(blob);
const importedData: ImportedDataState = JSON.parse(parsedString);
expect(sourceElements.map((x) => x.id)).toEqual(
importedData.elements?.map((el) => el.id),
);
});
});
});

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
}