@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user