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:
@@ -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);
|
||||
}
|
||||
@@ -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\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user