diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index a072b81a90..a30b11f2de 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -1,5 +1,4 @@ import rough from "roughjs/bin/rough"; - import { arrayToMap, type Bounds, @@ -7,7 +6,6 @@ import { rescalePoints, sizeOf, } from "@excalidraw/common"; - import { degreesToRadians, lineSegment, @@ -16,9 +14,7 @@ import { pointFromArray, pointRotateRads, } from "@excalidraw/math"; - import { getCurvePathOps } from "@excalidraw/utils/shape"; - import { pointsOnBezierCurves } from "points-on-curve"; import type { @@ -29,9 +25,7 @@ import type { LocalPoint, Radians, } from "@excalidraw/math"; - import type { AppState } from "@excalidraw/excalidraw/types"; - import type { Mutable } from "@excalidraw/common/utility-types"; import { generateRoughOptions } from "./shape"; @@ -41,18 +35,20 @@ import { getBoundTextElement, getContainerElement } from "./textElement"; import { isArrowElement, isBoundToContainer, + isFrameLikeElement, isFreeDrawElement, isLinearElement, isLineElement, isTextElement, + isExcalidrawElement, } from "./typeChecks"; - import { getElementShape } from "./shape"; - import { deconstructDiamondElement, deconstructRectanguloidElement, } from "./utils"; +import { intersectElementWithLineSegment } from "./collision"; +import { elementOverlapsWithFrame, getContainingFrame } from "./frame"; import type { Drawable, Op } from "roughjs/bin/core"; import type { Point as RoughPoint } from "roughjs/bin/geometry"; @@ -67,6 +63,7 @@ import type { ExcalidrawRectanguloidElement, ExcalidrawTextElementWithContainer, NonDeleted, + NonDeletedExcalidrawElement, } from "./types"; export type RectangleBox = { @@ -1295,6 +1292,295 @@ export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) => pointFrom(innerBounds[2], innerBounds[3]), ].every((point) => pointInsideBoundsInclusive(point, outerBounds)); +/** + * High level helper to get elements overlapping a bounding box. + * It can be used to get elements overlapping a selection box, for example. + * + */ +export const elementsOverlappingBBox = ({ + elements, + elementsMap, + bounds, + type, + excludeElementsInFrames, + shouldIgnoreElementFromSelection, +}: { + elements: readonly NonDeletedExcalidrawElement[]; + elementsMap?: ElementsMap; + bounds: Bounds | ExcalidrawElement; + /** + * - overlap: elements overlapping or inside bounds + * - contain: elements inside bounds + **/ + type: "contain" | "overlap"; + excludeElementsInFrames?: boolean; + shouldIgnoreElementFromSelection?: ( + element: NonDeletedExcalidrawElement, + ) => boolean; +}) => { + if (!elementsMap) { + elementsMap = arrayToMap(elements) as ElementsMap; + } + const selectionBounds = isExcalidrawElement(bounds) + ? getElementBounds(bounds, elementsMap) + : bounds; + const [selectionX1, selectionY1, selectionX2, selectionY2] = selectionBounds; + const selectionEdges = [ + lineSegment( + pointFrom(selectionX1, selectionY1), + pointFrom(selectionX2, selectionY1), + ), + lineSegment( + pointFrom(selectionX2, selectionY1), + pointFrom(selectionX2, selectionY2), + ), + lineSegment( + pointFrom(selectionX2, selectionY2), + pointFrom(selectionX1, selectionY2), + ), + lineSegment( + pointFrom(selectionX1, selectionY2), + pointFrom(selectionX1, selectionY1), + ), + ]; + + const framesInSelection = excludeElementsInFrames + ? new Set() + : null; + const groups: Record = {}; + const elementsInSelection: Set = new Set(); + + for (const element of elements) { + if (shouldIgnoreElementFromSelection?.(element)) { + continue; + } + + // Track only selectable top-level group members, so ignored elements such + // as bound text and locked elements don't affect group selection. + const groupId = element.groupIds.at(-1); + if (groupId) { + if (!groups[groupId]) { + groups[groupId] = []; + } + groups[groupId].push(element); + } + + const strokeWidth = element.strokeWidth; + let labelAABB: Bounds | null = null; + let elementAABB = getElementBounds(element, elementsMap); + + elementAABB = [ + elementAABB[0] - strokeWidth / 2, + elementAABB[1] - strokeWidth / 2, + elementAABB[2] + strokeWidth / 2, + elementAABB[3] + strokeWidth / 2, + ] as Bounds; + + // Whether the element bounds should include the bound text element bounds + const boundTextElement = + isArrowElement(element) && getBoundTextElement(element, elementsMap); + if (boundTextElement) { + const { x, y } = LinearElementEditor.getBoundTextElementPosition( + element, + boundTextElement, + elementsMap, + ); + labelAABB = [ + x, + y, + x + boundTextElement.width, + y + boundTextElement.height, + ] as Bounds; + } + + // Clip element bounds by its containing frame (if any), since only the + // visible (frame-clipped) portion of the element is relevant for selection. + const associatedFrame = getContainingFrame(element, elementsMap); + if ( + associatedFrame && + elementOverlapsWithFrame(element, associatedFrame, elementsMap) + ) { + const frameAABB = getElementBounds(associatedFrame, elementsMap); + elementAABB = [ + Math.max(elementAABB[0], frameAABB[0]), + Math.max(elementAABB[1], frameAABB[1]), + Math.min(elementAABB[2], frameAABB[2]), + Math.min(elementAABB[3], frameAABB[3]), + ] as Bounds; + + labelAABB = labelAABB + ? ([ + Math.max(labelAABB[0], frameAABB[0]), + Math.max(labelAABB[1], frameAABB[1]), + Math.min(labelAABB[2], frameAABB[2]), + Math.min(labelAABB[3], frameAABB[3]), + ] as Bounds) + : null; + } + + const commonAABB = labelAABB + ? ([ + Math.min(labelAABB[0], elementAABB[0]), + Math.min(labelAABB[1], elementAABB[1]), + Math.max(labelAABB[2], elementAABB[2]), + Math.max(labelAABB[3], elementAABB[3]), + ] as Bounds) + : elementAABB; + + // ============== Evaluation ============== + + // 1. If the selection box WRAPs the element's AABB, then add it to the + // selection and move on, regardless of the selection mode. + // + // PERF: This trick only works with axis-aligned box selection and the + // current convex element shapes! + if (boundsContainBounds(selectionBounds, commonAABB)) { + if (framesInSelection && isFrameLikeElement(element)) { + framesInSelection.add(element.id); + } + elementsInSelection.add(element); + continue; + } + + // 2. Handle the case where the label is overlapped by the selection box + if ( + type === "overlap" && + labelAABB && + doBoundsIntersect(selectionBounds, labelAABB) + ) { + elementsInSelection.add(element); + continue; + } + + // 3. Handle the case where the selection is not wrapping the element, but + // it does intersect the element's outline (non-AABB). + if (type === "overlap" && doBoundsIntersect(selectionBounds, elementAABB)) { + let hasIntersection = false; + + // Preliminary check potential intersection imprecision + if (isLinearElement(element) || isFreeDrawElement(element)) { + const center = elementCenterPoint(element, elementsMap); + hasIntersection = element.points.some((point) => { + const rotatedPoint = pointRotateRads( + pointFrom(element.x + point[0], element.y + point[1]), + center, + element.angle, + ); + + return pointInsideBounds(rotatedPoint, selectionBounds); + }); + } else { + const nonRotatedElementBounds = getElementBounds( + element, + elementsMap, + true, + ); + const center = elementCenterPoint(element, elementsMap); + hasIntersection = [ + pointRotateRads( + pointFrom( + (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2, + nonRotatedElementBounds[1], + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + nonRotatedElementBounds[2], + (nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2, + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2, + nonRotatedElementBounds[3], + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + nonRotatedElementBounds[0], + (nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2, + ), + center, + element.angle, + ), + ].some((point) => { + return pointInsideBounds( + pointRotateRads(point, center, element.angle), + selectionBounds, + ); + }); + } + + if (!hasIntersection) { + hasIntersection = selectionEdges.some( + (selectionEdge) => + intersectElementWithLineSegment( + element, + elementsMap, + selectionEdge, + strokeWidth / 2, + true, // Stop at first hit for better performance + ).length > 0, + ); + } + + if (hasIntersection) { + if (framesInSelection && isFrameLikeElement(element)) { + framesInSelection.add(element.id); + } + + elementsInSelection.add(element); + continue; + } + } + + // 4. We don't need to handle when the selection is inside the element + // as it is separately handled in App. + } + + if (framesInSelection) { + elementsInSelection.forEach((element) => { + if (element.frameId && framesInSelection.has(element.frameId)) { + elementsInSelection.delete(element); + } + }); + } + + if (type === "overlap") { + Array.from(elementsInSelection).forEach((element) => { + const groupId = element.groupIds.at(-1); + const group = groupId ? groups[groupId] : null; + + group?.forEach((groupElement) => elementsInSelection.add(groupElement)); + }); + } else if (type === "contain") { + elementsInSelection.forEach((element) => { + // note: currently we only support top-level group handling since + // we don't support box selecting while editing the group/subgroup + // see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451 + const groupId = element.groupIds.at(-1); + + const group = groupId ? groups[groupId] : null; + + if ( + group && + !group.every((groupElement) => elementsInSelection.has(groupElement)) + ) { + elementsInSelection.delete(element); + } + }); + } + + // to maintain original order elements (namely for group selection) + return elements.filter((element) => elementsInSelection.has(element)); +}; + export const elementCenterPoint = ( element: ExcalidrawElement, elementsMap: ElementsMap, diff --git a/packages/element/src/frame.ts b/packages/element/src/frame.ts index 3d1449a072..7489c3459a 100644 --- a/packages/element/src/frame.ts +++ b/packages/element/src/frame.ts @@ -1,6 +1,9 @@ import { arrayToMap } from "@excalidraw/common"; -import { isPointWithinBounds, pointFrom } from "@excalidraw/math"; -import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox"; +import { + isPointWithinBounds, + pointFrom, + segmentsIntersectAt, +} from "@excalidraw/math"; import type { AppClassProperties, @@ -78,7 +81,7 @@ export function isElementIntersectingFrame( const intersecting = frameLineSegments.some((frameLineSegment) => elementLineSegments.some((elementLineSegment) => - doLineSegmentsIntersect(frameLineSegment, elementLineSegment), + segmentsIntersectAt(frameLineSegment, elementLineSegment), ), ); diff --git a/packages/element/src/selection.ts b/packages/element/src/selection.ts index a65d1d3ce3..6c8de26820 100644 --- a/packages/element/src/selection.ts +++ b/packages/element/src/selection.ts @@ -1,10 +1,4 @@ import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common"; -import { - lineSegment, - pointFrom, - pointRotateRads, - type GlobalPoint, -} from "@excalidraw/math"; import type { AppState, @@ -12,33 +6,18 @@ import type { InteractiveCanvasAppState, } from "@excalidraw/excalidraw/types"; -import { - boundsContainBounds, - doBoundsIntersect, - elementCenterPoint, - getElementAbsoluteCoords, - getElementBounds, - pointInsideBounds, -} from "./bounds"; -import { intersectElementWithLineSegment } from "./collision"; +import { elementsOverlappingBBox, getElementAbsoluteCoords } from "./bounds"; import { isElementInViewport } from "./sizeHelpers"; import { - isArrowElement, isBoundToContainer, isFrameLikeElement, - isFreeDrawElement, isLinearElement, isTextElement, } from "./typeChecks"; -import { - elementOverlapsWithFrame, - getContainingFrame, - getFrameChildren, -} from "./frame"; +import { getFrameChildren } from "./frame"; import { LinearElementEditor } from "./linearElementEditor"; import { selectGroupsForSelectedElements } from "./groups"; -import { getBoundTextElement } from "./textElement"; import type { ElementsMap, @@ -107,263 +86,15 @@ export const getElementsWithinSelection = ( selectionX2, selectionY2, ] as Bounds; - const selectionEdges = [ - lineSegment( - pointFrom(selectionX1, selectionY1), - pointFrom(selectionX2, selectionY1), - ), - lineSegment( - pointFrom(selectionX2, selectionY1), - pointFrom(selectionX2, selectionY2), - ), - lineSegment( - pointFrom(selectionX2, selectionY2), - pointFrom(selectionX1, selectionY2), - ), - lineSegment( - pointFrom(selectionX1, selectionY2), - pointFrom(selectionX1, selectionY1), - ), - ]; - const framesInSelection = excludeElementsInFrames - ? new Set() - : null; - const groups: Record = {}; - const elementsInSelection: Set = new Set(); - - for (const element of elements) { - if (shouldIgnoreElementFromSelection(element)) { - continue; - } - - // Track only selectable top-level group members, so ignored elements such - // as bound text and locked elements don't affect group selection. - const groupId = element.groupIds.at(-1); - if (groupId) { - if (!groups[groupId]) { - groups[groupId] = []; - } - groups[groupId].push(element); - } - - const strokeWidth = element.strokeWidth; - let labelAABB: Bounds | null = null; - let elementAABB = getElementBounds(element, elementsMap); - - elementAABB = [ - elementAABB[0] - strokeWidth / 2, - elementAABB[1] - strokeWidth / 2, - elementAABB[2] + strokeWidth / 2, - elementAABB[3] + strokeWidth / 2, - ] as Bounds; - - // Whether the element bounds should include the bound text element bounds - const boundTextElement = - isArrowElement(element) && getBoundTextElement(element, elementsMap); - if (boundTextElement) { - const { x, y } = LinearElementEditor.getBoundTextElementPosition( - element, - boundTextElement, - elementsMap, - ); - labelAABB = [ - x, - y, - x + boundTextElement.width, - y + boundTextElement.height, - ] as Bounds; - } - - // Clip element bounds by its containing frame (if any), since only the - // visible (frame-clipped) portion of the element is relevant for selection. - const associatedFrame = getContainingFrame(element, elementsMap); - if ( - associatedFrame && - elementOverlapsWithFrame(element, associatedFrame, elementsMap) - ) { - const frameAABB = getElementBounds(associatedFrame, elementsMap); - elementAABB = [ - Math.max(elementAABB[0], frameAABB[0]), - Math.max(elementAABB[1], frameAABB[1]), - Math.min(elementAABB[2], frameAABB[2]), - Math.min(elementAABB[3], frameAABB[3]), - ] as Bounds; - - labelAABB = labelAABB - ? ([ - Math.max(labelAABB[0], frameAABB[0]), - Math.max(labelAABB[1], frameAABB[1]), - Math.min(labelAABB[2], frameAABB[2]), - Math.min(labelAABB[3], frameAABB[3]), - ] as Bounds) - : null; - } - - const commonAABB = labelAABB - ? ([ - Math.min(labelAABB[0], elementAABB[0]), - Math.min(labelAABB[1], elementAABB[1]), - Math.max(labelAABB[2], elementAABB[2]), - Math.max(labelAABB[3], elementAABB[3]), - ] as Bounds) - : elementAABB; - - // ============== Evaluation ============== - - // 1. If the selection box WRAPs the element's AABB, then add it to the - // selection and move on, regardless of the selection mode. - // - // PERF: This trick only works with axis-aligned box selection and the - // current convex element shapes! - if (boundsContainBounds(selectionBounds, commonAABB)) { - if (framesInSelection && isFrameLikeElement(element)) { - framesInSelection.add(element.id); - } - elementsInSelection.add(element); - continue; - } - - // 2. Handle the case where the label is overlapped by the selection box - if ( - boxSelectionMode === "overlap" && - labelAABB && - doBoundsIntersect(selectionBounds, labelAABB) - ) { - elementsInSelection.add(element); - continue; - } - - // 3. Handle the case where the selection is not wrapping the element, but - // it does intersect the element's outline (non-AABB). - if ( - boxSelectionMode === "overlap" && - doBoundsIntersect(selectionBounds, elementAABB) - ) { - let hasIntersection = false; - - // Preliminary check potential intersection imprecision - if (isLinearElement(element) || isFreeDrawElement(element)) { - const center = elementCenterPoint(element, elementsMap); - hasIntersection = element.points.some((point) => { - const rotatedPoint = pointRotateRads( - pointFrom(element.x + point[0], element.y + point[1]), - center, - element.angle, - ); - - return pointInsideBounds(rotatedPoint, selectionBounds); - }); - } else { - const nonRotatedElementBounds = getElementBounds( - element, - elementsMap, - true, - ); - const center = elementCenterPoint(element, elementsMap); - hasIntersection = [ - pointRotateRads( - pointFrom( - (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2, - nonRotatedElementBounds[1], - ), - center, - element.angle, - ), - pointRotateRads( - pointFrom( - nonRotatedElementBounds[2], - (nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2, - ), - center, - element.angle, - ), - pointRotateRads( - pointFrom( - (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2, - nonRotatedElementBounds[3], - ), - center, - element.angle, - ), - pointRotateRads( - pointFrom( - nonRotatedElementBounds[0], - (nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2, - ), - center, - element.angle, - ), - ].some((point) => { - return pointInsideBounds( - pointRotateRads(point, center, element.angle), - selectionBounds, - ); - }); - } - - if (!hasIntersection) { - hasIntersection = selectionEdges.some( - (selectionEdge) => - intersectElementWithLineSegment( - element, - elementsMap, - selectionEdge, - strokeWidth / 2, - true, // Stop at first hit for better performance - ).length > 0, - ); - } - - if (hasIntersection) { - if (framesInSelection && isFrameLikeElement(element)) { - framesInSelection.add(element.id); - } - - elementsInSelection.add(element); - continue; - } - } - - // 4. We don't need to handle when the selection is inside the element - // as it is separately handled in App. - } - - if (framesInSelection) { - elementsInSelection.forEach((element) => { - if (element.frameId && framesInSelection.has(element.frameId)) { - elementsInSelection.delete(element); - } - }); - } - - if (boxSelectionMode === "overlap") { - Array.from(elementsInSelection).forEach((element) => { - const groupId = element.groupIds.at(-1); - const group = groupId ? groups[groupId] : null; - - group?.forEach((groupElement) => elementsInSelection.add(groupElement)); - }); - } else if (boxSelectionMode === "contain") { - elementsInSelection.forEach((element) => { - // note: currently we only support top-level group handling since - // we don't support box selecting while editing the group/subgroup - // see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451 - const groupId = element.groupIds.at(-1); - - const group = groupId ? groups[groupId] : null; - - if ( - group && - !group.every((groupElement) => elementsInSelection.has(groupElement)) - ) { - elementsInSelection.delete(element); - } - }); - } - - // to maintain original order elements (namely for group selection) - return elements.filter((element) => elementsInSelection.has(element)); + return elementsOverlappingBBox({ + elements, + bounds: selectionBounds, + elementsMap, + type: boxSelectionMode, + shouldIgnoreElementFromSelection, + excludeElementsInFrames, + }); }; export const getVisibleAndNonSelectedElements = ( diff --git a/packages/element/src/visualdebug.ts b/packages/element/src/visualdebug.ts index 695c6f9f43..1dea8cf568 100644 --- a/packages/element/src/visualdebug.ts +++ b/packages/element/src/visualdebug.ts @@ -5,6 +5,7 @@ import { pointFrom, type GlobalPoint, type LocalPoint, + type LineSegment, } from "@excalidraw/math"; import { type Bounds, isBounds } from "@excalidraw/common"; import { @@ -17,7 +18,6 @@ import { import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { Curve } from "@excalidraw/math"; -import type { LineSegment } from "@excalidraw/utils"; // The global data holder to collect the debug operations declare global { diff --git a/packages/element/tests/bounds.test.ts b/packages/element/tests/bounds.test.ts index 1c7cd3cd28..635bc47376 100644 --- a/packages/element/tests/bounds.test.ts +++ b/packages/element/tests/bounds.test.ts @@ -1,10 +1,14 @@ import { pointFrom } from "@excalidraw/math"; - -import { arrayToMap, ROUNDNESS } from "@excalidraw/common"; +import { arrayToMap, type Bounds, ROUNDNESS } from "@excalidraw/common"; +import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import type { LocalPoint } from "@excalidraw/math"; -import { getElementAbsoluteCoords, getElementBounds } from "../src/bounds"; +import { + elementsOverlappingBBox, + getElementAbsoluteCoords, + getElementBounds, +} from "../src/bounds"; import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types"; @@ -141,3 +145,65 @@ describe("getElementBounds", () => { expect(y2).toEqual(319.8162855827246); }); }); + +const makeElement = (x: number, y: number, width: number, height: number) => + API.createElement({ + type: "rectangle", + x, + y, + width, + height, + }); + +const makeBBox = ( + minX: number, + minY: number, + maxX: number, + maxY: number, +): Bounds => [minX, minY, maxX, maxY]; + +describe("elementsOverlappingBBox()", () => { + it("should return elements that overlap bbox", () => { + const bbox = makeBBox(0, 0, 100, 100); + + const rectOutside = makeElement(110, 110, 100, 100); + const rectInside = makeElement(10, 10, 85, 85); + const rectContainingBBox = makeElement(-10, -10, 110, 110); + const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50); + + expect( + elementsOverlappingBBox({ + bounds: bbox, + type: "overlap", + elements: [ + rectOutside, + rectInside, + rectContainingBBox, + rectOverlappingTopLeft, + ], + }), + ).toEqual([rectInside, rectOverlappingTopLeft]); + }); + + it("should return elements inside/containing bbox", () => { + const bbox = makeBBox(0, 0, 100, 100); + + const rectOutside = makeElement(110, 110, 100, 100); + const rectInside = makeElement(10, 10, 85, 85); + const rectContainingBBox = makeElement(-10, -10, 110, 110); + const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50); + + expect( + elementsOverlappingBBox({ + bounds: bbox, + type: "contain", + elements: [ + rectOutside, + rectInside, + rectContainingBBox, + rectOverlappingTopLeft, + ], + }), + ).toEqual([rectInside]); + }); +}); diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index b56e568b60..c038dad22f 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -398,11 +398,7 @@ export { convertToExcalidrawElements, } from "@excalidraw/element"; -export { - elementsOverlappingBBox, - isElementInsideBBox, - elementPartiallyOverlapsWithOrContainsBBox, -} from "@excalidraw/utils/withinBounds"; +export { elementsOverlappingBBox } from "@excalidraw/element"; export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin"; export { getDataURL } from "./data/blob"; diff --git a/packages/utils/src/bbox.ts b/packages/utils/src/bbox.ts deleted file mode 100644 index 4450da1b8d..0000000000 --- a/packages/utils/src/bbox.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - vectorCross, - vectorFromPoint, - type GlobalPoint, - type LocalPoint, -} from "@excalidraw/math"; - -import type { Bounds } from "@excalidraw/common"; - -export type LineSegment

= [P, P]; - -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 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

( - l: LineSegment

, - 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

( - l: LineSegment

, - 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

, 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) - ); -} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index a6207e9c34..28c2db28c7 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,4 +1,3 @@ export * from "./export"; -export * from "./withinBounds"; -export * from "./bbox"; +export { elementsOverlappingBBox } from "@excalidraw/element"; export { getCommonBounds } from "@excalidraw/element"; diff --git a/packages/utils/src/withinBounds.ts b/packages/utils/src/withinBounds.ts deleted file mode 100644 index 3ffab9d370..0000000000 --- a/packages/utils/src/withinBounds.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { arrayToMap, type Bounds } from "@excalidraw/common"; -import { getElementBounds } from "@excalidraw/element"; -import { - isArrowElement, - isExcalidrawElement, - isFreeDrawElement, - isLinearElement, - isTextElement, -} from "@excalidraw/element"; -import { - rangeIncludesValue, - pointFrom, - pointRotateRads, - rangeInclusive, -} from "@excalidraw/math"; - -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 LocalPoint[]; - -/** @returns vertices relative to element's top-left [0,0] position */ -const getNonLinearElementRelativePoints = ( - element: Exclude< - Element, - ExcalidrawLinearElement | ExcalidrawFreeDrawElement - >, -): [ - TopLeft: LocalPoint, - TopRight: LocalPoint, - BottomRight: LocalPoint, - BottomLeft: LocalPoint, -] => { - if (element.type === "diamond") { - return [ - pointFrom(element.width / 2, 0), - pointFrom(element.width, element.height / 2), - pointFrom(element.width / 2, element.height), - pointFrom(0, element.height / 2), - ]; - } - return [ - pointFrom(0, 0), - pointFrom(0 + element.width, 0), - pointFrom(0 + element.width, element.height), - pointFrom(0, element.height), - ]; -}; - -/** @returns vertices relative to element's top-left [0,0] position */ -const getElementRelativePoints = (element: ExcalidrawElement): Points => { - if (isLinearElement(element) || isFreeDrawElement(element)) { - return element.points; - } - return getNonLinearElementRelativePoints(element); -}; - -const getMinMaxPoints = (points: Points) => { - const ret = points.reduce( - (limits, [x, y]) => { - limits.minY = Math.min(limits.minY, y); - limits.minX = Math.min(limits.minX, x); - - limits.maxX = Math.max(limits.maxX, x); - limits.maxY = Math.max(limits.maxY, y); - - return limits; - }, - { - minX: Infinity, - minY: Infinity, - maxX: -Infinity, - maxY: -Infinity, - cx: 0, - cy: 0, - }, - ); - - ret.cx = (ret.maxX + ret.minX) / 2; - ret.cy = (ret.maxY + ret.minY) / 2; - - return ret; -}; - -const getRotatedBBox = (element: Element): Bounds => { - const points = getElementRelativePoints(element); - - const { cx, cy } = getMinMaxPoints(points); - const centerPoint = pointFrom(cx, cy); - - const rotatedPoints = points.map((p) => - pointRotateRads(p, centerPoint, element.angle), - ); - const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints); - - return [ - minX + element.x, - minY + element.y, - maxX + element.x, - maxY + element.y, - ]; -}; - -export const isElementInsideBBox = ( - element: Element, - bbox: Bounds, - eitherDirection = false, -): boolean => { - const elementBBox = getRotatedBBox(element); - - const elementInsideBbox = - bbox[0] <= elementBBox[0] && - bbox[2] >= elementBBox[2] && - bbox[1] <= elementBBox[1] && - bbox[3] >= elementBBox[3]; - - if (!eitherDirection) { - return elementInsideBbox; - } - - if (elementInsideBbox) { - return true; - } - - return ( - elementBBox[0] <= bbox[0] && - elementBBox[2] >= bbox[2] && - elementBBox[1] <= bbox[1] && - elementBBox[3] >= bbox[3] - ); -}; - -export const elementPartiallyOverlapsWithOrContainsBBox = ( - element: Element, - bbox: Bounds, -): boolean => { - const elementBBox = getRotatedBBox(element); - - return ( - (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]), - )) - ); -}; - -export const elementsOverlappingBBox = ({ - elements, - bounds, - type, - errorMargin = 0, -}: { - elements: Elements; - bounds: Bounds | ExcalidrawElement; - /** safety offset. Defaults to 0. */ - errorMargin?: number; - /** - * - overlap: elements overlapping or inside bounds - * - contain: elements inside bounds or bounds inside elements - * - inside: elements inside bounds - **/ - type: "overlap" | "contain" | "inside"; -}) => { - if (isExcalidrawElement(bounds)) { - bounds = getElementBounds(bounds, arrayToMap(elements)); - } - const adjustedBBox: Bounds = [ - bounds[0] - errorMargin, - bounds[1] - errorMargin, - bounds[2] + errorMargin, - bounds[3] + errorMargin, - ]; - - const includedElementSet = new Set(); - - for (const element of elements) { - if (includedElementSet.has(element.id)) { - continue; - } - - const isOverlaping = - type === "overlap" - ? elementPartiallyOverlapsWithOrContainsBBox(element, adjustedBBox) - : type === "inside" - ? isElementInsideBBox(element, adjustedBBox) - : isElementInsideBBox(element, adjustedBBox, true); - - if (isOverlaping) { - includedElementSet.add(element.id); - - if (element.boundElements) { - for (const boundElement of element.boundElements) { - includedElementSet.add(boundElement.id); - } - } - - if (isTextElement(element) && element.containerId) { - includedElementSet.add(element.containerId); - } - - if (isArrowElement(element)) { - if (element.startBinding) { - includedElementSet.add(element.startBinding.elementId); - } - - if (element.endBinding) { - includedElementSet.add(element.endBinding?.elementId); - } - } - } - } - - return elements.filter((element) => includedElementSet.has(element.id)); -}; diff --git a/packages/utils/tests/withinBounds.test.ts b/packages/utils/tests/withinBounds.test.ts deleted file mode 100644 index 5849120579..0000000000 --- a/packages/utils/tests/withinBounds.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { API } from "@excalidraw/excalidraw/tests/helpers/api"; - -import type { Bounds } from "@excalidraw/common"; - -import { - elementPartiallyOverlapsWithOrContainsBBox, - elementsOverlappingBBox, - isElementInsideBBox, -} from "../src/withinBounds"; - -const makeElement = (x: number, y: number, width: number, height: number) => - API.createElement({ - type: "rectangle", - x, - y, - width, - height, - }); - -const makeBBox = ( - minX: number, - minY: number, - maxX: number, - maxY: number, -): Bounds => [minX, minY, maxX, maxY]; - -describe("isElementInsideBBox()", () => { - it("should return true if element is fully inside", () => { - const bbox = makeBBox(0, 0, 100, 100); - - // bbox contains element - expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true); - expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true); - }); - - it("should return false if element is only partially overlapping", () => { - const bbox = makeBBox(0, 0, 100, 100); - - // element contains bbox - expect(isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox)).toBe( - false, - ); - - // element overlaps bbox from top-left - expect(isElementInsideBBox(makeElement(-10, -10, 100, 100), bbox)).toBe( - false, - ); - // element overlaps bbox from top-right - expect(isElementInsideBBox(makeElement(90, -10, 100, 100), bbox)).toBe( - false, - ); - // element overlaps bbox from bottom-left - expect(isElementInsideBBox(makeElement(-10, 90, 100, 100), bbox)).toBe( - false, - ); - // element overlaps bbox from bottom-right - expect(isElementInsideBBox(makeElement(90, 90, 100, 100), bbox)).toBe( - false, - ); - }); - - it("should return false if element outside", () => { - const bbox = makeBBox(0, 0, 100, 100); - - // outside diagonally - expect(isElementInsideBBox(makeElement(110, 110, 100, 100), bbox)).toBe( - false, - ); - - // outside on the left - expect(isElementInsideBBox(makeElement(-110, 10, 50, 50), bbox)).toBe( - false, - ); - // outside on the right - expect(isElementInsideBBox(makeElement(110, 10, 50, 50), bbox)).toBe(false); - // outside on the top - expect(isElementInsideBBox(makeElement(10, -110, 50, 50), bbox)).toBe( - false, - ); - // outside on the bottom - expect(isElementInsideBBox(makeElement(10, 110, 50, 50), bbox)).toBe(false); - }); - - it("should return true if bbox contains element and flag enabled", () => { - const bbox = makeBBox(0, 0, 100, 100); - - // element contains bbox - expect( - isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox, true), - ).toBe(true); - - // bbox contains element - expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true); - expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true); - }); -}); - -describe("elementPartiallyOverlapsWithOrContainsBBox()", () => { - it("should return true if element overlaps, is inside, or contains", () => { - const bbox = makeBBox(0, 0, 100, 100); - - // bbox contains element - expect( - elementPartiallyOverlapsWithOrContainsBBox( - makeElement(0, 0, 100, 100), - bbox, - ), - ).toBe(true); - expect( - elementPartiallyOverlapsWithOrContainsBBox( - makeElement(10, 10, 90, 90), - bbox, - ), - ).toBe(true); - - // element contains bbox - expect( - elementPartiallyOverlapsWithOrContainsBBox( - makeElement(-10, -10, 110, 110), - bbox, - ), - ).toBe(true); - - // element overlaps bbox from top-left - expect( - elementPartiallyOverlapsWithOrContainsBBox( - makeElement(-10, -10, 100, 100), - bbox, - ), - ).toBe(true); - // element overlaps bbox from top-right - expect( - elementPartiallyOverlapsWithOrContainsBBox( - makeElement(90, -10, 100, 100), - bbox, - ), - ).toBe(true); - // element overlaps bbox from bottom-left - expect( - elementPartiallyOverlapsWithOrContainsBBox( - makeElement(-10, 90, 100, 100), - bbox, - ), - ).toBe(true); - // element overlaps bbox from bottom-right - expect( - elementPartiallyOverlapsWithOrContainsBBox( - makeElement(90, 90, 100, 100), - bbox, - ), - ).toBe(true); - }); - - it("should return false if element does not overlap", () => { - const bbox = makeBBox(0, 0, 100, 100); - - // outside diagonally - expect( - elementPartiallyOverlapsWithOrContainsBBox( - makeElement(110, 110, 100, 100), - bbox, - ), - ).toBe(false); - - // outside on the left - expect( - elementPartiallyOverlapsWithOrContainsBBox( - makeElement(-110, 10, 50, 50), - bbox, - ), - ).toBe(false); - // outside on the right - expect( - elementPartiallyOverlapsWithOrContainsBBox( - makeElement(110, 10, 50, 50), - bbox, - ), - ).toBe(false); - // outside on the top - expect( - elementPartiallyOverlapsWithOrContainsBBox( - makeElement(10, -110, 50, 50), - bbox, - ), - ).toBe(false); - // outside on the bottom - expect( - elementPartiallyOverlapsWithOrContainsBBox( - makeElement(10, 110, 50, 50), - bbox, - ), - ).toBe(false); - }); -}); - -describe("elementsOverlappingBBox()", () => { - it("should return elements that overlap bbox", () => { - const bbox = makeBBox(0, 0, 100, 100); - - const rectOutside = makeElement(110, 110, 100, 100); - const rectInside = makeElement(10, 10, 90, 90); - const rectContainingBBox = makeElement(-10, -10, 110, 110); - const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50); - - expect( - elementsOverlappingBBox({ - bounds: bbox, - type: "overlap", - elements: [ - rectOutside, - rectInside, - rectContainingBBox, - rectOverlappingTopLeft, - ], - }), - ).toEqual([rectInside, rectContainingBBox, rectOverlappingTopLeft]); - }); - - it("should return elements inside/containing bbox", () => { - const bbox = makeBBox(0, 0, 100, 100); - - const rectOutside = makeElement(110, 110, 100, 100); - const rectInside = makeElement(10, 10, 90, 90); - const rectContainingBBox = makeElement(-10, -10, 110, 110); - const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50); - - expect( - elementsOverlappingBBox({ - bounds: bbox, - type: "contain", - elements: [ - rectOutside, - rectInside, - rectContainingBBox, - rectOverlappingTopLeft, - ], - }), - ).toEqual([rectInside, rectContainingBBox]); - }); - - it("should return elements inside bbox", () => { - const bbox = makeBBox(0, 0, 100, 100); - - const rectOutside = makeElement(110, 110, 100, 100); - const rectInside = makeElement(10, 10, 90, 90); - const rectContainingBBox = makeElement(-10, -10, 110, 110); - const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50); - - expect( - elementsOverlappingBBox({ - bounds: bbox, - type: "inside", - elements: [ - rectOutside, - rectInside, - rectContainingBBox, - rectOverlappingTopLeft, - ], - }), - ).toEqual([rectInside]); - }); - - // TODO test linear, freedraw, and diamond element types (+rotated) -});