From 9ece5ddaaa9db0689f74e7a8386d9b68126980d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Proch=C3=A1zka?= Date: Fri, 31 May 2024 17:03:12 +0200 Subject: [PATCH] Add fullscreen mode to web viewer (#6461) ### What - Closes https://github.com/rerun-io/rerun/issues/6433 This PR enables the `ToggleFullscreen` UI command for web target, implemented in JS land. The functionality is also exposed via the `toggle_fullscreen` method. Callbacks are used to communicate about fullscreen state between WASM and JS. To test, you'll need to build branch `fullscreen-temp` of https://github.com/rerun-io/web-viewer-example with a globally linked `web-viewer` package built from source: 1. Install Yarn: `npm i -g yarn` 2. In this repository: - `yarn --cwd rerun_js install` - `yarn --cwd rerun_js/web-viewer build` - `yarn --cwd rerun_js/web-viewer link` 3. In the `web-viewer-example` repository: - `yarn` - `yarn link @rerun-io/web-viewer` - `yarn dev` + open the page ### Checklist * [x] I have read and agree to [Contributor Guide](https://github.com/rerun-io/rerun/blob/main/CONTRIBUTING.md) and the [Code of Conduct](https://github.com/rerun-io/rerun/blob/main/CODE_OF_CONDUCT.md) * [x] I've included a screenshot or gif (if applicable) * [x] I have tested the web demo (if applicable): * Using examples from latest `main` build: [rerun.io/viewer](https://rerun.io/viewer/pr/6461?manifest_url=https://app.rerun.io/version/main/examples_manifest.json) * Using full set of examples from `nightly` build: [rerun.io/viewer](https://rerun.io/viewer/pr/6461?manifest_url=https://app.rerun.io/version/nightly/examples_manifest.json) * [x] The PR title and labels are set such as to maximize their usefulness for the next release's CHANGELOG * [x] If applicable, add a new check to the [release checklist](https://github.com/rerun-io/rerun/blob/main/tests/python/release_checklist)! - [PR Build Summary](https://build.rerun.io/pr/6461) - [Recent benchmark results](https://build.rerun.io/graphs/crates.html) - [Wasm size tracking](https://build.rerun.io/graphs/sizes.html) To run all checks from `main`, comment on the PR with `@rerun-bot full-check`. --- .../blueprint/components/panel_state_ext.rs | 2 +- crates/re_ui/src/command.rs | 12 +- crates/re_viewer/src/app.rs | 52 ++++- crates/re_viewer/src/ui/top_panel.rs | 19 ++ crates/re_viewer/src/web.rs | 20 +- crates/re_viewer/src/web_tools.rs | 13 +- rerun_js/web-viewer/index.js | 202 ++++++++++++++++-- rerun_js/yarn.lock | 14 +- 8 files changed, 301 insertions(+), 33 deletions(-) diff --git a/crates/re_types/src/blueprint/components/panel_state_ext.rs b/crates/re_types/src/blueprint/components/panel_state_ext.rs index 50bf598a230e..df01390853a0 100644 --- a/crates/re_types/src/blueprint/components/panel_state_ext.rs +++ b/crates/re_types/src/blueprint/components/panel_state_ext.rs @@ -7,7 +7,7 @@ impl PanelState { self == &Self::Expanded } - /// Returns `true` if self is [`PanelState::Expanded`] + /// Returns `true` if self is [`PanelState::Hidden`] #[inline] pub fn is_hidden(&self) -> bool { self == &Self::Hidden diff --git a/crates/re_ui/src/command.rs b/crates/re_ui/src/command.rs index 825920ccd089..ddbcdaf01a34 100644 --- a/crates/re_ui/src/command.rs +++ b/crates/re_ui/src/command.rs @@ -45,7 +45,6 @@ pub enum UICommand { #[cfg(debug_assertions)] ToggleEguiDebugPanel, - #[cfg(not(target_arch = "wasm32"))] ToggleFullscreen, #[cfg(not(target_arch = "wasm32"))] ZoomIn, @@ -171,6 +170,12 @@ impl UICommand { "Toggle between windowed and fullscreen viewer", ), + #[cfg(target_arch = "wasm32")] + Self::ToggleFullscreen => ( + "Toggle fullscreen", + "Toggle between full viewport dimensions and initial dimensions" + ), + #[cfg(not(target_arch = "wasm32"))] Self::ZoomIn => ("Zoom in", "Increases the UI zoom level"), #[cfg(not(target_arch = "wasm32"))] @@ -300,6 +305,8 @@ impl UICommand { #[cfg(not(target_arch = "wasm32"))] Self::ToggleFullscreen => Some(key(Key::F11)), + #[cfg(target_arch = "wasm32")] + Self::ToggleFullscreen => None, #[cfg(not(target_arch = "wasm32"))] Self::ZoomIn => Some(egui::gui_zoom::kb_shortcuts::ZOOM_IN), @@ -336,6 +343,9 @@ impl UICommand { Self::RestartWithWebGl => None, #[cfg(target_arch = "wasm32")] Self::RestartWithWebGpu => None, + + #[cfg(target_arch = "wasm32 ")] + Self::ViewportMode(_) => None, } } diff --git a/crates/re_viewer/src/app.rs b/crates/re_viewer/src/app.rs index ab82273e570a..0339ac630df7 100644 --- a/crates/re_viewer/src/app.rs +++ b/crates/re_viewer/src/app.rs @@ -71,6 +71,14 @@ pub struct StartupOptions { /// Forces wgpu backend to use the specified graphics API. pub force_wgpu_backend: Option, + /// Fullscreen is handled by JS on web. + /// + /// This holds some callbacks which we use to communicate + /// about fullscreen state to JS. + #[cfg(target_arch = "wasm32")] + pub fullscreen_options: Option, + + /// Default overrides for state of top/side/bottom panels. pub panel_state_overrides: PanelStateOverrides, } @@ -95,6 +103,9 @@ impl Default for StartupOptions { expect_data_soon: None, force_wgpu_backend: None, + #[cfg(target_arch = "wasm32")] + fullscreen_options: Default::default(), + panel_state_overrides: Default::default(), } } @@ -638,10 +649,8 @@ impl App { self.egui_debug_panel_open ^= true; } - #[cfg(not(target_arch = "wasm32"))] UICommand::ToggleFullscreen => { - let fullscreen = egui_ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); - egui_ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!fullscreen)); + self.toggle_fullscreen(); } #[cfg(not(target_arch = "wasm32"))] @@ -1325,6 +1334,43 @@ impl App { 1.0 } } + + pub(crate) fn toggle_fullscreen(&self) { + #[cfg(not(target_arch = "wasm32"))] + { + let egui_ctx = &self.re_ui.egui_ctx; + let fullscreen = egui_ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); + egui_ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!fullscreen)); + } + + #[cfg(target_arch = "wasm32")] + { + if let Some(options) = &self.startup_options.fullscreen_options { + // Tell JS to toggle fullscreen. + if let Err(err) = options.on_toggle.call() { + re_log::error!("{}", crate::web_tools::string_from_js_value(err)); + }; + } + } + } + + #[cfg(target_arch = "wasm32")] + pub(crate) fn is_fullscreen_allowed(&self) -> bool { + self.startup_options.fullscreen_options.is_some() + } + + #[cfg(target_arch = "wasm32")] + pub(crate) fn is_fullscreen_mode(&self) -> bool { + if let Some(options) = &self.startup_options.fullscreen_options { + // Ask JS if fullscreen is on or not. + match options.get_state.call() { + Ok(v) => return v.is_truthy(), + Err(err) => re_log::error_once!("{}", crate::web_tools::string_from_js_value(err)), + } + } + + false + } } #[cfg(target_arch = "wasm32")] diff --git a/crates/re_viewer/src/ui/top_panel.rs b/crates/re_viewer/src/ui/top_panel.rs index 1e11edb06fd2..53617eb4b255 100644 --- a/crates/re_viewer/src/ui/top_panel.rs +++ b/crates/re_viewer/src/ui/top_panel.rs @@ -241,6 +241,25 @@ fn connection_status_ui(ui: &mut egui::Ui, rx: &ReceiveSet /// Lay out the panel button right-to-left fn panel_buttons_r2l(app: &App, app_blueprint: &AppBlueprint<'_>, ui: &mut egui::Ui) { + #[cfg(target_arch = "wasm32")] + if app.is_fullscreen_allowed() { + let mut is_fullscreen = app.is_fullscreen_mode(); + let icon = if is_fullscreen { + &re_ui::icons::MINIMIZE + } else { + &re_ui::icons::MAXIMIZE + }; + + if app + .re_ui() + .medium_icon_toggle_button(ui, icon, &mut is_fullscreen) + .on_hover_text("Toggle fullscreen") + .clicked() + { + app.toggle_fullscreen(); + } + } + // selection panel if app .re_ui() diff --git a/crates/re_viewer/src/web.rs b/crates/re_viewer/src/web.rs index 624c7c5d6585..9f926a225ffa 100644 --- a/crates/re_viewer/src/web.rs +++ b/crates/re_viewer/src/web.rs @@ -296,6 +296,17 @@ pub struct AppOptions { render_backend: Option, hide_welcome_screen: Option, panel_state_overrides: Option, + fullscreen: Option, +} + +// Keep in sync with the `FullscreenOptions` typedef in `rerun_js/web-viewer/index.js` +#[derive(Clone, Deserialize)] +pub struct FullscreenOptions { + /// This returns the current fullscreen state, which is a boolean representing on/off. + pub get_state: Callback, + + /// This calls the JS version of "toggle fullscreen". + pub on_toggle: Callback, } #[derive(Clone, Default, Deserialize)] @@ -320,7 +331,13 @@ impl From for crate::app_blueprint::PanelStateOverrides { // Can't deserialize `Option` directly, so newtype it is. #[derive(Clone, Deserialize)] #[repr(transparent)] -struct Callback(#[serde(with = "serde_wasm_bindgen::preserve")] js_sys::Function); +pub struct Callback(#[serde(with = "serde_wasm_bindgen::preserve")] js_sys::Function); + +impl Callback { + pub fn call(&self) -> Result { + self.0.call0(&web_sys::window().unwrap()) + } +} fn create_app( cc: &eframe::CreationContext<'_>, @@ -341,6 +358,7 @@ fn create_app( expect_data_soon: None, force_wgpu_backend: None, hide_welcome_screen: app_options.hide_welcome_screen.unwrap_or(false), + fullscreen_options: app_options.fullscreen.clone(), panel_state_overrides: app_options.panel_state_overrides.unwrap_or_default().into(), }; let re_ui = crate::customize_eframe_and_setup_renderer(cc)?; diff --git a/crates/re_viewer/src/web_tools.rs b/crates/re_viewer/src/web_tools.rs index fc64730cb079..1a1ec1efcb33 100644 --- a/crates/re_viewer/src/web_tools.rs +++ b/crates/re_viewer/src/web_tools.rs @@ -1,6 +1,7 @@ use std::{ops::ControlFlow, sync::Arc}; use anyhow::Context as _; +use wasm_bindgen::JsCast as _; use wasm_bindgen::JsValue; use re_log::ResultExt as _; @@ -11,7 +12,17 @@ use re_viewer_context::CommandSender; /// Useful in error handlers #[allow(clippy::needless_pass_by_value)] pub fn string_from_js_value(s: wasm_bindgen::JsValue) -> String { - s.as_string().unwrap_or(format!("{s:#?}")) + // it's already a string + if let Some(s) = s.as_string() { + return s; + } + + // it's an Error, call `toString` instead + if let Some(s) = s.dyn_ref::() { + return format!("{}", s.to_string()); + } + + format!("{s:#?}") } pub fn set_url_parameter_and_refresh(key: &str, value: &str) -> Result<(), wasm_bindgen::JsValue> { diff --git a/rerun_js/web-viewer/index.js b/rerun_js/web-viewer/index.js index ec3077c1c317..05fb77755065 100644 --- a/rerun_js/web-viewer/index.js +++ b/rerun_js/web-viewer/index.js @@ -12,6 +12,13 @@ async function load() { return WebHandle; } +/** + * Used to prevent multiple viewers from being fullscreen at the same time. + * + * @type {(() => void) | null} + */ +let _minimize_current_fullscreen_viewer = null; + /** @returns {string} */ function randomId() { const bytes = new Uint8Array(16); @@ -23,18 +30,42 @@ function randomId() { /** * @typedef {"top" | "blueprint" | "selection" | "time"} Panel + * * @typedef {"hidden" | "collapsed" | "expanded"} PanelState + * * @typedef {"webgpu" | "webgl"} Backend - */ - -/** + * + * @typedef {{ + * width: string; height: string; + * top: string; left: string; + * bottom: string; right: string; + * }} CanvasRect + * + * @typedef {{ + * canvas: CanvasRect & { position: string; transition: string; }; + * document: { overflow: string }; + * }} CanvasStyle + * + * @typedef {{ on: false; saved_style: null; saved_rect: null }} FullscreenOff + * + * @typedef {{ on: true; saved_style: CanvasStyle; saved_rect: DOMRect }} FullscreenOn + * + * @typedef {(FullscreenOff | FullscreenOn)} FullscreenState + * * @typedef WebViewerOptions * @property {string} [manifest_url] Use a different example manifest. * @property {Backend} [render_backend] Force the viewer to use a specific rendering backend. * @property {boolean} [hide_welcome_screen] Whether to hide the welcome screen in favor of a simpler one. + * @property {boolean} [allow_fullscreen] Whether to allow the viewer to enter fullscreen mode. + * + * @typedef FullscreenOptions + * @property {() => boolean} get_state + * @property {() => void} on_toggle */ export class WebViewer { + #id = randomId(); + /** @type {(import("./re_viewer.js").WebHandle) | null} */ #handle = null; @@ -44,20 +75,36 @@ export class WebViewer { /** @type {'ready' | 'starting' | 'stopped'} */ #state = "stopped"; + /** + * @type {FullscreenState} + */ + #fullscreen_state = { + on: false, + saved_style: null, + saved_rect: null, + }; + + #allow_fullscreen = false; + /** * Start the viewer. * - * @param {string | string[]} [rrd] URLs to `.rrd` files or WebSocket connections to our SDK. - * @param {HTMLElement} [parent] The element to attach the canvas onto. - * @param {WebViewerOptions} [options] Whether to hide the welcome screen. + * @param {string | string[] | null} [rrd] URLs to `.rrd` files or WebSocket connections to our SDK. + * @param {HTMLElement | null} [parent] The element to attach the canvas onto. + * @param {WebViewerOptions | null} [options] Whether to hide the welcome screen. * @returns {Promise} */ - async start(rrd, parent = document.body, options = {}) { + async start(rrd, parent, options) { + parent ??= document.body; + options ??= {}; + + this.#allow_fullscreen = options.allow_fullscreen || false; + if (this.#state !== "stopped") return; this.#state = "starting"; this.#canvas = document.createElement("canvas"); - this.#canvas.id = randomId(); + this.#canvas.id = this.#id; parent.append(this.#canvas); /** @@ -65,16 +112,25 @@ export class WebViewer { * @property {string} [url] * @property {string} [manifest_url] * @property {Backend} [render_backend] - * @property {Partial<{[K in Panel]: PanelState}>} [panel_state_overrides] * @property {boolean} [hide_welcome_screen] + * @property {Partial<{[K in Panel]: PanelState}>} [panel_state_overrides] + * @property {FullscreenOptions} [fullscreen] + * + * @typedef {(import("./re_viewer.js").WebHandle)} _WebHandle + * @typedef {{ new(app_options?: AppOptions): _WebHandle }} WebHandleConstructor */ - /** @typedef {(import("./re_viewer.js").WebHandle)} _WebHandle */ - /** @typedef {{ new(app_options?: AppOptions): _WebHandle }} WebHandleConstructor */ let WebHandle_class = /** @type {WebHandleConstructor} */ (await load()); if (this.#state !== "starting") return; - this.#handle = new WebHandle_class({ ...options }); + const fullscreen = this.#allow_fullscreen + ? { + get_state: () => this.#fullscreen_state.on, + on_toggle: () => this.toggle_fullscreen(), + } + : undefined; + + this.#handle = new WebHandle_class({ ...options, fullscreen }); await this.#handle.start(this.#canvas.id); if (this.#state !== "starting") return; @@ -83,7 +139,6 @@ export class WebViewer { } this.#state = "ready"; - if (rrd) { this.open(rrd); } @@ -152,6 +207,11 @@ export class WebViewer { */ stop() { if (this.#state === "stopped") return; + if (this.#allow_fullscreen && this.#canvas) { + const state = this.#fullscreen_state; + if (state.on) this.#minimize(this.#canvas, state); + } + this.#state = "stopped"; this.#canvas?.remove(); @@ -160,6 +220,8 @@ export class WebViewer { this.#canvas = null; this.#handle = null; + this.#fullscreen_state.on = false; + this.#allow_fullscreen = false; } /** @@ -225,6 +287,120 @@ export class WebViewer { } this.#handle.toggle_panel_overrides(); } + + /** + * Toggle fullscreen mode. + * + * This does nothing if `allow_fullscreen` was not set to `true` when starting the viewer. + * + * Fullscreen mode works by updating the underlying `` element's `style`: + * - `position` to `fixed` + * - width/height/top/left to cover the entire viewport + * + * When fullscreen mode is toggled off, the style is restored to its previous values. + * + * When fullscreen mode is toggled on, any other instance of the viewer on the page + * which is already in fullscreen mode is toggled off. This means that it doesn't + * have to be tracked manually. + * + * This functionality can also be directly accessed in the viewer: + * - The maximize/minimize top panel button + * - The `Toggle fullscreen` UI command (accessible via the command palette, CTRL+P) + */ + toggle_fullscreen() { + if (!this.#allow_fullscreen) return; + + if (!this.#handle || !this.#canvas) { + throw new Error( + `attempted to toggle fullscreen mode in a stopped web viewer`, + ); + } + + const state = this.#fullscreen_state; + if (state.on) { + this.#minimize(this.#canvas, state); + } else { + this.#maximize(this.#canvas); + } + } + + #minimize = ( + /** @type {HTMLCanvasElement} */ canvas, + /** @type {FullscreenOn} */ { saved_style, saved_rect }, + ) => { + this.#fullscreen_state = { + on: false, + saved_style: null, + saved_rect: null, + }; + + if (this.#fullscreen_state.on) return; + + canvas.style.width = saved_rect.width + "px"; + canvas.style.height = saved_rect.height + "px"; + canvas.style.top = saved_rect.top + "px"; + canvas.style.left = saved_rect.left + "px"; + canvas.style.bottom = saved_rect.bottom + "px"; + canvas.style.right = saved_rect.right + "px"; + + setTimeout( + () => + requestAnimationFrame(() => { + for (const key in saved_style.canvas) { + // @ts-expect-error + canvas.style[key] = saved_style.canvas[key]; + } + for (const key in saved_style.document) { + // @ts-expect-error + document.body.style[key] = saved_style.document[key]; + } + }), + 100, + ); + + _minimize_current_fullscreen_viewer = null; + }; + + #maximize = (/** @type {HTMLCanvasElement} */ canvas) => { + _minimize_current_fullscreen_viewer?.(); + + const style = canvas.style; + + /** @type {CanvasStyle} */ + const saved_style = { + canvas: { + position: style.position, + width: style.width, + height: style.height, + top: style.top, + left: style.left, + bottom: style.bottom, + right: style.right, + transition: style.transition, + }, + document: { overflow: document.body.style.overflow }, + }; + const saved_rect = canvas.getBoundingClientRect(); + + style.width = `100%`; + style.height = `100%`; + style.top = `0px`; + style.left = `0px`; + style.bottom = `0px`; + style.right = `0px`; + style.transition = ["width", "height", "top", "left", "bottom", "right"] + .map((p) => `${p} 0.1s linear`) + .join(", "); + document.body.style.overflow = "hidden"; + + this.#fullscreen_state = { + on: true, + saved_style, + saved_rect, + }; + + _minimize_current_fullscreen_viewer = () => this.toggle_fullscreen(); + }; } export class LogChannel { diff --git a/rerun_js/yarn.lock b/rerun_js/yarn.lock index 537d660aea70..de5a463a3fb3 100644 --- a/rerun_js/yarn.lock +++ b/rerun_js/yarn.lock @@ -42,18 +42,6 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@rerun-io/web-viewer-react@file:/home/jp/rerun-io/rerun/rerun_js/web-viewer-react": - version "0.13.0" - resolved "file:web-viewer-react" - dependencies: - "@rerun-io/web-viewer" "0.13.0" - "@types/react" "^18.2.33" - react "^18.2.0" - -"@rerun-io/web-viewer@0.13.0", "@rerun-io/web-viewer@file:/home/jp/rerun-io/rerun/rerun_js/web-viewer": - version "0.13.0" - resolved "file:web-viewer" - "@types/prop-types@*": version "15.7.11" resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz" @@ -164,7 +152,7 @@ ts-api-utils@^1.0.3: resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz" integrity sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA== -typescript@^5.2.2, typescript@>=4.2.0, "typescript@>=5.0.4 <5.3": +typescript@^5.2.2: version "5.3.3" resolved "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz" integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==