[debug] arrow control points

This commit is contained in:
dwelle
2026-03-21 20:50:19 +01:00
parent d73f700fa8
commit 3b47fe2f87
4 changed files with 1455 additions and 112 deletions
+597 -17
View File
@@ -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<GlobalPoint>;
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<GlobalPoint>(cx, cy);
return {
pointToGlobal: (point: LocalPoint): GlobalPoint => {
const rotated = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointFrom<GlobalPoint>(rotated[0], rotated[1]);
},
vectorToGlobal: (vector: [number, number]): [number, number] => {
const rotated = pointRotateRads(
pointFrom<GlobalPoint>(vector[0], vector[1]),
pointFrom<GlobalPoint>(0, 0),
element.angle,
);
return [rotated[0], rotated[1]];
},
vectorToLocal: (vector: [number, number]): [number, number] => {
const rotated = pointRotateRads(
pointFrom<GlobalPoint>(vector[0], vector[1]),
pointFrom<GlobalPoint>(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<GlobalPoint> = {
...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<GlobalPoint>(
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<GlobalPoint>(
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<AppState, "selectedElementIds">,
) => {
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<HTMLCanvasElement, DebugCanvasProps>(
({ appState, scale }, ref) => {
const { width, height } = appState;
const excalidrawAPI = useExcalidrawAPI();
const [overlayState, setOverlayState] = useState<DebugCanvasOverlayState>(
() => pickOverlayState(appState),
);
const [selectedSimpleArrowDebugState, setSelectedSimpleArrowDebugState] =
useState<SelectedSimpleArrowDebugState | null>(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<HTMLDivElement>,
) => {
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<HTMLDivElement>,
) => {
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 (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={ref}
>
Debug Canvas
</canvas>
<>
<canvas
style={{
width,
height,
position: "absolute",
inset: 0,
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={ref}
>
Debug Canvas
</canvas>
{selectedSimpleArrowDebugState?.handles.map((handle) => {
const { x, y } = sceneCoordsToViewportCoords(
{
sceneX: handle.handle[0],
sceneY: handle.handle[1],
},
overlayState,
);
return (
<div
key={handle.key}
title={handle.title}
onPointerDown={(event) => 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",
}}
/>
);
})}
</>
);
},
);