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///
25/// 通过 `dangerous_inner_html` 注入目录结构,快捷键 `Alt + C` 可聚焦(移动端形态)。
26#[component]
27pub fn PostToc(
28    toc_html: String,
29    #[props(default = "Table of Contents")] title: &'static str,
30) -> Element {
31    // pin 状态:是否锁定展开(仅本次挂载有效,切文章 remount 后重置)。
32    let mut pinned = use_signal(|| false);
33
34    // 挂载后初始化 scroll-spy(yggdrasil-core.js 由 Dioxus.toml 全局注入)。
35    // article 以 slug 为 key,上下篇切换会 remount 本组件 → effect 重跑;
36    // __initTocSidebar 内部幂等(先 dispose 上一次的 observer 与激活态)。
37    #[cfg(target_arch = "wasm32")]
38    use_effect(move || {
39        let window =
40            web_sys::window().expect("post_toc use_effect 仅在 WASM 浏览器上下文执行:无 window");
41        invoke_optional_global(&window, "__initTocSidebar", &[]);
42    });
43
44    rsx! {
45        // 移动端 / <1200px:顶部折叠块(CSS 在 ≥1200px 隐藏它)。
46        details { class: "toc",
47            summary { accesskey: "c", title: "(Alt + C)",
48                span { class: "title", "{title}" }
49            }
50            div { class: "inner", dangerous_inner_html: "{toc_html}" }
51        }
52
53        // 桌面端 / ≥1200px:右缘悬浮目录(CSS 在 <1200px 隐藏它)。
54        // 展开动画纯 CSS(:hover + .pinned),SSR/未 hydration 时已可用;
55        // scroll-spy 由 __initTocSidebar 增强。
56        nav {
57            class: if pinned() { "toc-sidebar pinned" } else { "toc-sidebar" },
58            aria_label: "{title}",
59            div { class: "toc-sidebar-panel",
60                div { class: "toc-sidebar-head",
61                    span { class: "toc-sidebar-title", "{title}" }
62                    button {
63                        class: "toc-sidebar-pin",
64                        aria_label: if pinned() { "取消固定目录" } else { "固定目录" },
65                        aria_pressed: "{pinned}",
66                        onclick: move |_| pinned.set(!pinned()),
67                        // lucide "pin" 图标,stroke=currentColor,CSS 控制尺寸 14px
68                        svg {
69                            view_box: "0 0 24 24",
70                            fill: "none",
71                            stroke: "currentColor",
72                            stroke_width: "2",
73                            stroke_linecap: "round",
74                            stroke_linejoin: "round",
75                            path { d: "M12 17v5" }
76                            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" }
77                        }
78                    }
79                }
80                div {
81                    class: "toc-sidebar-body",
82                    dangerous_inner_html: "{toc_html}",
83                }
84            }
85        }
86    }
87}