gpui_web: Implement clipboard write and paste (#60890)

# Objective

write_to_clipboard was a no-op and read_from_clipboard returned None.

## Solution

Writes go through the browser's asynchronous Clipboard API,
fire-and-forget, invoked synchronously inside the user's input event so
the user-activation requirement holds.

Reads cannot be implemented the same way: the async clipboard read API
cannot fit read_from_clipboard's synchronous signature. Paste is instead
delivered by a DOM paste listener on the hidden input, whose
ClipboardEvent exposes clipboardData synchronously; the text feeds the
active input handler. This covers keyboard-, menu-bar- and
context-menu-initiated paste. Note the keyboard path requires the paste
chord's keydown to reach the browser unconsumed (apps should not bind
cmd/ctrl-v on web).

## Testing

Tested locally

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
This commit is contained in:
Baris Palaska 2026-07-13 15:22:44 +01:00 committed by GitHub
parent 8906914149
commit 084b5d21f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 38 additions and 1 deletions

View file

@ -35,6 +35,8 @@ raw-window-handle = "0.6"
wasm_thread = { version = "0.3", features = ["es_modules"], optional = true }
web-sys = { version = "0.3", features = [
"console",
"Clipboard",
"ClipboardEvent",
"CompositionEvent",
"CssStyleDeclaration",
"DataTransfer",

View file

@ -64,6 +64,7 @@ impl WebWindowInner {
self.register_dragleave(),
self.register_key_down(),
self.register_key_up(),
self.register_paste(),
self.register_composition_start(),
self.register_composition_update(),
self.register_composition_end(),
@ -427,6 +428,32 @@ impl WebWindowInner {
})
}
/// Paste is delivered through the DOM `paste` event rather than
/// `Platform::read_from_clipboard`: the browser's asynchronous clipboard
/// read API cannot fit that synchronous signature, while `ClipboardEvent`
/// exposes `clipboardData` synchronously inside the event. It fires for
/// any browser-initiated paste (keyboard, menu bar, context menu).
fn register_paste(self: &Rc<Self>) -> Closure<dyn FnMut(JsValue)> {
let this = Rc::clone(self);
self.listen_input("paste", move |event: JsValue| {
let event: web_sys::ClipboardEvent = event.unchecked_into();
let Some(clipboard_data) = event.clipboard_data() else {
return;
};
let Ok(text) = clipboard_data.get_data("text/plain") else {
return;
};
if text.is_empty() {
return;
}
event.prevent_default();
this.with_input_handler(|handler| {
handler.replace_text_in_range(None, &text);
});
})
}
fn register_composition_start(self: &Rc<Self>) -> Closure<dyn FnMut(JsValue)> {
let this = Rc::clone(self);
self.listen_input("compositionstart", move |_event: JsValue| {

View file

@ -342,7 +342,15 @@ impl Platform for WebPlatform {
None
}
fn write_to_clipboard(&self, _item: ClipboardItem) {}
fn write_to_clipboard(&self, item: ClipboardItem) {
if let Some(text) = item.text()
&& let Some(window) = web_sys::window()
{
// Fire-and-forget; called synchronously inside the user's input
// event, which satisfies the browser's user-activation requirement.
drop(window.navigator().clipboard().write_text(&text));
}
}
fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task<Result<()>> {
Task::ready(Err(anyhow::anyhow!(