-
Notifications
You must be signed in to change notification settings - Fork 83
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
Changes from 4 commits
60d7893
1d4ef76
55199d5
fcebde1
c728d6d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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>' | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
) | ||
).toBeNull(); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -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"], | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||
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; | ||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment.
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:
Let's verify the current browser support:
🏁 Script executed:
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
This ensures a stable UUID in all supported runtimes.
🤖 Prompt for AI Agents