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 Self::init_immersive_safe_area();
101 App::use_window_event("fullscreenchange", || {
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("webkitfullscreenchange", || {
130 Self::apply_cached_insets();
131 });
132 App::use_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 /// Applies the real top safe-area inset to the mobile header and drawer when
152 /// the host environment declares immersive (edge-to-edge) mode.
153 ///
154 /// Immersive hosts — such as a Tauri Android WebView laid out edge-to-edge —
155 /// declare themselves either by setting `window.__EUV_IMMERSIVE__ = true`
156 /// before app initialisation or by including
157 /// `<meta name="euv-immersive" content="true">` in the document. Only then is
158 /// the cached `env(safe-area-inset-top)` pixel value written to the
159 /// `--euv-mobile-safe-top` CSS custom property on `<html>`, which
160 /// `c_mobile_header` and `c_mobile_nav_drawer` consume for their top padding.
161 ///
162 /// Browsers that letterbox the page below the system status bar never set the
163 /// marker, so the variable keeps its `0px` default. This deliberately avoids
164 /// trusting `env()` unconditionally: some Android browsers (e.g. VivoBrowser)
165 /// letterbox the page yet still report a non-zero top inset, which would
166 /// otherwise render as a blank band above the navbar.
167 fn init_immersive_safe_area() {
168 if !Self::is_immersive_declared() {
169 return;
170 }
171 let top_value: String =
172 SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
173 if top_value.is_empty() {
174 return;
175 }
176 let Some(window_value) = window() else {
177 return;
178 };
179 let Some(document_value) = window_value.document() else {
180 return;
181 };
182 let Some(root) = document_value.document_element() else {
183 return;
184 };
185 let root_element: HtmlElement = root.unchecked_into();
186 let _: Result<(), JsValue> = root_element
187 .style()
188 .set_property("--euv-mobile-safe-top", &top_value);
189 }
190
191 /// Returns whether the host environment declares immersive (edge-to-edge)
192 /// mode via `window.__EUV_IMMERSIVE__` or a
193 /// `<meta name="euv-immersive" content="true">` tag.
194 ///
195 /// # Returns
196 ///
197 /// - `bool` - `true` when immersive mode is declared by the host.
198 fn is_immersive_declared() -> bool {
199 let Some(window_value) = window() else {
200 return false;
201 };
202 let global_flag: bool =
203 js_sys::Reflect::get(&window_value, &JsValue::from_str("__EUV_IMMERSIVE__"))
204 .ok()
205 .and_then(|value: JsValue| value.as_bool())
206 .unwrap_or(false);
207 if global_flag {
208 return true;
209 }
210 window_value
211 .document()
212 .and_then(|document_value: Document| {
213 document_value
214 .query_selector(r#"meta[name="euv-immersive"]"#)
215 .ok()
216 .flatten()
217 })
218 .and_then(|meta: Element| meta.get_attribute("content"))
219 .map(|content: String| content == "true")
220 .unwrap_or(false)
221 }
222
223 /// Reads the current `env(safe-area-inset-*)` pixel values via a temporary
224 /// sentinel element and persists them in thread-local storage.
225 ///
226 /// The sentinel `<div>` is created with `padding-top: env(safe-area-inset-top)`
227 /// (and similarly for the other three sides). After forcing a layout
228 /// calculation, `getComputedStyle` yields the resolved pixel value, which is
229 /// then stored in `SAFE_AREA_INSET_*` thread-local cells.
230 ///
231 /// If the top inset is empty or `0px` (i.e. no safe area on this device or
232 /// immersive mode not active), the values are not cached and no override is
233 /// applied.
234 fn cache_safe_area_insets() {
235 let top_cached: String =
236 SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
237 if !top_cached.is_empty() {
238 return;
239 }
240 let Some(win) = window() else {
241 return;
242 };
243 let Some(document_value) = win.document() else {
244 return;
245 };
246 let Some(body) = document_value.body() else {
247 return;
248 };
249 let Ok(created_element) = document_value.create_element("div") else {
250 return;
251 };
252 let sentinel: HtmlElement = created_element.unchecked_into();
253 let _: Result<(), JsValue> = sentinel.style().set_property("position", "absolute");
254 let _: Result<(), JsValue> = sentinel.style().set_property("visibility", "hidden");
255 let _: Result<(), JsValue> = sentinel.style().set_property("pointer-events", "none");
256 let _: Result<(), JsValue> = sentinel
257 .style()
258 .set_property("padding-top", "env(safe-area-inset-top, 0px)");
259 let _: Result<(), JsValue> = sentinel
260 .style()
261 .set_property("padding-right", "env(safe-area-inset-right, 0px)");
262 let _: Result<(), JsValue> = sentinel
263 .style()
264 .set_property("padding-bottom", "env(safe-area-inset-bottom, 0px)");
265 let _: Result<(), JsValue> = sentinel
266 .style()
267 .set_property("padding-left", "env(safe-area-inset-left, 0px)");
268 let _: Result<Node, JsValue> = body.append_child(&sentinel);
269 let Some(computed) = win.get_computed_style(&sentinel).ok().flatten() else {
270 let _: Result<Node, JsValue> = body.remove_child(&sentinel);
271 return;
272 };
273 let top_value: String = computed
274 .get_property_value("padding-top")
275 .unwrap_or_default();
276 let right_value: String = computed
277 .get_property_value("padding-right")
278 .unwrap_or_default();
279 let bottom_value: String = computed
280 .get_property_value("padding-bottom")
281 .unwrap_or_default();
282 let left_value: String = computed
283 .get_property_value("padding-left")
284 .unwrap_or_default();
285 let _: Result<Node, JsValue> = body.remove_child(&sentinel);
286 if top_value.is_empty() || top_value == "0px" {
287 return;
288 }
289 SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| *cell.borrow_mut() = top_value);
290 SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| *cell.borrow_mut() = right_value);
291 SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| *cell.borrow_mut() = bottom_value);
292 SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| *cell.borrow_mut() = left_value);
293 }
294
295 /// Writes the cached safe-area inset values as inline CSS custom properties
296 /// on the real app root element and any fullscreen overlay containers.
297 ///
298 /// Class rules such as `c_mobile_app_root`, `c_app_nav`, `c_app_main`,
299 /// `c_mobile_nav_drawer`, and `c_canvas_container_fullscreen` consume
300 /// `var(--safe-area-inset-top)` in their `padding` declarations. By overriding
301 /// these CSS custom properties with inline style (which has higher specificity
302 /// than the stylesheet rule from `vars!`), all `var()` references resolve to
303 /// the cached pixel values, bypassing the stale `env()` function after a
304 /// fullscreen exit.
305 ///
306 /// The canvas fullscreen container is `position: fixed` and outside the app
307 /// root subtree, so it does not inherit the inline overrides — it must be
308 /// patched separately.
309 pub fn apply_cached_insets() {
310 let top_value: String =
311 SAFE_AREA_INSET_TOP.with(|cell: &RefCell<String>| cell.borrow().clone());
312 if top_value.is_empty() {
313 return;
314 }
315 let right_value: String =
316 SAFE_AREA_INSET_RIGHT.with(|cell: &RefCell<String>| cell.borrow().clone());
317 let bottom_value: String =
318 SAFE_AREA_INSET_BOTTOM.with(|cell: &RefCell<String>| cell.borrow().clone());
319 let left_value: String =
320 SAFE_AREA_INSET_LEFT.with(|cell: &RefCell<String>| cell.borrow().clone());
321 let Some(window_value) = window() else {
322 return;
323 };
324 let Some(document_value) = window_value.document() else {
325 return;
326 };
327 let apply_to = |element: &HtmlElement| {
328 let _: Result<(), JsValue> = element
329 .style()
330 .set_property("--safe-area-inset-top", &top_value);
331 let _: Result<(), JsValue> = element
332 .style()
333 .set_property("--safe-area-inset-right", &right_value);
334 let _: Result<(), JsValue> = element
335 .style()
336 .set_property("--safe-area-inset-bottom", &bottom_value);
337 let _: Result<(), JsValue> = element
338 .style()
339 .set_property("--safe-area-inset-left", &left_value);
340 };
341 if let Some(app_root) = document_value
342 .query_selector(".c_mobile_app_root")
343 .ok()
344 .flatten()
345 .map(|element: Element| element.unchecked_into::<HtmlElement>())
346 .or_else(|| {
347 document_value
348 .query_selector(".c_app_root")
349 .ok()
350 .flatten()
351 .map(|element: Element| element.unchecked_into::<HtmlElement>())
352 })
353 {
354 apply_to(&app_root);
355 }
356 if let Some(canvas_fullscreen) = document_value
357 .query_selector(".c_canvas_container_fullscreen")
358 .ok()
359 .flatten()
360 .map(|element: Element| element.unchecked_into::<HtmlElement>())
361 {
362 apply_to(&canvas_fullscreen);
363 }
364 }
365}