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:
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, type MutableRefObject, type PointerEvent as ReactPointerEvent } from "react";
|
import { useEffect, useRef, type MutableRefObject, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
import type { CanvasNode, CanvasViewport } from "@/domain/design";
|
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 };
|
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 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 pathRef = useRef<import("leafer-ui").Path | null>(null);
|
||||||
|
const strokePathRefs = useRef<import("leafer-ui").Path[]>([]);
|
||||||
|
const strokeSegmentsRef = useRef<ShapeStrokeSegment[]>([]);
|
||||||
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);
|
||||||
@@ -36,6 +39,7 @@ export function CanvasPathEditor({
|
|||||||
viewportRef.current = viewport;
|
viewportRef.current = viewport;
|
||||||
commitRef.current = onCommit;
|
commitRef.current = onCommit;
|
||||||
closeRef.current = onClose;
|
closeRef.current = onClose;
|
||||||
|
strokeSegmentsRef.current = editableStrokeSegments(node);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mount = mountRef.current;
|
const mount = mountRef.current;
|
||||||
@@ -96,17 +100,41 @@ export function CanvasPathEditor({
|
|||||||
x: node.x,
|
x: node.x,
|
||||||
y: node.y,
|
y: node.y,
|
||||||
path: initialPath,
|
path: initialPath,
|
||||||
...pathNodeStyle(node),
|
...pathNodeStyle(node, strokeSegmentsRef.current.length > 0),
|
||||||
editable: true
|
editable: true
|
||||||
});
|
});
|
||||||
pathRef.current = path;
|
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);
|
||||||
|
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) => {
|
nextApp.editor.on(pathEditorModule.PathEditorEvent.CHANGE, (event: import("leafer-x-path-editor").PathEditorEvent) => {
|
||||||
const value = event.value as import("leafer-ui").Path;
|
const value = event.value as import("leafer-ui").Path;
|
||||||
latestPathRef.current = [...value.getPath()];
|
syncStyledStrokes([...value.getPath()]);
|
||||||
});
|
});
|
||||||
nextApp.editor.on(editorModule.InnerEditorEvent.CLOSE, () => {
|
nextApp.editor.on(editorModule.InnerEditorEvent.CLOSE, () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
@@ -124,6 +152,7 @@ export function CanvasPathEditor({
|
|||||||
document.removeEventListener("keydown", handleKeyDown, true);
|
document.removeEventListener("keydown", handleKeyDown, true);
|
||||||
controlRef.current = null;
|
controlRef.current = null;
|
||||||
pathRef.current = null;
|
pathRef.current = null;
|
||||||
|
strokePathRefs.current = [];
|
||||||
appRef.current?.destroy();
|
appRef.current?.destroy();
|
||||||
appRef.current = null;
|
appRef.current = null;
|
||||||
};
|
};
|
||||||
@@ -140,8 +169,14 @@ export function CanvasPathEditor({
|
|||||||
}, [stageSize.height, stageSize.width]);
|
}, [stageSize.height, stageSize.width]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pathRef.current?.set(pathNodeStyle(node));
|
const strokeSegments = editableStrokeSegments(node);
|
||||||
}, [node.fillColor, node.opacity, node.strokeColor, node.strokeStyle, node.strokeWidth]);
|
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();
|
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} />;
|
||||||
@@ -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 });
|
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 {
|
return {
|
||||||
fill: node.fillColor || "transparent",
|
fill: node.fillColor || "transparent",
|
||||||
stroke: node.strokeColor || "#111827",
|
stroke: hasSegmentStrokes ? "rgba(0, 0, 0, 0)" : node.strokeColor || "#111827",
|
||||||
strokeWidth: Math.max(node.strokeWidth ?? 2, 0),
|
strokeWidth: hasSegmentStrokes ? Math.max(strokeWidth, ...strokeSegmentsRefWidth(node)) : strokeWidth,
|
||||||
dashPattern: strokeDashPattern(node.strokeStyle, node.strokeWidth) ?? [],
|
dashPattern: hasSegmentStrokes ? [] : strokeDashPattern(node.strokeStyle, strokeWidth) ?? [],
|
||||||
opacity: typeof node.opacity === "number" ? node.opacity : 1
|
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) {
|
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)];
|
||||||
|
|||||||
@@ -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, /settings=\{activeTool === "path" \? pathPenControlSettings : penSettings\}/);
|
||||||
assert.match(workspaceSource, /updateShapeStyle\(pathPenControlNode\.id, pathPenSettingsToShapeStyle\(settings, viewportRef\.current\.k\)\);/);
|
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, /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.doesNotMatch(workspaceSource, /startPenDrawing\(event, true\)/);
|
||||||
assert.match(workspaceSource, /key === "p"\) return void run\(\(\) => chooseCanvasTool\("path"\)\)/);
|
assert.match(workspaceSource, /key === "p"\) return void run\(\(\) => chooseCanvasTool\("path"\)\)/);
|
||||||
assert.match(workspaceSource, /key === "b"\) return void run\(\(\) => chooseCanvasTool\("pen"\)\)/);
|
assert.match(workspaceSource, /key === "b"\) return void run\(\(\) => chooseCanvasTool\("pen"\)\)/);
|
||||||
|
|||||||
Reference in New Issue
Block a user