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:
@@ -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 },
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user