feat: Cached rendering

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-04-02 11:56:54 +00:00
parent d76f7b1cbc
commit 846720a286
3 changed files with 331 additions and 29 deletions
+293 -20
View File
@@ -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,
@@ -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<FormData>({
}
: selectedLinearElement;
if (element && isFreeDrawElement(element)) {
invalidateFreeDrawIncrementalCanvas(element);
}
return {
elements: newElements,
appState: {
+32 -9
View File
@@ -10171,23 +10171,46 @@ class App extends React.Component<AppProps, AppState> {
}
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<LocalPoint>(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<LocalPoint>(dx, dy)],
points: [...newElement.points, ...newPoints],
pressures,
},
{