import { DEFAULT_ADAPTIVE_RADIUS, DEFAULT_PROPORTIONAL_RADIUS, invariant, LINE_CONFIRM_THRESHOLD, ROUNDNESS, } from "@excalidraw/common"; import { bezierEquation, curve, curveCatmullRomCubicApproxPoints, curveOffsetPoints, lineSegment, lineSegmentIntersectionPoints, pointDistance, pointFrom, pointFromArray, pointFromVector, pointRotateRads, pointTranslate, rectangle, vectorFromPoint, vectorNormalize, vectorScale, type GlobalPoint, } from "@excalidraw/math"; import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math"; import type { AppState, NormalizedZoomValue, Zoom, } from "@excalidraw/excalidraw/types"; import { elementCenterPoint, getDiamondPoints } from "./bounds"; import { generateLinearCollisionShape } from "./shape"; import { hitElementItself, isPointInElement } from "./collision"; import { LinearElementEditor } from "./linearElementEditor"; import { isRectangularElement } from "./typeChecks"; import { maxBindingDistance_simple } from "./binding"; import { getGlobalFixedPointForBindableElement, normalizeFixedPoint, } from "./binding"; import type { ElementsMap, ExcalidrawArrowElement, ExcalidrawBindableElement, ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawFreeDrawElement, ExcalidrawLinearElement, ExcalidrawRectanguloidElement, } from "./types"; type ElementShape = [LineSegment[], Curve[]]; const ElementShapesCache = new WeakMap< ExcalidrawElement, { version: ExcalidrawElement["version"]; shapes: Map } >(); const getElementShapesCacheEntry = ( element: T, offset: number, ): ElementShape | undefined => { const record = ElementShapesCache.get(element); if (!record) { return undefined; } const { version, shapes } = record; if (version !== element.version) { ElementShapesCache.delete(element); return undefined; } return shapes.get(offset); }; const setElementShapesCacheEntry = ( element: T, shape: ElementShape, offset: number, ) => { const record = ElementShapesCache.get(element); if (!record) { ElementShapesCache.set(element, { version: element.version, shapes: new Map([[offset, shape]]), }); return; } const { version, shapes } = record; if (version !== element.version) { ElementShapesCache.set(element, { version: element.version, shapes: new Map([[offset, shape]]), }); return; } shapes.set(offset, shape); }; /** * Returns the **rotated** components of freedraw, line or arrow elements. * * @param element The linear element to deconstruct * @returns The rotated in components. */ export function deconstructLinearOrFreeDrawElement( element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, elementsMap: ElementsMap, ): [LineSegment[], Curve[]] { const cachedShape = getElementShapesCacheEntry(element, 0); if (cachedShape) { return cachedShape; } const ops = generateLinearCollisionShape(element, elementsMap); const lines = []; const curves = []; for (let idx = 0; idx < ops.length; idx += 1) { const op = ops[idx]; const prevPoint = ops[idx - 1] && pointFromArray(ops[idx - 1].data.slice(-2)); switch (op.op) { case "move": continue; case "lineTo": if (!prevPoint) { throw new Error("prevPoint is undefined"); } lines.push( lineSegment( pointFrom( element.x + prevPoint[0], element.y + prevPoint[1], ), pointFrom( element.x + op.data[0], element.y + op.data[1], ), ), ); continue; case "bcurveTo": if (!prevPoint) { throw new Error("prevPoint is undefined"); } curves.push( curve( pointFrom( element.x + prevPoint[0], element.y + prevPoint[1], ), pointFrom( element.x + op.data[0], element.y + op.data[1], ), pointFrom( element.x + op.data[2], element.y + op.data[3], ), pointFrom( element.x + op.data[4], element.y + op.data[5], ), ), ); continue; default: { console.error("Unknown op type", op.op); } } } const shape = [lines, curves] as ElementShape; setElementShapesCacheEntry(element, shape, 0); return shape; } /** * Get the building components of a rectanguloid element in the form of * line segments and curves **unrotated**. * * @param element Target rectanguloid element * @param offset Optional offset to expand the rectanguloid shape * @returns Tuple of **unrotated** line segments (0) and curves (1) */ export function deconstructRectanguloidElement( element: ExcalidrawRectanguloidElement, offset: number = 0, ): [LineSegment[], Curve[]] { const cachedShape = getElementShapesCacheEntry(element, offset); if (cachedShape) { return cachedShape; } let radius = getCornerRadius( Math.min(element.width, element.height), element, ); if (radius === 0) { radius = 0.01; } const r = rectangle( pointFrom(element.x, element.y), pointFrom(element.x + element.width, element.y + element.height), ); const top = lineSegment( pointFrom(r[0][0] + radius, r[0][1]), pointFrom(r[1][0] - radius, r[0][1]), ); const right = lineSegment( pointFrom(r[1][0], r[0][1] + radius), pointFrom(r[1][0], r[1][1] - radius), ); const bottom = lineSegment( pointFrom(r[0][0] + radius, r[1][1]), pointFrom(r[1][0] - radius, r[1][1]), ); const left = lineSegment( pointFrom(r[0][0], r[1][1] - radius), pointFrom(r[0][0], r[0][1] + radius), ); const baseCorners = [ curve( left[1], pointFrom( left[1][0] + (2 / 3) * (r[0][0] - left[1][0]), left[1][1] + (2 / 3) * (r[0][1] - left[1][1]), ), pointFrom( top[0][0] + (2 / 3) * (r[0][0] - top[0][0]), top[0][1] + (2 / 3) * (r[0][1] - top[0][1]), ), top[0], ), // TOP LEFT curve( top[1], pointFrom( top[1][0] + (2 / 3) * (r[1][0] - top[1][0]), top[1][1] + (2 / 3) * (r[0][1] - top[1][1]), ), pointFrom( right[0][0] + (2 / 3) * (r[1][0] - right[0][0]), right[0][1] + (2 / 3) * (r[0][1] - right[0][1]), ), right[0], ), // TOP RIGHT curve( right[1], pointFrom( right[1][0] + (2 / 3) * (r[1][0] - right[1][0]), right[1][1] + (2 / 3) * (r[1][1] - right[1][1]), ), pointFrom( bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]), bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]), ), bottom[1], ), // BOTTOM RIGHT curve( bottom[0], pointFrom( bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]), bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]), ), pointFrom( left[0][0] + (2 / 3) * (r[0][0] - left[0][0]), left[0][1] + (2 / 3) * (r[1][1] - left[0][1]), ), left[0], ), // BOTTOM LEFT ]; const corners = offset > 0 ? baseCorners.map( (corner) => curveCatmullRomCubicApproxPoints( curveOffsetPoints(corner, offset), )!, ) : [ [baseCorners[0]], [baseCorners[1]], [baseCorners[2]], [baseCorners[3]], ]; const sides = [ lineSegment( corners[0][corners[0].length - 1][3], corners[1][0][0], ), lineSegment( corners[1][corners[1].length - 1][3], corners[2][0][0], ), lineSegment( corners[2][corners[2].length - 1][3], corners[3][0][0], ), lineSegment( corners[3][corners[3].length - 1][3], corners[0][0][0], ), ]; const shape = [sides, corners.flat()] as ElementShape; setElementShapesCacheEntry(element, shape, offset); return shape; } export function getDiamondBaseCorners( element: ExcalidrawDiamondElement, offset: number = 0, ): Curve[] { const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = getDiamondPoints(element); const verticalRadius = element.roundness ? getCornerRadius(Math.abs(topX - leftX), element) : (topX - leftX) * 0.01; const horizontalRadius = element.roundness ? getCornerRadius(Math.abs(rightY - topY), element) : (rightY - topY) * 0.01; const [top, right, bottom, left]: GlobalPoint[] = [ pointFrom(element.x + topX, element.y + topY), pointFrom(element.x + rightX, element.y + rightY), pointFrom(element.x + bottomX, element.y + bottomY), pointFrom(element.x + leftX, element.y + leftY), ]; return [ curve( pointFrom( right[0] - verticalRadius, right[1] - horizontalRadius, ), right, right, pointFrom( right[0] - verticalRadius, right[1] + horizontalRadius, ), ), // RIGHT curve( pointFrom( bottom[0] + verticalRadius, bottom[1] - horizontalRadius, ), bottom, bottom, pointFrom( bottom[0] - verticalRadius, bottom[1] - horizontalRadius, ), ), // BOTTOM curve( pointFrom( left[0] + verticalRadius, left[1] + horizontalRadius, ), left, left, pointFrom( left[0] + verticalRadius, left[1] - horizontalRadius, ), ), // LEFT curve( pointFrom( top[0] - verticalRadius, top[1] + horizontalRadius, ), top, top, pointFrom( top[0] + verticalRadius, top[1] + horizontalRadius, ), ), // TOP ]; } /** * Get the **unrotated** building components of a diamond element * in the form of line segments and curves as a tuple, in this order. * * @param element The element to deconstruct * @param offset An optional offset * @returns Tuple of line **unrotated** segments (0) and curves (1) */ export function deconstructDiamondElement( element: ExcalidrawDiamondElement, offset: number = 0, ): [LineSegment[], Curve[]] { const cachedShape = getElementShapesCacheEntry(element, offset); if (cachedShape) { return cachedShape; } const baseCorners = getDiamondBaseCorners(element, offset); const corners = baseCorners.map( (corner) => curveCatmullRomCubicApproxPoints(curveOffsetPoints(corner, offset))!, ); const sides = [ lineSegment( corners[0][corners[0].length - 1][3], corners[1][0][0], ), lineSegment( corners[1][corners[1].length - 1][3], corners[2][0][0], ), lineSegment( corners[2][corners[2].length - 1][3], corners[3][0][0], ), lineSegment( corners[3][corners[3].length - 1][3], corners[0][0][0], ), ]; const shape = [sides, corners.flat()] as ElementShape; setElementShapesCacheEntry(element, shape, offset); return shape; } // Checks if the first and last point are close enough // to be considered a loop export const isPathALoop = ( points: ExcalidrawLinearElement["points"], /** supply if you want the loop detection to account for current zoom */ zoomValue: Zoom["value"] = 1 as NormalizedZoomValue, ): boolean => { if (points.length >= 3) { const [first, last] = [points[0], points[points.length - 1]]; const distance = pointDistance(first, last); // Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in // really close we make the threshold smaller, and vice versa. return distance <= LINE_CONFIRM_THRESHOLD / zoomValue; } return false; }; export const getCornerRadius = (x: number, element: ExcalidrawElement) => { if ( element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS || element.roundness?.type === ROUNDNESS.LEGACY ) { return x * DEFAULT_PROPORTIONAL_RADIUS; } if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) { const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS; const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS; if (x <= CUTOFF_SIZE) { return x * DEFAULT_PROPORTIONAL_RADIUS; } return fixedRadiusSize; } return 0; }; const getDiagonalsForBindableElement = ( element: ExcalidrawElement, elementsMap: ElementsMap, ) => { // for rectangles, shrink the diagonals a bit because there's something // going on with the focus points around the corners. Ask Mark for details. const OFFSET_PX = element.type === "rectangle" ? 15 : 0; const shrinkSegment = (seg: LineSegment) => { const v = vectorNormalize(vectorFromPoint(seg[1], seg[0])); const offset = vectorScale(v, OFFSET_PX); return lineSegment( pointTranslate(seg[0], offset), pointTranslate(seg[1], vectorScale(offset, -1)), ); }; const center = elementCenterPoint(element, elementsMap); const diagonalOne = shrinkSegment( isRectangularElement(element) ? lineSegment( pointRotateRads( pointFrom(element.x, element.y), center, element.angle, ), pointRotateRads( pointFrom( element.x + element.width, element.y + element.height, ), center, element.angle, ), ) : lineSegment( pointRotateRads( pointFrom(element.x + element.width / 2, element.y), center, element.angle, ), pointRotateRads( pointFrom( element.x + element.width / 2, element.y + element.height, ), center, element.angle, ), ), ); const diagonalTwo = shrinkSegment( isRectangularElement(element) ? lineSegment( pointRotateRads( pointFrom(element.x + element.width, element.y), center, element.angle, ), pointRotateRads( pointFrom(element.x, element.y + element.height), center, element.angle, ), ) : lineSegment( pointRotateRads( pointFrom(element.x, element.y + element.height / 2), center, element.angle, ), pointRotateRads( pointFrom( element.x + element.width, element.y + element.height / 2, ), center, element.angle, ), ), ); return [diagonalOne, diagonalTwo]; }; export const getSnapOutlineMidPoint = ( point: GlobalPoint, element: ExcalidrawBindableElement, elementsMap: ElementsMap, zoom: AppState["zoom"], ) => { const center = elementCenterPoint(element, elementsMap); const sideMidpoints = element.type === "diamond" ? getDiamondBaseCorners(element).map((curve) => { const point = bezierEquation(curve, 0.5); const rotatedPoint = pointRotateRads(point, center, element.angle); return pointFrom(rotatedPoint[0], rotatedPoint[1]); }) : [ // RIGHT midpoint pointRotateRads( pointFrom( element.x + element.width, element.y + element.height / 2, ), center, element.angle, ), // BOTTOM midpoint pointRotateRads( pointFrom( element.x + element.width / 2, element.y + element.height, ), center, element.angle, ), // LEFT midpoint pointRotateRads( pointFrom(element.x, element.y + element.height / 2), center, element.angle, ), // TOP midpoint pointRotateRads( pointFrom(element.x + element.width / 2, element.y), center, element.angle, ), ]; const candidate = sideMidpoints.find( (midpoint) => pointDistance(point, midpoint) <= maxBindingDistance_simple(zoom) + element.strokeWidth / 2 && !hitElementItself({ point, element, threshold: 0, elementsMap, overrideShouldTestInside: true, }), ); return candidate; }; export const projectFixedPointOntoDiagonal = ( arrow: ExcalidrawArrowElement, point: GlobalPoint, element: ExcalidrawBindableElement, startOrEnd: "start" | "end", elementsMap: ElementsMap, zoom: AppState["zoom"], isMidpointSnappingEnabled: boolean = true, ): GlobalPoint | null => { invariant(arrow.points.length >= 2, "Arrow must have at least two points"); if (arrow.width < 3 && arrow.height < 3) { return null; } if (isMidpointSnappingEnabled) { const sideMidPoint = getSnapOutlineMidPoint( point, element, elementsMap, zoom, ); if (sideMidPoint) { return sideMidPoint; } } // Do the projection onto the diagonals (or center lines // for non-rectangular shapes) const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement( element, elementsMap, ); // To avoid working with stale arrow state, we use the opposite focus point // of the current endpoint, which will always be unchanged during moving of // the endpoint. This is only needed when the arrow has only two points. let a = LinearElementEditor.getPointAtIndexGlobalCoordinates( arrow, startOrEnd === "start" ? 1 : arrow.points.length - 2, elementsMap, ); if (arrow.points.length === 2) { const otherBinding = startOrEnd === "start" ? arrow.endBinding : arrow.startBinding; const otherBindable = otherBinding && (elementsMap.get(otherBinding.elementId) as | ExcalidrawBindableElement | undefined); const otherFocusPoint = otherBinding && otherBindable && getGlobalFixedPointForBindableElement( normalizeFixedPoint(otherBinding.fixedPoint), otherBindable, elementsMap, ); if (otherFocusPoint) { a = otherFocusPoint; } } const b = pointFromVector( vectorScale( vectorFromPoint(point, a), 2 * pointDistance(a, point) + Math.max( pointDistance(diagonalOne[0], diagonalOne[1]), pointDistance(diagonalTwo[0], diagonalTwo[1]), ), ), a, ); const intersector = lineSegment(b, a); const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector); const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector); const d1 = p1 && pointDistance(a, p1); const d2 = p2 && pointDistance(a, p2); let projection = null; if (d1 != null && d2 != null) { projection = d1 < d2 ? p1 : p2; } else { projection = p1 || p2 || null; } if (projection && isPointInElement(projection, element, elementsMap)) { return projection; } return null; };