Files
moteva/frontend/tests/canvasNodeClipboard.test.mjs
T
root 6ff4dc510a feat(canvas): add lossless canvas node clipboard serialization
Serialize canvas nodes into a versioned JSON payload carried on a
custom clipboard MIME type, with the same payload embedded in the
copied SVG markup as a text/html and text/plain fallback. Parsing
prefers the internal format over PNG previews and plain text, restores
path data from the SVG fallback, rejects malformed payloads, and
creates pasted nodes at the paste anchor without rasterizing them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 00:23:40 +08:00

117 lines
4.4 KiB
JavaScript

import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import {
canvasNodeClipboardMime,
canvasNodeFromClipboardData,
canvasNodeFromClipboardItems,
createPastedCanvasNode,
parseCanvasNodeClipboard,
serializeCanvasNodeClipboard,
serializeCanvasNodeClipboardSvg
} from "../src/ui/pages/CanvasWorkspace/canvas/canvasNodeClipboard.ts";
const pathNode = {
id: "path-1",
type: "frame",
title: "Circle",
x: 100,
y: 200,
width: 300,
height: 180,
content: JSON.stringify({ kind: "path", pathData: [1, 0, 0.5, 5, 0.25, 0, 0.75, 1, 1, 0.5, 11] }),
tone: "shape",
status: "draft",
layerRole: "shape",
fillColor: "transparent",
strokeColor: "#111827",
strokeWidth: 2
};
test("round-trips editable path nodes through the internal clipboard format", () => {
const parsed = parseCanvasNodeClipboard(serializeCanvasNodeClipboard(pathNode));
assert.deepEqual(parsed, pathNode);
assert.equal(JSON.parse(parsed.content).kind, "path");
});
test("creates a pasted path node at the clipboard anchor without rasterizing it", () => {
const pasted = createPastedCanvasNode(pathNode, "path-copy", { x: 800, y: 600 });
assert.equal(pasted.id, "path-copy");
assert.equal(pasted.type, "frame");
assert.equal(pasted.layerRole, "shape");
assert.equal(pasted.title, pathNode.title);
assert.equal(pasted.status, pathNode.status);
assert.equal(JSON.parse(pasted.content).kind, "path");
assert.equal(pasted.x, 650);
assert.equal(pasted.y, 510);
});
test("prefers internal path data when the clipboard also contains a PNG preview", async () => {
const serialized = serializeCanvasNodeClipboard(pathNode);
const dataTransfer = {
files: [new File(["png"], "preview.png", { type: "image/png" })],
getData: (type) => (type === canvasNodeClipboardMime ? serialized : "")
};
const clipboardItem = {
types: ["image/png", canvasNodeClipboardMime],
getType: async (type) => new Blob([type === canvasNodeClipboardMime ? serialized : "png"], { type })
};
assert.deepEqual(canvasNodeFromClipboardData(dataTransfer), pathNode);
assert.deepEqual(await canvasNodeFromClipboardItems([clipboardItem]), pathNode);
});
test("restores path data from the standard SVG clipboard fallback", () => {
const svg = serializeCanvasNodeClipboardSvg(pathNode, '<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0"/></svg>');
const dataTransfer = {
getData: (type) => (type === "text/plain" ? svg : "")
};
assert.match(svg, /^<svg data-fluxo-canvas-node=/);
assert.deepEqual(canvasNodeFromClipboardData(dataTransfer), pathNode);
});
test("preserves raster source formats and node properties when pasted", () => {
for (const extension of ["png", "webp"]) {
const source = {
...pathNode,
id: `${extension}-1`,
type: "image",
title: `source.${extension}`,
content: `https://assets.example/source.${extension}`,
renderContent: `https://assets.example/render.${extension}`,
layerRole: "image",
imageAdjustments: JSON.stringify({ brightness: 12 })
};
const pasted = createPastedCanvasNode(parseCanvasNodeClipboard(serializeCanvasNodeClipboard(source)), `${extension}-copy`, { x: 500, y: 400 });
assert.deepEqual(pasted, {
...source,
id: `${extension}-copy`,
x: 350,
y: 310
});
}
});
test("rejects unrelated or malformed clipboard text", () => {
assert.equal(parseCanvasNodeClipboard("hello"), null);
assert.equal(parseCanvasNodeClipboard('{"version":1}'), null);
});
test("routes context-menu and keyboard copy-paste through the lossless node clipboard", async () => {
const source = await readFile(new URL("../src/ui/pages/CanvasWorkspace/index.tsx", import.meta.url), "utf8");
const dataTransferPaste = source.slice(source.indexOf("const pasteFromClipboardData"), source.indexOf("const pasteFromSystemClipboard"));
assert.ok(dataTransferPaste.indexOf("canvasNodeFromClipboardData") < dataTransferPaste.indexOf("latestDataTransferImageFile"));
assert.match(source, /copy: \(\) => copyNode\(node\)/);
assert.match(source, /paste: \(\) => void pasteFromSystemClipboard\(\)/);
assert.match(source, /key === "c"\) return void run\(selectedHandlers\?\.copy\)/);
assert.match(source, /pasteFromClipboardData\(event\.clipboardData\)/);
assert.match(source, /writeClipboardBlob\(blob, node\)/);
assert.match(source, /writeClipboardTextNode\(node\)/);
});