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:
@@ -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;/);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
deletePathSegment,
|
||||
getClosestSegmentLocation,
|
||||
splitPathSegment
|
||||
} from "leafer-x-path-editor";
|
||||
|
||||
test("splits an existing straight segment at the clicked position", () => {
|
||||
const points = [{ x: 0, y: 0, move: true }, { x: 10, y: 0 }];
|
||||
const segment = { fromIndex: 0, toIndex: 1, insertIndex: 1 };
|
||||
|
||||
const result = splitPathSegment(points, segment, 0.4);
|
||||
|
||||
assert.deepEqual(result, [{ x: 0, y: 0, move: true }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
|
||||
});
|
||||
|
||||
test("keeps a closed path closed when its closing segment is split", () => {
|
||||
const result = splitPathSegment(
|
||||
[{ x: 0, y: 0, move: true }, { x: 10, y: 0, closed: true }],
|
||||
{ fromIndex: 1, toIndex: 0, insertIndex: 2, closing: true },
|
||||
0.5
|
||||
);
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{ x: 0, y: 0, move: true },
|
||||
{ x: 10, y: 0, closed: false },
|
||||
{ x: 5, y: 0, closed: true }
|
||||
]);
|
||||
});
|
||||
|
||||
test("splits quadratic and cubic segments without changing their geometry", () => {
|
||||
const quadratic = splitPathSegment(
|
||||
[{ x: 0, y: 0, move: true, x2: 0, y2: 10 }, { x: 10, y: 0 }],
|
||||
{ fromIndex: 0, toIndex: 1, insertIndex: 1 },
|
||||
0.5
|
||||
);
|
||||
assert.deepEqual(quadratic, [
|
||||
{ x: 0, y: 0, move: true, x2: undefined, y2: undefined },
|
||||
{ x: 2.5, y: 5, x1: 0, y1: 5, x2: 5, y2: 5, mode: "no-mirror" },
|
||||
{ x: 10, y: 0, x1: undefined, y1: undefined }
|
||||
]);
|
||||
|
||||
const cubic = splitPathSegment(
|
||||
[{ x: 0, y: 0, move: true, x2: 0, y2: 10 }, { x: 10, y: 0, x1: 10, y1: 10 }],
|
||||
{ fromIndex: 0, toIndex: 1, insertIndex: 1 },
|
||||
0.5
|
||||
);
|
||||
assert.deepEqual(cubic, [
|
||||
{ x: 0, y: 0, move: true, x2: 0, y2: 5 },
|
||||
{ x: 5, y: 7.5, x1: 2.5, y1: 7.5, x2: 7.5, y2: 7.5, mode: "no-mirror" },
|
||||
{ x: 10, y: 0, x1: 10, y1: 5 }
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds the insertion point nearest to the pointer", () => {
|
||||
const points = [{ x: 0, y: 0, move: true }, { x: 10, y: 0 }];
|
||||
const location = getClosestSegmentLocation(points, { fromIndex: 0, toIndex: 1, insertIndex: 1 }, { x: 3, y: 5 });
|
||||
|
||||
assert.ok(Math.abs(location.t - 0.3) < 0.0001);
|
||||
assert.deepEqual(location.point, { x: 3, y: 0 });
|
||||
});
|
||||
|
||||
test("deletes the selected segment without reconnecting its endpoints", () => {
|
||||
const openResult = deletePathSegment(
|
||||
[{ x: 0, y: 0, move: true, x2: 4, y2: 0 }, { x: 10, y: 0, x1: 6, y1: 0 }],
|
||||
{ fromIndex: 0, toIndex: 1, insertIndex: 1 }
|
||||
);
|
||||
assert.deepEqual(openResult, [
|
||||
{ x: 0, y: 0, move: true, x2: undefined, y2: undefined },
|
||||
{ x: 10, y: 0, x1: undefined, y1: undefined, move: true, type: "start" }
|
||||
]);
|
||||
|
||||
const closedResult = deletePathSegment(
|
||||
[{ x: 0, y: 0, move: true, x1: 2, y1: 2 }, { x: 10, y: 0, x2: 8, y2: 2, closed: true }],
|
||||
{ fromIndex: 1, toIndex: 0, insertIndex: 2, closing: true }
|
||||
);
|
||||
assert.deepEqual(closedResult, [
|
||||
{ x: 0, y: 0, move: true, x1: undefined, y1: undefined },
|
||||
{ x: 10, y: 0, x2: undefined, y2: undefined, closed: false }
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
appendPathPenAnchor,
|
||||
closePathPenDraft,
|
||||
constrainPathPenPoint,
|
||||
createPathPenNode,
|
||||
pathPenDraftToPathData,
|
||||
setLastPathPenAnchorHandles,
|
||||
startPathPenDraft
|
||||
} from "../src/ui/pages/CanvasWorkspace/canvas/pathPen.ts";
|
||||
|
||||
const settings = { color: "#123456", width: 3 };
|
||||
|
||||
function point(x, y) {
|
||||
return { screen: { x, y }, world: { x, y } };
|
||||
}
|
||||
|
||||
test("creates one Pen anchor for each user click", () => {
|
||||
let draft = startPathPenDraft(settings, 1, point(10, 20));
|
||||
draft = appendPathPenAnchor(draft, point(110, 20));
|
||||
draft = appendPathPenAnchor(draft, point(110, 80));
|
||||
|
||||
assert.equal(draft.anchors.length, 3);
|
||||
assert.deepEqual(pathPenDraftToPathData(draft), [1, 10, 20, 2, 110, 20, 2, 110, 80]);
|
||||
});
|
||||
|
||||
test("turns click-drag anchors into Photoshop-style mirrored Bezier handles", () => {
|
||||
let draft = startPathPenDraft(settings, 1, point(10, 20));
|
||||
draft = setLastPathPenAnchorHandles(draft, point(40, 20));
|
||||
draft = appendPathPenAnchor(draft, point(110, 80));
|
||||
draft = setLastPathPenAnchorHandles(draft, point(130, 100));
|
||||
|
||||
assert.deepEqual(draft.anchors[0].handleIn?.world, { x: -20, y: 20 });
|
||||
assert.deepEqual(draft.anchors[0].handleOut?.world, { x: 40, y: 20 });
|
||||
assert.deepEqual(draft.anchors[1].handleIn?.world, { x: 90, y: 60 });
|
||||
assert.deepEqual(pathPenDraftToPathData(draft), [1, 10, 20, 5, 40, 20, 90, 60, 110, 80]);
|
||||
});
|
||||
|
||||
test("constrains Pen anchors and handles to 45-degree increments with Shift", () => {
|
||||
const constrained = constrainPathPenPoint({ x: 10, y: 10 }, { x: 42, y: 24 });
|
||||
|
||||
assert.equal(Math.round(constrained.x), 35);
|
||||
assert.equal(Math.round(constrained.y), 35);
|
||||
});
|
||||
|
||||
test("closes a Pen path back to its first user anchor", () => {
|
||||
let draft = startPathPenDraft(settings, 1, point(0, 0));
|
||||
draft = appendPathPenAnchor(draft, point(100, 0));
|
||||
draft = appendPathPenAnchor(draft, point(100, 100));
|
||||
draft = closePathPenDraft(draft);
|
||||
|
||||
assert.deepEqual(pathPenDraftToPathData(draft), [1, 0, 0, 2, 100, 0, 2, 100, 100, 11]);
|
||||
});
|
||||
|
||||
test("persists a completed Pen path as an editable shape node", () => {
|
||||
let draft = startPathPenDraft(settings, 1, point(10, 20));
|
||||
draft = appendPathPenAnchor(draft, point(110, 20));
|
||||
const node = createPathPenNode(draft, "Pen");
|
||||
const options = JSON.parse(node.content);
|
||||
|
||||
assert.equal(node.type, "frame");
|
||||
assert.equal(node.layerRole, "shape");
|
||||
assert.equal(node.strokeColor, settings.color);
|
||||
assert.equal(options.kind, "path");
|
||||
assert.deepEqual(options.pathData, [1, 0.04955, 0.5, 2, 0.95045, 0.5]);
|
||||
});
|
||||
Reference in New Issue
Block a user