Skip to main content

euv_ui/component/layout/hook/
impl.rs

1use super::*;
2
3/// Implementation of layout functionality.
4///
5/// Provides methods for managing viewport resize, drawer toggle, and safe area.
6impl UseEuvLayout {
7    /// Creates a reactive signal that tracks whether the viewport is in mobile mode
8    /// and subscribes to browser `resize` events to keep it updated.
9    ///
10    /// The resize handler is debounced by `RESIZE_DEBOUNCE_MILLIS` (16ms) to avoid
11    /// excessive recomputation during continuous resize operations.
12    /// The listener is automatically removed when the hook context is cleared.
13    ///
14    /// # Returns
15    ///
16    /// - `Signal<bool>` - A reactive signal that is `true` when the viewport is mobile-sized.
17    pub fn use_resize() -> Signal<bool> {
18        let mobile_signal: Signal<bool> = App::use_signal(Router::is_mobile);
19        let timer_signal: Signal<Option<i32>> = App::use_signal(|| None);
20        let debounce_closure: Closure<dyn FnMut()> = Closure::wrap(Box::new(move || {
21            let mobile: bool = Router::is_mobile();
22            mobile_signal.set(mobile);
23        }));
24        let debounce_callback: Function = debounce_closure
25            .as_ref()
26            .unchecked_ref::<Function>()
27            .clone();
28        debounce_closure.forget();
29        let Some(timeout_window) = window() else {
30            return mobile_signal;
31        };
32        App::use_window_event(WINDOW_EVENT_RESIZE, move || {
33            let old_timer: Option<i32> = timer_signal.get();
34            if let Some(timer_id) = old_timer {
35                timeout_window.clear_timeout_with_handle(timer_id);
36            }
37            let new_timer: i32 = timeout_window
38                .set_timeout_with_callback_and_timeout_and_arguments_0(
39                    &debounce_callback,
40                    RESIZE_DEBOUNCE_MILLIS,
41                )
42                .unwrap_or_default();
43            timer_signal.set(Some(new_timer));
44        });
45        mobile_signal
46    }
47
48    /// Creates a click event handler that toggles the mobile nav drawer signal
49    /// with proper browser history management.
50    ///
51    /// When toggling from open to closed, calls `overlay_back` to remove the
52    /// extra history entry that was pushed when the drawer opened. When toggling
53    /// from closed to open, the `use_overlay_history` hook handles the
54    /// `pushState` call automatically.
55    ///
56    /// # Arguments
57    ///
58    /// - `Signal<bool>` - The boolean signal controlling the drawer visibility.
59    ///
60    /// # Returns
61    ///
62    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler that toggles the drawer.
63    pub fn use_drawer_toggle(drawer_open: Signal<bool>) -> Option<Rc<dyn Fn(Event)>> {
64        Some(Rc::new(move |_: Event| {
65            let is_open: bool = drawer_open.get();
66            if is_open {
67                Router::overlay_stack_close();
68            }
69            drawer_open.set(!is_open);
70        }))
71    }
72
73    /// Registers global event listeners that preserve `env(safe-area-inset-*)`
74    /// values after exiting any type of fullscreen on Android, and ensures that
75    /// the system back button exits native fullscreen instead of navigating away.
76    ///
77    /// On initialisation, reads the current `env(safe-area-inset-*)` pixel values
78    /// through a sentinel `<div>` and caches them in thread-local storage.
79    /// When a `fullscreenchange` or `resize` event fires, the cached values are
80    /// written directly as inline CSS custom properties on the real app root
81    /// element so that layout never depends on the potentially stale `env()`
82    /// function result.
83    ///
84    /// When a native (browser) fullscreen is entered — for example the user taps
85    /// the fullscreen button on a `<video controls>` element — a browser history
86    /// entry is added via `overlay_push_state` so that the system back gesture
87    /// will fire `popstate`. A `popstate` guard registered via
88    /// [`Router::register_popstate_guard`] then calls `document.exitFullscreen()` to leave
89    /// fullscreen, consuming the history entry without navigating to the previous
90    /// route. When the native fullscreen is exited through other means (e.g. the
91    /// browser's own exit button), the `fullscreenchange` handler consumes the
92    /// extra history entry via `overlay_back`.
93    ///
94    /// This hook should be called once during app initialization and covers:
95    /// - Native video fullscreen → exit via system back button
96    /// - CSS simulated fullscreen → exit (canvas drawing mode)
97    /// - Any future fullscreen scenarios
98    pub fn use_safe_area_fix() {
99        Self::cache_safe_area_insets();
100        Self::init_safe_area_contract();
101        App::use_window_event(WINDOW_EVENT_FULLSCREEN_CHANGE, || {
102            let Some(window_value) = window() else {
103                return;
104            };
105            let Some(document_value) = window_value.document() else {
106                return;
107            };
108            let is_fullscreen: bool = document_value.fullscreen_element().is_some();
109            if is_fullscreen {
110                NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.set(true));
111                Router::overlay_push_state();
112            } else {
113                let was_active: bool =
114                    NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.get());
115                if was_active {
116                    NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.set(false));
117                    let exit_by_popstate: bool =
118                        NATIVE_FULLSCREEN_EXIT_BY_POPSTATE.with(|flag: &Cell<bool>| flag.get());
119                    if exit_by_popstate {
120                        NATIVE_FULLSCREEN_EXIT_BY_POPSTATE
121                            .with(|flag: &Cell<bool>| flag.set(false));
122                    } else {
123                        Router::overlay_back(None);
124                    }
125                }
126                Self::apply_cached_insets();
127            }
128        });
129        App::use_window_event(WINDOW_EVENT_WEBKIT_FULLSCREEN_CHANGE, || {
130            Self::apply_cached_insets();
131        });
132        App::use_window_event(WINDOW_EVENT_RESIZE, || {
133            Self::apply_cached_insets();
134        });
135        Router::register_popstate_guard(Rc::new(|| {
136            if !NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.get()) {
137                return false;
138            }
139            NATIVE_FULLSCREEN_EXIT_BY_POPSTATE.with(|flag: &Cell<bool>| flag.set(true));
140            let Some(window_value) = window() else {
141                return false;
142            };
143            let Some(document_value) = window_value.document() else {
144                return false;
145            };
146            document_value.exit_fullscreen();
147            true
148        }));
149    }
150
151    /// Publishes the measured safe-area insets as CSS contract variables when
152    /// the host genuinely lays out edge-to-edge.
153    ///
154    /// The measured `env(safe-area-inset-*)` pixels are written to
155    /// `--euv-mobile-safe-top` and `--euv-safe-{right,bottom,left}` on `<html>`,
156    /// which `c_mobile_header`, `c_mobile_nav_drawer`, `c_app_root` and the
157    /// edge-anchored overlays consume as `var(--euv-safe-*, 0px)`. Every one of
158    /// those defaults to `0px`, so a browser host that letterboxes the page yet
159    /// reports non-zero insets renders flush against the screen edge instead of
160    /// reserving a blank band.
161    ///
162    /// Trust is decided by `is_edge_to_edge_viewport`, which combines the
163    /// explicit immersive declaration with the runtime
164    /// `screen.height - window.innerHeight` measurement.
165    fn init_safe_area_contract() {
166        if !is_edge_to_edge_viewport(Self::is_immersive_declared(), measure_viewport_screen_gap()) {
167            return;
168        }
169        let top_value: String =
170            SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
171        if top_value.is_empty() {
172            return;
173        }
174        Self::apply_safe_area_contract(&top_value);
175    }
176
177    /// Writes the supplied measured top inset and the cached side insets onto
178    /// `<html>` as the safe-area contract variables.
179    ///
180    /// # Arguments
181    ///
182    /// - `&str` - The measured top safe-area pixel value.
183    fn apply_safe_area_contract(top_value: &str) {
184        let Some(window_value) = window() else {
185            return;
186        };
187        let Some(document_value) = window_value.document() else {
188            return;
189        };
190        let Some(root) = document_value.document_element() else {
191            return;
192        };
193        let root_element: HtmlElement = root.unchecked_into();
194        let right_value: String = safe_area_contract_value(
195            true,
196            &SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| cell.borrow().clone()),
197        );
198        let bottom_value: String = safe_area_contract_value(
199            true,
200            &SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| cell.borrow().clone()),
201        );
202        let left_value: String = safe_area_contract_value(
203            true,
204            &SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| cell.borrow().clone()),
205        );
206        let _: Result<(), JsValue> = root_element
207            .style()
208            .set_property(IMMERSIVE_SAFE_TOP_PROPERTY, top_value);
209        let _: Result<(), JsValue> = root_element
210            .style()
211            .set_property(SAFE_AREA_RIGHT_PROPERTY, &right_value);
212        let _: Result<(), JsValue> = root_element
213            .style()
214            .set_property(SAFE_AREA_BOTTOM_PROPERTY, &bottom_value);
215        let _: Result<(), JsValue> = root_element
216            .style()
217            .set_property(SAFE_AREA_LEFT_PROPERTY, &left_value);
218    }
219
220    /// Returns whether the host environment declares immersive (edge-to-edge)
221    /// mode via `window.__EUV_IMMERSIVE__` or a
222    /// `<meta name="euv-immersive" content="true">` tag.
223    ///
224    /// # Returns
225    ///
226    /// - `bool` - `true` when immersive mode is declared by the host.
227    pub(crate) fn is_immersive_declared() -> bool {
228        let Some(window_value) = window() else {
229            return false;
230        };
231        let global_flag: bool = js_sys::Reflect::get(
232            &window_value,
233            &JsValue::from_str(IMMERSIVE_WINDOW_FLAG_PROPERTY),
234        )
235        .ok()
236        .and_then(|value: JsValue| value.as_bool())
237        .unwrap_or(false);
238        if global_flag {
239            return true;
240        }
241        window_value
242            .document()
243            .and_then(|document_value: Document| {
244                document_value
245                    .query_selector(IMMERSIVE_META_SELECTOR)
246                    .ok()
247                    .flatten()
248            })
249            .and_then(|meta: Element| meta.get_attribute(IMMERSIVE_META_CONTENT_ATTRIBUTE))
250            .map(|content: String| content == IMMERSIVE_META_CONTENT_ENABLED)
251            .unwrap_or(false)
252    }
253
254    /// Reads the current `env(safe-area-inset-*)` pixel values via a temporary
255    /// sentinel element and persists them in thread-local storage.
256    ///
257    /// The sentinel `<div>` is created with `padding-top: env(safe-area-inset-top)`
258    /// (and similarly for the other three sides). After forcing a layout
259    /// calculation, `getComputedStyle` yields the resolved pixel value, which is
260    /// then stored in `SAFE_AREA_INSET_*` thread-local cells.
261    ///
262    /// If the top inset is empty or `0px` (i.e. no safe area on this device or
263    /// immersive mode not active), the values are not cached and no override is
264    /// applied.
265    fn cache_safe_area_insets() {
266        let top_cached: String =
267            SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
268        if !top_cached.is_empty() {
269            return;
270        }
271        let Some(win) = window() else {
272            return;
273        };
274        let Some(document_value) = win.document() else {
275            return;
276        };
277        let Some(body) = document_value.body() else {
278            return;
279        };
280        let Ok(created_element) = document_value.create_element("div") else {
281            return;
282        };
283        let sentinel: HtmlElement = created_element.unchecked_into();
284        let _: Result<(), JsValue> = sentinel.style().set_property(
285            SENTINEL_STYLE_POSITION_PROPERTY,
286            SENTINEL_STYLE_POSITION_ABSOLUTE,
287        );
288        let _: Result<(), JsValue> = sentinel.style().set_property(
289            SENTINEL_STYLE_VISIBILITY_PROPERTY,
290            SENTINEL_STYLE_VISIBILITY_HIDDEN,
291        );
292        let _: Result<(), JsValue> = sentinel.style().set_property(
293            SENTINEL_STYLE_POINTER_EVENTS_PROPERTY,
294            SENTINEL_STYLE_POINTER_EVENTS_NONE,
295        );
296        let _: Result<(), JsValue> = sentinel
297            .style()
298            .set_property(SAFE_AREA_PADDING_TOP_PROPERTY, SAFE_AREA_PADDING_TOP_VALUE);
299        let _: Result<(), JsValue> = sentinel.style().set_property(
300            SAFE_AREA_PADDING_RIGHT_PROPERTY,
301            SAFE_AREA_PADDING_RIGHT_VALUE,
302        );
303        let _: Result<(), JsValue> = sentinel.style().set_property(
304            SAFE_AREA_PADDING_BOTTOM_PROPERTY,
305            SAFE_AREA_PADDING_BOTTOM_VALUE,
306        );
307        let _: Result<(), JsValue> = sentinel.style().set_property(
308            SAFE_AREA_PADDING_LEFT_PROPERTY,
309            SAFE_AREA_PADDING_LEFT_VALUE,
310        );
311        let _: Result<Node, JsValue> = body.append_child(&sentinel);
312        let Some(computed) = win.get_computed_style(&sentinel).ok().flatten() else {
313            let _: Result<Node, JsValue> = body.remove_child(&sentinel);
314            return;
315        };
316        let top_value: String = computed
317            .get_property_value(SAFE_AREA_PADDING_TOP_PROPERTY)
318            .unwrap_or_default();
319        let right_value: String = computed
320            .get_property_value(SAFE_AREA_PADDING_RIGHT_PROPERTY)
321            .unwrap_or_default();
322        let bottom_value: String = computed
323            .get_property_value(SAFE_AREA_PADDING_BOTTOM_PROPERTY)
324            .unwrap_or_default();
325        let left_value: String = computed
326            .get_property_value(SAFE_AREA_PADDING_LEFT_PROPERTY)
327            .unwrap_or_default();
328        let _: Result<Node, JsValue> = body.remove_child(&sentinel);
329        if top_value.is_empty() || top_value == "0px" {
330            return;
331        }
332        SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| *cell.borrow_mut() = top_value);
333        SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| *cell.borrow_mut() = right_value);
334        SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| *cell.borrow_mut() = bottom_value);
335        SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| *cell.borrow_mut() = left_value);
336    }
337
338    /// Writes the cached safe-area inset values as inline CSS custom properties
339    /// on the real app root element and any fullscreen overlay containers.
340    ///
341    /// Class rules such as `c_mobile_app_root`, `c_app_nav`, `c_app_main`,
342    /// `c_mobile_nav_drawer`, `c_canvas_container_fullscreen`, and
343    /// `c_game_container_fullscreen` consume `var(--safe-area-inset-top)`
344    /// in their `padding` declarations. By overriding these CSS custom
345    /// properties with inline style (which has higher specificity than the
346    /// stylesheet rule from `vars!`), all `var()` references resolve to the
347    /// cached pixel values, bypassing the stale `env()` function after a
348    /// fullscreen exit.
349    ///
350    /// The canvas and game fullscreen containers are `position: fixed`
351    /// and outside the app root subtree, so they do not inherit the inline
352    /// overrides — they must be patched separately.
353    pub fn apply_cached_insets() {
354        let raw_top: String =
355            SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
356        let raw_right: String =
357            SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| cell.borrow().clone());
358        let raw_bottom: String =
359            SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| cell.borrow().clone());
360        let raw_left: String =
361            SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| cell.borrow().clone());
362        if raw_top.is_empty()
363            && raw_right.is_empty()
364            && raw_bottom.is_empty()
365            && raw_left.is_empty()
366        {
367            return;
368        }
369        let is_trusted: bool =
370            is_edge_to_edge_viewport(Self::is_immersive_declared(), measure_viewport_screen_gap());
371        let top_value: String = safe_area_contract_value(is_trusted, &raw_top);
372        let right_value: String = safe_area_contract_value(is_trusted, &raw_right);
373        let bottom_value: String = safe_area_contract_value(is_trusted, &raw_bottom);
374        let left_value: String = safe_area_contract_value(is_trusted, &raw_left);
375        let Some(window_value) = window() else {
376            return;
377        };
378        let Some(document_value) = window_value.document() else {
379            return;
380        };
381        let apply_to: &dyn Fn(&HtmlElement) = &|element: &HtmlElement| {
382            let _: Result<(), JsValue> = element
383                .style()
384                .set_property(SAFE_AREA_INSET_TOP_PROPERTY, &top_value);
385            let _: Result<(), JsValue> = element
386                .style()
387                .set_property(SAFE_AREA_INSET_RIGHT_PROPERTY, &right_value);
388            let _: Result<(), JsValue> = element
389                .style()
390                .set_property(SAFE_AREA_INSET_BOTTOM_PROPERTY, &bottom_value);
391            let _: Result<(), JsValue> = element
392                .style()
393                .set_property(SAFE_AREA_INSET_LEFT_PROPERTY, &left_value);
394            let _: Result<(), JsValue> = element
395                .style()
396                .set_property(IMMERSIVE_SAFE_TOP_PROPERTY, &top_value);
397            let _: Result<(), JsValue> = element
398                .style()
399                .set_property(SAFE_AREA_RIGHT_PROPERTY, &right_value);
400            let _: Result<(), JsValue> = element
401                .style()
402                .set_property(SAFE_AREA_BOTTOM_PROPERTY, &bottom_value);
403            let _: Result<(), JsValue> = element
404                .style()
405                .set_property(SAFE_AREA_LEFT_PROPERTY, &left_value);
406        };
407        if let Some(app_root) = document_value
408            .query_selector(APP_ROOT_MOBILE_SELECTOR)
409            .ok()
410            .flatten()
411            .map(|element: Element| element.unchecked_into::<HtmlElement>())
412            .or_else(|| {
413                document_value
414                    .query_selector(APP_ROOT_DESKTOP_SELECTOR)
415                    .ok()
416                    .flatten()
417                    .map(|element: Element| element.unchecked_into::<HtmlElement>())
418            })
419        {
420            apply_to(&app_root);
421        }
422        if let Some(canvas_fullscreen) = document_value
423            .query_selector(FULLSCREEN_CANVAS_CONTAINER_SELECTOR)
424            .ok()
425            .flatten()
426            .map(|element: Element| element.unchecked_into::<HtmlElement>())
427        {
428            apply_to(&canvas_fullscreen);
429        }
430        if let Some(game_fullscreen) = document_value
431            .query_selector(FULLSCREEN_GAME_CONTAINER_SELECTOR)
432            .ok()
433            .flatten()
434            .map(|element: Element| element.unchecked_into::<HtmlElement>())
435        {
436            apply_to(&game_fullscreen);
437        }
438    }
439}