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}