StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | //! Hidden input element for triggering the soft keyboard on mobile browsers. |
| 2 | //! |
| 3 | //! On mobile browsers, the soft keyboard only appears when a native HTML input element |
| 4 | //! is focused. This module creates and manages a hidden `<input>` element that can be |
| 5 | //! programmatically focused to trigger the keyboard. |
| 6 | //! |
| 7 | //! ## Sentinel Character Pattern |
| 8 | //! |
| 9 | //! We use a "sentinel character" pattern to capture mobile keyboard input reliably: |
| 10 | //! - The hidden input always contains a single space " " with the cursor after it |
| 11 | //! - This ensures the keyboard always sees "deletable" text, preventing the |
| 12 | //! "Android Backspace" bug where empty inputs don't emit backspace events |
| 13 | //! - We listen to `input` events, process them, then reset the input |
| 14 | //! |
| 15 | //! Note: Cursor movement (e.g., iOS trackpad gesture) is not captured here - users tap |
| 16 | //! on the canvas to reposition the cursor. This is a known limitation of the hidden input |
| 17 | //! approach. |
| 18 | |
| 19 | use gloo::events::EventListener; |
| 20 | use std::cell::RefCell; |
| 21 | use std::rc::Rc; |
| 22 | use wasm_bindgen::{JsCast, JsValue}; |
| 23 | use web_sys::{HtmlInputElement, InputEvent, KeyboardEvent}; |
| 24 | |
| 25 | /// The ID used for the hidden input element in the DOM. |
| 26 | const HIDDEN_INPUT_ID: &str = "warp-soft-keyboard-input"; |
| 27 | |
| 28 | /// The sentinel character used to ensure backspace events are always emitted. |
| 29 | /// A single space ensures the keyboard always sees "deletable" content. |
| 30 | const SENTINEL: &str = " "; |
| 31 | |
| 32 | /// Manages the hidden input element used to trigger the soft keyboard. |
| 33 | /// |
| 34 | /// This struct holds a reference to the hidden input and manages its lifecycle. |
| 35 | /// It should be created once when the window is created on mobile WASM. |
| 36 | pub struct HiddenInput { |
| 37 | element: HtmlInputElement, |
| 38 | /// Stores event listeners to keep them alive. |
| 39 | /// When this struct is dropped, the listeners will be cleaned up. |
| 40 | _listeners: Vec<EventListener>, |
| 41 | } |
| 42 | |
| 43 | /// Callback type for input events from the hidden input. |
| 44 | pub type InputCallback = Rc<RefCell<dyn FnMut(HiddenInputEvent)>>; |
| 45 | |
| 46 | /// Events that can be emitted by the hidden input. |
| 47 | #[derive(Debug, Clone)] |
| 48 | pub enum HiddenInputEvent { |
| 49 | /// Text was inserted via the soft keyboard. |
| 50 | InsertText { |
| 51 | /// The text that was inserted. |
| 52 | text: String, |
| 53 | }, |
| 54 | /// Backspace was pressed (deleteContentBackward). |
| 55 | Backspace, |
| 56 | /// Delete was pressed (deleteContentForward). |
| 57 | Delete, |
| 58 | /// The hidden input lost focus (keyboard was dismissed externally). |
| 59 | Blur, |
| 60 | /// A key was pressed (for keys like Enter that don't trigger input events). |
| 61 | KeyDown { |
| 62 | /// The key code (e.g., "Enter"). |
| 63 | key: String, |
| 64 | }, |
| 65 | } |
| 66 | |
| 67 | impl HiddenInput { |
| 68 | /// Resets the hidden input to its sentinel state. |
| 69 | /// |
| 70 | /// Sets the value to a single space and positions the cursor after it. |
| 71 | /// This ensures backspace always has something to delete. |
| 72 | fn reset_input_element(element: &HtmlInputElement) { |
| 73 | element.set_value(SENTINEL); |
| 74 | let _ = element.set_selection_range(1, 1); |
| 75 | } |
| 76 | |
| 77 | /// Creates a new hidden input element and attaches it to the DOM. |
| 78 | /// |
| 79 | /// The input is styled to be invisible but still focusable by the browser. |
| 80 | /// On mobile devices, focusing this input will trigger the soft keyboard. |
| 81 | /// |
| 82 | /// # Arguments |
| 83 | /// * `callback` - A callback that will be invoked when input events occur. |
| 84 | /// |
| 85 | /// # Errors |
| 86 | /// Returns an error if the DOM element cannot be created or configured. |
| 87 | pub fn new(callback: InputCallback) -> Result<Self, JsValue> { |
| 88 | let document = gloo::utils::document(); |
| 89 | |
| 90 | // Check if element already exists (e.g., from a previous session) |
| 91 | if let Some(existing) = document.get_element_by_id(HIDDEN_INPUT_ID) { |
| 92 | existing.remove(); |
| 93 | } |
| 94 | |
| 95 | // Create the input element |
| 96 | let element = document |
| 97 | .create_element("input")? |
| 98 | .dyn_into::<HtmlInputElement>()?; |
| 99 | |
| 100 | element.set_id(HIDDEN_INPUT_ID); |
| 101 | element.set_type("text"); |
| 102 | |
| 103 | // Apply styles to make it invisible but still focusable. |
| 104 | // We use a combination of techniques to ensure the input doesn't affect layout |
| 105 | // or become visible, while still being able to receive focus and trigger the |
| 106 | // soft keyboard on mobile. |
| 107 | let style = element.style(); |
| 108 | style.set_property("position", "fixed")?; |
| 109 | style.set_property("left", "-9999px")?; |
| 110 | style.set_property("top", "0")?; |
| 111 | style.set_property("opacity", "0")?; |
| 112 | style.set_property("width", "1px")?; |
| 113 | style.set_property("height", "1px")?; |
| 114 | style.set_property("border", "none")?; |
| 115 | style.set_property("outline", "none")?; |
| 116 | style.set_property("padding", "0")?; |
| 117 | style.set_property("margin", "0")?; |
| 118 | // iOS Safari auto-zooms the viewport when focusing inputs with font-size < 16px. |
| 119 | // Setting 16px prevents this unwanted zoom behavior. |
| 120 | style.set_property("font-size", "16px")?; |
| 121 | // Prevent the hidden input from intercepting touch/pointer events. |
| 122 | // Focus/blur will still work when called programmatically. |
| 123 | style.set_property("pointer-events", "none")?; |
| 124 | // Ensure the input is behind everything else |
| 125 | style.set_property("z-index", "-1")?; |
| 126 | // Disable autocorrect/autocomplete to get raw input |
| 127 | element.set_attribute("autocomplete", "off")?; |
| 128 | element.set_attribute("autocorrect", "off")?; |
| 129 | element.set_attribute("autocapitalize", "off")?; |
| 130 | element.set_attribute("spellcheck", "false")?; |
| 131 | |
| 132 | // Append to body |
| 133 | gloo::utils::body().append_child(&element)?; |
| 134 | |
| 135 | // Initialize with sentinel character BEFORE setting up listeners |
| 136 | Self::reset_input_element(&element); |
| 137 | |
| 138 | // Now set up event listeners |
| 139 | let listeners = Self::setup_listeners(&element, callback); |
| 140 | |
| 141 | Ok(Self { |
| 142 | element, |
| 143 | _listeners: listeners, |
| 144 | }) |
| 145 | } |
| 146 | |
| 147 | /// Sets up event listeners on the hidden input element. |
| 148 | fn setup_listeners(element: &HtmlInputElement, callback: InputCallback) -> Vec<EventListener> { |
| 149 | let mut listeners = Vec::new(); |
| 150 | |
| 151 | // We use 'input' event (fires after modification) because 'beforeinput' preventDefault |
| 152 | // doesn't work reliably on mobile browsers (iOS Safari, Android Chrome). |
| 153 | let callback_clone = Rc::clone(&callback); |
| 154 | let element_clone = element.clone(); |
| 155 | let input_listener = EventListener::new(element, "input", move |event| { |
| 156 | let input_event = event.dyn_ref::<InputEvent>(); |
| 157 | |
| 158 | // Don't process input events during IME composition. |
| 159 | // Use the browser's built-in isComposing flag. |
| 160 | if input_event.map(|e| e.is_composing()).unwrap_or(false) { |
| 161 | return; |
| 162 | } |
| 163 | |
| 164 | let input_type = input_event.map(|e| e.input_type()).unwrap_or_default(); |
| 165 | let input_data = input_event.and_then(|e| e.data()); |
| 166 | |
| 167 | let hidden_event = match input_type.as_str() { |
| 168 | "insertText" | "insertCompositionText" => input_data |
| 169 | .filter(|s| !s.is_empty()) |
| 170 | .map(|text| HiddenInputEvent::InsertText { text }), |
| 171 | // Handle both single-char and word-level deletion (long-press backspace) |
| 172 | "deleteContentBackward" | "deleteWordBackward" => Some(HiddenInputEvent::Backspace), |
| 173 | "deleteContentForward" => Some(HiddenInputEvent::Delete), |
| 174 | _ => None, |
| 175 | }; |
| 176 | |
| 177 | // Always reset to sentinel state after processing |
| 178 | Self::reset_input_element(&element_clone); |
| 179 | |
| 180 | if let Some(hidden_event) = hidden_event { |
| 181 | callback_clone.borrow_mut()(hidden_event); |
| 182 | } |
| 183 | }); |
| 184 | listeners.push(input_listener); |
| 185 | |
| 186 | // Composition end event - ensures we reset the sentinel after IME composition completes. |
| 187 | // This handles CJK input (Chinese, Japanese, Korean, etc.) where the final composed |
| 188 | // text is committed. |
| 189 | let callback_clone = Rc::clone(&callback); |
| 190 | let element_clone = element.clone(); |
| 191 | let composition_end_listener = |
| 192 | EventListener::new(element, "compositionend", move |event| { |
| 193 | log::debug!("IME composition ended"); |
| 194 | |
| 195 | // Get the final composed text |
| 196 | let comp_event = event.dyn_ref::<web_sys::CompositionEvent>(); |
| 197 | let data = comp_event.and_then(|e| e.data()).unwrap_or_default(); |
| 198 | |
| 199 | // Reset the input to sentinel state |
| 200 | Self::reset_input_element(&element_clone); |
| 201 | |
| 202 | // Send the final text if non-empty |
| 203 | if !data.is_empty() { |
| 204 | callback_clone.borrow_mut()(HiddenInputEvent::InsertText { text: data }); |
| 205 | } |
| 206 | }); |
| 207 | listeners.push(composition_end_listener); |
| 208 | |
| 209 | // Focus event - reset input when focused to ensure clean state |
| 210 | let element_clone = element.clone(); |
| 211 | let focus_listener = EventListener::new(element, "focus", move |_| { |
| 212 | Self::reset_input_element(&element_clone); |
| 213 | }); |
| 214 | listeners.push(focus_listener); |
| 215 | |
| 216 | // Blur event - fires when the hidden input loses focus (keyboard dismissed) |
| 217 | let callback_clone = Rc::clone(&callback); |
| 218 | let blur_listener = EventListener::new(element, "blur", move |_| { |
| 219 | log::debug!("Hidden input blur event - keyboard dismissed externally"); |
| 220 | callback_clone.borrow_mut()(HiddenInputEvent::Blur); |
| 221 | }); |
| 222 | listeners.push(blur_listener); |
| 223 | |
| 224 | // Keydown event - for keys like Enter that don't trigger input events |
| 225 | let callback_clone = Rc::clone(&callback); |
| 226 | let keydown_listener = EventListener::new(element, "keydown", move |event| { |
| 227 | if let Some(keyboard_event) = event.dyn_ref::<KeyboardEvent>() { |
| 228 | let key = keyboard_event.key(); |
| 229 | // Only forward Enter key - other keys are handled via input events |
| 230 | if key == "Enter" { |
| 231 | callback_clone.borrow_mut()(HiddenInputEvent::KeyDown { key }); |
| 232 | } |
| 233 | } |
| 234 | }); |
| 235 | listeners.push(keydown_listener); |
| 236 | |
| 237 | listeners |
| 238 | } |
| 239 | |
| 240 | /// Focuses the hidden input element, which triggers the soft keyboard on mobile. |
| 241 | pub fn focus(&self) -> Result<(), JsValue> { |
| 242 | self.element.focus() |
| 243 | } |
| 244 | |
| 245 | /// Blurs (unfocuses) the hidden input element, which dismisses the soft keyboard. |
| 246 | pub fn blur(&self) -> Result<(), JsValue> { |
| 247 | self.element.blur() |
| 248 | } |
| 249 | |
| 250 | /// Returns whether the hidden input currently has focus. |
| 251 | pub fn has_focus(&self) -> bool { |
| 252 | gloo::utils::document() |
| 253 | .active_element() |
| 254 | .map(|el| el.id() == HIDDEN_INPUT_ID) |
| 255 | .unwrap_or(false) |
| 256 | } |
| 257 | |
| 258 | /// Resets the hidden input to its sentinel state. |
| 259 | /// |
| 260 | /// Sets the value to a single space and positions the cursor after it. |
| 261 | /// This ensures backspace always has something to delete. |
| 262 | pub fn reset_input(&self) { |
| 263 | Self::reset_input_element(&self.element); |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | impl Drop for HiddenInput { |
| 268 | fn drop(&mut self) { |
| 269 | // Remove the element from the DOM when the HiddenInput is dropped |
| 270 | self.element.remove(); |
| 271 | } |
| 272 | } |
| 273 |