@@ -1,20 +1,12 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
|
||||
import {
|
||||
bezierEquation,
|
||||
curve,
|
||||
type GlobalPoint,
|
||||
isRightAngleRads,
|
||||
lineSegment,
|
||||
type LocalPoint,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
@@ -398,6 +390,29 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -447,24 +462,159 @@ const drawTaperedCapsule = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Draws freedraw points as individual pressure-aware tapered capsule segments.
|
||||
* Each segment [points[i-1] → points[i]] is drawn as a filled tapered capsule
|
||||
* whose start radius is derived from pressures[i-1] and end radius from
|
||||
* pressures[i]. Because adjacent segments share the same radius at their
|
||||
* common junction point, the result is a continuous stroke with naturally
|
||||
* varying pen pressure.
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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. At the last real point, `predictedPoint` (if
|
||||
* supplied) stands in for the missing next neighbour so the stroke tip curves
|
||||
* smoothly toward the predicted pen position.
|
||||
*/
|
||||
const getCatmullRomTangent = (
|
||||
points: readonly (readonly [number, number])[],
|
||||
i: number,
|
||||
predictedPoint: readonly [number, number] | undefined,
|
||||
): [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 if (predictedPoint !== undefined) {
|
||||
next = predictedPoint;
|
||||
} 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): the bezier control point is
|
||||
// p + t/3, so |t| > 3*chord causes the curve to overshoot the segment
|
||||
// bounding box and loop back on itself — exactly the source of jaggedness
|
||||
// when high-frequency input produces densely-packed or noisy points.
|
||||
// Clamp: |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. When `predictedPoint` is supplied it is used as the tangent hint at
|
||||
* 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).
|
||||
* Segments are from max(fromIndex,1) to points.length-1.
|
||||
* Pass 0 to draw everything.
|
||||
* @param predictedPoint Element-local scene coords of the first predicted
|
||||
* pointer event, used for tangent and ghost rendering.
|
||||
*/
|
||||
const drawFreeDrawSegments = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
fromIndex: number,
|
||||
predictedPoint?: readonly [number, number],
|
||||
) => {
|
||||
const { points, pressures } = element;
|
||||
const N = points.length;
|
||||
const strokeColor =
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
@@ -477,70 +627,79 @@ const drawFreeDrawSegments = (
|
||||
const getPressure = (i: number): number =>
|
||||
pressures.length > i ? pressures[i] : DEFAULT_FREEDRAW_PRESSURE;
|
||||
|
||||
if (fromIndex === 0 && points.length === 1) {
|
||||
if (fromIndex === 0 && N === 1) {
|
||||
// Single-point stroke → filled circle (dot)
|
||||
const r = baseRadius * getPressure(0) * 2;
|
||||
context.beginPath();
|
||||
context.arc(points[0][0], points[0][1], r, 0, Math.PI * 2);
|
||||
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;
|
||||
}
|
||||
|
||||
// const FREEDRAW_DEBUG_COLORS = [
|
||||
// "#e03131",
|
||||
// "#f76707",
|
||||
// "#2f9e44",
|
||||
// "#1971c2",
|
||||
// "#7048e8",
|
||||
// "#e64980",
|
||||
// ];
|
||||
const start = Math.max(fromIndex, 1);
|
||||
for (let i = start; i < points.length; i++) {
|
||||
//context.fillStyle = FREEDRAW_DEBUG_COLORS[i % FREEDRAW_DEBUG_COLORS.length];
|
||||
for (let i = start; i < N; i++) {
|
||||
const p0 = points[i - 1];
|
||||
const p1 = points[i];
|
||||
const r0 = baseRadius * getPressure(i - 1) * 2;
|
||||
const r1 = baseRadius * getPressure(i) * 2;
|
||||
|
||||
const synthesizedPressures = [r0];
|
||||
const synthesizedPoints: LocalPoint[] = [points[i - 1]];
|
||||
if (i > 1) {
|
||||
const dist = pointDistance(points[i - 1], points[i]);
|
||||
if (dist > baseRadius / 2) {
|
||||
const c = curve(
|
||||
points[i - 1],
|
||||
pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(points[i - 1], points[i - 2])),
|
||||
dist / 2,
|
||||
),
|
||||
points[i - 1],
|
||||
),
|
||||
points[i],
|
||||
points[i],
|
||||
);
|
||||
synthesizedPoints.push(bezierEquation(c, 0.2));
|
||||
synthesizedPressures.push(r0 * 0.8 + r1 * 0.2);
|
||||
synthesizedPoints.push(bezierEquation(c, 0.4));
|
||||
synthesizedPressures.push(r0 * 0.6 + r1 * 0.4);
|
||||
synthesizedPoints.push(bezierEquation(c, 0.6));
|
||||
synthesizedPressures.push(r0 * 0.4 + r1 * 0.6);
|
||||
synthesizedPoints.push(bezierEquation(c, 0.8));
|
||||
synthesizedPressures.push(r0 * 0.2 + r1 * 0.8);
|
||||
}
|
||||
}
|
||||
synthesizedPoints.push(points[i]);
|
||||
synthesizedPressures.push(r1);
|
||||
// Catmull-Rom tangents. At the last real point, use predictedPoint as
|
||||
// the look-ahead so the tip curves smoothly toward the expected position.
|
||||
const isLastPoint = i === N - 1;
|
||||
const t0 = getCatmullRomTangent(points, i - 1, undefined);
|
||||
const t1 = getCatmullRomTangent(
|
||||
points,
|
||||
i,
|
||||
isLastPoint ? predictedPoint : undefined,
|
||||
);
|
||||
|
||||
for (let j = 1; j < synthesizedPoints.length; j++) {
|
||||
drawTaperedCapsule(
|
||||
context,
|
||||
synthesizedPoints[j - 1][0],
|
||||
synthesizedPoints[j - 1][1],
|
||||
synthesizedPressures[j - 1],
|
||||
synthesizedPoints[j][0],
|
||||
synthesizedPoints[j][1],
|
||||
synthesizedPressures[j],
|
||||
);
|
||||
}
|
||||
drawSubdividedSegment(
|
||||
context,
|
||||
p0[0],
|
||||
p0[1],
|
||||
r0,
|
||||
p1[0],
|
||||
p1[1],
|
||||
r1,
|
||||
t0[0],
|
||||
t0[1],
|
||||
t1[0],
|
||||
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 * getPressure(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,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -911,8 +1070,19 @@ const generateOrUpdateFreeDrawIncrementalCanvas = (
|
||||
canvasScale = prevInc.scale;
|
||||
}
|
||||
|
||||
// Append only the segments added since the last render
|
||||
if (fromIndex < element.points.length) {
|
||||
// Fetch predicted point for this element (set by the pointer-move handler).
|
||||
const predictedPoint = freedrawPredictedPoints.get(element.id);
|
||||
|
||||
// When Catmull-Rom tangents are in use the tangent at the last committed
|
||||
// point changes each time a new real point arrives (the new point becomes
|
||||
// the look-ahead for the previous segment). Roll back two points so that
|
||||
// the revised last-committed segment is always redrawn with the correct
|
||||
// tangent. 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
|
||||
// predicted point.
|
||||
if (drawFrom < element.points.length || predictedPoint !== undefined) {
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const canvasElemOffsetX =
|
||||
(element.x - canvasOriginSceneX) * dpr * canvasScale;
|
||||
@@ -921,10 +1091,17 @@ const generateOrUpdateFreeDrawIncrementalCanvas = (
|
||||
ctx.save();
|
||||
ctx.translate(canvasElemOffsetX, canvasElemOffsetY);
|
||||
ctx.scale(dpr * canvasScale, dpr * canvasScale);
|
||||
drawFreeDrawSegments(element, ctx, renderConfig, fromIndex);
|
||||
drawFreeDrawSegments(
|
||||
element,
|
||||
ctx,
|
||||
renderConfig,
|
||||
drawFrom,
|
||||
element.points[element.points.length - 1],
|
||||
//predictedPoint,
|
||||
);
|
||||
ctx.restore();
|
||||
|
||||
// Update lastRenderedPointCount in the cache entry
|
||||
// Update lastRenderedPointCount in the cache entry.
|
||||
const inc = freedrawIncrementalCache.get(element)!;
|
||||
inc.lastRenderedPointCount = element.points.length;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
isLineElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { invalidateFreeDrawIncrementalCanvas } from "@excalidraw/element";
|
||||
import {
|
||||
invalidateFreeDrawIncrementalCanvas,
|
||||
setFreeDrawPredictedPoint,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
KEYS,
|
||||
@@ -317,6 +320,7 @@ export const actionFinalize = register<FormData>({
|
||||
|
||||
if (element && isFreeDrawElement(element)) {
|
||||
invalidateFreeDrawIncrementalCanvas(element);
|
||||
setFreeDrawPredictedPoint(element.id, null);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextElement,
|
||||
@@ -131,6 +132,8 @@ import {
|
||||
ArrowheadCardinalityOneOrManyIcon,
|
||||
ArrowheadCardinalityZeroOrManyIcon,
|
||||
ArrowheadCardinalityZeroOrOneIcon,
|
||||
FreedrawPressureConstantIcon,
|
||||
FreedrawPressureSensitiveIcon,
|
||||
} from "../components/icons";
|
||||
|
||||
import { Fonts } from "../fonts";
|
||||
@@ -2041,3 +2044,63 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeStrokeShape = register<boolean>({
|
||||
name: "changeStrokeShape",
|
||||
label: "labels.strokeShape",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
if (isFreeDrawElement(el)) {
|
||||
return newElementWith(el, {
|
||||
simulatePressure: value,
|
||||
});
|
||||
}
|
||||
return el;
|
||||
}),
|
||||
appState: { ...appState, currentItemFreedrawConstantPressure: value },
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.strokeShape")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
group="stroke-shape"
|
||||
options={[
|
||||
{
|
||||
value: true,
|
||||
text: t("labels.strokeShape_constant"),
|
||||
icon: FreedrawPressureConstantIcon,
|
||||
testId: "strokeShape-constant",
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
text: t("labels.strokeShape_pressure"),
|
||||
icon: FreedrawPressureSensitiveIcon,
|
||||
testId: "strokeShape-pressure",
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
(element) => {
|
||||
if (isFreeDrawElement(element)) {
|
||||
return element.simulatePressure;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
(element) => isFreeDrawElement(element),
|
||||
(hasSelection) =>
|
||||
hasSelection
|
||||
? null
|
||||
: appState.currentItemFreedrawConstantPressure,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export {
|
||||
actionChangeTextAlign,
|
||||
actionChangeVerticalAlign,
|
||||
actionChangeArrowProperties,
|
||||
actionChangeStrokeShape,
|
||||
} from "./actionProperties";
|
||||
|
||||
export {
|
||||
|
||||
@@ -40,6 +40,7 @@ export const getDefaultAppState = (): Omit<
|
||||
currentItemArrowType: ARROW_TYPE.round,
|
||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
currentItemFreedrawConstantPressure: true,
|
||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||
currentHoveredFontFamily: null,
|
||||
cursorButton: "up",
|
||||
@@ -172,6 +173,11 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||
currentItemFreedrawConstantPressure: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||
currentHoveredFontFamily: { browser: false, export: false, server: false },
|
||||
cursorButton: { browser: true, export: false, server: false },
|
||||
|
||||
@@ -259,6 +259,7 @@ import {
|
||||
maybeHandleArrowPointlikeDrag,
|
||||
getUncroppedWidthAndHeight,
|
||||
getActiveTextElement,
|
||||
setFreeDrawPredictedPoint,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||
@@ -8818,7 +8819,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
y: gridY,
|
||||
});
|
||||
|
||||
const simulatePressure = event.pressure === 0.5;
|
||||
const simulatePressure = this.state.currentItemFreedrawConstantPressure;
|
||||
|
||||
const element = newFreeDrawElement({
|
||||
type: elementType,
|
||||
@@ -9478,7 +9479,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private onPointerMoveFromPointerDownHandler(
|
||||
pointerDownState: PointerDownState,
|
||||
) {
|
||||
return withBatchedUpdatesThrottled((event: PointerEvent) => {
|
||||
const handler = (event: PointerEvent) => {
|
||||
if (this.state.openDialog?.name === "elementLinkSelector") {
|
||||
return;
|
||||
}
|
||||
@@ -10202,6 +10203,28 @@ 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
|
||||
@@ -10368,7 +10391,23 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// For freedraw, bypass RAF throttling so every pointer event is processed
|
||||
// synchronously. This preserves coalesced pointer events and eliminates
|
||||
// stroke lag on high-frequency stylus / pointer input.
|
||||
if (this.state.activeTool.type === "freedraw") {
|
||||
const immediate = withBatchedUpdates(handler);
|
||||
const result = immediate as typeof immediate & {
|
||||
flush(): void;
|
||||
cancel(): void;
|
||||
};
|
||||
result.flush = () => {};
|
||||
result.cancel = () => {};
|
||||
return result;
|
||||
}
|
||||
|
||||
return withBatchedUpdatesThrottled(handler);
|
||||
}
|
||||
|
||||
// Returns whether the pointer move happened over either scrollbar
|
||||
|
||||
@@ -1186,6 +1186,26 @@ export const StrokeWidthExtraBoldIcon = createIcon(
|
||||
modifiedTablerIconProps,
|
||||
);
|
||||
|
||||
export const FreedrawPressureConstantIcon = createIcon(
|
||||
<path
|
||||
d="M4 10h12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>,
|
||||
modifiedTablerIconProps,
|
||||
);
|
||||
|
||||
export const FreedrawPressureSensitiveIcon = createIcon(
|
||||
<path
|
||||
d="M4 10C6 9.5 10 8 16 7L16 13C10 12 6 10.5 4 10Z"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
/>,
|
||||
modifiedTablerIconProps,
|
||||
);
|
||||
|
||||
export const StrokeStyleSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
"changeBackground": "Change background color",
|
||||
"fill": "Fill",
|
||||
"strokeWidth": "Stroke width",
|
||||
"strokeShape": "Pressure",
|
||||
"strokeShape_constant": "Constant pressure",
|
||||
"strokeShape_pressure": "Pressure-sensitive",
|
||||
"strokeStyle": "Stroke style",
|
||||
"strokeStyle_solid": "Solid",
|
||||
"strokeStyle_dashed": "Dashed",
|
||||
|
||||
@@ -357,6 +357,7 @@ export interface AppState {
|
||||
currentItemBackgroundColor: string;
|
||||
currentItemFillStyle: ExcalidrawElement["fillStyle"];
|
||||
currentItemStrokeWidth: number;
|
||||
currentItemFreedrawConstantPressure: boolean;
|
||||
currentItemStrokeStyle: ExcalidrawElement["strokeStyle"];
|
||||
currentItemRoughness: number;
|
||||
currentItemOpacity: number;
|
||||
@@ -888,7 +889,10 @@ export type PointerDownState = Readonly<{
|
||||
// We need to have these in the state so that we can unsubscribe them
|
||||
eventListeners: {
|
||||
// It's defined on the initial pointer down event
|
||||
onMove: null | ReturnType<typeof throttleRAF>;
|
||||
onMove:
|
||||
| null
|
||||
| ReturnType<typeof throttleRAF>
|
||||
| (((event: PointerEvent) => void) & { flush(): void; cancel(): void });
|
||||
// It's defined on the initial pointer down event
|
||||
onUp: null | ((event: PointerEvent) => void);
|
||||
// It's defined on the initial pointer down event
|
||||
|
||||
@@ -158,3 +158,14 @@ 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)));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user