From 0056f6f9045f1102a4d22aefeeb40889bb0b7675 Mon Sep 17 00:00:00 2001 From: liangxu Date: Sat, 18 Jul 2026 00:23:49 +0800 Subject: [PATCH] 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 --- .../canvas/CanvasPathEditor.tsx | 23 +++++++++++++++---- .../pages/CanvasWorkspace/canvas/pathPen.ts | 18 +++++++++++++-- frontend/tests/pathPen.test.mjs | 11 +++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor.tsx b/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor.tsx index dcf9fe4..86f5d24 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor.tsx @@ -27,6 +27,7 @@ export function CanvasPathEditor({ }) { const mountRef = useRef(null); const appRef = useRef(null); + const pathRef = useRef(null); const viewportRef = useRef(viewport); const latestPathRef = useRef(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) => event.stopPropagation(); return
; } @@ -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)]; diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/pathPen.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/pathPen.ts index f3ba53f..ef66254 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/pathPen.ts +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/pathPen.ts @@ -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 }, diff --git a/frontend/tests/pathPen.test.mjs b/frontend/tests/pathPen.test.mjs index 47c5b7b..7afbf18 100644 --- a/frontend/tests/pathPen.test.mjs +++ b/frontend/tests/pathPen.test.mjs @@ -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 }); +});