diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 96bff999bc..8b60cf5df0 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -144,6 +144,12 @@ export interface ExcalidrawElementWithCanvas { imageCrop: ExcalidrawImageElement["crop"] | null; containingFrameOpacity: number; boundTextCanvas: HTMLCanvasElement; + /** + * When set, overrides the canvas origin placement in drawElementFromCanvas. + * Used by the incremental freedraw rendering path. Scene coordinates. + */ + canvasOriginSceneX?: number; + canvasOriginSceneY?: number; } const cappedElementCanvasSize = ( @@ -384,6 +390,51 @@ const drawImagePlaceholder = ( ); }; +/** + * Draws freedraw points as individual rounded capsule segments. + * Each segment [points[i-1] → points[i]] is an independent stroke with + * round lineCap so adjacent caps overlap, producing paint-splat splotches. + * + * @param fromIndex Draw segments starting from this index (inclusive). + * Segments are from max(fromIndex,1) to points.length-1. + * Pass 0 to draw everything. + */ +const drawFreeDrawSegments = ( + element: ExcalidrawFreeDrawElement, + context: CanvasRenderingContext2D, + renderConfig: StaticCanvasRenderConfig, + fromIndex: number, +) => { + const { points } = element; + const strokeColor = + renderConfig.theme === THEME.DARK + ? applyDarkModeFilter(element.strokeColor) + : element.strokeColor; + + context.strokeStyle = strokeColor; + context.fillStyle = strokeColor; + context.lineCap = "round"; + context.lineJoin = "round"; + context.lineWidth = element.strokeWidth * 4.25; + + if (fromIndex === 0 && points.length === 1) { + // Single-point stroke → filled circle (dot) + const r = (element.strokeWidth * 4.25) / 2; + context.beginPath(); + context.arc(points[0][0], points[0][1], r, 0, Math.PI * 2); + context.fill(); + return; + } + + const start = Math.max(fromIndex, 1); + for (let i = start; i < points.length; i++) { + context.beginPath(); + context.moveTo(points[i - 1][0], points[i - 1][1]); + context.lineTo(points[i][0], points[i][1]); + context.stroke(); + } +}; + const drawElementOnCanvas = ( element: NonDeletedExcalidrawElement, rc: RoughCanvas, @@ -415,23 +466,8 @@ const drawElementOnCanvas = ( break; } case "freedraw": { - // Draw directly to canvas context.save(); - - const shapes = ShapeCache.generateElementShape(element, renderConfig); - - for (const shape of shapes) { - if (typeof shape === "string") { - context.fillStyle = - renderConfig.theme === THEME.DARK - ? applyDarkModeFilter(element.strokeColor) - : element.strokeColor; - context.fill(new Path2D(shape)); - } else { - rc.draw(shape); - } - } - + drawFreeDrawSegments(element, context, renderConfig, 0); context.restore(); break; } @@ -605,6 +641,204 @@ export const elementWithCanvasCache = new WeakMap< ExcalidrawElementWithCanvas >(); +// ─── Incremental freedraw canvas cache ──────────────────────────────────────── +// A separate WeakMap that survives ShapeCache.delete() calls so that the raster +// accumulates new capsule segments without full regeneration on every added point. + +const FREEDRAW_CANVAS_OVERSHOOT_MIN = 200; // scene units — minimum extra space on each side +const FREEDRAW_CANVAS_OVERSHOOT_FACTOR = 0.5; // allocate current_dimension * factor extra on each side + +interface FreeDrawIncrementalCanvas { + canvas: HTMLCanvasElement; + lastRenderedPointCount: number; + canvasOriginSceneX: number; + canvasOriginSceneY: number; + /** Scene bounds guaranteed to be visible inside this canvas (without padding) */ + canvasAllocX1: number; + canvasAllocY1: number; + canvasAllocX2: number; + canvasAllocY2: number; + scale: number; + theme: AppState["theme"]; +} + +const freedrawIncrementalCache = new WeakMap< + ExcalidrawFreeDrawElement, + FreeDrawIncrementalCanvas +>(); + +/** + * Generates or incrementally updates a raster canvas for a freedraw element + * that is being actively drawn. Unlike the standard element canvas cache, this + * cache is NOT cleared on each mutateElement call, allowing new segments to be + * appended without full regeneration. + * + * When element bounds grow beyond the over-allocated canvas, the existing raster + * is copied into a new larger canvas before appending the next segments. + */ +const generateOrUpdateFreeDrawIncrementalCanvas = ( + element: ExcalidrawFreeDrawElement, + elementsMap: NonDeletedSceneElementsMap, + zoom: Zoom, + renderConfig: StaticCanvasRenderConfig, + appState: StaticCanvasAppState | InteractiveCanvasAppState, +): ExcalidrawElementWithCanvas | null => { + const scale = zoom.value; + const dpr = window.devicePixelRatio; + const padding = getCanvasPadding(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const containingFrameOpacity = + getContainingFrame(element, elementsMap)?.opacity || 100; + + const prevInc = freedrawIncrementalCache.get(element); + + const boundsExceeded = + prevInc !== undefined && + (x1 < prevInc.canvasAllocX1 || + y1 < prevInc.canvasAllocY1 || + x2 > prevInc.canvasAllocX2 || + y2 > prevInc.canvasAllocY2); + + const needsAlloc = + prevInc === undefined || + boundsExceeded || + prevInc.scale !== scale || + prevInc.theme !== appState.theme; + + let canvas: HTMLCanvasElement; + let canvasOriginSceneX: number; + let canvasOriginSceneY: number; + let fromIndex: number; + let canvasScale: number; + + if (needsAlloc) { + // Over-allocate proportionally to the current bounding box, like std::vector doubling, + // so fast large strokes trigger far fewer reallocations. The over-sized canvas is + // discarded on stroke finalisation so the memory waste is only transient. + const overshootX = Math.max( + FREEDRAW_CANVAS_OVERSHOOT_MIN, + (x2 - x1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR, + ); + const overshootY = Math.max( + FREEDRAW_CANVAS_OVERSHOOT_MIN, + (y2 - y1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR, + ); + const allocX1 = x1 - overshootX; + const allocY1 = y1 - overshootY; + const allocX2 = x2 + overshootX; + const allocY2 = y2 + overshootY; + + canvasOriginSceneX = allocX1 - padding / dpr; + canvasOriginSceneY = allocY1 - padding / dpr; + + // Raw canvas pixels before zoom scale + const rawW = (allocX2 - allocX1) * dpr + padding * 2; + const rawH = (allocY2 - allocY1) * dpr + padding * 2; + + // Respect browser canvas size limits + const AREA_LIMIT = 16777216; + const WIDTH_HEIGHT_LIMIT = 32767; + canvasScale = scale; + if ( + rawW * canvasScale > WIDTH_HEIGHT_LIMIT || + rawH * canvasScale > WIDTH_HEIGHT_LIMIT + ) { + canvasScale = Math.min( + WIDTH_HEIGHT_LIMIT / rawW, + WIDTH_HEIGHT_LIMIT / rawH, + ); + } + if (rawW * rawH * canvasScale * canvasScale > AREA_LIMIT) { + canvasScale = Math.sqrt(AREA_LIMIT / (rawW * rawH)); + } + + const canvasWidth = Math.floor(rawW * canvasScale); + const canvasHeight = Math.floor(rawH * canvasScale); + if (!canvasWidth || !canvasHeight) { + return null; + } + + canvas = document.createElement("canvas"); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + const context = canvas.getContext("2d")!; + + if ( + prevInc !== undefined && + boundsExceeded && + prevInc.scale === canvasScale && + prevInc.theme === appState.theme + ) { + // Copy existing raster to the new canvas at the correct pixel offset. + // The shift is determined by how much the canvas origin moved in scene coords. + const copyX = + (prevInc.canvasOriginSceneX - canvasOriginSceneX) * dpr * canvasScale; + const copyY = + (prevInc.canvasOriginSceneY - canvasOriginSceneY) * dpr * canvasScale; + context.drawImage(prevInc.canvas, copyX, copyY); + fromIndex = prevInc.lastRenderedPointCount; + } else { + // Full regeneration on first canvas, zoom change, or theme change + fromIndex = 0; + } + + freedrawIncrementalCache.set(element, { + canvas, + lastRenderedPointCount: fromIndex, + canvasOriginSceneX, + canvasOriginSceneY, + canvasAllocX1: allocX1, + canvasAllocY1: allocY1, + canvasAllocX2: allocX2, + canvasAllocY2: allocY2, + scale: canvasScale, + theme: appState.theme, + }); + } else { + canvas = prevInc.canvas; + canvasOriginSceneX = prevInc.canvasOriginSceneX; + canvasOriginSceneY = prevInc.canvasOriginSceneY; + fromIndex = prevInc.lastRenderedPointCount; + canvasScale = prevInc.scale; + } + + // Append only the segments added since the last render + if (fromIndex < element.points.length) { + const ctx = canvas.getContext("2d")!; + const canvasElemOffsetX = + (element.x - canvasOriginSceneX) * dpr * canvasScale; + const canvasElemOffsetY = + (element.y - canvasOriginSceneY) * dpr * canvasScale; + ctx.save(); + ctx.translate(canvasElemOffsetX, canvasElemOffsetY); + ctx.scale(dpr * canvasScale, dpr * canvasScale); + drawFreeDrawSegments(element, ctx, renderConfig, fromIndex); + ctx.restore(); + + // Update lastRenderedPointCount in the cache entry + const inc = freedrawIncrementalCache.get(element)!; + inc.lastRenderedPointCount = element.points.length; + } + + const inc = freedrawIncrementalCache.get(element)!; + return { + element, + canvas, + theme: appState.theme, + scale: canvasScale, + angle: element.angle, + zoomValue: zoom.value, + canvasOffsetX: 0, + canvasOffsetY: 0, + boundTextElementVersion: null, + imageCrop: null, + containingFrameOpacity, + boundTextCanvas: document.createElement("canvas"), + canvasOriginSceneX: inc.canvasOriginSceneX, + canvasOriginSceneY: inc.canvasOriginSceneY, + }; +}; + const generateElementWithCanvas = ( element: NonDeletedExcalidrawElement, elementsMap: NonDeletedSceneElementsMap, @@ -616,6 +850,24 @@ const generateElementWithCanvas = ( : { value: 1 as NormalizedZoomValue, }; + + // Incremental rendering path for freedraw elements being actively drawn. + // ShapeCache.delete() clears elementWithCanvasCache on every added point, so + // we bypass that cache entirely and use freedrawIncrementalCache instead. + if ( + isFreeDrawElement(element) && + "newElement" in appState && + appState.newElement?.id === element.id + ) { + return generateOrUpdateFreeDrawIncrementalCanvas( + element as ExcalidrawFreeDrawElement, + elementsMap, + zoom, + renderConfig, + appState, + ); + } + const prevElementWithCanvas = elementWithCanvasCache.get(element); const shouldRegenerateBecauseZoom = prevElementWithCanvas && @@ -718,12 +970,22 @@ const drawElementFromCanvas = ( // revert afterwards we don't have account for it during drawing context.translate(-cx, -cy); + // For the incremental freedraw path, the canvas origin is stored explicitly + // because the canvas is over-allocated beyond the tight element bounds. + const destX = + elementWithCanvas.canvasOriginSceneX !== undefined + ? (elementWithCanvas.canvasOriginSceneX + appState.scrollX) * + window.devicePixelRatio + : (x1 + appState.scrollX) * window.devicePixelRatio - padding; + const destY = + elementWithCanvas.canvasOriginSceneY !== undefined + ? (elementWithCanvas.canvasOriginSceneY + appState.scrollY) * + window.devicePixelRatio + : (y1 + appState.scrollY) * window.devicePixelRatio - padding; context.drawImage( elementWithCanvas.canvas!, - (x1 + appState.scrollX) * window.devicePixelRatio - - (padding * elementWithCanvas.scale) / elementWithCanvas.scale, - (y1 + appState.scrollY) * window.devicePixelRatio - - (padding * elementWithCanvas.scale) / elementWithCanvas.scale, + destX, + destY, elementWithCanvas.canvas!.width / elementWithCanvas.scale, elementWithCanvas.canvas!.height / elementWithCanvas.scale, ); @@ -753,6 +1015,17 @@ const drawElementFromCanvas = ( // Clear the nested element we appended to the DOM }; +/** + * Removes the incremental freedraw canvas for the given element. + * Call this when a freedraw stroke is finalised so the next render + * produces a fresh tight-bounds canvas instead of the over-allocated one. + */ +export const invalidateFreeDrawIncrementalCanvas = ( + element: ExcalidrawFreeDrawElement, +) => { + freedrawIncrementalCache.delete(element); +}; + export const renderSelectionElement = ( element: NonDeletedExcalidrawElement, context: CanvasRenderingContext2D, diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 37332c4124..6b7cdf8edd 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -14,6 +14,8 @@ import { isLineElement, } from "@excalidraw/element"; +import { invalidateFreeDrawIncrementalCanvas } from "@excalidraw/element"; + import { KEYS, arrayToMap, @@ -313,6 +315,10 @@ export const actionFinalize = register({ } : selectedLinearElement; + if (element && isFreeDrawElement(element)) { + invalidateFreeDrawIncrementalCanvas(element); + } + return { elements: newElements, appState: { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 4db823c2d4..fea3464210 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -10171,23 +10171,46 @@ class App extends React.Component { } if (newElement.type === "freedraw") { - const points = newElement.points; - const dx = pointerCoords.x - newElement.x; - const dy = pointerCoords.y - newElement.y; + // Collect all coalesced pointer positions that accumulated between + // RAF frames (important for high-frequency Apple Pencil / stylus + // input). getCoalescedEvents() returns the intermediate samples that + // would otherwise be discarded by throttleRAF, giving a dense stroke + // without extra renders. + const coalescedEvents: PointerEvent[] = + event.getCoalescedEvents?.() ?? []; + // The final event is always included; coalesced list may duplicate it + const allEvents = + coalescedEvents.length > 0 ? coalescedEvents : [event]; - const lastPoint = points.length > 0 && points[points.length - 1]; - const discardPoint = - lastPoint && lastPoint[0] === dx && lastPoint[1] === dy; + const newPoints: LocalPoint[] = []; + const newPressures: number[] = []; - if (!discardPoint) { + let lastPoint = + newElement.points.length > 0 + ? newElement.points[newElement.points.length - 1] + : null; + + for (const ev of allEvents) { + const coords = viewportCoordsToSceneCoords(ev, this.state); + const dx = coords.x - newElement.x; + const dy = coords.y - newElement.y; + if (!lastPoint || lastPoint[0] !== dx || lastPoint[1] !== dy) { + const pt = pointFrom(dx, dy); + newPoints.push(pt); + newPressures.push(ev.pressure); + lastPoint = pt; + } + } + + if (newPoints.length > 0) { const pressures = newElement.simulatePressure ? newElement.pressures - : [...newElement.pressures, event.pressure]; + : [...newElement.pressures, ...newPressures]; this.scene.mutateElement( newElement, { - points: [...points, pointFrom(dx, dy)], + points: [...newElement.points, ...newPoints], pressures, }, {