Compare commits

..
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 type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors"; import { COLOR_PALETTE } from "./colors";
import {
STROKE_WIDTH,
DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
} from "./constants.strokeWidth";
export const supportsResizeObserver = export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window; typeof window !== "undefined" && "ResizeObserver" in window;
@@ -404,48 +408,6 @@ export const ROUGHNESS = {
cartoonist: 2, cartoonist: 2,
} as const; } 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: { export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"]; strokeColor: ExcalidrawElement["strokeColor"];
backgroundColor: ExcalidrawElement["backgroundColor"]; backgroundColor: ExcalidrawElement["backgroundColor"];
+1
View File
@@ -2,6 +2,7 @@ export * from "./binary-heap";
export * from "./bounds"; export * from "./bounds";
export * from "./colors"; export * from "./colors";
export * from "./constants"; export * from "./constants";
export * from "./constants.strokeWidth";
export * from "./font-metadata"; export * from "./font-metadata";
export * from "./queue"; export * from "./queue";
export * from "./keys"; export * from "./keys";
+1 -30
View File
@@ -2,7 +2,6 @@ import {
curvePointDistance, curvePointDistance,
distanceToLineSegment, distanceToLineSegment,
pointRotateRads, pointRotateRads,
polygonIncludesPointNonZero,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse"; import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
@@ -48,9 +47,8 @@ export const distanceToElement = (
return distanceToEllipseElement(element, elementsMap, p); return distanceToEllipseElement(element, elementsMap, p);
case "line": case "line":
case "arrow": case "arrow":
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
case "freedraw": case "freedraw":
return distanceToFreeDrawElement(element, elementsMap, p); return distanceToLinearOrFreeDraElement(element, elementsMap, p);
} }
}; };
@@ -147,30 +145,3 @@ const distanceToLinearOrFreeDraElement = (
...curves.map((a) => curvePointDistance(a, p)), ...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": { case "freedraw": {
const outlinePoints = getFreedrawOutlinePoints(element); if (element.points.length < 2) {
if (outlinePoints.length < 2) {
return []; return [];
} }
const collisionOutline = const simplifiedPoints = simplify(
element.strokeOptions?.variability === "constant" && element.points as Mutable<LocalPoint[]>,
CONSTANT_WIDTH_FREEDRAW.STREAMLINE > 0 0.75,
? (simplify( );
outlinePoints as Mutable<LocalPoint[]>,
CONSTANT_WIDTH_FREEDRAW.STREAMLINE,
) as [number, number][])
: outlinePoints;
if (collisionOutline.length < 2) { return generator
return []; .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. return {
const [firstX, firstY] = collisionOutline[0]; op: "move",
const [lastX, lastY] = collisionOutline[collisionOutline.length - 1]; data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
const closedOutline = };
firstX === lastX && firstY === lastY }
? collisionOutline
: [...collisionOutline, collisionOutline[0]];
return closedOutline.map((point, idx) => { return {
const p = pointRotateRads<GlobalPoint>( op: "bcurveTo",
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]), data: [
center, pointRotateRads(
element.angle, pointFrom<GlobalPoint>(
); element.x + op.data[0],
element.y + op.data[1],
return { ),
op: idx === 0 ? "move" : "lineTo", center,
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y), 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); ).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, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS, ROUNDNESS,
STROKE_WIDTH_KEYS,
VERTICAL_ALIGN, VERTICAL_ALIGN,
KEYS, KEYS,
randomInteger, randomInteger,
@@ -25,6 +24,7 @@ import {
invariant, invariant,
FONT_SIZES, FONT_SIZES,
type StrokeWidthKey, type StrokeWidthKey,
getStrokeWidthKeyForElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element"; 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>({ export const actionChangeStrokeWidth = register<StrokeWidthKey>({
name: "changeStrokeWidth", name: "changeStrokeWidth",
label: "labels.strokeWidth", label: "labels.strokeWidth",
@@ -581,7 +564,7 @@ export const actionChangeStrokeWidth = register<StrokeWidthKey>({
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
newElementWith(el, { newElementWith(el, {
strokeWidth: getStrokeWidthForElement(el, value), strokeWidth: getStrokeWidthByKey(el.type, value),
}), }),
), ),
appState: { ...appState, currentItemStrokeWidthKey: value }, appState: { ...appState, currentItemStrokeWidthKey: value },
+3 -2
View File
@@ -19,9 +19,8 @@ import {
getSizeFromPoints, getSizeFromPoints,
normalizeLink, normalizeLink,
getLineHeight, getLineHeight,
STROKE_WIDTH,
STROKE_WIDTH_KEYS, STROKE_WIDTH_KEYS,
type StrokeWidthKey, STROKE_WIDTH,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
@@ -58,6 +57,8 @@ import { getNormalizedDimensions } from "@excalidraw/element";
import { isInvisiblySmallElement } from "@excalidraw/element"; import { isInvisiblySmallElement } from "@excalidraw/element";
import type { StrokeWidthKey } from "@excalidraw/common";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
import type { import type {