From 91b6057d9ce81cda97ed5913af82d3b996a8874c Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 27 Sep 2024 15:58:18 +0200 Subject: [PATCH] Bounds refactor and duplication removal --- excalidraw-app/data/index.ts | 4 +- packages/excalidraw/actions/actionCanvas.tsx | 6 +- packages/excalidraw/align.ts | 4 +- .../components/hyperlink/helpers.ts | 2 +- .../data/__snapshots__/transform.test.ts.snap | 231 ++++++++++++++++++ packages/excalidraw/element/binding.ts | 2 +- packages/excalidraw/element/bounds.ts | 62 ++--- packages/excalidraw/element/distance.ts | 120 +++++---- packages/excalidraw/element/dragElements.ts | 3 +- packages/excalidraw/element/heading.ts | 4 +- packages/excalidraw/element/index.ts | 2 +- .../excalidraw/element/linearElementEditor.ts | 2 +- packages/excalidraw/element/resizeTest.ts | 2 +- packages/excalidraw/element/routing.ts | 2 +- .../excalidraw/element/transformHandles.ts | 2 +- packages/excalidraw/element/typeChecks.ts | 2 +- packages/excalidraw/element/types.ts | 10 + packages/excalidraw/frame.ts | 4 +- packages/excalidraw/index.tsx | 4 +- packages/excalidraw/scene/Shape.ts | 17 +- packages/excalidraw/scene/export.ts | 2 +- packages/excalidraw/shapes.tsx | 2 +- packages/excalidraw/snapping.ts | 2 +- packages/excalidraw/tests/resize.test.tsx | 4 +- packages/excalidraw/visualdebug.ts | 2 +- packages/math/point.ts | 16 ++ packages/utils/withinBounds.test.ts | 36 +-- packages/utils/withinBounds.ts | 29 +-- 28 files changed, 431 insertions(+), 147 deletions(-) diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index ba7df82b29..00a35f8e2b 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -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: { diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index ef149d6d86..63dddc97c6 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -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; /** whether to fit content to viewport (beyond >100%) */ diff --git a/packages/excalidraw/align.ts b/packages/excalidraw/align.ts index 3bf866d10f..685513ae50 100644 --- a/packages/excalidraw/align.ts +++ b/packages/excalidraw/align.ts @@ -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 { diff --git a/packages/excalidraw/components/hyperlink/helpers.ts b/packages/excalidraw/components/hyperlink/helpers.ts index efa5862f0c..e0d8d681bf 100644 --- a/packages/excalidraw/components/hyperlink/helpers.ts +++ b/packages/excalidraw/components/hyperlink/helpers.ts @@ -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"; diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index 161e23ac5d..77dfba211a 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -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, + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any, + "strokeColor": "#66a80f", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "ellipse", + "updated": 1, + "version": 4, + "versionNonce": Any, + "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, + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any, + "strokeColor": "#9c36b5", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "diamond", + "updated": 1, + "version": 3, + "versionNonce": Any, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "index": "a4", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 3, + "versionNonce": Any, + "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, diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 407de4ac31..8d3057c8e3 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -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"; diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index b39f965f52..6fbafc4db4 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -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, diff --git a/packages/excalidraw/element/distance.ts b/packages/excalidraw/element/distance.ts index 422e591bb5..8a0fa8dc22 100644 --- a/packages/excalidraw/element/distance.ts +++ b/packages/excalidraw/element/distance.ts @@ -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, r: number, ): Segment => { + 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( + // 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( point(element.x + element.width, element.y + element.height / 2), - center, - element.angle, - ); - const bottom = pointRotateRads( point(element.x + element.width / 2, element.y + element.height), - center, - element.angle, - ); - const left = pointRotateRads( 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), ); diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index 5775f0eb74..bad4118d87 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -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, diff --git a/packages/excalidraw/element/heading.ts b/packages/excalidraw/element/heading.ts index 0931712a87..59dce8585a 100644 --- a/packages/excalidraw/element/heading.ts +++ b/packages/excalidraw/element/heading.ts @@ -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; diff --git a/packages/excalidraw/element/index.ts b/packages/excalidraw/element/index.ts index 1730b4cabb..bf9bb24952 100644 --- a/packages/excalidraw/element/index.ts +++ b/packages/excalidraw/element/index.ts @@ -19,9 +19,9 @@ export { getElementAbsoluteCoords, getElementBounds, getCommonBounds, - getDiamondPoints, getClosestElementBounds, } from "./bounds"; +export { getDiamondPoints } from "../scene/Shape"; export { OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 6e1a0e58e5..b9e871382c 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -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, diff --git a/packages/excalidraw/element/resizeTest.ts b/packages/excalidraw/element/resizeTest.ts index c23cef1917..99214b5a78 100644 --- a/packages/excalidraw/element/resizeTest.ts +++ b/packages/excalidraw/element/resizeTest.ts @@ -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"; diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts index 93f6a49fdc..ea6d503d1d 100644 --- a/packages/excalidraw/element/routing.ts +++ b/packages/excalidraw/element/routing.ts @@ -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, diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts index 173c9fdc9c..68234814c3 100644 --- a/packages/excalidraw/element/transformHandles.ts +++ b/packages/excalidraw/element/transformHandles.ts @@ -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 { diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts index 6bb4269f87..6776a0b955 100644 --- a/packages/excalidraw/element/typeChecks.ts +++ b/packages/excalidraw/element/typeChecks.ts @@ -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 = ( diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 5ebf505444..b0e98a17f6 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -373,3 +373,13 @@ export type NonDeletedSceneElementsMap = Map< export type ElementsMapOrArray = | readonly ExcalidrawElement[] | Readonly; + +/** + * Axis-aligned bounding box (i.e. no rotation) + */ +export type Bounds = readonly [ + minX: number, + minY: number, + maxX: number, + maxY: number, +]; diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index 10fe103def..bbbc265aad 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -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", diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 2e34eb7a6d..dcb5df351a 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -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"; diff --git a/packages/excalidraw/scene/Shape.ts b/packages/excalidraw/scene/Shape.ts index 06c55b52ac..1096366c41 100644 --- a/packages/excalidraw/scene/Shape.ts +++ b/packages/excalidraw/scene/Shape.ts @@ -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. * diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 72bef0d9b8..63982b63d9 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -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"; diff --git a/packages/excalidraw/shapes.tsx b/packages/excalidraw/shapes.tsx index 76e7a80643..a4a1579ce5 100644 --- a/packages/excalidraw/shapes.tsx +++ b/packages/excalidraw/shapes.tsx @@ -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, diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index 9da3d74c4e..60b42eee07 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -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, diff --git a/packages/excalidraw/tests/resize.test.tsx b/packages/excalidraw/tests/resize.test.tsx index 8de7157b18..e5141219ba 100644 --- a/packages/excalidraw/tests/resize.test.tsx +++ b/packages/excalidraw/tests/resize.test.tsx @@ -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, ); diff --git a/packages/excalidraw/visualdebug.ts b/packages/excalidraw/visualdebug.ts index 5bf9340442..61b66d2256 100644 --- a/packages/excalidraw/visualdebug.ts +++ b/packages/excalidraw/visualdebug.ts @@ -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 { diff --git a/packages/math/point.ts b/packages/math/point.ts index 471eab8765..0fd3ad26c6 100644 --- a/packages/math/point.ts +++ b/packages/math/point.ts @@ -102,6 +102,22 @@ export function pointRotateRads( ); } +/** + * 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( + p: Point[], + c: Point, + angle: Radians, +): Point[] { + return p.map((x, idx) => pointRotateRads(x, c, angle)); +} + /** * Roate a point by [angle] degree. * diff --git a/packages/utils/withinBounds.test.ts b/packages/utils/withinBounds.test.ts index d0cc5e339e..5e9fe006c4 100644 --- a/packages/utils/withinBounds.test.ts +++ b/packages/utils/withinBounds.test.ts @@ -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: [ diff --git a/packages/utils/withinBounds.ts b/packages/utils/withinBounds.ts index 1920c15cdd..a5315d8fbf 100644 --- a/packages/utils/withinBounds.ts +++ b/packages/utils/withinBounds.ts @@ -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);