import type { ElementsMap, ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawEllipseElement, ExcalidrawRectangleElement, ExcalidrawRectanguloidElement, } from "./types"; import { getElementBounds } from "./bounds"; import type { FrameNameBounds } from "../types"; import type { GeometricShape } from "../../utils/geometry/shape"; import { getPolygonShape } from "../../utils/geometry/shape"; import { isPointInShape, isPointOnShape } from "../../utils/collision"; import { isTransparent } from "../utils"; import { hasBoundTextElement, isIframeLikeElement, isImageElement, isTextElement, } from "./typeChecks"; import { getBoundTextShape, isPathALoop } from "../shapes"; import type { GlobalPoint, LineSegment, LocalPoint, Polygon, Radians, } from "../../math"; import { curveIntersectLineSegment, isPointWithinBounds, line, lineSegment, lineSegmentIntersectionPoints, pointFrom, pointRotateRads, pointsEqual, } from "../../math"; import { ellipse, ellipseLineIntersectionPoints } from "../../math/ellipse"; import { deconstructDiamondElement, deconstructRectanguloidElement, } from "./utils"; export const shouldTestInside = (element: ExcalidrawElement) => { if (element.type === "arrow") { return false; } const isDraggableFromInside = !isTransparent(element.backgroundColor) || hasBoundTextElement(element) || isIframeLikeElement(element) || isTextElement(element); if (element.type === "line") { return isDraggableFromInside && isPathALoop(element.points); } if (element.type === "freedraw") { return isDraggableFromInside && isPathALoop(element.points); } return isDraggableFromInside || isImageElement(element); }; export type HitTestArgs = { x: number; y: number; element: ExcalidrawElement; shape: GeometricShape; threshold?: number; frameNameBound?: FrameNameBounds | null; }; export const hitElementItself = ({ x, y, element, shape, threshold = 10, frameNameBound = null, }: HitTestArgs) => { let hit = shouldTestInside(element) ? // Since `inShape` tests STRICTLY againt the insides of a shape // we would need `onShape` as well to include the "borders" isPointInShape(pointFrom(x, y), shape) || isPointOnShape(pointFrom(x, y), shape, threshold) : isPointOnShape(pointFrom(x, y), shape, threshold); // hit test against a frame's name if (!hit && frameNameBound) { hit = isPointInShape(pointFrom(x, y), { type: "polygon", data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) .data as Polygon, }); } return hit; }; export const hitElementBoundingBox = ( x: number, y: number, element: ExcalidrawElement, elementsMap: ElementsMap, tolerance = 0, ) => { let [x1, y1, x2, y2] = getElementBounds(element, elementsMap); x1 -= tolerance; y1 -= tolerance; x2 += tolerance; y2 += tolerance; return isPointWithinBounds( pointFrom(x1, y1), pointFrom(x, y), pointFrom(x2, y2), ); }; export const hitElementBoundingBoxOnly = < Point extends GlobalPoint | LocalPoint, >( hitArgs: HitTestArgs, elementsMap: ElementsMap, ) => { return ( !hitElementItself(hitArgs) && // bound text is considered part of the element (even if it's outside the bounding box) !hitElementBoundText( hitArgs.x, hitArgs.y, getBoundTextShape(hitArgs.element, elementsMap), ) && hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap) ); }; export const hitElementBoundText = ( x: number, y: number, textShape: GeometricShape | null, ): boolean => { return !!textShape && isPointInShape(pointFrom(x, y), textShape); }; /** * Intersect a line with an element for binding test * * @param element * @param line * @param offset * @returns */ export const intersectElementWithLineSegment = ( element: ExcalidrawElement, line: LineSegment, offset: number = 0, ): GlobalPoint[] => { switch (element.type) { case "rectangle": case "image": case "text": case "iframe": case "embeddable": case "frame": case "magicframe": return intersectRectanguloidWithLineSegment(element, line, offset); case "diamond": return intersectDiamondWithLineSegment(element, line, offset); case "ellipse": return intersectEllipseWithLineSegment(element, line, offset); default: throw new Error(`Unimplemented element type '${element.type}'`); } }; const intersectRectanguloidWithLineSegment = ( element: ExcalidrawRectanguloidElement, l: LineSegment, offset: number = 0, ): GlobalPoint[] => { const center = pointFrom( element.x + element.width / 2, element.y + element.height / 2, ); // To emulate a rotated rectangle we rotate the point in the inverse angle // instead. It's all the same distance-wise. const rotatedA = pointRotateRads( l[0], center, -element.angle as Radians, ); const rotatedB = pointRotateRads( l[1], center, -element.angle as Radians, ); // Get the element's building components we can test against const [sides, corners] = deconstructRectanguloidElement( element, offset, ); return ( [ // Test intersection against the sides, keep only the valid // intersection points and rotate them back to scene space ...sides .map((s) => lineSegmentIntersectionPoints( lineSegment(rotatedA, rotatedB), s, ), ) .filter((x) => x != null) .map((j) => pointRotateRads(j!, center, element.angle)), // Test intersection against the corners which are cubic bezier curves, // keep only the valid intersection points and rotate them back to scene // space ...corners .flatMap((t) => curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)), ) .filter((i) => i != null) .map((j) => pointRotateRads(j, center, element.angle)), ] // Remove duplicates .filter( (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, ) ); }; /** * * @param element * @param a * @param b * @returns */ const intersectDiamondWithLineSegment = ( element: ExcalidrawDiamondElement, l: LineSegment, offset: number = 0, ): GlobalPoint[] => { const center = pointFrom( element.x + element.width / 2, element.y + element.height / 2, ); // Rotate the point to the inverse direction to simulate the rotated diamond // points. It's all the same distance-wise. const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); const [sides, curves] = deconstructDiamondElement(element, offset); return ( [ ...sides .map((s) => lineSegmentIntersectionPoints( lineSegment(rotatedA, rotatedB), s, ), ) .filter((p): p is GlobalPoint => p != null) // Rotate back intersection points .map((p) => pointRotateRads(p!, center, element.angle)), ...curves .flatMap((p) => curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)), ) .filter((p) => p != null) // Rotate back intersection points .map((p) => pointRotateRads(p, center, element.angle)), ] // Remove duplicates .filter( (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, ) ); }; /** * * @param element * @param a * @param b * @returns */ const intersectEllipseWithLineSegment = ( element: ExcalidrawEllipseElement, l: LineSegment, offset: number = 0, ): GlobalPoint[] => { const center = pointFrom( element.x + element.width / 2, element.y + element.height / 2, ); const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); return ellipseLineIntersectionPoints( ellipse(center, element.width / 2 + offset, element.height / 2 + offset), line(rotatedA, rotatedB), ).map((p) => pointRotateRads(p, center, element.angle)); };