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\)/); +});