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>
This commit is contained in:
2026-07-18 00:23:40 +08:00
parent be0d0888ea
commit 6ff4dc510a
2 changed files with 211 additions and 0 deletions
@@ -0,0 +1,95 @@
import type { CanvasNode } from "@/domain/design";
export const canvasNodeClipboardMime = "web application/x-fluxo-canvas-node+json";
export const canvasNodeClipboardBaseMime = "application/x-fluxo-canvas-node+json";
const canvasNodeClipboardKind = "fluxo-canvas-node";
const canvasNodeClipboardVersion = 1;
type ClipboardDataReader = {
getData: (format: string) => string;
};
type ClipboardItemReader = {
types: readonly string[];
getType: (type: string) => Promise<Blob>;
};
export function serializeCanvasNodeClipboard(node: CanvasNode) {
return JSON.stringify({
kind: canvasNodeClipboardKind,
version: canvasNodeClipboardVersion,
node
});
}
export function serializeCanvasNodeClipboardSvg(node: CanvasNode, svg: string) {
const payload = encodeURIComponent(serializeCanvasNodeClipboard(node));
return svg.replace(/<svg\b/i, `<svg data-fluxo-canvas-node="${payload}"`);
}
export function parseCanvasNodeClipboard(value: string) {
if (!value.trim()) return null;
try {
const embeddedPayload = value.match(/\bdata-fluxo-canvas-node=["']([^"']+)["']/i)?.[1];
const parsed = JSON.parse(embeddedPayload ? decodeURIComponent(embeddedPayload) : value) as { kind?: unknown; version?: unknown; node?: unknown };
if (parsed.kind !== canvasNodeClipboardKind || parsed.version !== canvasNodeClipboardVersion || !isCanvasNode(parsed.node)) return null;
return parsed.node;
} catch {
return null;
}
}
export function canvasNodeFromClipboardData(data: ClipboardDataReader) {
for (const type of [canvasNodeClipboardMime, canvasNodeClipboardBaseMime, "text/html", "text/plain"]) {
const node = parseCanvasNodeClipboard(data.getData(type));
if (node) return node;
}
return null;
}
export async function canvasNodeFromClipboardItems(items: readonly ClipboardItemReader[]) {
for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) {
const item = items[itemIndex];
for (const type of [canvasNodeClipboardMime, canvasNodeClipboardBaseMime, "text/html", "text/plain"]) {
if (!item.types.includes(type)) continue;
try {
const node = parseCanvasNodeClipboard(await item.getType(type).then((blob) => blob.text()));
if (node) return node;
} catch {
// Try the next representation exposed by the clipboard item.
}
}
}
return null;
}
export function createPastedCanvasNode(node: CanvasNode, id: string, anchor: { x: number; y: number }): CanvasNode {
return {
...node,
id,
x: anchor.x - node.width / 2,
y: anchor.y - node.height / 2
};
}
function isCanvasNode(value: unknown): value is CanvasNode {
if (!value || typeof value !== "object") return false;
const node = value as Partial<CanvasNode>;
return (
typeof node.id === "string" &&
(node.type === "brief" || node.type === "reference" || node.type === "image" || node.type === "text" || node.type === "frame") &&
typeof node.title === "string" &&
isFiniteNumber(node.x) &&
isFiniteNumber(node.y) &&
isFiniteNumber(node.width) &&
isFiniteNumber(node.height) &&
typeof node.content === "string" &&
typeof node.tone === "string" &&
typeof node.status === "string"
);
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
+116
View File
@@ -0,0 +1,116 @@
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\)/);
});