diff --git a/.cargo.toml b/.cargo.toml new file mode 100644 index 000000000..1ba169264 --- /dev/null +++ b/.cargo.toml @@ -0,0 +1,5 @@ +[alias] +run-wasm = ["run", "--release", "--package", "run-wasm", "--"] + +[workspace] +"run-wasm" diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..37505ddfb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +[alias] +run-wasm = ["run", "--release", "--package", "run-wasm", "--"] + +# Using unstable APIs is required for writing to clipboard +[target.wasm32-unknown-unknown] +rustflags = ["--cfg=web_sys_unstable_apis"] diff --git a/Cargo.toml b/Cargo.toml index 2599b8687..2c45e4247 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,9 +39,7 @@ name = "ui" required-features = ["render"] [dependencies] -bevy = { version = "0.13", default-features = false, features = [ - "bevy_asset", -] } +bevy = { version = "0.13", default-features = false, features = ["bevy_asset"] } egui = { version = "0.26", default-features = false, features = ["bytemuck"] } webbrowser = { version = "0.8.2", optional = true } @@ -49,9 +47,6 @@ webbrowser = { version = "0.8.2", optional = true } arboard = { version = "3.2.0", optional = true } thread_local = { version = "1.1.0", optional = true } -[target.'cfg(target_arch = "wasm32")'.dependencies] -web-sys = { version = "0.3.63", features = ["Navigator"] } - [dev-dependencies] version-sync = "0.9.4" bevy = { version = "0.13", default-features = false, features = [ @@ -60,4 +55,26 @@ bevy = { version = "0.13", default-features = false, features = [ "bevy_pbr", "bevy_core_pipeline", "tonemapping_luts", + "webgl2", ] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +winit = "0.29" +web-sys = { version = "0.3.63", features = [ + "Clipboard", + "ClipboardEvent", + "DataTransfer", + 'Document', + 'EventTarget', + "Window", + "Navigator", +] } +js-sys = "0.3.63" +wasm-bindgen = "0.2.84" +wasm-bindgen-futures = "0.4.36" +console_log = "1.0.0" +log = "0.4" +crossbeam-channel = "0.5.8" + +[workspace] +members = ["run-wasm"] diff --git a/examples/ui.rs b/examples/ui.rs index 91d8174bb..6592fb4ae 100644 --- a/examples/ui.rs +++ b/examples/ui.rs @@ -25,7 +25,13 @@ fn main() { .insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.0))) .insert_resource(Msaa::Sample4) .init_resource::() - .add_plugins(DefaultPlugins) + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + prevent_default_event_handling: false, + ..default() + }), + ..default() + })) .add_plugins(EguiPlugin) .add_systems(Startup, configure_visuals_system) .add_systems(Startup, configure_ui_state_system) diff --git a/run-wasm/Cargo.toml b/run-wasm/Cargo.toml new file mode 100644 index 000000000..3ac77b26d --- /dev/null +++ b/run-wasm/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "run-wasm" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +cargo-run-wasm = "0.2.0" diff --git a/run-wasm/src/main.rs b/run-wasm/src/main.rs new file mode 100644 index 000000000..6961358d9 --- /dev/null +++ b/run-wasm/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + cargo_run_wasm::run_wasm_with_css("body { margin: 0px; }"); +} diff --git a/src/lib.rs b/src/lib.rs index f125cfbeb..a25a195e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -60,7 +60,13 @@ pub mod systems; #[cfg(feature = "render")] pub mod egui_node; +/// Clipboard management for web +#[cfg(all(feature = "manage_clipboard", target_arch = "wasm32"))] +pub mod web_clipboard; + pub use egui; +#[cfg(all(feature = "manage_clipboard", target_arch = "wasm32"))] +use web_clipboard::{WebEventCopy, WebEventCut, WebEventPaste}; use crate::systems::*; #[cfg(feature = "render")] @@ -179,8 +185,15 @@ pub struct EguiInput(pub egui::RawInput); pub struct EguiClipboard { #[cfg(not(target_arch = "wasm32"))] clipboard: ThreadLocal>>, + /// for copy events. + #[cfg(target_arch = "wasm32")] + pub web_copy: web_clipboard::WebChannel, + /// for copy events. + #[cfg(target_arch = "wasm32")] + pub web_cut: web_clipboard::WebChannel, + /// for paste events, only supporting strings. #[cfg(target_arch = "wasm32")] - clipboard: String, + pub web_paste: web_clipboard::WebChannel, } #[cfg(all(feature = "manage_clipboard", not(target_os = "android")))] @@ -192,12 +205,20 @@ impl EguiClipboard { /// Gets clipboard contents. Returns [`None`] if clipboard provider is unavailable or returns an error. #[must_use] - pub fn get_contents(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + pub fn get_contents(&mut self) -> Option { + self.get_contents_impl() + } + + /// Gets clipboard contents. Returns [`None`] if clipboard provider is unavailable or returns an error. + #[must_use] + #[cfg(target_arch = "wasm32")] + pub fn get_contents(&mut self) -> Option { self.get_contents_impl() } #[cfg(not(target_arch = "wasm32"))] - fn set_contents_impl(&self, contents: &str) { + fn set_contents_impl(&mut self, contents: &str) { if let Some(mut clipboard) = self.get() { if let Err(err) = clipboard.set_text(contents.to_owned()) { log::error!("Failed to set clipboard contents: {:?}", err); @@ -207,15 +228,15 @@ impl EguiClipboard { #[cfg(target_arch = "wasm32")] fn set_contents_impl(&mut self, contents: &str) { - self.clipboard = contents.to_owned(); + web_clipboard::clipboard_copy(contents.to_owned()); } #[cfg(not(target_arch = "wasm32"))] - fn get_contents_impl(&self) -> Option { + fn get_contents_impl(&mut self) -> Option { if let Some(mut clipboard) = self.get() { match clipboard.get_text() { Ok(contents) => return Some(contents), - Err(err) => log::info!("Failed to get clipboard contents: {:?}", err), + Err(err) => log::error!("Failed to get clipboard contents: {:?}", err), } }; None @@ -223,8 +244,8 @@ impl EguiClipboard { #[cfg(target_arch = "wasm32")] #[allow(clippy::unnecessary_wraps)] - fn get_contents_impl(&self) -> Option { - Some(self.clipboard.clone()) + fn get_contents_impl(&mut self) -> Option { + self.web_paste.try_read_clipboard_event().map(|e| e.0) } #[cfg(not(target_arch = "wasm32"))] @@ -234,7 +255,7 @@ impl EguiClipboard { Clipboard::new() .map(RefCell::new) .map_err(|err| { - log::info!("Failed to initialize clipboard: {:?}", err); + log::error!("Failed to initialize clipboard: {:?}", err); }) .ok() }) @@ -597,6 +618,8 @@ impl Plugin for EguiPlugin { #[cfg(feature = "render")] app.add_plugins(ExtractComponentPlugin::::default()); + #[cfg(all(feature = "manage_clipboard", target_arch = "wasm32"))] + app.add_systems(PreStartup, web_clipboard::startup_setup_web_events); app.add_systems( PreStartup, ( diff --git a/src/systems.rs b/src/systems.rs index f06ffd80e..11ee98e51 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -73,7 +73,7 @@ pub struct ModifierKeysState { #[derive(SystemParam)] pub struct InputResources<'w, 's> { #[cfg(all(feature = "manage_clipboard", not(target_os = "android")))] - pub egui_clipboard: Res<'w, crate::EguiClipboard>, + pub egui_clipboard: ResMut<'w, crate::EguiClipboard>, pub modifier_keys_state: Local<'s, ModifierKeysState>, #[system_param(ignore)] _marker: PhantomData<&'w ()>, @@ -110,7 +110,7 @@ pub fn process_input_system( if let Some(window) = web_sys::window() { let nav = window.navigator(); if let Ok(user_agent) = nav.user_agent() { - if user_agent.to_ascii_lowercase().contains("Mac") { + if user_agent.to_ascii_lowercase().contains("mac") { *context_params.is_macos = true; } } @@ -130,7 +130,6 @@ pub fn process_input_system( None }; } - let mut keyboard_input_events = Vec::new(); for event in input_events.ev_keyboard_input.read() { // Copy the events as we might want to pass them to an Egui context later. @@ -149,7 +148,7 @@ pub fn process_input_system( Key::Alt => { input_resources.modifier_keys_state.alt = state.is_pressed(); } - Key::Super => { + Key::Super | Key::Meta => { input_resources.modifier_keys_state.win = state.is_pressed(); } _ => {} @@ -306,20 +305,47 @@ pub fn process_input_system( // We also check that it's an `ButtonState::Pressed` event, as we don't want to // copy, cut or paste on the key release. #[cfg(all(feature = "manage_clipboard", not(target_os = "android")))] - if command && ev.state.is_pressed() { - match key { - egui::Key::C => { + { + #[cfg(not(target_arch = "wasm32"))] + if command && ev.state.is_pressed() { + match key { + egui::Key::C => { + focused_input.events.push(egui::Event::Copy); + } + egui::Key::X => { + focused_input.events.push(egui::Event::Cut); + } + egui::Key::V => { + if let Some(contents) = + input_resources.egui_clipboard.get_contents() + { + focused_input.events.push(egui::Event::Text(contents)) + } + } + _ => {} + } + } + #[cfg(target_arch = "wasm32")] + { + if input_resources + .egui_clipboard + .web_copy + .try_read_clipboard_event() + .is_some() + { focused_input.events.push(egui::Event::Copy); } - egui::Key::X => { + if input_resources + .egui_clipboard + .web_cut + .try_read_clipboard_event() + .is_some() + { focused_input.events.push(egui::Event::Cut); } - egui::Key::V => { - if let Some(contents) = input_resources.egui_clipboard.get_contents() { - focused_input.events.push(egui::Event::Text(contents)) - } + if let Some(contents) = input_resources.egui_clipboard.get_contents() { + focused_input.events.push(egui::Event::Text(contents)); } - _ => {} } } } diff --git a/src/web_clipboard.rs b/src/web_clipboard.rs new file mode 100644 index 000000000..10ce1750d --- /dev/null +++ b/src/web_clipboard.rs @@ -0,0 +1,132 @@ +use crossbeam_channel::{Receiver, Sender}; + +use bevy::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::EguiClipboard; +use wasm_bindgen::{closure::Closure, prelude::*}; + +/// startup system for bevy to initialize web events. +pub fn startup_setup_web_events(mut clipboard_channel: ResMut) { + setup_clipboard_copy(&mut clipboard_channel.web_copy); + setup_clipboard_cut(&mut clipboard_channel.web_cut); + setup_clipboard_paste(&mut clipboard_channel.web_paste); +} + +/// To get data from web events +#[derive(Default)] +pub struct WebChannel { + rx: Option>, +} + +impl WebChannel { + /// Only returns Some if user explicitly triggered an event. Should be called each frame to react as soon as the event is fired. + pub fn try_read_clipboard_event(&mut self) -> Option { + match &mut self.rx { + Some(rx) => { + if let Ok(data) = rx.try_recv() { + return Some(data); + } + None + } + None => None, + } + } +} + +/// User provided a string to paste +#[derive(Debug, Default)] +pub struct WebEventPaste(pub String); +/// User asked to cut +#[derive(Default)] +pub struct WebEventCut; +/// Used asked to copy +#[derive(Default)] +pub struct WebEventCopy; + +fn setup_clipboard_copy(clipboard_channel: &mut WebChannel) { + let (tx, rx): (Sender, Receiver) = crossbeam_channel::bounded(1); + + let closure = Closure::::new(move |_event: web_sys::ClipboardEvent| { + let _ = tx.try_send(WebEventCopy); + }); + + let listener = closure.as_ref().unchecked_ref(); + web_sys::window() + .expect("Could not retrieve web_sys::window()") + .document() + .expect("Could not retrieve web_sys window's document") + .add_event_listener_with_callback("copy", listener) + .expect("Could not add copy event listener."); + closure.forget(); + *clipboard_channel = WebChannel:: { rx: Some(rx) }; +} + +fn setup_clipboard_cut(clipboard_channel: &mut WebChannel) { + let (tx, rx): (Sender, Receiver) = crossbeam_channel::bounded(1); + + let closure = Closure::::new(move |_event: web_sys::ClipboardEvent| { + let _ = tx.try_send(WebEventCut); + }); + + let listener = closure.as_ref().unchecked_ref(); + web_sys::window() + .expect("Could not retrieve web_sys::window()") + .document() + .expect("Could not retrieve web_sys window's document") + .add_event_listener_with_callback("cut", listener) + .expect("Could not add cut event listener."); + closure.forget(); + *clipboard_channel = WebChannel:: { rx: Some(rx) }; +} + +fn setup_clipboard_paste(clipboard_channel: &mut WebChannel) { + let (tx, rx): (Sender, Receiver) = crossbeam_channel::bounded(1); + + let closure = Closure::::new(move |event: web_sys::ClipboardEvent| { + match event + .clipboard_data() + .expect("could not get clipboard data.") + .get_data("text/plain") + { + Ok(data) => { + let _ = tx.try_send(WebEventPaste(data)); + } + _ => { + error!("Not implemented."); + } + } + }); + + let listener = closure.as_ref().unchecked_ref(); + web_sys::window() + .expect("Could not retrieve web_sys::window()") + .document() + .expect("Could not retrieve web_sys window's document") + .add_event_listener_with_callback("paste", listener) + .expect("Could not add paste event listener."); + closure.forget(); + *clipboard_channel = WebChannel:: { rx: Some(rx) }; +} + +/// Puts argument string to the web clipboard +pub fn clipboard_copy(text: String) { + spawn_local(async move { + let window = web_sys::window().expect("window"); + + let nav = window.navigator(); + + let clipboard = nav.clipboard(); + match clipboard { + Some(a) => { + let p = a.write_text(&text); + let _result = wasm_bindgen_futures::JsFuture::from(p) + .await + .expect("clipboard populated"); + } + None => { + warn!("failed to write clipboard data"); + } + }; + }); +}