Skip to main content

yggdrasil/components/post/
post_toc.rs

1//! 文章目录组件
2//!
3//! 双形态(CSS 媒体查询切换,断点 1200px):
4//! - <1200px(移动端/窄屏):正文顶部 `<details class="toc">` 折叠块,Alt+C 聚焦。
5//! - ≥1200px(桌面端):右缘悬浮侧边目录——收起为一列层级刻度,scroll-spy 高亮
6//!   当前阅读节(`__initTocSidebar`,IntersectionObserver 探测带),鼠标悬浮动画
7//!   展开为卡片面板,点 pin 按钮锁定展开。
8//!
9//! 两形态共用同一份服务端生成的 `toc_html`(嵌套 `<ul><li><a href="#id">`)。
10//! 锚点点击由 yggdrasil-core 的 `initAnchorClick` 全局 capture 拦截平滑滚动,
11//! 侧边目录链接自动受益。
12
13use dioxus::prelude::*;
14
15#[cfg(target_arch = "wasm32")]
16use crate::utils::js::invoke_optional_global;
17
18/// 文章目录(Table of Contents)组件。
19///
20/// Props:
21/// - `toc_html`:服务端生成的目录 HTML 字符串
22/// - `title`:目录标题,文章详情用默认的 "Table of Contents",
23///   更新日志页传入「版本索引」
24/// - `nav_id`、`content_id`、`scroll_id`:可选的图鉴局部作用域;
25///   正式文章使用默认的页面目录和窗口滚动。
26///
27/// 通过 `dangerous_inner_html` 注入目录结构,快捷键 `Alt + C` 可聚焦(移动端形态)。
28#[component]
29pub fn PostToc(
30    toc_html: String,
31    #[props(default = "Table of Contents")] title: &'static str,
32    #[props(default)] nav_id: Option<&'static str>,
33    #[props(default)] content_id: Option<&'static str>,
34    #[props(default)] scroll_id: Option<&'static str>,
35) -> Element {
36    // pin 状态:是否锁定展开(仅本次挂载有效,切文章 remount 后重置)。
37    let mut pinned = use_signal(|| nav_id.is_some());
38
39    // 挂载后初始化 scroll-spy(yggdrasil-core.js 由 Dioxus.toml 全局注入)。
40    // 调用方以 slug 为 key 的单元素列表包裹本组件,切文章时 remount → effect 重跑;
41    // __initTocSidebar 内部幂等(先 dispose 上一次的 observer 与激活态)。
42    #[cfg(target_arch = "wasm32")]
43    use_effect(move || {
44        let window =
45            web_sys::window().expect("post_toc use_effect 仅在 WASM 浏览器上下文执行:无 window");
46        if let (Some(nav_id), Some(content_id), Some(scroll_id)) = (nav_id, content_id, scroll_id) {
47            invoke_optional_global(
48                &window,
49                "__initTocSidebar",
50                &[
51                    format!("#{nav_id}").into(),
52                    format!("#{content_id}").into(),
53                    format!("#{scroll_id}").into(),
54                ],
55            );
56        } else {
57            invoke_optional_global(&window, "__initTocSidebar", &[]);
58        }
59    });
60
61    #[cfg(target_arch = "wasm32")]
62    use_drop(move || {
63        if let Some(window) = web_sys::window() {
64            if let Some(nav_id) = nav_id {
65                invoke_optional_global(
66                    &window,
67                    "__disposeTocSidebar",
68                    &[format!("#{nav_id}").into()],
69                );
70            } else {
71                invoke_optional_global(&window, "__disposeTocSidebar", &[]);
72            }
73        }
74    });
75
76    rsx! {
77        // 移动端 / <1200px:顶部折叠块(CSS 在 ≥1200px 隐藏它)。
78        details { class: "toc",
79            summary { accesskey: "c", title: "(Alt + C)",
80                span { class: "title", "{title}" }
81            }
82            div { class: "inner", dangerous_inner_html: "{toc_html}" }
83        }
84
85        // 桌面端 / ≥1200px:右缘悬浮目录(CSS 在 <1200px 隐藏它)。
86        // 展开动画纯 CSS(:hover + .pinned),SSR/未 hydration 时已可用;
87        // scroll-spy 由 __initTocSidebar 增强。
88        nav {
89            id: nav_id,
90            class: if pinned() { "toc-sidebar pinned" } else { "toc-sidebar" },
91            aria_label: "{title}",
92            div { class: "toc-sidebar-panel",
93                div { class: "toc-sidebar-head",
94                    span { class: "toc-sidebar-title", "{title}" }
95                    button {
96                        class: "toc-sidebar-pin",
97                        aria_label: if pinned() { "取消固定目录" } else { "固定目录" },
98                        aria_pressed: "{pinned}",
99                        onclick: move |_| pinned.set(!pinned()),
100                        // lucide "pin" 图标,stroke=currentColor,CSS 控制尺寸 14px
101                        svg {
102                            view_box: "0 0 24 24",
103                            fill: "none",
104                            stroke: "currentColor",
105                            stroke_width: "2",
106                            stroke_linecap: "round",
107                            stroke_linejoin: "round",
108                            path { d: "M12 17v5" }
109                            path { d: "M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 8 15.24V17h8v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 12 10.76V6h1a2 2 0 0 0 0-4h-2a2 2 0 0 0 0 4h1z" }
110                        }
111                    }
112                }
113                div {
114                    class: "toc-sidebar-body",
115                    dangerous_inner_html: "{toc_html}",
116                }
117            }
118        }
119    }
120}