diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor.tsx b/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor.tsx index 86f5d24..9ee451f 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor.tsx @@ -2,7 +2,8 @@ import { useEffect, useRef, type MutableRefObject, type PointerEvent as ReactPointerEvent } from "react"; import type { CanvasNode, CanvasViewport } from "@/domain/design"; -import { editablePathForNode, type EditablePathData } from "./editablePath"; +import { editablePathForNode, normalizedPathDataToSvg, strokeSegmentsForEditedPath, type EditablePathData } from "./editablePath"; +import { parseShapeOptions, type ShapeStrokeSegment } from "./shapeNode"; type LeaferEditorApp = import("leafer-ui").App & { editor: import("@leafer-in/editor").Editor }; @@ -28,6 +29,8 @@ export function CanvasPathEditor({ const mountRef = useRef(null); const appRef = useRef(null); const pathRef = useRef(null); + const strokePathRefs = useRef([]); + const strokeSegmentsRef = useRef([]); const viewportRef = useRef(viewport); const latestPathRef = useRef(null); const commitRef = useRef(onCommit); @@ -36,6 +39,7 @@ export function CanvasPathEditor({ viewportRef.current = viewport; commitRef.current = onCommit; closeRef.current = onClose; + strokeSegmentsRef.current = editableStrokeSegments(node); useEffect(() => { const mount = mountRef.current; @@ -96,17 +100,41 @@ export function CanvasPathEditor({ x: node.x, y: node.y, path: initialPath, - ...pathNodeStyle(node), + ...pathNodeStyle(node, strokeSegmentsRef.current.length > 0), 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); + strokePathRefs.current = strokeSegmentsRef.current.map((segment) => { + const strokePath = new leaferModule.Path({ + x: node.x, + y: node.y, + path: normalizedPathDataToSvg(segment.pathData, node.width, node.height), + ...strokeSegmentNodeStyle(segment), + hittable: false + }); + nextApp.tree.add(strokePath); + return strokePath; + }); + const syncStyledStrokes = (pathData: EditablePathData) => { + latestPathRef.current = pathData; + const strokeSegments = strokeSegmentsForEditedPath(pathData, strokeSegmentsRef.current) || []; + strokePathRefs.current.forEach((strokePath, index) => { + const segment = strokeSegments[index]; + strokePath.set(segment ? { path: segment.pathData, visible: true } : { visible: false }); + }); + }; + nextApp.on(leaferModule.DragEvent.DRAG, () => { + const innerEditor = nextApp.editor.innerEditor as (import("@leafer-in/editor").InnerEditor & { editTargetDuplicate?: import("leafer-ui").Path }) | null; + const livePath = innerEditor?.editTargetDuplicate; + if (livePath) syncStyledStrokes([...livePath.getPath()]); + }); nextApp.editor.on(pathEditorModule.PathEditorEvent.CHANGE, (event: import("leafer-x-path-editor").PathEditorEvent) => { const value = event.value as import("leafer-ui").Path; - latestPathRef.current = [...value.getPath()]; + syncStyledStrokes([...value.getPath()]); }); nextApp.editor.on(editorModule.InnerEditorEvent.CLOSE, () => { if (disposed) return; @@ -124,6 +152,7 @@ export function CanvasPathEditor({ document.removeEventListener("keydown", handleKeyDown, true); controlRef.current = null; pathRef.current = null; + strokePathRefs.current = []; appRef.current?.destroy(); appRef.current = null; }; @@ -140,8 +169,14 @@ export function CanvasPathEditor({ }, [stageSize.height, stageSize.width]); useEffect(() => { - pathRef.current?.set(pathNodeStyle(node)); - }, [node.fillColor, node.opacity, node.strokeColor, node.strokeStyle, node.strokeWidth]); + const strokeSegments = editableStrokeSegments(node); + strokeSegmentsRef.current = strokeSegments; + pathRef.current?.set(pathNodeStyle(node, strokeSegments.length > 0)); + strokePathRefs.current.forEach((strokePath, index) => { + const segment = strokeSegments[index]; + strokePath.set(segment ? { ...strokeSegmentNodeStyle(segment), visible: true } : { visible: false }); + }); + }, [node.content, node.fillColor, node.opacity, node.strokeColor, node.strokeStyle, node.strokeWidth]); const stopPropagation = (event: ReactPointerEvent) => event.stopPropagation(); return
; @@ -151,16 +186,37 @@ 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) { +function pathNodeStyle(node: CanvasNode, hasSegmentStrokes: boolean) { + const strokeWidth = Math.max(node.strokeWidth ?? 2, 0); return { fill: node.fillColor || "transparent", - stroke: node.strokeColor || "#111827", - strokeWidth: Math.max(node.strokeWidth ?? 2, 0), - dashPattern: strokeDashPattern(node.strokeStyle, node.strokeWidth) ?? [], + stroke: hasSegmentStrokes ? "rgba(0, 0, 0, 0)" : node.strokeColor || "#111827", + strokeWidth: hasSegmentStrokes ? Math.max(strokeWidth, ...strokeSegmentsRefWidth(node)) : strokeWidth, + dashPattern: hasSegmentStrokes ? [] : strokeDashPattern(node.strokeStyle, strokeWidth) ?? [], opacity: typeof node.opacity === "number" ? node.opacity : 1 }; } +function editableStrokeSegments(node: CanvasNode) { + const options = parseShapeOptions(node.content); + if (!options.pathData) return []; + return strokeSegmentsForEditedPath(options.pathData, options.strokeSegments) || options.strokeSegments || []; +} + +function strokeSegmentsRefWidth(node: CanvasNode) { + return editableStrokeSegments(node).map((segment) => segment.strokeWidth); +} + +function strokeSegmentNodeStyle(segment: ShapeStrokeSegment) { + return { + fill: "transparent", + stroke: segment.strokeColor, + strokeWidth: Math.max(segment.strokeWidth, 0), + dashPattern: strokeDashPattern(segment.strokeStyle, segment.strokeWidth) ?? [], + opacity: segment.opacity + }; +} + 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/tests/editablePath.test.mjs b/frontend/tests/editablePath.test.mjs index 24f9e72..73457f4 100644 --- a/frontend/tests/editablePath.test.mjs +++ b/frontend/tests/editablePath.test.mjs @@ -161,7 +161,11 @@ test("keeps the Pen tool immediately after Shape in the workspace toolbar", asyn assert.match(workspaceSource, /settings=\{activeTool === "path" \? pathPenControlSettings : penSettings\}/); assert.match(workspaceSource, /updateShapeStyle\(pathPenControlNode\.id, pathPenSettingsToShapeStyle\(settings, viewportRef\.current\.k\)\);/); assert.match(pathEditorSource, /const pathRef = useRef\(null\);/); - assert.match(pathEditorSource, /pathRef\.current\?\.set\(pathNodeStyle\(node\)\);/); + assert.match(pathEditorSource, /pathRef\.current\?\.set\(pathNodeStyle\(node, strokeSegments\.length > 0\)\);/); + assert.match(pathEditorSource, /strokePathRefs\.current/); + assert.match(pathEditorSource, /strokeSegmentsForEditedPath\(pathData, strokeSegmentsRef\.current\)/); + assert.match(pathEditorSource, /DragEvent\.DRAG/); + assert.match(pathEditorSource, /editTargetDuplicate/); assert.doesNotMatch(workspaceSource, /startPenDrawing\(event, true\)/); assert.match(workspaceSource, /key === "p"\) return void run\(\(\) => chooseCanvasTool\("path"\)\)/); assert.match(workspaceSource, /key === "b"\) return void run\(\(\) => chooseCanvasTool\("pen"\)\)/);