eframe web: ignore keyboard events unless canvas has focus (#4718)

* Fixes https://github.com/rerun-io/rerun/issues/6638
* Related? https://github.com/emilk/egui/issues/4563

This improves how an eframe canvas works inside of a larger web page,
and how it works when there are multiple eframe apps in the same page.

`eframe` will set `tabindex="0"` on the canvas automatically, making it
focusable.
It will also set `outline: none` on the CSS, so the focused canvas won't
have an ugly outline.


## Breaking changes
You may wanna add this to your `index.html` to give the canvas focus on
startup:
```js
document.getElementById("the_canvas_id").focus();
```

## Test setup
```sh
./scripts/build_demo_web.sh
./scripts/start_server.sh
open http://localhost:8888/multiple_apps.html
```
Then open the "Input Event History" and "Text Edit" windows

## Tested
* Chromium
	* [x] drag-and-drop of files
	* Test both when a `TextEdit` is focused and when it is not:
	  * [x] `Event::Key`
	  * [x] `Event::Text`
	  * [x] copy-cut-paste
	  * [x] Wheel scroll
	* [x] `Event::PointerGone`
	* [x] Mouse drag
	* [x] Mouse click
	* [x] Mouse right-click
	* [x] Defocus all eframe canvas, and then start typing text
* [x] Firefox (all of the above)
* [x] Desktop Safari (all of the above)
* [x] Mobile Safari


## Future work (pre-existing issues)
* https://github.com/emilk/egui/issues/4723
* https://github.com/emilk/egui/issues/4724
* https://github.com/emilk/egui/issues/4725
* https://github.com/emilk/egui/issues/4726
This commit is contained in:
Emil Ernerfeldt 2024-06-28 15:01:06 +02:00 committed by GitHub
parent 779312ac0c
commit 3b9f964aed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 223 additions and 136 deletions

View File

@ -187,6 +187,10 @@ impl AppRunner {
///
/// The result can be painted later with a call to [`Self::run_and_paint`] or [`Self::paint`].
pub fn logic(&mut self) {
// We sometimes miss blur/focus events due to the text agent, so let's just poll each frame:
self.input
.set_focus(super::has_focus(self.canvas()) || self.text_agent.has_focus());
let canvas_size = super::canvas_size_in_points(self.canvas(), self.egui_ctx());
let mut raw_input = self.input.new_frame(canvas_size);

View File

@ -36,8 +36,12 @@ impl WebInput {
raw_input
}
/// On alt-tab and similar.
pub fn on_web_page_focus_change(&mut self, focused: bool) {
/// On alt-tab, or user clicking another HTML element.
pub fn set_focus(&mut self, focused: bool) {
if self.raw.focused == focused {
return;
}
// log::debug!("on_web_page_focus_change: {focused}");
self.raw.modifiers = egui::Modifiers::default(); // Avoid sticky modifier keys on alt-tab:
self.raw.focused = focused;

View File

@ -2,6 +2,9 @@ use web_sys::EventTarget;
use super::*;
// TODO(emilk): there are more calls to `prevent_default` and `stop_propagaton`
// than what is probably needed.
// ------------------------------------------------------------------------
/// Calls `request_animation_frame` to schedule repaint.
@ -54,10 +57,9 @@ pub(crate) fn install_event_handlers(runner_ref: &WebRunner) -> Result<(), JsVal
let document = window.document().unwrap();
let canvas = runner_ref.try_lock().unwrap().canvas().clone();
install_blur_focus(runner_ref, &document)?;
install_blur_focus(runner_ref, &window)?;
install_blur_focus(runner_ref, &canvas)?;
prevent_default(
prevent_default_and_stop_propagation(
runner_ref,
&canvas,
&[
@ -69,8 +71,11 @@ pub(crate) fn install_event_handlers(runner_ref: &WebRunner) -> Result<(), JsVal
],
)?;
install_keydown(runner_ref, &document)?;
install_keyup(runner_ref, &document)?;
install_keydown(runner_ref, &canvas)?;
install_keyup(runner_ref, &canvas)?;
// It seems copy/cut/paste events only work on the document,
// so we check if we have focus inside of the handler.
install_copy_cut_paste(runner_ref, &document)?;
install_mousedown(runner_ref, &canvas)?;
@ -94,9 +99,12 @@ pub(crate) fn install_event_handlers(runner_ref: &WebRunner) -> Result<(), JsVal
}
fn install_blur_focus(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> {
// NOTE: because of the text agent we sometime miss 'blur' events,
// so we also poll the focus state each frame in `AppRunner::logic`.
for event_name in ["blur", "focus"] {
let closure = move |_event: web_sys::MouseEvent, runner: &mut AppRunner| {
// log::debug!("{event_name:?}");
let has_focus = event_name == "focus";
if !has_focus {
@ -104,7 +112,7 @@ fn install_blur_focus(runner_ref: &WebRunner, target: &EventTarget) -> Result<()
runner.save();
}
runner.input.on_web_page_focus_change(has_focus);
runner.input.set_focus(has_focus);
runner.egui_ctx().request_repaint();
};
@ -118,94 +126,140 @@ fn install_keydown(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), J
target,
"keydown",
|event: web_sys::KeyboardEvent, runner| {
if event.is_composing() || event.key_code() == 229 {
// https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/
if !runner.input.raw.focused {
return;
}
let modifiers = modifiers_from_kb_event(&event);
runner.input.raw.modifiers = modifiers;
let key = event.key();
let egui_key = translate_key(&key);
if let Some(key) = egui_key {
runner.input.raw.events.push(egui::Event::Key {
key,
physical_key: None, // TODO(fornwall)
pressed: true,
repeat: false, // egui will fill this in for us!
modifiers,
});
}
if !modifiers.ctrl
&& !modifiers.command
&& !should_ignore_key(&key)
// When text agent is focused, it is responsible for handling input events
&& !runner.text_agent.has_focus()
{
runner.input.raw.events.push(egui::Event::Text(key));
}
runner.needs_repaint.repaint_asap();
if let Some(text) = text_from_keyboard_event(&event) {
runner.input.raw.events.push(egui::Event::Text(text));
runner.needs_repaint.repaint_asap();
let egui_wants_keyboard = runner.egui_ctx().wants_keyboard_input();
// If this is indeed text, then prevent any other action.
event.prevent_default();
#[allow(clippy::if_same_then_else)]
let prevent_default = if egui_key == Some(egui::Key::Tab) {
// Always prevent moving cursor to url bar.
// egui wants to use tab to move to the next text field.
true
} else if egui_key == Some(egui::Key::P) {
#[allow(clippy::needless_bool)]
if modifiers.ctrl || modifiers.command || modifiers.mac_cmd {
true // Prevent ctrl-P opening the print dialog. Users may want to use it for a command palette.
} else {
false // let normal P:s through
// Assume egui uses all key events, and don't let them propagate to parent elements.
event.stop_propagation();
}
} else if egui_wants_keyboard {
matches!(
event.key().as_str(),
"Backspace" // so we don't go back to previous page when deleting text
| "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ArrowUp" // cmd-left is "back" on Mac (https://github.com/emilk/egui/issues/58)
)
} else {
// We never want to prevent:
// * F5 / cmd-R (refresh)
// * cmd-shift-C (debug tools)
// * cmd/ctrl-c/v/x (or we stop copy/past/cut events)
false
};
// log::debug!(
// "On key-down {:?}, egui_wants_keyboard: {}, prevent_default: {}",
// event.key().as_str(),
// egui_wants_keyboard,
// prevent_default
// );
if prevent_default {
event.prevent_default();
// event.stop_propagation();
}
on_keydown(event, runner);
},
)
}
fn install_keyup(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> {
runner_ref.add_event_listener(target, "keyup", |event: web_sys::KeyboardEvent, runner| {
let modifiers = modifiers_from_kb_event(&event);
runner.input.raw.modifiers = modifiers;
if let Some(key) = translate_key(&event.key()) {
runner.input.raw.events.push(egui::Event::Key {
key,
physical_key: None, // TODO(fornwall)
pressed: false,
repeat: false,
modifiers,
});
}
#[allow(clippy::needless_pass_by_value)] // So that we can pass it directly to `add_event_listener`
pub(crate) fn on_keydown(event: web_sys::KeyboardEvent, runner: &mut AppRunner) {
let has_focus = runner.input.raw.focused;
if !has_focus {
return;
}
if event.is_composing() || event.key_code() == 229 {
// https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/
return;
}
let modifiers = modifiers_from_kb_event(&event);
runner.input.raw.modifiers = modifiers;
let key = event.key();
let egui_key = translate_key(&key);
if let Some(egui_key) = egui_key {
runner.input.raw.events.push(egui::Event::Key {
key: egui_key,
physical_key: None, // TODO(fornwall)
pressed: true,
repeat: false, // egui will fill this in for us!
modifiers,
});
runner.needs_repaint.repaint_asap();
})
let prevent_default = should_prevent_default_for_key(runner, &modifiers, egui_key);
// log::debug!(
// "On keydown {:?} {egui_key:?}, has_focus: {has_focus}, egui_wants_keyboard: {}, prevent_default: {prevent_default}",
// event.key().as_str(),
// runner.egui_ctx().wants_keyboard_input()
// );
if prevent_default {
event.prevent_default();
}
// Assume egui uses all key events, and don't let them propagate to parent elements.
event.stop_propagation();
}
}
/// If the canvas (or text agent) has focus:
/// should we prevent the default browser event action when the user presses this key?
fn should_prevent_default_for_key(
runner: &AppRunner,
modifiers: &egui::Modifiers,
egui_key: egui::Key,
) -> bool {
// NOTE: We never want to prevent:
// * F5 / cmd-R (refresh)
// * cmd-shift-C (debug tools)
// * cmd/ctrl-c/v/x (lest we prevent copy/paste/cut events)
// Prevent ctrl-P from opening the print dialog. Users may want to use it for a command palette.
if egui_key == egui::Key::P && (modifiers.ctrl || modifiers.command || modifiers.mac_cmd) {
return true;
}
if egui_key == egui::Key::Space && !runner.text_agent.has_focus() {
// Space scrolls the web page, but we don't want that while canvas has focus
// However, don't prevent it if text agent has focus, or we can't type space!
return true;
}
matches!(
egui_key,
// Prevent browser from focusing the next HTML element.
// egui uses Tab to move focus within the egui app.
egui::Key::Tab
// So we don't go back to previous page while canvas has focus
| egui::Key::Backspace
// Don't scroll web page while canvas has focus.
// Also, cmd-left is "back" on Mac (https://github.com/emilk/egui/issues/58)
| egui::Key::ArrowDown | egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp
)
}
fn install_keyup(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> {
runner_ref.add_event_listener(target, "keyup", on_keyup)
}
#[allow(clippy::needless_pass_by_value)] // So that we can pass it directly to `add_event_listener`
pub(crate) fn on_keyup(event: web_sys::KeyboardEvent, runner: &mut AppRunner) {
let modifiers = modifiers_from_kb_event(&event);
runner.input.raw.modifiers = modifiers;
if let Some(key) = translate_key(&event.key()) {
runner.input.raw.events.push(egui::Event::Key {
key,
physical_key: None, // TODO(fornwall)
pressed: false,
repeat: false,
modifiers,
});
}
runner.needs_repaint.repaint_asap();
let has_focus = runner.input.raw.focused;
if has_focus {
// Assume egui uses all key events, and don't let them propagate to parent elements.
event.stop_propagation();
}
}
fn install_copy_cut_paste(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> {
@ -214,7 +268,7 @@ fn install_copy_cut_paste(runner_ref: &WebRunner, target: &EventTarget) -> Resul
if let Some(data) = event.clipboard_data() {
if let Ok(text) = data.get_data("text") {
let text = text.replace("\r\n", "\n");
if !text.is_empty() {
if !text.is_empty() && runner.input.raw.focused {
runner.input.raw.events.push(egui::Event::Paste(text));
runner.needs_repaint.repaint_asap();
}
@ -226,14 +280,16 @@ fn install_copy_cut_paste(runner_ref: &WebRunner, target: &EventTarget) -> Resul
#[cfg(web_sys_unstable_apis)]
runner_ref.add_event_listener(target, "cut", |event: web_sys::ClipboardEvent, runner| {
runner.input.raw.events.push(egui::Event::Cut);
if runner.input.raw.focused {
runner.input.raw.events.push(egui::Event::Cut);
// In Safari we are only allowed to write to the clipboard during the
// event callback, which is why we run the app logic here and now:
runner.logic();
// In Safari we are only allowed to write to the clipboard during the
// event callback, which is why we run the app logic here and now:
runner.logic();
// Make sure we paint the output of the above logic call asap:
runner.needs_repaint.repaint_asap();
// Make sure we paint the output of the above logic call asap:
runner.needs_repaint.repaint_asap();
}
event.stop_propagation();
event.prevent_default();
@ -241,14 +297,16 @@ fn install_copy_cut_paste(runner_ref: &WebRunner, target: &EventTarget) -> Resul
#[cfg(web_sys_unstable_apis)]
runner_ref.add_event_listener(target, "copy", |event: web_sys::ClipboardEvent, runner| {
runner.input.raw.events.push(egui::Event::Copy);
if runner.input.raw.focused {
runner.input.raw.events.push(egui::Event::Copy);
// In Safari we are only allowed to write to the clipboard during the
// event callback, which is why we run the app logic here and now:
runner.logic();
// In Safari we are only allowed to write to the clipboard during the
// event callback, which is why we run the app logic here and now:
runner.logic();
// Make sure we paint the output of the above logic call asap:
runner.needs_repaint.repaint_asap();
// Make sure we paint the output of the above logic call asap:
runner.needs_repaint.repaint_asap();
}
event.stop_propagation();
event.prevent_default();
@ -299,7 +357,7 @@ pub(crate) fn install_color_scheme_change_event(runner_ref: &WebRunner) -> Resul
Ok(())
}
fn prevent_default(
fn prevent_default_and_stop_propagation(
runner_ref: &WebRunner,
target: &EventTarget,
event_names: &[&'static str],
@ -307,7 +365,7 @@ fn prevent_default(
for event_name in event_names {
let closure = move |event: web_sys::MouseEvent, _runner: &mut AppRunner| {
event.prevent_default();
// event.stop_propagation();
event.stop_propagation();
// log::debug!("Preventing event {event_name:?}");
};
@ -348,9 +406,6 @@ fn install_mousedown(runner_ref: &WebRunner, target: &EventTarget) -> Result<(),
}
fn install_mousemove(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> {
// NOTE: we register "mousemove" on `document` instead of just the canvas
// in order to track a dragged mouse outside the canvas.
// See https://github.com/emilk/egui/issues/3157
runner_ref.add_event_listener(target, "mousemove", |event: web_sys::MouseEvent, runner| {
let modifiers = modifiers_from_mouse_event(&event);
runner.input.raw.modifiers = modifiers;
@ -363,11 +418,10 @@ fn install_mousemove(runner_ref: &WebRunner, target: &EventTarget) -> Result<(),
}
fn install_mouseup(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> {
// Use `document` here to notice if the user releases a drag outside of the canvas.
// See https://github.com/emilk/egui/issues/3157
runner_ref.add_event_listener(target, "mouseup", |event: web_sys::MouseEvent, runner| {
let modifiers = modifiers_from_mouse_event(&event);
runner.input.raw.modifiers = modifiers;
if let Some(button) = button_from_mouse_event(&event) {
let pos = pos_from_mouse_event(runner.canvas(), &event, runner.egui_ctx());
let modifiers = runner.input.raw.modifiers;
@ -458,8 +512,6 @@ fn install_touchmove(runner_ref: &WebRunner, target: &EventTarget) -> Result<(),
}
fn install_touchend(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> {
// Use `document` here to notice if the user releases a drag outside of the canvas.
// See https://github.com/emilk/egui/issues/3157
runner_ref.add_event_listener(target, "touchend", |event: web_sys::TouchEvent, runner| {
if let Some(pos) = runner.input.latest_touch_pos {
let modifiers = runner.input.raw.modifiers;

View File

@ -83,40 +83,50 @@ pub fn push_touches(runner: &mut AppRunner, phase: egui::TouchPhase, event: &web
}
}
/// Web sends all keys as strings, so it is up to us to figure out if it is
/// a real text input or the name of a key.
pub fn should_ignore_key(key: &str) -> bool {
/// The text input from a keyboard event (e.g. `X` when pressing the `X` key).
pub fn text_from_keyboard_event(event: &web_sys::KeyboardEvent) -> Option<String> {
let key = event.key();
let is_function_key = key.starts_with('F') && key.len() > 1;
is_function_key
|| matches!(
key,
"Alt"
| "ArrowDown"
| "ArrowLeft"
| "ArrowRight"
| "ArrowUp"
| "Backspace"
| "CapsLock"
| "ContextMenu"
| "Control"
| "Delete"
| "End"
| "Enter"
| "Esc"
| "Escape"
| "GroupNext" // https://github.com/emilk/egui/issues/510
| "Help"
| "Home"
| "Insert"
| "Meta"
| "NumLock"
| "PageDown"
| "PageUp"
| "Pause"
| "ScrollLock"
| "Shift"
| "Tab"
)
if is_function_key {
return None;
}
let is_control_key = matches!(
key.as_str(),
"Alt"
| "ArrowDown"
| "ArrowLeft"
| "ArrowRight"
| "ArrowUp"
| "Backspace"
| "CapsLock"
| "ContextMenu"
| "Control"
| "Delete"
| "End"
| "Enter"
| "Esc"
| "Escape"
| "GroupNext" // https://github.com/emilk/egui/issues/510
| "Help"
| "Home"
| "Insert"
| "Meta"
| "NumLock"
| "PageDown"
| "PageUp"
| "Pause"
| "ScrollLock"
| "Shift"
| "Tab"
);
if is_control_key {
return None;
}
Some(key)
}
/// Web sends all keys as strings, so it is up to us to figure out if it is

View File

@ -88,6 +88,11 @@ impl TextAgent {
runner_ref.add_event_listener(&input, "compositionupdate", on_composition_update)?;
runner_ref.add_event_listener(&input, "compositionend", on_composition_end)?;
// The canvas doesn't get keydown/keyup events when the text agent is focused,
// so we need to forward them to the runner:
runner_ref.add_event_listener(&input, "keydown", super::events::on_keydown)?;
runner_ref.add_event_listener(&input, "keyup", super::events::on_keyup)?;
Ok(Self {
input,
prev_ime_output: Default::default(),

View File

@ -68,6 +68,16 @@ impl WebRunner {
let text_agent = TextAgent::attach(self)?;
let runner = AppRunner::new(canvas_id, web_options, app_creator, text_agent).await?;
{
// Make sure the canvas can be given focus.
// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
runner.canvas().set_tab_index(0);
// Don't outline the canvas when it has focus:
runner.canvas().style().set_property("outline", "none")?;
}
self.runner.replace(Some(runner));
{

View File

@ -96,8 +96,8 @@
</head>
<body>
<!-- The WASM code will resize the canvas dynamically -->
<canvas id="the_canvas_id"></canvas>
<div class="centered" id="center_text">
<p style="font-size:16px">
Loading…
@ -175,6 +175,9 @@
console.debug("App started.");
document.getElementById("center_text").innerHTML = '';
// Make sure the canvas is focused so it can receive keyboard events right away:
document.getElementById("the_canvas_id").focus();
}
function on_error(error) {

View File

@ -38,7 +38,7 @@
margin: 8px;
padding: 8px;
width: 45%;
height: 98%;
height: 110%;
}
.centered {
@ -94,7 +94,6 @@
Stop one app
</button>
<canvas id="canvas_id_one"></canvas>
<canvas id="canvas_id_two"></canvas>