mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent
79c52166cf
commit
25a9cf1be6
@@ -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())?
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user