flams_web_utils/components/
anchors.rs

1use ftml_dom::utils::css::inject_css;
2use leptos::web_sys::{DomRect, Element};
3use leptos::{context::Provider, html, prelude::*};
4
5use super::Header;
6
7#[component]
8pub fn AnchorLink(
9    header: Header,
10    /// The target of link.
11    #[prop(into)]
12    href: String,
13    #[prop(optional)] children: Option<Children>,
14) -> impl IntoView {
15    let anchor = AnchorInjection::expect_context();
16    let title_ref = NodeRef::<html::A>::new();
17    let href_id = StoredValue::new(None::<String>);
18    let is_active = Memo::new(move |_| {
19        href_id.with_value(|href_id| {
20            if href_id.is_none() {
21                false
22            } else {
23                anchor.active_id.with(|active_id| active_id == href_id)
24            }
25        })
26    });
27
28    if !href.is_empty() && href.starts_with('#') {
29        let id = href[1..].to_string();
30        href_id.set_value(Some(id.clone()));
31        anchor.append_id(id);
32
33        on_cleanup(move || {
34            href_id.with_value(|id| {
35                if let Some(id) = id {
36                    anchor.remove_id(id);
37                }
38            });
39        });
40
41        Effect::new(move |_| {
42            let Some(title_el) = title_ref.get() else {
43                return;
44            };
45
46            if is_active.get() {
47                let title_rect = title_el.get_bounding_client_rect();
48                anchor.update_background_position(&title_rect);
49            }
50        });
51    }
52    let on_click = move |_| {
53        href_id.with_value(move |href_id| {
54            if let Some(href_id) = href_id {
55                AnchorInjection::scroll_into_view(href_id);
56            }
57        });
58    };
59
60    view! {
61        <div class="thaw-anchor-link" class:thaw-anchor-link--active= move || is_active.get()>
62            <a
63                href=href
64                class="thaw-anchor-link__title"
65                on:click=on_click
66                node_ref=title_ref
67            >
68                {(header.children)()}
69            </a>
70            {children.map(|c| c())}
71        </div>
72    }
73}
74
75#[component]
76pub fn Anchor(children: Children) -> impl IntoView {
77    inject_css("anchor", include_str!("./anchor.css"));
78
79    let anchor_ref = NodeRef::new();
80    let bar_ref = NodeRef::new();
81    let element_ids = RwSignal::new(Vec::<String>::new());
82    let active_id = RwSignal::new(None::<String>);
83
84    #[cfg(any(feature = "csr", feature = "hydrate"))]
85    {
86        use leptos::ev;
87        use std::cmp::Ordering;
88        use thaw_utils::{add_event_listener_with_bool, throttle};
89
90        struct LinkInfo {
91            top: f64,
92            id: String,
93        }
94
95        let offset_target: send_wrapper::SendWrapper<Option<OffsetTarget>> =
96            send_wrapper::SendWrapper::new(None);
97
98        let on_scroll = move || {
99            element_ids.with(|ids| {
100                let offset_target_top = if let Some(offset_target) = offset_target.as_ref() {
101                    if let Some(rect) = offset_target.get_bounding_client_rect() {
102                        rect.top()
103                    } else {
104                        return;
105                    }
106                } else {
107                    0.0
108                };
109
110                let mut links: Vec<LinkInfo> = vec![];
111                for id in ids {
112                    if let Some(link_el) = document().get_element_by_id(id) {
113                        let link_rect = link_el.get_bounding_client_rect();
114                        links.push(LinkInfo {
115                            top: link_rect.top() - offset_target_top,
116                            id: id.clone(),
117                        });
118                    }
119                }
120                links.sort_by(|a, b| {
121                    if a.top > b.top {
122                        Ordering::Greater
123                    } else {
124                        Ordering::Less
125                    }
126                });
127
128                let mut temp_link = None::<LinkInfo>;
129                for link in links {
130                    if link.top >= 0.0 {
131                        if link.top <= 12.0 {
132                            temp_link = Some(link);
133                            break;
134                        } else if temp_link.is_some() {
135                            break;
136                        }
137                        temp_link = None;
138                    } else {
139                        temp_link = Some(link);
140                    }
141                }
142                active_id.set(temp_link.map(|link| link.id));
143            });
144        };
145        let cb = throttle(
146            move || {
147                on_scroll();
148            },
149            std::time::Duration::from_millis(200),
150        );
151        let scroll_handle = add_event_listener_with_bool(
152            document(),
153            ev::scroll,
154            move |_| {
155                cb();
156            },
157            true,
158        );
159        on_cleanup(move || {
160            scroll_handle.remove();
161        });
162    }
163
164    view! {
165        <div class="thaw-anchor" node_ref=anchor_ref>
166            <div class="thaw-anchor-rail">
167                <div
168                    class="thaw-anchor-rail__bar"
169                    class=(
170                        "thaw-anchor-rail__bar--active",
171                        move || active_id.with(Option::is_some),
172                    )
173
174                    node_ref=bar_ref
175                ></div>
176            </div>
177            <Provider value=AnchorInjection::new(
178                anchor_ref,
179                bar_ref,
180                element_ids,
181                active_id,
182            )>{children()}</Provider>
183        </div>
184    }
185}
186
187#[derive(Clone, Copy)]
188struct AnchorInjection {
189    anchor_ref: NodeRef<html::Div>,
190    bar_ref: NodeRef<html::Div>,
191    element_ids: RwSignal<Vec<String>>,
192    active_id: RwSignal<Option<String>>,
193}
194
195impl AnchorInjection {
196    pub fn expect_context() -> Self {
197        expect_context()
198    }
199
200    const fn new(
201        anchor_ref: NodeRef<html::Div>,
202        bar_ref: NodeRef<html::Div>,
203        element_ids: RwSignal<Vec<String>>,
204        active_id: RwSignal<Option<String>>,
205    ) -> Self {
206        Self {
207            anchor_ref,
208            bar_ref,
209            element_ids,
210            active_id,
211        }
212    }
213
214    pub fn scroll_into_view(id: &str) {
215        let Some(link_el) = document().get_element_by_id(id) else {
216            return;
217        };
218        link_el.scroll_into_view();
219    }
220
221    pub fn append_id(&self, id: String) {
222        self.element_ids.update(|ids| {
223            ids.push(id);
224        });
225    }
226
227    pub fn remove_id(&self, id: &String) {
228        self.element_ids.update(|ids| {
229            if let Some(index) = ids.iter().position(|item_id| item_id == id) {
230                ids.remove(index);
231            }
232        });
233    }
234
235    pub fn update_background_position(&self, title_rect: &DomRect) {
236        if let Some(anchor_el) = self.anchor_ref.get_untracked() {
237            let bar_el = self
238                .bar_ref
239                .get_untracked()
240                .expect("This should not happen");
241            let anchor_rect = anchor_el.get_bounding_client_rect();
242
243            let offset_top = title_rect.top() - anchor_rect.top();
244            // let offset_left = title_rect.left() - anchor_rect.left();
245
246            bar_el.style(("top", format!("{offset_top}px")));
247            bar_el.style(("height", format!("{}px", title_rect.height())));
248        }
249    }
250}
251
252pub enum OffsetTarget {
253    Selector(String),
254    Element(Element),
255}
256
257#[cfg(any(feature = "csr", feature = "hydrate"))]
258impl OffsetTarget {
259    fn get_bounding_client_rect(&self) -> Option<DomRect> {
260        match self {
261            Self::Selector(selector) => {
262                let el = document().query_selector(selector).ok().flatten()?;
263                Some(el.get_bounding_client_rect())
264            }
265            Self::Element(el) => Some(el.get_bounding_client_rect()),
266        }
267    }
268}
269
270impl From<&'static str> for OffsetTarget {
271    fn from(value: &'static str) -> Self {
272        Self::Selector(value.to_string())
273    }
274}
275
276impl From<String> for OffsetTarget {
277    fn from(value: String) -> Self {
278        Self::Selector(value)
279    }
280}
281
282impl From<Element> for OffsetTarget {
283    fn from(value: Element) -> Self {
284        Self::Element(value)
285    }
286}