Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-renderer/src/platform/wasm/hidden_input.rs
StratoSDK / crates / strato-ui-renderer / src / platform / wasm / hidden_input.rs
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 
19use gloo::events::EventListener;
20use std::cell::RefCell;
21use std::rc::Rc;
22use wasm_bindgen::{JsCast, JsValue};
23use web_sys::{HtmlInputElement, InputEvent, KeyboardEvent};
24 
25/// The ID used for the hidden input element in the DOM.
26const 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.
30const 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.
36pub 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.
44pub type InputCallback = Rc<RefCell<dyn FnMut(HiddenInputEvent)>>;
45 
46/// Events that can be emitted by the hidden input.
47#[derive(Debug, Clone)]
48pub 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 
67impl 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 
267impl 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