mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
merge with master
This commit is contained in:
commit
39f79927ae
938 changed files with 127961 additions and 41753 deletions
|
@ -1,65 +0,0 @@
|
|||
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)
|
||||
);
|
||||
}
|
3
packages/utils/global.d.ts
vendored
Normal file
3
packages/utils/global.d.ts
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
/// <reference types="vite/client" />
|
||||
import "@excalidraw/excalidraw/global";
|
||||
import "@excalidraw/excalidraw/css";
|
|
@ -1 +0,0 @@
|
|||
export * from "./export";
|
|
@ -1,11 +1,25 @@
|
|||
{
|
||||
"name": "@excalidraw/utils",
|
||||
"version": "0.1.2",
|
||||
"main": "dist/excalidraw-utils.min.js",
|
||||
"type": "module",
|
||||
"types": "./dist/types/utils/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
"module": "./dist/prod/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/utils/src/index.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./../utils/dist/types/utils/src/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/*"
|
||||
],
|
||||
"description": "Excalidraw utilities functions",
|
||||
"description": "Excalidraw utility functions",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
@ -33,30 +47,29 @@
|
|||
"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": {
|
||||
"@babel/core": "7.18.9",
|
||||
"@babel/plugin-transform-arrow-functions": "7.18.6",
|
||||
"@babel/plugin-transform-async-to-generator": "7.18.6",
|
||||
"@babel/plugin-transform-runtime": "7.18.6",
|
||||
"@babel/plugin-transform-typescript": "7.18.8",
|
||||
"@babel/preset-env": "7.18.9",
|
||||
"@babel/preset-typescript": "7.18.6",
|
||||
"babel-loader": "8.2.5",
|
||||
"babel-plugin-transform-class-properties": "6.24.1",
|
||||
"cross-env": "7.0.3",
|
||||
"css-loader": "6.7.1",
|
||||
"file-loader": "6.2.0",
|
||||
"sass-loader": "13.0.2",
|
||||
"ts-loader": "9.3.1",
|
||||
"webpack": "5.76.0",
|
||||
"webpack-bundle-analyzer": "4.5.0",
|
||||
"webpack-cli": "4.10.0"
|
||||
"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": {
|
||||
"build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js",
|
||||
"build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js",
|
||||
"pack": "yarn build:umd && yarn pack"
|
||||
"gen:types": "rm -rf types && tsc",
|
||||
"build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types"
|
||||
}
|
||||
}
|
||||
|
|
73
packages/utils/src/bbox.ts
Normal file
73
packages/utils/src/bbox.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import {
|
||||
vectorCross,
|
||||
vectorFromPoint,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Bounds } from "@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)
|
||||
);
|
||||
}
|
135
packages/utils/src/collision.ts
Normal file
135
packages/utils/src/collision.ts
Normal file
|
@ -0,0 +1,135 @@
|
|||
import {
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
polygonIncludesPoint,
|
||||
pointOnLineSegment,
|
||||
pointOnPolygon,
|
||||
polygonFromPoints,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
type Polygon,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Curve } from "@excalidraw/math";
|
||||
|
||||
import { pointInEllipse, pointOnEllipse } from "./shape";
|
||||
|
||||
import type { Polycurve, Polyline, GeometricShape } from "./shape";
|
||||
|
||||
// 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));
|
||||
};
|
|
@ -1,23 +1,24 @@
|
|||
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 { MIME_TYPES } from "@excalidraw/common";
|
||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||
import {
|
||||
copyBlobToClipboardAsPng,
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
} from "../excalidraw/clipboard";
|
||||
} from "@excalidraw/excalidraw/clipboard";
|
||||
import { encodePngMetadata } from "@excalidraw/excalidraw/data/image";
|
||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
||||
import {
|
||||
exportToCanvas as _exportToCanvas,
|
||||
exportToSvg as _exportToSvg,
|
||||
} from "@excalidraw/excalidraw/scene/export";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types";
|
||||
|
||||
export { MIME_TYPES };
|
||||
|
||||
|
@ -173,9 +174,13 @@ export const exportToSvg = async ({
|
|||
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 },
|
||||
|
@ -191,6 +196,8 @@ export const exportToSvg = async ({
|
|||
return _exportToSvg(restoredElements, exportAppState, files, {
|
||||
exportingFrame,
|
||||
renderEmbeddables,
|
||||
skipInliningFonts,
|
||||
reuseImages,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -212,21 +219,3 @@ export const exportToClipboard = async (
|
|||
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";
|
4
packages/utils/src/index.ts
Normal file
4
packages/utils/src/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from "./export";
|
||||
export * from "./withinBounds";
|
||||
export * from "./bbox";
|
||||
export { getCommonBounds } from "@excalidraw/element/bounds";
|
544
packages/utils/src/shape.ts
Normal file
544
packages/utils/src/shape.ts
Normal file
|
@ -0,0 +1,544 @@
|
|||
/**
|
||||
* this file defines pure geometric shapes
|
||||
*
|
||||
* for instance, a cubic bezier curve is specified by its four control points and
|
||||
* an ellipse is defined by its center, angle, semi major axis and semi minor axis
|
||||
* (but in semi-width and semi-height so it's more relevant to Excalidraw)
|
||||
*
|
||||
* the idea with pure shapes is so that we can provide collision and other geoemtric methods not depending on
|
||||
* the specifics of roughjs or elements in Excalidraw; instead, we can focus on the pure shapes themselves
|
||||
*
|
||||
* also included in this file are methods for converting an Excalidraw element or a Drawable from roughjs
|
||||
* to pure shapes
|
||||
*/
|
||||
import { pointsOnBezierCurves } from "points-on-curve";
|
||||
|
||||
import { invariant } from "@excalidraw/common";
|
||||
import {
|
||||
curve,
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
pointFromArray,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
polygon,
|
||||
polygonFromPoints,
|
||||
PRECISION,
|
||||
segmentsIntersectAt,
|
||||
vector,
|
||||
vectorAdd,
|
||||
vectorFromPoint,
|
||||
vectorScale,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getElementAbsoluteCoords } from "@excalidraw/element/bounds";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawIframeElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "@excalidraw/element/types";
|
||||
import type { Curve, LineSegment, Polygon, Radians } from "@excalidraw/math";
|
||||
|
||||
import type { Drawable, Op } from "roughjs/bin/core";
|
||||
|
||||
// a polyline (made up term here) is a line consisting of other line segments
|
||||
// this corresponds to a straight line element in the editor but it could also
|
||||
// be used to model other elements
|
||||
export type Polyline<Point extends GlobalPoint | LocalPoint> =
|
||||
LineSegment<Point>[];
|
||||
|
||||
// a polycurve is a curve consisting of ther curves, this corresponds to a complex
|
||||
// curve on the canvas
|
||||
export type Polycurve<Point extends GlobalPoint | LocalPoint> = Curve<Point>[];
|
||||
|
||||
// an ellipse is specified by its center, angle, and its major and minor axes
|
||||
// but for the sake of simplicity, we've used halfWidth and halfHeight instead
|
||||
// in replace of semi major and semi minor axes
|
||||
export type Ellipse<Point extends GlobalPoint | LocalPoint> = {
|
||||
center: Point;
|
||||
angle: Radians;
|
||||
halfWidth: number;
|
||||
halfHeight: number;
|
||||
};
|
||||
|
||||
export type GeometricShape<Point extends GlobalPoint | LocalPoint> =
|
||||
| {
|
||||
type: "line";
|
||||
data: LineSegment<Point>;
|
||||
}
|
||||
| {
|
||||
type: "polygon";
|
||||
data: Polygon<Point>;
|
||||
}
|
||||
| {
|
||||
type: "curve";
|
||||
data: Curve<Point>;
|
||||
}
|
||||
| {
|
||||
type: "ellipse";
|
||||
data: Ellipse<Point>;
|
||||
}
|
||||
| {
|
||||
type: "polyline";
|
||||
data: Polyline<Point>;
|
||||
}
|
||||
| {
|
||||
type: "polycurve";
|
||||
data: Polycurve<Point>;
|
||||
};
|
||||
|
||||
type RectangularElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawFrameLikeElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawIframeElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawSelectionElement;
|
||||
|
||||
// polygon
|
||||
export const getPolygonShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: RectangularElement,
|
||||
): GeometricShape<Point> => {
|
||||
const { angle, width, height, x, y } = element;
|
||||
|
||||
const cx = x + width / 2;
|
||||
const cy = y + height / 2;
|
||||
|
||||
const center: Point = pointFrom(cx, cy);
|
||||
|
||||
let data: Polygon<Point>;
|
||||
|
||||
if (element.type === "diamond") {
|
||||
data = polygon(
|
||||
pointRotateRads(pointFrom(cx, y), center, angle),
|
||||
pointRotateRads(pointFrom(x + width, cy), center, angle),
|
||||
pointRotateRads(pointFrom(cx, y + height), center, angle),
|
||||
pointRotateRads(pointFrom(x, cy), center, angle),
|
||||
);
|
||||
} else {
|
||||
data = polygon(
|
||||
pointRotateRads(pointFrom(x, y), center, angle),
|
||||
pointRotateRads(pointFrom(x + width, y), center, angle),
|
||||
pointRotateRads(pointFrom(x + width, y + height), center, angle),
|
||||
pointRotateRads(pointFrom(x, y + height), center, angle),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "polygon",
|
||||
data,
|
||||
};
|
||||
};
|
||||
|
||||
// return the selection box for an element, possibly rotated as well
|
||||
export const getSelectionBoxShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
padding = 10,
|
||||
) => {
|
||||
let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
true,
|
||||
);
|
||||
|
||||
x1 -= padding;
|
||||
x2 += padding;
|
||||
y1 -= padding;
|
||||
y2 += padding;
|
||||
|
||||
//const angleInDegrees = angleToDegrees(element.angle);
|
||||
const center = pointFrom(cx, cy);
|
||||
const topLeft = pointRotateRads(pointFrom(x1, y1), center, element.angle);
|
||||
const topRight = pointRotateRads(pointFrom(x2, y1), center, element.angle);
|
||||
const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, element.angle);
|
||||
const bottomRight = pointRotateRads(pointFrom(x2, y2), center, element.angle);
|
||||
|
||||
return {
|
||||
type: "polygon",
|
||||
data: [topLeft, topRight, bottomRight, bottomLeft],
|
||||
} as GeometricShape<Point>;
|
||||
};
|
||||
|
||||
// ellipse
|
||||
export const getEllipseShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: ExcalidrawEllipseElement,
|
||||
): GeometricShape<Point> => {
|
||||
const { width, height, angle, x, y } = element;
|
||||
|
||||
return {
|
||||
type: "ellipse",
|
||||
data: {
|
||||
center: pointFrom(x + width / 2, y + height / 2),
|
||||
angle,
|
||||
halfWidth: width / 2,
|
||||
halfHeight: height / 2,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const getCurvePathOps = (shape: Drawable): Op[] => {
|
||||
// NOTE (mtolmacs): Temporary fix for extremely large elements
|
||||
if (!shape) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const set of shape.sets) {
|
||||
if (set.type === "path") {
|
||||
return set.ops;
|
||||
}
|
||||
}
|
||||
return shape.sets[0].ops;
|
||||
};
|
||||
|
||||
// linear
|
||||
export const getCurveShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
roughShape: Drawable,
|
||||
startingPoint: Point = pointFrom(0, 0),
|
||||
angleInRadian: Radians,
|
||||
center: Point,
|
||||
): GeometricShape<Point> => {
|
||||
const transform = (p: Point): Point =>
|
||||
pointRotateRads(
|
||||
pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]),
|
||||
center,
|
||||
angleInRadian,
|
||||
);
|
||||
|
||||
const ops = getCurvePathOps(roughShape);
|
||||
const polycurve: Polycurve<Point> = [];
|
||||
let p0 = pointFrom<Point>(0, 0);
|
||||
|
||||
for (const op of ops) {
|
||||
if (op.op === "move") {
|
||||
const p = pointFromArray<Point>(op.data);
|
||||
invariant(p != null, "Ops data is not a point");
|
||||
p0 = transform(p);
|
||||
}
|
||||
if (op.op === "bcurveTo") {
|
||||
const p1 = transform(pointFrom<Point>(op.data[0], op.data[1]));
|
||||
const p2 = transform(pointFrom<Point>(op.data[2], op.data[3]));
|
||||
const p3 = transform(pointFrom<Point>(op.data[4], op.data[5]));
|
||||
polycurve.push(curve<Point>(p0, p1, p2, p3));
|
||||
p0 = p3;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "polycurve",
|
||||
data: polycurve,
|
||||
};
|
||||
};
|
||||
|
||||
const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>(
|
||||
points: Point[],
|
||||
): Polyline<Point> => {
|
||||
let previousPoint: Point = points[0];
|
||||
const polyline: LineSegment<Point>[] = [];
|
||||
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const nextPoint = points[i];
|
||||
polyline.push(lineSegment<Point>(previousPoint, nextPoint));
|
||||
previousPoint = nextPoint;
|
||||
}
|
||||
|
||||
return polyline;
|
||||
};
|
||||
|
||||
export const getFreedrawShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
center: Point,
|
||||
isClosed: boolean = false,
|
||||
): GeometricShape<Point> => {
|
||||
const transform = (p: Point) =>
|
||||
pointRotateRads(
|
||||
pointFromVector(
|
||||
vectorAdd(vectorFromPoint(p), vector(element.x, element.y)),
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
const polyline = polylineFromPoints(
|
||||
element.points.map((p) => transform(p as Point)),
|
||||
);
|
||||
|
||||
return (
|
||||
isClosed
|
||||
? {
|
||||
type: "polygon",
|
||||
data: polygonFromPoints(polyline.flat()),
|
||||
}
|
||||
: {
|
||||
type: "polyline",
|
||||
data: polyline,
|
||||
}
|
||||
) as GeometricShape<Point>;
|
||||
};
|
||||
|
||||
export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: ExcalidrawLinearElement,
|
||||
roughShape: Drawable,
|
||||
startingPoint: Point = pointFrom<Point>(0, 0),
|
||||
angleInRadian: Radians,
|
||||
center: Point,
|
||||
): GeometricShape<Point> => {
|
||||
const transform = (p: Point) =>
|
||||
pointRotateRads(
|
||||
pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]),
|
||||
center,
|
||||
angleInRadian,
|
||||
);
|
||||
|
||||
if (element.roundness === null) {
|
||||
return {
|
||||
type: "polygon",
|
||||
data: polygonFromPoints(
|
||||
element.points.map((p) => transform(p as Point)) as Point[],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const ops = getCurvePathOps(roughShape);
|
||||
|
||||
const points: Point[] = [];
|
||||
let odd = false;
|
||||
for (const operation of ops) {
|
||||
if (operation.op === "move") {
|
||||
odd = !odd;
|
||||
if (odd) {
|
||||
points.push(pointFrom(operation.data[0], operation.data[1]));
|
||||
}
|
||||
} else if (operation.op === "bcurveTo") {
|
||||
if (odd) {
|
||||
points.push(pointFrom(operation.data[0], operation.data[1]));
|
||||
points.push(pointFrom(operation.data[2], operation.data[3]));
|
||||
points.push(pointFrom(operation.data[4], operation.data[5]));
|
||||
}
|
||||
} else if (operation.op === "lineTo") {
|
||||
if (odd) {
|
||||
points.push(pointFrom(operation.data[0], operation.data[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) =>
|
||||
transform(p as Point),
|
||||
) as Point[];
|
||||
|
||||
return {
|
||||
type: "polygon",
|
||||
data: polygonFromPoints<Point>(polygonPoints),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine intersection of a rectangular shaped element and a
|
||||
* line segment.
|
||||
*
|
||||
* @param element The rectangular element to test against
|
||||
* @param segment The segment intersecting the element
|
||||
* @param gap Optional value to inflate the shape before testing
|
||||
* @returns An array of intersections
|
||||
*/
|
||||
// TODO: Replace with final rounded rectangle code
|
||||
export const segmentIntersectRectangleElement = <
|
||||
Point extends LocalPoint | GlobalPoint,
|
||||
>(
|
||||
element: ExcalidrawBindableElement,
|
||||
segment: LineSegment<Point>,
|
||||
gap: number = 0,
|
||||
): Point[] => {
|
||||
const bounds = [
|
||||
element.x - gap,
|
||||
element.y - gap,
|
||||
element.x + element.width + gap,
|
||||
element.y + element.height + gap,
|
||||
];
|
||||
const center = pointFrom(
|
||||
(bounds[0] + bounds[2]) / 2,
|
||||
(bounds[1] + bounds[3]) / 2,
|
||||
);
|
||||
|
||||
return [
|
||||
lineSegment(
|
||||
pointRotateRads(pointFrom(bounds[0], bounds[1]), center, element.angle),
|
||||
pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle),
|
||||
),
|
||||
lineSegment(
|
||||
pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle),
|
||||
pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle),
|
||||
),
|
||||
lineSegment(
|
||||
pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle),
|
||||
pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle),
|
||||
),
|
||||
lineSegment(
|
||||
pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle),
|
||||
pointRotateRads(pointFrom(bounds[0], bounds[1]), center, element.angle),
|
||||
),
|
||||
]
|
||||
.map((s) => segmentsIntersectAt(segment, s))
|
||||
.filter((i): i is Point => !!i);
|
||||
};
|
||||
|
||||
const distanceToEllipse = <Point extends LocalPoint | GlobalPoint>(
|
||||
p: Point,
|
||||
ellipse: Ellipse<Point>,
|
||||
) => {
|
||||
const { angle, halfWidth, halfHeight, center } = ellipse;
|
||||
const a = halfWidth;
|
||||
const b = halfHeight;
|
||||
const translatedPoint = vectorAdd(
|
||||
vectorFromPoint(p),
|
||||
vectorScale(vectorFromPoint(center), -1),
|
||||
);
|
||||
const [rotatedPointX, rotatedPointY] = pointRotateRads(
|
||||
pointFromVector(translatedPoint),
|
||||
pointFrom(0, 0),
|
||||
-angle as Radians,
|
||||
);
|
||||
|
||||
const px = Math.abs(rotatedPointX);
|
||||
const py = Math.abs(rotatedPointY);
|
||||
|
||||
let tx = 0.707;
|
||||
let ty = 0.707;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const x = a * tx;
|
||||
const y = b * ty;
|
||||
|
||||
const ex = ((a * a - b * b) * tx ** 3) / a;
|
||||
const ey = ((b * b - a * a) * ty ** 3) / b;
|
||||
|
||||
const rx = x - ex;
|
||||
const ry = y - ey;
|
||||
|
||||
const qx = px - ex;
|
||||
const qy = py - ey;
|
||||
|
||||
const r = Math.hypot(ry, rx);
|
||||
const q = Math.hypot(qy, qx);
|
||||
|
||||
tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
|
||||
ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
|
||||
const t = Math.hypot(ty, tx);
|
||||
tx /= t;
|
||||
ty /= t;
|
||||
}
|
||||
|
||||
const [minX, minY] = [
|
||||
a * tx * Math.sign(rotatedPointX),
|
||||
b * ty * Math.sign(rotatedPointY),
|
||||
];
|
||||
|
||||
return pointDistance(
|
||||
pointFrom(rotatedPointX, rotatedPointY),
|
||||
pointFrom(minX, minY),
|
||||
);
|
||||
};
|
||||
|
||||
export const pointOnEllipse = <Point extends LocalPoint | GlobalPoint>(
|
||||
point: Point,
|
||||
ellipse: Ellipse<Point>,
|
||||
threshold = PRECISION,
|
||||
) => {
|
||||
return distanceToEllipse(point, ellipse) <= threshold;
|
||||
};
|
||||
|
||||
export const pointInEllipse = <Point extends LocalPoint | GlobalPoint>(
|
||||
p: Point,
|
||||
ellipse: Ellipse<Point>,
|
||||
) => {
|
||||
const { center, angle, halfWidth, halfHeight } = ellipse;
|
||||
const translatedPoint = vectorAdd(
|
||||
vectorFromPoint(p),
|
||||
vectorScale(vectorFromPoint(center), -1),
|
||||
);
|
||||
const [rotatedPointX, rotatedPointY] = pointRotateRads(
|
||||
pointFromVector(translatedPoint),
|
||||
pointFrom(0, 0),
|
||||
-angle as Radians,
|
||||
);
|
||||
|
||||
return (
|
||||
(rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) +
|
||||
(rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <=
|
||||
1
|
||||
);
|
||||
};
|
||||
|
||||
export const ellipseAxes = <Point extends LocalPoint | GlobalPoint>(
|
||||
ellipse: Ellipse<Point>,
|
||||
) => {
|
||||
const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight;
|
||||
|
||||
const majorAxis = widthGreaterThanHeight
|
||||
? ellipse.halfWidth * 2
|
||||
: ellipse.halfHeight * 2;
|
||||
const minorAxis = widthGreaterThanHeight
|
||||
? ellipse.halfHeight * 2
|
||||
: ellipse.halfWidth * 2;
|
||||
|
||||
return {
|
||||
majorAxis,
|
||||
minorAxis,
|
||||
};
|
||||
};
|
||||
|
||||
export const ellipseFocusToCenter = <Point extends LocalPoint | GlobalPoint>(
|
||||
ellipse: Ellipse<Point>,
|
||||
) => {
|
||||
const { majorAxis, minorAxis } = ellipseAxes(ellipse);
|
||||
|
||||
return Math.sqrt(majorAxis ** 2 - minorAxis ** 2);
|
||||
};
|
||||
|
||||
export const ellipseExtremes = <Point extends LocalPoint | GlobalPoint>(
|
||||
ellipse: Ellipse<Point>,
|
||||
) => {
|
||||
const { center, angle } = ellipse;
|
||||
const { majorAxis, minorAxis } = ellipseAxes(ellipse);
|
||||
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
|
||||
const sqSum = majorAxis ** 2 + minorAxis ** 2;
|
||||
const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle);
|
||||
|
||||
const yMax = Math.sqrt((sqSum - sqDiff) / 2);
|
||||
const xAtYMax =
|
||||
(yMax * sqSum * sin * cos) /
|
||||
(majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2);
|
||||
|
||||
const xMax = Math.sqrt((sqSum + sqDiff) / 2);
|
||||
const yAtXMax =
|
||||
(xMax * sqSum * sin * cos) /
|
||||
(majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2);
|
||||
const centerVector = vectorFromPoint(center);
|
||||
|
||||
return [
|
||||
vectorAdd(vector(xAtYMax, yMax), centerVector),
|
||||
vectorAdd(vectorScale(vector(xAtYMax, yMax), -1), centerVector),
|
||||
vectorAdd(vector(xMax, yAtXMax), centerVector),
|
||||
vectorAdd(vector(xMax, yAtXMax), centerVector),
|
||||
];
|
||||
};
|
33
packages/utils/src/test-utils.ts
Normal file
33
packages/utils/src/test-utils.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { diffStringsUnified } from "jest-diff";
|
||||
|
||||
expect.extend({
|
||||
toCloselyEqualPoints(received, expected, precision) {
|
||||
if (!Array.isArray(received) || !Array.isArray(expected)) {
|
||||
throw new Error("expected and received are not point arrays");
|
||||
}
|
||||
|
||||
const COMPARE = 1 / 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,
|
||||
};
|
||||
},
|
||||
});
|
|
@ -1,24 +1,32 @@
|
|||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../excalidraw/element/types";
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
import { getElementBounds } from "@excalidraw/element/bounds";
|
||||
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";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import {
|
||||
rangeIncludesValue,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
rangeInclusive,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Bounds } from "@excalidraw/element/bounds";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
type Element = NonDeletedExcalidrawElement;
|
||||
type Elements = readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
type Points = readonly Point[];
|
||||
type Points = readonly LocalPoint[];
|
||||
|
||||
/** @returns vertices relative to element's top-left [0,0] position */
|
||||
const getNonLinearElementRelativePoints = (
|
||||
|
@ -26,20 +34,25 @@ const getNonLinearElementRelativePoints = (
|
|||
Element,
|
||||
ExcalidrawLinearElement | ExcalidrawFreeDrawElement
|
||||
>,
|
||||
): [TopLeft: Point, TopRight: Point, BottomRight: Point, BottomLeft: Point] => {
|
||||
): [
|
||||
TopLeft: LocalPoint,
|
||||
TopRight: LocalPoint,
|
||||
BottomRight: LocalPoint,
|
||||
BottomLeft: LocalPoint,
|
||||
] => {
|
||||
if (element.type === "diamond") {
|
||||
return [
|
||||
[element.width / 2, 0],
|
||||
[element.width, element.height / 2],
|
||||
[element.width / 2, element.height],
|
||||
[0, element.height / 2],
|
||||
pointFrom(element.width / 2, 0),
|
||||
pointFrom(element.width, element.height / 2),
|
||||
pointFrom(element.width / 2, element.height),
|
||||
pointFrom(0, element.height / 2),
|
||||
];
|
||||
}
|
||||
return [
|
||||
[0, 0],
|
||||
[0 + element.width, 0],
|
||||
[0 + element.width, element.height],
|
||||
[0, element.height],
|
||||
pointFrom(0, 0),
|
||||
pointFrom(0 + element.width, 0),
|
||||
pointFrom(0 + element.width, element.height),
|
||||
pointFrom(0, element.height),
|
||||
];
|
||||
};
|
||||
|
||||
|
@ -82,10 +95,10 @@ const getRotatedBBox = (element: Element): Bounds => {
|
|||
const points = getElementRelativePoints(element);
|
||||
|
||||
const { cx, cy } = getMinMaxPoints(points);
|
||||
const centerPoint: Point = [cx, cy];
|
||||
const centerPoint = pointFrom<LocalPoint>(cx, cy);
|
||||
|
||||
const rotatedPoints = points.map((point) =>
|
||||
rotatePoint([point[0], point[1]], centerPoint, element.angle),
|
||||
const rotatedPoints = points.map((p) =>
|
||||
pointRotateRads(p, centerPoint, element.angle),
|
||||
);
|
||||
const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints);
|
||||
|
||||
|
@ -133,10 +146,16 @@ export const elementPartiallyOverlapsWithOrContainsBBox = (
|
|||
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]))
|
||||
(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]),
|
||||
))
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -158,7 +177,7 @@ export const elementsOverlappingBBox = ({
|
|||
type: "overlap" | "contain" | "inside";
|
||||
}) => {
|
||||
if (isExcalidrawElement(bounds)) {
|
||||
bounds = getElementBounds(bounds);
|
||||
bounds = getElementBounds(bounds, arrayToMap(elements));
|
||||
}
|
||||
const adjustedBBox: Bounds = [
|
||||
bounds[0] - errorMargin,
|
|
@ -5,17 +5,21 @@ exports[`exportToSvg > with default arguments 1`] = `
|
|||
"activeEmbeddable": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
"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": 1,
|
||||
"currentItemFontFamily": 5,
|
||||
"currentItemFontSize": 20,
|
||||
"currentItemOpacity": 100,
|
||||
"currentItemRoughness": 1,
|
||||
|
@ -27,11 +31,10 @@ exports[`exportToSvg > with default arguments 1`] = `
|
|||
"currentItemTextAlign": "left",
|
||||
"cursorButton": "up",
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"draggingElement": null,
|
||||
"editingElement": null,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
|
@ -48,14 +51,19 @@ exports[`exportToSvg > with default arguments 1`] = `
|
|||
"outline": true,
|
||||
},
|
||||
"frameToHighlight": null,
|
||||
"gridSize": 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,
|
||||
|
@ -77,6 +85,7 @@ exports[`exportToSvg > with default arguments 1`] = `
|
|||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
|
@ -84,10 +93,13 @@ exports[`exportToSvg > with default arguments 1`] = `
|
|||
"selectionElement": null,
|
||||
"shouldCacheIgnoreZoom": false,
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": false,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
90
packages/utils/tests/collision.test.ts
Normal file
90
packages/utils/tests/collision.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,9 +1,9 @@
|
|||
import * as utils from ".";
|
||||
import { diagramFactory } from "../excalidraw/tests/fixtures/diagramFixture";
|
||||
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 mockedSceneExportUtils from "../excalidraw/scene/export";
|
||||
|
||||
import { MIME_TYPES } from "../excalidraw/constants";
|
||||
import * as utils from "../src";
|
||||
|
||||
const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
|
||||
|
||||
|
@ -32,7 +32,6 @@ describe("exportToCanvas", async () => {
|
|||
|
||||
describe("exportToBlob", async () => {
|
||||
describe("mime type", () => {
|
||||
// afterEach(vi.restoreAllMocks);
|
||||
it("should change image/jpg to image/jpeg", async () => {
|
||||
const blob = await utils.exportToBlob({
|
||||
...diagramFactory(),
|
163
packages/utils/tests/geometry.test.ts
Normal file
163
packages/utils/tests/geometry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -1,7 +1,10 @@
|
|||
import { decodePngMetadata, decodeSvgMetadata } from "../excalidraw/data/image";
|
||||
import { ImportedDataState } from "../excalidraw/data/types";
|
||||
import * as utils from "../utils";
|
||||
import { API } from "../excalidraw/tests/helpers/api";
|
||||
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
|
||||
|
@ -19,7 +22,7 @@ describe("embedding scene data", () => {
|
|||
elements: sourceElements,
|
||||
appState: {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
gridSize: null,
|
||||
gridModeEnabled: false,
|
||||
exportEmbedScene: true,
|
||||
},
|
||||
files: null,
|
||||
|
@ -27,7 +30,7 @@ describe("embedding scene data", () => {
|
|||
|
||||
const svg = svgNode.outerHTML;
|
||||
|
||||
const parsedString = await decodeSvgMetadata({ svg });
|
||||
const parsedString = decodeSvgBase64Payload({ svg });
|
||||
const importedData: ImportedDataState = JSON.parse(parsedString);
|
||||
|
||||
expect(sourceElements.map((x) => x.id)).toEqual(
|
||||
|
@ -50,7 +53,7 @@ describe("embedding scene data", () => {
|
|||
elements: sourceElements,
|
||||
appState: {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
gridSize: null,
|
||||
gridModeEnabled: false,
|
||||
exportEmbedScene: true,
|
||||
},
|
||||
files: null,
|
|
@ -1,10 +1,12 @@
|
|||
import { Bounds } from "../excalidraw/element/bounds";
|
||||
import { API } from "../excalidraw/tests/helpers/api";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import type { Bounds } from "@excalidraw/element/bounds";
|
||||
|
||||
import {
|
||||
elementPartiallyOverlapsWithOrContainsBBox,
|
||||
elementsOverlappingBBox,
|
||||
isElementInsideBBox,
|
||||
} from "./withinBounds";
|
||||
} from "../src/withinBounds";
|
||||
|
||||
const makeElement = (x: number, y: number, width: number, height: number) =>
|
||||
API.createElement({
|
8
packages/utils/tsconfig.json
Normal file
8
packages/utils/tsconfig.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
const webpack = require("webpack");
|
||||
const path = require("path");
|
||||
const BundleAnalyzerPlugin =
|
||||
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
|
||||
|
||||
module.exports = {
|
||||
mode: "production",
|
||||
entry: { "excalidraw-utils.min": "./index.js" },
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
filename: "[name].js",
|
||||
library: "ExcalidrawUtils",
|
||||
libraryTarget: "umd",
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".tsx", ".ts", ".js", ".css", ".scss"],
|
||||
},
|
||||
optimization: {
|
||||
runtimeChunk: false,
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(sa|sc|c)ss$/,
|
||||
exclude: /node_modules/,
|
||||
use: ["style-loader", { loader: "css-loader" }, "sass-loader"],
|
||||
},
|
||||
{
|
||||
test: /\.(ts|tsx|js)$/,
|
||||
use: [
|
||||
{
|
||||
loader: "ts-loader",
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
configFile: path.resolve(__dirname, "../tsconfig.prod.json"),
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: "babel-loader",
|
||||
|
||||
options: {
|
||||
presets: [
|
||||
"@babel/preset-env",
|
||||
["@babel/preset-react", { runtime: "automatic" }],
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
plugins: [["@babel/plugin-transform-runtime"]],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new webpack.optimize.LimitChunkCountPlugin({
|
||||
maxChunks: 1,
|
||||
}),
|
||||
...(process.env.ANALYZER === "true" ? [new BundleAnalyzerPlugin()] : []),
|
||||
],
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue