feat(canvas): refine connections, text styles and icon editing
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
Reference in New Issue
Block a user