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("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    /// [`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        App::use_window_event("fullscreenchange", || {
101            let Some(window_value) = window() else {
102                return;
103            };
104            let Some(document_value) = window_value.document() else {
105                return;
106            };
107            let is_fullscreen: bool = document_value.fullscreen_element().is_some();
108            if is_fullscreen {
109                NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.set(true));
110                Router::overlay_push_state();
111            } else {
112                let was_active: bool =
113                    NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.get());
114                if was_active {
115                    NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.set(false));
116                    let exit_by_popstate: bool =
117                        NATIVE_FULLSCREEN_EXIT_BY_POPSTATE.with(|flag: &Cell<bool>| flag.get());
118                    if exit_by_popstate {
119                        NATIVE_FULLSCREEN_EXIT_BY_POPSTATE
120                            .with(|flag: &Cell<bool>| flag.set(false));
121                    } else {
122                        Router::overlay_back(None);
123                    }
124                }
125                Self::apply_cached_insets();
126            }
127        });
128        App::use_window_event("webkitfullscreenchange", || {
129            Self::apply_cached_insets();
130        });
131        App::use_window_event("resize", || {
132            Self::apply_cached_insets();
133        });
134        Router::register_popstate_guard(Rc::new(|| {
135            if !NATIVE_FULLSCREEN_ACTIVE.with(|flag: &Cell<bool>| flag.get()) {
136                return false;
137            }
138            NATIVE_FULLSCREEN_EXIT_BY_POPSTATE.with(|flag: &Cell<bool>| flag.set(true));
139            let Some(window_value) = window() else {
140                return false;
141            };
142            let Some(document_value) = window_value.document() else {
143                return false;
144            };
145            document_value.exit_fullscreen();
146            true
147        }));
148    }
149
150    /// Reads the current `env(safe-area-inset-*)` pixel values via a temporary
151    /// sentinel element and persists them in thread-local storage.
152    ///
153    /// The sentinel `<div>` is created with `padding-top: env(safe-area-inset-top)`
154    /// (and similarly for the other three sides). After forcing a layout
155    /// calculation, `getComputedStyle` yields the resolved pixel value, which is
156    /// then stored in `SAFE_AREA_INSET_*` thread-local cells.
157    ///
158    /// If the top inset is empty or `0px` (i.e. no safe area on this device or
159    /// immersive mode not active), the values are not cached and no override is
160    /// applied.
161    fn cache_safe_area_insets() {
162        let top_cached: String =
163            SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
164        if !top_cached.is_empty() {
165            return;
166        }
167        let Some(win) = window() else {
168            return;
169        };
170        let Some(document_value) = win.document() else {
171            return;
172        };
173        let Some(body) = document_value.body() else {
174            return;
175        };
176        let Ok(created_element) = document_value.create_element("div") else {
177            return;
178        };
179        let sentinel: HtmlElement = created_element.unchecked_into();
180        let _: Result<(), JsValue> = sentinel.style().set_property("position", "absolute");
181        let _: Result<(), JsValue> = sentinel.style().set_property("visibility", "hidden");
182        let _: Result<(), JsValue> = sentinel.style().set_property("pointer-events", "none");
183        let _: Result<(), JsValue> = sentinel
184            .style()
185            .set_property("padding-top", "env(safe-area-inset-top, 0px)");
186        let _: Result<(), JsValue> = sentinel
187            .style()
188            .set_property("padding-right", "env(safe-area-inset-right, 0px)");
189        let _: Result<(), JsValue> = sentinel
190            .style()
191            .set_property("padding-bottom", "env(safe-area-inset-bottom, 0px)");
192        let _: Result<(), JsValue> = sentinel
193            .style()
194            .set_property("padding-left", "env(safe-area-inset-left, 0px)");
195        let _: Result<Node, JsValue> = body.append_child(&sentinel);
196        let Some(computed) = win.get_computed_style(&sentinel).ok().flatten() else {
197            let _: Result<Node, JsValue> = body.remove_child(&sentinel);
198            return;
199        };
200        let top_value: String = computed
201            .get_property_value("padding-top")
202            .unwrap_or_default();
203        let right_value: String = computed
204            .get_property_value("padding-right")
205            .unwrap_or_default();
206        let bottom_value: String = computed
207            .get_property_value("padding-bottom")
208            .unwrap_or_default();
209        let left_value: String = computed
210            .get_property_value("padding-left")
211            .unwrap_or_default();
212        let _: Result<Node, JsValue> = body.remove_child(&sentinel);
213        if top_value.is_empty() || top_value == "0px" {
214            return;
215        }
216        SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| *cell.borrow_mut() = top_value);
217        SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| *cell.borrow_mut() = right_value);
218        SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| *cell.borrow_mut() = bottom_value);
219        SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| *cell.borrow_mut() = left_value);
220    }
221
222    /// Writes the cached safe-area inset values as inline CSS custom properties
223    /// on the real app root element and any fullscreen overlay containers.
224    ///
225    /// Class rules such as `c_mobile_app_root`, `c_app_nav`, `c_app_main`,
226    /// `c_mobile_nav_drawer`, and `c_canvas_container_fullscreen` consume
227    /// `var(--safe-area-inset-top)` in their `padding` declarations. By overriding
228    /// these CSS custom properties with inline style (which has higher specificity
229    /// than the stylesheet rule from `vars!`), all `var()` references resolve to
230    /// the cached pixel values, bypassing the stale `env()` function after a
231    /// fullscreen exit.
232    ///
233    /// The canvas fullscreen container is `position: fixed` and outside the app
234    /// root subtree, so it does not inherit the inline overrides — it must be
235    /// patched separately.
236    pub fn apply_cached_insets() {
237        let top_value: String =
238            SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
239        if top_value.is_empty() {
240            return;
241        }
242        let right_value: String =
243            SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| cell.borrow().clone());
244        let bottom_value: String =
245            SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| cell.borrow().clone());
246        let left_value: String =
247            SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| cell.borrow().clone());
248        let Some(window_value) = window() else {
249            return;
250        };
251        let Some(document_value) = window_value.document() else {
252            return;
253        };
254        let apply_to = |element: &HtmlElement| {
255            let _: Result<(), JsValue> = element
256                .style()
257                .set_property("--safe-area-inset-top", &top_value);
258            let _: Result<(), JsValue> = element
259                .style()
260                .set_property("--safe-area-inset-right", &right_value);
261            let _: Result<(), JsValue> = element
262                .style()
263                .set_property("--safe-area-inset-bottom", &bottom_value);
264            let _: Result<(), JsValue> = element
265                .style()
266                .set_property("--safe-area-inset-left", &left_value);
267        };
268        if let Some(app_root) = document_value
269            .query_selector(".c_mobile_app_root")
270            .ok()
271            .flatten()
272            .map(|element: Element| element.unchecked_into::<HtmlElement>())
273            .or_else(|| {
274                document_value
275                    .query_selector(".c_app_root")
276                    .ok()
277                    .flatten()
278                    .map(|element: Element| element.unchecked_into::<HtmlElement>())
279            })
280        {
281            apply_to(&app_root);
282        }
283        if let Some(canvas_fullscreen) = document_value
284            .query_selector(".c_canvas_container_fullscreen")
285            .ok()
286            .flatten()
287            .map(|element: Element| element.unchecked_into::<HtmlElement>())
288        {
289            apply_to(&canvas_fullscreen);
290        }
291    }
292}