feat(canvas): refine connections, text styles and icon editing

This commit is contained in:
2026-11-06 02:38:39 +08:00
parent 54ff5a0fbd
commit 86b5a4e9e4
19 changed files with 580 additions and 149 deletions
+9 -5
View File
@@ -49,12 +49,16 @@ test("all eight handles resize cards while retaining the opposite local anchor,
}
});
test("plain text resizing keeps aspect ratio and scales the font", () => {
test("plain text width and height resize independently without changing the font", () => {
const node = new PlainTextElement({ x: 0, y: 0 }).toNode();
const patch = resizeFromPointer(node, "bottom-right", { x: 240, y: 72 });
close(patch.width, 480); close(patch.height, 144); close(patch.fontSize, 48);
close(patch.width, 480); close(patch.height, 144); assert.equal(patch.fontSize, undefined);
const widthOnly = resizeFromPointer(node, "bottom-right", { x: 120, y: 0 });
close(widthOnly.width, 360); close(widthOnly.height, 72);
const heightOnly = resizeFromPointer(node, "bottom-right", { x: 0, y: 60 });
close(heightOnly.width, 240); close(heightOnly.height, 132);
const min = resizeFromPointer(node, "bottom-right", { x: -10000, y: -10000 });
assert(min.width >= 60); assert(min.height >= 24); assert(min.fontSize >= 8);
assert(min.width >= 60); assert(min.height >= 24); assert.equal(min.fontSize, undefined);
});
test("rotation follows the pointer, supports 15-degree snapping and normalizes the angle", () => {
@@ -73,8 +77,8 @@ test("geometry and plain-text type survive save/restore and transforms undo as o
state = canvasReducer(state, { type: "checkpoint" });
state = canvasReducer(state, { type: "transform", id: node.id, transform: { ...resizeFromPointer(node, "right", { x: 120, y: 0 }), rotation: 45 } });
const restored = decodeCanvas(JSON.stringify(state.present)).nodes[0];
assert.equal(restored.type, "plainText"); close(restored.width, 360); close(restored.height, 108);
close(restored.data.rotation, 45); close(restored.data.fontSize, 36);
assert.equal(restored.type, "plainText"); close(restored.width, 360); close(restored.height, 72);
close(restored.data.rotation, 45); close(restored.data.fontSize, 24);
const undo = canvasReducer(state, { type: "undo" });
assert.deepEqual(nodeSize(undo.present.nodes[0]), { width: 240, height: 72 });
close(undo.present.nodes[0].data.rotation, 0);
+73
View File
@@ -0,0 +1,73 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { CanvasElement, CardElement, IconElement } from "../src/lib/canvas-elements.ts";
import { resizeFromPointer } from "../src/lib/canvas-geometry.ts";
function seeded() { return { ...emptyCanvas(), nodes: [makeCanvasNode("text", { x: 100, y: 200 })] }; }
test("branch type selection creates the chosen text, script or uploaded image and keeps the link in one undo step", () => {
for (const kind of ["text", "script", "image"]) {
const original = seeded();
const src = kind === "image" ? "data:image/png;base64,YQ==" : undefined;
const result = branchCanvasNode(original, original.nodes[0].id, "bottom", kind, src);
assert.equal(result.nodes[1].data.kind, kind);
assert.equal(result.nodes[1].data.src, src);
assert.equal(result.edges[0].sourceHandle, "bottom");
assert.equal(result.edges[0].targetHandle, "top");
const restored = decodeCanvas(JSON.stringify(result));
assert.equal(restored.nodes[1].data.kind, kind);
const history = canvasReducer({ past: [], present: original, future: [] }, { type: "replace", document: result });
assert.deepEqual(canvasReducer(history, { type: "undo" }).present, original);
}
});
test("a connection released on the canvas creates the chosen node at the release point", () => {
const original = seeded();
const at = { x: -300, y: 700 };
const result = branchCanvasNode(original, original.nodes[0].id, "left", "script", undefined, at);
assert.deepEqual(result.nodes[1].position, at);
assert.equal(result.edges.length, 1);
});
test("all text styles and font size survive serialization, undo and redo", () => {
const original = seeded();
const style = { color: "#123456", background: "#fedcba", bold: true, italic: true, underline: true, align: "right", fontFamily: "serif", lineHeight: 1.8 };
const edited = canvasReducer({ past: [], present: original, future: [] }, { type: "style", id: original.nodes[0].id, style, fontSize: 32 });
const restored = decodeCanvas(JSON.stringify(edited.present));
assert.deepEqual(restored.nodes[0].data.textStyle, style);
assert.equal(restored.nodes[0].data.fontSize, 32);
const undo = canvasReducer(edited, { type: "undo" });
assert.equal(undo.present.nodes[0].data.textStyle, undefined);
assert.equal(canvasReducer(undo, { type: "redo" }).present.nodes[0].data.textStyle.bold, true);
const cleared = canvasReducer(edited, { type: "style", id: original.nodes[0].id, style: { bold: false, background: "transparent" } });
assert.equal(cleared.present.nodes[0].data.textStyle.color, style.color);
assert.equal(decodeCanvas(JSON.stringify(cleared.present)).nodes[0].data.textStyle.bold, false);
});
test("unsafe CSS, unknown styles and invalid icon identifiers cannot enter a restored canvas", () => {
for (const style of [{ color: "url(https://example.com)" }, { background: "red" }, { fontFamily: "custom" }, { lineHeight: Infinity }, { bold: "true" }, { align: "justify" }, { align: ["left"] }, { fontFamily: ["sans"] }, { position: "fixed" }, []]) {
const doc = seeded(); doc.nodes[0].data.textStyle = style;
assert.throws(() => decodeCanvas(JSON.stringify(doc)), /样式/);
}
const icon = makeCanvasNode("icon", { x: 0, y: 0 });
icon.data.icon = "<svg onload=evil>";
assert.throws(() => decodeCanvas(JSON.stringify({ ...emptyCanvas(), nodes: [icon] })), /图标/);
});
test("icon elements inherit transforms without card branching, preserve their identity and resize proportionally", () => {
const element = new IconElement({ x: 10, y: 20 });
assert(element instanceof CanvasElement);
assert.equal(element instanceof CardElement, false);
const icon = element.toNode(); icon.data.icon = "Rocket";
icon.data.textStyle = { color: "#cc6633" }; icon.data.rotation = 45;
const result = decodeCanvas(JSON.stringify({ ...emptyCanvas(), nodes: [icon] })).nodes[0];
assert.equal(result.type, "icon"); assert.equal(result.data.icon, "Rocket");
assert.equal(result.data.textStyle.color, "#cc6633"); assert.equal(result.data.rotation, 45);
icon.data.rotation = 0;
const larger = resizeFromPointer(icon, "bottom-right", { x: 80, y: 80 });
assert.equal(larger.width, 160); assert.equal(larger.height, 160);
const smaller = resizeFromPointer(icon, "bottom-right", { x: -1000, y: -1000 });
assert.equal(smaller.width, 24); assert.equal(smaller.height, 24);
assert.throws(() => branchCanvasNode({ ...emptyCanvas(), nodes: [icon] }, icon.id, "right"));
});