Skip to content

fix(clipboard): one cut, one copy, one paste (#207) - #548

Merged
PathGao merged 5 commits into
masterfrom
fix/webview-clipboard
Aug 8, 2026
Merged

fix(clipboard): one cut, one copy, one paste (#207)#548
PathGao merged 5 commits into
masterfrom
fix/webview-clipboard

Conversation

@PathGao

@PathGao PathGao commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Closes #207. Carries item 3 of the audit in #393.

Right-clicking in the editor and choosing Paste did nothing. The menu half of
that report was fixed in #266; this is the clipboard itself, and the fix is
smaller than the diagnosis.

Why the obvious fix is not the fix

Markpad is a webview, not Electron, so Monaco's platform.isNative is false
and its clipboard code takes the branch written for the web. There it decides
whether to register a Paste action by asking whether the API exists:

const supportsPaste = (typeof navigator.clipboard === 'undefined' || isFirefox)
    ? document.queryCommandSupported('paste') : true;

navigator.clipboard is an object in any Chromium webview, so the action
registers and the menu item is drawn — the check is never for permission.
readText() then rejects, BrowserClipboardService catches it and returns
'', and if (clipboardText !== '') declines to paste. No error, no feedback.

wry leaves the webview's clipboard attribute off, which on Windows means the
WebView2 PermissionRequested handler allowing CLIPBOARD_READ is never
registered. Turning that on is the obvious fix and the plumbing is intact all
the way down — enable_clipboard_access()webview_attributes.clipboard
.with_clipboard(...)add_PermissionRequested(...).

It does not work. Built it, ran it on Windows, right-clicked Paste: still
nothing. tauri-apps/tauri#12007 has been open since December 2024 on exactly
this, and the answer the ecosystem gives is the one Markpad already had for
⌘V — go through Rust. The official tauri-plugin-clipboard-manager exists to
be "an alternative to the native navigator.clipboard methods".

What this does

Three operations had six implementations. ⌘X was the browser's, ⌘C and ⌘V were
ours through Rust, and the three menu items were Monaco's — of which Paste
cannot work in a webview at all, and Cut and Copy are dead on Linux, where the
same wry default gates set_javascript_can_access_clipboard.

        keyboard                     menu
cut     ⌘X ─┐                   Cut ─┐
copy    ⌘C ─┼→ cutToClipboard   Copy ┼→ the same three functions → arboard
paste   ⌘V ─┘  copyToClipboard  Paste┘
               pasteFromClipboard

Both halves were free to take. Monaco leaves ⌘X/⌘C/⌘V unbound in a browser by
design ("browsers do that for us" — clipboard.js), so the key slots were
available. And its editor context menu holds little else this app can use, so
contextmenu: false and the menu the preview pane already had is drawn for the
editor too — translated, styled, and the same on both sides.

Nothing here depends on a webview permission and nothing imports a Monaco
internal, so there is no upgrade that can quietly take it away.

Three things that only showed up in a build

Each was found by using the app, and none would have failed a test:

  • Command Palette and Change All Occurrences went missing. Redrawing the
    menu drops everything Monaco contributed, and those two work without a
    language provider. They are back, and translated — Monaco's menu is English
    whatever language the app is in. The earlier claim that nothing else was
    usable came from searching MenuId.EditorContext; standalone actions
    register through contextMenuOpts.
  • The chord beside each item went with them. Back for those two, where the
    menu entry is most of how anyone learns the shortcut exists. Not for
    cut/copy/paste — OS conventions rather than app shortcuts, as shortcuts.ts
    says of the same three.
  • "⌘Z stopped working" on Windows. It had not: paste from the menu left
    focus on the menu item, so there was no caret and the next keystroke went to
    the document. ⌘X/⌘C/⌘V never needed a focus() — a keybinding fires with the
    editor focused by definition — which is exactly why it was missing once those
    functions got a second entry point.

Also: copying carries the colours no longer (#393 item 3)

Monaco writes a styled text/html flavour beside the plain text, so pasting
into Word or Outlook gave coloured monospace instead of Markdown. Still needed
after the collapse above, which is easy to get wrong: those three functions
cover ⌘C and the context menu, and macOS has a third way in — Edit > Copy in
the menu bar is a PredefinedMenuItem::copy (#527), which asks the webview to
perform its copy. Removed once on the reasoning that Monaco's copy had become
unreachable; it had not.

Tests

Four existing tests changed, each because the thing it described moved rather
than because it started failing — the reasons are in the commits. New
assertions hold the shape rather than the spelling: clipboard_write_text may
appear exactly twice in the component, no inline paste may appear beside the
named one, every label the menu asks for exists in all 26 languages, and focus
is restored before the work rather than after.

931 frontend tests, 145 Rust tests, npm run check clean.

Verified

macOS and Windows, by hand: right-click Cut/Copy/Paste, ⌘X/⌘C/⌘V, cut with
nothing selected (takes the whole line), the caret and undo after a menu paste,
and both restored menu entries. Linux is untested — the wry flag that kills
cut and copy there is read from the same attribute, and this change removes the
dependency rather than relying on it, but nobody has run it.

PathGao added 5 commits August 8, 2026 17:52
Right-clicking in the editor and choosing Paste did nothing. The menu half of
that report was fixed in #266; this is the clipboard itself, and the answer
turned out to be smaller than the diagnosis.

Markpad is a webview, not Electron, so Monaco's `platform.isNative` is false
and its clipboard code takes the branch written for the web. There it decides
whether to register a Paste action at all by asking whether the API *exists*:

```js
const supportsPaste = (typeof navigator.clipboard === 'undefined' || isFirefox)
    ? document.queryCommandSupported('paste') : true;
```

`navigator.clipboard` is an object in any Chromium webview, so the action
registers and the menu item is drawn — the check is never for permission.
`readText()` then rejects, `BrowserClipboardService` catches it and returns
`''`, and `if (clipboardText !== '')` declines to paste. No error, no feedback.

wry leaves the webview's `clipboard` attribute off, which on Windows means the
WebView2 `PermissionRequested` handler allowing CLIPBOARD_READ is never
registered. Turning it on is the obvious fix, the plumbing is intact all the
way down — `enable_clipboard_access()` → `webview_attributes.clipboard` →
`.with_clipboard(...)` → `add_PermissionRequested(...)` — and **it does not
work**. Built it, ran it on Windows, right-clicked Paste: still nothing.

tauri-apps/tauri#12007 has been open since December 2024 on exactly this, and
the answer the ecosystem gives is the one Markpad already had for ⌘V: go
through Rust. The official `tauri-plugin-clipboard-manager` exists to be "an
alternative to the native navigator.clipboard methods".

Three operations had six implementations. ⌘X was the browser's, ⌘C and ⌘V were
ours through Rust, and the three menu items were Monaco's — of which Paste
cannot work in a webview at all, and Cut and Copy are dead on Linux, where the
same wry default gates `set_javascript_can_access_clipboard`.

Now: three functions, six entry points.

```
        keyboard                     menu
cut     ⌘X ─┐                   Cut ─┐
copy    ⌘C ─┼→ cutToClipboard   Copy ┼→ same three functions
paste   ⌘V ─┘  copyToClipboard  Paste┘
               pasteFromClipboard          → invoke → arboard
```

Both halves are free to take. Monaco leaves ⌘X/⌘C/⌘V unbound in a browser by
design ("browsers do that for us" — clipboard.js), so the key slots were
available. And its editor context menu holds nothing else this app can use:
`gotoSymbol` and `inlayHints` need language providers Markpad does not
register, and Copy As / Share are empty in standalone. So `contextmenu: false`,
and the menu the preview pane already had is drawn for the editor too —
already translated six ways, already styled, and now the same on both sides.

That also settles what #266 could not. Its fix was to stop covering Monaco's
menu, which was right for the overlay bug and left the reader pointed at a
menu whose Paste silently did nothing.

Nothing here depends on a webview permission, and nothing imports a Monaco
internal, so there is no upgrade that can quietly take it away.

Three existing tests changed, each because the thing it described moved:

- `issue261EditorPdf` pinned "Monaco must receive the event before the document
  menu prevents it". The invariant is now that the editor's branch runs before
  every other rule — including the carve-out that leaves text fields their
  native menu, which would otherwise claim every right-click in the editor,
  because Monaco takes input through a hidden `<textarea>`.
- `editorOptionWiring` pinned the whole-line-on-empty-selection rule inside
  `custom-copy`. It lives in `clipboardTextForSelection` now, where cut reads
  it too — a cut has to remove exactly what a copy would have taken. A new
  assertion holds the line: `clipboard_write_text` may appear exactly twice in
  the component.
- `imageUndoKeepsFile` located the paste body as "the `addCommand` callback
  that reads a clipboard image". It is a named function now, and the lookup
  asserts no inline paste has appeared beside it.
Drawing the editor's context menu ourselves took away everything Monaco used
to contribute to it, and two of those worked: **Command Palette** and **Change
All Occurrences**. Neither needs a language provider, so both were there in
every build, and both disappeared. Found by looking, not by a test.

The rest of what Monaco offers there — Go to Symbol, Quick Fix, Refactor,
Format Document, Rename — is gated on providers Markdown has none of and never
appeared in this app, which is what made the earlier claim that "nothing else
is usable" wrong rather than merely imprecise. The audit that produced it
searched for `MenuId.EditorContext`, and standalone actions register through
`contextMenuOpts` instead.

They come back translated, which is a gain rather than parity: Monaco's menu is
English whatever language the app is in.

`menu.commandPalette` already existed — the shortcuts pane uses it — so it is
reused rather than declared a second time in the same object, where the later
one would have silently won.

`menu.changeAllOccurrences` is new, in all 26 languages. A missing key does not
throw: `t()` falls back to English and then to the key itself, so a forgotten
locale ships either an English label between translated siblings or the literal
string `menu.changeAllOccurrences`. Its neighbours (cut, copy, paste,
commandPalette) all carry 26, and the new test holds every label this menu asks
for to that count.
Monaco writes a styled `text/html` flavour beside the plain text on a copy.
Everything Markpad produces IS plain text, so pasting into Word or Outlook gave
coloured monospace instead of the Markdown that was copied — the styled flavour
has no audience here. Item 3 of the audit in #393; `false` is what that issue
recommends.

It was in the first draft of this branch and taken out when cut, copy and paste
were collapsed onto three functions of our own, on the reasoning that Monaco's
own copy had become unreachable. It had not.

Those three cover ⌘X/⌘C/⌘V and the editor's context menu. macOS has a third way
in that reaches none of them: Edit > Copy in the menu bar is a
`PredefinedMenuItem::copy` (#527), which asks the WEBVIEW to perform its own
copy. So without this option the menu bar puts a different clipboard on the
pasteboard than the other two routes do, from the same selection.

The test says that rather than saying the option is set, and asserts the menu
bar route still exists — so if `PredefinedMenuItem::copy` ever goes, this comes
up for review instead of sitting there as an option nobody remembers the reason
for.
Monaco's context menu showed a shortcut next to every item and ours showed
none — a regression from drawing the menu ourselves, and the kind that is
invisible until someone goes looking for a command they used to reach that way.

Only the bottom two get one. Command Palette is `F1` and Change All
Occurrences is `Mod+F2`, and for the second in particular the menu entry is
most of how anyone learns the chord exists. Cut, copy and paste do not: those
are OS conventions rather than app shortcuts — `shortcuts.ts` says so about the
same three — and printing them costs a column of width in every language to
tell people something they already know.

`formatChord` rather than two literals, so the Mac and Windows spellings cannot
drift apart. Confirmed against Monaco's own registrations: `KeyCode.F1` with no
modifier, and `KeyMod.CtrlCmd | KeyCode.F2`.
Reported from Windows as "⌘Z stopped working". It had not: paste from the
context menu inserted the text and left focus on the menu item, so there was no
caret and the next keystroke went to the document instead of the editor. The
visible symptom named a different feature than the broken one.

⌘X/⌘C/⌘V never needed a `focus()` — a keybinding fires with the editor focused
by definition — which is exactly why it was missing once those same functions
were given a second entry point. `cutToClipboard` was written fresh and had it;
`pasteFromClipboard` was lifted whole out of the ⌘V command and carried the
assumption with it.

All three focus first now, matching `runEditorAction`, and the test asserts the
order rather than the presence: focusing after the work leaves the same gap for
anything that reads the selection.
@PathGao
PathGao merged commit 35beb12 into master Aug 8, 2026
7 checks passed
@PathGao
PathGao deleted the fix/webview-clipboard branch August 8, 2026 10:33
PathGao added a commit that referenced this pull request Aug 8, 2026
Reported by PathGao in #549: selecting ten characters in the preview and copying
put 19MB on the macOS pasteboard, from an 805-byte document.

#548 gave cut, copy and paste one implementation each and six entry points. Two
entry points stayed outside it, because both read a DOM selection rather than the
editor's: Cmd+C in the preview, where Monaco's keybindings are gated on
`editorTextFocus` and never fire, and Edit > Copy in the menu bar, which is a
`PredefinedMenuItem::copy` and asks the webview to perform its own copy.

Mechanism. WKWebView's copy writes a WebArchive beside the text, and a WebArchive
is Safari's save-the-whole-page format: main resource plus subresource bytes. For
a copy the main resource is just the selection's markup, but
`LegacyWebArchive::createFromSelection` then does this (`LegacyWebArchive.cpp:752`):

    if (options.shouldSaveScriptsFromMemoryCache == Yes && responseURL.protocolIsInHTTPFamily()) {
        RegistrableDomain domain { responseURL };
        MemoryCache::singleton().forEachSessionResource(sessionID, [&](auto& resource) {
            if (domain.matches(resource.url()) && resource.hasClients()
                && (resource.type() == Script || resource.type() == JSON))
                subresourceURLs.add(resource.url());
        });
    }

Every cached script for the origin, whatever was selected. Not the stylesheets or
fonts the report guessed at: `addSubresourcesForCSSStyleSheetsIfNecessary` returns
early unless `options.mainResourceFileName` is set, which it is not for a copy.

The gate is `protocolIsInHTTPFamily()`, and it is the whole story. Under
`npm run tauri dev` the page is `http://localhost:1420` and Vite serves the module
graph unbundled and unminified (`node_modules/.vite/deps` is 54MB, monaco's ESM
tree 1,227 modules), so the sweep collects nearly all of it. A shipped build gets
`tauri://localhost` — `get_app_url` falls through to `tauri_protocol_url`,
`tauri-2.10.2/src/manager/mod.rs:331` — which is not in the http family, so the
sweep never runs. Same binary, same WebKit, different origin.

Measured on this machine, same file, same gesture:

    tauri build --debug   utf8 70                                       (no weba)
    tauri dev             weba 19,081,919  RTF 616  HTML 603  utf8 79

So the 19MB never reached a shipped build. What did reach it is a plain-text
clipboard that nobody chose: it is plain only because the scheme closes that gate,
and no code or test says so.

It also does not scale with the document, which #549 listed as unestablished. Two
selections in the same 805-byte file:

    utf8 79 -> weba 19,081,919
    utf8 73 -> weba 19,081,913

The archive shrank by exactly the six bytes the text did. Everything else in it is
constant, so `samples/stress-test.md` at 121,794 bytes gives the same number.

The fix is the branch WebKit takes first. `Editor::copy` calls `tryDHTMLCopy()`
before `performCutOrCopy`, so a cancelled copy event means the archive is never
built, and WebKit then commits what the handler set as real `public.html` /
`public.utf8-plain-text` flavours (`PlatformPasteboardMac.mm:339`). That check
does not look at the origin, so this makes the plain-text result ours on both
builds rather than a property of the scheme an upstream change could take back.

    tauri dev, after   utf8 64  ut16 46                                 (no weba)

which is the flavour shape a shipped build already had. Measurements in dev now
represent the shipped app — this issue was two agents' work to establish as
dev-only, and that class of false alarm is gone.

Document level rather than the preview's `<article>`: `styles.css:16` puts
`user-select: none` on the app root, and only two places turn it back on — the
preview, and the update dialog's release notes (#532). One listener covers both.

The carve-out mirrors WebKit's own. `Editor::performCutOrCopy` (`Editor.cpp:1619`)
sends a selection inside a form control to `writePlainText` and never builds an
archive, and `window.getSelection()` cannot read such a selection, so cancelling
there would copy an empty string. Monaco takes input through a hidden textarea and
is covered by the same test, which is what keeps the editor on its own Rust path.
Verified rather than assumed: Edit > Copy with the caret in the editor gives
utf8 51 and no weba.

Scope. Plain text, not `text/html`: every route in this app already puts plain
text on the clipboard, and adding a rich flavour would give shipped builds
formatting they have never produced. Whether the preview *should* paste with
formatting is the product question #549 raises and is left open — it is a change
in behaviour, not a fix. No platform gate: the plain-text result is what all three
platforms already produce, so there is nothing to preserve for WebView2 or
WebKitGTK, neither of which writes a page-snapshot flavour at all
(`Pasteboard.h:88` keeps `dataInWebArchiveFormat` under `PLATFORM(COCOA)`).

Tests. `scripts/previewCopyPlainText.test.ts`. Revert the handler and the file
goes red — `functionSource` cannot find it. The assertion worth keeping is
`doesNotMatch(handler, /text\/html/)`: it fails if someone turns this into a rich
copy, which would be the product decision above arriving as a refactor.

Verification. npm audit: 0 vulnerabilities. npm run check: 674 files, 0 errors.
npm test: 940 pass, 0 fail. cargo test: 145 passed. Clipboard measurements above
taken with `osascript -e 'clipboard info'` against both builds, with a sentinel
written to the pasteboard between readings so a copy that did not fire could not
be mistaken for one that did.

Not verified: Windows and Linux, reasoned about from WebCore's platform guards and
not run. The update dialog's release notes are covered by the same listener but
were not measured. Preview Edit > Copy was measured in dev but only inferred in a
shipped build, from sharing `Editor::copy` with Cmd+C, which was measured there.

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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