@@ -1,20 +1,12 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bezierEquation,
|
|
||||||
curve,
|
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
isRightAngleRads,
|
isRightAngleRads,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
type LocalPoint,
|
|
||||||
pointDistance,
|
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointFromVector,
|
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
type Radians,
|
type Radians,
|
||||||
vectorFromPoint,
|
|
||||||
vectorNormalize,
|
|
||||||
vectorScale,
|
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
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;
|
const DEFAULT_FREEDRAW_PRESSURE = 0.5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -447,24 +462,159 @@ const drawTaperedCapsule = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draws freedraw points as individual pressure-aware tapered capsule segments.
|
* Target spacing (in scene units) between consecutive capsule sub-segments
|
||||||
* Each segment [points[i-1] → points[i]] is drawn as a filled tapered capsule
|
* produced by the Catmull-Rom bezier subdivision. Smaller values give
|
||||||
* whose start radius is derived from pressures[i-1] and end radius from
|
* smoother curves at the cost of more draw calls.
|
||||||
* pressures[i]. Because adjacent segments share the same radius at their
|
*/
|
||||||
* common junction point, the result is a continuous stroke with naturally
|
const BEZIER_SUBDIVIDE_TARGET_SPACING = 3;
|
||||||
* varying pen pressure.
|
|
||||||
|
/**
|
||||||
|
* 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).
|
* @param fromIndex Draw segments starting from this index (inclusive).
|
||||||
* Segments are from max(fromIndex,1) to points.length-1.
|
|
||||||
* Pass 0 to draw everything.
|
* 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 = (
|
const drawFreeDrawSegments = (
|
||||||
element: ExcalidrawFreeDrawElement,
|
element: ExcalidrawFreeDrawElement,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
fromIndex: number,
|
fromIndex: number,
|
||||||
|
predictedPoint?: readonly [number, number],
|
||||||
) => {
|
) => {
|
||||||
const { points, pressures } = element;
|
const { points, pressures } = element;
|
||||||
|
const N = points.length;
|
||||||
const strokeColor =
|
const strokeColor =
|
||||||
renderConfig.theme === THEME.DARK
|
renderConfig.theme === THEME.DARK
|
||||||
? applyDarkModeFilter(element.strokeColor)
|
? applyDarkModeFilter(element.strokeColor)
|
||||||
@@ -477,70 +627,79 @@ const drawFreeDrawSegments = (
|
|||||||
const getPressure = (i: number): number =>
|
const getPressure = (i: number): number =>
|
||||||
pressures.length > i ? pressures[i] : DEFAULT_FREEDRAW_PRESSURE;
|
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)
|
// Single-point stroke → filled circle (dot)
|
||||||
const r = baseRadius * getPressure(0) * 2;
|
const r = baseRadius * getPressure(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 FREEDRAW_DEBUG_COLORS = [
|
|
||||||
// "#e03131",
|
|
||||||
// "#f76707",
|
|
||||||
// "#2f9e44",
|
|
||||||
// "#1971c2",
|
|
||||||
// "#7048e8",
|
|
||||||
// "#e64980",
|
|
||||||
// ];
|
|
||||||
const start = Math.max(fromIndex, 1);
|
const start = Math.max(fromIndex, 1);
|
||||||
for (let i = start; i < points.length; i++) {
|
for (let i = start; i < N; i++) {
|
||||||
//context.fillStyle = FREEDRAW_DEBUG_COLORS[i % FREEDRAW_DEBUG_COLORS.length];
|
const p0 = points[i - 1];
|
||||||
|
const p1 = points[i];
|
||||||
const r0 = baseRadius * getPressure(i - 1) * 2;
|
const r0 = baseRadius * getPressure(i - 1) * 2;
|
||||||
const r1 = baseRadius * getPressure(i) * 2;
|
const r1 = baseRadius * getPressure(i) * 2;
|
||||||
|
|
||||||
const synthesizedPressures = [r0];
|
// Catmull-Rom tangents. At the last real point, use predictedPoint as
|
||||||
const synthesizedPoints: LocalPoint[] = [points[i - 1]];
|
// the look-ahead so the tip curves smoothly toward the expected position.
|
||||||
if (i > 1) {
|
const isLastPoint = i === N - 1;
|
||||||
const dist = pointDistance(points[i - 1], points[i]);
|
const t0 = getCatmullRomTangent(points, i - 1, undefined);
|
||||||
if (dist > baseRadius / 2) {
|
const t1 = getCatmullRomTangent(
|
||||||
const c = curve(
|
points,
|
||||||
points[i - 1],
|
i,
|
||||||
pointFromVector(
|
isLastPoint ? predictedPoint : undefined,
|
||||||
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);
|
|
||||||
|
|
||||||
for (let j = 1; j < synthesizedPoints.length; j++) {
|
drawSubdividedSegment(
|
||||||
drawTaperedCapsule(
|
context,
|
||||||
context,
|
p0[0],
|
||||||
synthesizedPoints[j - 1][0],
|
p0[1],
|
||||||
synthesizedPoints[j - 1][1],
|
r0,
|
||||||
synthesizedPressures[j - 1],
|
p1[0],
|
||||||
synthesizedPoints[j][0],
|
p1[1],
|
||||||
synthesizedPoints[j][1],
|
r1,
|
||||||
synthesizedPressures[j],
|
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;
|
canvasScale = prevInc.scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append only the segments added since the last render
|
// Fetch predicted point for this element (set by the pointer-move handler).
|
||||||
if (fromIndex < element.points.length) {
|
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 ctx = canvas.getContext("2d")!;
|
||||||
const canvasElemOffsetX =
|
const canvasElemOffsetX =
|
||||||
(element.x - canvasOriginSceneX) * dpr * canvasScale;
|
(element.x - canvasOriginSceneX) * dpr * canvasScale;
|
||||||
@@ -921,10 +1091,17 @@ const generateOrUpdateFreeDrawIncrementalCanvas = (
|
|||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(canvasElemOffsetX, canvasElemOffsetY);
|
ctx.translate(canvasElemOffsetX, canvasElemOffsetY);
|
||||||
ctx.scale(dpr * canvasScale, dpr * canvasScale);
|
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();
|
ctx.restore();
|
||||||
|
|
||||||
// Update lastRenderedPointCount in the cache entry
|
// Update lastRenderedPointCount in the cache entry.
|
||||||
const inc = freedrawIncrementalCache.get(element)!;
|
const inc = freedrawIncrementalCache.get(element)!;
|
||||||
inc.lastRenderedPointCount = element.points.length;
|
inc.lastRenderedPointCount = element.points.length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
isLineElement,
|
isLineElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { invalidateFreeDrawIncrementalCanvas } from "@excalidraw/element";
|
import {
|
||||||
|
invalidateFreeDrawIncrementalCanvas,
|
||||||
|
setFreeDrawPredictedPoint,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
KEYS,
|
KEYS,
|
||||||
@@ -317,6 +320,7 @@ export const actionFinalize = register<FormData>({
|
|||||||
|
|
||||||
if (element && isFreeDrawElement(element)) {
|
if (element && isFreeDrawElement(element)) {
|
||||||
invalidateFreeDrawIncrementalCanvas(element);
|
invalidateFreeDrawIncrementalCanvas(element);
|
||||||
|
setFreeDrawPredictedPoint(element.id, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
isArrowElement,
|
isArrowElement,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isLineElement,
|
isLineElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
@@ -131,6 +132,8 @@ import {
|
|||||||
ArrowheadCardinalityOneOrManyIcon,
|
ArrowheadCardinalityOneOrManyIcon,
|
||||||
ArrowheadCardinalityZeroOrManyIcon,
|
ArrowheadCardinalityZeroOrManyIcon,
|
||||||
ArrowheadCardinalityZeroOrOneIcon,
|
ArrowheadCardinalityZeroOrOneIcon,
|
||||||
|
FreedrawPressureConstantIcon,
|
||||||
|
FreedrawPressureSensitiveIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
|
|
||||||
import { Fonts } from "../fonts";
|
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,
|
actionChangeTextAlign,
|
||||||
actionChangeVerticalAlign,
|
actionChangeVerticalAlign,
|
||||||
actionChangeArrowProperties,
|
actionChangeArrowProperties,
|
||||||
|
actionChangeStrokeShape,
|
||||||
} from "./actionProperties";
|
} from "./actionProperties";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
currentItemArrowType: ARROW_TYPE.round,
|
currentItemArrowType: ARROW_TYPE.round,
|
||||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||||
|
currentItemFreedrawConstantPressure: true,
|
||||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||||
currentHoveredFontFamily: null,
|
currentHoveredFontFamily: null,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
@@ -172,6 +173,11 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
currentItemStrokeColor: { browser: true, export: false, server: false },
|
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeWidth: { 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 },
|
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||||
currentHoveredFontFamily: { browser: false, export: false, server: false },
|
currentHoveredFontFamily: { browser: false, export: false, server: false },
|
||||||
cursorButton: { browser: true, export: false, server: false },
|
cursorButton: { browser: true, export: false, server: false },
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ import {
|
|||||||
maybeHandleArrowPointlikeDrag,
|
maybeHandleArrowPointlikeDrag,
|
||||||
getUncroppedWidthAndHeight,
|
getUncroppedWidthAndHeight,
|
||||||
getActiveTextElement,
|
getActiveTextElement,
|
||||||
|
setFreeDrawPredictedPoint,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||||
@@ -8818,7 +8819,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
y: gridY,
|
y: gridY,
|
||||||
});
|
});
|
||||||
|
|
||||||
const simulatePressure = event.pressure === 0.5;
|
const simulatePressure = this.state.currentItemFreedrawConstantPressure;
|
||||||
|
|
||||||
const element = newFreeDrawElement({
|
const element = newFreeDrawElement({
|
||||||
type: elementType,
|
type: elementType,
|
||||||
@@ -9478,7 +9479,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
private onPointerMoveFromPointerDownHandler(
|
private onPointerMoveFromPointerDownHandler(
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
) {
|
) {
|
||||||
return withBatchedUpdatesThrottled((event: PointerEvent) => {
|
const handler = (event: PointerEvent) => {
|
||||||
if (this.state.openDialog?.name === "elementLinkSelector") {
|
if (this.state.openDialog?.name === "elementLinkSelector") {
|
||||||
return;
|
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) {
|
if (newPoints.length > 0) {
|
||||||
const pressures = newElement.simulatePressure
|
const pressures = newElement.simulatePressure
|
||||||
? newElement.pressures
|
? 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
|
// Returns whether the pointer move happened over either scrollbar
|
||||||
|
|||||||
@@ -1186,6 +1186,26 @@ export const StrokeWidthExtraBoldIcon = createIcon(
|
|||||||
modifiedTablerIconProps,
|
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 }) =>
|
export const StrokeStyleSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
"changeBackground": "Change background color",
|
"changeBackground": "Change background color",
|
||||||
"fill": "Fill",
|
"fill": "Fill",
|
||||||
"strokeWidth": "Stroke width",
|
"strokeWidth": "Stroke width",
|
||||||
|
"strokeShape": "Pressure",
|
||||||
|
"strokeShape_constant": "Constant pressure",
|
||||||
|
"strokeShape_pressure": "Pressure-sensitive",
|
||||||
"strokeStyle": "Stroke style",
|
"strokeStyle": "Stroke style",
|
||||||
"strokeStyle_solid": "Solid",
|
"strokeStyle_solid": "Solid",
|
||||||
"strokeStyle_dashed": "Dashed",
|
"strokeStyle_dashed": "Dashed",
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ export interface AppState {
|
|||||||
currentItemBackgroundColor: string;
|
currentItemBackgroundColor: string;
|
||||||
currentItemFillStyle: ExcalidrawElement["fillStyle"];
|
currentItemFillStyle: ExcalidrawElement["fillStyle"];
|
||||||
currentItemStrokeWidth: number;
|
currentItemStrokeWidth: number;
|
||||||
|
currentItemFreedrawConstantPressure: boolean;
|
||||||
currentItemStrokeStyle: ExcalidrawElement["strokeStyle"];
|
currentItemStrokeStyle: ExcalidrawElement["strokeStyle"];
|
||||||
currentItemRoughness: number;
|
currentItemRoughness: number;
|
||||||
currentItemOpacity: 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
|
// We need to have these in the state so that we can unsubscribe them
|
||||||
eventListeners: {
|
eventListeners: {
|
||||||
// It's defined on the initial pointer down event
|
// 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
|
// It's defined on the initial pointer down event
|
||||||
onUp: null | ((event: PointerEvent) => void);
|
onUp: null | ((event: PointerEvent) => void);
|
||||||
// It's defined on the initial pointer down event
|
// 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.
|
* Calculate the right-hand normal of the vector.
|
||||||
*/
|
*/
|
||||||
export const vectorNormal = (v: Vector): Vector => vector(v[1], -v[0]);
|
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