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)*
#h>
)
@@ -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! {
+
+ }
+}
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! {
+
+ }
+}
+
+#[derive(Clone)]
+pub(crate) struct AnchorInjection {
+ anchor_ref: NodeRef,
+ background_ref: NodeRef,
+ bar_ref: NodeRef,
+ element_ids: RwSignal>,
+ pub active_id: RwSignal