text_agent.rs
1 //! The text agent is an `<input>` element used to trigger 2 //! mobile keyboard and IME input. 3 4 use crate::{canvas_element, AppRunner, AppRunnerContainer}; 5 use egui::mutex::MutexGuard; 6 use std::cell::Cell; 7 use std::rc::Rc; 8 use wasm_bindgen::prelude::*; 9 10 static AGENT_ID: &str = "egui_text_agent"; 11 12 pub fn text_agent() -> web_sys::HtmlInputElement { 13 use wasm_bindgen::JsCast; 14 web_sys::window() 15 .unwrap() 16 .document() 17 .unwrap() 18 .get_element_by_id(AGENT_ID) 19 .unwrap() 20 .dyn_into() 21 .unwrap() 22 } 23 24 /// Text event handler, 25 pub fn install_text_agent(runner_container: &AppRunnerContainer) -> Result<(), JsValue> { 26 use wasm_bindgen::JsCast; 27 let window = web_sys::window().unwrap(); 28 let document = window.document().unwrap(); 29 let body = document.body().expect("document should have a body"); 30 let input = document 31 .create_element("input")? 32 .dyn_into::<web_sys::HtmlInputElement>()?; 33 let input = std::rc::Rc::new(input); 34 input.set_id(AGENT_ID); 35 let is_composing = Rc::new(Cell::new(false)); 36 { 37 let style = input.style(); 38 // Transparent 39 style.set_property("opacity", "0").unwrap(); 40 // Hide under canvas 41 style.set_property("z-index", "-1").unwrap(); 42 } 43 // Set size as small as possible, in case user may click on it. 44 input.set_size(1); 45 input.set_autofocus(true); 46 input.set_hidden(true); 47 48 // When IME is off 49 runner_container.add_event_listener(&input, "input", { 50 let input_clone = input.clone(); 51 let is_composing = is_composing.clone(); 52 53 move |_event: web_sys::InputEvent, mut runner_lock| { 54 let text = input_clone.value(); 55 if !text.is_empty() && !is_composing.get() { 56 input_clone.set_value(""); 57 runner_lock.input.raw.events.push(egui::Event::Text(text)); 58 runner_lock.needs_repaint.set_true(); 59 } 60 } 61 })?; 62 63 { 64 // When IME is on, handle composition event 65 runner_container.add_event_listener(&input, "compositionstart", { 66 let input_clone = input.clone(); 67 let is_composing = is_composing.clone(); 68 69 move |_event: web_sys::CompositionEvent, mut runner_lock: MutexGuard<'_, AppRunner>| { 70 is_composing.set(true); 71 input_clone.set_value(""); 72 73 runner_lock 74 .input 75 .raw 76 .events 77 .push(egui::Event::CompositionStart); 78 runner_lock.needs_repaint.set_true(); 79 } 80 })?; 81 82 runner_container.add_event_listener( 83 &input, 84 "compositionupdate", 85 move |event: web_sys::CompositionEvent, mut runner_lock: MutexGuard<'_, AppRunner>| { 86 if let Some(event) = event.data().map(egui::Event::CompositionUpdate) { 87 runner_lock.input.raw.events.push(event); 88 runner_lock.needs_repaint.set_true(); 89 } 90 }, 91 )?; 92 93 runner_container.add_event_listener(&input, "compositionend", { 94 let input_clone = input.clone(); 95 96 move |event: web_sys::CompositionEvent, mut runner_lock: MutexGuard<'_, AppRunner>| { 97 is_composing.set(false); 98 input_clone.set_value(""); 99 100 if let Some(event) = event.data().map(egui::Event::CompositionEnd) { 101 runner_lock.input.raw.events.push(event); 102 runner_lock.needs_repaint.set_true(); 103 } 104 } 105 })?; 106 } 107 108 // When input lost focus, focus on it again. 109 // It is useful when user click somewhere outside canvas. 110 runner_container.add_event_listener( 111 &input, 112 "focusout", 113 move |_event: web_sys::MouseEvent, _| { 114 // Delay 10 ms, and focus again. 115 let func = js_sys::Function::new_no_args(&format!( 116 "document.getElementById('{}').focus()", 117 AGENT_ID 118 )); 119 window 120 .set_timeout_with_callback_and_timeout_and_arguments_0(&func, 10) 121 .unwrap(); 122 }, 123 )?; 124 125 body.append_child(&input)?; 126 127 Ok(()) 128 } 129 130 /// Focus or blur text agent to toggle mobile keyboard. 131 pub fn update_text_agent(runner: MutexGuard<'_, AppRunner>) -> Option<()> { 132 use wasm_bindgen::JsCast; 133 use web_sys::HtmlInputElement; 134 let window = web_sys::window()?; 135 let document = window.document()?; 136 let input: HtmlInputElement = document.get_element_by_id(AGENT_ID)?.dyn_into().unwrap(); 137 let canvas_style = canvas_element(runner.canvas_id())?.style(); 138 139 if runner.mutable_text_under_cursor { 140 let is_already_editing = input.hidden(); 141 if is_already_editing { 142 input.set_hidden(false); 143 input.focus().ok()?; 144 145 // Move up canvas so that text edit is shown at ~30% of screen height. 146 // Only on touch screens, when keyboard popups. 147 if let Some(latest_touch_pos) = runner.input.latest_touch_pos { 148 let window_height = window.inner_height().ok()?.as_f64()? as f32; 149 let current_rel = latest_touch_pos.y / window_height; 150 151 // estimated amount of screen covered by keyboard 152 let keyboard_fraction = 0.5; 153 154 if current_rel > keyboard_fraction { 155 // below the keyboard 156 157 let target_rel = 0.3; 158 159 // Note: `delta` is negative, since we are moving the canvas UP 160 let delta = target_rel - current_rel; 161 162 let delta = delta.max(-keyboard_fraction); // Don't move it crazy much 163 164 let new_pos_percent = format!("{}%", (delta * 100.0).round()); 165 166 canvas_style.set_property("position", "absolute").ok()?; 167 canvas_style.set_property("top", &new_pos_percent).ok()?; 168 } 169 } 170 } 171 } else { 172 // Drop runner lock 173 drop(runner); 174 175 // Holding the runner lock while calling input.blur() causes a panic. 176 // This is most probably caused by the browser running the event handler 177 // for the triggered blur event synchronously, meaning that the mutex 178 // lock does not get dropped by the time another event handler is called. 179 // 180 // Why this didn't exist before #1290 is a mystery to me, but it exists now 181 // and this apparently is the fix for it 182 // 183 // ¯\_(ツ)_/¯ - @DusterTheFirst 184 input.blur().ok()?; 185 186 input.set_hidden(true); 187 canvas_style.set_property("position", "absolute").ok()?; 188 canvas_style.set_property("top", "0%").ok()?; // move back to normal position 189 } 190 Some(()) 191 } 192 193 /// If context is running under mobile device? 194 fn is_mobile() -> Option<bool> { 195 const MOBILE_DEVICE: [&str; 6] = ["Android", "iPhone", "iPad", "iPod", "webOS", "BlackBerry"]; 196 197 let user_agent = web_sys::window()?.navigator().user_agent().ok()?; 198 let is_mobile = MOBILE_DEVICE.iter().any(|&name| user_agent.contains(name)); 199 Some(is_mobile) 200 } 201 202 // Move text agent to text cursor's position, on desktop/laptop, 203 // candidate window moves following text element (agent), 204 // so it appears that the IME candidate window moves with text cursor. 205 // On mobile devices, there is no need to do that. 206 pub fn move_text_cursor(cursor: Option<egui::Pos2>, canvas_id: &str) -> Option<()> { 207 let style = text_agent().style(); 208 // Note: movint agent on mobile devices will lead to unpredictable scroll. 209 if is_mobile() == Some(false) { 210 cursor.as_ref().and_then(|&egui::Pos2 { x, y }| { 211 let canvas = canvas_element(canvas_id)?; 212 let bounding_rect = text_agent().get_bounding_client_rect(); 213 let y = (y + (canvas.scroll_top() + canvas.offset_top()) as f32) 214 .min(canvas.client_height() as f32 - bounding_rect.height() as f32); 215 let x = x + (canvas.scroll_left() + canvas.offset_left()) as f32; 216 // Canvas is translated 50% horizontally in html. 217 let x = (x - canvas.offset_width() as f32 / 2.0) 218 .min(canvas.client_width() as f32 - bounding_rect.width() as f32); 219 style.set_property("position", "absolute").ok()?; 220 style.set_property("top", &format!("{}px", y)).ok()?; 221 style.set_property("left", &format!("{}px", x)).ok() 222 }) 223 } else { 224 style.set_property("position", "absolute").ok()?; 225 style.set_property("top", "0px").ok()?; 226 style.set_property("left", "0px").ok() 227 } 228 }