diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/utils | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/utils')
| -rw-r--r-- | packages/utils/CHANGELOG.md | 11 | ||||
| -rw-r--r-- | packages/utils/README.md | 99 | ||||
| -rw-r--r-- | packages/utils/__snapshots__/export.test.ts.snap | 113 | ||||
| -rw-r--r-- | packages/utils/__snapshots__/utils.test.ts.snap | 102 | ||||
| -rw-r--r-- | packages/utils/bbox.ts | 72 | ||||
| -rw-r--r-- | packages/utils/collision.test.ts | 87 | ||||
| -rw-r--r-- | packages/utils/collision.ts | 136 | ||||
| -rw-r--r-- | packages/utils/export.test.ts | 132 | ||||
| -rw-r--r-- | packages/utils/export.ts | 213 | ||||
| -rw-r--r-- | packages/utils/geometry/geometry.test.ts | 161 | ||||
| -rw-r--r-- | packages/utils/geometry/shape.ts | 541 | ||||
| -rw-r--r-- | packages/utils/global.d.ts | 3 | ||||
| -rw-r--r-- | packages/utils/index.ts | 4 | ||||
| -rw-r--r-- | packages/utils/package.json | 75 | ||||
| -rw-r--r-- | packages/utils/test-utils.ts | 33 | ||||
| -rw-r--r-- | packages/utils/tsconfig.json | 24 | ||||
| -rw-r--r-- | packages/utils/utils.unmocked.test.ts | 68 | ||||
| -rw-r--r-- | packages/utils/withinBounds.test.ts | 262 | ||||
| -rw-r--r-- | packages/utils/withinBounds.ts | 228 |
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)); +}; |
