Skip to main content

euv_ui/component/layout/hook/
impl.rs

1use crate::*;
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 timeout_window: Window = window().expect("no global window exists");
30        App::use_window_event("resize", move || {
31            let old_timer: Option<i32> = timer_signal.get();
32            if let Some(timer_id) = old_timer {
33                timeout_window.clear_timeout_with_handle(timer_id);
34            }
35            let new_timer: i32 = timeout_window
36                .set_timeout_with_callback_and_timeout_and_arguments_0(
37                    &debounce_callback,
38                    RESIZE_DEBOUNCE_MILLIS,
39                )
40                .unwrap_or_default();
41            timer_signal.set(Some(new_timer));
42        });
43        mobile_signal
44    }
45
46    /// Creates a click event handler that toggles the mobile nav drawer signal
47    /// with proper browser history management.
48    ///
49    /// When toggling from open to closed, calls `overlay_back` to remove the
50    /// extra history entry that was pushed when the drawer opened. When toggling
51    /// from closed to open, the `use_overlay_history` hook handles the
52    /// `pushState` call automatically.
53    ///
54    /// # Arguments
55    ///
56    /// - `Signal<bool>` - The boolean signal controlling the drawer visibility.
57    ///
58    /// # Returns
59    ///
60    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler that toggles the drawer.
61    pub fn use_drawer_toggle(drawer_open: Signal<bool>) -> Option<Rc<dyn Fn(Event)>> {
62        Some(Rc::new(move |_: Event| {
63            let is_open: bool = drawer_open.get();
64            if is_open {
65                Router::overlay_stack_close();
66            }
67            drawer_open.set(!is_open);
68        }))
69    }
70
71    /// Registers global event listeners that preserve `env(safe-area-inset-*)`
72    /// values after exiting any type of fullscreen on Android, and ensures that
73    /// the system back button exits native fullscreen instead of navigating away.
74    ///
75    /// On initialisation, reads the current `env(safe-area-inset-*)` pixel values
76    /// through a sentinel `<div>` and caches them in thread-local storage.
77    /// When a `fullscreenchange` or `resize` event fires, the cached values are
78    /// written directly as inline CSS custom properties on the real app root
79    /// element so that layout never depends on the potentially stale `env()`
80    /// function result.
81    ///
82    /// When a native (browser) fullscreen is entered — for example the user taps
83    /// the fullscreen button on a `<video controls>` element — a browser history
84    /// entry is added via `overlay_push_state` so that the system back gesture
85    /// will fire `popstate`. A `popstate` guard registered via
86    /// [`register_popstate_guard`] then calls `document.exitFullscreen()` to leave
87    /// fullscreen, consuming the history entry without navigating to the previous
88    /// route. When the native fullscreen is exited through other means (e.g. the
89    /// browser's own exit button), the `fullscreenchange` handler consumes the
90    /// extra history entry via `overlay_back`.
91    ///
92    /// This hook should be called once during app initialization and covers:
93    /// - Native video fullscreen → exit via system back button
94    /// - CSS simulated fullscreen → exit (canvas drawing mode)
95    /// - Any future fullscreen scenarios
96    pub fn use_safe_area_fix() {
97        Self::cache_safe_area_insets();
98        App::use_window_event("fullscreenchange", || {
99            let is_fullscreen: bool = window()
100                .expect("no global window exists")
101                .document()
102                .expect("should have a document")
103                .fullscreen_element()
104                .is_some();
105            if is_fullscreen {
106                NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.set(true));
107                Router::overlay_push_state();
108            } else {
109                let was_active: bool =
110                    NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.get());
111                if was_active {
112                    NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.set(false));
113                    let exit_by_popstate: bool =
114                        NATIVE_FULLSCREEN_EXIT_BY_POPSTATE.with(|flag: &Cell<bool>| flag.get());
115                    if exit_by_popstate {
116                        NATIVE_FULLSCREEN_EXIT_BY_POPSTATE
117                            .with(|flag: &Cell<bool>| flag.set(false));
118                    } else {
119                        Router::overlay_back(None);
120                    }
121                }
122                Self::apply_cached_insets();
123            }
124        });
125        App::use_window_event("webkitfullscreenchange", || {
126            Self::apply_cached_insets();
127        });
128        App::use_window_event("resize", || {
129            Self::apply_cached_insets();
130        });
131        Router::register_popstate_guard(Rc::new(|| {
132            if !NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.get()) {
133                return false;
134            }
135            NATIVE_FULLSCREEN_EXIT_BY_POPSTATE.with(|flag: &Cell<bool>| flag.set(true));
136            let document_value: Document = window()
137                .expect("no global window exists")
138                .document()
139                .expect("should have a document");
140            document_value.exit_fullscreen();
141            true
142        }));
143    }
144
145    /// Reads the current `env(safe-area-inset-*)` pixel values via a temporary
146    /// sentinel element and persists them in thread-local storage.
147    ///
148    /// The sentinel `<div>` is created with `padding-top: env(safe-area-inset-top)`
149    /// (and similarly for the other three sides). After forcing a layout
150    /// calculation, `getComputedStyle` yields the resolved pixel value, which is
151    /// then stored in `SAFE_AREA_INSET_*` thread-local cells.
152    ///
153    /// If the top inset is empty or `0px` (i.e. no safe area on this device or
154    /// immersive mode not active), the values are not cached and no override is
155    /// applied.
156    fn cache_safe_area_insets() {
157        let top_cached: String =
158            SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
159        if !top_cached.is_empty() {
160            return;
161        }
162        let win: Window = window().expect("no global window exists");
163        let document_value: Document = win.document().expect("should have a document");
164        let body: HtmlElement = document_value.body().expect("should have a body");
165        let sentinel: HtmlElement = document_value
166            .create_element("div")
167            .expect("should create div")
168            .unchecked_into();
169        let _ = sentinel.style().set_property("position", "absolute");
170        let _ = sentinel.style().set_property("visibility", "hidden");
171        let _ = sentinel.style().set_property("pointer-events", "none");
172        let _ = sentinel
173            .style()
174            .set_property("padding-top", "env(safe-area-inset-top, 0px)");
175        let _ = sentinel
176            .style()
177            .set_property("padding-right", "env(safe-area-inset-right, 0px)");
178        let _ = sentinel
179            .style()
180            .set_property("padding-bottom", "env(safe-area-inset-bottom, 0px)");
181        let _ = sentinel
182            .style()
183            .set_property("padding-left", "env(safe-area-inset-left, 0px)");
184        let _ = body.append_child(&sentinel);
185        let Some(computed) = win.get_computed_style(&sentinel).ok().flatten() else {
186            let _ = body.remove_child(&sentinel);
187            return;
188        };
189        let top_value: String = computed
190            .get_property_value("padding-top")
191            .unwrap_or_default();
192        let right_value: String = computed
193            .get_property_value("padding-right")
194            .unwrap_or_default();
195        let bottom_value: String = computed
196            .get_property_value("padding-bottom")
197            .unwrap_or_default();
198        let left_value: String = computed
199            .get_property_value("padding-left")
200            .unwrap_or_default();
201        let _ = body.remove_child(&sentinel);
202        if top_value.is_empty() || top_value == "0px" {
203            return;
204        }
205        SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| *cell.borrow_mut() = top_value);
206        SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| *cell.borrow_mut() = right_value);
207        SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| *cell.borrow_mut() = bottom_value);
208        SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| *cell.borrow_mut() = left_value);
209    }
210
211    /// Writes the cached safe-area inset values as inline CSS custom properties
212    /// on the real app root element and any fullscreen overlay containers.
213    ///
214    /// Class rules such as `c_mobile_app_root`, `c_app_nav`, `c_app_main`,
215    /// `c_mobile_nav_drawer`, and `c_canvas_container_fullscreen` consume
216    /// `var(--safe-area-inset-top)` in their `padding` declarations. By overriding
217    /// these CSS custom properties with inline style (which has higher specificity
218    /// than the stylesheet rule from `vars!`), all `var()` references resolve to
219    /// the cached pixel values, bypassing the stale `env()` function after a
220    /// fullscreen exit.
221    ///
222    /// The canvas fullscreen container is `position: fixed` and outside the app
223    /// root subtree, so it does not inherit the inline overrides — it must be
224    /// patched separately.
225    pub fn apply_cached_insets() {
226        let top_value: String =
227            SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
228        if top_value.is_empty() {
229            return;
230        }
231        let right_value: String =
232            SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| cell.borrow().clone());
233        let bottom_value: String =
234            SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| cell.borrow().clone());
235        let left_value: String =
236            SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| cell.borrow().clone());
237        let document_value: Document = window()
238            .expect("no global window exists")
239            .document()
240            .expect("should have a document");
241        let apply_to = |element: &HtmlElement| {
242            let _ = element
243                .style()
244                .set_property("--safe-area-inset-top", &top_value);
245            let _ = element
246                .style()
247                .set_property("--safe-area-inset-right", &right_value);
248            let _ = element
249                .style()
250                .set_property("--safe-area-inset-bottom", &bottom_value);
251            let _ = element
252                .style()
253                .set_property("--safe-area-inset-left", &left_value);
254        };
255        if let Some(app_root) = document_value
256            .query_selector(".c_mobile_app_root")
257            .ok()
258            .flatten()
259            .map(|element: Element| element.unchecked_into::<HtmlElement>())
260            .or_else(|| {
261                document_value
262                    .query_selector(".c_app_root")
263                    .ok()
264                    .flatten()
265                    .map(|element: Element| element.unchecked_into::<HtmlElement>())
266            })
267        {
268            apply_to(&app_root);
269        }
270        if let Some(canvas_fullscreen) = document_value
271            .query_selector(".c_canvas_container_fullscreen")
272            .ok()
273            .flatten()
274            .map(|element: Element| element.unchecked_into::<HtmlElement>())
275        {
276            apply_to(&canvas_fullscreen);
277        }
278    }
279}