feat: paste composer text without formatting (#4801)

## 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>
This commit is contained in:
Kalvin C
2026-08-04 16:16:14 -07:00
committed by GitHub
co-authored by npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent 79c52166cf
commit 25a9cf1be6
6 changed files with 136 additions and 1 deletions
@@ -34,3 +34,22 @@ pub fn with_clipboard<T>(
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<String, String> {
let (tx, rx) = std::sync::mpsc::sync_channel::<Result<String, String>>(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())?
}
+1
View File
@@ -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,
@@ -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
+18 -1
View File
@@ -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<string> {
// 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<string>("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,
+2
View File
@@ -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<typeof handleGetEvent>[0],
@@ -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(
[
'<p><strong>Bold</strong> and <a href="https://example.com">linked</a></p><ul><li>list item</li></ul>',
],
{ 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" },