feat(canvas): preview per-segment stroke styles live in the path editor

The vertex editor now hides the base path's uniform stroke when
segment styles exist and overlays one non-hittable path per stroke
segment, resyncing their geometry on every path-editor change and
editor drag so multi-color boolean outlines stay accurate while
editing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:59:35 +08:00
parent ca101fd916
commit b5aa547c36
2 changed files with 70 additions and 10 deletions
@@ -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<HTMLDivElement | null>(null);
const appRef = useRef<import("leafer-ui").App | null>(null);
const pathRef = useRef<import("leafer-ui").Path | null>(null);
const strokePathRefs = useRef<import("leafer-ui").Path[]>([]);
const strokeSegmentsRef = useRef<ShapeStrokeSegment[]>([]);
const viewportRef = useRef(viewport);
const latestPathRef = useRef<EditablePathData | null>(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<HTMLDivElement>) => event.stopPropagation();
return <div ref={mountRef} className="canvas-path-editor-overlay" onPointerDown={stopPropagation} />;
@@ -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)];
+5 -1
View File
@@ -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<import\("leafer-ui"\)\.Path \| null>\(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"\)\)/);