dioxus_mdx/components/
toc.rs1use dioxus::prelude::*;
9use dioxus_free_icons::{Icon, icons::ld_icons::LdList};
10
11#[derive(Props, Clone, PartialEq)]
13pub struct DocTableOfContentsProps {
14 pub headers: Vec<(String, String, u8)>,
16}
17
18#[component]
23pub fn DocTableOfContents(props: DocTableOfContentsProps) -> Element {
24 #[allow(unused_variables)]
26 let header_ids: Vec<String> = props.headers.iter().map(|(id, _, _)| id.clone()).collect();
27
28 #[cfg(target_arch = "wasm32")]
30 {
31 let header_ids_for_effect = header_ids.clone();
32 use_effect(move || {
33 let ids = header_ids_for_effect.clone();
34 if ids.is_empty() {
35 return;
36 }
37
38 let js = format!(
41 r#"
42 (function() {{
43 const ids = {};
44
45 // Update active TOC item
46 function setActiveTocItem(activeId) {{
47 // Remove active class from all TOC links
48 document.querySelectorAll('[data-toc-link]').forEach(link => {{
49 link.classList.remove('toc-active');
50 link.classList.add('toc-inactive');
51 }});
52
53 // Add active class to the current link
54 if (activeId) {{
55 const activeLink = document.querySelector(`[data-toc-link="${{activeId}}"]`);
56 if (activeLink) {{
57 activeLink.classList.remove('toc-inactive');
58 activeLink.classList.add('toc-active');
59 }}
60 }}
61 }}
62
63 // Find the currently active heading based on scroll position
64 function updateActiveHeading() {{
65 let activeId = null;
66 const scrollPos = window.scrollY + 100; // Offset for fixed header
67
68 for (const id of ids) {{
69 const el = document.getElementById(id);
70 if (el) {{
71 const rect = el.getBoundingClientRect();
72 const absoluteTop = rect.top + window.scrollY;
73 if (absoluteTop <= scrollPos) {{
74 activeId = id;
75 }}
76 }}
77 }}
78
79 setActiveTocItem(activeId);
80 }}
81
82 // Debounce scroll handler
83 let scrollTimeout;
84 function handleScroll() {{
85 clearTimeout(scrollTimeout);
86 scrollTimeout = setTimeout(updateActiveHeading, 10);
87 }}
88
89 // Set up scroll listener
90 window.addEventListener('scroll', handleScroll, {{ passive: true }});
91
92 // Initial update
93 setTimeout(updateActiveHeading, 100);
94
95 // Store cleanup function
96 window.tocCleanup = () => {{
97 window.removeEventListener('scroll', handleScroll);
98 }};
99 }})();
100 "#,
101 serde_json::to_string(&ids).unwrap_or_default()
102 );
103
104 spawn(async move {
106 let _ = document::eval(&js);
107 });
108 });
109 }
110
111 if props.headers.is_empty() {
112 return rsx! {};
113 }
114
115 rsx! {
116 nav { class: "text-sm",
117 h4 { class: "font-semibold text-base-content mb-4 text-xs uppercase tracking-wider flex items-center gap-1.5",
118 Icon { class: "size-3.5", icon: LdList }
119 "On this page"
120 }
121 ul { class: "space-y-2.5",
122 for (i, (id, title, level)) in props.headers.iter().enumerate() {
123 TocItem {
124 key: "{i}",
125 id: id.clone(),
126 title: title.clone(),
127 level: *level,
128 }
129 }
130 }
131 }
132 style {
134 r#"
135 .toc-active {{
136 color: oklch(var(--p)) !important;
137 font-weight: 500;
138 }}
139 .toc-active::before {{
140 content: '';
141 position: absolute;
142 left: -14px;
143 top: 50%;
144 transform: translateY(-50%);
145 width: 3px;
146 height: 18px;
147 background: oklch(var(--p));
148 border-radius: 9999px;
149 transition: all 0.15s ease-out;
150 }}
151 .toc-inactive {{
152 color: oklch(var(--bc) / 0.55);
153 transition: color 0.15s ease-out;
154 }}
155 .toc-inactive:hover {{
156 color: oklch(var(--bc) / 0.9);
157 }}
158 "#
159 }
160 }
161}
162
163#[derive(Props, Clone, PartialEq)]
165struct TocItemProps {
166 id: String,
167 title: String,
168 level: u8,
169}
170
171#[component]
173fn TocItem(props: TocItemProps) -> Element {
174 let (indent_class, text_class) = match props.level {
175 2 => ("", ""),
176 3 => ("ml-4", "text-[13px]"),
177 _ => ("ml-6", "text-xs"),
178 };
179
180 rsx! {
181 li {
182 class: "{indent_class} relative",
183 a {
184 href: "#{props.id}",
185 class: "toc-inactive block py-0.5 {text_class}",
186 "data-toc-link": "{props.id}",
187 onclick: move |evt| {
188 evt.prevent_default();
189 #[cfg(target_arch = "wasm32")]
191 {
192 let id = props.id.clone();
193 spawn(async move {
194 let js = format!(
195 r#"
196 const el = document.getElementById({});
197 if (el) {{
198 el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
199 // Update URL hash without jumping
200 history.pushState(null, '', '#' + {});
201 }}
202 "#,
203 serde_json::to_string(&id).unwrap_or_default(),
204 serde_json::to_string(&id).unwrap_or_default()
205 );
206 let _ = document::eval(&js);
207 });
208 }
209 },
210 "{props.title}"
211 }
212 }
213 }
214}
215
216pub fn extract_headers(content: &str) -> Vec<(String, String, u8)> {
218 let mut headers = Vec::new();
219 let heading_re = regex::Regex::new(r"(?m)^(#{2,4})\s+(.+)$").unwrap();
220
221 for caps in heading_re.captures_iter(content) {
222 let level = caps[1].len() as u8;
223 let title = caps[2].trim().to_string();
224 let id = slugify(&title);
225 headers.push((id, title, level));
226 }
227
228 headers
229}
230
231pub fn slugify(text: &str) -> String {
233 text.to_lowercase()
234 .chars()
235 .filter_map(|c| {
236 if c.is_alphanumeric() {
237 Some(c)
238 } else if c.is_whitespace() || c == '-' || c == '_' || c == '.' {
239 Some('-')
240 } else {
241 None
242 }
243 })
244 .collect::<String>()
245 .split('-')
246 .filter(|s| !s.is_empty())
247 .collect::<Vec<_>>()
248 .join("-")
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn test_extract_headers() {
257 let content = r#"
258## Introduction
259
260Some text.
261
262### Getting Started
263
264More text.
265
266## Configuration
267
268### Advanced Options
269"#;
270
271 let headers = extract_headers(content);
272 assert_eq!(headers.len(), 4);
273 assert_eq!(
274 headers[0],
275 ("introduction".to_string(), "Introduction".to_string(), 2)
276 );
277 assert_eq!(
278 headers[1],
279 (
280 "getting-started".to_string(),
281 "Getting Started".to_string(),
282 3
283 )
284 );
285 assert_eq!(
286 headers[2],
287 ("configuration".to_string(), "Configuration".to_string(), 2)
288 );
289 assert_eq!(
290 headers[3],
291 (
292 "advanced-options".to_string(),
293 "Advanced Options".to_string(),
294 3
295 )
296 );
297 }
298
299 #[test]
300 fn test_slugify() {
301 assert_eq!(slugify("Hello World"), "hello-world");
302 assert_eq!(slugify("Getting Started!"), "getting-started");
303 assert_eq!(slugify("API v1.0"), "api-v1-0");
304 }
305}