snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
11
packages/utils/CHANGELOG.md
Normal file
11
packages/utils/CHANGELOG.md
Normal 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
99
packages/utils/README.md
Normal 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
3
packages/utils/global.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
import "@excalidraw/excalidraw/global";
|
||||
import "@excalidraw/excalidraw/css";
|
||||
74
packages/utils/package.json
Normal file
74
packages/utils/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
225
packages/utils/src/export.ts
Normal file
225
packages/utils/src/export.ts
Normal 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");
|
||||
}
|
||||
};
|
||||
3
packages/utils/src/index.ts
Normal file
3
packages/utils/src/index.ts
Normal 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
546
packages/utils/src/shape.ts
Normal 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),
|
||||
];
|
||||
};
|
||||
33
packages/utils/src/test-utils.ts
Normal file
33
packages/utils/src/test-utils.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
127
packages/utils/tests/__snapshots__/export.test.ts.snap
Normal file
127
packages/utils/tests/__snapshots__/export.test.ts.snap
Normal 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,
|
||||
},
|
||||
}
|
||||
`;
|
||||
102
packages/utils/tests/__snapshots__/utils.test.ts.snap
Normal file
102
packages/utils/tests/__snapshots__/utils.test.ts.snap
Normal 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,
|
||||
},
|
||||
}
|
||||
`;
|
||||
219
packages/utils/tests/export.test.ts
Normal file
219
packages/utils/tests/export.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
146
packages/utils/tests/geometry.test.ts
Normal file
146
packages/utils/tests/geometry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
70
packages/utils/tests/utils.unmocked.test.ts
Normal file
70
packages/utils/tests/utils.unmocked.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
9
packages/utils/tsconfig.json
Normal file
9
packages/utils/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user