feat: add CanvasPathEditor and editable path functionality

- Implemented CanvasPathEditor component for editing paths on the canvas.
- Created editablePath module to handle path data manipulation and conversion to SVG.
- Introduced pathPen module for managing path drawing with a pen tool, including anchor management and path data generation.
- Added tests for editablePath and pathPen functionalities to ensure correctness of path editing and drawing behavior.
This commit is contained in:
2026-07-17 11:28:29 +08:00
parent fe4b08f548
commit d424b56076
30 changed files with 3791 additions and 44 deletions
+101
View File
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { applyEditedPathToNode, editablePathForNode, isEditablePathNode, normalizedPathDataToSvg } from "../src/ui/pages/CanvasWorkspace/canvas/editablePath.ts";
const shapeNode = {
id: "shape-1",
type: "frame",
title: "Rectangle",
x: 10,
y: 20,
width: 100,
height: 60,
content: JSON.stringify({ kind: "rectangle", cornerRadius: 0 }),
tone: "shape",
status: "draft",
layerRole: "shape",
fillColor: "transparent",
strokeColor: "#111827",
strokeWidth: 2
};
test("converts shape-tool geometry into commands supported by the path editor", () => {
const path = editablePathForNode(shapeNode);
assert.match(path, /^M /);
assert.match(path, / L /);
assert.match(path, / Z$/);
assert.doesNotMatch(path, /[AHV]/);
});
test("persists an edited shape path and expands its canvas bounds without moving world vertices", () => {
const edited = applyEditedPathToNode(shapeNode, [1, -10, 10, 2, 100, 10, 2, 100, 60, 2, -10, 60, 11]);
const options = JSON.parse(edited.content);
assert.equal(options.kind, "path");
assert.ok(Array.isArray(options.pathData));
assert.equal(edited.x, -3);
assert.equal(edited.width, 116);
assert.match(editablePathForNode(edited), /^M 3 10/);
});
test("keeps unmarked Brush strokes outside the vertex editor", () => {
const brushSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50" viewBox="0 0 100 50"><path d="M 5 5 L 90 45" fill="none" stroke="#000000" stroke-width="3"/></svg>';
const brushNode = {
...shapeNode,
id: "brush-1",
type: "image",
title: "Brush stroke",
width: 100,
height: 50,
content: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(brushSvg)}`,
layerRole: "pen-stroke",
strokeColor: "#000000",
strokeWidth: 3
};
assert.equal(isEditablePathNode(brushNode), false);
assert.equal(editablePathForNode(brushNode), null);
assert.equal(applyEditedPathToNode(brushNode, [1, 5, 5, 2, 95, 45]), brushNode);
});
test("scales normalized cubic data back into SVG coordinates", () => {
assert.equal(normalizedPathDataToSvg([1, 0, 0.5, 5, 0.25, 0, 0.75, 1, 1, 0.5, 11], 200, 100), "M 0 50 C 50 0 150 100 200 50 Z");
});
test("registers SVGPathEditor on the project's Leafer 2.2 Path runtime", async () => {
await import("@leafer-in/editor");
const core = await import("@leafer-ui/core");
await import("leafer-x-path-editor");
const path = new core.Path({ path: "M 0 0 L 10 10", editable: true });
assert.equal(path.editInner, "SVGPathEditor");
});
test("keeps the Pen tool immediately after Shape in the workspace toolbar", async () => {
const source = await readFile(new URL("../src/ui/pages/CanvasWorkspace/canvas/WorkspaceToolbar.tsx", import.meta.url), "utf8");
const workspaceSource = await readFile(new URL("../src/ui/pages/CanvasWorkspace/index.tsx", import.meta.url), "utf8");
const styles = await readFile(new URL("../src/ui/styles.css", import.meta.url), "utf8");
const nodePointerSource = workspaceSource.slice(workspaceSource.indexOf("const handleNodePointerDown"), workspaceSource.indexOf("const startPathEditing"));
assert.ok(source.indexOf("<ShapeToolMenu") < source.indexOf("<PenTool"));
assert.ok(source.indexOf("<PenTool") < source.indexOf("<PenLine"));
assert.match(source, /title=\{`\$\{t\("pathPen"\)\} P`\}/);
assert.match(source, /title=\{`\$\{t\("pen"\)\} B`\}/);
assert.match(workspaceSource, /if \(activeTool === "pen"\) \{\s+startPenDrawing\(event\);/);
assert.match(workspaceSource, /setActiveTool\(editAfterCommit \? "path" : "select"\);/);
assert.match(workspaceSource, /if \(editAfterCommit\) \{\s+pathEditorExitToolRef\.current = null;\s+setPathEditingNodeId\(node\.id\);\s+\}/);
assert.match(workspaceSource, /const startPathEditing[\s\S]*?setActiveTool\("path"\);[\s\S]*?setPathEditingNodeId\(node\.id\);/);
assert.match(workspaceSource, /onClose=\{handlePathEditorClose\}/);
assert.match(nodePointerSource, /if \(activeTool === "path"\) \{\s+if \(isEditablePathNode\(node\) && editablePathForNode\(node\)\) \{/);
assert.match(nodePointerSource, /commitPathPenDrawing\(currentPathPenDraft, false, false\);/);
assert.match(nodePointerSource, /event\.preventDefault\(\);\s+startPathEditing\(node\);\s+return;/);
assert.doesNotMatch(nodePointerSource, /!pathPenDraftRef\.current && isEditablePathNode/);
assert.match(workspaceSource, /if \(tool !== "path" && pathPenDraftRef\.current\) finishPathPenDrawing\(false, false\);/);
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"\)\)/);
assert.match(styles, /cursor:\s*url\("\/cursors\/pen-tool\.svg\?v=3"\) 4 4, default !important;/);
});