@@ -146,6 +146,13 @@ export interface ExcalidrawElementWithCanvas {
|
|||||||
boundTextCanvas: HTMLCanvasElement;
|
boundTextCanvas: HTMLCanvasElement;
|
||||||
canvasOriginSceneX?: number;
|
canvasOriginSceneX?: number;
|
||||||
canvasOriginSceneY?: number;
|
canvasOriginSceneY?: number;
|
||||||
|
/**
|
||||||
|
* Tip canvas for incremental freedraw rendering. Contains only the last
|
||||||
|
* unfinalised segment (whose Catmull-Rom right-hand tangent changes with
|
||||||
|
* each new point) and is cleared + redrawn every frame. Composited on top
|
||||||
|
* of `canvas` (the committed accumulation canvas) in drawElementFromCanvas.
|
||||||
|
*/
|
||||||
|
tipCanvas?: HTMLCanvasElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cappedElementCanvasSize = (
|
const cappedElementCanvasSize = (
|
||||||
@@ -410,7 +417,7 @@ const drawTaperedCapsule = (
|
|||||||
const r = Math.max(r0, r1);
|
const r = Math.max(r0, r1);
|
||||||
|
|
||||||
if (len < r / 2) {
|
if (len < r / 2) {
|
||||||
// Degenerate segment — draw a filled circle at the larger radius
|
// Degenerate segment - draw a filled circle at the larger radius
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.arc((x0 + x1) / 2, (y0 + y1) / 2, r, 0, Math.PI * 2);
|
context.arc((x0 + x1) / 2, (y0 + y1) / 2, r, 0, Math.PI * 2);
|
||||||
context.fill();
|
context.fill();
|
||||||
@@ -424,11 +431,11 @@ const drawTaperedCapsule = (
|
|||||||
context.beginPath();
|
context.beginPath();
|
||||||
// Back semicircle at P0: clockwise from (P0 + perp*r0) through (back of P0) to (P0 - perp*r0)
|
// 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);
|
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)
|
// Neg-perp side: P0 - perp*r0 -> P1 - perp*r1 (arc endpoint is already P0 - perp*r0)
|
||||||
context.lineTo(x1 - px * r1, y1 - py * r1);
|
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)
|
// 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);
|
context.arc(x1, y1, r1, angle - Math.PI / 2, angle + Math.PI / 2, false);
|
||||||
// Perp side: P1 + perp*r1 → P0 + perp*r0
|
// Perp side: P1 + perp*r1 -> P0 + perp*r0
|
||||||
context.lineTo(x0 + px * r0, y0 + py * r0);
|
context.lineTo(x0 + px * r0, y0 + py * r0);
|
||||||
context.closePath();
|
context.closePath();
|
||||||
context.fill();
|
context.fill();
|
||||||
@@ -453,14 +460,11 @@ const PRESSURE_SMOOTHING_RADIUS = 6;
|
|||||||
/**
|
/**
|
||||||
* Returns the Catmull-Rom tangent vector at points[i], using the neighbouring
|
* Returns the Catmull-Rom tangent vector at points[i], using the neighbouring
|
||||||
* points for a uniform parameterisation. At the first point a one-sided
|
* points for a uniform parameterisation. At the first point a one-sided
|
||||||
* forward tangent is used. At the last real point, `predictedPoint` (if
|
* forward tangent is used.
|
||||||
* supplied) stands in for the missing next neighbour so the stroke tip curves
|
|
||||||
* smoothly toward the predicted pen position.
|
|
||||||
*/
|
*/
|
||||||
const getCatmullRomTangent = (
|
const getCatmullRomTangent = (
|
||||||
points: readonly (readonly [number, number])[],
|
points: readonly (readonly [number, number])[],
|
||||||
i: number,
|
i: number,
|
||||||
predictedPoint: readonly [number, number] | undefined,
|
|
||||||
): [number, number] => {
|
): [number, number] => {
|
||||||
const N = points.length;
|
const N = points.length;
|
||||||
const cur = points[i];
|
const cur = points[i];
|
||||||
@@ -469,8 +473,6 @@ const getCatmullRomTangent = (
|
|||||||
let next: readonly [number, number];
|
let next: readonly [number, number];
|
||||||
if (i < N - 1) {
|
if (i < N - 1) {
|
||||||
next = points[i + 1];
|
next = points[i + 1];
|
||||||
} else if (predictedPoint !== undefined) {
|
|
||||||
next = predictedPoint;
|
|
||||||
} else {
|
} else {
|
||||||
// Mirror back across cur to get a forward tangent at the last point.
|
// Mirror back across cur to get a forward tangent at the last point.
|
||||||
const prev2 = i > 0 ? points[i - 1] : cur;
|
const prev2 = i > 0 ? points[i - 1] : cur;
|
||||||
@@ -576,21 +578,23 @@ const drawSubdividedSegment = (
|
|||||||
* Draws freedraw points as bezier-subdivided, pressure-aware tapered capsule
|
* Draws freedraw points as bezier-subdivided, pressure-aware tapered capsule
|
||||||
* segments. Consecutive real points are connected with Catmull-Rom cubic
|
* segments. Consecutive real points are connected with Catmull-Rom cubic
|
||||||
* bezier curves so the rendered stroke is smooth even when input samples are
|
* 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
|
* sparse.
|
||||||
* 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).
|
* @param fromIndex Draw segments starting from this point index (inclusive).
|
||||||
* Pass 0 to draw everything.
|
* Pass 0 to draw from the beginning.
|
||||||
* @param predictedPoint Element-local scene coords of the first predicted
|
* @param upToIndex Draw segments only up to (but not including) this point
|
||||||
* pointer event, used for tangent and ghost rendering.
|
* 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 = (
|
const drawFreeDrawSegments = (
|
||||||
element: ExcalidrawFreeDrawElement,
|
element: ExcalidrawFreeDrawElement,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
fromIndex: number,
|
fromIndex: number,
|
||||||
predictedPoint?: readonly [number, number],
|
upToIndex?: number,
|
||||||
) => {
|
) => {
|
||||||
const { points, pressures } = element;
|
const { points, pressures } = element;
|
||||||
const N = points.length;
|
const N = points.length;
|
||||||
@@ -628,23 +632,23 @@ const drawFreeDrawSegments = (
|
|||||||
return totalWeight > 0 ? sum / totalWeight : DEFAULT_FREEDRAW_PRESSURE;
|
return totalWeight > 0 ? sum / totalWeight : DEFAULT_FREEDRAW_PRESSURE;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fromIndex === 0 && N === 1) {
|
if (
|
||||||
// Single-point stroke → filled circle (dot)
|
fromIndex === 0 &&
|
||||||
|
N === 1 &&
|
||||||
|
(upToIndex === undefined || upToIndex >= 1)
|
||||||
|
) {
|
||||||
|
// Single-point stroke -> filled circle (dot)
|
||||||
const r = baseRadius * getSmoothedPressure(0) * 2;
|
const r = baseRadius * getSmoothedPressure(0) * 2;
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.arc(points[0][0], points[0][1], r, 0, Math.PI * 2);
|
context.arc(points[0][0], points[0][1], r, 0, Math.PI * 2);
|
||||||
context.fill();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const end = upToIndex !== undefined ? Math.min(upToIndex, N) : N;
|
||||||
const start = Math.max(fromIndex, 1);
|
const start = Math.max(fromIndex, 1);
|
||||||
for (let i = start; i < N; i++) {
|
for (let i = start; i < end; i++) {
|
||||||
const p0 = points[i - 1];
|
const p0 = points[i - 1];
|
||||||
const p1 = points[i];
|
const p1 = points[i];
|
||||||
// Very first pressure values are often unreliable,
|
// Very first pressure values are often unreliable,
|
||||||
@@ -652,15 +656,8 @@ const drawFreeDrawSegments = (
|
|||||||
const r0 = baseRadius * getSmoothedPressure(i - 1) * 2;
|
const r0 = baseRadius * getSmoothedPressure(i - 1) * 2;
|
||||||
const r1 = baseRadius * getSmoothedPressure(i) * 2;
|
const r1 = baseRadius * getSmoothedPressure(i) * 2;
|
||||||
|
|
||||||
// Catmull-Rom tangents. At the last real point, use predictedPoint as
|
const t0 = getCatmullRomTangent(points, i - 1);
|
||||||
// the look-ahead so the tip curves smoothly toward the expected position.
|
const t1 = getCatmullRomTangent(points, i);
|
||||||
const isLastPoint = i === N - 1;
|
|
||||||
const t0 = getCatmullRomTangent(points, i - 1, undefined);
|
|
||||||
const t1 = getCatmullRomTangent(
|
|
||||||
points,
|
|
||||||
i,
|
|
||||||
isLastPoint ? predictedPoint : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
drawSubdividedSegment(
|
drawSubdividedSegment(
|
||||||
context,
|
context,
|
||||||
@@ -676,34 +673,6 @@ const drawFreeDrawSegments = (
|
|||||||
t1[1],
|
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 * getSmoothedPressure(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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawElementOnCanvas = (
|
const drawElementOnCanvas = (
|
||||||
@@ -917,7 +886,7 @@ export const elementWithCanvasCache = new WeakMap<
|
|||||||
// accumulates new capsule segments without full regeneration on every added
|
// accumulates new capsule segments without full regeneration on every added
|
||||||
// point.
|
// point.
|
||||||
|
|
||||||
// screen pixels — minimum extra lookahead space on each side
|
// screen pixels - minimum extra lookahead space on each side
|
||||||
// (divided by scale at use)
|
// (divided by scale at use)
|
||||||
const FREEDRAW_CANVAS_OVERSHOOT_MIN = 200;
|
const FREEDRAW_CANVAS_OVERSHOOT_MIN = 200;
|
||||||
|
|
||||||
@@ -925,8 +894,31 @@ const FREEDRAW_CANVAS_OVERSHOOT_MIN = 200;
|
|||||||
const FREEDRAW_CANVAS_OVERSHOOT_FACTOR = 0.5;
|
const FREEDRAW_CANVAS_OVERSHOOT_FACTOR = 0.5;
|
||||||
|
|
||||||
interface FreeDrawIncrementalCanvas {
|
interface FreeDrawIncrementalCanvas {
|
||||||
canvas: HTMLCanvasElement;
|
/**
|
||||||
lastRenderedPointCount: number;
|
* 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;
|
canvasOriginSceneX: number;
|
||||||
canvasOriginSceneY: number;
|
canvasOriginSceneY: number;
|
||||||
canvasAllocX1: number;
|
canvasAllocX1: number;
|
||||||
@@ -943,13 +935,26 @@ const freedrawIncrementalCache = new WeakMap<
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates or incrementally updates a raster canvas for a freedraw element
|
* Generates or incrementally updates the two-canvas (committed + tip) raster
|
||||||
* that is being actively drawn. Unlike the standard element canvas cache, this
|
* for a freedraw element being actively drawn.
|
||||||
* 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
|
* ## Two-canvas split
|
||||||
* raster is copied into a new larger canvas before appending the next segments
|
*
|
||||||
|
* 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 = (
|
const generateOrUpdateFreeDrawIncrementalCanvas = (
|
||||||
element: ExcalidrawFreeDrawElement,
|
element: ExcalidrawFreeDrawElement,
|
||||||
@@ -964,6 +969,7 @@ const generateOrUpdateFreeDrawIncrementalCanvas = (
|
|||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
const containingFrameOpacity =
|
const containingFrameOpacity =
|
||||||
getContainingFrame(element, elementsMap)?.opacity || 100;
|
getContainingFrame(element, elementsMap)?.opacity || 100;
|
||||||
|
const N = element.points.length;
|
||||||
|
|
||||||
const prevInc = freedrawIncrementalCache.get(element);
|
const prevInc = freedrawIncrementalCache.get(element);
|
||||||
|
|
||||||
@@ -980,22 +986,26 @@ const generateOrUpdateFreeDrawIncrementalCanvas = (
|
|||||||
prevInc.scale !== scale ||
|
prevInc.scale !== scale ||
|
||||||
prevInc.theme !== appState.theme;
|
prevInc.theme !== appState.theme;
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement;
|
// ── Canvas allocation / reallocation ──────────────────────────────────────
|
||||||
|
let committedCanvas: HTMLCanvasElement;
|
||||||
|
let tipCanvas: HTMLCanvasElement;
|
||||||
let canvasOriginSceneX: number;
|
let canvasOriginSceneX: number;
|
||||||
let canvasOriginSceneY: number;
|
let canvasOriginSceneY: number;
|
||||||
let fromIndex: number;
|
|
||||||
let canvasScale: 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) {
|
if (needsAlloc) {
|
||||||
// Over-allocate proportionally to the current bounding box, like std::vector doubling,
|
// Over-allocate proportionally to the current bounding box so fast large
|
||||||
// so fast large strokes trigger far fewer reallocations. The over-sized canvas is
|
// strokes trigger far fewer reallocations.
|
||||||
// discarded on stroke finalisation so the memory waste is only transient.
|
|
||||||
const overshootX = Math.max(
|
const overshootX = Math.max(
|
||||||
FREEDRAW_CANVAS_OVERSHOOT_MIN / scale, // convert screen-pixel budget to scene units
|
FREEDRAW_CANVAS_OVERSHOOT_MIN / scale,
|
||||||
(x2 - x1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR,
|
(x2 - x1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR,
|
||||||
);
|
);
|
||||||
const overshootY = Math.max(
|
const overshootY = Math.max(
|
||||||
FREEDRAW_CANVAS_OVERSHOOT_MIN / scale, // convert screen-pixel budget to scene units
|
FREEDRAW_CANVAS_OVERSHOOT_MIN / scale,
|
||||||
(y2 - y1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR,
|
(y2 - y1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR,
|
||||||
);
|
);
|
||||||
const allocX1 = x1 - overshootX;
|
const allocX1 = x1 - overshootX;
|
||||||
@@ -1006,11 +1016,10 @@ const generateOrUpdateFreeDrawIncrementalCanvas = (
|
|||||||
canvasOriginSceneX = allocX1 - padding / dpr;
|
canvasOriginSceneX = allocX1 - padding / dpr;
|
||||||
canvasOriginSceneY = allocY1 - padding / dpr;
|
canvasOriginSceneY = allocY1 - padding / dpr;
|
||||||
|
|
||||||
// Raw canvas pixels before zoom scale
|
|
||||||
const rawW = (allocX2 - allocX1) * dpr + padding * 2;
|
const rawW = (allocX2 - allocX1) * dpr + padding * 2;
|
||||||
const rawH = (allocY2 - allocY1) * dpr + padding * 2;
|
const rawH = (allocY2 - allocY1) * dpr + padding * 2;
|
||||||
|
|
||||||
// Respect browser canvas size limits
|
// Respect browser canvas size limits.
|
||||||
const AREA_LIMIT = 16777216;
|
const AREA_LIMIT = 16777216;
|
||||||
const WIDTH_HEIGHT_LIMIT = 32767;
|
const WIDTH_HEIGHT_LIMIT = 32767;
|
||||||
canvasScale = scale;
|
canvasScale = scale;
|
||||||
@@ -1033,10 +1042,13 @@ const generateOrUpdateFreeDrawIncrementalCanvas = (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas = document.createElement("canvas");
|
committedCanvas = document.createElement("canvas");
|
||||||
canvas.width = canvasWidth;
|
committedCanvas.width = canvasWidth;
|
||||||
canvas.height = canvasHeight;
|
committedCanvas.height = canvasHeight;
|
||||||
const context = canvas.getContext("2d")!;
|
|
||||||
|
tipCanvas = document.createElement("canvas");
|
||||||
|
tipCanvas.width = canvasWidth;
|
||||||
|
tipCanvas.height = canvasHeight;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
prevInc !== undefined &&
|
prevInc !== undefined &&
|
||||||
@@ -1044,22 +1056,25 @@ const generateOrUpdateFreeDrawIncrementalCanvas = (
|
|||||||
prevInc.scale === canvasScale &&
|
prevInc.scale === canvasScale &&
|
||||||
prevInc.theme === appState.theme
|
prevInc.theme === appState.theme
|
||||||
) {
|
) {
|
||||||
// Copy existing raster to the new canvas at the correct pixel offset.
|
// Bounds grew: copy committed raster to new canvas at the correct offset
|
||||||
// The shift is determined by how much the canvas origin moved in scene coords.
|
// and keep accumulating. Tip will be redrawn below.
|
||||||
const copyX =
|
const copyX =
|
||||||
(prevInc.canvasOriginSceneX - canvasOriginSceneX) * dpr * canvasScale;
|
(prevInc.canvasOriginSceneX - canvasOriginSceneX) * dpr * canvasScale;
|
||||||
const copyY =
|
const copyY =
|
||||||
(prevInc.canvasOriginSceneY - canvasOriginSceneY) * dpr * canvasScale;
|
(prevInc.canvasOriginSceneY - canvasOriginSceneY) * dpr * canvasScale;
|
||||||
context.drawImage(prevInc.canvas, copyX, copyY);
|
committedCanvas
|
||||||
fromIndex = prevInc.lastRenderedPointCount;
|
.getContext("2d")!
|
||||||
|
.drawImage(prevInc.committedCanvas, copyX, copyY);
|
||||||
|
committedFromIndex = prevInc.committedPointCount - 1;
|
||||||
} else {
|
} else {
|
||||||
// Full regeneration on first canvas, zoom change, or theme change
|
// Full regeneration: zoom/theme change or first frame.
|
||||||
fromIndex = 0;
|
committedFromIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
freedrawIncrementalCache.set(element, {
|
freedrawIncrementalCache.set(element, {
|
||||||
canvas,
|
committedCanvas,
|
||||||
lastRenderedPointCount: fromIndex,
|
tipCanvas,
|
||||||
|
committedPointCount: committedFromIndex,
|
||||||
canvasOriginSceneX,
|
canvasOriginSceneX,
|
||||||
canvasOriginSceneY,
|
canvasOriginSceneY,
|
||||||
canvasAllocX1: allocX1,
|
canvasAllocX1: allocX1,
|
||||||
@@ -1070,49 +1085,73 @@ const generateOrUpdateFreeDrawIncrementalCanvas = (
|
|||||||
theme: appState.theme,
|
theme: appState.theme,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
canvas = prevInc.canvas;
|
committedCanvas = prevInc.committedCanvas;
|
||||||
|
tipCanvas = prevInc.tipCanvas;
|
||||||
canvasOriginSceneX = prevInc.canvasOriginSceneX;
|
canvasOriginSceneX = prevInc.canvasOriginSceneX;
|
||||||
canvasOriginSceneY = prevInc.canvasOriginSceneY;
|
canvasOriginSceneY = prevInc.canvasOriginSceneY;
|
||||||
fromIndex = prevInc.lastRenderedPointCount;
|
|
||||||
canvasScale = prevInc.scale;
|
canvasScale = prevInc.scale;
|
||||||
|
committedFromIndex = prevInc.committedPointCount - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Roll back 2 points so the Catmull-Rom look-ahead tangent at the last
|
const inc = freedrawIncrementalCache.get(element)!;
|
||||||
// committed segment is redrawn correctly when a new point arrives.
|
|
||||||
// Pressure smoothing is causal (one-sided) so it requires no extra rollback.
|
|
||||||
// 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
|
// ── Helper: draw onto a canvas with the element's scene->pixel transform ──
|
||||||
// predicted point.
|
const withElementContext = (
|
||||||
if (drawFrom < element.points.length) {
|
target: HTMLCanvasElement,
|
||||||
const ctx = canvas.getContext("2d")!;
|
fn: (ctx: CanvasRenderingContext2D) => void,
|
||||||
const canvasElemOffsetX =
|
) => {
|
||||||
(element.x - canvasOriginSceneX) * dpr * canvasScale;
|
const ctx = target.getContext("2d")!;
|
||||||
const canvasElemOffsetY =
|
const offsetX = (element.x - canvasOriginSceneX) * dpr * canvasScale;
|
||||||
(element.y - canvasOriginSceneY) * dpr * canvasScale;
|
const offsetY = (element.y - canvasOriginSceneY) * dpr * canvasScale;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(canvasElemOffsetX, canvasElemOffsetY);
|
ctx.translate(offsetX, offsetY);
|
||||||
ctx.scale(dpr * canvasScale, dpr * canvasScale);
|
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(
|
drawFreeDrawSegments(
|
||||||
element,
|
element,
|
||||||
ctx,
|
ctx,
|
||||||
renderConfig,
|
renderConfig,
|
||||||
drawFrom,
|
inc.committedPointCount - 1,
|
||||||
element.points[element.points.length - 1],
|
undefined, // draw to natural end (the tip segment)
|
||||||
//predictedPoint,
|
|
||||||
);
|
);
|
||||||
ctx.restore();
|
});
|
||||||
|
|
||||||
// Update lastRenderedPointCount in the cache entry.
|
|
||||||
const inc = freedrawIncrementalCache.get(element)!;
|
|
||||||
inc.lastRenderedPointCount = element.points.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inc = freedrawIncrementalCache.get(element)!;
|
|
||||||
return {
|
return {
|
||||||
element,
|
element,
|
||||||
canvas,
|
canvas: committedCanvas,
|
||||||
|
tipCanvas,
|
||||||
theme: appState.theme,
|
theme: appState.theme,
|
||||||
scale: canvasScale,
|
scale: canvasScale,
|
||||||
angle: element.angle,
|
angle: element.angle,
|
||||||
@@ -1279,6 +1318,19 @@ const drawElementFromCanvas = (
|
|||||||
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
|
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Composite the tip canvas (incremental freedraw path) on top. It is
|
||||||
|
// the same size and at the same scene origin as the committed canvas, so
|
||||||
|
// it uses identical destX / destY / dimensions.
|
||||||
|
if (elementWithCanvas.tipCanvas) {
|
||||||
|
context.drawImage(
|
||||||
|
elementWithCanvas.tipCanvas,
|
||||||
|
destX,
|
||||||
|
destY,
|
||||||
|
elementWithCanvas.tipCanvas.width / elementWithCanvas.scale,
|
||||||
|
elementWithCanvas.tipCanvas.height / elementWithCanvas.scale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
|
import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
|
||||||
"true" &&
|
"true" &&
|
||||||
@@ -1555,7 +1607,7 @@ export const renderElement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
// not exporting → optimized rendering (cache & render from element
|
// not exporting -> optimized rendering (cache & render from element
|
||||||
// canvases)
|
// canvases)
|
||||||
} else {
|
} else {
|
||||||
const elementWithCanvas = generateElementWithCanvas(
|
const elementWithCanvas = generateElementWithCanvas(
|
||||||
|
|||||||
Reference in New Issue
Block a user