@@ -81,6 +81,7 @@ export * from "./mutateElement";
|
||||
export * from "./newElement";
|
||||
export * from "./positionElementsOnGrid";
|
||||
export * from "./renderElement";
|
||||
export { invalidateFreeDrawIncrementalCanvas } from "./renderFreedraw";
|
||||
export * from "./resizeElements";
|
||||
export * from "./resizeTest";
|
||||
export * from "./Scene";
|
||||
|
||||
@@ -65,8 +65,12 @@ import {
|
||||
} from "./typeChecks";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { getCornerRadius } from "./utils";
|
||||
|
||||
import { ShapeCache } from "./shape";
|
||||
import {
|
||||
drawFreeDrawSegments,
|
||||
generateOrUpdateFreeDrawIncrementalCanvas,
|
||||
getFreedrawCanvasPadding,
|
||||
} from "./renderFreedraw";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -92,7 +96,7 @@ const isPendingImageElement = (
|
||||
const getCanvasPadding = (element: ExcalidrawElement) => {
|
||||
switch (element.type) {
|
||||
case "freedraw":
|
||||
return element.strokeWidth * 12;
|
||||
return getFreedrawCanvasPadding(element);
|
||||
case "text":
|
||||
return element.fontSize / 2;
|
||||
case "arrow":
|
||||
@@ -393,288 +397,6 @@ const drawImagePlaceholder = (
|
||||
);
|
||||
};
|
||||
|
||||
const DEFAULT_FREEDRAW_PRESSURE = 0.5;
|
||||
|
||||
/**
|
||||
* Draws a single tapered capsule (variable-width filled stroke segment) from
|
||||
* (x0,y0) with radius r0 to (x1,y1) with radius r1. The shape is a filled
|
||||
* path consisting of a back semicircle at the start, a straight side on each
|
||||
* side, and a front semicircle at the end, so that adjacent segments sharing
|
||||
* a point use the same radius and produce a seamlessly continuous stroke.
|
||||
*/
|
||||
const drawTaperedCapsule = (
|
||||
context: CanvasRenderingContext2D,
|
||||
x0: number,
|
||||
y0: number,
|
||||
r0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
r1: number,
|
||||
) => {
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
const r = Math.max(r0, r1);
|
||||
|
||||
if (len < r / 2) {
|
||||
// Degenerate segment - draw a filled circle at the larger radius
|
||||
context.beginPath();
|
||||
context.arc((x0 + x1) / 2, (y0 + y1) / 2, r, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
return;
|
||||
}
|
||||
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const px = -dy / len; // perpendicular unit x = -sin(angle)
|
||||
const py = dx / len; // perpendicular unit y = cos(angle)
|
||||
|
||||
context.beginPath();
|
||||
// Back semicircle at P0: clockwise from (P0 + perp*r0) through (back of P0) to (P0 - perp*r0)
|
||||
context.arc(x0, y0, r0, angle + Math.PI / 2, angle - Math.PI / 2, false);
|
||||
// Neg-perp side: P0 - perp*r0 -> P1 - perp*r1 (arc endpoint is already P0 - perp*r0)
|
||||
context.lineTo(x1 - px * r1, y1 - py * r1);
|
||||
// Front semicircle at P1: clockwise from (P1 - perp*r1) through (front of P1) to (P1 + perp*r1)
|
||||
context.arc(x1, y1, r1, angle - Math.PI / 2, angle + Math.PI / 2, false);
|
||||
// Perp side: P1 + perp*r1 -> P0 + perp*r0
|
||||
context.lineTo(x0 + px * r0, y0 + py * r0);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Half-width (in samples) of the triangular smoothing kernel applied to raw
|
||||
* pressure values before computing stroke radii. A radius of R means each
|
||||
* pressure sample is averaged with R neighbours on each side, weighted
|
||||
* linearly so the centre sample has weight R+1 and the outermost weight 1.
|
||||
* Larger values produce a smoother, more uniform stroke width.
|
||||
*/
|
||||
const PRESSURE_SMOOTHING_RADIUS = 6;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const getCatmullRomTangent = (
|
||||
points: readonly (readonly [number, number])[],
|
||||
i: number,
|
||||
): [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 {
|
||||
// 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):
|
||||
// |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.
|
||||
*
|
||||
* @param fromIndex Draw segments starting from this point index (inclusive).
|
||||
* Pass 0 to draw from the beginning.
|
||||
* @param upToIndex Draw segments only up to (but not including) this point
|
||||
* index. Omit or pass `undefined` to draw all remaining
|
||||
* points. Used by the incremental canvas to stop short of
|
||||
* the last segment so the committed canvas only contains
|
||||
* segments whose Catmull-Rom tangents are fully finalised
|
||||
* (i.e. the right-hand neighbour is known).
|
||||
*/
|
||||
const drawFreeDrawSegments = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
fromIndex: number,
|
||||
upToIndex?: number,
|
||||
) => {
|
||||
const { points, pressures } = element;
|
||||
const N = points.length;
|
||||
const strokeColor =
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
|
||||
context.fillStyle = strokeColor;
|
||||
|
||||
const baseRadius = (element.strokeWidth * 1.25) / 2;
|
||||
|
||||
// Causal (one-sided) triangular-kernel weighted average of past pressure
|
||||
// samples. Only looks backward [i-R .. i], so a newly-arrived point never
|
||||
// retroactively changes the smoothed pressure of any previously rendered
|
||||
// segment. This ensures live and final renders are identical at all points.
|
||||
// When simulatePressure is true, constant pressure is used for all points.
|
||||
const getSmoothedPressure = (i: number): number => {
|
||||
if (element.simulatePressure || pressures.length === 0) {
|
||||
return DEFAULT_FREEDRAW_PRESSURE;
|
||||
}
|
||||
let sum = 0;
|
||||
let totalWeight = 0;
|
||||
for (let k = -PRESSURE_SMOOTHING_RADIUS; k <= 0; k++) {
|
||||
const idx = i + k;
|
||||
if (idx < 0) {
|
||||
continue;
|
||||
}
|
||||
const p =
|
||||
idx < pressures.length ? pressures[idx] : DEFAULT_FREEDRAW_PRESSURE;
|
||||
const w = PRESSURE_SMOOTHING_RADIUS + 1 + k; // 1 at i-R, R+1 at i
|
||||
sum += p * w;
|
||||
totalWeight += w;
|
||||
}
|
||||
return totalWeight > 0 ? sum / totalWeight : DEFAULT_FREEDRAW_PRESSURE;
|
||||
};
|
||||
|
||||
if (
|
||||
fromIndex === 0 &&
|
||||
N === 1 &&
|
||||
(upToIndex === undefined || upToIndex >= 1)
|
||||
) {
|
||||
// Single-point stroke -> filled circle (dot)
|
||||
const r = baseRadius * getSmoothedPressure(0) * 2;
|
||||
context.beginPath();
|
||||
context.arc(points[0][0], points[0][1], r, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const end = upToIndex !== undefined ? Math.min(upToIndex, N) : N;
|
||||
const start = Math.max(fromIndex, 1);
|
||||
for (let i = start; i < end; i++) {
|
||||
const p0 = points[i - 1];
|
||||
const p1 = points[i];
|
||||
// Very first pressure values are often unreliable,
|
||||
// so for the first couple of segments use a radius
|
||||
const r0 = baseRadius * getSmoothedPressure(i - 1) * 2;
|
||||
const r1 = baseRadius * getSmoothedPressure(i) * 2;
|
||||
|
||||
const t0 = getCatmullRomTangent(points, i - 1);
|
||||
const t1 = getCatmullRomTangent(points, i);
|
||||
|
||||
drawSubdividedSegment(
|
||||
context,
|
||||
p0[0],
|
||||
p0[1],
|
||||
r0,
|
||||
p1[0],
|
||||
p1[1],
|
||||
r1,
|
||||
t0[0],
|
||||
t0[1],
|
||||
t1[0],
|
||||
t1[1],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const drawElementOnCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
rc: RoughCanvas,
|
||||
@@ -881,292 +603,6 @@ 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.
|
||||
|
||||
// screen pixels - minimum extra lookahead space on each side
|
||||
// (divided by scale at use)
|
||||
const FREEDRAW_CANVAS_OVERSHOOT_MIN = 200;
|
||||
|
||||
// allocate current_dimension * factor extra on each side
|
||||
const FREEDRAW_CANVAS_OVERSHOOT_FACTOR = 0.5;
|
||||
|
||||
interface FreeDrawIncrementalCanvas {
|
||||
/**
|
||||
* Accumulation canvas - contains all segments whose Catmull-Rom tangents are
|
||||
* fully finalised (right-hand neighbour is known). With N points the last
|
||||
* finalised segment ends at index `committedPointCount - 1`, meaning segment
|
||||
* `[committedPointCount-2 -> committedPointCount-1]` has been drawn with the
|
||||
* correct tangent at `committedPointCount-1` (since point
|
||||
* `committedPointCount` existed when it was drawn). Never cleared; only
|
||||
* appended to (or copied when bounds grow).
|
||||
*/
|
||||
committedCanvas: HTMLCanvasElement;
|
||||
/**
|
||||
* Tip canvas - same pixel dimensions and scene origin as `committedCanvas`.
|
||||
* Cleared and redrawn every frame to contain only the last segment
|
||||
* `[committedPointCount-1 -> N-1]` whose tangent at `N-1` is still
|
||||
* provisional (no right-hand neighbour yet). Composited on top of
|
||||
* `committedCanvas` at display time.
|
||||
*/
|
||||
tipCanvas: HTMLCanvasElement;
|
||||
/**
|
||||
* Number of points that have been permanently drawn on `committedCanvas`.
|
||||
* The committed canvas contains segments through point index
|
||||
* `committedPointCount - 1` with final tangents. Always lags the current
|
||||
* point count by 1 (the tip holds the last unfinalisable segment).
|
||||
*/
|
||||
committedPointCount: number;
|
||||
canvasOriginSceneX: number;
|
||||
canvasOriginSceneY: number;
|
||||
canvasAllocX1: number;
|
||||
canvasAllocY1: number;
|
||||
canvasAllocX2: number;
|
||||
canvasAllocY2: number;
|
||||
scale: number;
|
||||
theme: AppState["theme"];
|
||||
}
|
||||
|
||||
const freedrawIncrementalCache = new WeakMap<
|
||||
ExcalidrawFreeDrawElement,
|
||||
FreeDrawIncrementalCanvas
|
||||
>();
|
||||
|
||||
/**
|
||||
* Generates or incrementally updates the two-canvas (committed + tip) raster
|
||||
* for a freedraw element being actively drawn.
|
||||
*
|
||||
* ## Two-canvas split
|
||||
*
|
||||
* A Catmull-Rom tangent at point `i` depends on `points[i+1]`. Until
|
||||
* `points[i+1]` arrives, the tangent at `i` uses a mirrored fallback and is
|
||||
* therefore provisional. The segment ending at the current tip `[N-2 -> N-1]`
|
||||
* is the only one with a provisional tangent.
|
||||
*
|
||||
* - **`committedCanvas`** - contains all segments whose tangents are final.
|
||||
* With N points: segments `[0->1, ..., N-3->N-2]` (`committedPointCount =
|
||||
* N-1`). This canvas is append-only; its pixels are never invalidated.
|
||||
* When a new point `N` arrives, the segment `[N-2 -> N-1]` is now
|
||||
* finalised (tangent at `N-1` uses `N` as the right-hand neighbour) and is
|
||||
* drawn onto the committed canvas. `committedPointCount` advances to `N`.
|
||||
*
|
||||
* - **`tipCanvas`** - cleared and redrawn every frame to contain only the
|
||||
* last provisional segment `[committedPointCount-1 -> N-1]`. Composited on
|
||||
* top of `committedCanvas` at display time.
|
||||
*/
|
||||
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 N = element.points.length;
|
||||
|
||||
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;
|
||||
|
||||
// ── Canvas allocation / reallocation ──────────────────────────────────────
|
||||
let committedCanvas: HTMLCanvasElement;
|
||||
let tipCanvas: HTMLCanvasElement;
|
||||
let canvasOriginSceneX: number;
|
||||
let canvasOriginSceneY: number;
|
||||
let canvasScale: number;
|
||||
// How many points to start the committed-canvas update from. On a full
|
||||
// regen this is 0; on a bounds-exceeded realloc it is the existing committed
|
||||
// count so we only append the new segments.
|
||||
let committedFromIndex: number;
|
||||
|
||||
if (needsAlloc) {
|
||||
// Over-allocate proportionally to the current bounding box so fast large
|
||||
// strokes trigger far fewer reallocations.
|
||||
const overshootX = Math.max(
|
||||
FREEDRAW_CANVAS_OVERSHOOT_MIN / scale,
|
||||
(x2 - x1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR,
|
||||
);
|
||||
const overshootY = Math.max(
|
||||
FREEDRAW_CANVAS_OVERSHOOT_MIN / scale,
|
||||
(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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
committedCanvas = document.createElement("canvas");
|
||||
committedCanvas.width = canvasWidth;
|
||||
committedCanvas.height = canvasHeight;
|
||||
|
||||
tipCanvas = document.createElement("canvas");
|
||||
tipCanvas.width = canvasWidth;
|
||||
tipCanvas.height = canvasHeight;
|
||||
|
||||
if (
|
||||
prevInc !== undefined &&
|
||||
boundsExceeded &&
|
||||
prevInc.scale === canvasScale &&
|
||||
prevInc.theme === appState.theme
|
||||
) {
|
||||
// Bounds grew: copy committed raster to new canvas at the correct offset
|
||||
// and keep accumulating. Tip will be redrawn below.
|
||||
const copyX =
|
||||
(prevInc.canvasOriginSceneX - canvasOriginSceneX) * dpr * canvasScale;
|
||||
const copyY =
|
||||
(prevInc.canvasOriginSceneY - canvasOriginSceneY) * dpr * canvasScale;
|
||||
committedCanvas
|
||||
.getContext("2d")!
|
||||
.drawImage(prevInc.committedCanvas, copyX, copyY);
|
||||
committedFromIndex = prevInc.committedPointCount - 1;
|
||||
} else {
|
||||
// Full regeneration: zoom/theme change or first frame.
|
||||
committedFromIndex = 0;
|
||||
}
|
||||
|
||||
freedrawIncrementalCache.set(element, {
|
||||
committedCanvas,
|
||||
tipCanvas,
|
||||
committedPointCount: committedFromIndex,
|
||||
canvasOriginSceneX,
|
||||
canvasOriginSceneY,
|
||||
canvasAllocX1: allocX1,
|
||||
canvasAllocY1: allocY1,
|
||||
canvasAllocX2: allocX2,
|
||||
canvasAllocY2: allocY2,
|
||||
scale: canvasScale,
|
||||
theme: appState.theme,
|
||||
});
|
||||
} else {
|
||||
committedCanvas = prevInc.committedCanvas;
|
||||
tipCanvas = prevInc.tipCanvas;
|
||||
canvasOriginSceneX = prevInc.canvasOriginSceneX;
|
||||
canvasOriginSceneY = prevInc.canvasOriginSceneY;
|
||||
canvasScale = prevInc.scale;
|
||||
committedFromIndex = prevInc.committedPointCount - 1;
|
||||
}
|
||||
|
||||
const inc = freedrawIncrementalCache.get(element)!;
|
||||
|
||||
// ── Helper: draw onto a canvas with the element's scene->pixel transform ──
|
||||
const withElementContext = (
|
||||
target: HTMLCanvasElement,
|
||||
fn: (ctx: CanvasRenderingContext2D) => void,
|
||||
) => {
|
||||
const ctx = target.getContext("2d")!;
|
||||
const offsetX = (element.x - canvasOriginSceneX) * dpr * canvasScale;
|
||||
const offsetY = (element.y - canvasOriginSceneY) * dpr * canvasScale;
|
||||
ctx.save();
|
||||
ctx.translate(offsetX, offsetY);
|
||||
ctx.scale(dpr * canvasScale, dpr * canvasScale);
|
||||
fn(ctx);
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// ── Update committed canvas ───────────────────────────────────────────────
|
||||
// With N points the last finalisable segment ends at N-2 (needs N-1 as
|
||||
// right-hand neighbour for the tangent at N-2, and N-1 is always present).
|
||||
// We draw from `committedFromIndex` up to (but not including) point N-1,
|
||||
// so the committed canvas contains segments [0->1, ..., N-3->N-2].
|
||||
const newCommittedCount = Math.max(1, N - 1);
|
||||
if (committedFromIndex < newCommittedCount) {
|
||||
withElementContext(committedCanvas, (ctx) => {
|
||||
drawFreeDrawSegments(
|
||||
element,
|
||||
ctx,
|
||||
renderConfig,
|
||||
committedFromIndex,
|
||||
newCommittedCount, // upToIndex - stop before the last provisional segment
|
||||
);
|
||||
});
|
||||
inc.committedPointCount = newCommittedCount;
|
||||
}
|
||||
|
||||
// ── Redraw tip canvas ─────────────────────────────────────────────────────
|
||||
// Always cleared and redrawn: contains the single provisional segment
|
||||
// [committedPointCount-1 -> N-1] with a predicted-point ghost if available.
|
||||
withElementContext(tipCanvas, (ctx) => {
|
||||
ctx.clearRect(
|
||||
-(element.x - canvasOriginSceneX),
|
||||
-(element.y - canvasOriginSceneY),
|
||||
tipCanvas.width / (dpr * canvasScale),
|
||||
tipCanvas.height / (dpr * canvasScale),
|
||||
);
|
||||
drawFreeDrawSegments(
|
||||
element,
|
||||
ctx,
|
||||
renderConfig,
|
||||
inc.committedPointCount - 1,
|
||||
undefined, // draw to natural end (the tip segment)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
element,
|
||||
canvas: committedCanvas,
|
||||
tipCanvas,
|
||||
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,
|
||||
@@ -1356,17 +792,6 @@ 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,
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
import { applyDarkModeFilter, THEME } from "@excalidraw/common";
|
||||
|
||||
import type { StaticCanvasRenderConfig } from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
InteractiveCanvasAppState,
|
||||
StaticCanvasAppState,
|
||||
Zoom,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { getContainingFrame } from "./frame";
|
||||
|
||||
import type { ExcalidrawElementWithCanvas } from "./renderElement";
|
||||
import type {
|
||||
ExcalidrawFreeDrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
const DEFAULT_FREEDRAW_PRESSURE = 0.5;
|
||||
|
||||
/**
|
||||
* Draws a single tapered capsule (variable-width filled stroke segment) from
|
||||
* (x0,y0) with radius r0 to (x1,y1) with radius r1. The shape is a filled
|
||||
* path consisting of a back semicircle at the start, a straight side on each
|
||||
* side, and a front semicircle at the end, so that adjacent segments sharing
|
||||
* a point use the same radius and produce a seamlessly continuous stroke.
|
||||
*/
|
||||
const drawTaperedCapsule = (
|
||||
context: CanvasRenderingContext2D,
|
||||
x0: number,
|
||||
y0: number,
|
||||
r0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
r1: number,
|
||||
) => {
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
const r = Math.max(r0, r1);
|
||||
|
||||
if (len < r / 2) {
|
||||
// Degenerate segment - draw a filled circle at the larger radius
|
||||
context.beginPath();
|
||||
context.arc((x0 + x1) / 2, (y0 + y1) / 2, r, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
return;
|
||||
}
|
||||
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const px = -dy / len; // perpendicular unit x = -sin(angle)
|
||||
const py = dx / len; // perpendicular unit y = cos(angle)
|
||||
|
||||
context.beginPath();
|
||||
// Back semicircle at P0: clockwise from (P0 + perp*r0) through (back of P0) to (P0 - perp*r0)
|
||||
context.arc(x0, y0, r0, angle + Math.PI / 2, angle - Math.PI / 2, false);
|
||||
// Neg-perp side: P0 - perp*r0 -> P1 - perp*r1 (arc endpoint is already P0 - perp*r0)
|
||||
context.lineTo(x1 - px * r1, y1 - py * r1);
|
||||
// Front semicircle at P1: clockwise from (P1 - perp*r1) through (front of P1) to (P1 + perp*r1)
|
||||
context.arc(x1, y1, r1, angle - Math.PI / 2, angle + Math.PI / 2, false);
|
||||
// Perp side: P1 + perp*r1 -> P0 + perp*r0
|
||||
context.lineTo(x0 + px * r0, y0 + py * r0);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Half-width (in samples) of the triangular smoothing kernel applied to raw
|
||||
* pressure values before computing stroke radii. A radius of R means each
|
||||
* pressure sample is averaged with R neighbours on each side, weighted
|
||||
* linearly so the centre sample has weight R+1 and the outermost weight 1.
|
||||
* Larger values produce a smoother, more uniform stroke width.
|
||||
*/
|
||||
const PRESSURE_SMOOTHING_RADIUS = 6;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const getCatmullRomTangent = (
|
||||
points: readonly (readonly [number, number])[],
|
||||
i: number,
|
||||
): [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 {
|
||||
// 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):
|
||||
// |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.
|
||||
*
|
||||
* @param fromIndex Draw segments starting from this point index (inclusive).
|
||||
* Pass 0 to draw from the beginning.
|
||||
* @param upToIndex Draw segments only up to (but not including) this point
|
||||
* index. Omit or pass `undefined` to draw all remaining
|
||||
* points. Used by the incremental canvas to stop short of
|
||||
* the last segment so the committed canvas only contains
|
||||
* segments whose Catmull-Rom tangents are fully finalised
|
||||
* (i.e. the right-hand neighbour is known).
|
||||
*/
|
||||
export const drawFreeDrawSegments = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
fromIndex: number,
|
||||
upToIndex?: number,
|
||||
) => {
|
||||
const { points, pressures } = element;
|
||||
const N = points.length;
|
||||
const strokeColor =
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
|
||||
context.fillStyle = strokeColor;
|
||||
|
||||
const baseRadius = (element.strokeWidth * 1.25) / 2;
|
||||
|
||||
// Causal (one-sided) triangular-kernel weighted average of past pressure
|
||||
// samples. Only looks backward [i-R .. i], so a newly-arrived point never
|
||||
// retroactively changes the smoothed pressure of any previously rendered
|
||||
// segment. This ensures live and final renders are identical at all points.
|
||||
// When simulatePressure is true, constant pressure is used for all points.
|
||||
const getSmoothedPressure = (i: number): number => {
|
||||
if (element.simulatePressure || pressures.length === 0) {
|
||||
return DEFAULT_FREEDRAW_PRESSURE;
|
||||
}
|
||||
let sum = 0;
|
||||
let totalWeight = 0;
|
||||
for (let k = -PRESSURE_SMOOTHING_RADIUS; k <= 0; k++) {
|
||||
const idx = i + k;
|
||||
if (idx < 0) {
|
||||
continue;
|
||||
}
|
||||
const p =
|
||||
idx < pressures.length ? pressures[idx] : DEFAULT_FREEDRAW_PRESSURE;
|
||||
const w = PRESSURE_SMOOTHING_RADIUS + 1 + k; // 1 at i-R, R+1 at i
|
||||
sum += p * w;
|
||||
totalWeight += w;
|
||||
}
|
||||
return totalWeight > 0 ? sum / totalWeight : DEFAULT_FREEDRAW_PRESSURE;
|
||||
};
|
||||
|
||||
if (
|
||||
fromIndex === 0 &&
|
||||
N === 1 &&
|
||||
(upToIndex === undefined || upToIndex >= 1)
|
||||
) {
|
||||
// Single-point stroke -> filled circle (dot)
|
||||
const r = baseRadius * getSmoothedPressure(0) * 2;
|
||||
context.beginPath();
|
||||
context.arc(points[0][0], points[0][1], r, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const end = upToIndex !== undefined ? Math.min(upToIndex, N) : N;
|
||||
const start = Math.max(fromIndex, 1);
|
||||
for (let i = start; i < end; i++) {
|
||||
const p0 = points[i - 1];
|
||||
const p1 = points[i];
|
||||
// Very first pressure values are often unreliable,
|
||||
// so for the first couple of segments use a radius
|
||||
const r0 = baseRadius * getSmoothedPressure(i - 1) * 2;
|
||||
const r1 = baseRadius * getSmoothedPressure(i) * 2;
|
||||
|
||||
const t0 = getCatmullRomTangent(points, i - 1);
|
||||
const t1 = getCatmullRomTangent(points, i);
|
||||
|
||||
drawSubdividedSegment(
|
||||
context,
|
||||
p0[0],
|
||||
p0[1],
|
||||
r0,
|
||||
p1[0],
|
||||
p1[1],
|
||||
r1,
|
||||
t0[0],
|
||||
t0[1],
|
||||
t1[0],
|
||||
t1[1],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 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.
|
||||
|
||||
// screen pixels - minimum extra lookahead space on each side
|
||||
// (divided by scale at use)
|
||||
const FREEDRAW_CANVAS_OVERSHOOT_MIN = 200;
|
||||
|
||||
// allocate current_dimension * factor extra on each side
|
||||
const FREEDRAW_CANVAS_OVERSHOOT_FACTOR = 0.5;
|
||||
|
||||
interface FreeDrawIncrementalCanvas {
|
||||
/**
|
||||
* Accumulation canvas - contains all segments whose Catmull-Rom tangents are
|
||||
* fully finalised (right-hand neighbour is known). With N points the last
|
||||
* finalised segment ends at index `committedPointCount - 1`, meaning segment
|
||||
* `[committedPointCount-2 -> committedPointCount-1]` has been drawn with the
|
||||
* correct tangent at `committedPointCount-1` (since point
|
||||
* `committedPointCount` existed when it was drawn). Never cleared; only
|
||||
* appended to (or copied when bounds grow).
|
||||
*/
|
||||
committedCanvas: HTMLCanvasElement;
|
||||
/**
|
||||
* Tip canvas - same pixel dimensions and scene origin as `committedCanvas`.
|
||||
* Cleared and redrawn every frame to contain only the last segment
|
||||
* `[committedPointCount-1 -> N-1]` whose tangent at `N-1` is still
|
||||
* provisional (no right-hand neighbour yet). Composited on top of
|
||||
* `committedCanvas` at display time.
|
||||
*/
|
||||
tipCanvas: HTMLCanvasElement;
|
||||
/**
|
||||
* Number of points that have been permanently drawn on `committedCanvas`.
|
||||
* The committed canvas contains segments through point index
|
||||
* `committedPointCount - 1` with final tangents. Always lags the current
|
||||
* point count by 1 (the tip holds the last unfinalisable segment).
|
||||
*/
|
||||
committedPointCount: number;
|
||||
canvasOriginSceneX: number;
|
||||
canvasOriginSceneY: number;
|
||||
canvasAllocX1: number;
|
||||
canvasAllocY1: number;
|
||||
canvasAllocX2: number;
|
||||
canvasAllocY2: number;
|
||||
scale: number;
|
||||
theme: AppState["theme"];
|
||||
}
|
||||
|
||||
const freedrawIncrementalCache = new WeakMap<
|
||||
ExcalidrawFreeDrawElement,
|
||||
FreeDrawIncrementalCanvas
|
||||
>();
|
||||
|
||||
export const getFreedrawCanvasPadding = (element: ExcalidrawFreeDrawElement) =>
|
||||
element.strokeWidth * 12;
|
||||
|
||||
/**
|
||||
* Generates or incrementally updates the two-canvas (committed + tip) raster
|
||||
* for a freedraw element being actively drawn.
|
||||
*
|
||||
* ## Two-canvas split
|
||||
*
|
||||
* A Catmull-Rom tangent at point `i` depends on `points[i+1]`. Until
|
||||
* `points[i+1]` arrives, the tangent at `i` uses a mirrored fallback and is
|
||||
* therefore provisional. The segment ending at the current tip `[N-2 -> N-1]`
|
||||
* is the only one with a provisional tangent.
|
||||
*
|
||||
* - **`committedCanvas`** - contains all segments whose tangents are final.
|
||||
* With N points: segments `[0->1, ..., N-3->N-2]` (`committedPointCount =
|
||||
* N-1`). This canvas is append-only; its pixels are never invalidated.
|
||||
* When a new point `N` arrives, the segment `[N-2 -> N-1]` is now
|
||||
* finalised (tangent at `N-1` uses `N` as the right-hand neighbour) and is
|
||||
* drawn onto the committed canvas. `committedPointCount` advances to `N`.
|
||||
*
|
||||
* - **`tipCanvas`** - cleared and redrawn every frame to contain only the
|
||||
* last provisional segment `[committedPointCount-1 -> N-1]`. Composited on
|
||||
* top of `committedCanvas` at display time.
|
||||
*/
|
||||
export 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 = getFreedrawCanvasPadding(element);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const containingFrameOpacity =
|
||||
getContainingFrame(element, elementsMap)?.opacity || 100;
|
||||
const N = element.points.length;
|
||||
|
||||
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;
|
||||
|
||||
// ── Canvas allocation / reallocation ──────────────────────────────────────
|
||||
let committedCanvas: HTMLCanvasElement;
|
||||
let tipCanvas: HTMLCanvasElement;
|
||||
let canvasOriginSceneX: number;
|
||||
let canvasOriginSceneY: number;
|
||||
let canvasScale: number;
|
||||
// How many points to start the committed-canvas update from. On a full
|
||||
// regen this is 0; on a bounds-exceeded realloc it is the existing committed
|
||||
// count so we only append the new segments.
|
||||
let committedFromIndex: number;
|
||||
|
||||
if (needsAlloc) {
|
||||
// Over-allocate proportionally to the current bounding box so fast large
|
||||
// strokes trigger far fewer reallocations.
|
||||
const overshootX = Math.max(
|
||||
FREEDRAW_CANVAS_OVERSHOOT_MIN / scale,
|
||||
(x2 - x1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR,
|
||||
);
|
||||
const overshootY = Math.max(
|
||||
FREEDRAW_CANVAS_OVERSHOOT_MIN / scale,
|
||||
(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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
committedCanvas = document.createElement("canvas");
|
||||
committedCanvas.width = canvasWidth;
|
||||
committedCanvas.height = canvasHeight;
|
||||
|
||||
tipCanvas = document.createElement("canvas");
|
||||
tipCanvas.width = canvasWidth;
|
||||
tipCanvas.height = canvasHeight;
|
||||
|
||||
if (
|
||||
prevInc !== undefined &&
|
||||
boundsExceeded &&
|
||||
prevInc.scale === canvasScale &&
|
||||
prevInc.theme === appState.theme
|
||||
) {
|
||||
// Bounds grew: copy committed raster to new canvas at the correct offset
|
||||
// and keep accumulating. Tip will be redrawn below.
|
||||
const copyX =
|
||||
(prevInc.canvasOriginSceneX - canvasOriginSceneX) * dpr * canvasScale;
|
||||
const copyY =
|
||||
(prevInc.canvasOriginSceneY - canvasOriginSceneY) * dpr * canvasScale;
|
||||
committedCanvas
|
||||
.getContext("2d")!
|
||||
.drawImage(prevInc.committedCanvas, copyX, copyY);
|
||||
committedFromIndex = prevInc.committedPointCount - 1;
|
||||
} else {
|
||||
// Full regeneration: zoom/theme change or first frame.
|
||||
committedFromIndex = 0;
|
||||
}
|
||||
|
||||
freedrawIncrementalCache.set(element, {
|
||||
committedCanvas,
|
||||
tipCanvas,
|
||||
committedPointCount: committedFromIndex,
|
||||
canvasOriginSceneX,
|
||||
canvasOriginSceneY,
|
||||
canvasAllocX1: allocX1,
|
||||
canvasAllocY1: allocY1,
|
||||
canvasAllocX2: allocX2,
|
||||
canvasAllocY2: allocY2,
|
||||
scale: canvasScale,
|
||||
theme: appState.theme,
|
||||
});
|
||||
} else {
|
||||
committedCanvas = prevInc.committedCanvas;
|
||||
tipCanvas = prevInc.tipCanvas;
|
||||
canvasOriginSceneX = prevInc.canvasOriginSceneX;
|
||||
canvasOriginSceneY = prevInc.canvasOriginSceneY;
|
||||
canvasScale = prevInc.scale;
|
||||
committedFromIndex = prevInc.committedPointCount - 1;
|
||||
}
|
||||
|
||||
const inc = freedrawIncrementalCache.get(element)!;
|
||||
|
||||
// ── Helper: draw onto a canvas with the element's scene->pixel transform ──
|
||||
const withElementContext = (
|
||||
target: HTMLCanvasElement,
|
||||
fn: (ctx: CanvasRenderingContext2D) => void,
|
||||
) => {
|
||||
const ctx = target.getContext("2d")!;
|
||||
const offsetX = (element.x - canvasOriginSceneX) * dpr * canvasScale;
|
||||
const offsetY = (element.y - canvasOriginSceneY) * dpr * canvasScale;
|
||||
ctx.save();
|
||||
ctx.translate(offsetX, offsetY);
|
||||
ctx.scale(dpr * canvasScale, dpr * canvasScale);
|
||||
fn(ctx);
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// ── Update committed canvas ───────────────────────────────────────────────
|
||||
// With N points the last finalisable segment ends at N-2 (needs N-1 as
|
||||
// right-hand neighbour for the tangent at N-2, and N-1 is always present).
|
||||
// We draw from `committedFromIndex` up to (but not including) point N-1,
|
||||
// so the committed canvas contains segments [0->1, ..., N-3->N-2].
|
||||
const newCommittedCount = Math.max(1, N - 1);
|
||||
if (committedFromIndex < newCommittedCount) {
|
||||
withElementContext(committedCanvas, (ctx) => {
|
||||
drawFreeDrawSegments(
|
||||
element,
|
||||
ctx,
|
||||
renderConfig,
|
||||
committedFromIndex,
|
||||
newCommittedCount, // upToIndex - stop before the last provisional segment
|
||||
);
|
||||
});
|
||||
inc.committedPointCount = newCommittedCount;
|
||||
}
|
||||
|
||||
// ── Redraw tip canvas ─────────────────────────────────────────────────────
|
||||
// Always cleared and redrawn: contains the single provisional segment
|
||||
// [committedPointCount-1 -> N-1] with a predicted-point ghost if available.
|
||||
withElementContext(tipCanvas, (ctx) => {
|
||||
ctx.clearRect(
|
||||
-(element.x - canvasOriginSceneX),
|
||||
-(element.y - canvasOriginSceneY),
|
||||
tipCanvas.width / (dpr * canvasScale),
|
||||
tipCanvas.height / (dpr * canvasScale),
|
||||
);
|
||||
drawFreeDrawSegments(
|
||||
element,
|
||||
ctx,
|
||||
renderConfig,
|
||||
inc.committedPointCount - 1,
|
||||
undefined, // draw to natural end (the tip segment)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
element,
|
||||
canvas: committedCanvas,
|
||||
tipCanvas,
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
@@ -10218,35 +10218,41 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const coords = viewportCoordsToSceneCoords(ev, this.state);
|
||||
const rawDx = coords.x - newElement.x;
|
||||
const rawDy = coords.y - newElement.y;
|
||||
let dx = rawDx;
|
||||
let dy = rawDy;
|
||||
|
||||
// One Euro Filter: speed-adaptive low-pass for mouse events.
|
||||
const dt = Math.max(
|
||||
prevTs !== null ? (ev.timeStamp - prevTs) / 1000 : 1 / 60,
|
||||
0.001,
|
||||
); // seconds
|
||||
if (event.pointerType === "mouse") {
|
||||
// One Euro Filter: speed-adaptive low-pass for mouse events.
|
||||
const dt = Math.max(
|
||||
prevTs !== null ? (ev.timeStamp - prevTs) / 1000 : 1 / 60,
|
||||
0.001,
|
||||
); // seconds
|
||||
|
||||
// 1. Smooth the derivative (fixed D_CUTOFF pre-filter).
|
||||
const alphaD = emaAlpha(D_CUTOFF, dt);
|
||||
const rawDxDt = prevRawX !== null ? (rawDx - prevRawX) / dt : 0;
|
||||
const rawDyDt = prevRawY !== null ? (rawDy - prevRawY) / dt : 0;
|
||||
emaDx = alphaD * rawDxDt + (1 - alphaD) * emaDx;
|
||||
emaDy = alphaD * rawDyDt + (1 - alphaD) * emaDy;
|
||||
// 1. Smooth the derivative (fixed D_CUTOFF pre-filter).
|
||||
const alphaD = emaAlpha(D_CUTOFF, dt);
|
||||
const rawDxDt = prevRawX !== null ? (rawDx - prevRawX) / dt : 0;
|
||||
const rawDyDt = prevRawY !== null ? (rawDy - prevRawY) / dt : 0;
|
||||
emaDx = alphaD * rawDxDt + (1 - alphaD) * emaDx;
|
||||
emaDy = alphaD * rawDyDt + (1 - alphaD) * emaDy;
|
||||
|
||||
// 2. Adaptive cutoff: higher speed → higher cutoff → less lag.
|
||||
const speed = Math.sqrt(emaDx * emaDx + emaDy * emaDy);
|
||||
const cutoff = MIN_CUTOFF + BETA * speed;
|
||||
const alphaP = emaAlpha(cutoff, dt);
|
||||
// 2. Adaptive cutoff: higher speed → higher cutoff → less lag.
|
||||
const speed = Math.sqrt(emaDx * emaDx + emaDy * emaDy);
|
||||
const cutoff = MIN_CUTOFF + BETA * speed;
|
||||
const alphaP = emaAlpha(cutoff, dt);
|
||||
|
||||
// 3. Smooth position with adaptive alpha.
|
||||
emaX = emaX === null ? rawDx : alphaP * rawDx + (1 - alphaP) * emaX;
|
||||
emaY = emaY === null ? rawDy : alphaP * rawDy + (1 - alphaP) * emaY;
|
||||
// 3. Smooth position with adaptive alpha.
|
||||
emaX =
|
||||
emaX === null ? rawDx : alphaP * rawDx + (1 - alphaP) * emaX;
|
||||
emaY =
|
||||
emaY === null ? rawDy : alphaP * rawDy + (1 - alphaP) * emaY;
|
||||
|
||||
prevRawX = rawDx;
|
||||
prevRawY = rawDy;
|
||||
prevTs = ev.timeStamp;
|
||||
prevRawX = rawDx;
|
||||
prevRawY = rawDy;
|
||||
prevTs = ev.timeStamp;
|
||||
|
||||
const dx = emaX;
|
||||
const dy = emaY;
|
||||
dx = emaX;
|
||||
dy = emaY;
|
||||
}
|
||||
|
||||
if (!lastPoint || lastPoint[0] !== dx || lastPoint[1] !== dy) {
|
||||
const pt = pointFrom<LocalPoint>(dx, dy);
|
||||
|
||||
Reference in New Issue
Block a user