refactor: separate elements logic into a standalone package (#9285)
Some checks failed
Auto release excalidraw next / Auto-release-excalidraw-next (push) Failing after 2m36s
Build Docker image / build-docker (push) Failing after 6s
Cancel previous runs / cancel (push) Failing after 1s
Publish Docker / publish-docker (push) Failing after 31s
New Sentry production release / sentry (push) Failing after 2m3s

This commit is contained in:
Marcel Mraz 2025-03-26 15:24:59 +01:00 committed by GitHub
parent a18f059188
commit 432a46ef9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
372 changed files with 3466 additions and 2466 deletions

View file

@ -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,
},
}
`;

View file

@ -0,0 +1,102 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`exportToSvg > with default arguments 1`] = `
{
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"lastActiveTool": null,
"locked": false,
"type": "selection",
},
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
"currentItemFontFamily": 1,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "round",
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"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,
},
}
`;

View file

@ -0,0 +1,90 @@
import {
curve,
degreesToRadians,
lineSegment,
lineSegmentRotate,
pointFrom,
pointRotateDegs,
} from "@excalidraw/math";
import type { Curve, Degrees, GlobalPoint } from "@excalidraw/math";
import { pointOnCurve, pointOnPolyline } from "../src/collision";
import type { Polyline } from "../src/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);
});
});
});

View file

@ -0,0 +1,132 @@
import { MIME_TYPES } from "@excalidraw/common";
import * as mockedSceneExportUtils from "@excalidraw/excalidraw/scene/export";
import { diagramFactory } from "@excalidraw/excalidraw/tests/fixtures/diagramFixture";
import { vi } from "vitest";
import * as utils from "../src";
const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
describe("exportToCanvas", async () => {
const EXPORT_PADDING = 10;
it("with default arguments", async () => {
const canvas = await utils.exportToCanvas({
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
});
expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING);
expect(canvas.height).toBe(100 + 2 * EXPORT_PADDING);
});
it("when custom width and height", async () => {
const canvas = await utils.exportToCanvas({
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
});
expect(canvas.width).toBe(200);
expect(canvas.height).toBe(200);
});
});
describe("exportToBlob", async () => {
describe("mime type", () => {
it("should change image/jpg to image/jpeg", async () => {
const blob = await utils.exportToBlob({
...diagramFactory(),
getDimensions: (width, height) => ({ width, height, scale: 1 }),
// testing typo in MIME type (jpg → jpeg)
mimeType: "image/jpg",
appState: {
exportBackground: true,
},
});
expect(blob?.type).toBe(MIME_TYPES.jpg);
});
it("should default to image/png", async () => {
const blob = await utils.exportToBlob({
...diagramFactory(),
});
expect(blob?.type).toBe(MIME_TYPES.png);
});
it("should warn when using quality with image/png", async () => {
const consoleSpy = vi
.spyOn(console, "warn")
.mockImplementationOnce(() => void 0);
await utils.exportToBlob({
...diagramFactory(),
mimeType: MIME_TYPES.png,
quality: 1,
});
expect(consoleSpy).toHaveBeenCalledWith(
`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`,
);
});
});
});
describe("exportToSvg", () => {
const passedElements = () => exportToSvgSpy.mock.calls[0][0];
const passedOptions = () => exportToSvgSpy.mock.calls[0][1];
afterEach(() => {
vi.clearAllMocks();
});
it("with default arguments", async () => {
await utils.exportToSvg({
...diagramFactory({
overrides: { appState: void 0 },
}),
});
const passedOptionsWhenDefault = {
...passedOptions(),
// To avoid varying snapshots
name: "name",
};
expect(passedElements().length).toBe(3);
expect(passedOptionsWhenDefault).toMatchSnapshot();
});
// 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);
});
});

View file

@ -0,0 +1,163 @@
import {
pointFrom,
lineSegment,
polygon,
pointOnLineSegment,
pointOnPolygon,
polygonIncludesPoint,
segmentsIntersectAt,
} from "@excalidraw/math";
import type {
GlobalPoint,
LineSegment,
Polygon,
Radians,
} from "@excalidraw/math";
import { pointInEllipse, pointOnEllipse, type Ellipse } from "../src/shape";
describe("point and line", () => {
// const l: Line<GlobalPoint> = line(point(1, 0), point(1, 2));
// it("point on left or right of line", () => {
// expect(pointLeftofLine(point(0, 1), l)).toBe(true);
// expect(pointLeftofLine(point(1, 1), l)).toBe(false);
// expect(pointLeftofLine(point(2, 1), l)).toBe(false);
// expect(pointRightofLine(point(0, 1), l)).toBe(false);
// expect(pointRightofLine(point(1, 1), l)).toBe(false);
// expect(pointRightofLine(point(2, 1), l)).toBe(true);
// });
const s: LineSegment<GlobalPoint> = lineSegment(
pointFrom(1, 0),
pointFrom(1, 2),
);
it("point on the line", () => {
expect(pointOnLineSegment(pointFrom(0, 1), s)).toBe(false);
expect(pointOnLineSegment(pointFrom(1, 1), s, 0)).toBe(true);
expect(pointOnLineSegment(pointFrom(2, 1), s)).toBe(false);
});
});
describe("point and polygon", () => {
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);
});
});

View file

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

View file

@ -0,0 +1,264 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { Bounds } from "@excalidraw/element/bounds";
import {
elementPartiallyOverlapsWithOrContainsBBox,
elementsOverlappingBBox,
isElementInsideBBox,
} from "../src/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)
});