fix: Various fixes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-04-10 18:29:29 +00:00
parent 56b2b3a41e
commit 25ec8d0869
8 changed files with 161 additions and 152 deletions
+12 -38
View File
@@ -144,10 +144,6 @@ 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;
}
@@ -390,29 +386,6 @@ const drawImagePlaceholder = (
);
};
// ─── Freedraw predicted-point store ─────────────────────────────────────────
// Keyed by element ID. Coordinates are in element-local scene units
// (i.e. relative to element.x / element.y), matching the format of
// ExcalidrawFreeDrawElement.points.
const freedrawPredictedPoints = new Map<string, readonly [number, number]>();
/**
* Sets (or clears) the predicted next point for a freedraw element that is
* currently being drawn. Call from the pointer-move handler using the first
* entry returned by PointerEvent.getPredictedEvents(). Call with `null` when
* the stroke is finalised so the ghost segment is no longer rendered.
*/
export const setFreeDrawPredictedPoint = (
elementId: string,
point: readonly [number, number] | null,
): void => {
if (point !== null) {
freedrawPredictedPoints.set(elementId, point);
} else {
freedrawPredictedPoints.delete(elementId);
}
};
const DEFAULT_FREEDRAW_PRESSURE = 0.5;
/**
@@ -939,19 +912,23 @@ export const elementWithCanvasCache = new WeakMap<
ExcalidrawElementWithCanvas
>();
// ─── Incremental freedraw canvas cache ───────────────────────────────────────
// ─── 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.
// accumulates new capsule segments without full regeneration on every added
// point.
const FREEDRAW_CANVAS_OVERSHOOT_MIN = 200; // screen pixels — minimum extra lookahead space on each side (divided by scale at use)
const FREEDRAW_CANVAS_OVERSHOOT_FACTOR = 0.5; // allocate current_dimension * factor extra on each side
// 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 {
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;
@@ -971,8 +948,8 @@ const freedrawIncrementalCache = new WeakMap<
* 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.
* 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,
@@ -1100,9 +1077,6 @@ const generateOrUpdateFreeDrawIncrementalCanvas = (
canvasScale = prevInc.scale;
}
// Fetch predicted point for this element (set by the pointer-move handler).
const predictedPoint = freedrawPredictedPoints.get(element.id);
// Roll back 2 points so the Catmull-Rom look-ahead tangent at the last
// committed segment is redrawn correctly when a new point arrives.
// Pressure smoothing is causal (one-sided) so it requires no extra rollback.
@@ -1111,7 +1085,7 @@ const generateOrUpdateFreeDrawIncrementalCanvas = (
// Draw new (and possibly revised) segments plus the ghost toward the
// predicted point.
if (drawFrom < element.points.length || predictedPoint !== undefined) {
if (drawFrom < element.points.length) {
const ctx = canvas.getContext("2d")!;
const canvasElemOffsetX =
(element.x - canvasOriginSceneX) * dpr * canvasScale;
+143 -52
View File
@@ -1,5 +1,4 @@
import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand";
import {
type GeometricShape,
@@ -981,7 +980,7 @@ const _generateElementShape = (
);
}
// (2) stroke — one path element per capsule, mirrors canvas fill() calls
// (2) stroke
shapes.push(...getFreeDrawCapsulePaths(element));
return shapes;
@@ -1172,19 +1171,17 @@ export const toggleLinePolygonState = (
// freedraw shape helper
// -----------------------------------------------------------------------------
// ─── Capsule-based SVG export (mirrors renderElement.ts) ─────────────────────
// These constants and helpers must be kept in sync with renderElement.ts.
const FREEDRAW_DEFAULT_PRESSURE = 0.5;
const FREEDRAW_BEZIER_SUBDIVIDE_TARGET_SPACING = 3;
const FREEDRAW_PRESSURE_SMOOTHING_RADIUS = 6;
/** Round to 2 dp — sub-pixel accuracy at SVG 96 dpi, much shorter strings. */
// Round to 2 dp — sub-pixel accuracy at SVG 96 dpi
const r2 = (v: number) => Math.round(v * 100) / 100;
/**
* SVG path `d` string for a single tapered capsule — the SVG counterpart of
* `drawTaperedCapsule` in renderElement.ts. Uses clockwise arcs (sweep=1)
* so the geometry matches the canvas 2D `arc(..., anticlockwise=false)` calls.
* SVG path `d` string for a single tapered capsule. Uses clockwise arcs
* (sweep=1) so the geometry matches the canvas 2D
* `arc(..., anticlockwise=false)` calls.
*/
const freedrawTaperedCapsulePath = (
x0: number,
@@ -1214,12 +1211,12 @@ const freedrawTaperedCapsulePath = (
const px = -dy / len; // perpendicular unit x
const py = dx / len; // perpendicular unit y
// P0 ± perp·r0 (start / back cap tangent points)
// P0 +/- perp·r0 (start / back cap tangent points)
const b0x = r2(x0 + px * r0);
const b0y = r2(y0 + py * r0);
const b1x = r2(x0 - px * r0);
const b1y = r2(y0 - py * r0);
// P1 ± perp·r1 (end / front cap tangent points)
// P1 +/- perp·r1 (end / front cap tangent points)
const f0x = r2(x1 - px * r1);
const f0y = r2(y1 - py * r1);
const f1x = r2(x1 + px * r1);
@@ -1242,7 +1239,7 @@ const freedrawTaperedCapsulePath = (
};
/**
* Catmull-Rom tangent at points[i] — identical math to `getCatmullRomTangent`
* Catmull-Rom tangent at points[i].Identical math to `getCatmullRomTangent`
* in renderElement.ts (predictedPoint is not needed for finalised strokes).
*/
const freedrawCatmullRomTangent = (
@@ -1296,40 +1293,49 @@ const freedrawCatmullRomTangent = (
return [tx, ty];
};
/**
* Triangular-kernel causal weighted pressure average (backward-only window).
* When `simulatePressure` is true or pressures array is empty, returns the
* default constant pressure so the geometry mirrors constant-pressure rendering.
*/
const getFreeDrawSmoothedPressure = (
element: ExcalidrawFreeDrawElement,
i: number,
): number => {
const { pressures } = element;
if (element.simulatePressure || pressures.length === 0) {
return FREEDRAW_DEFAULT_PRESSURE;
}
let sum = 0;
let totalWeight = 0;
for (let k = -FREEDRAW_PRESSURE_SMOOTHING_RADIUS; k <= 0; k++) {
const idx = i + k;
if (idx < 0) {
continue;
}
const p =
idx < pressures.length ? pressures[idx] : FREEDRAW_DEFAULT_PRESSURE;
const w = FREEDRAW_PRESSURE_SMOOTHING_RADIUS + 1 + k;
sum += p * w;
totalWeight += w;
}
return totalWeight > 0 ? sum / totalWeight : FREEDRAW_DEFAULT_PRESSURE;
};
/**
* Returns one SVG path `d` string per tapered-capsule sub-segment for a
* freedraw element, using the same Catmull-Rom Bezier subdivision and pressure
* smoothing as the canvas renderer (`drawFreeDrawSegments` in renderElement.ts).
* Returning individual paths (rather than a single compound `d` string) avoids
* fill-rule artifacts that arise when overlapping subpaths invert the winding
* direction inside a compound path.
* smoothing as the canvas renderer.
*/
const getFreeDrawCapsulePaths = (
element: ExcalidrawFreeDrawElement,
): SVGPathString[] => {
const { points, pressures } = element;
const { points } = element;
const N = points.length;
const baseRadius = (element.strokeWidth * 1.25) / 2;
const getSmoothedPressure = (i: number): number => {
if (element.simulatePressure || pressures.length === 0) {
return FREEDRAW_DEFAULT_PRESSURE;
}
let sum = 0;
let totalWeight = 0;
for (let k = -FREEDRAW_PRESSURE_SMOOTHING_RADIUS; k <= 0; k++) {
const idx = i + k;
if (idx < 0) {
continue;
}
const p =
idx < pressures.length ? pressures[idx] : FREEDRAW_DEFAULT_PRESSURE;
const w = FREEDRAW_PRESSURE_SMOOTHING_RADIUS + 1 + k;
sum += p * w;
totalWeight += w;
}
return totalWeight > 0 ? sum / totalWeight : FREEDRAW_DEFAULT_PRESSURE;
};
const getSmoothedPressure = (i: number): number =>
getFreeDrawSmoothedPressure(element, i);
const paths: SVGPathString[] = [];
@@ -1357,7 +1363,7 @@ const getFreeDrawCapsulePaths = (
const t0 = freedrawCatmullRomTangent(points, i - 1);
const t1 = freedrawCatmullRomTangent(points, i);
// Bezier subdivision (mirrors drawSubdividedSegment).
// Bezier subdivision.
const segLen = Math.sqrt((p1[0] - p0[0]) ** 2 + (p1[1] - p0[1]) ** 2);
const nSubdiv = Math.max(
1,
@@ -1406,25 +1412,110 @@ const getFreeDrawCapsulePaths = (
return paths;
};
/**
* Generates an outline polygon for a freedraw element using the same
* Catmull-Rom Bezier subdivision and pressure smoothing as the canvas renderer.
* Returns `[x, y]` points in element-local coordinates that form a closed
* polygon around the stroke (left side + right side reversed), suitable for
* hit-testing and eraser intersection.
*/
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
) => {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
): [number, number][] => {
const { points } = element;
const N = points.length;
const baseRadius = (element.strokeWidth * 1.25) / 2;
return getStroke(inputPoints as number[][], {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
}) as [number, number][];
if (N === 0) {
return [];
}
const radius0 = baseRadius * getFreeDrawSmoothedPressure(element, 0) * 2;
if (N === 1) {
// Single point case
const cx = points[0][0];
const cy = points[0][1];
const result: [number, number][] = [];
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2;
result.push([
cx + Math.cos(angle) * radius0,
cy + Math.sin(angle) * radius0,
]);
}
return result;
}
const leftPoints: [number, number][] = [];
const rightPoints: [number, number][] = [];
for (let i = 1; i < N; i++) {
const p0 = points[i - 1];
const p1 = points[i];
const r0 = baseRadius * getFreeDrawSmoothedPressure(element, i - 1) * 2;
const r1 = baseRadius * getFreeDrawSmoothedPressure(element, i) * 2;
const t0 = freedrawCatmullRomTangent(points, i - 1);
const t1 = freedrawCatmullRomTangent(points, i);
const segLen = Math.sqrt((p1[0] - p0[0]) ** 2 + (p1[1] - p0[1]) ** 2);
const nSubdiv = Math.max(
1,
Math.ceil(segLen / FREEDRAW_BEZIER_SUBDIVIDE_TARGET_SPACING),
);
const cp1x = p0[0] + t0[0] / 3;
const cp1y = p0[1] + t0[1] / 3;
const cp2x = p1[0] - t1[0] / 3;
const cp2y = p1[1] - t1[1] / 3;
// Include the start point of the first segment.
const kStart = i === 1 ? 0 : 1;
for (let k = kStart; k <= nSubdiv; k++) {
const tParam = k / nSubdiv;
const mt = 1 - tParam;
const mt2 = mt * mt;
const t2 = tParam * tParam;
const mt3 = mt2 * mt;
const t3 = t2 * tParam;
const x =
mt3 * p0[0] + 3 * mt2 * tParam * cp1x + 3 * mt * t2 * cp2x + t3 * p1[0];
const y =
mt3 * p0[1] + 3 * mt2 * tParam * cp1y + 3 * mt * t2 * cp2y + t3 * p1[1];
// Bezier first derivative for the tangent direction.
const dtx =
3 *
(mt2 * (cp1x - p0[0]) +
2 * mt * tParam * (cp2x - cp1x) +
t2 * (p1[0] - cp2x));
const dty =
3 *
(mt2 * (cp1y - p0[1]) +
2 * mt * tParam * (cp2y - cp1y) +
t2 * (p1[1] - cp2y));
const len = Math.sqrt(dtx * dtx + dty * dty);
if (len === 0) {
continue;
}
// Perpendicular (left = +, right = ).
const px = -dty / len;
const py = dtx / len;
const r = r0 + (r1 - r0) * tParam;
leftPoints.push([x + px * r, y + py * r]);
rightPoints.push([x - px * r, y - py * r]);
}
}
// Closed polygon: left side (start -> end) + right side (end -> start).
return [...leftPoints, ...rightPoints.reverse()];
};
// -----------------------------------------------------------------------------
@@ -14,10 +14,7 @@ import {
isLineElement,
} from "@excalidraw/element";
import {
invalidateFreeDrawIncrementalCanvas,
setFreeDrawPredictedPoint,
} from "@excalidraw/element";
import { invalidateFreeDrawIncrementalCanvas } from "@excalidraw/element";
import {
KEYS,
@@ -320,7 +317,6 @@ export const actionFinalize = register<FormData>({
if (element && isFreeDrawElement(element)) {
invalidateFreeDrawIncrementalCanvas(element);
setFreeDrawPredictedPoint(element.id, null);
}
return {
+3 -37
View File
@@ -259,7 +259,6 @@ import {
maybeHandleArrowPointlikeDrag,
getUncroppedWidthAndHeight,
getActiveTextElement,
setFreeDrawPredictedPoint,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -8837,7 +8836,7 @@ class App extends React.Component<AppProps, AppState> {
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
points: [pointFrom<LocalPoint>(0, 0)],
pressures: simulatePressure ? [] : [event.pressure],
pressures: [event.pressure],
});
this.scene.insertElement(element);
@@ -10172,17 +10171,10 @@ class App extends React.Component<AppProps, AppState> {
}
if (newElement.type === "freedraw") {
// 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 newPoints: LocalPoint[] = [];
const newPressures: number[] = [];
@@ -10203,32 +10195,8 @@ class App extends React.Component<AppProps, AppState> {
}
}
// Update the predicted next point for smoother stroke rendering.
// getPredictedEvents() returns samples predicted by the browser for
// the current pointer position, compensating for event-delivery
// latency (especially useful with Apple Pencil / high-frequency
// stylus input). We pass the first predicted point to the renderer
// so it can (a) use it as a Catmull-Rom look-ahead tangent and
// (b) draw a ghost segment toward the predicted position.
const predictedEvents: PointerEvent[] =
event.getPredictedEvents?.() ?? [];
if (predictedEvents.length > 0) {
const predCoords = viewportCoordsToSceneCoords(
predictedEvents[0],
this.state,
);
setFreeDrawPredictedPoint(newElement.id, [
predCoords.x - newElement.x,
predCoords.y - newElement.y,
]);
} else {
setFreeDrawPredictedPoint(newElement.id, null);
}
if (newPoints.length > 0) {
const pressures = newElement.simulatePressure
? newElement.pressures
: [...newElement.pressures, ...newPressures];
const pressures = [...newElement.pressures, ...newPressures];
this.scene.mutateElement(
newElement,
@@ -10698,9 +10666,7 @@ class App extends React.Component<AppProps, AppState> {
dx += 0.0001;
}
const pressures = newElement.simulatePressure
? []
: [...newElement.pressures, childEvent.pressure];
const pressures = [...newElement.pressures, childEvent.pressure];
this.scene.mutateElement(newElement, {
points: [...points, pointFrom<LocalPoint>(dx, dy)],
+1 -2
View File
@@ -104,7 +104,6 @@
"lodash.throttle": "4.1.1",
"nanoid": "3.3.3",
"pako": "2.0.3",
"perfect-freehand": "1.2.0",
"pica": "7.1.1",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
@@ -138,4 +137,4 @@
"jest-diff": "29.7.0",
"typescript": "5.9.3"
}
}
}
-11
View File
@@ -158,14 +158,3 @@ export const vectorNormalize = (v: Vector): Vector => {
* Calculate the right-hand normal of the vector.
*/
export const vectorNormal = (v: Vector): Vector => vector(v[1], -v[0]);
/**
* Scalar projection of vector v onto normal.
*
* @param v
* @param normal
* @returns
*/
export const vectorReflect = (v: Vector, normal: Vector): Vector => {
return vectorSubtract(v, vectorScale(normal, 2 * vectorDot(v, normal)));
};
+1 -2
View File
@@ -52,7 +52,6 @@
"@excalidraw/laser-pointer": "1.3.1",
"browser-fs-access": "0.38.0",
"pako": "2.0.3",
"perfect-freehand": "1.2.0",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
@@ -71,4 +70,4 @@
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildUtils.js && yarn gen:types"
}
}
}
-5
View File
@@ -8381,11 +8381,6 @@ pepjs@0.5.3:
resolved "https://registry.yarnpkg.com/pepjs/-/pepjs-0.5.3.tgz#dc755f03d965c20e4b1bb65e42a03a97c382cfc7"
integrity sha512-5yHVB9OHqKd9fr/OIsn8ss0NgThQ9buaqrEuwr9Or5YjPp6h+WTDKWZI+xZLaBGZCtODTnFtlSHNmhFsq67THg==
perfect-freehand@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-1.2.0.tgz#706a0f854544f6175772440c51d3b0563eb3988a"
integrity sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==
pica@7.1.1, pica@^7.1.0:
version "7.1.1"
resolved "https://registry.yarnpkg.com/pica/-/pica-7.1.1.tgz#c68b42f5cfa6cc26eaec5cfa10cc0a5299ef3b7a"