From 8956b27f8c97a80d4412b5f700a9f7b69372107c Mon Sep 17 00:00:00 2001 From: mvlabat Date: Fri, 4 Oct 2024 17:55:37 +0300 Subject: [PATCH] Implement mobile virtual keyboard support in web (#279) Co-authored-by: v-kat <1297926+v-kat@users.noreply.github.com> --- CHANGELOG.md | 6 + Cargo.toml | 23 ++- README.md | 1 + examples/ui.rs | 1 + src/lib.rs | 127 ++++++++++++- src/systems.rs | 33 +++- src/text_agent.rs | 428 +++++++++++++++++++++++++++++++++++++++++++ src/web_clipboard.rs | 82 +++------ 8 files changed, 636 insertions(+), 65 deletions(-) create mode 100644 src/text_agent.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e53808a..3b84e90d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +- `prepare_render` step support for `EguiBevyPaintCallbackImpl` ([#306](https://github.com/mvlabat/bevy_egui/pull/306) by @PPakalns). +- Mobile virtual keyboard support in web ([#279](https://github.com/mvlabat/bevy_egui/pull/279) by @v-kat). + - Requires `Window::prevent_default_event_handling` being set to `false`. +- IME support (#[204](https://github.com/mvlabat/bevy_egui/pull/204) by @EReeves). + ### Changed - Update Egui to 0.29 ([#313](https://github.com/mvlabat/bevy_egui/pull/313) by @PPakalns). diff --git a/Cargo.toml b/Cargo.toml index 824e51ec9..e1b0b1fea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "bevy_egui" version = "0.29.0" +rust-version = "1.80.0" # needed for LazyLock https://doc.rust-lang.org/stable/std/sync/struct.LazyLock.html authors = ["mvlabat "] description = "A plugin for Egui integration into Bevy" license = "MIT" @@ -21,6 +22,8 @@ open_url = ["webbrowser"] default_fonts = ["egui/default_fonts"] render = ["bevy/bevy_render"] serde = ["egui/serde"] +# The enabled logs will print with the info log level, to make it less cumbersome to debug in browsers. +log_input_events = [] [[example]] name = "paint_callback" @@ -76,18 +79,22 @@ egui = { version = "0.29", default-features = false, features = ["bytemuck"] } [target.'cfg(target_arch = "wasm32")'.dependencies] winit = "0.30" web-sys = { version = "0.3.63", features = [ - "Clipboard", - "ClipboardEvent", - "DataTransfer", - 'Document', - 'EventTarget', - "Window", - "Navigator", + "Clipboard", + "ClipboardEvent", + "CompositionEvent", + "DataTransfer", + "Document", + "EventTarget", + "HtmlInputElement", + "InputEvent", + "KeyboardEvent", + "Navigator", + "TouchEvent", + "Window", ] } 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" diff --git a/README.md b/README.md index 86a52c59a..bb0deb2ac 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ An example WASM project is live at [mvlabat.github.io/bevy_egui_web_showcase](ht - Opening URLs - Multiple windows support (see [./examples/two_windows.rs](https://github.com/mvlabat/bevy_egui/blob/v0.29.0/examples/two_windows.rs)) - Paint callback support (see [./examples/paint_callback.rs](https://github.com/mvlabat/bevy_egui/blob/v0.29.0/examples/paint_callback.rs)) +- Mobile web virtual keyboard (still rough support and only works without prevent_default_event_handling set to false on the WindowPlugin primary_window) `bevy_egui` can be compiled with using only `bevy`, `egui` and `bytemuck` as dependencies: `manage_clipboard` and `open_url` features, that require additional crates, can be disabled. diff --git a/examples/ui.rs b/examples/ui.rs index b518986b7..c47e74127 100644 --- a/examples/ui.rs +++ b/examples/ui.rs @@ -27,6 +27,7 @@ fn main() { .init_resource::() .add_plugins(DefaultPlugins.set(WindowPlugin { primary_window: Some(Window { + // You may want this set to `true` if you need virtual keyboard work in mobile browsers. prevent_default_event_handling: false, ..default() }), diff --git a/src/lib.rs b/src/lib.rs index ac6351801..d5649b3e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -68,7 +68,10 @@ pub mod egui_render_to_texture_node; pub mod render_systems; /// Plugin systems. pub mod systems; -/// Clipboard management for web. +/// Mobile web keyboard hacky input support +#[cfg(target_arch = "wasm32")] +mod text_agent; +/// Clipboard management for web #[cfg(all( feature = "manage_clipboard", target_arch = "wasm32", @@ -129,6 +132,15 @@ use bevy::{ ))] use std::cell::{RefCell, RefMut}; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::*; + +#[cfg(target_arch = "wasm32")] +use crate::text_agent::{ + install_text_agent, is_mobile_safari, process_safari_virtual_keyboard, propagate_text, + SafariVirtualKeyboardHack, TextAgentChannel, VirtualTouchInfo, +}; + /// Adds all Egui resources and render graph nodes. pub struct EguiPlugin; @@ -673,6 +685,9 @@ impl Plugin for EguiPlugin { app.add_plugins(ExtractComponentPlugin::::default()); } + #[cfg(target_arch = "wasm32")] + app.init_non_send_resource::(); + #[cfg(all(feature = "manage_clipboard", not(target_os = "android")))] app.init_resource::(); @@ -682,7 +697,6 @@ impl Plugin for EguiPlugin { web_sys_unstable_apis ))] { - app.init_non_send_resource::(); app.add_systems(PreStartup, web_clipboard::startup_setup_web_events); } @@ -716,6 +730,58 @@ impl Plugin for EguiPlugin { .after(InputSystem) .after(EguiSet::InitContexts), ); + #[cfg(target_arch = "wasm32")] + { + use std::sync::{LazyLock, Mutex}; + + let maybe_window_plugin = app.get_added_plugins::(); + + if !maybe_window_plugin.is_empty() + && maybe_window_plugin[0].primary_window.is_some() + && maybe_window_plugin[0] + .primary_window + .as_ref() + .unwrap() + .prevent_default_event_handling + { + app.init_resource::(); + + let (sender, receiver) = crossbeam_channel::unbounded(); + static TOUCH_INFO: LazyLock> = + LazyLock::new(|| Mutex::new(VirtualTouchInfo::default())); + + app.insert_resource(SafariVirtualKeyboardHack { + sender, + receiver, + touch_info: &TOUCH_INFO, + }); + + app.add_systems( + PreStartup, + install_text_agent + .in_set(EguiSet::ProcessInput) + .after(process_input_system) + .after(InputSystem) + .after(EguiSet::InitContexts), + ); + + app.add_systems( + PreUpdate, + propagate_text + .in_set(EguiSet::ProcessInput) + .after(process_input_system) + .after(InputSystem) + .after(EguiSet::InitContexts), + ); + + if is_mobile_safari() { + app.add_systems( + PostUpdate, + process_safari_virtual_keyboard.after(process_output_system), + ); + } + } + } app.add_systems( PreUpdate, begin_pass_system @@ -978,6 +1044,63 @@ fn free_egui_textures_system( } } +/// Helper function for outputting a String from a JsValue +#[cfg(target_arch = "wasm32")] +pub fn string_from_js_value(value: &JsValue) -> String { + value.as_string().unwrap_or_else(|| format!("{value:#?}")) +} + +#[cfg(target_arch = "wasm32")] +struct EventClosure { + target: web_sys::EventTarget, + event_name: String, + closure: wasm_bindgen::closure::Closure, +} + +/// Stores event listeners. +#[cfg(target_arch = "wasm32")] +#[derive(Default)] +pub struct SubscribedEvents { + #[cfg(all(feature = "manage_clipboard", web_sys_unstable_apis))] + clipboard_event_closures: Vec>, + composition_event_closures: Vec>, + keyboard_event_closures: Vec>, + input_event_closures: Vec>, + touch_event_closures: Vec>, +} + +#[cfg(target_arch = "wasm32")] +impl SubscribedEvents { + /// Use this method to unsubscribe from all stored events, this can be useful + /// for gracefully destroying a Bevy instance in a page. + pub fn unsubscribe_from_all_events(&mut self) { + #[cfg(all(feature = "manage_clipboard", web_sys_unstable_apis))] + Self::unsubscribe_from_events(&mut self.clipboard_event_closures); + Self::unsubscribe_from_events(&mut self.composition_event_closures); + Self::unsubscribe_from_events(&mut self.keyboard_event_closures); + Self::unsubscribe_from_events(&mut self.input_event_closures); + Self::unsubscribe_from_events(&mut self.touch_event_closures); + } + + fn unsubscribe_from_events(events: &mut Vec>) { + let events_to_unsubscribe = std::mem::take(events); + + if !events_to_unsubscribe.is_empty() { + for event in events_to_unsubscribe { + if let Err(err) = event.target.remove_event_listener_with_callback( + event.event_name.as_str(), + event.closure.as_ref().unchecked_ref(), + ) { + log::error!( + "Failed to unsubscribe from event: {}", + string_from_js_value(&err) + ); + } + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/systems.rs b/src/systems.rs index b391dba00..3b312eefa 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -1,10 +1,11 @@ +#[cfg(target_arch = "wasm32")] +use crate::text_agent::{is_mobile_safari, update_text_agent}; #[cfg(feature = "render")] use crate::EguiRenderToTextureHandle; use crate::{ EguiContext, EguiContextQuery, EguiContextQueryItem, EguiFullOutput, EguiInput, EguiSettings, RenderTargetSize, }; - #[cfg(feature = "render")] use bevy::{asset::Assets, render::texture::Image}; use bevy::{ @@ -133,6 +134,8 @@ pub fn process_input_system( for event in input_events.ev_keyboard_input.read() { // Copy the events as we might want to pass them to an Egui context later. keyboard_input_events.push(event.clone()); + #[cfg(feature = "log_input_events")] + log::info!("{event:?}"); let KeyboardInput { logical_key, state, .. @@ -196,6 +199,8 @@ pub fn process_input_system( let Some(mut window_context) = context_params.window_context(event.window) else { continue; }; + #[cfg(feature = "log_input_events")] + log::info!("{event:?}"); let button = match event.button { MouseButton::Left => Some(egui::PointerButton::Primary), @@ -224,6 +229,8 @@ pub fn process_input_system( let Some(mut window_context) = context_params.window_context(event.window) else { continue; }; + #[cfg(feature = "log_input_events")] + log::info!("{event:?}"); let delta = egui::vec2(event.x, event.y); @@ -242,6 +249,17 @@ pub fn process_input_system( }); } + #[cfg(target_arch = "wasm32")] + let mut editing_text = false; + #[cfg(target_arch = "wasm32")] + for context in context_params.contexts.iter() { + let platform_output = &context.egui_output.platform_output; + if platform_output.ime.is_some() || platform_output.mutable_text_under_cursor { + editing_text = true; + break; + } + } + for event in input_events.ev_ime_input.read() { let window = match &event { Ime::Preedit { window, .. } @@ -253,6 +271,8 @@ pub fn process_input_system( let Some(mut window_context) = context_params.window_context(window) else { continue; }; + #[cfg(feature = "log_input_events")] + log::info!("{event:?}"); // Aligned with the egui-winit implementation: https://github.com/emilk/egui/blob/0f2b427ff4c0a8c68f6622ec7d0afb7ba7e71bba/crates/egui-winit/src/lib.rs#L348 match event { @@ -288,6 +308,8 @@ pub fn process_input_system( let Some(mut window_context) = context_params.window_context(event.window) else { continue; }; + #[cfg(feature = "log_input_events")] + log::info!("{event:?}"); if text_event_allowed && event.state.is_pressed() { match &event.logical_key { @@ -353,6 +375,8 @@ pub fn process_input_system( while let Some(event) = input_resources.egui_clipboard.try_receive_clipboard_event() { // In web, we assume that we have only 1 window per app. let mut window_context = context_params.contexts.single_mut(); + #[cfg(feature = "log_input_events")] + log::info!("{event:?}"); match event { crate::web_clipboard::WebClipboardEvent::Copy => { @@ -377,6 +401,8 @@ pub fn process_input_system( let Some(mut window_context) = context_params.window_context(event.window) else { continue; }; + #[cfg(feature = "log_input_events")] + log::info!("{event:?}"); let touch_id = egui::TouchId::from(event.id); let scale_factor = window_context.egui_settings.scale_factor; @@ -456,6 +482,11 @@ pub fn process_input_system( .egui_input .events .push(egui::Event::PointerGone); + + #[cfg(target_arch = "wasm32")] + if !is_mobile_safari() { + update_text_agent(editing_text); + } } bevy::input::touch::TouchPhase::Canceled => { window_context.ctx.pointer_touch_id = None; diff --git a/src/text_agent.rs b/src/text_agent.rs new file mode 100644 index 000000000..1044186a8 --- /dev/null +++ b/src/text_agent.rs @@ -0,0 +1,428 @@ +//! The text agent is an `` element used to trigger +//! mobile keyboard and IME input. + +use std::sync::{LazyLock, Mutex}; + +use bevy::{ + prelude::{EventWriter, NonSendMut, Res, Resource}, + window::RequestRedraw, +}; +use crossbeam_channel::{unbounded, Receiver, Sender}; + +use wasm_bindgen::prelude::*; + +use crate::{systems::ContextSystemParams, EventClosure, SubscribedEvents}; + +static AGENT_ID: &str = "egui_text_agent"; + +#[allow(missing_docs)] +#[derive(Clone, Copy, Debug, Default)] +pub struct VirtualTouchInfo { + pub editing_text: bool, +} + +#[derive(Resource)] +pub struct TextAgentChannel { + pub sender: Sender, + pub receiver: Receiver, +} + +impl Default for TextAgentChannel { + fn default() -> Self { + let (sender, receiver) = unbounded(); + Self { sender, receiver } + } +} + +#[derive(Resource)] +pub struct SafariVirtualKeyboardHack { + pub sender: Sender, + pub receiver: Receiver, + pub touch_info: &'static LazyLock>, +} + +pub fn process_safari_virtual_keyboard( + context_params: ContextSystemParams, + safari_virtual_keyboard_hack: Res, +) { + for contexts in context_params.contexts.iter() { + while let Ok(true) = safari_virtual_keyboard_hack.receiver.try_recv() { + let platform_output = &contexts.egui_output.platform_output; + let mut editing_text = false; + + if platform_output.ime.is_some() || platform_output.mutable_text_under_cursor { + editing_text = true; + } + match safari_virtual_keyboard_hack.touch_info.lock() { + Ok(mut touch_info) => { + touch_info.editing_text = editing_text; + } + Err(poisoned) => { + let _unused = poisoned.into_inner(); + } + }; + } + } +} + +pub fn propagate_text( + channel: Res, + mut context_params: ContextSystemParams, + mut redraw_event: EventWriter, +) { + for mut contexts in context_params.contexts.iter_mut() { + if contexts.egui_input.focused { + let mut redraw = false; + while let Ok(r) = channel.receiver.try_recv() { + redraw = true; + contexts.egui_input.events.push(r); + } + if redraw { + redraw_event.send(RequestRedraw); + } + break; + } + } +} + +/// Text event handler, +pub fn install_text_agent( + mut subscribed_events: NonSendMut, + text_agent_channel: Res, + safari_virtual_keyboard_hack: Res, +) { + let window = web_sys::window().unwrap(); + let document = window.document().unwrap(); + let body = document.body().expect("document should have a body"); + let input = document + .create_element("input") + .expect("failed to create input") + .dyn_into::() + .expect("failed input type coercion"); + let input = std::rc::Rc::new(input); + input.set_type("text"); + input.set_autofocus(true); + input + .set_attribute("autocapitalize", "off") + .expect("failed to turn off autocapitalize"); + input.set_id(AGENT_ID); + { + let style = input.style(); + // Make the input hidden. + style + .set_property("background-color", "transparent") + .expect("failed to set text_agent css properties"); + style + .set_property("border", "none") + .expect("failed to set text_agent css properties"); + style + .set_property("outline", "none") + .expect("failed to set text_agent css properties"); + style + .set_property("width", "1px") + .expect("failed to set text_agent css properties"); + style + .set_property("height", "1px") + .expect("failed to set text_agent css properties"); + style + .set_property("caret-color", "transparent") + .expect("failed to set text_agent css properties"); + style + .set_property("position", "absolute") + .expect("failed to set text_agent css properties"); + style + .set_property("top", "0") + .expect("failed to set text_agent css properties"); + style + .set_property("left", "0") + .expect("failed to set text_agent css properties"); + } + // Set size as small as possible, in case user may click on it. + input.set_size(1); + input.set_autofocus(true); + input.set_hidden(true); + + let sender = text_agent_channel.sender.clone(); + + if let Some(true) = is_mobile() { + let input_clone = input.clone(); + let sender_clone = sender.clone(); + let closure = Closure::wrap(Box::new(move |event: web_sys::InputEvent| { + #[cfg(feature = "log_input_events")] + log::info!( + "Input event: is_composing={}, data={:?}", + event.is_composing(), + event.data() + ); + let text = input_clone.value(); + + if !text.is_empty() && !event.is_composing() { + input_clone.set_value(""); + input_clone.blur().ok(); + input_clone.focus().ok(); + if let Err(err) = sender_clone.send(egui::Event::Text(text.clone())) { + log::error!("Failed to send input event: {:?}", err); + } + } + }) as Box); + input + .add_event_listener_with_callback("input", closure.as_ref().unchecked_ref()) + .expect("failed to create input listener"); + subscribed_events.input_event_closures.push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_input".to_owned(), + closure, + }); + + let input_clone = input.clone(); + let sender_clone = sender.clone(); + let closure = Closure::wrap(Box::new(move |_event: web_sys::CompositionEvent| { + #[cfg(feature = "log_input_events")] + log::info!("Composition start: data={:?}", _event.data()); + input_clone.set_value(""); + let _ = sender_clone.send(egui::Event::Ime(egui::ImeEvent::Enabled)); + }) as Box); + input + .add_event_listener_with_callback("compositionstart", closure.as_ref().unchecked_ref()) + .expect("failed to create compositionstart listener"); + subscribed_events + .composition_event_closures + .push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_compositionstart".to_owned(), + closure, + }); + + let sender_clone = sender.clone(); + let closure = Closure::wrap(Box::new(move |event: web_sys::CompositionEvent| { + #[cfg(feature = "log_input_events")] + log::info!("Composition update: data={:?}", event.data()); + let Some(text) = event.data() else { return }; + let event = egui::Event::Ime(egui::ImeEvent::Preedit(text)); + let _ = sender_clone.send(event); + }) as Box); + input + .add_event_listener_with_callback("compositionupdate", closure.as_ref().unchecked_ref()) + .expect("failed to create compositionupdate listener"); + subscribed_events + .composition_event_closures + .push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_compositionupdate".to_owned(), + closure, + }); + + let input_clone = input.clone(); + let sender_clone = sender.clone(); + let closure = Closure::wrap(Box::new(move |event: web_sys::CompositionEvent| { + #[cfg(feature = "log_input_events")] + log::info!("Composition end: data={:?}", event.data()); + let Some(text) = event.data() else { return }; + input_clone.set_value(""); + let event = egui::Event::Ime(egui::ImeEvent::Commit(text)); + let _ = sender_clone.send(event); + }) as Box); + input + .add_event_listener_with_callback("compositionend", closure.as_ref().unchecked_ref()) + .expect("failed to create compositionend listener"); + subscribed_events + .composition_event_closures + .push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_compositionend".to_owned(), + closure, + }); + + // Mobile safari doesn't let you set input focus outside of an event handler. + if is_mobile_safari() { + let safari_sender = safari_virtual_keyboard_hack.sender.clone(); + let closure = Closure::wrap(Box::new(move |_event: web_sys::TouchEvent| { + #[cfg(feature = "log_input_events")] + log::info!("Touch start: {:?}", _event); + let _ = safari_sender.send(true); + }) as Box); + document + .add_event_listener_with_callback("touchstart", closure.as_ref().unchecked_ref()) + .expect("failed to create touchstart listener"); + subscribed_events.touch_event_closures.push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_touchstart".to_owned(), + closure, + }); + + let safari_touch_info_lock = safari_virtual_keyboard_hack.touch_info; + let closure = Closure::wrap(Box::new(move |_event: web_sys::TouchEvent| { + #[cfg(feature = "log_input_events")] + log::info!("Touch end: {:?}", _event); + match safari_touch_info_lock.lock() { + Ok(touch_info) => { + update_text_agent(touch_info.editing_text); + } + Err(poisoned) => { + let _unused = poisoned.into_inner(); + } + }; + }) as Box); + document + .add_event_listener_with_callback("touchend", closure.as_ref().unchecked_ref()) + .expect("failed to create touchend listener"); + subscribed_events.touch_event_closures.push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_touchend".to_owned(), + closure, + }); + } + + let sender_clone = sender.clone(); + let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| { + #[cfg(feature = "log_input_events")] + log::info!("Keyboard event: {:?}", event); + if event.is_composing() || event.key_code() == 229 { + // https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/ + return; + } + if "Backspace" == event.key() { + let _ = sender_clone.send(egui::Event::Key { + key: egui::Key::Backspace, + physical_key: None, + pressed: true, + modifiers: egui::Modifiers::NONE, + repeat: false, + }); + } + }) as Box); + document + .add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref()) + .expect("failed to create keydown listener"); + subscribed_events + .keyboard_event_closures + .push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_keydown".to_owned(), + closure, + }); + + let input_clone = input.clone(); + let sender_clone = sender.clone(); + let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| { + #[cfg(feature = "log_input_events")] + log::info!("{:?}", event); + input_clone.focus().ok(); + if "Backspace" == event.key() { + let _ = sender_clone.send(egui::Event::Key { + key: egui::Key::Backspace, + physical_key: None, + pressed: false, + modifiers: egui::Modifiers::NONE, + repeat: false, + }); + } + }) as Box); + document + .add_event_listener_with_callback("keyup", closure.as_ref().unchecked_ref()) + .expect("failed to create keyup listener"); + subscribed_events + .keyboard_event_closures + .push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_keyup".to_owned(), + closure, + }); + } + + body.append_child(&input).expect("failed to append to body"); +} + +/// Focus or blur text agent to toggle mobile keyboard. +pub fn update_text_agent(editing_text: bool) { + use web_sys::HtmlInputElement; + + let window = match web_sys::window() { + Some(window) => window, + None => { + bevy::log::error!("No window found"); + return; + } + }; + let document = match window.document() { + Some(doc) => doc, + None => { + bevy::log::error!("No document found"); + return; + } + }; + let input: HtmlInputElement = match document.get_element_by_id(AGENT_ID) { + Some(ele) => ele, + None => { + bevy::log::error!("Agent element not found"); + return; + } + } + .dyn_into() + .unwrap(); + + let keyboard_open = !input.hidden(); + + if editing_text { + // Open the keyboard. + input.set_hidden(false); + match input.focus().ok() { + Some(_) => {} + None => { + bevy::log::error!("Unable to set focus"); + } + } + } else if keyboard_open { + // Close the keyboard. + if input.blur().is_err() { + bevy::log::error!("Agent element not found"); + return; + } + + input.set_hidden(true); + } +} + +pub fn is_mobile_safari() -> bool { + (|| { + let user_agent = web_sys::window()?.navigator().user_agent().ok()?; + let is_ios = user_agent.contains("iPhone") + || user_agent.contains("iPad") + || user_agent.contains("iPod"); + let is_safari = user_agent.contains("Safari"); + Some(is_ios && is_safari) + })() + .unwrap_or(false) +} + +fn is_mobile() -> Option { + const MOBILE_DEVICE: [&str; 6] = ["Android", "iPhone", "iPad", "iPod", "webOS", "BlackBerry"]; + + let user_agent = web_sys::window()?.navigator().user_agent().ok()?; + let is_mobile = MOBILE_DEVICE.iter().any(|&name| user_agent.contains(name)); + Some(is_mobile) +} diff --git a/src/web_clipboard.rs b/src/web_clipboard.rs index 34bef83ed..7dfa68a1f 100644 --- a/src/web_clipboard.rs +++ b/src/web_clipboard.rs @@ -1,4 +1,4 @@ -use crate::EguiClipboard; +use crate::{string_from_js_value, EguiClipboard, EventClosure, SubscribedEvents}; use bevy::{log, prelude::*}; use crossbeam_channel::{Receiver, Sender}; use wasm_bindgen::prelude::*; @@ -71,40 +71,6 @@ impl WebClipboard { } } -/// Stores the clipboard event listeners. -#[derive(Default)] -pub struct SubscribedEvents { - event_closures: Vec, -} - -impl SubscribedEvents { - /// Use this method to unsubscribe from all the clipboard events, this can be useful - /// for gracefully destroying a Bevy instance in a page. - pub fn unsubscribe_from_events(&mut self) { - let events_to_unsubscribe = std::mem::take(&mut self.event_closures); - - if !events_to_unsubscribe.is_empty() { - for event in events_to_unsubscribe { - if let Err(err) = event.target.remove_event_listener_with_callback( - event.event_name.as_str(), - event.closure.as_ref().unchecked_ref(), - ) { - log::error!( - "Failed to unsubscribe from event: {}", - string_from_js_value(&err) - ); - } - } - } - } -} - -struct EventClosure { - target: web_sys::EventTarget, - event_name: String, - closure: Closure, -} - fn setup_clipboard_copy(subscribed_events: &mut SubscribedEvents, tx: Sender) { let Some(window) = web_sys::window() else { log::error!("Failed to add the \"copy\" listener: no window object"); @@ -131,12 +97,16 @@ fn setup_clipboard_copy(subscribed_events: &mut SubscribedEvents, tx: Sender>::as_ref(&document) + subscribed_events + .clipboard_event_closures + .push(EventClosure { + target: >::as_ref( + &document, + ) .clone(), - event_name: "copy".to_owned(), - closure, - }); + event_name: "copy".to_owned(), + closure, + }); } fn setup_clipboard_cut(subscribed_events: &mut SubscribedEvents, tx: Sender) { @@ -165,12 +135,16 @@ fn setup_clipboard_cut(subscribed_events: &mut SubscribedEvents, tx: Sender>::as_ref(&document) + subscribed_events + .clipboard_event_closures + .push(EventClosure { + target: >::as_ref( + &document, + ) .clone(), - event_name: "cut".to_owned(), - closure, - }); + event_name: "cut".to_owned(), + closure, + }); } fn setup_clipboard_paste(subscribed_events: &mut SubscribedEvents, tx: Sender) { @@ -213,12 +187,16 @@ fn setup_clipboard_paste(subscribed_events: &mut SubscribedEvents, tx: Sender>::as_ref(&document) + subscribed_events + .clipboard_event_closures + .push(EventClosure { + target: >::as_ref( + &document, + ) .clone(), - event_name: "paste".to_owned(), - closure, - }); + event_name: "paste".to_owned(), + closure, + }); } /// Sets contents of the clipboard via the Web API. @@ -240,7 +218,3 @@ fn clipboard_copy(contents: String) { } }); } - -fn string_from_js_value(value: &JsValue) -> String { - value.as_string().unwrap_or_else(|| format!("{value:#?}")) -}