impulse_thaw/anchor/
mod.rs

1mod anchor_link;
2
3pub use anchor_link::AnchorLink;
4
5use leptos::{context::Provider, html, prelude::*};
6use thaw_utils::{class_list, mount_style};
7use web_sys::{DomRect, Element};
8
9#[component]
10pub fn Anchor(
11    #[prop(optional, into)] class: MaybeProp<String>,
12    /// The element or selector used to calc offset of link elements.
13    /// If you are not scrolling the entire document but only a part of it,
14    /// you may need to set this.
15    #[prop(into, optional)]
16    offset_target: Option<OffsetTarget>,
17    children: Children,
18) -> impl IntoView {
19    mount_style("anchor", include_str!("./anchor.css"));
20    let anchor_ref = NodeRef::new();
21    let bar_ref = NodeRef::new();
22    let element_ids = RwSignal::new(Vec::<String>::new());
23    let active_id = RwSignal::new(None::<String>);
24
25    #[cfg(any(feature = "csr", feature = "hydrate"))]
26    {
27        use leptos::ev;
28        use std::cmp::Ordering;
29        use thaw_utils::{add_event_listener_with_bool, throttle};
30
31        struct LinkInfo {
32            top: f64,
33            id: String,
34        }
35
36        let offset_target = send_wrapper::SendWrapper::new(offset_target);
37
38        let on_scroll = move || {
39            element_ids.with(|ids| {
40                let offset_target_top = if let Some(offset_target) = offset_target.as_ref() {
41                    if let Some(rect) = offset_target.get_bounding_client_rect() {
42                        rect.top()
43                    } else {
44                        return;
45                    }
46                } else {
47                    0.0
48                };
49
50                let mut links: Vec<LinkInfo> = vec![];
51                for id in ids.iter() {
52                    if let Some(link_el) = document().get_element_by_id(id) {
53                        let link_rect = link_el.get_bounding_client_rect();
54                        links.push(LinkInfo {
55                            top: link_rect.top() - offset_target_top,
56                            id: id.clone(),
57                        });
58                    }
59                }
60                links.sort_by(|a, b| {
61                    if a.top > b.top {
62                        Ordering::Greater
63                    } else {
64                        Ordering::Less
65                    }
66                });
67
68                let mut temp_link = None::<LinkInfo>;
69                for link in links.into_iter() {
70                    if link.top >= 0.0 {
71                        if link.top <= 12.0 {
72                            temp_link = Some(link);
73                            break;
74                        } else if temp_link.is_some() {
75                            break;
76                        } else {
77                            temp_link = None;
78                        }
79                    } else {
80                        temp_link = Some(link);
81                    }
82                }
83                active_id.set(temp_link.map(|link| link.id));
84            });
85        };
86        let cb = throttle(
87            move || {
88                on_scroll();
89            },
90            std::time::Duration::from_millis(200),
91        );
92        let scroll_handle = add_event_listener_with_bool(
93            document(),
94            ev::scroll,
95            move |_| {
96                cb();
97            },
98            true,
99        );
100        on_cleanup(move || {
101            scroll_handle.remove();
102        });
103    }
104    #[cfg(not(any(feature = "csr", feature = "hydrate")))]
105    {
106        let _ = offset_target;
107    }
108
109    view! {
110        <div class=class_list!["thaw-anchor", class] node_ref=anchor_ref>
111            <div class="thaw-anchor-rail">
112                <div
113                    class="thaw-anchor-rail__bar"
114                    class=(
115                        "thaw-anchor-rail__bar--active",
116                        move || active_id.with(|id| id.is_some()),
117                    )
118
119                    node_ref=bar_ref
120                ></div>
121            </div>
122            <Provider value=AnchorInjection::new(
123                anchor_ref,
124                bar_ref,
125                element_ids,
126                active_id,
127            )>{children()}</Provider>
128        </div>
129    }
130}
131
132#[derive(Clone)]
133pub(crate) struct AnchorInjection {
134    anchor_ref: NodeRef<html::Div>,
135    bar_ref: NodeRef<html::Div>,
136    element_ids: RwSignal<Vec<String>>,
137    pub active_id: RwSignal<Option<String>>,
138}
139
140impl Copy for AnchorInjection {}
141
142impl AnchorInjection {
143    pub fn expect_context() -> Self {
144        expect_context()
145    }
146
147    fn new(
148        anchor_ref: NodeRef<html::Div>,
149        bar_ref: NodeRef<html::Div>,
150        element_ids: RwSignal<Vec<String>>,
151        active_id: RwSignal<Option<String>>,
152    ) -> Self {
153        Self {
154            anchor_ref,
155            bar_ref,
156            element_ids,
157            active_id,
158        }
159    }
160
161    pub fn scroll_into_view(&self, id: &String) {
162        let Some(link_el) = document().get_element_by_id(id) else {
163            return;
164        };
165        link_el.scroll_into_view();
166    }
167
168    pub fn append_id(&self, id: String) {
169        self.element_ids.update(|ids| {
170            ids.push(id);
171        });
172    }
173
174    pub fn remove_id(&self, id: &String) {
175        self.element_ids.update(|ids| {
176            if let Some(index) = ids.iter().position(|item_id| item_id == id) {
177                ids.remove(index);
178            }
179        });
180    }
181
182    pub fn update_background_position(&self, title_rect: DomRect) {
183        if let Some(anchor_el) = self.anchor_ref.get_untracked() {
184            let bar_el = self.bar_ref.get_untracked().unwrap();
185            let anchor_rect = anchor_el.get_bounding_client_rect();
186
187            let offset_top = title_rect.top() - anchor_rect.top();
188            // let offset_left = title_rect.left() - anchor_rect.left();
189
190            bar_el.style(("top", format!("{}px", offset_top)));
191            bar_el.style(("height", format!("{}px", title_rect.height())));
192        }
193    }
194}
195
196pub enum OffsetTarget {
197    Selector(String),
198    Element(Element),
199}
200
201#[cfg(any(feature = "csr", feature = "hydrate"))]
202impl OffsetTarget {
203    fn get_bounding_client_rect(&self) -> Option<DomRect> {
204        match self {
205            OffsetTarget::Selector(selector) => {
206                let el = document().query_selector(selector).ok().flatten()?;
207                Some(el.get_bounding_client_rect())
208            }
209            OffsetTarget::Element(el) => Some(el.get_bounding_client_rect()),
210        }
211    }
212}
213
214impl From<&'static str> for OffsetTarget {
215    fn from(value: &'static str) -> Self {
216        Self::Selector(value.to_string())
217    }
218}
219
220impl From<String> for OffsetTarget {
221    fn from(value: String) -> Self {
222        Self::Selector(value)
223    }
224}
225
226impl From<Element> for OffsetTarget {
227    fn from(value: Element) -> Self {
228        Self::Element(value)
229    }
230}