flams_web_utils/components/
anchors.rs

1use leptos::{context::Provider, html, prelude::*};
2use leptos::web_sys::{DomRect, Element};
3
4use crate::inject_css;
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
76#[component]
77pub fn Anchor(
78  children: Children,
79) -> impl IntoView {
80  inject_css("anchor",include_str!("./anchor.css"));
81
82  let anchor_ref = NodeRef::new();
83  let bar_ref = NodeRef::new();
84  let element_ids = RwSignal::new(Vec::<String>::new());
85  let active_id = RwSignal::new(None::<String>);
86
87    #[cfg(any(feature = "csr", feature = "hydrate"))]
88    {
89        use leptos::ev;
90        use std::cmp::Ordering;
91        use thaw_utils::{add_event_listener_with_bool, throttle};
92
93        struct LinkInfo {
94            top: f64,
95            id: String,
96        }
97
98        let offset_target : send_wrapper::SendWrapper<Option<OffsetTarget>>  = send_wrapper::SendWrapper::new(None);
99
100        let on_scroll = move || {
101            element_ids.with(|ids| {
102                let offset_target_top = if let Some(offset_target) = offset_target.as_ref() {
103                    if let Some(rect) = offset_target.get_bounding_client_rect() {
104                        rect.top()
105                    } else {
106                        return;
107                    }
108                } else {
109                    0.0
110                };
111
112                let mut links: Vec<LinkInfo> = vec![];
113                for id in ids {
114                    if let Some(link_el) = document().get_element_by_id(id) {
115                        let link_rect = link_el.get_bounding_client_rect();
116                        links.push(LinkInfo {
117                            top: link_rect.top() - offset_target_top,
118                            id: id.clone(),
119                        });
120                    }
121                }
122                links.sort_by(|a, b| {
123                    if a.top > b.top {
124                        Ordering::Greater
125                    } else {
126                        Ordering::Less
127                    }
128                });
129
130                let mut temp_link = None::<LinkInfo>;
131                for link in links {
132                    if link.top >= 0.0 {
133                        if link.top <= 12.0 {
134                            temp_link = Some(link);
135                            break;
136                        } else if temp_link.is_some() {
137                            break;
138                        } 
139                        temp_link = None;
140                    } else {
141                        temp_link = Some(link);
142                    }
143                }
144                active_id.set(temp_link.map(|link| link.id));
145            });
146        };
147        let cb = throttle(
148            move || {
149                on_scroll();
150            },
151            std::time::Duration::from_millis(200),
152        );
153        let scroll_handle = add_event_listener_with_bool(
154            document(),
155            ev::scroll,
156            move |_| {
157                cb();
158            },
159            true,
160        );
161        on_cleanup(move || {
162            scroll_handle.remove();
163        });
164    }
165
166    view! {
167      <div class="thaw-anchor" node_ref=anchor_ref>
168          <div class="thaw-anchor-rail">
169              <div
170                  class="thaw-anchor-rail__bar"
171                  class=(
172                      "thaw-anchor-rail__bar--active",
173                      move || active_id.with(Option::is_some),
174                  )
175
176                  node_ref=bar_ref
177              ></div>
178          </div>
179          <Provider value=AnchorInjection::new(
180              anchor_ref,
181              bar_ref,
182              element_ids,
183              active_id,
184          )>{children()}</Provider>
185      </div>
186  }
187}
188
189#[derive(Clone,Copy)]
190struct AnchorInjection {
191    anchor_ref: NodeRef<html::Div>,
192    bar_ref: NodeRef<html::Div>,
193    element_ids: RwSignal<Vec<String>>,
194    active_id: RwSignal<Option<String>>,
195}
196
197
198impl AnchorInjection {
199  pub fn expect_context() -> Self {
200      expect_context()
201  }
202
203  const fn new(
204      anchor_ref: NodeRef<html::Div>,
205      bar_ref: NodeRef<html::Div>,
206      element_ids: RwSignal<Vec<String>>,
207      active_id: RwSignal<Option<String>>,
208  ) -> Self {
209      Self {
210          anchor_ref,
211          bar_ref,
212          element_ids,
213          active_id,
214      }
215  }
216
217  pub fn scroll_into_view(id: &str) {
218      let Some(link_el) = document().get_element_by_id(id) else {
219          return;
220      };
221      link_el.scroll_into_view();
222  }
223
224  pub fn append_id(&self, id: String) {
225      self.element_ids.update(|ids| {
226          ids.push(id);
227      });
228  }
229
230  pub fn remove_id(&self, id: &String) {
231      self.element_ids.update(|ids| {
232          if let Some(index) = ids.iter().position(|item_id| item_id == id) {
233              ids.remove(index);
234          }
235      });
236  }
237
238  pub fn update_background_position(&self, title_rect: &DomRect) {
239      if let Some(anchor_el) = self.anchor_ref.get_untracked() {
240          let bar_el = self.bar_ref.get_untracked().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}