Skip to content

fix(clipboard): WebKit (Safari / WKWebView) copy/cut/paste - #36

Merged
winlp4ever merged 3 commits into
mainfrom
fix/webkit-clipboard
Aug 3, 2026
Merged

fix(clipboard): WebKit (Safari / WKWebView) copy/cut/paste#36
winlp4ever merged 3 commits into
mainfrom
fix/webkit-clipboard

Conversation

@winlp4ever

Copy link
Copy Markdown
Owner

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:

  1. Keydown → async navigator.clipboard. WebKit restricts it: read/readText require a live user activation, which is spent across the awaits inside paste(), so the read rejects → paste() returned null → nothing happened. ClipboardItem also rejects our custom MIME on WebKit.
  2. Every failure was a silent return — no error, exactly the reported symptom.

Fix (WebKit-first; Chromium path unchanged)

  • DOM copy/cut/paste events instead of keydown+async. They run synchronously inside the user gesture and expose a DataTransfer WebKit allows. New core helpers writeSelectionToDataTransfer / readClipboardFromDataTransfer own the (de)serialization + MIME.
  • In-memory fallback clipboard (module-level) so intra-app — and cross-board within the 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.
  • Focusable host (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/plain still carries human-readable node text (external paste); JSON rides in the custom MIME.

Tests

tests/clipboard.browser.test.tsx:

  • DataTransfer helpers: write→read round-trip, and read falls back to in-memory on an empty transfer (the WKWebView case).
  • <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.

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.
@winlp4ever
winlp4ever merged commit fd6d1a0 into main Aug 3, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant