gpui_web: Improve mobile keyboard focus handling (#63220)
Prevent mobile browsers from dismissing the software keyboard during touch handling, and only activate the keyboard when a touch targets an editable text input. On iOS, the hidden IME element is made editable before it receives focus so the keyboard can reopen after interacting with non-editable controls or dismissing it manually. Release Notes: - N/A
This commit is contained in:
1 parent
ff521e234b
commit
d1e6a0a300
1 file changed
+59
-11
@@ -123,6 +123,7 @@ impl WebWindowInner {
|
||||
let mut handles = vec![
|
||||
self.register_pointer_down(),
|
||||
self.register_pointer_up(),
|
||||
self.register_touch_end(),
|
||||
self.register_pointer_move(),
|
||||
self.register_pointer_leave(),
|
||||
self.register_wheel(),
|
||||
@@ -193,7 +194,14 @@ impl WebWindowInner {
|
||||
let event: web_sys::PointerEvent = event.unchecked_into();
|
||||
event.prevent_default();
|
||||
|
||||
this.ime_mirror.focus();
|
||||
let pointer_type = event.pointer_type();
|
||||
let position = pointer_position_in_element(&event);
|
||||
if pointer_type != "touch" || this.pointer_targets_text_input(position) {
|
||||
if pointer_type == "touch" {
|
||||
this.ime_mirror.set_read_only(false);
|
||||
}
|
||||
this.ime_mirror.focus();
|
||||
}
|
||||
|
||||
// Capture the pointer so drags that leave the canvas keep
|
||||
// delivering pointermove/pointerup here; otherwise a release
|
||||
@@ -202,7 +210,6 @@ impl WebWindowInner {
|
||||
this.canvas.set_pointer_capture(event.pointer_id()).ok();
|
||||
|
||||
let button = dom_mouse_button_to_gpui(event.button());
|
||||
let position = pointer_position_in_element(&event);
|
||||
let modifiers = modifiers_from_mouse_event(&event, this.is_mac);
|
||||
let time = js_sys::Date::now();
|
||||
|
||||
@@ -225,6 +232,16 @@ impl WebWindowInner {
|
||||
})
|
||||
}
|
||||
|
||||
fn pointer_targets_text_input(&self, position: Point<Pixels>) -> bool {
|
||||
self.with_input_handler(|handler| {
|
||||
handler.query_accepts_text_input()
|
||||
&& handler
|
||||
.element_bounds()
|
||||
.is_some_and(|bounds| bounds.contains(&position))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn register_pointer_up(self: &Rc<Self>) -> EventListenerHandle {
|
||||
let this = Rc::clone(self);
|
||||
self.listen("pointerup", move |event: JsValue| {
|
||||
@@ -252,32 +269,41 @@ impl WebWindowInner {
|
||||
}));
|
||||
|
||||
if event.pointer_type() == "touch" {
|
||||
this.sync_virtual_keyboard();
|
||||
this.sync_virtual_keyboard(this.pointer_targets_text_input(position));
|
||||
}
|
||||
this.schedule_ime_mirror_sync();
|
||||
})
|
||||
}
|
||||
|
||||
/// Cancels touch default handling separately because iOS does not consistently
|
||||
/// transfer pointer-event cancellation to the corresponding touch event.
|
||||
fn register_touch_end(self: &Rc<Self>) -> EventListenerHandle {
|
||||
self.listen_non_passive("touchend", move |event: JsValue| {
|
||||
let event: web_sys::Event = event.unchecked_into();
|
||||
event.prevent_default();
|
||||
})
|
||||
}
|
||||
|
||||
/// See [`ImeMirror::schedule_sync`].
|
||||
fn schedule_ime_mirror_sync(self: &Rc<Self>) {
|
||||
ImeMirror::schedule_sync(self);
|
||||
}
|
||||
|
||||
/// Aligns the software keyboard with GPUI's focus after a touch tap.
|
||||
/// Aligns the software keyboard with the text input targeted by a touch tap.
|
||||
///
|
||||
/// Mobile browsers show the keyboard only when an editable element is
|
||||
/// focused from within a user gesture, so this runs while the tap's
|
||||
/// `pointerup` is still on the stack (by which point GPUI has usually
|
||||
/// painted a frame since the `MouseDown`, so the input handler reflects
|
||||
/// the tap's focus change). `readOnly` suppresses the keyboard while
|
||||
/// keeping hardware key events flowing to the hidden input; the
|
||||
/// blur/focus cycle is what makes the browser re-evaluate keyboard
|
||||
/// visibility, since `focus()` on an already-focused element is a no-op.
|
||||
/// keeping the hidden input available to the IME. Leaving it blurred after
|
||||
/// a non-editable tap lets the next editable tap establish a new input
|
||||
/// session instead of relying on a same-task blur/focus cycle, which iOS
|
||||
/// may coalesce.
|
||||
///
|
||||
/// We don't use `navigator.virtualKeyboard` here because it's
|
||||
/// Chromium-only.
|
||||
fn sync_virtual_keyboard(&self) {
|
||||
let editable = self.state.borrow().input_handler.is_some();
|
||||
fn sync_virtual_keyboard(self: &Rc<Self>, editable: bool) {
|
||||
let was_editable = !self.ime_mirror.read_only();
|
||||
self.ime_mirror.set_read_only(!editable);
|
||||
// Cycle only on an actual editability transition. Cycling on every
|
||||
@@ -285,9 +311,31 @@ impl WebWindowInner {
|
||||
// the tapped caret's context, racing its word segmentation.
|
||||
if editable != was_editable {
|
||||
self.suppress_focus_status_events.set(true);
|
||||
self.ime_mirror.blur();
|
||||
self.ime_mirror.focus();
|
||||
if editable {
|
||||
self.ime_mirror.focus();
|
||||
} else {
|
||||
self.ime_mirror.blur();
|
||||
}
|
||||
self.suppress_focus_status_events.set(false);
|
||||
|
||||
if editable {
|
||||
let callback = wasm_bindgen::closure::Closure::once_into_js({
|
||||
let this = Rc::clone(self);
|
||||
move || {
|
||||
this.state.borrow_mut().is_active = true;
|
||||
this.with_callback(
|
||||
|callbacks| &mut callbacks.active_status_change,
|
||||
|callback| callback(true),
|
||||
);
|
||||
}
|
||||
});
|
||||
if let Err(error) = self
|
||||
.browser_window
|
||||
.set_timeout_with_callback(callback.unchecked_ref())
|
||||
{
|
||||
log::warn!("failed to defer web window activation: {error:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user