From 25a9cf1be6d245fbd7373cb1160dbc790baf5bd5 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Tue, 4 Aug 2026 16:16:14 -0700 Subject: [PATCH] feat: paste composer text without formatting (#4801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - handle Cmd+Shift+V on macOS and Ctrl+Shift+V on Windows/Linux in the message composer - read plain text through the native Tauri/arboard clipboard path in packaged builds, with a browser-only Clipboard API fallback - re-enter ProseMirror's paste pipeline with populated `text/plain` clipboard data so selection, undo, multiline behavior, and paste observers remain intact - cover both platform mappings with rendered composer E2E tests that assert the native command path ## Testing - `pnpm test` — 4,286 passed - `pnpm check` - `pnpm typecheck` - `pnpm exec playwright test composer-selection-formatting.spec.ts --project=smoke` — 26 passed - `cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace --all-targets --target aarch64-apple-darwin` - `just desktop-tauri-test` — 2,206 core tests plus integration and doc-test groups passed - full pre-push hooks passed ## Manual verification Physical packaged-app clipboard verification remains recommended on macOS, Windows, and Linux. The automated E2E uses mocked Tauri IPC but asserts the native `read_clipboard_text` command is invoked. Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/commands/clipboard.rs | 19 ++++++ desktop/src-tauri/src/lib.rs | 1 + .../messages/lib/useRichTextEditor.ts | 34 ++++++++++ desktop/src/shared/api/tauriMedia.ts | 19 +++++- desktop/src/testing/e2eBridge.ts | 2 + .../e2e/composer-selection-formatting.spec.ts | 62 +++++++++++++++++++ 6 files changed, 136 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/commands/clipboard.rs b/desktop/src-tauri/src/commands/clipboard.rs index b4fe072ef..c904e4a2d 100644 --- a/desktop/src-tauri/src/commands/clipboard.rs +++ b/desktop/src-tauri/src/commands/clipboard.rs @@ -34,3 +34,22 @@ pub fn with_clipboard( operation(stored.as_mut().expect("clipboard initialized")) .map_err(|e| format!("clipboard error: {e}")) } + +/// Read plain text from the system clipboard through the native shell. +/// +/// Browser clipboard reads are permission-gated or unavailable in embedded +/// webviews. Arboard provides one consistent path across WKWebView, WebView2, +/// and WebKitGTK. The operation runs on the main thread for macOS/AppKit safety. +#[tauri::command] +pub async fn read_clipboard_text(app: tauri::AppHandle) -> Result { + let (tx, rx) = std::sync::mpsc::sync_channel::>(1); + let clipboard_app = app.clone(); + app.run_on_main_thread(move || { + let result = with_clipboard(&clipboard_app, arboard::Clipboard::get_text); + let _ = tx.send(result); + }) + .map_err(|e| format!("main thread dispatch failed: {e}"))?; + + rx.recv() + .map_err(|_| "clipboard result channel closed unexpectedly".to_string())? +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1e73b1523..a7c191c43 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -732,6 +732,7 @@ pub fn run() { fetch_media_bytes, copy_image_to_clipboard, copy_text_to_clipboard, + read_clipboard_text, fetch_snapshot_bytes, relay_requires_membership, list_relay_members, diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 4fee2db46..c761081cc 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -9,6 +9,7 @@ import { Extension, type KeyboardShortcutCommand } from "@tiptap/core"; import { Plugin, Selection, TextSelection } from "@tiptap/pm/state"; import type { ResolvedPos } from "@tiptap/pm/model"; +import { readTextFromSystemClipboard } from "@/shared/api/tauriMedia"; import { hasPrimaryShortcutModifier, isMacPlatform, @@ -532,6 +533,39 @@ export function useRichTextEditor({ return true; } + // Cmd+Shift+V / Ctrl+Shift+V → paste the clipboard's plain-text + // representation. Embedded webviews permission-gate the browser + // clipboard API differently across operating systems, so packaged + // builds read through the native arboard command. Browser builds use + // navigator.clipboard as a fallback. Feed the result through + // ProseMirror's paste pipeline with clipboardData populated so its + // plain-text observers keep normal paste behavior. + if ( + event.key.toLowerCase() === "v" && + hasPrimaryShortcutModifier(event) && + event.shiftKey && + !event.altKey && + !event.repeat && + !event.isComposing + ) { + event.preventDefault(); + void readTextFromSystemClipboard() + .then((text) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + view.pasteText( + text, + new ClipboardEvent("paste", { clipboardData }), + ); + }) + .catch(() => { + // The key is already consumed. Letting a delayed native paste + // race the asynchronous read could duplicate or unexpectedly + // format content. + }); + return true; + } + // ⌘K / Ctrl+K → link editor. The formatting toolbar has always // advertised this shortcut on its link button; bind it here so it // actually works. Kept alongside the ArrowUp handling below rather diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index 60205a45f..daedebde5 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -1,4 +1,4 @@ -import { invoke as invokeTauriRaw } from "@tauri-apps/api/core"; +import { invoke as invokeTauriRaw, isTauri } from "@tauri-apps/api/core"; import { type BlobDescriptor, invokeTauri } from "./tauri"; function encodeRawIpcHeader(value: string): string { @@ -66,6 +66,23 @@ export async function fetchMediaBytes( return new Uint8Array(bytes); } +/** Read plain text without depending on embedded-webview clipboard grants. */ +export async function readTextFromSystemClipboard(): Promise { + // E2E installs Tauri's mocked IPC surface in a browser page, where the SDK's + // `isTauri()` marker remains false. Exercise the packaged-app command path in + // that build so tests detect accidental regressions to permission-gated DOM + // clipboard reads. + if (isTauri() || import.meta.env.MODE === "e2e") { + return invokeTauri("read_clipboard_text"); + } + + const clipboard = navigator.clipboard; + if (!clipboard?.readText) { + throw new Error("Clipboard text reading is unavailable"); + } + return clipboard.readText(); +} + /** Write text through the native clipboard after an asynchronous workflow. */ export async function copyTextToSystemClipboard( text: string, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index cf05ce6f1..204f67f51 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12516,6 +12516,8 @@ export function maybeInstallE2eTauriMocks() { case "copy_text_to_clipboard": await navigator.clipboard.writeText((payload as { text: string }).text); return; + case "read_clipboard_text": + return navigator.clipboard.readText(); case "get_event": return handleGetEvent( payload as Parameters[0], diff --git a/desktop/tests/e2e/composer-selection-formatting.spec.ts b/desktop/tests/e2e/composer-selection-formatting.spec.ts index b8ab46503..202ec9686 100644 --- a/desktop/tests/e2e/composer-selection-formatting.spec.ts +++ b/desktop/tests/e2e/composer-selection-formatting.spec.ts @@ -177,6 +177,68 @@ async function applyCaretFormat( await page.getByRole("button", { name: label, exact: true }).click(); } +for (const platform of [ + { name: "macOS", navigatorPlatform: "MacIntel", shortcut: "Meta+Shift+V" }, + { + name: "Windows/Linux", + navigatorPlatform: "Win32", + shortcut: "Control+Shift+V", + }, +]) { + test(`pastes rich clipboard content without formatting on ${platform.name}`, async ({ + page, + }) => { + await page.addInitScript((navigatorPlatform) => { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: navigatorPlatform, + }); + }, platform.navigatorPlatform); + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"], { + origin: "http://127.0.0.1:4173", + }); + await openGeneral(page); + + await page.evaluate(async () => { + await navigator.clipboard.write([ + new ClipboardItem({ + "text/html": new Blob( + [ + '

Bold and linked

  • list item
', + ], + { type: "text/html" }, + ), + "text/plain": new Blob(["Bold and linked\nlist item"], { + type: "text/plain", + }), + }), + ]); + }); + + const input = page.getByTestId("message-input"); + await input.click(); + await page.keyboard.press(platform.shortcut); + + await expect + .poll(() => + page.evaluate( + () => + (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) + .__BUZZ_E2E_COMMANDS__ ?? [], + ), + ) + .toContain("read_clipboard_text"); + await expect(input).toHaveText("Bold and linkedlist item"); + await expect(input.locator("strong, a, ul, li")).toHaveCount(0); + await expect(input.locator(":scope > p")).toHaveText([ + "Bold and linked", + "list item", + ]); + }); +} + for (const format of [ { label: "Code block", selector: "pre" }, { label: "Bullet list", selector: "ul" },