aboutsummaryrefslogtreecommitdiffstats
path: root/packages/utils
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/utils
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/utils')
-rw-r--r--packages/utils/CHANGELOG.md11
-rw-r--r--packages/utils/README.md99
-rw-r--r--packages/utils/__snapshots__/export.test.ts.snap113
-rw-r--r--packages/utils/__snapshots__/utils.test.ts.snap102
-rw-r--r--packages/utils/bbox.ts72
-rw-r--r--packages/utils/collision.test.ts87
-rw-r--r--packages/utils/collision.ts136
-rw-r--r--packages/utils/export.test.ts132
-rw-r--r--packages/utils/export.ts213
-rw-r--r--packages/utils/geometry/geometry.test.ts161
-rw-r--r--packages/utils/geometry/shape.ts541
-rw-r--r--packages/utils/global.d.ts3
-rw-r--r--packages/utils/index.ts4
-rw-r--r--packages/utils/package.json75
-rw-r--r--packages/utils/test-utils.ts33
-rw-r--r--packages/utils/tsconfig.json24
-rw-r--r--packages/utils/utils.unmocked.test.ts68
-rw-r--r--packages/utils/withinBounds.test.ts262
-rw-r--r--packages/utils/withinBounds.ts228
19 files changed, 2364 insertions, 0 deletions
diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md
new file mode 100644
index 0000000..875d8e8
--- /dev/null
+++ b/packages/utils/CHANGELOG.md
@@ -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)
diff --git a/packages/utils/README.md b/packages/utils/README.md
new file mode 100644
index 0000000..a6e4eab
--- /dev/null
+++ b/packages/utils/README.md
@@ -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));
+})();
+```
diff --git a/packages/utils/__snapshots__/export.test.ts.snap b/packages/utils/__snapshots__/export.test.ts.snap
new file mode 100644
index 0000000..54d4af4
--- /dev/null
+++ b/packages/utils/__snapshots__/export.test.ts.snap
@@ -0,0 +1,113 @@
+// 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,
+ "croppingElementId": null,
+ "currentChartType": "bar",
+ "currentHoveredFontFamily": null,
+ "currentItemArrowType": "round",
+ "currentItemBackgroundColor": "transparent",
+ "currentItemEndArrowhead": "arrow",
+ "currentItemFillStyle": "solid",
+ "currentItemFontFamily": 5,
+ "currentItemFontSize": 20,
+ "currentItemOpacity": 100,
+ "currentItemRoughness": 1,
+ "currentItemRoundness": "round",
+ "currentItemStartArrowhead": null,
+ "currentItemStrokeColor": "#1e1e1e",
+ "currentItemStrokeStyle": "solid",
+ "currentItemStrokeWidth": 2,
+ "currentItemTextAlign": "left",
+ "cursorButton": "up",
+ "defaultSidebarDockedPreference": false,
+ "editingFrame": null,
+ "editingGroupId": null,
+ "editingLinearElement": null,
+ "editingTextElement": 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,
+ "gridModeEnabled": false,
+ "gridSize": 20,
+ "gridStep": 5,
+ "hoveredElementIds": {},
+ "isBindingEnabled": true,
+ "isCropping": false,
+ "isLoading": false,
+ "isResizing": false,
+ "isRotating": false,
+ "lastPointerDownWith": "mouse",
+ "multiElement": null,
+ "name": "name",
+ "newElement": null,
+ "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,
+ "searchMatches": [],
+ "selectedElementIds": {},
+ "selectedElementsAreBeingDragged": false,
+ "selectedGroupIds": {},
+ "selectedLinearElement": null,
+ "selectionElement": null,
+ "shouldCacheIgnoreZoom": false,
+ "showHyperlinkPopup": false,
+ "showWelcomeScreen": false,
+ "snapLines": [],
+ "startBoundElement": null,
+ "stats": {
+ "open": false,
+ "panels": 3,
+ },
+ "suggestedBindings": [],
+ "theme": "light",
+ "toast": null,
+ "userToFollow": null,
+ "viewBackgroundColor": "#ffffff",
+ "viewModeEnabled": false,
+ "zenModeEnabled": false,
+ "zoom": {
+ "value": 1,
+ },
+}
+`;
diff --git a/packages/utils/__snapshots__/utils.test.ts.snap b/packages/utils/__snapshots__/utils.test.ts.snap
new file mode 100644
index 0000000..fdcb712
--- /dev/null
+++ b/packages/utils/__snapshots__/utils.test.ts.snap
@@ -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",
+ "currentItemStrokeWidth": 2,
+ "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,
+ },
+}
+`;
diff --git a/packages/utils/bbox.ts b/packages/utils/bbox.ts
new file mode 100644
index 0000000..19a1a54
--- /dev/null
+++ b/packages/utils/bbox.ts
@@ -0,0 +1,72 @@
+import {
+ vectorCross,
+ vectorFromPoint,
+ type GlobalPoint,
+ type LocalPoint,
+} from "@excalidraw/math";
+import type { Bounds } from "@excalidraw/excalidraw/element/bounds";
+
+export type LineSegment<P extends LocalPoint | GlobalPoint> = [P, P];
+
+export function getBBox<P extends LocalPoint | GlobalPoint>(
+ line: LineSegment<P>,
+): Bounds {
+ return [
+ Math.min(line[0][0], line[1][0]),
+ Math.min(line[0][1], line[1][1]),
+ Math.max(line[0][0], line[1][0]),
+ Math.max(line[0][1], line[1][1]),
+ ];
+}
+
+export function doBBoxesIntersect(a: Bounds, b: Bounds) {
+ return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1];
+}
+
+const EPSILON = 0.000001;
+
+export function isPointOnLine<P extends GlobalPoint | LocalPoint>(
+ l: LineSegment<P>,
+ p: P,
+) {
+ const p1 = vectorFromPoint(l[1], l[0]);
+ const p2 = vectorFromPoint(p, l[0]);
+
+ const r = vectorCross(p1, p2);
+
+ return Math.abs(r) < EPSILON;
+}
+
+export function isPointRightOfLine<P extends GlobalPoint | LocalPoint>(
+ l: LineSegment<P>,
+ p: P,
+) {
+ const p1 = vectorFromPoint(l[1], l[0]);
+ const p2 = vectorFromPoint(p, l[0]);
+
+ return vectorCross(p1, p2) < 0;
+}
+
+export function isLineSegmentTouchingOrCrossingLine<
+ P extends GlobalPoint | LocalPoint,
+>(a: LineSegment<P>, b: LineSegment<P>) {
+ return (
+ isPointOnLine(a, b[0]) ||
+ isPointOnLine(a, b[1]) ||
+ (isPointRightOfLine(a, b[0])
+ ? !isPointRightOfLine(a, b[1])
+ : isPointRightOfLine(a, b[1]))
+ );
+}
+
+// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
+export function doLineSegmentsIntersect<P extends GlobalPoint | LocalPoint>(
+ a: LineSegment<P>,
+ b: LineSegment<P>,
+) {
+ return (
+ doBBoxesIntersect(getBBox(a), getBBox(b)) &&
+ isLineSegmentTouchingOrCrossingLine(a, b) &&
+ isLineSegmentTouchingOrCrossingLine(b, a)
+ );
+}
diff --git a/packages/utils/collision.test.ts b/packages/utils/collision.test.ts
new file mode 100644
index 0000000..744bea3
--- /dev/null
+++ b/packages/utils/collision.test.ts
@@ -0,0 +1,87 @@
+import type { Curve, Degrees, GlobalPoint } from "@excalidraw/math";
+import {
+ curve,
+ degreesToRadians,
+ lineSegment,
+ lineSegmentRotate,
+ pointFrom,
+ pointRotateDegs,
+} from "@excalidraw/math";
+import { pointOnCurve, pointOnPolyline } from "./collision";
+import type { Polyline } from "./geometry/shape";
+
+describe("point and curve", () => {
+ const c: Curve<GlobalPoint> = curve(
+ pointFrom(1.4, 1.65),
+ pointFrom(1.9, 7.9),
+ pointFrom(5.9, 1.65),
+ pointFrom(6.44, 4.84),
+ );
+
+ it("point on curve", () => {
+ expect(pointOnCurve(c[0], c, 10e-5)).toBe(true);
+ expect(pointOnCurve(c[3], c, 10e-5)).toBe(true);
+
+ expect(pointOnCurve(pointFrom(2, 4), c, 0.1)).toBe(true);
+ expect(pointOnCurve(pointFrom(4, 4.4), c, 0.1)).toBe(true);
+ expect(pointOnCurve(pointFrom(5.6, 3.85), c, 0.1)).toBe(true);
+
+ expect(pointOnCurve(pointFrom(5.6, 4), c, 0.1)).toBe(false);
+ expect(pointOnCurve(c[1], c, 0.1)).toBe(false);
+ expect(pointOnCurve(c[2], c, 0.1)).toBe(false);
+ });
+});
+
+describe("point and polylines", () => {
+ const polyline: Polyline<GlobalPoint> = [
+ lineSegment(pointFrom(1, 0), pointFrom(1, 2)),
+ lineSegment(pointFrom(1, 2), pointFrom(2, 2)),
+ lineSegment(pointFrom(2, 2), pointFrom(2, 1)),
+ lineSegment(pointFrom(2, 1), pointFrom(3, 1)),
+ ];
+
+ it("point on the line", () => {
+ expect(pointOnPolyline(pointFrom(1, 0), polyline)).toBe(true);
+ expect(pointOnPolyline(pointFrom(1, 2), polyline)).toBe(true);
+ expect(pointOnPolyline(pointFrom(2, 2), polyline)).toBe(true);
+ expect(pointOnPolyline(pointFrom(2, 1), polyline)).toBe(true);
+ expect(pointOnPolyline(pointFrom(3, 1), polyline)).toBe(true);
+
+ expect(pointOnPolyline(pointFrom(1, 1), polyline)).toBe(true);
+ expect(pointOnPolyline(pointFrom(2, 1.5), polyline)).toBe(true);
+ expect(pointOnPolyline(pointFrom(2.5, 1), polyline)).toBe(true);
+
+ expect(pointOnPolyline(pointFrom(0, 1), polyline)).toBe(false);
+ expect(pointOnPolyline(pointFrom(2.1, 1.5), polyline)).toBe(false);
+ });
+
+ it("point on the line with rotation", () => {
+ const truePoints = [
+ pointFrom(1, 0),
+ pointFrom(1, 2),
+ pointFrom(2, 2),
+ pointFrom(2, 1),
+ pointFrom(3, 1),
+ ];
+
+ truePoints.forEach((p) => {
+ const rotation = (Math.random() * 360) as Degrees;
+ const rotatedPoint = pointRotateDegs(p, pointFrom(0, 0), rotation);
+ const rotatedPolyline = polyline.map((line) =>
+ lineSegmentRotate(line, degreesToRadians(rotation), pointFrom(0, 0)),
+ );
+ expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true);
+ });
+
+ const falsePoints = [pointFrom(0, 1), pointFrom(2.1, 1.5)];
+
+ falsePoints.forEach((p) => {
+ const rotation = (Math.random() * 360) as Degrees;
+ const rotatedPoint = pointRotateDegs(p, pointFrom(0, 0), rotation);
+ const rotatedPolyline = polyline.map((line) =>
+ lineSegmentRotate(line, degreesToRadians(rotation), pointFrom(0, 0)),
+ );
+ expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false);
+ });
+ });
+});
diff --git a/packages/utils/collision.ts b/packages/utils/collision.ts
new file mode 100644
index 0000000..fb48f4e
--- /dev/null
+++ b/packages/utils/collision.ts
@@ -0,0 +1,136 @@
+import type { Polycurve, Polyline } from "./geometry/shape";
+import {
+ pointInEllipse,
+ pointOnEllipse,
+ type GeometricShape,
+} from "./geometry/shape";
+import type { Curve } from "@excalidraw/math";
+import {
+ lineSegment,
+ pointFrom,
+ polygonIncludesPoint,
+ pointOnLineSegment,
+ pointOnPolygon,
+ polygonFromPoints,
+ type GlobalPoint,
+ type LocalPoint,
+ type Polygon,
+} from "@excalidraw/math";
+
+// check if the given point is considered on the given shape's border
+export const isPointOnShape = <Point extends GlobalPoint | LocalPoint>(
+ point: Point,
+ shape: GeometricShape<Point>,
+ tolerance = 0,
+) => {
+ // get the distance from the given point to the given element
+ // check if the distance is within the given epsilon range
+ switch (shape.type) {
+ case "polygon":
+ return pointOnPolygon(point, shape.data, tolerance);
+ case "ellipse":
+ return pointOnEllipse(point, shape.data, tolerance);
+ case "line":
+ return pointOnLineSegment(point, shape.data, tolerance);
+ case "polyline":
+ return pointOnPolyline(point, shape.data, tolerance);
+ case "curve":
+ return pointOnCurve(point, shape.data, tolerance);
+ case "polycurve":
+ return pointOnPolycurve(point, shape.data, tolerance);
+ default:
+ throw Error(`shape ${shape} is not implemented`);
+ }
+};
+
+// check if the given point is considered inside the element's border
+export const isPointInShape = <Point extends GlobalPoint | LocalPoint>(
+ point: Point,
+ shape: GeometricShape<Point>,
+) => {
+ switch (shape.type) {
+ case "polygon":
+ return polygonIncludesPoint(point, shape.data);
+ case "line":
+ return false;
+ case "curve":
+ return false;
+ case "ellipse":
+ return pointInEllipse(point, shape.data);
+ case "polyline": {
+ const polygon = polygonFromPoints(shape.data.flat());
+ return polygonIncludesPoint(point, polygon);
+ }
+ case "polycurve": {
+ return false;
+ }
+ default:
+ throw Error(`shape ${shape} is not implemented`);
+ }
+};
+
+// check if the given element is in the given bounds
+export const isPointInBounds = <Point extends GlobalPoint | LocalPoint>(
+ point: Point,
+ bounds: Polygon<Point>,
+) => {
+ return polygonIncludesPoint(point, bounds);
+};
+
+const pointOnPolycurve = <Point extends LocalPoint | GlobalPoint>(
+ point: Point,
+ polycurve: Polycurve<Point>,
+ tolerance: number,
+) => {
+ return polycurve.some((curve) => pointOnCurve(point, curve, tolerance));
+};
+
+const cubicBezierEquation = <Point extends LocalPoint | GlobalPoint>(
+ curve: Curve<Point>,
+) => {
+ const [p0, p1, p2, p3] = curve;
+ // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
+ return (t: number, idx: number) =>
+ Math.pow(1 - t, 3) * p3[idx] +
+ 3 * t * Math.pow(1 - t, 2) * p2[idx] +
+ 3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
+ p0[idx] * Math.pow(t, 3);
+};
+
+const polyLineFromCurve = <Point extends LocalPoint | GlobalPoint>(
+ curve: Curve<Point>,
+ segments = 10,
+): Polyline<Point> => {
+ const equation = cubicBezierEquation(curve);
+ let startingPoint = [equation(0, 0), equation(0, 1)] as Point;
+ const lineSegments: Polyline<Point> = [];
+ let t = 0;
+ const increment = 1 / segments;
+
+ for (let i = 0; i < segments; i++) {
+ t += increment;
+ if (t <= 1) {
+ const nextPoint: Point = pointFrom(equation(t, 0), equation(t, 1));
+ lineSegments.push(lineSegment(startingPoint, nextPoint));
+ startingPoint = nextPoint;
+ }
+ }
+
+ return lineSegments;
+};
+
+export const pointOnCurve = <Point extends LocalPoint | GlobalPoint>(
+ point: Point,
+ curve: Curve<Point>,
+ threshold: number,
+) => {
+ return pointOnPolyline(point, polyLineFromCurve(curve), threshold);
+};
+
+export const pointOnPolyline = <Point extends LocalPoint | GlobalPoint>(
+ point: Point,
+ polyline: Polyline<Point>,
+ threshold = 10e-5,
+) => {
+ return polyline.some((line) => pointOnLineSegment(point, line, threshold));
+};
diff --git a/packages/utils/export.test.ts b/packages/utils/export.test.ts
new file mode 100644
index 0000000..e2af763
--- /dev/null
+++ b/packages/utils/export.test.ts
@@ -0,0 +1,132 @@
+import * as utils from ".";
+import { diagramFactory } from "@excalidraw/excalidraw/tests/fixtures/diagramFixture";
+import { vi } from "vitest";
+import * as mockedSceneExportUtils from "@excalidraw/excalidraw/scene/export";
+
+import { MIME_TYPES } from "@excalidraw/excalidraw/constants";
+
+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();
+ });
+
+ // FIXME the utils.exportToSvg no longer filters out deleted elements.
+ // It's already supposed to be passed non-deleted elements by we're not
+ // type-checking for it correctly.
+ it.skip("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);
+ });
+});
diff --git a/packages/utils/export.ts b/packages/utils/export.ts
new file mode 100644
index 0000000..22287ce
--- /dev/null
+++ b/packages/utils/export.ts
@@ -0,0 +1,213 @@
+import {
+ exportToCanvas as _exportToCanvas,
+ exportToSvg as _exportToSvg,
+} from "@excalidraw/excalidraw/scene/export";
+import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
+import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types";
+import type {
+ ExcalidrawElement,
+ ExcalidrawFrameLikeElement,
+ NonDeleted,
+} from "@excalidraw/excalidraw/element/types";
+import { restore } from "@excalidraw/excalidraw/data/restore";
+import { MIME_TYPES } from "@excalidraw/excalidraw/constants";
+import { encodePngMetadata } from "@excalidraw/excalidraw/data/image";
+import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
+import {
+ copyBlobToClipboardAsPng,
+ copyTextToSystemClipboard,
+ copyToClipboard,
+} from "@excalidraw/excalidraw/clipboard";
+
+export { MIME_TYPES };
+
+type ExportOpts = {
+ elements: readonly NonDeleted<ExcalidrawElement>[];
+ appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
+ files: BinaryFiles | null;
+ maxWidthOrHeight?: number;
+ exportingFrame?: ExcalidrawFrameLikeElement | null;
+ getDimensions?: (
+ width: number,
+ height: number,
+ ) => { width: number; height: number; scale?: number };
+};
+
+export const exportToCanvas = ({
+ elements,
+ appState,
+ files,
+ maxWidthOrHeight,
+ getDimensions,
+ exportPadding,
+ exportingFrame,
+}: ExportOpts & {
+ exportPadding?: number;
+}) => {
+ const { elements: restoredElements, appState: restoredAppState } = restore(
+ { elements, appState },
+ null,
+ null,
+ );
+ const { exportBackground, viewBackgroundColor } = restoredAppState;
+ return _exportToCanvas(
+ restoredElements,
+ { ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
+ files || {},
+ { exportBackground, exportPadding, viewBackgroundColor, exportingFrame },
+ (width: number, height: number) => {
+ const canvas = document.createElement("canvas");
+
+ if (maxWidthOrHeight) {
+ if (typeof getDimensions === "function") {
+ console.warn(
+ "`getDimensions()` is ignored when `maxWidthOrHeight` is supplied.",
+ );
+ }
+
+ const max = Math.max(width, height);
+
+ // if content is less then maxWidthOrHeight, fallback on supplied scale
+ const scale =
+ maxWidthOrHeight < max
+ ? maxWidthOrHeight / max
+ : appState?.exportScale ?? 1;
+
+ canvas.width = width * scale;
+ canvas.height = height * scale;
+
+ return {
+ canvas,
+ scale,
+ };
+ }
+
+ const ret = getDimensions?.(width, height) || { width, height };
+
+ canvas.width = ret.width;
+ canvas.height = ret.height;
+
+ return {
+ canvas,
+ scale: ret.scale ?? 1,
+ };
+ },
+ );
+};
+
+export const exportToBlob = async (
+ opts: ExportOpts & {
+ mimeType?: string;
+ quality?: number;
+ exportPadding?: number;
+ },
+): Promise<Blob> => {
+ let { mimeType = MIME_TYPES.png, quality } = opts;
+
+ if (mimeType === MIME_TYPES.png && typeof quality === "number") {
+ console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
+ }
+
+ // typo in MIME type (should be "jpeg")
+ if (mimeType === "image/jpg") {
+ mimeType = MIME_TYPES.jpg;
+ }
+
+ if (mimeType === MIME_TYPES.jpg && !opts.appState?.exportBackground) {
+ console.warn(
+ `Defaulting "exportBackground" to "true" for "${MIME_TYPES.jpg}" mimeType`,
+ );
+ opts = {
+ ...opts,
+ appState: { ...opts.appState, exportBackground: true },
+ };
+ }
+
+ const canvas = await exportToCanvas(opts);
+
+ quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
+
+ return new Promise((resolve, reject) => {
+ canvas.toBlob(
+ async (blob) => {
+ if (!blob) {
+ return reject(new Error("couldn't export to blob"));
+ }
+ if (
+ blob &&
+ mimeType === MIME_TYPES.png &&
+ opts.appState?.exportEmbedScene
+ ) {
+ blob = await encodePngMetadata({
+ blob,
+ metadata: serializeAsJSON(
+ // NOTE as long as we're using the Scene hack, we need to ensure
+ // we pass the original, uncloned elements when serializing
+ // so that we keep ids stable
+ opts.elements,
+ opts.appState,
+ opts.files || {},
+ "local",
+ ),
+ });
+ }
+ resolve(blob);
+ },
+ mimeType,
+ quality,
+ );
+ });
+};
+
+export const exportToSvg = async ({
+ elements,
+ appState = getDefaultAppState(),
+ files = {},
+ exportPadding,
+ renderEmbeddables,
+ exportingFrame,
+ skipInliningFonts,
+ reuseImages,
+}: Omit<ExportOpts, "getDimensions"> & {
+ exportPadding?: number;
+ renderEmbeddables?: boolean;
+ skipInliningFonts?: true;
+ reuseImages?: boolean;
+}): Promise<SVGSVGElement> => {
+ const { elements: restoredElements, appState: restoredAppState } = restore(
+ { elements, appState },
+ null,
+ null,
+ );
+
+ const exportAppState = {
+ ...restoredAppState,
+ exportPadding,
+ };
+
+ return _exportToSvg(restoredElements, exportAppState, files, {
+ exportingFrame,
+ renderEmbeddables,
+ skipInliningFonts,
+ reuseImages,
+ });
+};
+
+export const exportToClipboard = async (
+ opts: ExportOpts & {
+ mimeType?: string;
+ quality?: number;
+ type: "png" | "svg" | "json";
+ },
+) => {
+ if (opts.type === "svg") {
+ const svg = await exportToSvg(opts);
+ await copyTextToSystemClipboard(svg.outerHTML);
+ } else if (opts.type === "png") {
+ await copyBlobToClipboardAsPng(exportToBlob(opts));
+ } else if (opts.type === "json") {
+ await copyToClipboard(opts.elements, opts.files);
+ } else {
+ throw new Error("Invalid export type");
+ }
+};
diff --git a/packages/utils/geometry/geometry.test.ts b/packages/utils/geometry/geometry.test.ts
new file mode 100644
index 0000000..e9ad067
--- /dev/null
+++ b/packages/utils/geometry/geometry.test.ts
@@ -0,0 +1,161 @@
+import type {
+ GlobalPoint,
+ LineSegment,
+ Polygon,
+ Radians,
+} from "@excalidraw/math";
+import {
+ pointFrom,
+ lineSegment,
+ polygon,
+ pointOnLineSegment,
+ pointOnPolygon,
+ polygonIncludesPoint,
+ segmentsIntersectAt,
+} from "@excalidraw/math";
+import { pointInEllipse, pointOnEllipse, type Ellipse } from "./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", () => {
+ const poly: Polygon<GlobalPoint> = polygon(
+ pointFrom(10, 10),
+ pointFrom(50, 10),
+ pointFrom(50, 50),
+ pointFrom(10, 50),
+ );
+
+ it("point on polygon", () => {
+ expect(pointOnPolygon(pointFrom(30, 10), poly)).toBe(true);
+ expect(pointOnPolygon(pointFrom(50, 30), poly)).toBe(true);
+ expect(pointOnPolygon(pointFrom(30, 50), poly)).toBe(true);
+ expect(pointOnPolygon(pointFrom(10, 30), poly)).toBe(true);
+ expect(pointOnPolygon(pointFrom(30, 30), poly)).toBe(false);
+ expect(pointOnPolygon(pointFrom(30, 70), poly)).toBe(false);
+ });
+
+ 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);
+ });
+});
diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts
new file mode 100644
index 0000000..10fc06e
--- /dev/null
+++ b/packages/utils/geometry/shape.ts
@@ -0,0 +1,541 @@
+/**
+ * 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 type { Curve, LineSegment, Polygon, Radians } from "@excalidraw/math";
+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/excalidraw/element";
+import type {
+ ElementsMap,
+ ExcalidrawBindableElement,
+ ExcalidrawDiamondElement,
+ ExcalidrawElement,
+ ExcalidrawEllipseElement,
+ ExcalidrawEmbeddableElement,
+ ExcalidrawFrameLikeElement,
+ ExcalidrawFreeDrawElement,
+ ExcalidrawIframeElement,
+ ExcalidrawImageElement,
+ ExcalidrawLinearElement,
+ ExcalidrawRectangleElement,
+ ExcalidrawSelectionElement,
+ ExcalidrawTextElement,
+} from "@excalidraw/excalidraw/element/types";
+import { pointsOnBezierCurves } from "points-on-curve";
+import type { Drawable, Op } from "roughjs/bin/core";
+import { invariant } from "@excalidraw/excalidraw/utils";
+
+// 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
+ | 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),
+ ];
+};
diff --git a/packages/utils/global.d.ts b/packages/utils/global.d.ts
new file mode 100644
index 0000000..16ade7a
--- /dev/null
+++ b/packages/utils/global.d.ts
@@ -0,0 +1,3 @@
+/// <reference types="vite/client" />
+import "@excalidraw/excalidraw/global";
+import "@excalidraw/excalidraw/css";
diff --git a/packages/utils/index.ts b/packages/utils/index.ts
new file mode 100644
index 0000000..2a92913
--- /dev/null
+++ b/packages/utils/index.ts
@@ -0,0 +1,4 @@
+export * from "./export";
+export * from "./withinBounds";
+export * from "./bbox";
+export { getCommonBounds } from "@excalidraw/excalidraw/element/bounds";
diff --git a/packages/utils/package.json b/packages/utils/package.json
new file mode 100644
index 0000000..ddda1e7
--- /dev/null
+++ b/packages/utils/package.json
@@ -0,0 +1,75 @@
+{
+ "name": "@excalidraw/utils",
+ "version": "0.1.2",
+ "type": "module",
+ "types": "./dist/types/utils/index.d.ts",
+ "main": "./dist/prod/index.js",
+ "module": "./dist/prod/index.js",
+ "exports": {
+ ".": {
+ "types": "./dist/types/utils/index.d.ts",
+ "development": "./dist/dev/index.js",
+ "production": "./dist/prod/index.js",
+ "default": "./dist/prod/index.js"
+ },
+ "./*": {
+ "types": "./../utils/dist/types/utils/*"
+ }
+ },
+ "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.29.1",
+ "open-color": "1.9.1",
+ "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": "4.9.4",
+ "wawoff2": "2.0.1",
+ "which": "4.0.0"
+ },
+ "bugs": "https://github.com/excalidraw/excalidraw/issues",
+ "repository": "https://github.com/excalidraw/excalidraw",
+ "scripts": {
+ "gen:types": "rm -rf types && tsc",
+ "build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types"
+ }
+}
diff --git a/packages/utils/test-utils.ts b/packages/utils/test-utils.ts
new file mode 100644
index 0000000..1dfd14c
--- /dev/null
+++ b/packages/utils/test-utils.ts
@@ -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 / 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,
+ };
+ },
+});
diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json
new file mode 100644
index 0000000..f61b8d0
--- /dev/null
+++ b/packages/utils/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "outDir": "./dist/types",
+ "target": "ESNext",
+ "strict": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "allowSyntheticDefaultImports": true,
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "resolveJsonModule": true,
+ "jsx": "react-jsx",
+ "emitDeclarationOnly": true,
+ "paths": {
+ "@excalidraw/excalidraw": ["../excalidraw/index.tsx"],
+ "@excalidraw/utils": ["../utils/index.ts"],
+ "@excalidraw/math": ["../math/index.ts"],
+ "@excalidraw/excalidraw/*": ["../excalidraw/*"],
+ "@excalidraw/utils/*": ["../utils/*"],
+ "@excalidraw/math/*": ["../math/*"]
+ }
+ },
+ "exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
+}
diff --git a/packages/utils/utils.unmocked.test.ts b/packages/utils/utils.unmocked.test.ts
new file mode 100644
index 0000000..341adef
--- /dev/null
+++ b/packages/utils/utils.unmocked.test.ts
@@ -0,0 +1,68 @@
+import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
+import * as utils from "./index";
+import { API } from "@excalidraw/excalidraw/tests/helpers/api";
+import { decodeSvgBase64Payload } from "@excalidraw/excalidraw/scene/export";
+import { decodePngMetadata } from "@excalidraw/excalidraw/data/image";
+
+// 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),
+ );
+ });
+ });
+});
diff --git a/packages/utils/withinBounds.test.ts b/packages/utils/withinBounds.test.ts
new file mode 100644
index 0000000..85354d7
--- /dev/null
+++ b/packages/utils/withinBounds.test.ts
@@ -0,0 +1,262 @@
+import type { Bounds } from "@excalidraw/excalidraw/element/bounds";
+import { API } from "@excalidraw/excalidraw/tests/helpers/api";
+import {
+ elementPartiallyOverlapsWithOrContainsBBox,
+ elementsOverlappingBBox,
+ isElementInsideBBox,
+} from "./withinBounds";
+
+const makeElement = (x: number, y: number, width: number, height: number) =>
+ API.createElement({
+ type: "rectangle",
+ x,
+ y,
+ width,
+ height,
+ });
+
+const makeBBox = (
+ minX: number,
+ minY: number,
+ maxX: number,
+ maxY: number,
+): Bounds => [minX, minY, maxX, maxY];
+
+describe("isElementInsideBBox()", () => {
+ it("should return true if element is fully inside", () => {
+ const bbox = makeBBox(0, 0, 100, 100);
+
+ // bbox contains element
+ expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true);
+ expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true);
+ });
+
+ it("should return false if element is only partially overlapping", () => {
+ const bbox = makeBBox(0, 0, 100, 100);
+
+ // element contains bbox
+ expect(isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox)).toBe(
+ false,
+ );
+
+ // element overlaps bbox from top-left
+ expect(isElementInsideBBox(makeElement(-10, -10, 100, 100), bbox)).toBe(
+ false,
+ );
+ // element overlaps bbox from top-right
+ expect(isElementInsideBBox(makeElement(90, -10, 100, 100), bbox)).toBe(
+ false,
+ );
+ // element overlaps bbox from bottom-left
+ expect(isElementInsideBBox(makeElement(-10, 90, 100, 100), bbox)).toBe(
+ false,
+ );
+ // element overlaps bbox from bottom-right
+ expect(isElementInsideBBox(makeElement(90, 90, 100, 100), bbox)).toBe(
+ false,
+ );
+ });
+
+ it("should return false if element outside", () => {
+ const bbox = makeBBox(0, 0, 100, 100);
+
+ // outside diagonally
+ expect(isElementInsideBBox(makeElement(110, 110, 100, 100), bbox)).toBe(
+ false,
+ );
+
+ // outside on the left
+ expect(isElementInsideBBox(makeElement(-110, 10, 50, 50), bbox)).toBe(
+ false,
+ );
+ // outside on the right
+ expect(isElementInsideBBox(makeElement(110, 10, 50, 50), bbox)).toBe(false);
+ // outside on the top
+ expect(isElementInsideBBox(makeElement(10, -110, 50, 50), bbox)).toBe(
+ false,
+ );
+ // outside on the bottom
+ expect(isElementInsideBBox(makeElement(10, 110, 50, 50), bbox)).toBe(false);
+ });
+
+ it("should return true if bbox contains element and flag enabled", () => {
+ const bbox = makeBBox(0, 0, 100, 100);
+
+ // element contains bbox
+ expect(
+ isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox, true),
+ ).toBe(true);
+
+ // bbox contains element
+ expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true);
+ expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true);
+ });
+});
+
+describe("elementPartiallyOverlapsWithOrContainsBBox()", () => {
+ it("should return true if element overlaps, is inside, or contains", () => {
+ const bbox = makeBBox(0, 0, 100, 100);
+
+ // bbox contains element
+ expect(
+ elementPartiallyOverlapsWithOrContainsBBox(
+ makeElement(0, 0, 100, 100),
+ bbox,
+ ),
+ ).toBe(true);
+ expect(
+ elementPartiallyOverlapsWithOrContainsBBox(
+ makeElement(10, 10, 90, 90),
+ bbox,
+ ),
+ ).toBe(true);
+
+ // element contains bbox
+ expect(
+ elementPartiallyOverlapsWithOrContainsBBox(
+ makeElement(-10, -10, 110, 110),
+ bbox,
+ ),
+ ).toBe(true);
+
+ // element overlaps bbox from top-left
+ expect(
+ elementPartiallyOverlapsWithOrContainsBBox(
+ makeElement(-10, -10, 100, 100),
+ bbox,
+ ),
+ ).toBe(true);
+ // element overlaps bbox from top-right
+ expect(
+ elementPartiallyOverlapsWithOrContainsBBox(
+ makeElement(90, -10, 100, 100),
+ bbox,
+ ),
+ ).toBe(true);
+ // element overlaps bbox from bottom-left
+ expect(
+ elementPartiallyOverlapsWithOrContainsBBox(
+ makeElement(-10, 90, 100, 100),
+ bbox,
+ ),
+ ).toBe(true);
+ // element overlaps bbox from bottom-right
+ expect(
+ elementPartiallyOverlapsWithOrContainsBBox(
+ makeElement(90, 90, 100, 100),
+ bbox,
+ ),
+ ).toBe(true);
+ });
+
+ it("should return false if element does not overlap", () => {
+ const bbox = makeBBox(0, 0, 100, 100);
+
+ // outside diagonally
+ expect(
+ elementPartiallyOverlapsWithOrContainsBBox(
+ makeElement(110, 110, 100, 100),
+ bbox,
+ ),
+ ).toBe(false);
+
+ // outside on the left
+ expect(
+ elementPartiallyOverlapsWithOrContainsBBox(
+ makeElement(-110, 10, 50, 50),
+ bbox,
+ ),
+ ).toBe(false);
+ // outside on the right
+ expect(
+ elementPartiallyOverlapsWithOrContainsBBox(
+ makeElement(110, 10, 50, 50),
+ bbox,
+ ),
+ ).toBe(false);
+ // outside on the top
+ expect(
+ elementPartiallyOverlapsWithOrContainsBBox(
+ makeElement(10, -110, 50, 50),
+ bbox,
+ ),
+ ).toBe(false);
+ // outside on the bottom
+ expect(
+ elementPartiallyOverlapsWithOrContainsBBox(
+ makeElement(10, 110, 50, 50),
+ bbox,
+ ),
+ ).toBe(false);
+ });
+});
+
+describe("elementsOverlappingBBox()", () => {
+ it("should return elements that overlap bbox", () => {
+ const bbox = makeBBox(0, 0, 100, 100);
+
+ const rectOutside = makeElement(110, 110, 100, 100);
+ const rectInside = makeElement(10, 10, 90, 90);
+ const rectContainingBBox = makeElement(-10, -10, 110, 110);
+ const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
+
+ expect(
+ elementsOverlappingBBox({
+ bounds: bbox,
+ type: "overlap",
+ elements: [
+ rectOutside,
+ rectInside,
+ rectContainingBBox,
+ rectOverlappingTopLeft,
+ ],
+ }),
+ ).toEqual([rectInside, rectContainingBBox, rectOverlappingTopLeft]);
+ });
+
+ it("should return elements inside/containing bbox", () => {
+ const bbox = makeBBox(0, 0, 100, 100);
+
+ const rectOutside = makeElement(110, 110, 100, 100);
+ const rectInside = makeElement(10, 10, 90, 90);
+ const rectContainingBBox = makeElement(-10, -10, 110, 110);
+ const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
+
+ expect(
+ elementsOverlappingBBox({
+ bounds: bbox,
+ type: "contain",
+ elements: [
+ rectOutside,
+ rectInside,
+ rectContainingBBox,
+ rectOverlappingTopLeft,
+ ],
+ }),
+ ).toEqual([rectInside, rectContainingBBox]);
+ });
+
+ it("should return elements inside bbox", () => {
+ const bbox = makeBBox(0, 0, 100, 100);
+
+ const rectOutside = makeElement(110, 110, 100, 100);
+ const rectInside = makeElement(10, 10, 90, 90);
+ const rectContainingBBox = makeElement(-10, -10, 110, 110);
+ const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
+
+ expect(
+ elementsOverlappingBBox({
+ bounds: bbox,
+ type: "inside",
+ elements: [
+ rectOutside,
+ rectInside,
+ rectContainingBBox,
+ rectOverlappingTopLeft,
+ ],
+ }),
+ ).toEqual([rectInside]);
+ });
+
+ // TODO test linear, freedraw, and diamond element types (+rotated)
+});
diff --git a/packages/utils/withinBounds.ts b/packages/utils/withinBounds.ts
new file mode 100644
index 0000000..8d52eb3
--- /dev/null
+++ b/packages/utils/withinBounds.ts
@@ -0,0 +1,228 @@
+import type {
+ ExcalidrawElement,
+ ExcalidrawFreeDrawElement,
+ ExcalidrawLinearElement,
+ NonDeletedExcalidrawElement,
+} from "@excalidraw/excalidraw/element/types";
+import {
+ isArrowElement,
+ isExcalidrawElement,
+ isFreeDrawElement,
+ isLinearElement,
+ isTextElement,
+} from "@excalidraw/excalidraw/element/typeChecks";
+import type { Bounds } from "@excalidraw/excalidraw/element/bounds";
+import { getElementBounds } from "@excalidraw/excalidraw/element/bounds";
+import { arrayToMap } from "@excalidraw/excalidraw/utils";
+import type { LocalPoint } from "@excalidraw/math";
+import {
+ rangeIncludesValue,
+ pointFrom,
+ pointRotateRads,
+ rangeInclusive,
+} from "@excalidraw/math";
+
+type Element = NonDeletedExcalidrawElement;
+type Elements = readonly NonDeletedExcalidrawElement[];
+
+type Points = readonly LocalPoint[];
+
+/** @returns vertices relative to element's top-left [0,0] position */
+const getNonLinearElementRelativePoints = (
+ element: Exclude<
+ Element,
+ ExcalidrawLinearElement | ExcalidrawFreeDrawElement
+ >,
+): [
+ TopLeft: LocalPoint,
+ TopRight: LocalPoint,
+ BottomRight: LocalPoint,
+ BottomLeft: LocalPoint,
+] => {
+ if (element.type === "diamond") {
+ return [
+ pointFrom(element.width / 2, 0),
+ pointFrom(element.width, element.height / 2),
+ pointFrom(element.width / 2, element.height),
+ pointFrom(0, element.height / 2),
+ ];
+ }
+ return [
+ pointFrom(0, 0),
+ pointFrom(0 + element.width, 0),
+ pointFrom(0 + element.width, element.height),
+ pointFrom(0, element.height),
+ ];
+};
+
+/** @returns vertices relative to element's top-left [0,0] position */
+const getElementRelativePoints = (element: ExcalidrawElement): Points => {
+ if (isLinearElement(element) || isFreeDrawElement(element)) {
+ return element.points;
+ }
+ return getNonLinearElementRelativePoints(element);
+};
+
+const getMinMaxPoints = (points: Points) => {
+ const ret = points.reduce(
+ (limits, [x, y]) => {
+ limits.minY = Math.min(limits.minY, y);
+ limits.minX = Math.min(limits.minX, x);
+
+ limits.maxX = Math.max(limits.maxX, x);
+ limits.maxY = Math.max(limits.maxY, y);
+
+ return limits;
+ },
+ {
+ minX: Infinity,
+ minY: Infinity,
+ maxX: -Infinity,
+ maxY: -Infinity,
+ cx: 0,
+ cy: 0,
+ },
+ );
+
+ ret.cx = (ret.maxX + ret.minX) / 2;
+ ret.cy = (ret.maxY + ret.minY) / 2;
+
+ return ret;
+};
+
+const getRotatedBBox = (element: Element): Bounds => {
+ const points = getElementRelativePoints(element);
+
+ const { cx, cy } = getMinMaxPoints(points);
+ const centerPoint = pointFrom<LocalPoint>(cx, cy);
+
+ const rotatedPoints = points.map((p) =>
+ pointRotateRads(p, centerPoint, element.angle),
+ );
+ const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints);
+
+ return [
+ minX + element.x,
+ minY + element.y,
+ maxX + element.x,
+ maxY + element.y,
+ ];
+};
+
+export const isElementInsideBBox = (
+ element: Element,
+ bbox: Bounds,
+ eitherDirection = false,
+): boolean => {
+ const elementBBox = getRotatedBBox(element);
+
+ const elementInsideBbox =
+ bbox[0] <= elementBBox[0] &&
+ bbox[2] >= elementBBox[2] &&
+ bbox[1] <= elementBBox[1] &&
+ bbox[3] >= elementBBox[3];
+
+ if (!eitherDirection) {
+ return elementInsideBbox;
+ }
+
+ if (elementInsideBbox) {
+ return true;
+ }
+
+ return (
+ elementBBox[0] <= bbox[0] &&
+ elementBBox[2] >= bbox[2] &&
+ elementBBox[1] <= bbox[1] &&
+ elementBBox[3] >= bbox[3]
+ );
+};
+
+export const elementPartiallyOverlapsWithOrContainsBBox = (
+ element: Element,
+ bbox: Bounds,
+): boolean => {
+ const elementBBox = getRotatedBBox(element);
+
+ return (
+ (rangeIncludesValue(elementBBox[0], rangeInclusive(bbox[0], bbox[2])) ||
+ rangeIncludesValue(
+ bbox[0],
+ rangeInclusive(elementBBox[0], elementBBox[2]),
+ )) &&
+ (rangeIncludesValue(elementBBox[1], rangeInclusive(bbox[1], bbox[3])) ||
+ rangeIncludesValue(
+ bbox[1],
+ rangeInclusive(elementBBox[1], elementBBox[3]),
+ ))
+ );
+};
+
+export const elementsOverlappingBBox = ({
+ elements,
+ bounds,
+ type,
+ errorMargin = 0,
+}: {
+ elements: Elements;
+ bounds: Bounds | ExcalidrawElement;
+ /** safety offset. Defaults to 0. */
+ errorMargin?: number;
+ /**
+ * - overlap: elements overlapping or inside bounds
+ * - contain: elements inside bounds or bounds inside elements
+ * - inside: elements inside bounds
+ **/
+ type: "overlap" | "contain" | "inside";
+}) => {
+ if (isExcalidrawElement(bounds)) {
+ bounds = getElementBounds(bounds, arrayToMap(elements));
+ }
+ const adjustedBBox: Bounds = [
+ bounds[0] - errorMargin,
+ bounds[1] - errorMargin,
+ bounds[2] + errorMargin,
+ bounds[3] + errorMargin,
+ ];
+
+ const includedElementSet = new Set<string>();
+
+ for (const element of elements) {
+ if (includedElementSet.has(element.id)) {
+ continue;
+ }
+
+ const isOverlaping =
+ type === "overlap"
+ ? elementPartiallyOverlapsWithOrContainsBBox(element, adjustedBBox)
+ : type === "inside"
+ ? isElementInsideBBox(element, adjustedBBox)
+ : isElementInsideBBox(element, adjustedBBox, true);
+
+ if (isOverlaping) {
+ includedElementSet.add(element.id);
+
+ if (element.boundElements) {
+ for (const boundElement of element.boundElements) {
+ includedElementSet.add(boundElement.id);
+ }
+ }
+
+ if (isTextElement(element) && element.containerId) {
+ includedElementSet.add(element.containerId);
+ }
+
+ if (isArrowElement(element)) {
+ if (element.startBinding) {
+ includedElementSet.add(element.startBinding.elementId);
+ }
+
+ if (element.endBinding) {
+ includedElementSet.add(element.endBinding?.elementId);
+ }
+ }
+ }
+ }
+
+ return elements.filter((element) => includedElementSet.has(element.id));
+};