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 mountRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<import("leafer-ui").App | 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 viewportRef = useRef(viewport);
const latestPathRef = useRef<EditablePathData | null>(null); const latestPathRef = useRef<EditablePathData | null>(null);
const commitRef = useRef(onCommit); const commitRef = useRef(onCommit);
@@ -95,13 +96,10 @@ export function CanvasPathEditor({
x: node.x, x: node.x,
y: node.y, y: node.y,
path: initialPath, path: initialPath,
fill: node.fillColor || "transparent", ...pathNodeStyle(node),
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,
editable: true editable: true
}); });
pathRef.current = path;
applyViewport(nextApp, viewportRef.current); applyViewport(nextApp, viewportRef.current);
nextApp.resize({ width: stageSize.width, height: stageSize.height, pixelRatio: window.devicePixelRatio || 1 }); nextApp.resize({ width: stageSize.width, height: stageSize.height, pixelRatio: window.devicePixelRatio || 1 });
nextApp.tree.add(path); nextApp.tree.add(path);
@@ -125,6 +123,7 @@ export function CanvasPathEditor({
disposed = true; disposed = true;
document.removeEventListener("keydown", handleKeyDown, true); document.removeEventListener("keydown", handleKeyDown, true);
controlRef.current = null; controlRef.current = null;
pathRef.current = null;
appRef.current?.destroy(); appRef.current?.destroy();
appRef.current = null; appRef.current = null;
}; };
@@ -140,6 +139,10 @@ export function CanvasPathEditor({
appRef.current?.resize({ width: stageSize.width, height: stageSize.height, pixelRatio: window.devicePixelRatio || 1 }); appRef.current?.resize({ width: stageSize.width, height: stageSize.height, pixelRatio: window.devicePixelRatio || 1 });
}, [stageSize.height, stageSize.width]); }, [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(); const stopPropagation = (event: ReactPointerEvent<HTMLDivElement>) => event.stopPropagation();
return <div ref={mountRef} className="canvas-path-editor-overlay" onPointerDown={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 }); 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) { function strokeDashPattern(style: string | undefined, width = 1) {
if (style === "dashed") return [Math.max(width * 3, 8), Math.max(width * 2, 6)]; if (style === "dashed") return [Math.max(width * 3, 8), Math.max(width * 2, 6)];
if (style === "dotted") return [1, 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 type { CanvasNode } from "@/domain/design";
import { shapeOptionsToContent } from "./shapeNode.ts"; import { defaultShapeStrokeColor, defaultShapeStrokeWidth, shapeOptionsToContent, type ShapeStylePatch } from "./shapeNode.ts";
export type PathPenPoint = { export type PathPenPoint = {
x: number; x: number;
@@ -25,7 +25,7 @@ export type PathPenDraft = {
viewportScale: number; viewportScale: number;
}; };
type PathPenSettings = { export type PathPenSettings = {
color: string; color: string;
width: number; 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 { function toAnchor(position: PathPenPosition): PathPenAnchor {
return { return {
screen: { ...position.screen }, screen: { ...position.screen },
+11
View File
@@ -7,6 +7,8 @@ import {
constrainPathPenPoint, constrainPathPenPoint,
createPathPenNode, createPathPenNode,
pathPenDraftToPathData, pathPenDraftToPathData,
pathPenSettingsForNode,
pathPenSettingsToShapeStyle,
setLastPathPenAnchorHandles, setLastPathPenAnchorHandles,
startPathPenDraft startPathPenDraft
} from "../src/ui/pages/CanvasWorkspace/canvas/pathPen.ts"; } 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.equal(options.kind, "path");
assert.deepEqual(options.pathData, [1, 0.04955, 0.5, 2, 0.95045, 0.5]); 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 });
});