mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: move utils to utils package and make @excalidraw/utils a workspace (#7432)
* feat: move utils to utils package and make @excalidraw/utils a workspace * remove esm and update types path * remove esm script * fix package.json and yarn.lock * update path * fix * fix lint and test
This commit is contained in:
parent
b635b10b59
commit
88a2b286c7
18 changed files with 261 additions and 56 deletions
100
packages/utils/__snapshots__/export.test.ts.snap
Normal file
100
packages/utils/__snapshots__/export.test.ts.snap
Normal file
|
@ -0,0 +1,100 @@
|
|||
// 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,
|
||||
"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,
|
||||
"viewBackgroundColor": "#ffffff",
|
||||
"viewModeEnabled": false,
|
||||
"zenModeEnabled": false,
|
||||
"zoom": {
|
||||
"value": 1,
|
||||
},
|
||||
}
|
||||
`;
|
65
packages/utils/bbox.ts
Normal file
65
packages/utils/bbox.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { Bounds } from "../excalidraw/element/bounds";
|
||||
import { Point } from "../excalidraw/types";
|
||||
|
||||
export type LineSegment = [Point, Point];
|
||||
|
||||
export function getBBox(line: LineSegment): 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 crossProduct(a: Point, b: Point) {
|
||||
return a[0] * b[1] - b[0] * a[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];
|
||||
}
|
||||
|
||||
export function translate(a: Point, b: Point): Point {
|
||||
return [a[0] - b[0], a[1] - b[1]];
|
||||
}
|
||||
|
||||
const EPSILON = 0.000001;
|
||||
|
||||
export function isPointOnLine(l: LineSegment, p: Point) {
|
||||
const p1 = translate(l[1], l[0]);
|
||||
const p2 = translate(p, l[0]);
|
||||
|
||||
const r = crossProduct(p1, p2);
|
||||
|
||||
return Math.abs(r) < EPSILON;
|
||||
}
|
||||
|
||||
export function isPointRightOfLine(l: LineSegment, p: Point) {
|
||||
const p1 = translate(l[1], l[0]);
|
||||
const p2 = translate(p, l[0]);
|
||||
|
||||
return crossProduct(p1, p2) < 0;
|
||||
}
|
||||
|
||||
export function isLineSegmentTouchingOrCrossingLine(
|
||||
a: LineSegment,
|
||||
b: LineSegment,
|
||||
) {
|
||||
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(a: LineSegment, b: LineSegment) {
|
||||
return (
|
||||
doBBoxesIntersect(getBBox(a), getBBox(b)) &&
|
||||
isLineSegmentTouchingOrCrossingLine(a, b) &&
|
||||
isLineSegmentTouchingOrCrossingLine(b, a)
|
||||
);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import * as utils from "../utils";
|
||||
import * as utils from ".";
|
||||
import { diagramFactory } from "../excalidraw/tests/fixtures/diagramFixture";
|
||||
import { vi } from "vitest";
|
||||
import * as mockedSceneExportUtils from "../excalidraw/scene/export";
|
225
packages/utils/export.ts
Normal file
225
packages/utils/export.ts
Normal file
|
@ -0,0 +1,225 @@
|
|||
import {
|
||||
exportToCanvas as _exportToCanvas,
|
||||
exportToSvg as _exportToSvg,
|
||||
} from "../excalidraw/scene/export";
|
||||
import { getDefaultAppState } from "../excalidraw/appState";
|
||||
import { AppState, BinaryFiles } from "../excalidraw/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeleted,
|
||||
} from "../excalidraw/element/types";
|
||||
import { restore } from "../excalidraw/data/restore";
|
||||
import { MIME_TYPES } from "../excalidraw/constants";
|
||||
import { encodePngMetadata } from "../excalidraw/data/image";
|
||||
import { serializeAsJSON } from "../excalidraw/data/json";
|
||||
import {
|
||||
copyBlobToClipboardAsPng,
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
} from "../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,
|
||||
}: Omit<ExportOpts, "getDimensions"> & {
|
||||
exportPadding?: number;
|
||||
renderEmbeddables?: boolean;
|
||||
}): Promise<SVGSVGElement> => {
|
||||
const { elements: restoredElements, appState: restoredAppState } = restore(
|
||||
{ elements, appState },
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
const exportAppState = {
|
||||
...restoredAppState,
|
||||
exportPadding,
|
||||
};
|
||||
|
||||
return _exportToSvg(restoredElements, exportAppState, files, {
|
||||
exportingFrame,
|
||||
renderEmbeddables,
|
||||
});
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
export * from "./bbox";
|
||||
export {
|
||||
elementsOverlappingBBox,
|
||||
isElementInsideBBox,
|
||||
elementPartiallyOverlapsWithOrContainsBBox,
|
||||
} from "./withinBounds";
|
||||
export {
|
||||
serializeAsJSON,
|
||||
serializeLibraryAsJSON,
|
||||
} from "../excalidraw/data/json";
|
||||
export {
|
||||
loadFromBlob,
|
||||
loadSceneOrLibraryFromBlob,
|
||||
loadLibraryFromBlob,
|
||||
} from "../excalidraw/data/blob";
|
||||
export { getFreeDrawSvgPath } from "../excalidraw/renderer/renderElement";
|
||||
export { mergeLibraryItems } from "../excalidraw/data/library";
|
|
@ -1,5 +1 @@
|
|||
export {
|
||||
exportToBlob,
|
||||
exportToSvg,
|
||||
exportToCanvas,
|
||||
} from "../excalidraw/packages/utils.ts";
|
||||
export * from "./export";
|
||||
|
|
262
packages/utils/withinBounds.test.ts
Normal file
262
packages/utils/withinBounds.test.ts
Normal file
|
@ -0,0 +1,262 @@
|
|||
import { Bounds } from "../excalidraw/element/bounds";
|
||||
import { API } from "../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)
|
||||
});
|
210
packages/utils/withinBounds.ts
Normal file
210
packages/utils/withinBounds.ts
Normal file
|
@ -0,0 +1,210 @@
|
|||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../excalidraw/element/types";
|
||||
import {
|
||||
isArrowElement,
|
||||
isExcalidrawElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "../excalidraw/element/typeChecks";
|
||||
import { isValueInRange, rotatePoint } from "../excalidraw/math";
|
||||
import type { Point } from "../excalidraw/types";
|
||||
import { Bounds, getElementBounds } from "../excalidraw/element/bounds";
|
||||
|
||||
type Element = NonDeletedExcalidrawElement;
|
||||
type Elements = readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
type Points = readonly Point[];
|
||||
|
||||
/** @returns vertices relative to element's top-left [0,0] position */
|
||||
const getNonLinearElementRelativePoints = (
|
||||
element: Exclude<
|
||||
Element,
|
||||
ExcalidrawLinearElement | ExcalidrawFreeDrawElement
|
||||
>,
|
||||
): [TopLeft: Point, TopRight: Point, BottomRight: Point, BottomLeft: Point] => {
|
||||
if (element.type === "diamond") {
|
||||
return [
|
||||
[element.width / 2, 0],
|
||||
[element.width, element.height / 2],
|
||||
[element.width / 2, element.height],
|
||||
[0, element.height / 2],
|
||||
];
|
||||
}
|
||||
return [
|
||||
[0, 0],
|
||||
[0 + element.width, 0],
|
||||
[0 + element.width, element.height],
|
||||
[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: Point = [cx, cy];
|
||||
|
||||
const rotatedPoints = points.map((point) =>
|
||||
rotatePoint([point[0], point[1]], 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 (
|
||||
(isValueInRange(elementBBox[0], bbox[0], bbox[2]) ||
|
||||
isValueInRange(bbox[0], elementBBox[0], elementBBox[2])) &&
|
||||
(isValueInRange(elementBBox[1], bbox[1], bbox[3]) ||
|
||||
isValueInRange(bbox[1], 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);
|
||||
}
|
||||
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));
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue