179 lines
5.9 KiB
Rust
179 lines
5.9 KiB
Rust
//! The text agent is a hidden `<input>` element used to capture
|
|
//! IME and mobile keyboard input events.
|
|
|
|
use std::cell::Cell;
|
|
|
|
use wasm_bindgen::prelude::*;
|
|
|
|
use super::{AppRunner, WebRunner};
|
|
|
|
pub struct TextAgent {
|
|
input: web_sys::HtmlInputElement,
|
|
prev_ime_output: Cell<Option<egui::output::IMEOutput>>,
|
|
}
|
|
|
|
impl TextAgent {
|
|
/// Attach the agent to the document.
|
|
pub fn attach(runner_ref: &WebRunner) -> Result<Self, JsValue> {
|
|
let document = web_sys::window().unwrap().document().unwrap();
|
|
|
|
// create an `<input>` element
|
|
let input = document
|
|
.create_element("input")?
|
|
.dyn_into::<web_sys::HtmlInputElement>()?;
|
|
input.set_type("text");
|
|
|
|
// append it to `<body>` and hide it outside of the viewport
|
|
let style = input.style();
|
|
style.set_property("opacity", "0")?;
|
|
style.set_property("width", "1px")?;
|
|
style.set_property("height", "1px")?;
|
|
style.set_property("position", "absolute")?;
|
|
style.set_property("top", "0")?;
|
|
style.set_property("left", "0")?;
|
|
document.body().unwrap().append_child(&input)?;
|
|
|
|
// attach event listeners
|
|
|
|
let on_input = {
|
|
let input = input.clone();
|
|
move |event: web_sys::InputEvent, runner: &mut AppRunner| {
|
|
let text = input.value();
|
|
// if `is_composing` is true, then user is using IME, for example: emoji, pinyin, kanji, hangul, etc.
|
|
// In that case, the browser emits both `input` and `compositionupdate` events,
|
|
// and we need to ignore the `input` event.
|
|
if !text.is_empty() && !event.is_composing() {
|
|
input.set_value("");
|
|
let event = egui::Event::Text(text);
|
|
runner.input.raw.events.push(event);
|
|
runner.needs_repaint.repaint_asap();
|
|
}
|
|
}
|
|
};
|
|
|
|
let on_composition_start = {
|
|
let input = input.clone();
|
|
move |_: web_sys::CompositionEvent, runner: &mut AppRunner| {
|
|
input.set_value("");
|
|
let event = egui::Event::Ime(egui::ImeEvent::Enabled);
|
|
runner.input.raw.events.push(event);
|
|
// Repaint moves the text agent into place,
|
|
// see `move_to` in `AppRunner::handle_platform_output`.
|
|
runner.needs_repaint.repaint_asap();
|
|
}
|
|
};
|
|
|
|
let on_composition_update = {
|
|
move |event: web_sys::CompositionEvent, runner: &mut AppRunner| {
|
|
let Some(text) = event.data() else { return };
|
|
let event = egui::Event::Ime(egui::ImeEvent::Preedit(text));
|
|
runner.input.raw.events.push(event);
|
|
runner.needs_repaint.repaint_asap();
|
|
}
|
|
};
|
|
|
|
let on_composition_end = {
|
|
let input = input.clone();
|
|
move |event: web_sys::CompositionEvent, runner: &mut AppRunner| {
|
|
let Some(text) = event.data() else { return };
|
|
input.set_value("");
|
|
let event = egui::Event::Ime(egui::ImeEvent::Commit(text));
|
|
runner.input.raw.events.push(event);
|
|
runner.needs_repaint.repaint_asap();
|
|
}
|
|
};
|
|
|
|
runner_ref.add_event_listener(&input, "input", on_input)?;
|
|
runner_ref.add_event_listener(&input, "compositionstart", on_composition_start)?;
|
|
runner_ref.add_event_listener(&input, "compositionupdate", on_composition_update)?;
|
|
runner_ref.add_event_listener(&input, "compositionend", on_composition_end)?;
|
|
|
|
Ok(Self {
|
|
input,
|
|
prev_ime_output: Default::default(),
|
|
})
|
|
}
|
|
|
|
pub fn move_to(
|
|
&self,
|
|
ime: Option<egui::output::IMEOutput>,
|
|
canvas: &web_sys::HtmlCanvasElement,
|
|
) -> Result<(), JsValue> {
|
|
// Mobile keyboards don't follow the text input it's writing to,
|
|
// instead typically being fixed in place on the bottom of the screen,
|
|
// so don't bother moving the text agent on mobile.
|
|
if is_mobile() {
|
|
return Ok(());
|
|
}
|
|
|
|
// Don't move the text agent unless the position actually changed:
|
|
if self.prev_ime_output.get() == ime {
|
|
return Ok(());
|
|
}
|
|
self.prev_ime_output.set(ime);
|
|
|
|
let Some(ime) = ime else { return Ok(()) };
|
|
|
|
let ime_pos = ime.cursor_rect.left_top();
|
|
let canvas_rect = super::canvas_content_rect(canvas);
|
|
let new_pos = canvas_rect.min + ime_pos.to_vec2();
|
|
|
|
let style = self.input.style();
|
|
style.set_property("top", &format!("{}px", new_pos.y))?;
|
|
style.set_property("left", &format!("{}px", new_pos.x))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn set_focus(&self, on: bool) {
|
|
if on {
|
|
self.focus();
|
|
} else {
|
|
self.blur();
|
|
}
|
|
}
|
|
|
|
pub fn has_focus(&self) -> bool {
|
|
super::has_focus(&self.input)
|
|
}
|
|
|
|
fn focus(&self) {
|
|
if self.has_focus() {
|
|
return;
|
|
}
|
|
|
|
if let Err(err) = self.input.focus() {
|
|
log::error!("failed to set focus: {}", super::string_from_js_value(&err));
|
|
};
|
|
}
|
|
|
|
fn blur(&self) {
|
|
if !self.has_focus() {
|
|
return;
|
|
}
|
|
|
|
if let Err(err) = self.input.blur() {
|
|
log::error!("failed to set focus: {}", super::string_from_js_value(&err));
|
|
};
|
|
}
|
|
}
|
|
|
|
impl Drop for TextAgent {
|
|
fn drop(&mut self) {
|
|
self.input.remove();
|
|
}
|
|
}
|
|
|
|
/// Returns `true` if the app is likely running on a mobile device.
|
|
fn is_mobile() -> bool {
|
|
fn try_is_mobile() -> Option<bool> {
|
|
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)
|
|
}
|
|
try_is_mobile().unwrap_or(false)
|
|
}
|