Skip to main content

yggdrasil/hooks/
near_viewport.rs

1//! 元素接近视口的单向可见性激活。
2
3use leptos::prelude::*;
4
5/// 观察指定 id 的元素是否进入「视口 + 提前量」范围;一旦相交就把返回的信号置为
6/// `true` 并停止观察(单向激活,滚出视口不回退——调用方负责激活后的资源生命周期)。
7///
8/// - 服务端永远返回 `false`:服务端不装观察器,水合首帧与 SSR 输出一致,
9///   可见性判断只在水合之后的 `Effect` 里生效。
10/// - 浏览器不支持 IntersectionObserver 时保持 `false`:调用方必须提供交互式激活
11///   兜底(点击/按钮),不能让目标永远无法激活。
12/// - 提前量由 `root_margin` 控制,默认约一个视口高度;调用方按移动端体验调整。
13///
14/// 卸载安全:观察器与回调存放在 `StoredValue::new_local` 里,`on_cleanup` 断开连接;
15/// 回调触发的信号写入经 `try_set`,组件卸载后的迟到回调不会写已释放的信号。
16///
17/// 约束:观察器只在首次 `Effect` 运行时按 `target_id` 查找元素并挂接,`target_id`
18/// 变化不会重连。调用方必须保证 id 在组件实例存续期内稳定,且目标元素在水合完成时
19/// 已在 DOM 中(当前 CodeRunner/post 容器均以确定性索引派生 id 且内容变化经
20/// keyed 重建实例)。
21pub fn use_near_viewport(target_id: String, root_margin: &'static str) -> ReadSignal<bool> {
22    let (visible, set_visible) = signal(false);
23
24    #[cfg(target_arch = "wasm32")]
25    {
26        use wasm_bindgen::{closure::Closure, JsCast};
27
28        type Watch = (
29            web_sys::IntersectionObserver,
30            Closure<dyn FnMut(js_sys::Array, web_sys::IntersectionObserver)>,
31        );
32        let watcher = StoredValue::new_local(None::<Watch>);
33        Effect::new(move |_| {
34            // 已激活或已挂接就不再观察。
35            if visible.get_untracked() || watcher.with_value(|w| w.is_some()) {
36                return;
37            }
38            let Some(target) = web_sys::window()
39                .and_then(|window| window.document())
40                .and_then(|document| document.get_element_by_id(&target_id))
41            else {
42                return;
43            };
44            let callback = Closure::new(
45                move |entries: js_sys::Array, observer: web_sys::IntersectionObserver| {
46                    if entries.iter().any(|value| {
47                        value
48                            .unchecked_into::<web_sys::IntersectionObserverEntry>()
49                            .is_intersecting()
50                    }) {
51                        observer.disconnect();
52                        let _ = set_visible.try_set(true);
53                    }
54                },
55            );
56            let init = web_sys::IntersectionObserverInit::new();
57            init.set_root_margin(root_margin);
58            match web_sys::IntersectionObserver::new_with_options(
59                callback.as_ref().unchecked_ref(),
60                &init,
61            ) {
62                Ok(observer) => {
63                    observer.observe(&target);
64                    watcher.set_value(Some((observer, callback)));
65                }
66                Err(_) => {
67                    // IntersectionObserver 不可用(老浏览器):保持未激活,
68                    // 由调用方的交互入口(点击代码 / 运行按钮)兜底激活。
69                }
70            }
71        });
72        on_cleanup(move || {
73            if let Some(Some((observer, _callback))) = watcher.try_update_value(Option::take) {
74                observer.disconnect();
75            }
76        });
77    }
78    #[cfg(not(target_arch = "wasm32"))]
79    {
80        let _ = (target_id, root_margin, set_visible);
81    }
82
83    visible
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use leptos::reactive::owner::Owner;
90
91    #[test]
92    fn server_side_never_activates() {
93        Owner::new().with(|| {
94            let visible = use_near_viewport("target".to_string(), "0px 0px 100% 0px");
95            assert!(!visible.get_untracked());
96        });
97    }
98}