Skip to content

Commit f75b38f

Browse files
authored
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
1 parent 1b0f664 commit f75b38f

File tree

15 files changed

+710
-16
lines changed

15 files changed

+710
-16
lines changed

demo/src/app.rs

+1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ fn TheRouter(is_routing: RwSignal<bool>) -> impl IntoView {
4747
<Route path="/nav-bar" view=NavBarPage/>
4848
<Route path="/toast" view=ToastPage/>
4949
<Route path="/alert" view=AlertMdPage/>
50+
<Route path="/anchor" view=AnchorMdPage/>
5051
<Route path="/auto-complete" view=AutoCompleteMdPage/>
5152
<Route path="/avatar" view=AvatarMdPage/>
5253
<Route path="/back-top" view=BackTopMdPage/>

demo/src/pages/components.rs

+14-1
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,19 @@ pub fn ComponentsPage() -> impl IntoView {
3333
width: 896px;
3434
margin: 0 auto;
3535
}
36+
.demo-components__toc {
37+
width: 190px;
38+
margin: 12px 2px 12px 12px;
39+
}
40+
.demo-components__toc > .thaw-anchor {
41+
position: sticky;
42+
top: 36px;
43+
}
3644
.demo-md-table-box {
3745
overflow: auto;
3846
}
3947
@media screen and (max-width: 1200px) {
48+
.demo-components__toc,
4049
.demo-components__sider {
4150
display: none;
4251
}
@@ -56,7 +65,7 @@ pub fn ComponentsPage() -> impl IntoView {
5665

5766
</Menu>
5867
</LayoutSider>
59-
<Layout content_style="padding: 8px 12px 28px;">
68+
<Layout content_style="padding: 8px 12px 28px; display: flex;" class="doc-content">
6069
<Outlet/>
6170
</Layout>
6271
</Layout>
@@ -208,6 +217,10 @@ pub(crate) fn gen_menu_data() -> Vec<MenuGroupOption> {
208217
MenuGroupOption {
209218
label: "Navigation Components".into(),
210219
children: vec![
220+
MenuItemOption {
221+
value: "anchor".into(),
222+
label: "Anchor".into(),
223+
},
211224
MenuItemOption {
212225
value: "back-top".into(),
213226
label: "Back Top".into(),

demo/src/pages/guide.rs

+10-1
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,19 @@ pub fn GuidePage() -> impl IntoView {
3232
width: 896px;
3333
margin: 0 auto;
3434
}
35+
.demo-components__toc {
36+
width: 190px;
37+
margin: 12px 2px 12px 12px;
38+
}
39+
.demo-components__toc > .thaw-anchor {
40+
position: sticky;
41+
top: 36px;
42+
}
3543
.demo-md-table-box {
3644
overflow: auto;
3745
}
3846
@media screen and (max-width: 1200px) {
47+
.demo-components__toc,
3948
.demo-guide__sider {
4049
display: none;
4150
}
@@ -55,7 +64,7 @@ pub fn GuidePage() -> impl IntoView {
5564

5665
</Menu>
5766
</LayoutSider>
58-
<Layout content_style="padding: 8px 12px 28px;">
67+
<Layout content_style="padding: 8px 12px 28px; display: flex;" class="doc-content">
5968
<Outlet/>
6069
</Layout>
6170
</Layout>

demo_markdown/docs/anchor/mod.md

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Anchor
2+
3+
```rust demo
4+
view! {
5+
<Anchor>
6+
<AnchorLink title="Web API" href="#web">
7+
<AnchorLink title="DOM" href="#dom"/>
8+
<AnchorLink title="SVG" href="#svg"/>
9+
<AnchorLink title="File API" href="#file"/>
10+
</AnchorLink>
11+
<AnchorLink title="Rust" href="#rust"/>
12+
<AnchorLink title="Anchor Props" href="#anchor-props"/>
13+
<AnchorLink title="AnchorLink Props" href="#anchorlink-props"/>
14+
</Anchor>
15+
}
16+
```
17+
18+
```rust demo
19+
view! {
20+
<Anchor>
21+
<AnchorLink title="Anchor" href="#anchor"/>
22+
<AnchorLink title="Anchor Props" href="#anchor-props"/>
23+
<AnchorLink title="AnchorLink Props" href="#anchorlink-props"/>
24+
</Anchor>
25+
}
26+
```
27+
28+
### Anchor Props
29+
30+
| Name | Type | Default | Description |
31+
| --- | --- | --- | --- |
32+
| class | `OptionalProp<MaybeSignal<String>>` | `Default::default()` | Additional classes for the anchor element. |
33+
| offset_target | `Option<OffsetTarget>` | `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. |
34+
| children | `Children` | | Anchor's children. |
35+
36+
### AnchorLink Props
37+
38+
| Name | Type | Default | Description |
39+
| --- | --- | --- | --- |
40+
| class | `OptionalProp<MaybeSignal<String>>` | `Default::default()` | Additional classes for the anchor link element. |
41+
| title | `MaybeSignal<String>` | | The content of link. |
42+
| href | `String` | | The target of link. |
43+
| children | `Option<Children>` | `None` | AnchorLink's children. |

demo_markdown/src/lib.rs

+21-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt
2929
"TabbarMdPage" => "../docs/_mobile/tabbar/mod.md",
3030
"ToastMdPage" => "../docs/_mobile/toast/mod.md",
3131
"AlertMdPage" => "../docs/alert/mod.md",
32+
"AnchorMdPage" => "../docs/anchor/mod.md",
3233
"AutoCompleteMdPage" => "../docs/auto_complete/mod.md",
3334
"AvatarMdPage" => "../docs/avatar/mod.md",
3435
"BackTopMdPage" => "../docs/back_top/mod.md",
@@ -77,13 +78,27 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt
7778
for (fn_name, file_str) in file_list {
7879
let fn_name = Ident::new(fn_name, Span::call_site());
7980

80-
let (body, demos) = match parse_markdown(file_str) {
81+
let (body, demos, toc) = match parse_markdown(file_str) {
8182
Ok(body) => body,
8283
Err(err) => {
8384
return quote!(compile_error!(#err)).into();
8485
}
8586
};
8687

88+
let toc = {
89+
let links = toc
90+
.into_iter()
91+
.map(|h| format!(r##"<AnchorLink href="#{}" title="{}" />"##, h.0, h.1))
92+
.collect::<Vec<_>>()
93+
.join(" ");
94+
let toc = format!(
95+
r##"#[component] fn Toc() -> impl IntoView {{ view! {{ <Anchor offset_target=".doc-content">{}</Anchor> }} }}"##,
96+
links
97+
);
98+
syn::parse_str::<ItemFn>(&toc)
99+
.expect(&format!("Cannot be resolved as a function: \n {toc}"))
100+
};
101+
87102
let demos: Vec<ItemFn> = demos
88103
.into_iter()
89104
.enumerate()
@@ -105,10 +120,15 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt
105120
pub fn #fn_name() -> impl IntoView {
106121
#(#demos)*
107122

123+
#toc
124+
108125
view! {
109126
<div class="demo-components__component">
110127
#body
111128
</div>
129+
<div class="demo-components__toc">
130+
<Toc />
131+
</div>
112132
}
113133
}
114134
});

demo_markdown/src/markdown/mod.rs

+56-7
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,35 @@
11
mod code_block;
22

33
use comrak::{
4-
nodes::{AstNode, NodeValue},
4+
nodes::{AstNode, LineColumn, NodeValue},
55
parse_document, Arena,
66
};
77
use proc_macro2::{Ident, Span, TokenStream};
88
use quote::quote;
99
use syn::ItemMacro;
1010

11-
pub fn parse_markdown(md_text: &str) -> Result<(TokenStream, Vec<String>), String> {
11+
pub fn parse_markdown(md_text: &str) -> Result<(TokenStream, Vec<String>, Vec<(String, String)>), String> {
1212
let mut demos: Vec<String> = vec![];
13+
let mut toc: Vec<(String, String)> = vec![];
1314

1415
let arena = Arena::new();
1516
let mut options = comrak::Options::default();
1617
options.extension.table = true;
1718

1819
let root = parse_document(&arena, &md_text, &options);
19-
let body = iter_nodes(root, &mut demos);
20-
Ok((body, demos))
20+
let body = iter_nodes(md_text, root, &mut demos, &mut toc);
21+
Ok((body, demos, toc))
2122
}
2223

23-
fn iter_nodes<'a>(node: &'a AstNode<'a>, demos: &mut Vec<String>) -> TokenStream {
24+
fn iter_nodes<'a>(
25+
md_text: &str,
26+
node: &'a AstNode<'a>,
27+
demos: &mut Vec<String>,
28+
toc: &mut Vec<(String, String)>,
29+
) -> TokenStream {
2430
let mut children = vec![];
2531
for c in node.children() {
26-
children.push(iter_nodes(c, demos));
32+
children.push(iter_nodes(md_text, c, demos, toc));
2733
}
2834
match &node.data.borrow().value {
2935
NodeValue::Document => quote!(#(#children)*),
@@ -55,9 +61,15 @@ fn iter_nodes<'a>(node: &'a AstNode<'a>, demos: &mut Vec<String>) -> TokenStream
5561
</p >
5662
),
5763
NodeValue::Heading(node_h) => {
64+
let sourcepos = node.data.borrow().sourcepos;
65+
let text = range_text(md_text, sourcepos.start.clone(), sourcepos.end.clone());
66+
let level = node_h.level as usize + 1;
67+
let text = text[level..].to_string();
68+
let h_id = format!("{}", text.replace(' ', "-").to_ascii_lowercase());
69+
toc.push((h_id.clone(), text));
5870
let h = Ident::new(&format!("h{}", node_h.level), Span::call_site());
5971
quote!(
60-
<#h>
72+
<#h id=#h_id>
6173
#(#children)*
6274
</#h>
6375
)
@@ -148,3 +160,40 @@ fn iter_nodes<'a>(node: &'a AstNode<'a>, demos: &mut Vec<String>) -> TokenStream
148160
NodeValue::MultilineBlockQuote(_) => quote!("FootnoteReference todo!!!"),
149161
}
150162
}
163+
164+
fn range_text(text: &str, start: LineColumn, end: LineColumn) -> &str {
165+
let LineColumn {
166+
line: start_line,
167+
column: start_col,
168+
} = start;
169+
let LineColumn {
170+
line: end_line,
171+
column: end_col,
172+
} = end;
173+
174+
let mut lines = text.lines();
175+
176+
let mut current_line_num = 1;
177+
let mut start_line_text = lines.next().unwrap_or("");
178+
while current_line_num < start_line {
179+
start_line_text = lines.next().unwrap_or("");
180+
current_line_num += 1;
181+
}
182+
183+
let start_index = start_col - 1;
184+
let mut start_line_text = &start_line_text[start_index..];
185+
186+
let mut current_line_num = start_line + 1;
187+
while current_line_num < end_line {
188+
let next_line = lines.next().unwrap_or("");
189+
start_line_text = &next_line;
190+
current_line_num += 1;
191+
}
192+
193+
let end_index = end_col;
194+
if current_line_num == end_line {
195+
start_line_text = &start_line_text[..end_index];
196+
}
197+
198+
start_line_text
199+
}

thaw/src/anchor/anchor.css

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
.thaw-anchor {
2+
position: relative;
3+
padding-left: 4px;
4+
}
5+
6+
.thaw-anchor .thaw-anchor-link + .thaw-anchor-link,
7+
.thaw-anchor .thaw-anchor-link > .thaw-anchor-link {
8+
margin-top: 0.5em;
9+
}
10+
11+
.thaw-anchor-background {
12+
max-width: 0;
13+
position: absolute;
14+
left: 2px;
15+
width: 100%;
16+
background-color: var(--thaw-link-background-color);
17+
transition: top 0.15s cubic-bezier(0.4, 0, 0.2, 1),
18+
max-width 0.15s cubic-bezier(0.4, 0, 0.2, 1),
19+
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
20+
}
21+
22+
.thaw-anchor-rail {
23+
position: absolute;
24+
left: 0;
25+
top: 0;
26+
bottom: 0;
27+
width: 4px;
28+
border-radius: 2px;
29+
overflow: hidden;
30+
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
31+
background-color: var(--thaw-rail-background-color);
32+
}
33+
34+
.thaw-anchor-rail__bar {
35+
position: absolute;
36+
left: 0;
37+
width: 4px;
38+
height: 21px;
39+
transition: top 0.15s cubic-bezier(0.4, 0, 0.2, 1),
40+
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
41+
}
42+
43+
.thaw-anchor-rail__bar.thaw-anchor-rail__bar--active {
44+
background-color: var(--thaw-title-color-active);
45+
}
46+
47+
.thaw-anchor-link {
48+
padding: 0 0 0 16px;
49+
position: relative;
50+
line-height: 1.5;
51+
font-size: 13px;
52+
min-height: 1.5em;
53+
display: flex;
54+
flex-direction: column;
55+
}
56+
57+
.thaw-anchor-link.thaw-anchor-link--active > .thaw-anchor-link__title {
58+
color: var(--thaw-title-color-active);
59+
}
60+
61+
.thaw-anchor-link__title {
62+
outline: none;
63+
max-width: 100%;
64+
text-decoration: none;
65+
white-space: nowrap;
66+
text-overflow: ellipsis;
67+
overflow: hidden;
68+
cursor: pointer;
69+
display: inline-block;
70+
padding-right: 16px;
71+
color: inherit;
72+
transition: color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
73+
}
74+
75+
.thaw-anchor-link__title:hover {
76+
color: var(--thaw-title-color-hover);
77+
}

0 commit comments

Comments
 (0)