Web: don't scroll host page when text agent or canvas grabs focus (#8296)

* Closes <https://github.com/emilk/egui/issues/8295>
* [x] I have followed the instructions in the PR template

When an eframe app is embedded in a scrollable host page, the host page
jumped to the top whenever the app booted or grabbed keyboard focus.
Cause: the hidden text-agent `<input>` sat at (0,0) of `<body>` with the
`autofocus` attribute, and all focus calls (text agent and canvas) used
plain `focus()`, so the browser scrolled the focused element into view —
i.e. to the top of the page.

Changes:

* All focus calls in `eframe` web (text agent, canvas, Gboard
blur/refocus workaround) now go through a new `focus_without_scroll()`
helper that uses `focus_with_options` with `preventScroll: true`
(supported by all evergreen browsers).
* The `autofocus` attribute is replaced with an explicit focus call
after DOM insertion — the browser-internal autofocus path always scrolls
the element into view and cannot be prevented by any focus option.
Boot-focus behavior is preserved.
* The text-agent input is initially parked at the canvas origin instead
of (0,0) of the page, so any residual scroll-into-view would target the
canvas rather than the top of the host page. (`move_to` keeps
repositioning it for IME as before.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Emil Ernerfeldt
2026-07-08 07:30:24 -07:00
committed by GitHub
parent d3850a9ca7
commit b865da1942
6 changed files with 37 additions and 13 deletions
+1
View File
@@ -232,6 +232,7 @@ web-sys = { workspace = true, features = [
"File",
"FileList",
"FocusEvent",
"FocusOptions",
"HtmlCanvasElement",
"HtmlElement",
"HtmlInputElement",
+1 -1
View File
@@ -401,7 +401,7 @@ impl AppRunner {
} else {
// We are not editing text - give the focus to the canvas.
self.text_agent.blur();
self.canvas().focus().ok();
super::focus_without_scroll(self.canvas()).ok();
}
}
+1 -1
View File
@@ -599,7 +599,7 @@ fn install_pointerup(runner_ref: &WebRunner, target: &EventTarget) -> Result<(),
// not working when focusing on a text field in an egui app.
// This attempts to fix that by forcing the focus on any
// click on the canvas.
runner.canvas().focus().ok();
super::focus_without_scroll(runner.canvas()).ok();
// In Safari we are only allowed to do certain things
// (like playing audio, start a download, etc)
+11
View File
@@ -84,6 +84,17 @@ pub(crate) fn has_focus<T: JsCast>(element: &T) -> bool {
try_has_focus(element).unwrap_or(false)
}
/// Focus the given element without scrolling it into view.
///
/// Scrolling the element into view would scroll the whole page when
/// the app is embedded in a larger scrollable page,
/// see <https://github.com/emilk/egui/issues/8295>.
pub(crate) fn focus_without_scroll(element: &web_sys::HtmlElement) -> Result<(), JsValue> {
let options = web_sys::FocusOptions::new();
options.set_prevent_scroll(true);
element.focus_with_options(&options)
}
/// Current time in seconds (since undefined point in time).
///
/// Monotonically increasing.
+22 -10
View File
@@ -4,7 +4,7 @@
use std::cell::Cell;
use wasm_bindgen::prelude::*;
use web_sys::{Document, Node};
use web_sys::Document;
use super::{AppRunner, WebRunner};
@@ -15,19 +15,23 @@ pub struct TextAgent {
impl TextAgent {
/// Attach the agent to the document.
pub fn attach(runner_ref: &WebRunner, root: Node) -> Result<Self, JsValue> {
pub fn attach(
runner_ref: &WebRunner,
canvas: &web_sys::HtmlCanvasElement,
) -> 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::HtmlElement>()?;
input.set_autofocus(true)?;
let input = input.dyn_into::<web_sys::HtmlInputElement>()?;
.dyn_into::<web_sys::HtmlInputElement>()?;
input.set_type("text");
input.set_attribute("autocapitalize", "off")?;
// append it to `<body>` and hide it outside of the viewport
// Hide the element, and park it over the canvas
// so that focusing it can never scroll some other part
// of the page into view.
let canvas_rect = super::canvas_content_rect(canvas);
let style = input.style();
style.set_property("background-color", "transparent")?;
style.set_property("border", "none")?;
@@ -36,11 +40,12 @@ impl TextAgent {
style.set_property("height", "1px")?;
style.set_property("caret-color", "transparent")?;
style.set_property("position", "absolute")?;
style.set_property("top", "0")?;
style.set_property("left", "0")?;
style.set_property("top", &format!("{}px", canvas_rect.min.y))?;
style.set_property("left", &format!("{}px", canvas_rect.min.x))?;
// Prevent auto-zoom on mobile browsers (requires at least 16px).
style.set_property("font-size", "16px")?;
let root = canvas.get_root_node();
if root.has_type::<Document>() {
// root object is a document, append to its body
root.dyn_into::<Document>()?
@@ -52,6 +57,13 @@ impl TextAgent {
root.append_child(&input)?;
}
// Focus the app on startup, without scrolling the page.
// We do this instead of setting the `autofocus` attribute,
// since the browser scrolls the focused element into view when
// honoring `autofocus`, and there is no way to prevent that.
// See https://github.com/emilk/egui/issues/8295
super::focus_without_scroll(&input).ok();
// attach event listeners
let on_input = {
@@ -67,7 +79,7 @@ impl TextAgent {
// between versions 14.7.09 and 17.0.12.
if !event.is_composing() {
input.blur().ok();
input.focus().ok();
super::focus_without_scroll(&input).ok();
}
if event.is_composing() {
@@ -221,7 +233,7 @@ impl TextAgent {
log::trace!("Focusing text agent");
if let Err(err) = self.input.focus() {
if let Err(err) = super::focus_without_scroll(&self.input) {
log::error!("failed to set focus: {}", super::string_from_js_value(&err));
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ impl WebRunner {
{
// First set up the app runner:
let text_agent = TextAgent::attach(self, canvas.get_root_node())?;
let text_agent = TextAgent::attach(self, &canvas)?;
let app_runner =
AppRunner::new(canvas.clone(), web_options, app_creator, text_agent).await?;
self.app_runner.replace(Some(app_runner));