From f75b38f97df3154c4f742294ba97d047d8f4c1f6 Mon Sep 17 00:00:00 2001 From: luoxiaozero <48741584+luoxiaozero@users.noreply.github.com> Date: Fri, 19 Apr 2024 14:42:30 +0800 Subject: [PATCH] Feat/anchor (#170) * feat: Add Anchor component * feat: Anchor collection Link * feat: Anchor scroll view * feat: Demo adds Toc * fix: GuideDemo Toc * feat: Anchor scroll * feat: Anchor offset target --- demo/src/app.rs | 1 + demo/src/pages/components.rs | 15 +- demo/src/pages/guide.rs | 11 +- demo_markdown/docs/anchor/mod.md | 43 +++++ demo_markdown/src/lib.rs | 22 ++- demo_markdown/src/markdown/mod.rs | 63 ++++++- thaw/src/anchor/anchor.css | 77 +++++++++ thaw/src/anchor/anchor_link.rs | 83 ++++++++++ thaw/src/anchor/mod.rs | 267 ++++++++++++++++++++++++++++++ thaw/src/anchor/theme.rs | 20 +++ thaw/src/lib.rs | 2 + thaw/src/theme/mod.rs | 13 +- thaw_utils/src/event_listener.rs | 72 ++++++++ thaw_utils/src/lib.rs | 6 +- thaw_utils/src/throttle.rs | 31 ++++ 15 files changed, 710 insertions(+), 16 deletions(-) create mode 100644 demo_markdown/docs/anchor/mod.md create mode 100644 thaw/src/anchor/anchor.css create mode 100644 thaw/src/anchor/anchor_link.rs create mode 100644 thaw/src/anchor/mod.rs create mode 100644 thaw/src/anchor/theme.rs create mode 100644 thaw_utils/src/throttle.rs diff --git a/demo/src/app.rs b/demo/src/app.rs index 7a3cc26..a243770 100644 --- a/demo/src/app.rs +++ b/demo/src/app.rs @@ -47,6 +47,7 @@ fn TheRouter(is_routing: RwSignal) -> impl IntoView { + diff --git a/demo/src/pages/components.rs b/demo/src/pages/components.rs index 3855a59..1cf7946 100644 --- a/demo/src/pages/components.rs +++ b/demo/src/pages/components.rs @@ -33,10 +33,19 @@ pub fn ComponentsPage() -> impl IntoView { width: 896px; margin: 0 auto; } + .demo-components__toc { + width: 190px; + margin: 12px 2px 12px 12px; + } + .demo-components__toc > .thaw-anchor { + position: sticky; + top: 36px; + } .demo-md-table-box { overflow: auto; } @media screen and (max-width: 1200px) { + .demo-components__toc, .demo-components__sider { display: none; } @@ -56,7 +65,7 @@ pub fn ComponentsPage() -> impl IntoView { - + @@ -208,6 +217,10 @@ pub(crate) fn gen_menu_data() -> Vec { MenuGroupOption { label: "Navigation Components".into(), children: vec![ + MenuItemOption { + value: "anchor".into(), + label: "Anchor".into(), + }, MenuItemOption { value: "back-top".into(), label: "Back Top".into(), diff --git a/demo/src/pages/guide.rs b/demo/src/pages/guide.rs index 639cdd3..172b0e2 100644 --- a/demo/src/pages/guide.rs +++ b/demo/src/pages/guide.rs @@ -32,10 +32,19 @@ pub fn GuidePage() -> impl IntoView { width: 896px; margin: 0 auto; } + .demo-components__toc { + width: 190px; + margin: 12px 2px 12px 12px; + } + .demo-components__toc > .thaw-anchor { + position: sticky; + top: 36px; + } .demo-md-table-box { overflow: auto; } @media screen and (max-width: 1200px) { + .demo-components__toc, .demo-guide__sider { display: none; } @@ -55,7 +64,7 @@ pub fn GuidePage() -> impl IntoView { - + diff --git a/demo_markdown/docs/anchor/mod.md b/demo_markdown/docs/anchor/mod.md new file mode 100644 index 0000000..91ec112 --- /dev/null +++ b/demo_markdown/docs/anchor/mod.md @@ -0,0 +1,43 @@ +# Anchor + +```rust demo +view! { + + + + + + + + + + +} +``` + +```rust demo +view! { + + + + + +} +``` + +### Anchor Props + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| class | `OptionalProp>` | `Default::default()` | Additional classes for the anchor element. | +| offset_target | `Option` | `None` | The element or selector used to calc offset of link elements. If you are not scrolling the entire document but only a part of it, you may need to set this. | +| children | `Children` | | Anchor's children. | + +### AnchorLink Props + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| class | `OptionalProp>` | `Default::default()` | Additional classes for the anchor link element. | +| title | `MaybeSignal` | | The content of link. | +| href | `String` | | The target of link. | +| children | `Option` | `None` | AnchorLink's children. | diff --git a/demo_markdown/src/lib.rs b/demo_markdown/src/lib.rs index 700eabd..4794c2c 100644 --- a/demo_markdown/src/lib.rs +++ b/demo_markdown/src/lib.rs @@ -29,6 +29,7 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt "TabbarMdPage" => "../docs/_mobile/tabbar/mod.md", "ToastMdPage" => "../docs/_mobile/toast/mod.md", "AlertMdPage" => "../docs/alert/mod.md", + "AnchorMdPage" => "../docs/anchor/mod.md", "AutoCompleteMdPage" => "../docs/auto_complete/mod.md", "AvatarMdPage" => "../docs/avatar/mod.md", "BackTopMdPage" => "../docs/back_top/mod.md", @@ -77,13 +78,27 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt for (fn_name, file_str) in file_list { let fn_name = Ident::new(fn_name, Span::call_site()); - let (body, demos) = match parse_markdown(file_str) { + let (body, demos, toc) = match parse_markdown(file_str) { Ok(body) => body, Err(err) => { return quote!(compile_error!(#err)).into(); } }; + let toc = { + let links = toc + .into_iter() + .map(|h| format!(r##""##, h.0, h.1)) + .collect::>() + .join(" "); + let toc = format!( + r##"#[component] fn Toc() -> impl IntoView {{ view! {{ {} }} }}"##, + links + ); + syn::parse_str::(&toc) + .expect(&format!("Cannot be resolved as a function: \n {toc}")) + }; + let demos: Vec = demos .into_iter() .enumerate() @@ -105,10 +120,15 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt pub fn #fn_name() -> impl IntoView { #(#demos)* + #toc + view! {
#body
+
+ +
} } }); diff --git a/demo_markdown/src/markdown/mod.rs b/demo_markdown/src/markdown/mod.rs index 0379fc4..95f8d0d 100644 --- a/demo_markdown/src/markdown/mod.rs +++ b/demo_markdown/src/markdown/mod.rs @@ -1,29 +1,35 @@ mod code_block; use comrak::{ - nodes::{AstNode, NodeValue}, + nodes::{AstNode, LineColumn, NodeValue}, parse_document, Arena, }; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; use syn::ItemMacro; -pub fn parse_markdown(md_text: &str) -> Result<(TokenStream, Vec), String> { +pub fn parse_markdown(md_text: &str) -> Result<(TokenStream, Vec, Vec<(String, String)>), String> { let mut demos: Vec = vec![]; + let mut toc: Vec<(String, String)> = vec![]; let arena = Arena::new(); let mut options = comrak::Options::default(); options.extension.table = true; let root = parse_document(&arena, &md_text, &options); - let body = iter_nodes(root, &mut demos); - Ok((body, demos)) + let body = iter_nodes(md_text, root, &mut demos, &mut toc); + Ok((body, demos, toc)) } -fn iter_nodes<'a>(node: &'a AstNode<'a>, demos: &mut Vec) -> TokenStream { +fn iter_nodes<'a>( + md_text: &str, + node: &'a AstNode<'a>, + demos: &mut Vec, + toc: &mut Vec<(String, String)>, +) -> TokenStream { let mut children = vec![]; for c in node.children() { - children.push(iter_nodes(c, demos)); + children.push(iter_nodes(md_text, c, demos, toc)); } match &node.data.borrow().value { NodeValue::Document => quote!(#(#children)*), @@ -55,9 +61,15 @@ fn iter_nodes<'a>(node: &'a AstNode<'a>, demos: &mut Vec) -> TokenStream

), NodeValue::Heading(node_h) => { + let sourcepos = node.data.borrow().sourcepos; + let text = range_text(md_text, sourcepos.start.clone(), sourcepos.end.clone()); + let level = node_h.level as usize + 1; + let text = text[level..].to_string(); + let h_id = format!("{}", text.replace(' ', "-").to_ascii_lowercase()); + toc.push((h_id.clone(), text)); let h = Ident::new(&format!("h{}", node_h.level), Span::call_site()); quote!( - <#h> + <#h id=#h_id> #(#children)* ) @@ -148,3 +160,40 @@ fn iter_nodes<'a>(node: &'a AstNode<'a>, demos: &mut Vec) -> TokenStream NodeValue::MultilineBlockQuote(_) => quote!("FootnoteReference todo!!!"), } } + +fn range_text(text: &str, start: LineColumn, end: LineColumn) -> &str { + let LineColumn { + line: start_line, + column: start_col, + } = start; + let LineColumn { + line: end_line, + column: end_col, + } = end; + + let mut lines = text.lines(); + + let mut current_line_num = 1; + let mut start_line_text = lines.next().unwrap_or(""); + while current_line_num < start_line { + start_line_text = lines.next().unwrap_or(""); + current_line_num += 1; + } + + let start_index = start_col - 1; + let mut start_line_text = &start_line_text[start_index..]; + + let mut current_line_num = start_line + 1; + while current_line_num < end_line { + let next_line = lines.next().unwrap_or(""); + start_line_text = &next_line; + current_line_num += 1; + } + + let end_index = end_col; + if current_line_num == end_line { + start_line_text = &start_line_text[..end_index]; + } + + start_line_text +} diff --git a/thaw/src/anchor/anchor.css b/thaw/src/anchor/anchor.css new file mode 100644 index 0000000..f6af1a1 --- /dev/null +++ b/thaw/src/anchor/anchor.css @@ -0,0 +1,77 @@ +.thaw-anchor { + position: relative; + padding-left: 4px; +} + +.thaw-anchor .thaw-anchor-link + .thaw-anchor-link, +.thaw-anchor .thaw-anchor-link > .thaw-anchor-link { + margin-top: 0.5em; +} + +.thaw-anchor-background { + max-width: 0; + position: absolute; + left: 2px; + width: 100%; + background-color: var(--thaw-link-background-color); + transition: top 0.15s cubic-bezier(0.4, 0, 0.2, 1), + max-width 0.15s cubic-bezier(0.4, 0, 0.2, 1), + background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.thaw-anchor-rail { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + border-radius: 2px; + overflow: hidden; + transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1); + background-color: var(--thaw-rail-background-color); +} + +.thaw-anchor-rail__bar { + position: absolute; + left: 0; + width: 4px; + height: 21px; + transition: top 0.15s cubic-bezier(0.4, 0, 0.2, 1), + background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.thaw-anchor-rail__bar.thaw-anchor-rail__bar--active { + background-color: var(--thaw-title-color-active); +} + +.thaw-anchor-link { + padding: 0 0 0 16px; + position: relative; + line-height: 1.5; + font-size: 13px; + min-height: 1.5em; + display: flex; + flex-direction: column; +} + +.thaw-anchor-link.thaw-anchor-link--active > .thaw-anchor-link__title { + color: var(--thaw-title-color-active); +} + +.thaw-anchor-link__title { + outline: none; + max-width: 100%; + text-decoration: none; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + cursor: pointer; + display: inline-block; + padding-right: 16px; + color: inherit; + transition: color 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.thaw-anchor-link__title:hover { + color: var(--thaw-title-color-hover); +} diff --git a/thaw/src/anchor/anchor_link.rs b/thaw/src/anchor/anchor_link.rs new file mode 100644 index 0000000..f281c12 --- /dev/null +++ b/thaw/src/anchor/anchor_link.rs @@ -0,0 +1,83 @@ +use crate::use_anchor; +use leptos::*; +use thaw_components::OptionComp; +use thaw_utils::{class_list, OptionalProp, StoredMaybeSignal}; + +#[component] +pub fn AnchorLink( + #[prop(optional, into)] class: OptionalProp>, + #[prop(into)] title: MaybeSignal, + #[prop(into)] href: String, + #[prop(optional)] children: Option, +) -> impl IntoView { + let anchor = use_anchor(); + + let title: StoredMaybeSignal<_> = title.into(); + let title_ref = NodeRef::::new(); + let href_id = StoredValue::new(None::); + let is_active = Memo::new(move |_| { + href_id.with_value(|href_id| { + if href_id.is_none() { + false + } else { + anchor.active_id.with(|active_id| active_id == href_id) + } + }) + }); + + if !href.is_empty() { + if href.starts_with('#') { + let id = href[1..].to_string(); + href_id.set_value(Some(id.clone())); + anchor.append_id(id); + + on_cleanup(move || { + href_id.with_value(|id| { + if let Some(id) = id { + anchor.remove_id(id); + } + }); + }); + + title_ref.on_load(move |title_el| { + let _ = watch( + move || is_active.get(), + move |is_active, _, _| { + if *is_active { + let title_rect = title_el.get_bounding_client_rect(); + anchor.update_background_position(title_rect); + } + }, + true, + ); + }); + } + } + let on_click = move |_| { + href_id.with_value(move |href_id| { + if let Some(href_id) = href_id { + anchor.scroll_into_view(href_id); + } + }); + }; + + view! { +
+ + {move || title.get()} + + + {children()} + +
+ } +} diff --git a/thaw/src/anchor/mod.rs b/thaw/src/anchor/mod.rs new file mode 100644 index 0000000..cda81cc --- /dev/null +++ b/thaw/src/anchor/mod.rs @@ -0,0 +1,267 @@ +mod anchor_link; +mod theme; + +pub use anchor_link::AnchorLink; +pub use theme::AnchorTheme; + +use crate::{use_theme, Theme}; +use leptos::*; +use std::cmp::Ordering; +use thaw_utils::{add_event_listener_with_bool, class_list, mount_style, throttle, OptionalProp}; +use web_sys::{DomRect, Element}; + +#[component] +pub fn Anchor( + #[prop(optional, into)] class: OptionalProp>, + #[prop(into, optional)] offset_target: Option, + children: Children, +) -> impl IntoView { + mount_style("anchor", include_str!("./anchor.css")); + let theme = use_theme(Theme::light); + let css_vars = Memo::new(move |_| { + let mut css_vars = String::new(); + theme.with(|theme| { + css_vars.push_str(&format!( + "--thaw-rail-background-color: {};", + theme.anchor.rail_background_color + )); + css_vars.push_str(&format!( + "--thaw-title-color-hover: {};", + theme.common.color_primary_hover + )); + css_vars.push_str(&format!( + "--thaw-title-color-active: {};", + theme.common.color_primary + )); + css_vars.push_str(&format!( + "--thaw-link-background-color: {}1a;", + theme.common.color_primary + )); + }); + css_vars + }); + let anchor_ref = NodeRef::new(); + let background_ref = NodeRef::::new(); + let bar_ref = NodeRef::new(); + let element_ids = RwSignal::new(Vec::::new()); + let active_id = RwSignal::new(None::); + + let _ = watch( + move || active_id.get(), + move |id, _, _| { + if id.is_none() { + if let Some(background_el) = background_ref.get_untracked() { + let _ = background_el.style("max-width", "0"); + } + } + }, + false, + ); + let on_scroll = move || { + element_ids.with(|ids| { + let offset_target_top = if let Some(offset_target) = offset_target.as_ref() { + if let Some(rect) = offset_target.get_bounding_client_rect() { + rect.top() + } else { + return; + } + } else { + 0.0 + }; + + let mut links: Vec = vec![]; + for id in ids.iter() { + if let Some(link_el) = document().get_element_by_id(id) { + let link_rect = link_el.get_bounding_client_rect(); + links.push(LinkInfo { + top: link_rect.top() - offset_target_top, + id: id.clone(), + }); + } + } + links.sort_by(|a, b| { + if a.top > b.top { + Ordering::Greater + } else { + Ordering::Less + } + }); + + let mut temp_link = None::; + for link in links.into_iter() { + if link.top >= 0.0 { + if link.top <= 12.0 { + temp_link = Some(link); + break; + } else if temp_link.is_some() { + break; + } else { + temp_link = None; + } + } else { + temp_link = Some(link); + } + } + active_id.set(temp_link.map(|link| link.id)); + }); + }; + let cb = throttle( + move || { + on_scroll(); + }, + std::time::Duration::from_millis(200), + ); + let scroll_handle = add_event_listener_with_bool( + document(), + ev::scroll, + move |_| { + cb(); + }, + true, + ); + on_cleanup(move || { + scroll_handle.remove(); + }); + view! { +
+
+
+
+
+ {children()} +
+ } +} + +#[derive(Clone)] +pub(crate) struct AnchorInjection { + anchor_ref: NodeRef, + background_ref: NodeRef, + bar_ref: NodeRef, + element_ids: RwSignal>, + pub active_id: RwSignal>, +} + +impl Copy for AnchorInjection {} + +impl AnchorInjection { + fn new( + anchor_ref: NodeRef, + background_ref: NodeRef, + bar_ref: NodeRef, + element_ids: RwSignal>, + active_id: RwSignal>, + ) -> Self { + Self { + anchor_ref, + background_ref, + bar_ref, + element_ids, + active_id, + } + } + + pub fn scroll_into_view(&self, id: &String) { + let Some(link_el) = document().get_element_by_id(id) else { + return; + }; + link_el.scroll_into_view(); + } + + pub fn append_id(&self, id: String) { + self.element_ids.update(|ids| { + ids.push(id); + }); + } + + pub fn remove_id(&self, id: &String) { + self.element_ids.update(|ids| { + if let Some(index) = ids.iter().position(|item_id| item_id == id) { + ids.remove(index); + } + }); + } + + pub fn update_background_position(&self, title_rect: DomRect) { + if let Some(anchor_el) = self.anchor_ref.get_untracked() { + let background_el = self.background_ref.get_untracked().unwrap(); + let bar_el = self.bar_ref.get_untracked().unwrap(); + let anchor_rect = anchor_el.get_bounding_client_rect(); + + let offset_top = title_rect.top() - anchor_rect.top(); + let offset_left = title_rect.left() - anchor_rect.left(); + let _ = background_el + .style("top", format!("{}px", offset_top)) + .style("height", format!("{}px", title_rect.height())) + .style( + "max-width", + format!("{}px", title_rect.width() + offset_left), + ); + let _ = bar_el + .style("top", format!("{}px", offset_top)) + .style("height", format!("{}px", title_rect.height())); + } + } +} + +pub(crate) fn use_anchor() -> AnchorInjection { + expect_context() +} + +struct LinkInfo { + top: f64, + id: String, +} + +pub enum OffsetTarget { + Selector(String), + Element(Element), +} + +impl OffsetTarget { + fn get_bounding_client_rect(&self) -> Option { + match self { + OffsetTarget::Selector(selector) => { + let el = document().query_selector(selector).ok().flatten()?; + Some(el.get_bounding_client_rect()) + } + OffsetTarget::Element(el) => Some(el.get_bounding_client_rect()), + } + } +} + +impl From<&'static str> for OffsetTarget { + fn from(value: &'static str) -> Self { + Self::Selector(value.to_string()) + } +} + +impl From for OffsetTarget { + fn from(value: String) -> Self { + Self::Selector(value) + } +} + +impl From for OffsetTarget { + fn from(value: Element) -> Self { + Self::Element(value) + } +} diff --git a/thaw/src/anchor/theme.rs b/thaw/src/anchor/theme.rs new file mode 100644 index 0000000..8c3e082 --- /dev/null +++ b/thaw/src/anchor/theme.rs @@ -0,0 +1,20 @@ +use crate::theme::ThemeMethod; + +#[derive(Clone)] +pub struct AnchorTheme { + pub rail_background_color: String, +} + +impl ThemeMethod for AnchorTheme { + fn light() -> Self { + Self { + rail_background_color: "#dbdbdf".into(), + } + } + + fn dark() -> Self { + Self { + rail_background_color: "#ffffff33".into(), + } + } +} diff --git a/thaw/src/lib.rs b/thaw/src/lib.rs index cd06dd6..396ce71 100644 --- a/thaw/src/lib.rs +++ b/thaw/src/lib.rs @@ -1,4 +1,5 @@ mod alert; +mod anchor; mod auto_complete; mod avatar; mod back_top; @@ -45,6 +46,7 @@ mod typography; mod upload; pub use alert::*; +pub use anchor::*; pub use auto_complete::*; pub use avatar::*; pub use back_top::*; diff --git a/thaw/src/theme/mod.rs b/thaw/src/theme/mod.rs index 602d857..2f20a9a 100644 --- a/thaw/src/theme/mod.rs +++ b/thaw/src/theme/mod.rs @@ -3,11 +3,11 @@ mod common; use self::common::CommonTheme; use crate::{ mobile::{NavBarTheme, TabbarTheme}, - AlertTheme, AutoCompleteTheme, AvatarTheme, BackTopTheme, BreadcrumbTheme, ButtonTheme, - CalendarTheme, CollapseTheme, ColorPickerTheme, DatePickerTheme, InputTheme, MenuTheme, - MessageTheme, PopoverTheme, ProgressTheme, ScrollbarTheme, SelectTheme, SkeletionTheme, - SliderTheme, SpinnerTheme, SwitchTheme, TableTheme, TagTheme, TimePickerTheme, TypographyTheme, - UploadTheme, + AlertTheme, AnchorTheme, AutoCompleteTheme, AvatarTheme, BackTopTheme, BreadcrumbTheme, + ButtonTheme, CalendarTheme, CollapseTheme, ColorPickerTheme, DatePickerTheme, InputTheme, + MenuTheme, MessageTheme, PopoverTheme, ProgressTheme, ScrollbarTheme, SelectTheme, + SkeletionTheme, SliderTheme, SpinnerTheme, SwitchTheme, TableTheme, TagTheme, TimePickerTheme, + TypographyTheme, UploadTheme, }; use leptos::*; @@ -48,6 +48,7 @@ pub struct Theme { pub collapse: CollapseTheme, pub scrollbar: ScrollbarTheme, pub back_top: BackTopTheme, + pub anchor: AnchorTheme, } impl Theme { @@ -83,6 +84,7 @@ impl Theme { collapse: CollapseTheme::light(), scrollbar: ScrollbarTheme::light(), back_top: BackTopTheme::light(), + anchor: AnchorTheme::light(), } } pub fn dark() -> Self { @@ -117,6 +119,7 @@ impl Theme { collapse: CollapseTheme::dark(), scrollbar: ScrollbarTheme::dark(), back_top: BackTopTheme::dark(), + anchor: AnchorTheme::dark(), } } } diff --git a/thaw_utils/src/event_listener.rs b/thaw_utils/src/event_listener.rs index 8532e46..965c58c 100644 --- a/thaw_utils/src/event_listener.rs +++ b/thaw_utils/src/event_listener.rs @@ -1,5 +1,7 @@ use ::wasm_bindgen::{prelude::Closure, JsCast}; use leptos::{html::AnyElement, *}; +use std::ops::Deref; +use web_sys::EventTarget; pub fn add_event_listener( target: HtmlElement, @@ -48,3 +50,73 @@ fn add_event_listener_untyped( wel(target, Box::new(cb), event_name) } + +pub fn add_event_listener_with_bool( + target: impl IntoEventTarget, + event: E, + cb: impl Fn(E::EventType) + 'static, + use_capture: bool, +) -> EventListenerHandle +where + E::EventType: JsCast, +{ + add_event_listener_untyped_with_bool( + target.into_event_target(), + &event.name(), + move |e| cb(e.unchecked_into::()), + use_capture, + ) +} + +fn add_event_listener_untyped_with_bool( + target: EventTarget, + event_name: &str, + cb: impl Fn(web_sys::Event) + 'static, + use_capture: bool, +) -> EventListenerHandle { + fn wel( + target: EventTarget, + cb: Box, + event_name: &str, + use_capture: bool, + ) -> EventListenerHandle { + let cb = Closure::wrap(cb).into_js_value(); + _ = target.add_event_listener_with_callback_and_bool( + event_name, + cb.unchecked_ref(), + use_capture, + ); + let event_name = event_name.to_string(); + EventListenerHandle(Box::new(move || { + _ = target.remove_event_listener_with_callback_and_bool( + &event_name, + cb.unchecked_ref(), + use_capture, + ); + })) + } + + wel(target, Box::new(cb), event_name, use_capture) +} + +pub trait IntoEventTarget { + fn into_event_target(self) -> EventTarget; +} + +impl IntoEventTarget for EventTarget { + fn into_event_target(self) -> EventTarget { + self + } +} + +impl IntoEventTarget for web_sys::Document { + fn into_event_target(self) -> EventTarget { + self.into() + } +} + +impl IntoEventTarget for HtmlElement { + fn into_event_target(self) -> EventTarget { + self.deref().deref().deref().deref().clone() + } +} diff --git a/thaw_utils/src/lib.rs b/thaw_utils/src/lib.rs index 80c8d29..2b6d071 100644 --- a/thaw_utils/src/lib.rs +++ b/thaw_utils/src/lib.rs @@ -4,15 +4,19 @@ mod event_listener; mod hooks; mod optional_prop; mod signals; +mod throttle; mod time; pub use dom::{get_scroll_parent, mount_style}; -pub use event_listener::{add_event_listener, EventListenerHandle}; +pub use event_listener::{ + add_event_listener, add_event_listener_with_bool, EventListenerHandle, IntoEventTarget, +}; pub use hooks::{use_click_position, use_lock_html_scroll, use_next_frame, NextFrame}; pub use optional_prop::OptionalProp; pub use signals::{ create_component_ref, ComponentRef, Model, OptionalMaybeSignal, SignalWatch, StoredMaybeSignal, }; +pub use throttle::throttle; pub use time::now_date; pub fn with_hydration_off(f: impl FnOnce() -> T) -> T { diff --git a/thaw_utils/src/throttle.rs b/thaw_utils/src/throttle.rs new file mode 100644 index 0000000..0c970ae --- /dev/null +++ b/thaw_utils/src/throttle.rs @@ -0,0 +1,31 @@ +use leptos::{leptos_dom::helpers::TimeoutHandle, *}; +use std::time::Duration; + +pub fn throttle(cb: impl Fn() + 'static, duration: Duration) -> impl Fn() -> () { + let cb = Callback::new(move |_| cb()); + let timeout_handle = StoredValue::new(None::); + on_cleanup(move || { + timeout_handle.update_value(move |handle| { + if let Some(handle) = handle.take() { + handle.clear(); + } + }); + }); + + move || { + if timeout_handle.with_value(|handle| handle.is_some()) { + return; + } + let handle = set_timeout_with_handle( + move || { + cb.call(()); + timeout_handle.update_value(move |handle| { + *handle = None; + }); + }, + duration, + ) + .unwrap(); + timeout_handle.set_value(Some(handle)); + } +}