6ff4dc510a
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>
117 lines
4.4 KiB
JavaScript
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\)/);
|
|
});
|