From c4de066d22e6383bb9d563e5716c075adce3ba0e Mon Sep 17 00:00:00 2001 From: adhoc Date: Wed, 10 Jun 2026 17:57:30 +0200 Subject: [PATCH] implemement wasm clipboard --- winit/Cargo.toml | 16 ++- winit/src/clipboard.rs | 305 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 311 insertions(+), 10 deletions(-) diff --git a/winit/Cargo.toml b/winit/Cargo.toml index 7024dec320..804721e041 100644 --- a/winit/Cargo.toml +++ b/winit/Cargo.toml @@ -43,7 +43,21 @@ arboard.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] web-sys.workspace = true -web-sys.features = ["Document", "Window", "HtmlCanvasElement"] +web-sys.features = [ + "Blob", + "BlobPropertyBag", + "Clipboard", + "ClipboardItem", + "Document", + "HtmlCanvasElement", + "ImageBitmap", + "ImageData", + "ImageEncodeOptions", + "Navigator", + "OffscreenCanvas", + "OffscreenCanvasRenderingContext2d", + "Window", +] wasm-bindgen-futures.workspace = true [target.'cfg(target_os = "linux")'.dependencies] diff --git a/winit/src/clipboard.rs b/winit/src/clipboard.rs index 30e4b37d13..ca5a030a7a 100644 --- a/winit/src/clipboard.rs +++ b/winit/src/clipboard.rs @@ -151,29 +151,316 @@ mod platform { } } -// TODO: Wasm support #[cfg(target_arch = "wasm32")] mod platform { use super::*; + use std::sync::Arc; + + use wasm_bindgen_futures::{JsFuture, spawn_local}; + use web_sys::js_sys::{Array, Object, Reflect, Uint8Array}; + use web_sys::wasm_bindgen::{JsCast, JsValue}; + use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; + /// A buffer for short-term storage and transfer within and between /// applications. - pub struct Clipboard; + pub struct Clipboard { + clipboard: Option, + } impl Clipboard { /// Creates a new [`Clipboard`] for the given window. pub fn new() -> Self { - Self + let clipboard = web_sys::window() + .map(|window| window.navigator().clipboard()); + + Self { clipboard } } - /// Reads the current content of the [`Clipboard`] as text. - pub fn read(&self, _kind: Kind, callback: impl FnOnce(Result)) { - callback(Err(Error::ClipboardUnavailable)); + /// Reads the current content of the [`Clipboard`]. + pub fn read( + &self, + kind: Kind, + callback: impl FnOnce(Result) + 'static, + ) { + let Some(clipboard) = self.clipboard.clone() else { + callback(Err(Error::ClipboardUnavailable)); + return; + }; + + spawn_local(async move { + let result = match kind { + Kind::Text => { + read_text(&clipboard).await.map(Content::Text) + } + Kind::Html => { + read_html(&clipboard).await.map(Content::Html) + } + #[cfg(feature = "image")] + Kind::Image => { + read_image(&clipboard).await.map(Content::Image) + } + Kind::Files => Err(Error::ContentNotAvailable), + kind => { + log::warn!("unsupported clipboard kind: {kind:?}"); + + Err(Error::ContentNotAvailable) + } + }; + + callback(result); + }); } - /// Writes the given text contents to the [`Clipboard`]. - pub fn write(&mut self, _content: Content, callback: impl FnOnce(Result<(), Error>)) { - callback(Err(Error::ClipboardUnavailable)); + /// Writes the given contents to the [`Clipboard`]. + pub fn write( + &mut self, + content: Content, + callback: impl FnOnce(Result<(), Error>) + 'static, + ) { + let Some(clipboard) = self.clipboard.clone() else { + callback(Err(Error::ClipboardUnavailable)); + return; + }; + + spawn_local(async move { + let result = match content { + Content::Text(text) => write_text(&clipboard, &text).await, + Content::Html(html) => write_html(&clipboard, &html).await, + #[cfg(feature = "image")] + Content::Image(image) => { + write_image(&clipboard, image).await + } + content => { + log::warn!("unsupported clipboard content: {content:?}"); + + Err(Error::ClipboardUnavailable) + } + }; + + callback(result); + }); + } + } + + async fn read_text( + clipboard: &web_sys::Clipboard, + ) -> Result { + let value = JsFuture::from(clipboard.read_text()) + .await + .map_err(js_error)?; + + value.as_string().ok_or(Error::ConversionFailure) + } + + async fn write_text( + clipboard: &web_sys::Clipboard, + text: &str, + ) -> Result<(), Error> { + let _ = JsFuture::from(clipboard.write_text(text)) + .await + .map_err(js_error)?; + + Ok(()) + } + + async fn read_html( + clipboard: &web_sys::Clipboard, + ) -> Result { + let blob = read_item_blob(clipboard, "text/html").await?; + let buffer = JsFuture::from(blob.array_buffer()) + .await + .map_err(js_error)?; + let bytes = Uint8Array::new(&buffer).to_vec(); + + String::from_utf8(bytes).map_err(|_| Error::ConversionFailure) + } + + async fn write_html( + clipboard: &web_sys::Clipboard, + html: &str, + ) -> Result<(), Error> { + let blob = blob_from_str(html, "text/html")?; + + write_item(clipboard, "text/html", &blob).await + } + + #[cfg(feature = "image")] + async fn read_image( + clipboard: &web_sys::Clipboard, + ) -> Result { + use web_sys::wasm_bindgen::Clamped; + + let blob = read_item_blob(clipboard, "image/png").await?; + + let window = web_sys::window().ok_or(Error::ClipboardUnavailable)?; + + let bitmap = JsFuture::from( + window + .create_image_bitmap_with_blob(&blob) + .map_err(js_error)?, + ) + .await + .map_err(js_error)? + .dyn_into::() + .map_err(|_| Error::ConversionFailure)?; + + let width = bitmap.width(); + let height = bitmap.height(); + + let canvas = web_sys::OffscreenCanvas::new(width, height) + .map_err(js_error)?; + + let context = canvas + .get_context("2d") + .map_err(js_error)? + .ok_or(Error::ConversionFailure)? + .dyn_into::() + .map_err(|_| Error::ConversionFailure)?; + + context + .draw_image_with_image_bitmap(&bitmap, 0.0, 0.0) + .map_err(js_error)?; + + let image_data = context + .get_image_data(0.0, 0.0, width as _, height as _) + .map_err(js_error)?; + + let Clamped(rgba) = image_data.data(); + + Ok(crate::core::clipboard::Image { + rgba: crate::core::Bytes::from_owner(rgba), + size: crate::core::Size { width, height }, + }) + } + + #[cfg(feature = "image")] + async fn write_image( + clipboard: &web_sys::Clipboard, + image: crate::core::clipboard::Image, + ) -> Result<(), Error> { + use web_sys::wasm_bindgen::Clamped; + + let crate::core::clipboard::Image { rgba, size } = image; + let width = size.width; + let height = size.height; + + let canvas = web_sys::OffscreenCanvas::new(width, height) + .map_err(js_error)?; + + let context = canvas + .get_context("2d") + .map_err(js_error)? + .ok_or(Error::ConversionFailure)? + .dyn_into::() + .map_err(|_| Error::ConversionFailure)?; + + let image_data = + web_sys::ImageData::new_with_u8_clamped_array_and_sh( + Clamped(rgba.as_ref()), + width, + height, + ) + .map_err(js_error)?; + + context + .put_image_data(&image_data, 0.0, 0.0) + .map_err(js_error)?; + + let options = web_sys::ImageEncodeOptions::new(); + options.set_type("image/png"); + + let blob = JsFuture::from( + canvas + .convert_to_blob_with_options(&options) + .map_err(js_error)?, + ) + .await + .map_err(js_error)? + .dyn_into::() + .map_err(|_| Error::ConversionFailure)?; + + write_item(clipboard, "image/png", &blob).await + } + + async fn read_item_blob( + clipboard: &web_sys::Clipboard, + mime: &str, + ) -> Result { + let items = JsFuture::from(clipboard.read()) + .await + .map_err(js_error)? + .dyn_into::() + .map_err(|_| Error::ConversionFailure)?; + + for index in 0..items.length() { + let Ok(item) = items.get(index).dyn_into::() else { + continue; + }; + + let Ok(blob_value) = JsFuture::from(item.get_type(mime)).await + else { + continue; + }; + + return blob_value + .dyn_into::() + .map_err(|_| Error::ConversionFailure); + } + + Err(Error::ContentNotAvailable) + } + + fn blob_from_str(content: &str, mime: &str) -> Result { + let parts = Array::new(); + let _ = parts.push(&JsValue::from_str(content)); + + let options = BlobPropertyBag::new(); + options.set_type(mime); + + Blob::new_with_str_sequence_and_options(&parts, &options) + .map_err(js_error) + } + + async fn write_item( + clipboard: &web_sys::Clipboard, + mime: &str, + blob: &Blob, + ) -> Result<(), Error> { + let record = Object::new(); + let _ = Reflect::set( + &record, + &JsValue::from_str(mime), + AsRef::::as_ref(blob), + ) + .map_err(js_error)?; + + let item = + ClipboardItem::new_with_record_from_str_to_blob_promise(&record) + .map_err(js_error)?; + + let items = Array::new(); + let _ = items.push(item.as_ref()); + + let _ = JsFuture::from(clipboard.write(&items)) + .await + .map_err(js_error)?; + + Ok(()) + } + + fn js_error(value: JsValue) -> Error { + let description = value + .as_string() + .or_else(|| { + value + .dyn_ref::() + .and_then(|error| error.message().as_string()) + }) + .unwrap_or_else(|| "clipboard operation failed".to_owned()); + + Error::Unknown { + description: Arc::new(description), } } }