From 3b47fe2f87b2c9a8e20ca00037af6251e6de96a7 Mon Sep 17 00:00:00 2001 From: dwelle <5153846+dwelle@users.noreply.github.com> Date: Sat, 21 Mar 2026 20:50:19 +0100 Subject: [PATCH] [debug] arrow control points --- excalidraw-app/components/DebugCanvas.tsx | 614 ++++++++++++- packages/element/src/shape.ts | 833 ++++++++++++++++-- packages/excalidraw/global.d.ts | 2 + .../excalidraw/renderer/interactiveScene.ts | 118 +++ 4 files changed, 1455 insertions(+), 112 deletions(-) diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index d9279296f7..f5ae90ff7a 100644 --- a/excalidraw-app/components/DebugCanvas.tsx +++ b/excalidraw-app/components/DebugCanvas.tsx @@ -3,23 +3,39 @@ import { CloseIcon, TrashIcon, } from "@excalidraw/excalidraw/components/icons"; +import { useExcalidrawAPI } from "@excalidraw/excalidraw/index"; import { bootstrapCanvas, + fillCircle, getNormalizedCanvasDimensions, } from "@excalidraw/excalidraw/renderer/helpers"; import { type AppState } from "@excalidraw/excalidraw/types"; -import { arrayToMap, throttleRAF } from "@excalidraw/common"; -import { useCallback } from "react"; +import { + arrayToMap, + sceneCoordsToViewportCoords, + throttleRAF, + viewportCoordsToSceneCoords, +} from "@excalidraw/common"; +import { useCallback, useEffect, useRef, useState } from "react"; import { + CaptureUpdateAction, + clearSimpleArrowTangentOverride, getGlobalFixedPointForBindableElement, + getElementAbsoluteCoords, + getSimpleArrowCurveDebugData, isArrowElement, isBindableElement, + setSimpleArrowTangentOverride, + ShapeCache, } from "@excalidraw/element"; import { isLineSegment, + pointFrom, + pointRotateRads, type GlobalPoint, + type LocalPoint, type LineSegment, } from "@excalidraw/math"; import { isCurve } from "@excalidraw/math/curve"; @@ -38,9 +54,257 @@ import type { FixedPointBinding, OrderedExcalidrawElement, } from "@excalidraw/element/types"; +import type { SimpleArrowCurveDebugData } from "@excalidraw/element"; import { STORAGE_KEYS } from "../app_constants"; +type DebugCanvasOverlayState = Pick< + AppState, + | "width" + | "height" + | "zoom" + | "scrollX" + | "scrollY" + | "offsetLeft" + | "offsetTop" + | "selectedElementIds" +>; + +type SimpleArrowHandleDirection = "incoming" | "outgoing"; + +type SimpleArrowHandleDescriptor = { + key: string; + elementId: string; + pointIndex: number; + direction: SimpleArrowHandleDirection; + point: GlobalPoint; + handle: GlobalPoint; + isOverridden: boolean; + title: string; +}; + +type SelectedSimpleArrowDebugState = { + element: ExcalidrawArrowElement; + debugData: SimpleArrowCurveDebugData; + handles: SimpleArrowHandleDescriptor[]; +}; + +const areSimpleArrowTangentHandlesEnabled = () => + window.EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENTS !== false; + +const pickOverlayState = (appState: AppState): DebugCanvasOverlayState => ({ + width: appState.width, + height: appState.height, + zoom: appState.zoom, + scrollX: appState.scrollX, + scrollY: appState.scrollY, + offsetLeft: appState.offsetLeft, + offsetTop: appState.offsetTop, + selectedElementIds: appState.selectedElementIds, +}); + +const getSimpleArrowTransform = ( + element: ExcalidrawArrowElement, + elementsMap: ElementsMap, +) => { + const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); + const center = pointFrom(cx, cy); + + return { + pointToGlobal: (point: LocalPoint): GlobalPoint => { + const rotated = pointRotateRads( + pointFrom(element.x + point[0], element.y + point[1]), + center, + element.angle, + ); + + return pointFrom(rotated[0], rotated[1]); + }, + vectorToGlobal: (vector: [number, number]): [number, number] => { + const rotated = pointRotateRads( + pointFrom(vector[0], vector[1]), + pointFrom(0, 0), + element.angle, + ); + + return [rotated[0], rotated[1]]; + }, + vectorToLocal: (vector: [number, number]): [number, number] => { + const rotated = pointRotateRads( + pointFrom(vector[0], vector[1]), + pointFrom(0, 0), + -element.angle, + ); + + return [rotated[0], rotated[1]]; + }, + }; +}; + +const getSimpleArrowDebugStateForElement = ( + elements: readonly OrderedExcalidrawElement[], + elementId: string, +): SelectedSimpleArrowDebugState | null => { + const element = elements.find( + (candidate): candidate is ExcalidrawArrowElement => + candidate.id === elementId && + !candidate.isDeleted && + isArrowElement(candidate) && + !!candidate.roundness && + !candidate.elbowed, + ); + + if (!element) { + return null; + } + + const elementsMap = arrayToMap(elements); + const transform = getSimpleArrowTransform(element, elementsMap); + const localDebugData = getSimpleArrowCurveDebugData(element.points, 0.5, { + elementId: element.id, + }); + const debugData: SimpleArrowCurveDebugData = { + ...localDebugData, + tangents: localDebugData.tangents.map((tangent) => ({ + ...tangent, + point: transform.pointToGlobal(tangent.point as LocalPoint), + base: transform.vectorToGlobal(tangent.base), + autoScaled: transform.vectorToGlobal(tangent.autoScaled), + scaled: transform.vectorToGlobal(tangent.scaled), + })), + segments: localDebugData.segments.map((segment) => ({ + ...segment, + start: transform.pointToGlobal(segment.start as LocalPoint), + end: transform.pointToGlobal(segment.end as LocalPoint), + baseCp1: transform.pointToGlobal(segment.baseCp1 as LocalPoint), + baseCp2: transform.pointToGlobal(segment.baseCp2 as LocalPoint), + cp1: transform.pointToGlobal(segment.cp1 as LocalPoint), + cp2: transform.pointToGlobal(segment.cp2 as LocalPoint), + })), + }; + const handles = debugData.tangents.flatMap((tangent, pointIndex) => { + const descriptors: SimpleArrowHandleDescriptor[] = []; + const lengthRatio = + tangent.normalized.finalLengthVsMinNeighbor === null + ? "n/a" + : tangent.normalized.finalLengthVsMinNeighbor.toFixed(3); + const angleDelta = tangent.normalized.angleDelta.toFixed(3); + const title = `point ${pointIndex} · len/min ${lengthRatio} · dAngle ${angleDelta} · dblclick resets`; + + if (pointIndex < debugData.tangents.length - 1) { + descriptors.push({ + key: `${element.id}:${pointIndex}:out`, + elementId: element.id, + pointIndex, + direction: "outgoing", + point: tangent.point, + handle: pointFrom( + tangent.point[0] + tangent.scaled[0] / 3, + tangent.point[1] + tangent.scaled[1] / 3, + ), + isOverridden: tangent.isOverridden, + title, + }); + } + + if (pointIndex > 0) { + descriptors.push({ + key: `${element.id}:${pointIndex}:in`, + elementId: element.id, + pointIndex, + direction: "incoming", + point: tangent.point, + handle: pointFrom( + tangent.point[0] - tangent.scaled[0] / 3, + tangent.point[1] - tangent.scaled[1] / 3, + ), + isOverridden: tangent.isOverridden, + title, + }); + } + + return descriptors; + }); + + return { + element, + debugData, + handles, + }; +}; + +const getSelectedSimpleArrowDebugState = ( + elements: readonly OrderedExcalidrawElement[], + appState: Pick, +) => { + const selectedIds = Object.keys(appState.selectedElementIds); + + if (selectedIds.length !== 1) { + return null; + } + + return getSimpleArrowDebugStateForElement(elements, selectedIds[0]); +}; + +const renderSelectedSimpleArrowTangentOverlay = ( + context: CanvasRenderingContext2D, + zoom: number, + debugState: SelectedSimpleArrowDebugState, +) => { + context.save(); + context.lineWidth = 1; + + context.setLineDash([6, 4]); + context.strokeStyle = "rgba(134, 142, 150, 0.75)"; + + for (const segment of debugState.debugData.segments) { + context.beginPath(); + context.moveTo(segment.start[0] * zoom, segment.start[1] * zoom); + context.lineTo(segment.baseCp1[0] * zoom, segment.baseCp1[1] * zoom); + context.lineTo(segment.baseCp2[0] * zoom, segment.baseCp2[1] * zoom); + context.lineTo(segment.end[0] * zoom, segment.end[1] * zoom); + context.stroke(); + } + + context.setLineDash([]); + + for (const segment of debugState.debugData.segments) { + context.strokeStyle = segment.overshootsBaseline + ? "rgba(245, 159, 0, 0.9)" + : "rgba(94, 90, 216, 0.85)"; + + context.beginPath(); + context.moveTo(segment.start[0] * zoom, segment.start[1] * zoom); + context.lineTo(segment.cp1[0] * zoom, segment.cp1[1] * zoom); + context.lineTo(segment.cp2[0] * zoom, segment.cp2[1] * zoom); + context.lineTo(segment.end[0] * zoom, segment.end[1] * zoom); + context.stroke(); + } + + for (const tangent of debugState.debugData.tangents) { + if (!tangent.isAdjusted) { + continue; + } + + context.strokeStyle = tangent.isOverridden + ? "rgba(230, 73, 128, 0.95)" + : "rgba(201, 42, 42, 0.85)"; + context.fillStyle = tangent.isOverridden + ? "rgba(230, 73, 128, 0.95)" + : "rgba(201, 42, 42, 0.95)"; + + fillCircle( + context, + tangent.point[0] * zoom, + tangent.point[1] * zoom, + 3, + true, + ); + } + + context.restore(); +}; + const renderLine = ( context: CanvasRenderingContext2D, zoom: number, @@ -359,6 +623,13 @@ const _debugRenderer = ( renderOrigin(context, appState.zoom.value); renderBindings(context, elements, appState.zoom.value); + const selectedSimpleArrowDebugState = areSimpleArrowTangentHandlesEnabled() + ? getSelectedSimpleArrowDebugState(elements, appState) + : null; + + window.EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW = + selectedSimpleArrowDebugState?.debugData; + if ( window.visualDebug?.currentFrame && window.visualDebug?.data && @@ -375,6 +646,14 @@ const _debugRenderer = ( }); } + if (selectedSimpleArrowDebugState) { + renderSelectedSimpleArrowTangentOverlay( + context, + appState.zoom.value, + selectedSimpleArrowDebugState, + ); + } + if (window.visualDebug) { window.visualDebug!.data = window.visualDebug?.data.map((frame) => @@ -542,23 +821,324 @@ interface DebugCanvasProps { const DebugCanvas = React.forwardRef( ({ appState, scale }, ref) => { - const { width, height } = appState; + const excalidrawAPI = useExcalidrawAPI(); + const [overlayState, setOverlayState] = useState( + () => pickOverlayState(appState), + ); + const [selectedSimpleArrowDebugState, setSelectedSimpleArrowDebugState] = + useState(null); + const dragStateRef = useRef<{ + elementId: string; + pointIndex: number; + direction: SimpleArrowHandleDirection; + } | null>(null); + + const syncSelectedSimpleArrowDebugState = useCallback( + ( + nextAppState: AppState, + nextElements?: readonly OrderedExcalidrawElement[], + ) => { + if (!excalidrawAPI || excalidrawAPI.isDestroyed) { + return; + } + + const elements = + nextElements ?? + (excalidrawAPI.getSceneElements() as OrderedExcalidrawElement[]); + + setOverlayState(pickOverlayState(nextAppState)); + + const nextDebugState = areSimpleArrowTangentHandlesEnabled() + ? getSelectedSimpleArrowDebugState(elements, nextAppState) + : null; + + window.EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW = + nextDebugState?.debugData; + setSelectedSimpleArrowDebugState(nextDebugState); + }, + [excalidrawAPI], + ); + + useEffect(() => { + if (!excalidrawAPI || excalidrawAPI.isDestroyed) { + return; + } + + syncSelectedSimpleArrowDebugState(excalidrawAPI.getAppState()); + + const unsubscribeChange = excalidrawAPI.onChange( + (elements, nextAppState) => + syncSelectedSimpleArrowDebugState( + nextAppState, + elements as readonly OrderedExcalidrawElement[], + ), + ); + const unsubscribeState = excalidrawAPI.onStateChange( + [ + "selectedElementIds", + "zoom", + "scrollX", + "scrollY", + "offsetLeft", + "offsetTop", + "width", + "height", + ], + (_value, nextAppState) => + syncSelectedSimpleArrowDebugState(nextAppState), + ); + + return () => { + unsubscribeChange(); + unsubscribeState(); + }; + }, [excalidrawAPI, syncSelectedSimpleArrowDebugState]); + + const rerenderSceneForSimpleArrowOverride = useCallback(() => { + if (!excalidrawAPI || excalidrawAPI.isDestroyed) { + return; + } + + excalidrawAPI.updateScene({ + elements: excalidrawAPI.getSceneElementsIncludingDeleted(), + captureUpdate: CaptureUpdateAction.NEVER, + }); + }, [excalidrawAPI]); + + const commitOverrideForHandle = useCallback( + ( + handle: Pick< + SimpleArrowHandleDescriptor, + "elementId" | "pointIndex" | "direction" + >, + clientX: number, + clientY: number, + ) => { + if (!excalidrawAPI || excalidrawAPI.isDestroyed) { + return; + } + + const nextAppState = excalidrawAPI.getAppState(); + const nextElements = + excalidrawAPI.getSceneElements() as OrderedExcalidrawElement[]; + const debugState = getSimpleArrowDebugStateForElement( + nextElements, + handle.elementId, + ); + + if (!debugState) { + return; + } + + const scenePoint = viewportCoordsToSceneCoords( + { clientX, clientY }, + nextAppState, + ); + const tangent = + handle.direction === "outgoing" + ? ([ + (scenePoint.x - + debugState.debugData.tangents[handle.pointIndex].point[0]) * + 3, + (scenePoint.y - + debugState.debugData.tangents[handle.pointIndex].point[1]) * + 3, + ] as [number, number]) + : ([ + (debugState.debugData.tangents[handle.pointIndex].point[0] - + scenePoint.x) * + 3, + (debugState.debugData.tangents[handle.pointIndex].point[1] - + scenePoint.y) * + 3, + ] as [number, number]); + const localTangent = getSimpleArrowTransform( + debugState.element, + arrayToMap(nextElements), + ).vectorToLocal(tangent); + + setSimpleArrowTangentOverride( + debugState.element.id, + handle.pointIndex, + localTangent, + ); + ShapeCache.delete(debugState.element); + rerenderSceneForSimpleArrowOverride(); + syncSelectedSimpleArrowDebugState(nextAppState, nextElements); + }, + [ + excalidrawAPI, + rerenderSceneForSimpleArrowOverride, + syncSelectedSimpleArrowDebugState, + ], + ); + + const onPointerMove = useCallback( + (event: PointerEvent) => { + if (!dragStateRef.current) { + return; + } + + event.preventDefault(); + commitOverrideForHandle( + dragStateRef.current, + event.clientX, + event.clientY, + ); + }, + [commitOverrideForHandle], + ); + + const onPointerUp = useCallback( + (event: PointerEvent) => { + if (!dragStateRef.current) { + return; + } + + event.preventDefault(); + dragStateRef.current = null; + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerUp); + }, + [onPointerMove], + ); + + useEffect(() => { + return () => { + dragStateRef.current = null; + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerUp); + }; + }, [onPointerMove, onPointerUp]); + + const handlePointerDown = useCallback( + ( + handle: SimpleArrowHandleDescriptor, + event: React.PointerEvent, + ) => { + event.preventDefault(); + event.stopPropagation(); + + dragStateRef.current = { + elementId: handle.elementId, + pointIndex: handle.pointIndex, + direction: handle.direction, + }; + + window.addEventListener("pointermove", onPointerMove, { + passive: false, + }); + window.addEventListener("pointerup", onPointerUp, { + passive: false, + }); + window.addEventListener("pointercancel", onPointerUp, { + passive: false, + }); + + commitOverrideForHandle(handle, event.clientX, event.clientY); + }, + [commitOverrideForHandle, onPointerMove, onPointerUp], + ); + + const resetHandleOverride = useCallback( + ( + handle: SimpleArrowHandleDescriptor, + event: React.MouseEvent, + ) => { + event.preventDefault(); + event.stopPropagation(); + + if (!excalidrawAPI || excalidrawAPI.isDestroyed) { + return; + } + + clearSimpleArrowTangentOverride(handle.elementId, handle.pointIndex); + + const nextElements = + excalidrawAPI.getSceneElements() as OrderedExcalidrawElement[]; + const element = nextElements.find( + (candidate): candidate is ExcalidrawArrowElement => + candidate.id === handle.elementId && + !candidate.isDeleted && + isArrowElement(candidate), + ); + + if (element) { + ShapeCache.delete(element); + rerenderSceneForSimpleArrowOverride(); + } + + syncSelectedSimpleArrowDebugState( + excalidrawAPI.getAppState(), + nextElements, + ); + }, + [ + excalidrawAPI, + rerenderSceneForSimpleArrowOverride, + syncSelectedSimpleArrowDebugState, + ], + ); + + const { width, height } = overlayState; return ( - - Debug Canvas - + <> + + Debug Canvas + + {selectedSimpleArrowDebugState?.handles.map((handle) => { + const { x, y } = sceneCoordsToViewportCoords( + { + sceneX: handle.handle[0], + sceneY: handle.handle[1], + }, + overlayState, + ); + + return ( +
handlePointerDown(handle, event)} + onDoubleClick={(event) => resetHandleOverride(handle, event)} + style={{ + position: "absolute", + left: x - overlayState.offsetLeft, + top: y - overlayState.offsetTop, + width: 12, + height: 12, + zIndex: 3, + borderRadius: handle.direction === "outgoing" ? "999px" : 3, + transform: "translate(-50%, -50%)", + background: handle.isOverridden + ? "rgba(230, 73, 128, 0.95)" + : handle.direction === "outgoing" + ? "rgba(94, 90, 216, 0.95)" + : "rgba(245, 159, 0, 0.95)", + border: "2px solid rgba(255,255,255,0.95)", + boxShadow: "0 0 0 1px rgba(0, 0, 0, 0.18)", + pointerEvents: "auto", + cursor: "grab", + }} + /> + ); + })} + ); }, ); diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts index 900095392c..6926bd37e3 100644 --- a/packages/element/src/shape.ts +++ b/packages/element/src/shape.ts @@ -634,9 +634,8 @@ export const generateLinearCollisionShape = ( }); } - // Generate collision ops using the same Catmull-Rom → cubic Bézier - // algorithm as generateSimpleArrowShape so hit-testing matches rendering. - const tension = 0.5; + // Rotate the same cubic ops used for rendering so hit-testing matches the + // visible arrow path. const rotateLocal = (lx: number, ly: number): LocalPoint => { const g = pointRotateRads( pointFrom(element.x + lx, element.y + ly), @@ -646,60 +645,23 @@ export const generateLinearCollisionShape = ( return pointFrom(g[0] - element.x, g[1] - element.y); }; - const collisionOps: Array<{ - op: string; - data: number[] | LocalPoint; - }> = []; - collisionOps.push({ - op: "move", - data: rotateLocal(points[0][0], points[0][1]), - }); + return generateSimpleArrowPathOps(points, 0.5, element.id).map((op) => { + if (op.op === "bcurveTo") { + const rcp1 = rotateLocal(op.data[0], op.data[1]); + const rcp2 = rotateLocal(op.data[2], op.data[3]); + const rend = rotateLocal(op.data[4], op.data[5]); - if (points.length === 2) { - collisionOps.push({ - op: "lineTo", - data: rotateLocal(points[1][0], points[1][1]), - }); - } else { - const n = points.length; - const ptx = new Float64Array(n); - const pty = new Float64Array(n); - for (let i = 0; i < n; i++) { - if (i === 0) { - const pbx = 3 * points[0][0] - 3 * points[1][0] + points[2][0]; - const pby = 3 * points[0][1] - 3 * points[1][1] + points[2][1]; - ptx[i] = tension * (points[1][0] - pbx); - pty[i] = tension * (points[1][1] - pby); - } else if (i === n - 1) { - const pax = - 3 * points[n - 1][0] - 3 * points[n - 2][0] + points[n - 3][0]; - const pay = - 3 * points[n - 1][1] - 3 * points[n - 2][1] + points[n - 3][1]; - ptx[i] = tension * (pax - points[n - 2][0]); - pty[i] = tension * (pay - points[n - 2][1]); - } else { - ptx[i] = tension * (points[i + 1][0] - points[i - 1][0]); - pty[i] = tension * (points[i + 1][1] - points[i - 1][1]); - } - } - - for (let i = 0; i < n - 1; i++) { - const cp1x = points[i][0] + ptx[i] / 3; - const cp1y = points[i][1] + pty[i] / 3; - const cp2x = points[i + 1][0] - ptx[i + 1] / 3; - const cp2y = points[i + 1][1] - pty[i + 1] / 3; - - const rcp1 = rotateLocal(cp1x, cp1y); - const rcp2 = rotateLocal(cp2x, cp2y); - const rend = rotateLocal(points[i + 1][0], points[i + 1][1]); - - collisionOps.push({ + return { op: "bcurveTo", data: [rcp1[0], rcp1[1], rcp2[0], rcp2[1], rend[0], rend[1]], - }); + }; } - } - return collisionOps; + + return { + op: op.op, + data: rotateLocal(op.data[0], op.data[1]), + }; + }); } case "freedraw": { if (element.points.length < 2) { @@ -943,7 +905,7 @@ const _generateElementShape = ( } else { shape = [ generator.path( - generateSimpleArrowShape(points, 0.5), + generateSimpleArrowShape(points, 0.5, element.id), generateRoughOptions(element, true, isDarkMode), ), ]; @@ -1030,67 +992,748 @@ const _generateElementShape = ( } }; -const generateSimpleArrowShape = ( - points: readonly LocalPoint[], - tension = 0.5, -): string => { - if (points.length < 2) { - return ""; +type SimpleArrowPathOp = + | { op: "move" | "lineTo"; data: LocalPoint } + | { op: "bcurveTo"; data: [number, number, number, number, number, number] }; + +const SIMPLE_ARROW_OVERSHOOT_EPSILON = 0.5; +const SIMPLE_ARROW_SCALE_EPSILON = 1e-4; +const SIMPLE_ARROW_SCALE_SEARCH_STEPS = 24; +const SIMPLE_ARROW_SCALE_PASSES = 8; + +type SimpleArrowVector = [number, number]; + +type SimpleArrowTangentOverrides = Record< + string, + Record +>; + +declare global { + interface Window { + EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENT_OVERRIDES?: + | SimpleArrowTangentOverrides + | undefined; + } +} + +type SimpleArrowCurveDebugDataOptions = { + elementId?: string; +}; + +export type SimpleArrowCurveDebugData< + Point extends GlobalPoint | LocalPoint = LocalPoint, +> = { + elementId?: string; + tangents: Array<{ + point: Point; + base: SimpleArrowVector; + autoScaled: SimpleArrowVector; + scale: number; + autoScale: number; + scaled: SimpleArrowVector; + isAdjusted: boolean; + isOverridden: boolean; + normalized: { + baseLength: number; + autoLength: number; + finalLength: number; + prevSegmentLength: number | null; + nextSegmentLength: number | null; + minNeighborLength: number | null; + finalLengthVsMinNeighbor: number | null; + autoLengthVsMinNeighbor: number | null; + angleDelta: number; + turnAngle: number | null; + }; + }>; + segments: Array<{ + start: Point; + end: Point; + baseCp1: Point; + baseCp2: Point; + cp1: Point; + cp2: Point; + overshootsBaseline: boolean; + overshootsResolved: boolean; + metrics: { + chordLength: number; + baseStartProjection: number; + baseEndProjection: number; + finalStartProjection: number; + finalEndProjection: number; + }; + }>; + inference: { + overriddenPointIndices: number[]; + }; +}; + +const SIMPLE_ARROW_ADJUSTMENT_EPSILON = 1e-3; + +const getSimpleArrowTangentOverrideStore = () => { + if (typeof window === "undefined") { + return null; } - if (points.length === 2) { - return `M ${points[0][0]} ${points[0][1]} L ${points[1][0]} ${points[1][1]}`; + window.EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENT_OVERRIDES ??= {}; + return window.EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENT_OVERRIDES; +}; + +export const setSimpleArrowTangentOverride = ( + elementId: string, + pointIndex: number, + tangent: SimpleArrowVector, +) => { + const store = getSimpleArrowTangentOverrideStore(); + + if (!store) { + return; } - // Catmull-Rom spline converted to cubic Bézier segments. - // Tangents are computed from neighboring points (one-sided at endpoints), - // guaranteeing C1 continuity — smooth tangent direction at every data point - // with no pinching at segment joints. - // - // tension=0 → straight lines; tension=0.5 → standard Catmull-Rom. - const n = points.length; + store[elementId] = { + ...(store[elementId] ?? {}), + [pointIndex]: [tangent[0], tangent[1]], + }; +}; - // Compute tangent vectors at each point. - const tx = new Float64Array(n); - const ty = new Float64Array(n); - // Quadratic-extrapolation phantom points so endpoints use the same - // central-difference formula as interior points, preventing degenerate - // (chord-parallel) first/last segments. - // phantom_before = 3*P[0] - 3*P[1] + P[2] - // phantom_after = 3*P[n-1] - 3*P[n-2] + P[n-3] - for (let i = 0; i < n; i++) { +export const clearSimpleArrowTangentOverride = ( + elementId: string, + pointIndex?: number, +) => { + const store = getSimpleArrowTangentOverrideStore(); + + if (!store?.[elementId]) { + return; + } + + if (typeof pointIndex === "number") { + delete store[elementId][pointIndex]; + + if (Object.keys(store[elementId]).length === 0) { + delete store[elementId]; + } + return; + } + + delete store[elementId]; +}; + +const getSimpleArrowTangentOverrides = (elementId?: string) => { + if (!elementId) { + return null; + } + + return getSimpleArrowTangentOverrideStore()?.[elementId] ?? null; +}; + +const getSimpleArrowVectorLength = ([x, y]: SimpleArrowVector) => + Math.hypot(x, y); + +const normalizeSimpleArrowAngle = (angle: number) => { + let normalized = angle; + + while (normalized <= -Math.PI) { + normalized += Math.PI * 2; + } + while (normalized > Math.PI) { + normalized -= Math.PI * 2; + } + + return normalized; +}; + +const getSimpleArrowBezierValue = ( + p0: number, + p1: number, + p2: number, + p3: number, + t: number, +) => { + const mt = 1 - t; + + return ( + mt * mt * mt * p0 + + 3 * mt * mt * t * p1 + + 3 * mt * t * t * p2 + + t * t * t * p3 + ); +}; + +const doesSimpleArrowSegmentOvershoot = ( + startProjection: number, + endProjection: number, + segmentLength: number, +) => { + const a = -3 * startProjection + 3 * endProjection + segmentLength; + const b = 2 * (segmentLength - 2 * endProjection + startProjection); + const c = startProjection; + const candidateTs = [0, 1]; + + if (Math.abs(a) < 1e-8) { + if (Math.abs(b) >= 1e-8) { + candidateTs.push(-c / b); + } + } else { + const discriminant = b * b - 4 * a * c; + + if (discriminant >= 0) { + const discriminantRoot = Math.sqrt(discriminant); + candidateTs.push((-b + discriminantRoot) / (2 * a)); + candidateTs.push((-b - discriminantRoot) / (2 * a)); + } + } + + let minProjection = Infinity; + let maxProjection = -Infinity; + + for (const t of candidateTs) { + if (t < 0 || t > 1) { + continue; + } + + const projection = getSimpleArrowBezierValue( + 0, + startProjection, + endProjection, + segmentLength, + t, + ); + + minProjection = Math.min(minProjection, projection); + maxProjection = Math.max(maxProjection, projection); + } + + return ( + minProjection < -SIMPLE_ARROW_OVERSHOOT_EPSILON || + maxProjection > segmentLength + SIMPLE_ARROW_OVERSHOOT_EPSILON + ); +}; + +const getSimpleArrowBaseTangents = ( + points: readonly Point[], + tension: number, +): [Float64Array, Float64Array] => { + const tx = new Float64Array(points.length); + const ty = new Float64Array(points.length); + + for (let i = 0; i < points.length; i++) { if (i === 0) { const pbx = 3 * points[0][0] - 3 * points[1][0] + points[2][0]; const pby = 3 * points[0][1] - 3 * points[1][1] + points[2][1]; tx[i] = tension * (points[1][0] - pbx); ty[i] = tension * (points[1][1] - pby); - } else if (i === n - 1) { + } else if (i === points.length - 1) { const pax = - 3 * points[n - 1][0] - 3 * points[n - 2][0] + points[n - 3][0]; + 3 * points[points.length - 1][0] - + 3 * points[points.length - 2][0] + + points[points.length - 3][0]; const pay = - 3 * points[n - 1][1] - 3 * points[n - 2][1] + points[n - 3][1]; - tx[i] = tension * (pax - points[n - 2][0]); - ty[i] = tension * (pay - points[n - 2][1]); + 3 * points[points.length - 1][1] - + 3 * points[points.length - 2][1] + + points[points.length - 3][1]; + tx[i] = tension * (pax - points[points.length - 2][0]); + ty[i] = tension * (pay - points[points.length - 2][1]); } else { tx[i] = tension * (points[i + 1][0] - points[i - 1][0]); ty[i] = tension * (points[i + 1][1] - points[i - 1][1]); } } - const path: string[] = [`M ${points[0][0]} ${points[0][1]}`]; - for (let i = 0; i < n - 1; i++) { - const cp1x = points[i][0] + tx[i] / 3; - const cp1y = points[i][1] + ty[i] / 3; - const cp2x = points[i + 1][0] - tx[i + 1] / 3; - const cp2y = points[i + 1][1] - ty[i + 1] / 3; - path.push( - `C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${points[i + 1][0]} ${ - points[i + 1][1] - }`, - ); + return [tx, ty]; +}; + +const getSimpleArrowSegmentProjections = < + Point extends GlobalPoint | LocalPoint, +>( + points: readonly Point[], + tangentX: Float64Array, + tangentY: Float64Array, + scales: Float64Array | undefined, + segmentIndex: number, + segmentScale = 1, +) => { + const start = points[segmentIndex]; + const end = points[segmentIndex + 1]; + const segmentDx = end[0] - start[0]; + const segmentDy = end[1] - start[1]; + const segmentLength = Math.hypot(segmentDx, segmentDy); + + if (!segmentLength) { + return { + segmentLength, + startProjection: 0, + endProjection: 0, + }; } - return path.join(" "); + const segmentUx = segmentDx / segmentLength; + const segmentUy = segmentDy / segmentLength; + const startScale = scales?.[segmentIndex] ?? 1; + const endScale = scales?.[segmentIndex + 1] ?? 1; + const startProjection = + startScale * + segmentScale * + ((tangentX[segmentIndex] * segmentUx + tangentY[segmentIndex] * segmentUy) / + 3); + const endProjection = + segmentLength - + endScale * + segmentScale * + ((tangentX[segmentIndex + 1] * segmentUx + + tangentY[segmentIndex + 1] * segmentUy) / + 3); + + return { + segmentLength, + startProjection, + endProjection, + }; +}; + +const isSimpleArrowSegmentOvershooting = < + Point extends GlobalPoint | LocalPoint, +>( + points: readonly Point[], + tangentX: Float64Array, + tangentY: Float64Array, + scales: Float64Array | undefined, + segmentIndex: number, + segmentScale = 1, +) => { + const { segmentLength, startProjection, endProjection } = + getSimpleArrowSegmentProjections( + points, + tangentX, + tangentY, + scales, + segmentIndex, + segmentScale, + ); + + if (!segmentLength) { + return false; + } + + return doesSimpleArrowSegmentOvershoot( + startProjection, + endProjection, + segmentLength, + ); +}; + +const getSimpleArrowSegmentScale = ( + points: readonly Point[], + tx: Float64Array, + ty: Float64Array, + scales: Float64Array, + segmentIndex: number, +) => { + if ( + !isSimpleArrowSegmentOvershooting(points, tx, ty, scales, segmentIndex, 1) + ) { + return 1; + } + + let low = 0; + let high = 1; + + for (let i = 0; i < SIMPLE_ARROW_SCALE_SEARCH_STEPS; i++) { + const mid = (low + high) / 2; + if ( + isSimpleArrowSegmentOvershooting( + points, + tx, + ty, + scales, + segmentIndex, + mid, + ) + ) { + high = mid; + } else { + low = mid; + } + } + + return low; +}; + +const getSimpleArrowTangentScales = ( + points: readonly Point[], + tx: Float64Array, + ty: Float64Array, +) => { + const scales = new Float64Array(points.length); + scales.fill(1); + + for (let pass = 0; pass < SIMPLE_ARROW_SCALE_PASSES; pass++) { + const nextScales = new Float64Array(scales); + let didChange = false; + + for ( + let segmentIndex = 0; + segmentIndex < points.length - 1; + segmentIndex++ + ) { + if ( + !isSimpleArrowSegmentOvershooting(points, tx, ty, scales, segmentIndex) + ) { + continue; + } + + const segmentScale = getSimpleArrowSegmentScale( + points, + tx, + ty, + scales, + segmentIndex, + ); + + const nextStartScale = scales[segmentIndex] * segmentScale; + const nextEndScale = scales[segmentIndex + 1] * segmentScale; + + if ( + nextStartScale < + nextScales[segmentIndex] - SIMPLE_ARROW_SCALE_EPSILON + ) { + nextScales[segmentIndex] = nextStartScale; + didChange = true; + } + + if ( + nextEndScale < + nextScales[segmentIndex + 1] - SIMPLE_ARROW_SCALE_EPSILON + ) { + nextScales[segmentIndex + 1] = nextEndScale; + didChange = true; + } + } + + if (!didChange) { + return scales; + } + + scales.set(nextScales); + } + + return scales; +}; + +const getSimpleArrowFinalTangents = ( + tx: Float64Array, + ty: Float64Array, + scales: Float64Array, + elementId?: string, +) => { + const finalX = new Float64Array(tx.length); + const finalY = new Float64Array(ty.length); + + for (let i = 0; i < tx.length; i++) { + finalX[i] = tx[i] * scales[i]; + finalY[i] = ty[i] * scales[i]; + } + + const overrides = getSimpleArrowTangentOverrides(elementId); + + if (!overrides) { + return { + finalX, + finalY, + overriddenPointIndices: [] as number[], + }; + } + + const overriddenPointIndices: number[] = []; + + for (const [indexKey, tangent] of Object.entries(overrides)) { + const index = Number(indexKey); + + if (!Number.isInteger(index) || index < 0 || index >= finalX.length) { + continue; + } + + finalX[index] = tangent[0]; + finalY[index] = tangent[1]; + overriddenPointIndices.push(index); + } + + overriddenPointIndices.sort((a, b) => a - b); + + return { + finalX, + finalY, + overriddenPointIndices, + }; +}; + +export const getSimpleArrowCurveDebugData = < + Point extends GlobalPoint | LocalPoint, +>( + points: readonly Point[], + tension = 0.5, + options?: SimpleArrowCurveDebugDataOptions, +): SimpleArrowCurveDebugData => { + if (points.length < 2) { + return { + elementId: options?.elementId, + tangents: [], + segments: [], + inference: { + overriddenPointIndices: [], + }, + }; + } + + if (points.length === 2) { + return { + elementId: options?.elementId, + tangents: points.map((point) => ({ + point, + base: [0, 0], + autoScaled: [0, 0], + scale: 1, + autoScale: 1, + scaled: [0, 0], + isAdjusted: false, + isOverridden: false, + normalized: { + baseLength: 0, + autoLength: 0, + finalLength: 0, + prevSegmentLength: null, + nextSegmentLength: null, + minNeighborLength: null, + finalLengthVsMinNeighbor: null, + autoLengthVsMinNeighbor: null, + angleDelta: 0, + turnAngle: null, + }, + })), + segments: [], + inference: { + overriddenPointIndices: [], + }, + }; + } + + const [tx, ty] = getSimpleArrowBaseTangents(points, tension); + const scales = getSimpleArrowTangentScales(points, tx, ty); + const baselineScales = new Float64Array(points.length); + baselineScales.fill(1); + const { finalX, finalY, overriddenPointIndices } = + getSimpleArrowFinalTangents(tx, ty, scales, options?.elementId); + + return { + elementId: options?.elementId, + tangents: points.map((point, index) => ({ + point, + base: [tx[index], ty[index]], + autoScaled: [tx[index] * scales[index], ty[index] * scales[index]], + scale: + getSimpleArrowVectorLength([tx[index], ty[index]]) > 0 + ? getSimpleArrowVectorLength([finalX[index], finalY[index]]) / + getSimpleArrowVectorLength([tx[index], ty[index]]) + : 1, + autoScale: scales[index], + scaled: [finalX[index], finalY[index]], + isAdjusted: + Math.abs(scales[index] - 1) > SIMPLE_ARROW_ADJUSTMENT_EPSILON || + Math.abs( + normalizeSimpleArrowAngle( + Math.atan2(finalY[index], finalX[index]) - + Math.atan2(ty[index], tx[index]), + ), + ) > SIMPLE_ARROW_ADJUSTMENT_EPSILON, + isOverridden: overriddenPointIndices.includes(index), + normalized: (() => { + const base = [tx[index], ty[index]] as SimpleArrowVector; + const autoScaled = [ + tx[index] * scales[index], + ty[index] * scales[index], + ] as SimpleArrowVector; + const scaled = [finalX[index], finalY[index]] as SimpleArrowVector; + const baseLength = getSimpleArrowVectorLength(base); + const autoLength = getSimpleArrowVectorLength(autoScaled); + const finalLength = getSimpleArrowVectorLength(scaled); + const prevSegmentLength = + index > 0 ? pointDistance(points[index - 1], point) : null; + const nextSegmentLength = + index < points.length - 1 + ? pointDistance(point, points[index + 1]) + : null; + const minNeighborLength = + prevSegmentLength === null + ? nextSegmentLength + : nextSegmentLength === null + ? prevSegmentLength + : Math.min(prevSegmentLength, nextSegmentLength); + const turnAngle = + prevSegmentLength !== null && nextSegmentLength !== null + ? normalizeSimpleArrowAngle( + Math.atan2( + points[index + 1][1] - point[1], + points[index + 1][0] - point[0], + ) - + Math.atan2( + point[1] - points[index - 1][1], + point[0] - points[index - 1][0], + ), + ) + : null; + + return { + baseLength, + autoLength, + finalLength, + prevSegmentLength, + nextSegmentLength, + minNeighborLength, + finalLengthVsMinNeighbor: + minNeighborLength && minNeighborLength > 0 + ? finalLength / minNeighborLength + : null, + autoLengthVsMinNeighbor: + minNeighborLength && minNeighborLength > 0 + ? autoLength / minNeighborLength + : null, + angleDelta: normalizeSimpleArrowAngle( + Math.atan2(finalY[index], finalX[index]) - + Math.atan2(ty[index], tx[index]), + ), + turnAngle, + }; + })(), + })), + segments: points.slice(0, -1).map((start, index) => { + const end = points[index + 1]; + const { + segmentLength: chordLength, + startProjection: baseStartProjection, + endProjection: baseEndProjection, + } = getSimpleArrowSegmentProjections( + points, + tx, + ty, + baselineScales, + index, + ); + const { + startProjection: finalStartProjection, + endProjection: finalEndProjection, + } = getSimpleArrowSegmentProjections( + points, + finalX, + finalY, + undefined, + index, + ); + const baseCp1 = pointFrom( + start[0] + tx[index] / 3, + start[1] + ty[index] / 3, + ); + const baseCp2 = pointFrom( + end[0] - tx[index + 1] / 3, + end[1] - ty[index + 1] / 3, + ); + const cp1 = pointFrom( + start[0] + finalX[index] / 3, + start[1] + finalY[index] / 3, + ); + const cp2 = pointFrom( + end[0] - finalX[index + 1] / 3, + end[1] - finalY[index + 1] / 3, + ); + + return { + start, + end, + baseCp1, + baseCp2, + cp1, + cp2, + overshootsBaseline: isSimpleArrowSegmentOvershooting( + points, + tx, + ty, + baselineScales, + index, + ), + overshootsResolved: isSimpleArrowSegmentOvershooting( + points, + finalX, + finalY, + undefined, + index, + ), + metrics: { + chordLength, + baseStartProjection, + baseEndProjection, + finalStartProjection, + finalEndProjection, + }, + }; + }), + inference: { + overriddenPointIndices, + }, + }; +}; + +const generateSimpleArrowPathOps = ( + points: readonly LocalPoint[], + tension = 0.5, + elementId?: string, +): SimpleArrowPathOp[] => { + if (points.length < 2) { + return []; + } + + const ops: SimpleArrowPathOp[] = [ + { + op: "move", + data: pointFrom(points[0][0], points[0][1]), + }, + ]; + + if (points.length === 2) { + ops.push({ + op: "lineTo", + data: pointFrom(points[1][0], points[1][1]), + }); + + return ops; + } + const debugData = getSimpleArrowCurveDebugData(points, tension, { + elementId, + }); + + for (const segment of debugData.segments) { + const { cp1, cp2, end } = segment; + + ops.push({ + op: "bcurveTo", + data: [cp1[0], cp1[1], cp2[0], cp2[1], end[0], end[1]], + }); + } + + return ops; +}; + +const generateSimpleArrowShape = ( + points: readonly LocalPoint[], + tension = 0.5, + elementId?: string, +): string => { + return generateSimpleArrowPathOps(points, tension, elementId) + .map((op) => { + if (op.op === "bcurveTo") { + return `C ${op.data[0]} ${op.data[1]} ${op.data[2]} ${op.data[3]} ${op.data[4]} ${op.data[5]}`; + } + + return `${op.op === "move" ? "M" : "L"} ${op.data[0]} ${op.data[1]}`; + }) + .join(" "); }; const generateElbowArrowShape = ( diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index 025d3f88e8..c60d58c3ef 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -4,6 +4,8 @@ interface Window { EXCALIDRAW_ASSET_PATH: string | string[] | undefined; EXCALIDRAW_THROTTLE_RENDER: boolean | undefined; DEBUG_FRACTIONAL_INDICES: boolean | undefined; + EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENTS: boolean | undefined; + EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW: unknown; EXCALIDRAW_EXPORT_SOURCE: string; gtag: Function; sa_event: Function; diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 7d31bf9a32..2a15ddcfe6 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -42,6 +42,7 @@ import { isTextElement, LinearElementEditor, getActiveTextElement, + getSimpleArrowCurveDebugData, } from "@excalidraw/element"; import { renderSelectionElement } from "@excalidraw/element"; @@ -1201,6 +1202,105 @@ const renderLinearPointHandles = ( context.restore(); }; +const isSimpleArrowTangentDebugEnabled = () => + window.EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENTS === true; + +const renderSimpleArrowTangentOverlay = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + element: NonDeleted, + elementsMap: RenderableElementsMap, +) => { + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); + const debugData = getSimpleArrowCurveDebugData(points); + + window.EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW = debugData; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + context.lineWidth = 1 / appState.zoom.value; + + context.setLineDash([6 / appState.zoom.value, 4 / appState.zoom.value]); + context.strokeStyle = "rgba(134, 142, 150, 0.75)"; + + for (const segment of debugData.segments) { + context.beginPath(); + context.moveTo(segment.start[0], segment.start[1]); + context.lineTo(segment.baseCp1[0], segment.baseCp1[1]); + context.lineTo(segment.baseCp2[0], segment.baseCp2[1]); + context.lineTo(segment.end[0], segment.end[1]); + context.stroke(); + } + + context.setLineDash([]); + + for (const segment of debugData.segments) { + const strokeStyle = segment.overshootsBaseline + ? "rgba(245, 159, 0, 0.9)" + : "rgba(94, 90, 216, 0.85)"; + + context.strokeStyle = strokeStyle; + context.fillStyle = "rgba(255, 255, 255, 0.95)"; + + context.beginPath(); + context.moveTo(segment.start[0], segment.start[1]); + context.lineTo(segment.cp1[0], segment.cp1[1]); + context.lineTo(segment.cp2[0], segment.cp2[1]); + context.lineTo(segment.end[0], segment.end[1]); + context.stroke(); + + fillCircle( + context, + segment.cp1[0], + segment.cp1[1], + 4 / appState.zoom.value, + true, + true, + ); + fillCircle( + context, + segment.cp2[0], + segment.cp2[1], + 4 / appState.zoom.value, + true, + true, + ); + } + + context.strokeStyle = "rgba(201, 42, 42, 0.85)"; + context.fillStyle = "rgba(201, 42, 42, 0.95)"; + + for (const tangent of debugData.tangents) { + if (!tangent.isAdjusted) { + continue; + } + + const handle = pointFrom( + tangent.point[0] + tangent.scaled[0] / 3, + tangent.point[1] + tangent.scaled[1] / 3, + ); + + context.beginPath(); + context.moveTo(tangent.point[0], tangent.point[1]); + context.lineTo(handle[0], handle[1]); + context.stroke(); + + fillCircle( + context, + tangent.point[0], + tangent.point[1], + 3 / appState.zoom.value, + true, + true, + ); + } + + context.restore(); +}; + const renderFocusPointConnectionLine = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -1722,6 +1822,13 @@ const _renderInteractiveScene = ({ const selectedLinearElement = linearState && LinearElementEditor.getElement(linearState.elementId, allElementsMap); + const selectedRoundedArrow = + selectedElements.length === 1 && + isArrowElement(selectedElements[0]) && + !isElbowArrow(selectedElements[0]) && + !!selectedElements[0].roundness + ? (selectedElements[0] as NonDeleted) + : null; // Arrows have a different highlight behavior when // they are the only selected element if (selectedLinearElement) { @@ -1758,6 +1865,17 @@ const _renderInteractiveScene = ({ } } + if (selectedRoundedArrow && isSimpleArrowTangentDebugEnabled()) { + renderSimpleArrowTangentOverlay( + context, + appState, + selectedRoundedArrow, + elementsMap, + ); + } else { + window.EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW = undefined; + } + // Paint selected elements if ( !appState.multiElement &&