Bounds refactor and duplication removal

This commit is contained in:
Mark Tolmacs 2024-09-27 15:58:18 +02:00
parent 7b4e989d65
commit 91b6057d9c
No known key found for this signature in database
28 changed files with 431 additions and 147 deletions

View file

@ -10,7 +10,7 @@ import {
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
import { restore } from "../../packages/excalidraw/data/restore";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type { SceneBounds } from "../../packages/excalidraw/element/bounds";
import type { ViewportBounds } from "../../packages/excalidraw/element/bounds";
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
import type {
@ -104,7 +104,7 @@ export type SocketUpdateDataSource = {
payload: {
socketId: SocketId;
username: string;
sceneBounds: SceneBounds;
sceneBounds: ViewportBounds;
};
};
IDLE_STATUS: {

View file

@ -35,7 +35,7 @@ import {
isHandToolActive,
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import type { SceneBounds } from "../element/bounds";
import type { ViewportBounds } from "../element/bounds";
import { setCursor } from "../cursor";
import { StoreAction } from "../store";
import { clamp, point, roundToStep } from "../../math";
@ -245,7 +245,7 @@ export const actionResetZoom = register({
});
const zoomValueToFitBoundsOnViewport = (
bounds: SceneBounds,
bounds: ViewportBounds,
viewportDimensions: { width: number; height: number },
viewportZoomFactor: number = 1, // default to 1 if not provided
) => {
@ -271,7 +271,7 @@ export const zoomToFitBounds = ({
minZoom = -Infinity,
maxZoom = Infinity,
}: {
bounds: SceneBounds;
bounds: ViewportBounds;
canvasOffsets?: Offsets;
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */

View file

@ -1,6 +1,6 @@
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import type { Bounds, ElementsMap, ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement";
import { getCommonBounds, type Bounds } from "./element/bounds";
import { getCommonBounds } from "./element/bounds";
import { getMaximumGroups } from "./groups";
export interface Alignment {

View file

@ -1,10 +1,10 @@
import type { GlobalPoint, Radians } from "../../../math";
import { point, pointRotateRads } from "../../../math";
import { MIME_TYPES } from "../../constants";
import type { Bounds } from "../../element/bounds";
import { getElementAbsoluteCoords } from "../../element/bounds";
import { hitElementBoundingBox } from "../../element/collision";
import type {
Bounds,
ElementsMap,
NonDeletedExcalidrawElement,
} from "../../element/types";

View file

@ -1,5 +1,236 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 1`] = `
{
"angle": 0,
"backgroundColor": "#d8f5a2",
"boundElements": [
{
"id": "id45",
"type": "arrow",
},
{
"id": "id46",
"type": "arrow",
},
],
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 300,
"id": Any<String>,
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"strokeColor": "#66a80f",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "ellipse",
"updated": 1,
"version": 4,
"versionNonce": Any<Number>,
"width": 300,
"x": 630,
"y": 316,
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 2`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id46",
"type": "arrow",
},
],
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 100,
"id": Any<String>,
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"strokeColor": "#9c36b5",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "diamond",
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"width": 140,
"x": 96,
"y": 400,
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 3`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"fixedPoint": null,
"focus": -0.008153707962747813,
"gap": 11.562288374879595,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 35,
"id": Any<String>,
"index": "a2",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
"points": [
[
0.5,
0.5,
],
[
394.5,
34.5,
],
],
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
"elementId": "id47",
"fixedPoint": null,
"focus": -0.08139534883720931,
"gap": 1,
},
"strokeColor": "#1864ab",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"versionNonce": Any<Number>,
"width": 395,
"x": 247,
"y": 420,
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 4`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"fixedPoint": null,
"focus": 0.10666666666666667,
"gap": 3.8343264684446097,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"id": Any<String>,
"index": "a3",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
"points": [
[
0.5,
0,
],
[
399.5,
0,
],
],
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
"elementId": "diamond-1",
"fixedPoint": null,
"focus": 0,
"gap": 5.2311437434718675,
},
"strokeColor": "#e67700",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"versionNonce": Any<Number>,
"width": 400,
"x": 227,
"y": 450,
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 5`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id45",
"type": "arrow",
},
],
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 300,
"id": Any<String>,
"index": "a4",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"width": 300,
"x": -53,
"y": 270,
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = `
{
"angle": 0,

View file

@ -25,9 +25,9 @@ import type {
ExcalidrawElbowArrowElement,
FixedPoint,
SceneElementsMap,
Bounds,
} from "./types";
import type { Bounds } from "./bounds";
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
import type { AppState } from "../types";
import { isPointOnShape } from "../../utils/collision";

View file

@ -4,6 +4,7 @@ import type {
ExcalidrawFreeDrawElement,
ExcalidrawTextElementWithContainer,
ElementsMap,
Bounds,
} from "./types";
import rough from "roughjs/bin/rough";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
@ -34,17 +35,7 @@ import { getCurvePathOps } from "../../utils/geometry/shape";
type MaybeQuadraticSolution = [number | null, number | null] | false;
/**
* x and y position of top left corner, x and y position of bottom right corner
*/
export type Bounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
];
export type SceneBounds = readonly [
export type ViewportBounds = readonly [
sceneX: number,
sceneY: number,
sceneX2: number,
@ -57,6 +48,7 @@ class ElementBounds {
{
bounds: Bounds;
version: ExcalidrawElement["version"];
versionNonce: ExcalidrawElement["versionNonce"];
}
>();
@ -66,6 +58,7 @@ class ElementBounds {
if (
cachedBounds?.version &&
cachedBounds.version === element.version &&
cachedBounds?.versionNonce === element.versionNonce &&
// we don't invalidate cache when we update containers and not labels,
// which is causing problems down the line. Fix TBA.
!isBoundToContainer(element)
@ -76,6 +69,7 @@ class ElementBounds {
ElementBounds.boundsCache.set(element, {
version: element.version,
versionNonce: element.versionNonce,
bounds,
});
@ -93,7 +87,7 @@ class ElementBounds {
elementsMap,
);
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
const [minX, minY, maxX, maxY] = getBoundsFromFreeDrawPoints(
element.points.map(([x, y]) =>
pointRotateRads(
point(x, y),
@ -177,6 +171,20 @@ class ElementBounds {
}
}
/**
* Get the axis-aligned bounds of the given element in global / scene coordinates
*
* @param element The element to determine the bounding box for
* @param elementsMap The elements map to retrieve attached elements (notably text label)
* @returns The axis-aligned bounding box in scene (global coordinates)
*/
export const getElementBounds = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): Bounds => {
return ElementBounds.getBounds(element, elementsMap);
};
// Scene -> Scene coords, but in x1,x2,y1,y2 format.
//
// If the element is created from right to left, the width is going to be negative
@ -224,21 +232,6 @@ export const getElementAbsoluteCoords = (
];
};
export const getDiamondPoints = (element: ExcalidrawElement) => {
// Here we add +1 to avoid these numbers to be 0
// otherwise rough.js will throw an error complaining about it
const topX = Math.floor(element.width / 2) + 1;
const topY = 0;
const rightX = element.width;
const rightY = Math.floor(element.height / 2) + 1;
const bottomX = topX;
const bottomY = element.height;
const leftX = 0;
const leftY = rightY;
return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
};
// reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
const getBezierValueForT = (
t: number,
@ -382,7 +375,7 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY];
};
export const getBoundsFromPoints = (
const getBoundsFromFreeDrawPoints = (
points: ExcalidrawFreeDrawElement["points"],
): Bounds => {
let minX = Infinity;
@ -403,7 +396,7 @@ export const getBoundsFromPoints = (
const getFreeDrawElementAbsoluteCoords = (
element: ExcalidrawFreeDrawElement,
): [number, number, number, number, number, number] => {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
const [minX, minY, maxX, maxY] = getBoundsFromFreeDrawPoints(element.points);
const x1 = minX + element.x;
const y1 = minY + element.y;
const x2 = maxX + element.x;
@ -496,13 +489,6 @@ const getLinearElementRotatedBounds = (
return coords;
};
export const getElementBounds = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): Bounds => {
return ElementBounds.getBounds(element, elementsMap);
};
export const getCommonBounds = (
elements: readonly ExcalidrawElement[],
elementsMap?: ElementsMap,
@ -568,7 +554,7 @@ export const getResizedElementAbsoluteCoords = (
if (isFreeDrawElement(element)) {
// Free Draw
bounds = getBoundsFromPoints(points);
bounds = getBoundsFromFreeDrawPoints(points);
} else {
// Line
const gen = rough.generator();
@ -651,7 +637,7 @@ export const getVisibleSceneBounds = ({
width,
height,
zoom,
}: AppState): SceneBounds => {
}: AppState): ViewportBounds => {
return [
-scrollX,
-scrollY,

View file

@ -40,31 +40,33 @@ export const distanceToBindableElement = (
}
};
/**
* Returns the distance of a point and the provided rectangular-shaped element,
* accounting for roundness and rotation
*
* @param element The rectanguloid element
* @param p The point to consider
* @returns The eucledian distance to the outline of the rectanguloid element
*/
export const distanceToRectangleElement = (
element: ExcalidrawRectanguloidElement,
p: GlobalPoint,
) => {
const center = point(
element.x + element.width / 2,
element.y + element.height / 2,
);
const r = rectangle(
pointRotateRads(
point(element.x, element.y),
center,
radians(element.angle),
),
pointRotateRads(
point(element.x + element.width, element.y + element.height),
center,
radians(element.angle),
),
point(element.x, element.y),
point(element.x + element.width, element.y + element.height),
);
// To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(
p,
point(element.x + element.width / 2, element.y + element.height / 2),
radians(-element.angle),
);
const roundness = getCornerRadius(
Math.min(element.width, element.height),
element,
);
const rotatedPoint = pointRotateRads(p, center, element.angle);
const sideDistances = [
segment(
point(r[0][0] + roundness, r[0][1]),
@ -116,10 +118,22 @@ export const distanceToRectangleElement = (
return Math.min(...[...sideDistances, ...cornerDistances]);
};
const roundedCutoffSegment = (
/**
* Shortens a segment on both ends to accomodate the arc in the rounded
* diamond shape
*
* @param s The segment to shorten
* @param r The radius to shorten by
* @returns The segment shortened on both ends by the same radius
*/
const createDiamondSide = (
s: Segment<GlobalPoint>,
r: number,
): Segment<GlobalPoint> => {
if (r === 0) {
return s;
}
const t = (4 * r) / Math.sqrt(2);
return segment(
@ -128,17 +142,36 @@ const roundedCutoffSegment = (
);
};
const diamondArc = (left: GlobalPoint, right: GlobalPoint, r: number) => {
const c = point((left[0] + right[0]) / 2, left[1]);
/**
* Creates an arc for the given roundness and position by taking the start
* and end positions and determining the angle points on the hypotethical
* circle with center point between start and end and raidus equals provided
* roundness. I.e. the created arc is gobal point-aware, or "rotated" in-place.
*
* @param start
* @param end
* @param r
* @returns
*/
const createDiamondArc = (start: GlobalPoint, end: GlobalPoint, r: number) => {
const c = point((start[0] + end[0]) / 2, start[1]);
return arc(
c,
r,
radians(Math.asin((left[1] - c[1]) / r)),
radians(Math.asin((right[1] - c[1]) / r)),
radians(Math.asin((start[1] - c[1]) / r)),
radians(Math.asin((end[1] - c[1]) / r)),
);
};
/**
* Returns the distance of a point and the provided diamond element, accounting
* for roundness and rotation
*
* @param element The diamond element
* @param p The point to consider
* @returns The eucledian distance to the outline of the diamond
*/
export const distanceToDiamondElement = (
element: ExcalidrawDiamondElement,
p: GlobalPoint,
@ -151,31 +184,19 @@ export const distanceToDiamondElement = (
Math.min(element.width, element.height),
element,
);
const rotatedPoint = pointRotateRads(p, center, element.angle);
const top = pointRotateRads<GlobalPoint>(
// Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, radians(-element.angle));
const [top, right, bottom, left]: GlobalPoint[] = [
point(element.x + element.width / 2, element.y),
center,
element.angle,
);
const right = pointRotateRads<GlobalPoint>(
point(element.x + element.width, element.y + element.height / 2),
center,
element.angle,
);
const bottom = pointRotateRads<GlobalPoint>(
point(element.x + element.width / 2, element.y + element.height),
center,
element.angle,
);
const left = pointRotateRads<GlobalPoint>(
point(element.x, element.y + element.height / 2),
center,
element.angle,
);
const topRight = roundedCutoffSegment(segment(top, right), roundness);
const bottomRight = roundedCutoffSegment(segment(right, bottom), roundness);
const bottomLeft = roundedCutoffSegment(segment(bottom, left), roundness);
const topLeft = roundedCutoffSegment(segment(left, top), roundness);
];
const topRight = createDiamondSide(segment(top, right), roundness);
const bottomRight = createDiamondSide(segment(right, bottom), roundness);
const bottomLeft = createDiamondSide(segment(bottom, left), roundness);
const topLeft = createDiamondSide(segment(left, top), roundness);
return Math.min(
...[
@ -184,16 +205,24 @@ export const distanceToDiamondElement = (
),
...(roundness > 0
? [
diamondArc(topLeft[1], topRight[0], roundness),
diamondArc(topRight[1], bottomRight[0], roundness),
diamondArc(bottomRight[1], bottomLeft[0], roundness),
diamondArc(bottomLeft[1], topLeft[0], roundness),
createDiamondArc(topLeft[1], topRight[0], roundness),
createDiamondArc(topRight[1], bottomRight[0], roundness),
createDiamondArc(bottomRight[1], bottomLeft[0], roundness),
createDiamondArc(bottomLeft[1], topLeft[0], roundness),
].map((a) => arcDistanceFromPoint(a, rotatedPoint))
: []),
],
);
};
/**
* Returns the distance of a point and the provided ellipse element, accounting
* for roundness and rotation
*
* @param element The ellipse element
* @param p The point to consider
* @returns The eucledian distance to the outline of the ellipse
*/
export const distanceToEllipseElement = (
element: ExcalidrawEllipseElement,
p: GlobalPoint,
@ -203,6 +232,7 @@ export const distanceToEllipseElement = (
element.y + element.height / 2,
);
return ellipseDistanceFromPoint(
// Instead of rotating the ellipse, rotate the point to the inverse angle
pointRotateRads(p, center, radians(-element.angle)),
ellipse(center, element.width / 2, element.height / 2),
);

View file

@ -1,9 +1,8 @@
import { updateBoundElements } from "./binding";
import type { Bounds } from "./bounds";
import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import type { NonDeletedExcalidrawElement } from "./types";
import type { Bounds, NonDeletedExcalidrawElement } from "./types";
import type {
AppState,
NormalizedZoomValue,

View file

@ -13,8 +13,8 @@ import {
radiansToDegrees,
triangleIncludesPoint,
} from "../../math";
import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
import { getCenterForBounds } from "./bounds";
import type { Bounds, ExcalidrawBindableElement } from "./types";
export const HEADING_RIGHT = [1, 0] as Heading;
export const HEADING_DOWN = [0, 1] as Heading;

View file

@ -19,9 +19,9 @@ export {
getElementAbsoluteCoords,
getElementBounds,
getCommonBounds,
getDiamondPoints,
getClosestElementBounds,
} from "./bounds";
export { getDiamondPoints } from "../scene/Shape";
export {
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,

View file

@ -10,9 +10,9 @@ import type {
OrderedExcalidrawElement,
FixedPointBinding,
SceneElementsMap,
Bounds,
} from "./types";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import type { Bounds } from "./bounds";
import { getElementPointsCoords, getMinMaxXYFromCurvePathOps } from "./bounds";
import type {
AppState,

View file

@ -3,6 +3,7 @@ import type {
PointerType,
NonDeletedExcalidrawElement,
ElementsMap,
Bounds,
} from "./types";
import type {
@ -17,7 +18,6 @@ import {
canResizeFromSides,
} from "./transformHandles";
import type { AppState, Device, Zoom } from "../types";
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants";
import { isLinearElement } from "./typeChecks";

View file

@ -24,7 +24,6 @@ import {
getGlobalFixedPointForBindableElement,
snapToMid,
} from "./binding";
import type { Bounds } from "./bounds";
import { distanceToBindableElement } from "./distance";
import type { Heading } from "./heading";
import {
@ -40,6 +39,7 @@ import type { ElementUpdate } from "./mutateElement";
import { mutateElement } from "./mutateElement";
import { isBindableElement, isRectanguloidElement } from "./typeChecks";
import type {
Bounds,
ExcalidrawElbowArrowElement,
NonDeletedSceneElementsMap,
SceneElementsMap,

View file

@ -1,11 +1,11 @@
import type {
Bounds,
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
PointerType,
} from "./types";
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import {

View file

@ -2,7 +2,6 @@ import { ROUNDNESS } from "../constants";
import type { ElementOrToolType } from "../types";
import type { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils";
import type { Bounds } from "./bounds";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
@ -26,6 +25,7 @@ import type {
PointBinding,
FixedPointBinding,
ExcalidrawFlowchartNodeElement,
Bounds,
} from "./types";
export const isInitializedImageElement = (

View file

@ -373,3 +373,13 @@ export type NonDeletedSceneElementsMap = Map<
export type ElementsMapOrArray =
| readonly ExcalidrawElement[]
| Readonly<ElementsMap>;
/**
* Axis-aligned bounding box (i.e. no rotation)
*/
export type Bounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
];

View file

@ -25,7 +25,7 @@ import type {
import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
import type { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
import { elementsOverlappingBBox } from "../utils/";
import { elementsOverlappingBounds } from "../utils/";
import {
isFrameElement,
isFrameLikeElement,
@ -863,7 +863,7 @@ export const getElementsOverlappingFrame = (
frame: ExcalidrawFrameLikeElement,
) => {
return (
elementsOverlappingBBox({
elementsOverlappingBounds({
elements,
bounds: frame,
type: "overlap",

View file

@ -284,9 +284,9 @@ export { convertToExcalidrawElements } from "./data/transform";
export { getCommonBounds, getVisibleSceneBounds } from "./element/bounds";
export {
elementsOverlappingBBox,
elementsOverlappingBounds as elementsOverlappingBBox,
isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox,
elementPartiallyOverlapsWithOrContainsBounds as elementPartiallyOverlapsWithOrContainsBBox,
} from "../utils/withinBounds";
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";

View file

@ -1,7 +1,7 @@
import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import { getArrowheadPoints, getDiamondPoints } from "../element";
import { getArrowheadPoints } from "../element";
import type { ElementShapes } from "./types";
import type {
ExcalidrawElement,
@ -278,6 +278,21 @@ const getArrowheadShapes = (
}
};
export const getDiamondPoints = (element: ExcalidrawElement) => {
// Here we add +1 to avoid these numbers to be 0
// otherwise rough.js will throw an error complaining about it
const topX = Math.floor(element.width / 2) + 1;
const topY = 0;
const rightX = element.width;
const rightY = Math.floor(element.height / 2) + 1;
const bottomX = topX;
const bottomY = element.height;
const leftX = 0;
const leftY = rightY;
return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
};
/**
* Generates the roughjs shape for given element.
*

View file

@ -1,12 +1,12 @@
import rough from "roughjs/bin/rough";
import type {
Bounds,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../element/types";
import type { Bounds } from "../element/bounds";
import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
import { renderSceneToSvg } from "../renderer/staticSvgScene";
import { arrayToMap, getFontString, toBrandedType } from "../utils";

View file

@ -36,11 +36,11 @@ import {
ROUNDNESS,
} from "./constants";
import { getElementAbsoluteCoords } from "./element";
import type { Bounds } from "./element/bounds";
import { shouldTestInside } from "./element/collision";
import { LinearElementEditor } from "./element/linearElementEditor";
import { getBoundTextElement } from "./element/textElement";
import type {
Bounds,
ElementsMap,
ExcalidrawElement,
ExcalidrawLinearElement,

View file

@ -8,7 +8,6 @@ import {
type GlobalPoint,
} from "../math";
import { TOOL_TYPE } from "./constants";
import type { Bounds } from "./element/bounds";
import {
getCommonBounds,
getDraggedElementsBounds,
@ -17,6 +16,7 @@ import {
import type { MaybeTransformHandleType } from "./element/transformHandles";
import { isBoundToContainer, isFrameLikeElement } from "./element/typeChecks";
import type {
Bounds,
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,

View file

@ -4,11 +4,11 @@ import { render } from "./test-utils";
import { reseed } from "../random";
import { UI, Keyboard, Pointer } from "./helpers/ui";
import type {
Bounds,
ExcalidrawElbowArrowElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
} from "../element/types";
import type { Bounds } from "../element/bounds";
import { getElementPointsCoords } from "../element/bounds";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
@ -895,7 +895,7 @@ describe("multiple selection", () => {
expect(rightBoundArrow.height).toBeCloseTo(0);
expect(rightBoundArrow.angle).toEqual(0);
expect(rightBoundArrow.startBinding).toBeNull();
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(7.0952);
expect(rightBoundArrow.endBinding?.elementId).toBe(
rightArrowBinding.elementId,
);

View file

@ -1,7 +1,7 @@
import type { Segment } from "../math";
import { isSegment, segment, point, type GlobalPoint } from "../math";
import type { Bounds } from "./element/bounds";
import { isBounds } from "./element/typeChecks";
import type { Bounds } from "./element/types";
// The global data holder to collect the debug operations
declare global {

View file

@ -102,6 +102,22 @@ export function pointRotateRads<Point extends GenericPoint>(
);
}
/**
* Rotate multiple points around a common center via the same angle in radians
*
* @param p The point array to rotate
* @param c The common center point
* @param angle The common angle to rotate by
* @returns The array of rotated points
*/
function pointsRotateRads<Point extends GenericPoint>(
p: Point[],
c: Point,
angle: Radians,
): Point[] {
return p.map((x, idx) => pointRotateRads(x, c, angle));
}
/**
* Roate a point by [angle] degree.
*

View file

@ -1,8 +1,8 @@
import type { Bounds } from "../excalidraw/element/bounds";
import type { Bounds } from "../excalidraw/element/types";
import { API } from "../excalidraw/tests/helpers/api";
import {
elementPartiallyOverlapsWithOrContainsBBox,
elementsOverlappingBBox,
elementPartiallyOverlapsWithOrContainsBounds,
elementsOverlappingBounds,
isElementInsideBBox,
} from "./withinBounds";
@ -99,13 +99,13 @@ describe("elementPartiallyOverlapsWithOrContainsBBox()", () => {
// bbox contains element
expect(
elementPartiallyOverlapsWithOrContainsBBox(
elementPartiallyOverlapsWithOrContainsBounds(
makeElement(0, 0, 100, 100),
bbox,
),
).toBe(true);
expect(
elementPartiallyOverlapsWithOrContainsBBox(
elementPartiallyOverlapsWithOrContainsBounds(
makeElement(10, 10, 90, 90),
bbox,
),
@ -113,7 +113,7 @@ describe("elementPartiallyOverlapsWithOrContainsBBox()", () => {
// element contains bbox
expect(
elementPartiallyOverlapsWithOrContainsBBox(
elementPartiallyOverlapsWithOrContainsBounds(
makeElement(-10, -10, 110, 110),
bbox,
),
@ -121,28 +121,28 @@ describe("elementPartiallyOverlapsWithOrContainsBBox()", () => {
// element overlaps bbox from top-left
expect(
elementPartiallyOverlapsWithOrContainsBBox(
elementPartiallyOverlapsWithOrContainsBounds(
makeElement(-10, -10, 100, 100),
bbox,
),
).toBe(true);
// element overlaps bbox from top-right
expect(
elementPartiallyOverlapsWithOrContainsBBox(
elementPartiallyOverlapsWithOrContainsBounds(
makeElement(90, -10, 100, 100),
bbox,
),
).toBe(true);
// element overlaps bbox from bottom-left
expect(
elementPartiallyOverlapsWithOrContainsBBox(
elementPartiallyOverlapsWithOrContainsBounds(
makeElement(-10, 90, 100, 100),
bbox,
),
).toBe(true);
// element overlaps bbox from bottom-right
expect(
elementPartiallyOverlapsWithOrContainsBBox(
elementPartiallyOverlapsWithOrContainsBounds(
makeElement(90, 90, 100, 100),
bbox,
),
@ -154,7 +154,7 @@ describe("elementPartiallyOverlapsWithOrContainsBBox()", () => {
// outside diagonally
expect(
elementPartiallyOverlapsWithOrContainsBBox(
elementPartiallyOverlapsWithOrContainsBounds(
makeElement(110, 110, 100, 100),
bbox,
),
@ -162,28 +162,28 @@ describe("elementPartiallyOverlapsWithOrContainsBBox()", () => {
// outside on the left
expect(
elementPartiallyOverlapsWithOrContainsBBox(
elementPartiallyOverlapsWithOrContainsBounds(
makeElement(-110, 10, 50, 50),
bbox,
),
).toBe(false);
// outside on the right
expect(
elementPartiallyOverlapsWithOrContainsBBox(
elementPartiallyOverlapsWithOrContainsBounds(
makeElement(110, 10, 50, 50),
bbox,
),
).toBe(false);
// outside on the top
expect(
elementPartiallyOverlapsWithOrContainsBBox(
elementPartiallyOverlapsWithOrContainsBounds(
makeElement(10, -110, 50, 50),
bbox,
),
).toBe(false);
// outside on the bottom
expect(
elementPartiallyOverlapsWithOrContainsBBox(
elementPartiallyOverlapsWithOrContainsBounds(
makeElement(10, 110, 50, 50),
bbox,
),
@ -201,7 +201,7 @@ describe("elementsOverlappingBBox()", () => {
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
elementsOverlappingBounds({
bounds: bbox,
type: "overlap",
elements: [
@ -223,7 +223,7 @@ describe("elementsOverlappingBBox()", () => {
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
elementsOverlappingBounds({
bounds: bbox,
type: "contain",
elements: [
@ -245,7 +245,7 @@ describe("elementsOverlappingBBox()", () => {
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
elementsOverlappingBounds({
bounds: bbox,
type: "inside",
elements: [

View file

@ -1,4 +1,5 @@
import type {
Bounds,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
@ -11,7 +12,6 @@ import {
isLinearElement,
isTextElement,
} from "../excalidraw/element/typeChecks";
import type { Bounds } from "../excalidraw/element/bounds";
import { getElementBounds } from "../excalidraw/element/bounds";
import { arrayToMap } from "../excalidraw/utils";
import type { LocalPoint } from "../math";
@ -22,15 +22,10 @@ import {
rangeInclusive,
} from "../math";
type Element = NonDeletedExcalidrawElement;
type Elements = readonly NonDeletedExcalidrawElement[];
type Points = readonly LocalPoint[];
/** @returns vertices relative to element's top-left [0,0] position */
const getNonLinearElementRelativePoints = (
element: Exclude<
Element,
NonDeletedExcalidrawElement,
ExcalidrawLinearElement | ExcalidrawFreeDrawElement
>,
): [
@ -56,14 +51,16 @@ const getNonLinearElementRelativePoints = (
};
/** @returns vertices relative to element's top-left [0,0] position */
const getElementRelativePoints = (element: ExcalidrawElement): Points => {
const getElementRelativePoints = (
element: ExcalidrawElement,
): readonly LocalPoint[] => {
if (isLinearElement(element) || isFreeDrawElement(element)) {
return element.points;
}
return getNonLinearElementRelativePoints(element);
};
const getMinMaxPoints = (points: Points) => {
const getMinMaxPoints = (points: readonly LocalPoint[]) => {
const ret = points.reduce(
(limits, [x, y]) => {
limits.minY = Math.min(limits.minY, y);
@ -90,7 +87,7 @@ const getMinMaxPoints = (points: Points) => {
return ret;
};
const getRotatedBBox = (element: Element): Bounds => {
const getRotatedBBox = (element: NonDeletedExcalidrawElement): Bounds => {
const points = getElementRelativePoints(element);
const { cx, cy } = getMinMaxPoints(points);
@ -110,7 +107,7 @@ const getRotatedBBox = (element: Element): Bounds => {
};
export const isElementInsideBBox = (
element: Element,
element: NonDeletedExcalidrawElement,
bbox: Bounds,
eitherDirection = false,
): boolean => {
@ -138,8 +135,8 @@ export const isElementInsideBBox = (
);
};
export const elementPartiallyOverlapsWithOrContainsBBox = (
element: Element,
export const elementPartiallyOverlapsWithOrContainsBounds = (
element: NonDeletedExcalidrawElement,
bbox: Bounds,
): boolean => {
const elementBBox = getRotatedBBox(element);
@ -158,13 +155,13 @@ export const elementPartiallyOverlapsWithOrContainsBBox = (
);
};
export const elementsOverlappingBBox = ({
export const elementsOverlappingBounds = ({
elements,
bounds,
type,
errorMargin = 0,
}: {
elements: Elements;
elements: readonly NonDeletedExcalidrawElement[];
bounds: Bounds | ExcalidrawElement;
/** safety offset. Defaults to 0. */
errorMargin?: number;
@ -194,7 +191,7 @@ export const elementsOverlappingBBox = ({
const isOverlaping =
type === "overlap"
? elementPartiallyOverlapsWithOrContainsBBox(element, adjustedBBox)
? elementPartiallyOverlapsWithOrContainsBounds(element, adjustedBBox)
: type === "inside"
? isElementInsideBBox(element, adjustedBBox)
: isElementInsideBBox(element, adjustedBBox, true);