use crate::web::string_from_js_value; use super::{ AppRunner, Closure, DEBUG_RESIZE, JsCast as _, JsValue, WebRunner, button_from_mouse_event, location_hash, modifiers_from_kb_event, modifiers_from_mouse_event, modifiers_from_wheel_event, native_pixels_per_point, pos_from_mouse_event, prefers_color_scheme, primary_touch_pos, push_touches, text_from_keyboard_event, translate_key, }; use js_sys::Reflect; use web_sys::{Document, EventTarget, ShadowRoot}; // TODO(emilk): there are more calls to `prevent_default` and `stop_propagation` // than what is probably needed. // ------------------------------------------------------------------------ /// Calls `request_animation_frame` to schedule repaint. /// /// It will only paint if needed, but will always call `request_animation_frame` immediately. pub(crate) fn paint_and_schedule(runner_ref: &WebRunner) -> Result<(), JsValue> { // Only paint and schedule if there has been no panic if let Some(mut runner_lock) = runner_ref.try_lock() { paint_if_needed(&mut runner_lock); drop(runner_lock); runner_ref.request_animation_frame()?; } Ok(()) } fn paint_if_needed(runner: &mut AppRunner) { if runner.needs_repaint.needs_repaint() { if runner.has_outstanding_paint_data() { // We have already run the logic, e.g. in an on-click event, // so let's only present the results: runner.paint(); // We schedule another repaint asap, so that we can run the actual logic // again, which may schedule a new repaint (if there's animations): runner.needs_repaint.repaint_asap(); } else { // Clear the `needs_repaint` flags _before_ // running the logic, as the logic could cause it to be set again. runner.needs_repaint.clear(); let mut stopwatch = crate::stopwatch::Stopwatch::new(); stopwatch.start(); // Run user code… runner.logic(); // …and paint the result. runner.paint(); runner.report_frame_time(stopwatch.total_time_sec()); } } runner.auto_save_if_needed(); } // ------------------------------------------------------------------------ pub(crate) fn install_event_handlers(runner_ref: &WebRunner) -> Result<(), JsValue> { let window = web_sys::window().unwrap(); 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, &canvas)?; prevent_default_and_stop_propagation( runner_ref, &canvas, &[ // Allow users to use ctrl-p for e.g. a command palette: "afterprint", // By default, right-clicks open a browser context menu. // We don't want to do that (right clicks are handled by egui): "contextmenu", ], )?; 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)?; // Use `document` here to notice if the user releases a drag outside of the canvas: // See https://github.com/emilk/egui/issues/3157 install_mousemove(runner_ref, &document)?; install_pointerup(runner_ref, &document)?; install_pointerdown(runner_ref, &canvas)?; install_mouseleave(runner_ref, &canvas)?; install_touchstart(runner_ref, &canvas)?; // Use `document` here to notice if the user drag outside of the canvas: // See https://github.com/emilk/egui/issues/3157 install_touchmove(runner_ref, &document)?; install_touchend(runner_ref, &document)?; install_touchcancel(runner_ref, &canvas)?; install_wheel(runner_ref, &canvas)?; install_gesture(runner_ref, &canvas)?; install_drag_and_drop(runner_ref, &canvas)?; install_window_events(runner_ref, &window)?; install_color_scheme_change_event(runner_ref, &window)?; Ok(()) } 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", "visibilitychange"] { let closure = move |_event: web_sys::MouseEvent, runner: &mut AppRunner, web_runner: &WebRunner| { log::trace!("{} {event_name:?}", runner.canvas().id()); runner.update_focus(); if event_name == "visibilitychange" { // The tab was hidden or shown. An in-flight `requestAnimationFrame` is paused // while hidden, so reschedule the paint loop using the scheduling mechanism // (`setTimeout` vs `requestAnimationFrame`) appropriate for the new state. // This keeps `App::update` running while hidden, and switches back to smooth // animation frames once visible again. if let Err(err) = web_runner.reschedule_frame() { log::error!( "Failed to reschedule frame on visibility change: {}", super::string_from_js_value(&err) ); } } }; runner_ref.add_event_listener_ex( target, event_name, &web_sys::AddEventListenerOptions::default(), closure, )?; } Ok(()) } fn install_keydown(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener( target, "keydown", |event: web_sys::KeyboardEvent, runner| { if !runner.input.raw.focused { return; } let modifiers = modifiers_from_kb_event(&event); if !modifiers.ctrl && !modifiers.command // When text agent is focused, it is responsible for handling input events && !runner.text_agent.has_focus() && let Some(text) = text_from_keyboard_event(&event) { let egui_event = egui::Event::Text(text); let should_stop_propagation = (runner.web_options.should_stop_propagation)(&egui_event); let should_prevent_default = (runner.web_options.should_prevent_default)(&egui_event); runner.input.raw.events.push(egui_event); runner.needs_repaint.repaint_asap(); // If this is indeed text, then prevent any other action. if should_prevent_default { event.prevent_default(); } // Use web options to tell if the event should be propagated to parent elements. if should_stop_propagation { event.stop_propagation(); } } on_keydown(event, runner); }, ) } #[expect(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; } let modifiers = modifiers_from_kb_event(&event); runner.input.set_modifiers(modifiers); let key = event.key(); let logical_key = translate_key(&key); let physical_key = translate_key(&event.code()); // Fall back to the physical key so that modifier keys (which have no logical // `egui::Key`) and non-Latin layouts still produce a `Key` event. if let Some(active_key) = logical_key.or(physical_key) { let egui_event = egui::Event::Key { key: active_key, physical_key, pressed: true, repeat: false, // egui will fill this in for us! modifiers, }; let should_stop_propagation = (runner.web_options.should_stop_propagation)(&egui_event); runner.input.raw.events.push(egui_event); runner.needs_repaint.repaint_asap(); let prevent_default = should_prevent_default_for_key(runner, &modifiers, active_key); if false { log::debug!( "On keydown {:?} {active_key:?}, has_focus: {has_focus}, egui_wants_keyboard: {}, prevent_default: {prevent_default}", event.key().as_str(), runner.egui_ctx().egui_wants_keyboard_input() ); } if prevent_default { event.prevent_default(); } // Use web options to tell if the web event should be propagated to parent elements based on the egui event. if should_stop_propagation { 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 cmd/ctrl plus these keys from triggering the default browser action: let keys = [ egui::Key::Comma, // cmd-, opens options on macOS, which egui apps may wanna "steal" egui::Key::O, // open egui::Key::P, // print (cmd-P is common for command palette) egui::Key::S, // save ]; for key in keys { if egui_key == key && (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) } #[expect(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.set_modifiers(modifiers); let mut should_stop_propagation = true; let logical_key = translate_key(&event.key()); let physical_key = translate_key(&event.code()); if let Some(active_key) = logical_key.or(physical_key) { let egui_event = egui::Event::Key { key: active_key, physical_key, pressed: false, repeat: false, modifiers, }; should_stop_propagation &= (runner.web_options.should_stop_propagation)(&egui_event); runner.input.raw.events.push(egui_event); } if event.key() == "Meta" || event.key() == "Control" { // When pressing Cmd+A (select all) or Ctrl+C (copy), // chromium will not fire a `keyup` for the letter key. // This leads to stuck keys, unless we do this hack. // See https://github.com/emilk/egui/issues/4724 let keys_down = runner.egui_ctx().input(|i| i.keys_down.clone()); #[expect(clippy::iter_over_hash_type)] for key in keys_down { let egui_event = egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers, }; should_stop_propagation &= (runner.web_options.should_stop_propagation)(&egui_event); runner.input.raw.events.push(egui_event); } } runner.needs_repaint.repaint_asap(); // Use web options to tell if the web event should be propagated to parent elements based on the egui event. let has_focus = runner.input.raw.focused; if has_focus && should_stop_propagation { event.stop_propagation(); } } fn install_copy_cut_paste(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener(target, "paste", |event: web_sys::ClipboardEvent, runner| { if !runner.input.raw.focused { return; // The eframe app is not interested } if let Some(data) = event.clipboard_data() && let Ok(text) = data.get_data("text") { let text = text.replace("\r\n", "\n"); let mut should_stop_propagation = true; let mut should_prevent_default = true; if !text.is_empty() { let egui_event = egui::Event::Paste(text); should_stop_propagation = (runner.web_options.should_stop_propagation)(&egui_event); should_prevent_default = (runner.web_options.should_prevent_default)(&egui_event); runner.input.raw.events.push(egui_event); runner.needs_repaint.repaint_asap(); } // Use web options to tell if the web event should be propagated to parent elements based on the egui event. if should_stop_propagation { event.stop_propagation(); } if should_prevent_default { event.prevent_default(); } } })?; runner_ref.add_event_listener(target, "cut", |event: web_sys::ClipboardEvent, runner| { if !runner.input.raw.focused { return; // The eframe app is not interested } 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(); // Make sure we paint the output of the above logic call asap: runner.needs_repaint.repaint_asap(); // Use web options to tell if the web event should be propagated to parent elements based on the egui event. if (runner.web_options.should_stop_propagation)(&egui::Event::Cut) { event.stop_propagation(); } if (runner.web_options.should_prevent_default)(&egui::Event::Cut) { event.prevent_default(); } })?; runner_ref.add_event_listener(target, "copy", |event: web_sys::ClipboardEvent, runner| { if !runner.input.raw.focused { return; // The eframe app is not interested } 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(); // Make sure we paint the output of the above logic call asap: runner.needs_repaint.repaint_asap(); // Use web options to tell if the web event should be propagated to parent elements based on the egui event. if (runner.web_options.should_stop_propagation)(&egui::Event::Copy) { event.stop_propagation(); } if (runner.web_options.should_prevent_default)(&egui::Event::Copy) { event.prevent_default(); } })?; Ok(()) } fn install_window_events(runner_ref: &WebRunner, window: &EventTarget) -> Result<(), JsValue> { // Save-on-close runner_ref.add_event_listener(window, "onbeforeunload", |_: web_sys::Event, runner| { runner.save(); })?; // We want to handle the case of dragging the browser from one monitor to another, // which can cause the DPR to change without any resize event (e.g. Safari). install_dpr_change_event(runner_ref)?; // No need to subscribe to "resize": we already subscribe to the canvas // size using a ResizeObserver, and we also subscribe to DPR changes of the monitor. for event_name in &["load", "pagehide", "pageshow", "popstate"] { runner_ref.add_event_listener(window, event_name, move |_: web_sys::Event, runner| { if DEBUG_RESIZE { log::debug!("{event_name:?}"); } runner.needs_repaint.repaint_asap(); })?; } runner_ref.add_event_listener(window, "hashchange", |_: web_sys::Event, runner| { // `epi::Frame::info(&self)` clones `epi::IntegrationInfo`, but we need to modify the original here runner.frame.info.web_info.location.hash = location_hash(); runner.needs_repaint.repaint_asap(); // tell the user about the new hash })?; Ok(()) } fn install_dpr_change_event(web_runner: &WebRunner) -> Result<(), JsValue> { let original_dpr = native_pixels_per_point(); let window = web_sys::window().unwrap(); let Some(media_query_list) = window.match_media(&format!("(resolution: {original_dpr}dppx)"))? else { log::error!( "Failed to create MediaQueryList: eframe won't be able to detect changes in DPR" ); return Ok(()); }; let closure = move |_: web_sys::Event, app_runner: &mut AppRunner, web_runner: &WebRunner| { let new_dpr = native_pixels_per_point(); log::debug!("Device Pixel Ratio changed from {original_dpr} to {new_dpr}"); if true { // Explicitly resize canvas to match the new DPR. // This is a bit ugly, but I haven't found a better way to do it. let canvas = app_runner.canvas(); canvas.set_width((canvas.width() as f32 * new_dpr / original_dpr).round() as _); canvas.set_height((canvas.height() as f32 * new_dpr / original_dpr).round() as _); log::debug!("Resized canvas to {}x{}", canvas.width(), canvas.height()); } // It may be tempting to call `resize_observer.observe(&canvas)` here, // but unfortunately this has no effect. if let Err(err) = install_dpr_change_event(web_runner) { log::error!( "Failed to install DPR change event: {}", string_from_js_value(&err) ); } }; let options = web_sys::AddEventListenerOptions::default(); options.set_once(true); web_runner.add_event_listener_ex(&media_query_list, "change", &options, closure) } fn install_color_scheme_change_event( runner_ref: &WebRunner, window: &web_sys::Window, ) -> Result<(), JsValue> { for theme in [egui::Theme::Dark, egui::Theme::Light] { if let Some(media_query_list) = prefers_color_scheme(window, theme)? { runner_ref.add_event_listener::( &media_query_list, "change", |_event, runner| { if let Some(theme) = super::system_theme() { runner.input.raw.system_theme = Some(theme); runner.needs_repaint.repaint_asap(); } }, )?; } } Ok(()) } fn prevent_default_and_stop_propagation( runner_ref: &WebRunner, target: &EventTarget, event_names: &[&'static str], ) -> Result<(), JsValue> { for event_name in event_names { let closure = move |event: web_sys::MouseEvent, _runner: &mut AppRunner| { event.prevent_default(); event.stop_propagation(); // log::debug!("Preventing event {event_name:?}"); }; runner_ref.add_event_listener(target, event_name, closure)?; } Ok(()) } fn install_pointerdown(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener( target, "pointerdown", |event: web_sys::PointerEvent, runner: &mut AppRunner| { let modifiers = modifiers_from_mouse_event(&event); runner.input.set_modifiers(modifiers); let mut should_stop_propagation = true; 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.modifiers; let egui_event = egui::Event::PointerButton { pos, button, pressed: true, modifiers, }; should_stop_propagation = (runner.web_options.should_stop_propagation)(&egui_event); runner.input.raw.events.push(egui_event); // 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(); } // Use web options to tell if the web event should be propagated to parent elements based on the egui event. if should_stop_propagation { event.stop_propagation(); } // Note: prevent_default breaks VSCode tab focusing, hence why we don't call it here. }, ) } fn install_pointerup(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener( target, "pointerup", |event: web_sys::PointerEvent, runner| { let modifiers = modifiers_from_mouse_event(&event); runner.input.set_modifiers(modifiers); let pos = pos_from_mouse_event(runner.canvas(), &event, runner.egui_ctx()); if is_interested_in_pointer_event( runner, egui::pos2(event.client_x() as f32, event.client_y() as f32), ) && let Some(button) = button_from_mouse_event(&event) { let modifiers = runner.input.modifiers; let egui_event = egui::Event::PointerButton { pos, button, pressed: false, modifiers, }; let should_stop_propagation = (runner.web_options.should_stop_propagation)(&egui_event); let should_prevent_default = (runner.web_options.should_prevent_default)(&egui_event); runner.input.raw.events.push(egui_event); // Previously on iOS, the canvas would not receive focus on // any touch event, which resulted in the on-screen keyboard // 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. super::focus_without_scroll(runner.canvas()).ok(); // In Safari we are only allowed to do certain things // (like playing audio, start a download, etc) // on user action, such as a click. // So we need to 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(); if should_prevent_default { event.prevent_default(); } // Use web options to tell if the web event should be propagated to parent elements based on the egui event. if should_stop_propagation { event.stop_propagation(); } } }, ) } /// Returns true if the cursor is above the canvas, or if we're dragging something. /// Pass in the position in browser viewport coordinates (usually event.clientX/Y). fn is_interested_in_pointer_event(runner: &AppRunner, pos: egui::Pos2) -> bool { let root_node = runner.canvas().get_root_node(); let element_at_point = if let Some(document) = root_node.dyn_ref::() { document.element_from_point(pos.x, pos.y) } else if let Some(shadow) = root_node.dyn_ref::() { shadow.element_from_point(pos.x, pos.y) } else { None }; let is_hovering_canvas = element_at_point.is_some_and(|element| element.eq(runner.canvas())); let is_pointer_down = runner .egui_ctx() .input(|i| i.pointer.any_down() || i.any_touches()); is_hovering_canvas || is_pointer_down } fn install_mousemove(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener(target, "mousemove", |event: web_sys::MouseEvent, runner| { let modifiers = modifiers_from_mouse_event(&event); runner.input.set_modifiers(modifiers); let pos = pos_from_mouse_event(runner.canvas(), &event, runner.egui_ctx()); if is_interested_in_pointer_event( runner, egui::pos2(event.client_x() as f32, event.client_y() as f32), ) { let egui_event = egui::Event::PointerMoved(pos); let should_stop_propagation = (runner.web_options.should_stop_propagation)(&egui_event); let should_prevent_default = (runner.web_options.should_prevent_default)(&egui_event); runner.input.raw.events.push(egui_event); runner.needs_repaint.repaint(); // Use web options to tell if the web event should be propagated to parent elements based on the egui event. if should_stop_propagation { event.stop_propagation(); } if should_prevent_default { event.prevent_default(); } } }) } fn install_mouseleave(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener( target, "mouseleave", |event: web_sys::MouseEvent, runner| { runner.input.raw.events.push(egui::Event::PointerGone); runner.needs_repaint.repaint_asap(); // Use web options to tell if the web event should be propagated to parent elements based on the egui event. if (runner.web_options.should_stop_propagation)(&egui::Event::PointerGone) { event.stop_propagation(); } if (runner.web_options.should_prevent_default)(&egui::Event::PointerGone) { event.prevent_default(); } }, ) } fn install_touchstart(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener( target, "touchstart", |event: web_sys::TouchEvent, runner| { let mut should_stop_propagation = true; let mut should_prevent_default = true; if let Some((pos, _)) = primary_touch_pos(runner, &event) { let egui_event = egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: runner.input.modifiers, }; should_stop_propagation = (runner.web_options.should_stop_propagation)(&egui_event); should_prevent_default = (runner.web_options.should_prevent_default)(&egui_event); runner.input.raw.events.push(egui_event); } push_touches(runner, egui::TouchPhase::Start, &event); runner.needs_repaint.repaint_asap(); // Use web options to tell if the web event should be propagated to parent elements based on the egui event. if should_stop_propagation { event.stop_propagation(); } if should_prevent_default { event.prevent_default(); } }, ) } fn install_touchmove(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener(target, "touchmove", |event: web_sys::TouchEvent, runner| { if let Some((pos, touch)) = primary_touch_pos(runner, &event) && is_interested_in_pointer_event( runner, egui::pos2(touch.client_x() as f32, touch.client_y() as f32), ) { let egui_event = egui::Event::PointerMoved(pos); let should_stop_propagation = (runner.web_options.should_stop_propagation)(&egui_event); let should_prevent_default = (runner.web_options.should_prevent_default)(&egui_event); runner.input.raw.events.push(egui_event); push_touches(runner, egui::TouchPhase::Move, &event); runner.needs_repaint.repaint(); // Use web options to tell if the web event should be propagated to parent elements based on the egui event. if should_stop_propagation { event.stop_propagation(); } if should_prevent_default { event.prevent_default(); } } }) } fn install_touchend(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener(target, "touchend", |event: web_sys::TouchEvent, runner| { if let Some((pos, touch)) = primary_touch_pos(runner, &event) && is_interested_in_pointer_event( runner, egui::pos2(touch.client_x() as f32, touch.client_y() as f32), ) { // First release mouse to click: let mut should_stop_propagation = true; let mut should_prevent_default = true; let egui_event = egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: runner.input.modifiers, }; should_stop_propagation &= (runner.web_options.should_stop_propagation)(&egui_event); should_prevent_default &= (runner.web_options.should_prevent_default)(&egui_event); runner.input.raw.events.push(egui_event); // Then remove hover effect: should_stop_propagation &= (runner.web_options.should_stop_propagation)(&egui::Event::PointerGone); should_prevent_default &= (runner.web_options.should_prevent_default)(&egui::Event::PointerGone); runner.input.raw.events.push(egui::Event::PointerGone); push_touches(runner, egui::TouchPhase::End, &event); runner.needs_repaint.repaint_asap(); // Use web options to tell if the web event should be propagated to parent elements based on the egui event. if should_stop_propagation { event.stop_propagation(); } if should_prevent_default { event.prevent_default(); } // Fix virtual keyboard IOS // Need call focus at the same time of event if runner.text_agent.has_focus() { runner.text_agent.set_focus(false); runner.text_agent.set_focus(true); } } }) } fn install_touchcancel(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener( target, "touchcancel", |event: web_sys::TouchEvent, runner| { push_touches(runner, egui::TouchPhase::Cancel, &event); event.stop_propagation(); event.prevent_default(); }, )?; Ok(()) } fn install_wheel(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener(target, "wheel", |event: web_sys::WheelEvent, runner| { let unit = match event.delta_mode() { web_sys::WheelEvent::DOM_DELTA_PIXEL => egui::MouseWheelUnit::Point, web_sys::WheelEvent::DOM_DELTA_LINE => egui::MouseWheelUnit::Line, web_sys::WheelEvent::DOM_DELTA_PAGE => egui::MouseWheelUnit::Page, _ => return, }; let delta = -egui::vec2(event.delta_x() as f32, event.delta_y() as f32); let modifiers = modifiers_from_wheel_event(&event); let egui_event = if modifiers.ctrl && !runner.input.modifiers.ctrl { // The browser is saying the ctrl key is down, but it isn't _really_. // This happens on pinch-to-zoom on multitouch trackpads // egui will treat ctrl+scroll as zoom, so it all works. // However, we explicitly handle it here in order to better match the pinch-to-zoom // speed of a native app, without being sensitive to egui's `scroll_zoom_speed` setting. let pinch_to_zoom_sensitivity = 0.01; // Feels good on a Mac trackpad in 2024 let zoom_factor = (pinch_to_zoom_sensitivity * delta.y).exp(); egui::Event::Zoom(zoom_factor) } else { egui::Event::MouseWheel { unit, delta, modifiers, phase: egui::TouchPhase::Move, } }; let should_stop_propagation = (runner.web_options.should_stop_propagation)(&egui_event); let should_prevent_default = (runner.web_options.should_prevent_default)(&egui_event); runner.input.raw.events.push(egui_event); runner.needs_repaint.repaint(); // Use web options to tell if the web event should be propagated to parent elements based on the egui event. if should_stop_propagation { event.stop_propagation(); } if should_prevent_default { event.prevent_default(); } }) } fn install_gesture(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener(target, "gesturestart", |event: web_sys::Event, runner| { runner.input.accumulated_scale = 1.0; runner.input.accumulated_rotation = 0.0; handle_gesture(event, runner); })?; runner_ref.add_event_listener(target, "gesturechange", handle_gesture)?; runner_ref.add_event_listener(target, "gestureend", |event: web_sys::Event, runner| { handle_gesture(event, runner); runner.input.accumulated_scale = 1.0; runner.input.accumulated_rotation = 0.0; })?; Ok(()) } #[expect(clippy::needless_pass_by_value)] // So that we can pass it directly to `add_event_listener` fn handle_gesture(event: web_sys::Event, runner: &mut AppRunner) { // GestureEvent is a non-standard API, so this attempts to get the relevant fields if they exist. let new_scale = Reflect::get(&event, &JsValue::from_str("scale")) .ok() .and_then(|scale| scale.as_f64()) .map_or(1.0, |scale| scale as f32); let new_rotation = Reflect::get(&event, &JsValue::from_str("rotation")) .ok() .and_then(|rotation| rotation.as_f64()) .map_or(0.0, |rotation| rotation.to_radians() as f32); let scale_delta = new_scale / runner.input.accumulated_scale; let rotation_delta = new_rotation - runner.input.accumulated_rotation; runner.input.accumulated_scale *= scale_delta; runner.input.accumulated_rotation += rotation_delta; let mut should_stop_propagation = true; let mut should_prevent_default = true; if scale_delta != 1.0 { let zoom_event = egui::Event::Zoom(scale_delta); should_stop_propagation &= (runner.web_options.should_stop_propagation)(&zoom_event); should_prevent_default &= (runner.web_options.should_prevent_default)(&zoom_event); runner.input.raw.events.push(zoom_event); } if rotation_delta != 0.0 { let rotate_event = egui::Event::Rotate(rotation_delta); should_stop_propagation &= (runner.web_options.should_stop_propagation)(&rotate_event); should_prevent_default &= (runner.web_options.should_prevent_default)(&rotate_event); runner.input.raw.events.push(rotate_event); } if scale_delta != 1.0 || rotation_delta != 0.0 { runner.needs_repaint.repaint_asap(); // Use web options to tell if the web event should be propagated to parent elements based on the egui event. if should_stop_propagation { event.stop_propagation(); } if should_prevent_default { // Prevents a simulated ctrl-scroll event for zoom event.prevent_default(); } } } fn install_drag_and_drop(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener(target, "dragover", |event: web_sys::DragEvent, runner| { if let Some(data_transfer) = event.data_transfer() { runner.input.raw.hovered_files.clear(); // NOTE: data_transfer.files() is always empty in dragover let items = data_transfer.items(); for i in 0..items.length() { if let Some(item) = items.get(i) { runner.input.raw.hovered_files.push(egui::HoveredFile { mime: item.type_(), ..Default::default() }); } } if runner.input.raw.hovered_files.is_empty() { // Fallback: just preview anything. Needed on Desktop Safari. runner .input .raw .hovered_files .push(egui::HoveredFile::default()); } runner.needs_repaint.repaint_asap(); event.stop_propagation(); event.prevent_default(); } })?; runner_ref.add_event_listener(target, "dragleave", |event: web_sys::DragEvent, runner| { runner.input.raw.hovered_files.clear(); runner.needs_repaint.repaint_asap(); event.stop_propagation(); event.prevent_default(); })?; runner_ref.add_event_listener(target, "drop", |event: web_sys::DragEvent, runner| { if let Some(data_transfer) = event.data_transfer() { // TODO(https://github.com/emilk/egui/issues/3702): support dropping folders runner.input.raw.hovered_files.clear(); runner.needs_repaint.repaint_asap(); if let Some(files) = data_transfer.files() { for i in 0..files.length() { if let Some(file) = files.get(i) { log::debug!("Dropped {:?} ({} bytes)", file.name(), file.size()); runner.input.raw.dropped_files.push(std::sync::Arc::new( super::dropped_file::WebFile::from(file), )); } } } event.stop_propagation(); event.prevent_default(); } })?; Ok(()) } /// A `ResizeObserver` is used to observe changes to the size of the canvas. /// /// The resize observer is called the by the browser at `observe` time, instead of just on the first actual resize. /// We use that to trigger the first `request_animation_frame` _after_ updating the size of the canvas to the correct dimensions, /// to avoid [#4622](https://github.com/emilk/egui/issues/4622). pub struct ResizeObserverContext { observer: web_sys::ResizeObserver, // Kept so it is not dropped until we are done with it. _closure: Closure, } impl Drop for ResizeObserverContext { fn drop(&mut self) { self.observer.disconnect(); } } impl ResizeObserverContext { pub fn new(runner_ref: &WebRunner) -> Result { let closure = Closure::wrap(Box::new({ let runner_ref = runner_ref.clone(); move |entries: js_sys::Array| { if DEBUG_RESIZE { log::info!("ResizeObserverContext callback"); } // Only call the wrapped closure if the egui code has not panicked if let Some(mut runner_lock) = runner_ref.try_lock() { let canvas = runner_lock.canvas(); let (width, height) = match get_display_size(&entries) { Ok(v) => v, Err(err) => { log::error!("{}", super::string_from_js_value(&err)); return; } }; if DEBUG_RESIZE { log::info!( "ResizeObserver: new canvas size: {width}x{height}, DPR: {}", web_sys::window().unwrap().device_pixel_ratio() ); } canvas.set_width(width); canvas.set_height(height); // force an immediate repaint runner_lock.needs_repaint.repaint_asap(); paint_if_needed(&mut runner_lock); drop(runner_lock); // we rely on the resize observer to trigger the first `request_animation_frame`: if let Err(err) = runner_ref.request_animation_frame() { log::error!("{}", super::string_from_js_value(&err)); } } else { log::warn!("ResizeObserverContext callback: failed to lock runner"); } } }) as Box); let observer = web_sys::ResizeObserver::new(closure.as_ref().unchecked_ref())?; Ok(Self { observer, _closure: closure, }) } pub fn observe(&self, canvas: &web_sys::HtmlCanvasElement) { if DEBUG_RESIZE { log::info!("Calling observe on canvas…"); } let options = web_sys::ResizeObserverOptions::new(); options.set_box(web_sys::ResizeObserverBoxOptions::ContentBox); self.observer.observe_with_options(canvas, &options); } } // Code ported to Rust from: // https://webglfundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html fn get_display_size(resize_observer_entries: &js_sys::Array) -> Result<(u32, u32), JsValue> { let width; let height; let mut dpr = web_sys::window().unwrap().device_pixel_ratio(); let entry: web_sys::ResizeObserverEntry = resize_observer_entries.at(0).dyn_into()?; if JsValue::from_str("devicePixelContentBoxSize").js_in(entry.as_ref()) { // NOTE: Only this path gives the correct answer for most browsers. // Unfortunately this doesn't work perfectly everywhere. let size: web_sys::ResizeObserverSize = entry.device_pixel_content_box_size().at(0).dyn_into()?; width = size.inline_size(); height = size.block_size(); dpr = 1.0; // no need to apply if DEBUG_RESIZE { // log::info!("devicePixelContentBoxSize {width}x{height}"); } } else if JsValue::from_str("contentBoxSize").js_in(entry.as_ref()) { let content_box_size = entry.content_box_size(); let idx0 = content_box_size.at(0); if idx0.is_undefined() { // legacy let size = JsValue::clone(content_box_size.as_ref()); let size: web_sys::ResizeObserverSize = size.dyn_into()?; width = size.inline_size(); height = size.block_size(); } else { let size: web_sys::ResizeObserverSize = idx0.dyn_into()?; width = size.inline_size(); height = size.block_size(); } if DEBUG_RESIZE { log::info!("contentBoxSize {width}x{height}"); } } else { // legacy let content_rect = entry.content_rect(); width = content_rect.width(); height = content_rect.height(); } Ok(((width.round() * dpr) as u32, (height.round() * dpr) as u32)) }