From 6ff4dc510af35a69a986d3d0de0a47a431a03159 Mon Sep 17 00:00:00 2001 From: liangxu Date: Sat, 18 Jul 2026 00:23:40 +0800 Subject: [PATCH] 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 --- .../canvas/canvasNodeClipboard.ts | 95 ++++++++++++++ frontend/tests/canvasNodeClipboard.test.mjs | 116 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 frontend/src/ui/pages/CanvasWorkspace/canvas/canvasNodeClipboard.ts create mode 100644 frontend/tests/canvasNodeClipboard.test.mjs diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/canvasNodeClipboard.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/canvasNodeClipboard.ts new file mode 100644 index 0000000..f22c7b4 --- /dev/null +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/canvasNodeClipboard.ts @@ -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; +}; + +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(/= 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; + 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); +} diff --git a/frontend/tests/canvasNodeClipboard.test.mjs b/frontend/tests/canvasNodeClipboard.test.mjs new file mode 100644 index 0000000..ff22932 --- /dev/null +++ b/frontend/tests/canvasNodeClipboard.test.mjs @@ -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, ''); + const dataTransfer = { + getData: (type) => (type === "text/plain" ? svg : "") + }; + + assert.match(svg, /^ { + 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\)/); +});