feat(canvas): round-trip Pen settings with the selected path style

Add pathPenSettingsForNode / pathPenSettingsToShapeStyle so the Pen
tool controls can display and edit the selected path node's stroke
color and width in screen space, and let CanvasPathEditor re-apply
fill/stroke/opacity to the mounted leafer path when the node's style
changes during editing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:23:49 +08:00
parent 6ff4dc510a
commit 0056f6f904
3 changed files with 45 additions and 7 deletions
@@ -27,6 +27,7 @@ export function CanvasPathEditor({
}) {
const mountRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<import("leafer-ui").App | null>(null);
const pathRef = useRef<import("leafer-ui").Path | null>(null);
const viewportRef = useRef(viewport);
const latestPathRef = useRef<EditablePathData | null>(null);
const commitRef = useRef(onCommit);
@@ -95,13 +96,10 @@ export function CanvasPathEditor({
x: node.x,
y: node.y,
path: initialPath,
fill: node.fillColor || "transparent",
stroke: node.strokeColor || "#111827",
strokeWidth: Math.max(node.strokeWidth ?? 2, 0),
dashPattern: strokeDashPattern(node.strokeStyle, node.strokeWidth),
opacity: typeof node.opacity === "number" ? node.opacity : 1,
...pathNodeStyle(node),
editable: true
});
pathRef.current = path;
applyViewport(nextApp, viewportRef.current);
nextApp.resize({ width: stageSize.width, height: stageSize.height, pixelRatio: window.devicePixelRatio || 1 });
nextApp.tree.add(path);
@@ -125,6 +123,7 @@ export function CanvasPathEditor({
disposed = true;
document.removeEventListener("keydown", handleKeyDown, true);
controlRef.current = null;
pathRef.current = null;
appRef.current?.destroy();
appRef.current = null;
};
@@ -140,6 +139,10 @@ export function CanvasPathEditor({
appRef.current?.resize({ width: stageSize.width, height: stageSize.height, pixelRatio: window.devicePixelRatio || 1 });
}, [stageSize.height, stageSize.width]);
useEffect(() => {
pathRef.current?.set(pathNodeStyle(node));
}, [node.fillColor, node.opacity, node.strokeColor, node.strokeStyle, node.strokeWidth]);
const stopPropagation = (event: ReactPointerEvent<HTMLDivElement>) => event.stopPropagation();
return <div ref={mountRef} className="canvas-path-editor-overlay" onPointerDown={stopPropagation} />;
}
@@ -148,6 +151,16 @@ function applyViewport(app: import("leafer-ui").App, viewport: CanvasViewport) {
app.tree.set({ x: viewport.x, y: viewport.y, scaleX: viewport.k, scaleY: viewport.k });
}
function pathNodeStyle(node: CanvasNode) {
return {
fill: node.fillColor || "transparent",
stroke: node.strokeColor || "#111827",
strokeWidth: Math.max(node.strokeWidth ?? 2, 0),
dashPattern: strokeDashPattern(node.strokeStyle, node.strokeWidth) ?? [],
opacity: typeof node.opacity === "number" ? node.opacity : 1
};
}
function strokeDashPattern(style: string | undefined, width = 1) {
if (style === "dashed") return [Math.max(width * 3, 8), Math.max(width * 2, 6)];
if (style === "dotted") return [1, Math.max(width * 2, 6)];
@@ -1,5 +1,5 @@
import type { CanvasNode } from "@/domain/design";
import { shapeOptionsToContent } from "./shapeNode.ts";
import { defaultShapeStrokeColor, defaultShapeStrokeWidth, shapeOptionsToContent, type ShapeStylePatch } from "./shapeNode.ts";
export type PathPenPoint = {
x: number;
@@ -25,7 +25,7 @@ export type PathPenDraft = {
viewportScale: number;
};
type PathPenSettings = {
export type PathPenSettings = {
color: string;
width: number;
};
@@ -183,6 +183,20 @@ export function createPathPenNode(draft: PathPenDraft, title: string): CanvasNod
};
}
export function pathPenSettingsForNode(node: CanvasNode, viewportScale: number): PathPenSettings {
return {
color: node.strokeColor || defaultShapeStrokeColor,
width: round(Math.max(1, (node.strokeWidth || defaultShapeStrokeWidth) * Math.max(viewportScale, 0.01)))
};
}
export function pathPenSettingsToShapeStyle(settings: PathPenSettings, viewportScale: number): ShapeStylePatch {
return {
strokeColor: settings.color,
strokeWidth: Math.max(1, settings.width / Math.max(viewportScale, 0.01))
};
}
function toAnchor(position: PathPenPosition): PathPenAnchor {
return {
screen: { ...position.screen },
+11
View File
@@ -7,6 +7,8 @@ import {
constrainPathPenPoint,
createPathPenNode,
pathPenDraftToPathData,
pathPenSettingsForNode,
pathPenSettingsToShapeStyle,
setLastPathPenAnchorHandles,
startPathPenDraft
} from "../src/ui/pages/CanvasWorkspace/canvas/pathPen.ts";
@@ -66,3 +68,12 @@ test("persists a completed Pen path as an editable shape node", () => {
assert.equal(options.kind, "path");
assert.deepEqual(options.pathData, [1, 0.04955, 0.5, 2, 0.95045, 0.5]);
});
test("round-trips the selected path style through screen-space Pen settings", () => {
let draft = startPathPenDraft(settings, 0.5, point(10, 20));
draft = appendPathPenAnchor(draft, point(110, 20));
const node = createPathPenNode(draft, "Pen");
assert.deepEqual(pathPenSettingsForNode(node, 0.5), settings);
assert.deepEqual(pathPenSettingsToShapeStyle({ color: "#A855F7", width: 8 }, 0.5), { strokeColor: "#A855F7", strokeWidth: 16 });
});