fix(clipboard): WebKit (Safari / WKWebView) copy/cut/paste - #36
Merged
Conversation
Copy/paste silently no-op'd in Safari and the WKWebView desktop app while working in Chrome — the same WebKit-vs-Chromium divergence as the pinch-zoom bug. Two Chromium-only assumptions were at fault: - Keydown handlers called the async navigator.clipboard. WebKit restricts it: clipboard.read/readText need a live user activation, which is spent across the awaits in paste(), so the read rejected and paste returned null. ClipboardItem also rejects our custom MIME on WebKit. Fixes, WebKit-first (Chromium path preserved): - Move copy/cut/paste onto the DOM copy/cut/paste events, which run synchronously inside the user gesture and expose a DataTransfer WebKit allows. New core helpers writeSelectionToDataTransfer / readClipboardFromDataTransfer own the (de)serialization + MIME. - Add a module-level in-memory fallback clipboard so intra-app (and cross-board, same JS context) copy/paste always works even when the system clipboard is unavailable or drops our payload — the guaranteed path in WKWebView. System clipboard stays primary for cross-app. - Make the canvas host focusable (role=application + tabIndex) so WebKit dispatches clipboard events to it; focus it on pointerdown (not while editing). Keydown now only handles the [ / ] z-order shortcuts. Adds browser tests for the DataTransfer helpers (round-trip + memory fallback) and the <Canvas> copy/cut/paste event wiring. Full suites (core 351, react 21 browser + unit), lint, typecheck all pass. Real Safari/WKWebView still needs a manual pass (chromium can't fully exercise WebKit clipboard restrictions).
Follow-up to the WebKit clipboard change: - readClipboardFromDataTransfer returns null when the transfer holds real but non-canvas content (plain text, foreign JSON), instead of falling back to the in-memory clipboard — so pasting external content is never hijacked by a previously-copied canvas selection. The memory fallback now fires only for a completely empty transfer (the WKWebView quirk). text/plain now carries the JSON so intra-app paste still round-trips in WebKit without relying on that fallback. - Scope the in-memory fallback per store (WeakMap) so independent <Canvas> instances can't leak clipboard content into each other. - Move the copy/cut/paste listeners from window onto the host element, so an ordinary page-text copy elsewhere on the page isn't hijacked when the canvas happens to hold a selection. - The pointerdown-to-focus effect skips interactive targets (inputs, buttons, links, contenteditable) so it never steals focus from a custom DOM-overlay node's own controls. Adds tests for the no-hijack (external text + foreign JSON), per-store isolation, and empty-transfer fallback cases. Full suites (core 351, react 24 browser + unit), lint, typecheck pass.
…cut dedup - Drop the in-memory fallback from the async paste(): readClipboard() returns null for BOTH an empty clipboard and a non-canvas one, so the fallback would paste stale nodes over legitimate external content on a programmatic paste(store). The DOM paste-event path keeps the WebKit empty-transfer fallback (readClipboardFromDataTransfer can tell empty from external apart; the async path can't). - Focus-steal guard now uses isContentEditable instead of the [contenteditable="true"] selector, so it also covers bare contenteditable and plaintext-only — consistent with the clipboard guard, so it never steals focus from those overlay editors. - Extract cutSelectionToDataTransfer into core; the React cut handler called it instead of re-implementing the copy-then-batch-remove loop. Full suites (core 351, react 24 browser + unit), lint, typecheck pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Copy/paste silently did nothing in Safari and the WKWebView desktop app, but worked in Chrome — the same WebKit-vs-Chromium divergence as the pinch-zoom bug (#35). Two Chromium-only assumptions:
navigator.clipboard. WebKit restricts it:read/readTextrequire a live user activation, which is spent across theawaits insidepaste(), so the read rejects →paste()returnednull→ nothing happened.ClipboardItemalso rejects our custom MIME on WebKit.return— no error, exactly the reported symptom.Fix (WebKit-first; Chromium path unchanged)
copy/cut/pasteevents instead of keydown+async. They run synchronously inside the user gesture and expose aDataTransferWebKit allows. New core helperswriteSelectionToDataTransfer/readClipboardFromDataTransferown the (de)serialization + MIME.role="application"+tabIndex, focus on pointerdown, not while editing) so WebKit dispatches clipboard events to the canvas. Keydown now only handles the[/]z-order shortcuts.text/plainstill carries human-readable node text (external paste); JSON rides in the custom MIME.Tests
tests/clipboard.browser.test.tsx:<Canvas>wiring: copy→paste re-creates the node with a fresh id; cut removes then pastes back.All green: core 351, react 21 browser + unit, repo lint + typecheck.
Still needs a human
Chromium can't fully reproduce WebKit's clipboard restrictions (and synthetic
ClipboardEvents don't reflect writes), so CI covers the wiring + logic, not real WebKit. Manual pass: desktop Safari and the Dim0 app — copy a node, paste it (should land at the cursor); confirm Chrome still works.Follow-up to #35. Same pattern: WebKit-first, Chromium preserved, browser test + manual Safari check.