Skip to content

cf240 [Grida Canvas] Implement cross-window clipboard paste #372

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions editor/grida-canvas-react/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { editor } from "@/grida-canvas";
import grida from "@grida/schema";
import iosvg from "@grida/io-svg";
import { io } from "@grida/io";
import type { tokens } from "@grida/tokens";
import type cg from "@grida/cg";
import { dq } from "@/grida-canvas/query";
Expand All @@ -23,6 +24,7 @@ import { is_direct_component_consumer } from "@/grida-canvas-utils/utils/support
import { Editor } from "@/grida-canvas/editor";
import { EditorContext, useCurrentEditor, useEditorState } from "./use-editor";
import assert from "assert";
import nid from "../grida-canvas/reducers/tools/id";

type Dispatcher = (action: Action) => void;

Expand Down Expand Up @@ -1013,6 +1015,7 @@ export function useDataTransferEventTarget() {
const state = useEditorState(instance, (state) => ({
transform: state.transform,
}));
const current_clipboard = useEditorState(instance, (s) => s.user_clipboard);

const canvasXY = useCallback(
(xy: cmath.Vector2) => {
Expand Down Expand Up @@ -1213,6 +1216,30 @@ export function useDataTransferEventTarget() {
clientY: window.innerHeight / 2,
});
});
} else if (item.kind === "string" && item.type === "text/html") {
pasted_from_data_transfer = true;
item.getAsString((html) => {
const data = io.clipboard.decodeClipboardHtml(html);
if (data) {
if (current_clipboard?.payload_id === data.payload_id) {
instance.paste();
} else {
data.prototypes.forEach((p) => {
const sub =
grida.program.nodes.factory.create_packed_scene_document_from_prototype(
p,
nid
);
instance.insert({ document: sub });
});
}
return;
}
insertText(html, {
clientX: window.innerWidth / 2,
clientY: window.innerHeight / 2,
});
});
}
}

Expand All @@ -1221,7 +1248,7 @@ export function useDataTransferEventTarget() {
instance.paste();
}
},
[instance, insertFromFile, insertText]
[instance, insertFromFile, insertText, current_clipboard]
);

const ondragover = (event: React.DragEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -1282,17 +1309,13 @@ export function useClipboardSync() {
useEffect(() => {
try {
if (user_clipboard) {
const serializedData = JSON.stringify(user_clipboard);
const htmltxt = `<meta>${serializedData}`;
const blob = new Blob([htmltxt], {
type: "text/html",
});
const htmltxt = io.clipboard.encodeClipboardHtml(
user_clipboard as io.clipboard.ClipboardPayload
);
const blob = new Blob([htmltxt], { type: "text/html" });

const clipboardItem = new ClipboardItem({
"text/html": blob,
// Optional: Add plain text for fallback
// TODO: copy content as texts. (if text)
// "text/plain": new Blob([serializedData], { type: "text/plain" }),
});
navigator.clipboard.write([clipboardItem]);
}
Expand Down
2 changes: 2 additions & 0 deletions editor/grida-canvas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,8 @@ export namespace editor.state {
* user clipboard - copied data
*/
user_clipboard?: {
/** unique payload id for distinguishing clipboard contents */
payload_id: string;
/**
* copied node data as prototype
*/
Expand Down
1 change: 1 addition & 0 deletions editor/grida-canvas/reducers/document.reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default function documentReducer<S extends editor.state.IEditorState>(
return produce(state, (draft) => {
// [copy]
draft.user_clipboard = {
payload_id: crypto.randomUUID(),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify browser compatibility for crypto.randomUUID().

The implementation correctly adds unique payload IDs to clipboard data. However, crypto.randomUUID() requires browser support and may not be available in all environments (e.g., older browsers, some Node.js contexts).

Consider checking browser compatibility or providing a fallback:

-          payload_id: crypto.randomUUID(),
+          payload_id: crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`,

Let's verify the current browser support:


🏁 Script executed:

#!/bin/bash
# Description: Check crypto.randomUUID() usage in the codebase and verify it's consistently used

# Search for other uses of crypto.randomUUID in the codebase
rg -A 2 -B 2 "crypto\.randomUUID"

# Search for alternative UUID generation methods
rg -A 2 -B 2 "randomUUID|uuid|UUID"

Length of output: 68119


Add a fallback for crypto.randomUUID() using the existing uuid library

To avoid runtime errors in environments where crypto.randomUUID is unavailable (e.g. older Safari or non-Web Crypto contexts), fall back to the project’s uuid package:

• File: editor/grida-canvas/reducers/document.reducer.ts
Line: 97

// At top of file, alongside other imports
+ import { v4 as uuidv4 } from 'uuid';-          payload_id: crypto.randomUUID(),
+          payload_id: crypto.randomUUID?.() ?? uuidv4(),

This ensures a stable UUID in all supported runtimes.

🤖 Prompt for AI Agents
In editor/grida-canvas/reducers/document.reducer.ts at line 97, the use of
crypto.randomUUID() may cause runtime errors in environments lacking support.
Modify the code to check if crypto.randomUUID is available; if not, use the
existing uuid library's UUID generation function as a fallback. This ensures
consistent UUID generation across all supported environments.

ids: target_node_ids,
prototypes: target_node_ids.map((id) =>
grida.program.nodes.factory.createPrototypeFromSnapshot(
Expand Down
2 changes: 1 addition & 1 deletion editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
"embla-carousel-autoplay": "^8.3.0",
"embla-carousel-react": "^8.6.0",
"fast-deep-equal": "^3.1.3",
"fast-xml-parser": "^4.4.0",
"fast-xml-parser": "^5.2.3",
"figma-api": "2.0.1-beta",
"file-saver": "^2.0.5",
"flat": "^6.0.1",
Expand Down
75 changes: 75 additions & 0 deletions packages/grida-canvas-io/__tests__/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { io } from "../index";

describe("clipboard", () => {
// Using a simple test payload instead of the full ClipboardPayload type
// This is just for testing the IO functionality
const testPayload: io.clipboard.ClipboardPayload = {
ids: ["<id>"],
payload_id: "52b698ef-d06a-4f9c-ac4c-c26e744c8567",
prototypes: [
{
active: true,
fill: {
color: {
a: 1,
b: 0,
g: 0,
r: 0,
},
type: "solid",
},
fontFamily: "Inter",
fontSize: 14,
fontWeight: 400,
height: "auto",
locked: false,
name: "text",
opacity: 1,
position: "absolute",
text: "Text",
type: "text",
width: "auto",
zIndex: 0,
},
],
} satisfies io.clipboard.ClipboardPayload;

it("should encode and decode clipboard data correctly", () => {
// Encode the test payload to HTML
const encoded = io.clipboard.encodeClipboardHtml(testPayload);

// Verify the encoded string contains the expected structure
expect(encoded).toContain("data-grida-io-clipboard");
expect(encoded).toContain("b64:");

// Decode the HTML back to the original payload
const decoded = io.clipboard.decodeClipboardHtml(encoded);

// Verify the decoded data matches the original
expect(decoded).toEqual(testPayload);
});

it("should decode clipboard data even if the html is manipulated by the browser", () => {
// browser will append meta, html, head, body tags
const html = `<meta charset='utf-8'><html><head></head><body>${io.clipboard.encodeClipboardHtml(
testPayload
)}</body></html>`;
const decoded = io.clipboard.decodeClipboardHtml(html);
expect(decoded).toEqual(testPayload);
});

it("should return null for invalid clipboard data", () => {
// Test with invalid HTML
expect(io.clipboard.decodeClipboardHtml("<div>invalid</div>")).toBeNull();

// Test with missing data attribute
expect(io.clipboard.decodeClipboardHtml("<span></span>")).toBeNull();

// Test with invalid base64 data
expect(
io.clipboard.decodeClipboardHtml(
'<span data-grida-clipboard="b64:invalid"></span>'
)
).toBeNull();
});
});
69 changes: 69 additions & 0 deletions packages/grida-canvas-io/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,77 @@ import type grida from "@grida/schema";
import type cmath from "@grida/cmath";
import { zipSync, unzipSync, strToU8, strFromU8 } from "fflate";
import { encode, decode, type PngDataArray } from "fast-png";
import { XMLParser } from "fast-xml-parser";

export namespace io {
export namespace clipboard {
const __data_grida_io_prefix = "data-grida-io-";
const __data_grida_clipboard = "data-grida-io-clipboard";

export interface ClipboardPayload {
payload_id: string;
prototypes: grida.program.nodes.NodePrototype[];
ids: string[];
}

export function encodeClipboardHtml(payload: ClipboardPayload): string {
const json = JSON.stringify(payload);
const utf8Bytes = new TextEncoder().encode(json);
const base64 = btoa(String.fromCharCode(...utf8Bytes));
return `<span ${__data_grida_clipboard}="b64:${base64}"></span>`;
}

/**
* Decodes clipboard HTML content into a ClipboardPayload object.
*
* This function is designed to be resilient against browser modifications to the clipboard HTML.
* When content is copied to the clipboard, browsers often wrap the content with additional HTML tags
* like <meta>, <html>, <head>, and <body>. This function handles such cases by:
*
* 1. Using XMLParser to parse the HTML structure
* 2. Looking for the clipboard data in both possible locations:
* - Under html.body.span (when browser adds wrapper tags)
* - Directly under span (when no wrapper tags are present)
*
* @param html - The HTML string from the clipboard, which may contain browser-appended tags
* @returns The decoded ClipboardPayload object, or null if the data is invalid or missing
*
* @example
* // Original clipboard data
* const html = '<span data-grida-io-clipboard="b64:..."></span>';
*
* // Browser-modified clipboard data
* const browserHtml = '<meta charset="utf-8"><html><head></head><body><span data-grida-io-clipboard="b64:..."></span></body></html>';
*
* // Both will work correctly
* const payload1 = decodeClipboardHtml(html);
* const payload2 = decodeClipboardHtml(browserHtml);
*/
export function decodeClipboardHtml(html: string): ClipboardPayload | null {
try {
const parser = new XMLParser({
ignoreAttributes: (key) => !key.startsWith(__data_grida_io_prefix),
attributeNamePrefix: "@",
unpairedTags: ["meta"],
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider adding more unpaired tags to XMLParser configuration.

Browsers might add other self-closing tags when manipulating clipboard content. Consider expanding the unpaired tags list to prevent parsing errors.

-          unpairedTags: ["meta"],
+          unpairedTags: ["meta", "link", "br", "hr", "img", "input", "area", "base", "col", "embed", "source", "track", "wbr"],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
unpairedTags: ["meta"],
unpairedTags: [
"meta",
"link",
"br",
"hr",
"img",
"input",
"area",
"base",
"col",
"embed",
"source",
"track",
"wbr"
],
🤖 Prompt for AI Agents
In packages/grida-canvas-io/index.ts at line 56, the XMLParser configuration
currently includes only "meta" as an unpaired tag. To prevent parsing errors
caused by browsers adding other self-closing tags in clipboard content, expand
the unpairedTags array to include additional common self-closing tags such as
"img", "br", "hr", "input", and "link". Update the configuration to cover these
tags to improve robustness.

});
const parsed = parser.parse(html);
const span = parsed.html?.body?.span || parsed.span;
const data = span?.[`@${__data_grida_clipboard}`];
if (!data || !data.startsWith("b64:")) return null;
const base64 = data.slice(4);
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const json = new TextDecoder().decode(bytes);
return JSON.parse(json) as ClipboardPayload;
} catch {
return null;
}
}
}

export interface LoadedDocument {
version: typeof grida.program.document.SCHEMA_VERSION;
document: grida.program.document.Document;
Expand Down
7 changes: 7 additions & 0 deletions packages/grida-canvas-io/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@
"description": "IO model for Grida Canvas",
"version": "0.0.0",
"private": true,
"scripts": {
"test": "jest"
},
"dependencies": {
"@grida/cmath": "workspace:*",
"fast-png": "^6.3.0",
"fast-xml-parser": "^5.2.3",
"fflate": "^0.8.2"
},
"devDependencies": {
"@grida/cg": "workspace:*",
"@grida/schema": "workspace:*"
},
"jest": {
"preset": "ts-jest"
}
}
Loading