Compare commits

..

2 Commits

Author SHA1 Message Date
dwelle 638d730cd5 split strokeWidth constants into own file 2026-06-24 09:23:42 +02:00
dwelle 80e83374d4 refactor(editor): add a lookup map for freedraw StrokeWidthKey 2026-06-24 09:18:44 +02:00
8 changed files with 131 additions and 177 deletions
@@ -0,0 +1,63 @@
import type { ExcalidrawElement } from "@excalidraw/element/types";
export type StrokeWidthKey = "thin" | "medium" | "bold";
export const STROKE_WIDTH_KEYS: readonly StrokeWidthKey[] = [
"thin",
"medium",
"bold",
];
export const STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 1,
medium: 2,
bold: 4,
extraBold: 8, // unused (may be introduced in the future)
};
// freedraw schema 2.0 uses thinner stroke, but to maintain backwards and
// forwards compatibility, instead of changing the shape renderer, we scale
// the stroke width by 1/2 (previous, thin was 1, medium 2 etc.)
//
// note that in the UI, STROKE_WIDTH.thin == FREEDRAW_STROKE_WIDTH.thin still
export const FREEDRAW_STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 0.5,
medium: 1,
bold: 2,
extraBold: 4, // legacy (may be used again in the future)
};
const STROKE_WIDTH_TO_KEY = {
generic: Object.fromEntries(
Object.entries(STROKE_WIDTH).map(([key, value]) => [value, key]),
) as Record<ExcalidrawElement["strokeWidth"], StrokeWidthKey | undefined>,
freedraw: Object.fromEntries(
Object.entries(FREEDRAW_STROKE_WIDTH).map(([key, value]) => [value, key]),
) as Record<ExcalidrawElement["strokeWidth"], StrokeWidthKey | undefined>,
};
export const getStrokeWidthKeyForElement = (
element: Pick<ExcalidrawElement, "type" | "strokeWidth">,
): StrokeWidthKey | null => {
const strokeWidthToKey =
element.type === "freedraw"
? STROKE_WIDTH_TO_KEY.freedraw
: STROKE_WIDTH_TO_KEY.generic;
return strokeWidthToKey[element.strokeWidth] ?? null;
};
export const getStrokeWidthByKey = (
elementType: ExcalidrawElement["type"],
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return elementType === "freedraw"
? FREEDRAW_STROKE_WIDTH[strokeWidthKey]
: STROKE_WIDTH[strokeWidthKey];
};
export const DEFAULT_ELEMENT_STROKE_WIDTH_KEY: StrokeWidthKey = "medium";
+4 -42
View File
@@ -5,6 +5,10 @@ import type {
import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
import {
STROKE_WIDTH,
DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
} from "./constants.strokeWidth";
export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window;
@@ -404,48 +408,6 @@ export const ROUGHNESS = {
cartoonist: 2,
} as const;
export type StrokeWidthKey = "thin" | "medium" | "bold";
export const STROKE_WIDTH_KEYS: readonly StrokeWidthKey[] = [
"thin",
"medium",
"bold",
];
export const STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 1,
medium: 2,
bold: 4,
extraBold: 8, // unused (may be introduced in the future)
};
// freedraw schema 2.0 uses thinner stroke, but to maintain backwards and
// forwards compatibility, instead of changing the shape renderer, we scale
// the stroke width by 1/2 (previous, thin was 1, medium 2 etc.)
//
// note that in the UI, STROKE_WIDTH.thin == FREEDRAW_STROKE_WIDTH.thin still
export const FREEDRAW_STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 0.5,
medium: 1,
bold: 2,
extraBold: 4, // legacy (may be used again in the future)
};
export const getStrokeWidthByKey = (
elementType: ExcalidrawElement["type"],
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return elementType === "freedraw"
? FREEDRAW_STROKE_WIDTH[strokeWidthKey]
: STROKE_WIDTH[strokeWidthKey];
};
export const DEFAULT_ELEMENT_STROKE_WIDTH_KEY: StrokeWidthKey = "medium";
export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"];
backgroundColor: ExcalidrawElement["backgroundColor"];
+1
View File
@@ -2,6 +2,7 @@ export * from "./binary-heap";
export * from "./bounds";
export * from "./colors";
export * from "./constants";
export * from "./constants.strokeWidth";
export * from "./font-metadata";
export * from "./queue";
export * from "./keys";
+1 -30
View File
@@ -2,7 +2,6 @@ import {
curvePointDistance,
distanceToLineSegment,
pointRotateRads,
polygonIncludesPointNonZero,
} from "@excalidraw/math";
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
@@ -48,9 +47,8 @@ export const distanceToElement = (
return distanceToEllipseElement(element, elementsMap, p);
case "line":
case "arrow":
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
case "freedraw":
return distanceToFreeDrawElement(element, elementsMap, p);
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
}
};
@@ -147,30 +145,3 @@ const distanceToLinearOrFreeDraElement = (
...curves.map((a) => curvePointDistance(a, p)),
);
};
/**
* Returns the distance of a point to a freedraw element.
*
* @param element The freedraw element
* @param p The point to consider
* @returns 0 if the point is within the inked area, otherwise the euclidean
* distance to the stroke outline
*/
const distanceToFreeDrawElement = (
element: ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const [lines] = deconstructLinearOrFreeDrawElement(element, elementsMap);
if (lines.length === 0) {
return Infinity;
}
const polygon = lines.map((line) => line[0]);
if (polygonIncludesPointNonZero(p, polygon)) {
return 0;
}
return Math.min(...lines.map((s) => distanceToLineSegment(p, s)));
};
+57 -33
View File
@@ -677,45 +677,69 @@ export const generateLinearCollisionShape = (
});
}
case "freedraw": {
const outlinePoints = getFreedrawOutlinePoints(element);
if (outlinePoints.length < 2) {
if (element.points.length < 2) {
return [];
}
const collisionOutline =
element.strokeOptions?.variability === "constant" &&
CONSTANT_WIDTH_FREEDRAW.STREAMLINE > 0
? (simplify(
outlinePoints as Mutable<LocalPoint[]>,
CONSTANT_WIDTH_FREEDRAW.STREAMLINE,
) as [number, number][])
: outlinePoints;
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
if (collisionOutline.length < 2) {
return [];
}
return generator
.curve(simplifiedPoints as [number, number][], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
// Close the outline polygon so its boundary never has a gap at the seam.
const [firstX, firstY] = collisionOutline[0];
const [lastX, lastY] = collisionOutline[collisionOutline.length - 1];
const closedOutline =
firstX === lastX && firstY === lastY
? collisionOutline
: [...collisionOutline, collisionOutline[0]];
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
return closedOutline.map((point, idx) => {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return {
op: idx === 0 ? "move" : "lineTo",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
});
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
}
}
};
-51
View File
@@ -218,54 +218,3 @@ describe("hitElementItself cache", () => {
).toBe(true);
});
});
describe("freedraw collision matches the rendered stroke width", () => {
// A straight, horizontal stroke centered on y === 0.
const points = Array.from({ length: 21 }, (_, i) =>
pointFrom<LocalPoint>(i * 5, 0),
);
const createFreeDraw = (variability: "variable" | "constant") =>
API.createElement({
type: "freedraw",
x: 0,
y: 0,
strokeWidth: 10,
points,
strokeOptions: { variability, streamline: 0.5 },
});
const distanceAt = (
element: ReturnType<typeof createFreeDraw>,
x: number,
y: number,
) =>
distance.distanceToElement(
element,
arrayToMap([element]),
pointFrom<GlobalPoint>(x, y),
);
it("treats a point on the centerline as a direct hit for both modes", () => {
expect(distanceAt(createFreeDraw("variable"), 50, 0)).toBe(0);
expect(distanceAt(createFreeDraw("constant"), 50, 0)).toBe(0);
});
it("hits across the body of a thick stroke, not just near the centerline", () => {
expect(distanceAt(createFreeDraw("variable"), 50, 20)).toBe(0);
});
it("uses a wider hit area for variable-width than for constant-width strokes", () => {
const offset = 20;
const variableDistance = distanceAt(createFreeDraw("variable"), 50, offset);
const constantDistance = distanceAt(createFreeDraw("constant"), 50, offset);
expect(variableDistance).toBe(0);
expect(constantDistance).toBeGreaterThan(0);
expect(variableDistance).toBeLessThan(constantDistance);
});
it("does not hit points clearly outside even the widest stroke", () => {
expect(distanceAt(createFreeDraw("variable"), 50, 45)).toBeGreaterThan(0);
});
});
@@ -12,7 +12,6 @@ import {
DEFAULT_FONT_SIZE,
FONT_FAMILY,
ROUNDNESS,
STROKE_WIDTH_KEYS,
VERTICAL_ALIGN,
KEYS,
randomInteger,
@@ -25,6 +24,7 @@ import {
invariant,
FONT_SIZES,
type StrokeWidthKey,
getStrokeWidthKeyForElement,
} from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
@@ -554,23 +554,6 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
},
});
const getStrokeWidthKeyForElement = (
element: ExcalidrawElement,
): StrokeWidthKey | null => {
return (
STROKE_WIDTH_KEYS.find(
(key) => getStrokeWidthByKey(element.type, key) === element.strokeWidth,
) ?? null
);
};
const getStrokeWidthForElement = (
element: ExcalidrawElement,
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return getStrokeWidthByKey(element.type, strokeWidthKey);
};
export const actionChangeStrokeWidth = register<StrokeWidthKey>({
name: "changeStrokeWidth",
label: "labels.strokeWidth",
@@ -581,7 +564,7 @@ export const actionChangeStrokeWidth = register<StrokeWidthKey>({
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeWidth: getStrokeWidthForElement(el, value),
strokeWidth: getStrokeWidthByKey(el.type, value),
}),
),
appState: { ...appState, currentItemStrokeWidthKey: value },
+3 -2
View File
@@ -19,9 +19,8 @@ import {
getSizeFromPoints,
normalizeLink,
getLineHeight,
STROKE_WIDTH,
STROKE_WIDTH_KEYS,
type StrokeWidthKey,
STROKE_WIDTH,
} from "@excalidraw/common";
import {
calculateFixedPointForNonElbowArrowBinding,
@@ -58,6 +57,8 @@ import { getNormalizedDimensions } from "@excalidraw/element";
import { isInvisiblySmallElement } from "@excalidraw/element";
import type { StrokeWidthKey } from "@excalidraw/common";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type {