From 5aa16c30528a1530a6eee3ed0448ec28e7ef2066 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 3 Apr 2026 16:09:31 +0000 Subject: [PATCH] fix: Speedy predictions Signed-off-by: Mark Tolmacs --- packages/element/src/renderElement.ts | 317 ++++++++++++++---- .../excalidraw/actions/actionFinalize.tsx | 6 +- .../excalidraw/actions/actionProperties.tsx | 63 ++++ packages/excalidraw/actions/index.ts | 1 + packages/excalidraw/appState.ts | 6 + packages/excalidraw/components/App.tsx | 45 ++- packages/excalidraw/components/icons.tsx | 20 ++ packages/excalidraw/locales/en.json | 3 + packages/excalidraw/types.ts | 6 +- packages/math/src/vector.ts | 11 + 10 files changed, 403 insertions(+), 75 deletions(-) diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 789e0dfa15..0baa4d658a 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -1,20 +1,12 @@ import rough from "roughjs/bin/rough"; import { - bezierEquation, - curve, type GlobalPoint, isRightAngleRads, lineSegment, - type LocalPoint, - pointDistance, pointFrom, - pointFromVector, pointRotateRads, type Radians, - vectorFromPoint, - vectorNormalize, - vectorScale, } from "@excalidraw/math"; import { @@ -398,6 +390,29 @@ const drawImagePlaceholder = ( ); }; +// ─── Freedraw predicted-point store ───────────────────────────────────────── +// Keyed by element ID. Coordinates are in element-local scene units +// (i.e. relative to element.x / element.y), matching the format of +// ExcalidrawFreeDrawElement.points. +const freedrawPredictedPoints = new Map(); + +/** + * Sets (or clears) the predicted next point for a freedraw element that is + * currently being drawn. Call from the pointer-move handler using the first + * entry returned by PointerEvent.getPredictedEvents(). Call with `null` when + * the stroke is finalised so the ghost segment is no longer rendered. + */ +export const setFreeDrawPredictedPoint = ( + elementId: string, + point: readonly [number, number] | null, +): void => { + if (point !== null) { + freedrawPredictedPoints.set(elementId, point); + } else { + freedrawPredictedPoints.delete(elementId); + } +}; + const DEFAULT_FREEDRAW_PRESSURE = 0.5; /** @@ -447,24 +462,159 @@ const drawTaperedCapsule = ( }; /** - * Draws freedraw points as individual pressure-aware tapered capsule segments. - * Each segment [points[i-1] → points[i]] is drawn as a filled tapered capsule - * whose start radius is derived from pressures[i-1] and end radius from - * pressures[i]. Because adjacent segments share the same radius at their - * common junction point, the result is a continuous stroke with naturally - * varying pen pressure. + * Target spacing (in scene units) between consecutive capsule sub-segments + * produced by the Catmull-Rom bezier subdivision. Smaller values give + * smoother curves at the cost of more draw calls. + */ +const BEZIER_SUBDIVIDE_TARGET_SPACING = 3; + +/** + * Returns the Catmull-Rom tangent vector at points[i], using the neighbouring + * points for a uniform parameterisation. At the first point a one-sided + * forward tangent is used. At the last real point, `predictedPoint` (if + * supplied) stands in for the missing next neighbour so the stroke tip curves + * smoothly toward the predicted pen position. + */ +const getCatmullRomTangent = ( + points: readonly (readonly [number, number])[], + i: number, + predictedPoint: readonly [number, number] | undefined, +): [number, number] => { + const N = points.length; + const cur = points[i]; + + // Determine the "next" point: real neighbour, predicted point, or mirrored. + let next: readonly [number, number]; + if (i < N - 1) { + next = points[i + 1]; + } else if (predictedPoint !== undefined) { + next = predictedPoint; + } else { + // Mirror back across cur to get a forward tangent at the last point. + const prev2 = i > 0 ? points[i - 1] : cur; + next = [2 * cur[0] - prev2[0], 2 * cur[1] - prev2[1]]; + } + + let tx: number; + let ty: number; + + if (i === 0) { + // One-sided tangent at the first point. + tx = (next[0] - cur[0]) * 0.5; + ty = (next[1] - cur[1]) * 0.5; + } else { + const prev = points[i - 1]; + tx = (next[0] - prev[0]) * 0.5; + ty = (next[1] - prev[1]) * 0.5; + } + + // Chord-length clamping (PCHIP-style): the bezier control point is + // p + t/3, so |t| > 3*chord causes the curve to overshoot the segment + // bounding box and loop back on itself — exactly the source of jaggedness + // when high-frequency input produces densely-packed or noisy points. + // Clamp: |t| <= 3 * min(chord_to_prev, chord_to_next). + const magSq = tx * tx + ty * ty; + if (magSq > 0) { + const dNx = next[0] - cur[0]; + const dNy = next[1] - cur[1]; + const chordNext = Math.sqrt(dNx * dNx + dNy * dNy); + let chordPrev = chordNext; + if (i > 0) { + const prev = points[i - 1]; + const dPx = cur[0] - prev[0]; + const dPy = cur[1] - prev[1]; + chordPrev = Math.sqrt(dPx * dPx + dPy * dPy); + } + const maxMag = 3 * Math.min(chordNext, chordPrev); + const mag = Math.sqrt(magSq); + if (mag > maxMag) { + const scale = maxMag / mag; + tx *= scale; + ty *= scale; + } + } + + return [tx, ty]; +}; + +/** + * Draws one bezier-subdivided tapered segment from p0 (radius r0) to p1 + * (radius r1). t0/t1 are the Catmull-Rom tangents at p0 and p1 respectively. + * The segment is sampled at BEZIER_SUBDIVIDE_TARGET_SPACING scene-unit + * intervals and each sub-interval is drawn as a tapered capsule. + */ +const drawSubdividedSegment = ( + context: CanvasRenderingContext2D, + p0x: number, + p0y: number, + r0: number, + p1x: number, + p1y: number, + r1: number, + t0x: number, + t0y: number, + t1x: number, + t1y: number, +) => { + const segLen = Math.sqrt((p1x - p0x) ** 2 + (p1y - p0y) ** 2); + const nSubdiv = Math.max( + 1, + Math.ceil(segLen / BEZIER_SUBDIVIDE_TARGET_SPACING), + ); + + // Cubic Bezier control points derived from Catmull-Rom tangents. + const cp1x = p0x + t0x / 3; + const cp1y = p0y + t0y / 3; + const cp2x = p1x - t1x / 3; + const cp2y = p1y - t1y / 3; + + let prevX = p0x; + let prevY = p0y; + let prevR = r0; + + for (let k = 1; k <= nSubdiv; k++) { + const t = k / nSubdiv; + const mt = 1 - t; + const mt2 = mt * mt; + const t2 = t * t; + const mt3 = mt2 * mt; + const t3 = t2 * t; + const mt2t3 = 3 * mt2 * t; + const mtt23 = 3 * mt * t2; + + const x = mt3 * p0x + mt2t3 * cp1x + mtt23 * cp2x + t3 * p1x; + const y = mt3 * p0y + mt2t3 * cp1y + mtt23 * cp2y + t3 * p1y; + const r = r0 + (r1 - r0) * t; + + drawTaperedCapsule(context, prevX, prevY, prevR, x, y, r); + prevX = x; + prevY = y; + prevR = r; + } +}; + +/** + * Draws freedraw points as bezier-subdivided, pressure-aware tapered capsule + * segments. Consecutive real points are connected with Catmull-Rom cubic + * bezier curves so the rendered stroke is smooth even when input samples are + * sparse. When `predictedPoint` is supplied it is used as the tangent hint at + * the tip of the stroke and an additional ghost segment is drawn toward it, + * compensating for pointer-event latency. * * @param fromIndex Draw segments starting from this index (inclusive). - * Segments are from max(fromIndex,1) to points.length-1. * Pass 0 to draw everything. + * @param predictedPoint Element-local scene coords of the first predicted + * pointer event, used for tangent and ghost rendering. */ const drawFreeDrawSegments = ( element: ExcalidrawFreeDrawElement, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, fromIndex: number, + predictedPoint?: readonly [number, number], ) => { const { points, pressures } = element; + const N = points.length; const strokeColor = renderConfig.theme === THEME.DARK ? applyDarkModeFilter(element.strokeColor) @@ -477,70 +627,79 @@ const drawFreeDrawSegments = ( const getPressure = (i: number): number => pressures.length > i ? pressures[i] : DEFAULT_FREEDRAW_PRESSURE; - if (fromIndex === 0 && points.length === 1) { + if (fromIndex === 0 && N === 1) { // Single-point stroke → filled circle (dot) const r = baseRadius * getPressure(0) * 2; context.beginPath(); context.arc(points[0][0], points[0][1], r, 0, Math.PI * 2); context.fill(); + // Draw ghost circle at predicted point if available + if (predictedPoint !== undefined) { + context.beginPath(); + context.arc(predictedPoint[0], predictedPoint[1], r, 0, Math.PI * 2); + context.fill(); + } return; } - // const FREEDRAW_DEBUG_COLORS = [ - // "#e03131", - // "#f76707", - // "#2f9e44", - // "#1971c2", - // "#7048e8", - // "#e64980", - // ]; const start = Math.max(fromIndex, 1); - for (let i = start; i < points.length; i++) { - //context.fillStyle = FREEDRAW_DEBUG_COLORS[i % FREEDRAW_DEBUG_COLORS.length]; + for (let i = start; i < N; i++) { + const p0 = points[i - 1]; + const p1 = points[i]; const r0 = baseRadius * getPressure(i - 1) * 2; const r1 = baseRadius * getPressure(i) * 2; - const synthesizedPressures = [r0]; - const synthesizedPoints: LocalPoint[] = [points[i - 1]]; - if (i > 1) { - const dist = pointDistance(points[i - 1], points[i]); - if (dist > baseRadius / 2) { - const c = curve( - points[i - 1], - pointFromVector( - vectorScale( - vectorNormalize(vectorFromPoint(points[i - 1], points[i - 2])), - dist / 2, - ), - points[i - 1], - ), - points[i], - points[i], - ); - synthesizedPoints.push(bezierEquation(c, 0.2)); - synthesizedPressures.push(r0 * 0.8 + r1 * 0.2); - synthesizedPoints.push(bezierEquation(c, 0.4)); - synthesizedPressures.push(r0 * 0.6 + r1 * 0.4); - synthesizedPoints.push(bezierEquation(c, 0.6)); - synthesizedPressures.push(r0 * 0.4 + r1 * 0.6); - synthesizedPoints.push(bezierEquation(c, 0.8)); - synthesizedPressures.push(r0 * 0.2 + r1 * 0.8); - } - } - synthesizedPoints.push(points[i]); - synthesizedPressures.push(r1); + // Catmull-Rom tangents. At the last real point, use predictedPoint as + // the look-ahead so the tip curves smoothly toward the expected position. + const isLastPoint = i === N - 1; + const t0 = getCatmullRomTangent(points, i - 1, undefined); + const t1 = getCatmullRomTangent( + points, + i, + isLastPoint ? predictedPoint : undefined, + ); - for (let j = 1; j < synthesizedPoints.length; j++) { - drawTaperedCapsule( - context, - synthesizedPoints[j - 1][0], - synthesizedPoints[j - 1][1], - synthesizedPressures[j - 1], - synthesizedPoints[j][0], - synthesizedPoints[j][1], - synthesizedPressures[j], - ); - } + drawSubdividedSegment( + context, + p0[0], + p0[1], + r0, + p1[0], + p1[1], + r1, + t0[0], + t0[1], + t1[0], + t1[1], + ); + } + + // Ghost segment: extend the visible stroke toward the predicted point to + // compensate for pointer-event latency. This segment is overwritten when + // the next real pointer event arrives. + if (predictedPoint !== undefined && N >= 1) { + const lastPt = points[N - 1]; + const r0 = baseRadius * getPressure(N - 1) * 2; + + // Tangent at the last real point (with predicted look-ahead) + const t0 = getCatmullRomTangent(points, N - 1, predictedPoint); + // Forward tangent at the predicted point itself + const fwdX = predictedPoint[0] - lastPt[0]; + const fwdY = predictedPoint[1] - lastPt[1]; + + drawSubdividedSegment( + context, + lastPt[0], + lastPt[1], + r0, + predictedPoint[0], + predictedPoint[1], + r0, // hold pressure at the tip + t0[0], + t0[1], + fwdX, + fwdY, + ); } }; @@ -911,8 +1070,19 @@ const generateOrUpdateFreeDrawIncrementalCanvas = ( canvasScale = prevInc.scale; } - // Append only the segments added since the last render - if (fromIndex < element.points.length) { + // Fetch predicted point for this element (set by the pointer-move handler). + const predictedPoint = freedrawPredictedPoints.get(element.id); + + // When Catmull-Rom tangents are in use the tangent at the last committed + // point changes each time a new real point arrives (the new point becomes + // the look-ahead for the previous segment). Roll back two points so that + // the revised last-committed segment is always redrawn with the correct + // tangent. Overpainting with an opaque fill is harmless. + const drawFrom = Math.max(0, fromIndex - 2); + + // Draw new (and possibly revised) segments plus the ghost toward the + // predicted point. + if (drawFrom < element.points.length || predictedPoint !== undefined) { const ctx = canvas.getContext("2d")!; const canvasElemOffsetX = (element.x - canvasOriginSceneX) * dpr * canvasScale; @@ -921,10 +1091,17 @@ const generateOrUpdateFreeDrawIncrementalCanvas = ( ctx.save(); ctx.translate(canvasElemOffsetX, canvasElemOffsetY); ctx.scale(dpr * canvasScale, dpr * canvasScale); - drawFreeDrawSegments(element, ctx, renderConfig, fromIndex); + drawFreeDrawSegments( + element, + ctx, + renderConfig, + drawFrom, + element.points[element.points.length - 1], + //predictedPoint, + ); ctx.restore(); - // Update lastRenderedPointCount in the cache entry + // Update lastRenderedPointCount in the cache entry. const inc = freedrawIncrementalCache.get(element)!; inc.lastRenderedPointCount = element.points.length; } diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 6b7cdf8edd..f967fdbf96 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -14,7 +14,10 @@ import { isLineElement, } from "@excalidraw/element"; -import { invalidateFreeDrawIncrementalCanvas } from "@excalidraw/element"; +import { + invalidateFreeDrawIncrementalCanvas, + setFreeDrawPredictedPoint, +} from "@excalidraw/element"; import { KEYS, @@ -317,6 +320,7 @@ export const actionFinalize = register({ if (element && isFreeDrawElement(element)) { invalidateFreeDrawIncrementalCanvas(element); + setFreeDrawPredictedPoint(element.id, null); } return { diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index a046db6ea8..03aa79cbfd 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -47,6 +47,7 @@ import { isArrowElement, isBoundToContainer, isElbowArrow, + isFreeDrawElement, isLinearElement, isLineElement, isTextElement, @@ -131,6 +132,8 @@ import { ArrowheadCardinalityOneOrManyIcon, ArrowheadCardinalityZeroOrManyIcon, ArrowheadCardinalityZeroOrOneIcon, + FreedrawPressureConstantIcon, + FreedrawPressureSensitiveIcon, } from "../components/icons"; import { Fonts } from "../fonts"; @@ -2041,3 +2044,63 @@ export const actionChangeArrowType = register({ ); }, }); + +export const actionChangeStrokeShape = register({ + name: "changeStrokeShape", + label: "labels.strokeShape", + trackEvent: false, + perform: (elements, appState, value) => { + return { + elements: changeProperty(elements, appState, (el) => { + if (isFreeDrawElement(el)) { + return newElementWith(el, { + simulatePressure: value, + }); + } + return el; + }), + appState: { ...appState, currentItemFreedrawConstantPressure: value }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData, app }) => ( +
+ {t("labels.strokeShape")} +
+ { + if (isFreeDrawElement(element)) { + return element.simulatePressure; + } + return null; + }, + (element) => isFreeDrawElement(element), + (hasSelection) => + hasSelection + ? null + : appState.currentItemFreedrawConstantPressure, + )} + onChange={(value) => updateData(value)} + /> +
+
+ ), +}); diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index d630c6ecaa..ad57e4d04d 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -19,6 +19,7 @@ export { actionChangeTextAlign, actionChangeVerticalAlign, actionChangeArrowProperties, + actionChangeStrokeShape, } from "./actionProperties"; export { diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 93fe770286..b047fcdb60 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -40,6 +40,7 @@ export const getDefaultAppState = (): Omit< currentItemArrowType: ARROW_TYPE.round, currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, + currentItemFreedrawConstantPressure: true, currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentHoveredFontFamily: null, cursorButton: "up", @@ -172,6 +173,11 @@ const APP_STATE_STORAGE_CONF = (< currentItemStrokeColor: { browser: true, export: false, server: false }, currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false }, + currentItemFreedrawConstantPressure: { + browser: true, + export: false, + server: false, + }, currentItemTextAlign: { browser: true, export: false, server: false }, currentHoveredFontFamily: { browser: false, export: false, server: false }, cursorButton: { browser: true, export: false, server: false }, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index fea3464210..8cdb11a44b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -259,6 +259,7 @@ import { maybeHandleArrowPointlikeDrag, getUncroppedWidthAndHeight, getActiveTextElement, + setFreeDrawPredictedPoint, } from "@excalidraw/element"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -8818,7 +8819,7 @@ class App extends React.Component { y: gridY, }); - const simulatePressure = event.pressure === 0.5; + const simulatePressure = this.state.currentItemFreedrawConstantPressure; const element = newFreeDrawElement({ type: elementType, @@ -9478,7 +9479,7 @@ class App extends React.Component { private onPointerMoveFromPointerDownHandler( pointerDownState: PointerDownState, ) { - return withBatchedUpdatesThrottled((event: PointerEvent) => { + const handler = (event: PointerEvent) => { if (this.state.openDialog?.name === "elementLinkSelector") { return; } @@ -10202,6 +10203,28 @@ class App extends React.Component { } } + // Update the predicted next point for smoother stroke rendering. + // getPredictedEvents() returns samples predicted by the browser for + // the current pointer position, compensating for event-delivery + // latency (especially useful with Apple Pencil / high-frequency + // stylus input). We pass the first predicted point to the renderer + // so it can (a) use it as a Catmull-Rom look-ahead tangent and + // (b) draw a ghost segment toward the predicted position. + const predictedEvents: PointerEvent[] = + event.getPredictedEvents?.() ?? []; + if (predictedEvents.length > 0) { + const predCoords = viewportCoordsToSceneCoords( + predictedEvents[0], + this.state, + ); + setFreeDrawPredictedPoint(newElement.id, [ + predCoords.x - newElement.x, + predCoords.y - newElement.y, + ]); + } else { + setFreeDrawPredictedPoint(newElement.id, null); + } + if (newPoints.length > 0) { const pressures = newElement.simulatePressure ? newElement.pressures @@ -10368,7 +10391,23 @@ class App extends React.Component { }); } } - }); + }; + + // For freedraw, bypass RAF throttling so every pointer event is processed + // synchronously. This preserves coalesced pointer events and eliminates + // stroke lag on high-frequency stylus / pointer input. + if (this.state.activeTool.type === "freedraw") { + const immediate = withBatchedUpdates(handler); + const result = immediate as typeof immediate & { + flush(): void; + cancel(): void; + }; + result.flush = () => {}; + result.cancel = () => {}; + return result; + } + + return withBatchedUpdatesThrottled(handler); } // Returns whether the pointer move happened over either scrollbar diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 7e2400728d..dc4dda9040 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -1186,6 +1186,26 @@ export const StrokeWidthExtraBoldIcon = createIcon( modifiedTablerIconProps, ); +export const FreedrawPressureConstantIcon = createIcon( + , + modifiedTablerIconProps, +); + +export const FreedrawPressureSensitiveIcon = createIcon( + , + modifiedTablerIconProps, +); + export const StrokeStyleSolidIcon = React.memo(({ theme }: { theme: Theme }) => createIcon( ; + onMove: + | null + | ReturnType + | (((event: PointerEvent) => void) & { flush(): void; cancel(): void }); // It's defined on the initial pointer down event onUp: null | ((event: PointerEvent) => void); // It's defined on the initial pointer down event diff --git a/packages/math/src/vector.ts b/packages/math/src/vector.ts index c520fce244..e2fbcda528 100644 --- a/packages/math/src/vector.ts +++ b/packages/math/src/vector.ts @@ -158,3 +158,14 @@ export const vectorNormalize = (v: Vector): Vector => { * Calculate the right-hand normal of the vector. */ export const vectorNormal = (v: Vector): Vector => vector(v[1], -v[0]); + +/** + * Scalar projection of vector v onto normal. + * + * @param v + * @param normal + * @returns + */ +export const vectorReflect = (v: Vector, normal: Vector): Vector => { + return vectorSubtract(v, vectorScale(normal, 2 * vectorDot(v, normal))); +};