Skip to main content

yggdrasil/components/post/
post_toc.rs

1//! 文章目录组件
2//!
3//! 同一个 `nav.toc-sidebar` 在两种宽度下换形态(CSS 媒体查询,断点 1100px):
4//! - 宽屏且位于 `.post-layout` 内:正文右侧吸顶目录,当前小节以 Clay 圆点标示,
5//!   列表两端滚动渐隐,底部显示「已读 N%」与进度条。
6//! - 窄屏或其他容器:底部悬浮条「目录 · 当前小节 · 百分比」,点开后目录浮起
7//!   (767px 以下为底部抽屉),选中小节后收起。手机上页头下方另有 2px 阅读进度线。
8//!
9//! 两形态共用服务端生成的 `toc_html`(嵌套 `<ul><li><a href="#id">`),只保留一份
10//! 列表,scroll-spy 只需维护一处激活态。scroll-spy、当前小节名与阅读进度由
11//! yggdrasil-core 的 `__initTocSidebar` 维护;锚点点击由 `initAnchorClick` 平滑滚动。
12
13use leptos::ev::{KeyboardEvent, PointerEvent};
14use leptos::prelude::*;
15
16#[cfg(target_arch = "wasm32")]
17use crate::bridges::globals;
18use crate::components::enhance_assets::EnhanceScript;
19use crate::components::ui::{Icon, IconName};
20
21/// 正式页面目录的默认 id;图鉴预览传入各自的局部 id。
22const DEFAULT_NAV_ID: &str = "post-toc";
23
24/// 文章目录(Table of Contents)组件。
25///
26/// Props:
27/// - `toc_html`:服务端生成的目录 HTML 字符串
28/// - `title`:目录标题,默认「目录」
29/// - `nav_id`、`content_id`、`scroll_id`:可选的图鉴局部作用域(省略即不传,
30///   不要传 `Some(..)`);正式文章使用默认的页面目录和窗口滚动。
31///
32/// 组件函数体只运行一次:切文章时由调用方按 slug 重建本组件,
33/// 重建会重跑初始化(`__initTocSidebar` 幂等,先 dispose 上一次的 observer 与激活态)。
34#[component]
35pub fn PostToc(
36    #[prop(into)] toc_html: String,
37    #[prop(default = "目录")] title: &'static str,
38    #[prop(optional)] nav_id: Option<&'static str>,
39    #[prop(optional)] content_id: Option<&'static str>,
40    #[prop(optional)] scroll_id: Option<&'static str>,
41) -> impl IntoView {
42    let open = RwSignal::new(false);
43    let id = nav_id.unwrap_or(DEFAULT_NAV_ID);
44    let pill_id = format!("{id}-pill");
45
46    // 挂载后初始化 scroll-spy(yggdrasil-core.js 由 shell 全局注入)。
47    #[cfg(target_arch = "wasm32")]
48    Effect::new(move |_| {
49        if let (Some(nav_id), Some(content_id), Some(scroll_id)) = (nav_id, content_id, scroll_id) {
50            globals::init_toc_sidebar(
51                Some(&format!("#{nav_id}")),
52                Some(&format!("#{content_id}")),
53                Some(&format!("#{scroll_id}")),
54            );
55        } else {
56            globals::init_toc_sidebar(None, None, None);
57        }
58    });
59    #[cfg(not(target_arch = "wasm32"))]
60    let _ = (content_id, scroll_id);
61
62    #[cfg(target_arch = "wasm32")]
63    on_cleanup(move || {
64        globals::dispose_toc_sidebar(nav_id.map(|nav_id| format!("#{nav_id}")).as_deref());
65    });
66
67    // 浮层渲染出来后把焦点移进目录(等一帧:class 变更落到 DOM 后再聚焦)。
68    Effect::new(move |_| {
69        if open.get() {
70            request_animation_frame(move || focus_by_id(id));
71        }
72    });
73    let pill_focus_id = pill_id.clone();
74    // 主动关闭(Esc、遮罩、关闭键)时还给悬浮条(restore = true)。
75    let close = Callback::new(move |restore: bool| {
76        if !open.get_untracked() {
77            return;
78        }
79        open.set(false);
80        if restore {
81            let pill_id = pill_focus_id.clone();
82            request_animation_frame(move || focus_by_id(&pill_id));
83        }
84    });
85
86    view! {
87        // P5 §8.2:toc scroll-spy 增强在 yggdrasil-enhance.js,按需注入。SSR 直达烤进
88        // head 带 defer,先于 hydration;SPA 首跳由增强调用队列兜底。
89        <EnhanceScript/>
90
91        <div
92            class=move || if open.get() { "post-toc is-open" } else { "post-toc" }
93            data-toc-root="true"
94            on:keydown=move |event: KeyboardEvent| {
95                if open.get_untracked() && event.key() == "Escape" {
96                    event.prevent_default();
97                    close.run(true);
98                }
99            }
100        >
101            <div class="toc-progress-line" aria-hidden="true"></div>
102            <button
103                id=pill_id
104                class="toc-pill"
105                type="button"
106                aria-expanded=move || open.get().to_string()
107                aria-controls=id
108                on:click=move |_| open.set(true)
109            >
110                <svg class="toc-pill-ring" viewBox="0 0 22 22" aria-hidden="true">
111                    <circle cx="11" cy="11" r="8.5"></circle>
112                    <circle class="toc-pill-ring-value" cx="11" cy="11" r="8.5" pathLength="100"></circle>
113                </svg>
114                <span class="toc-pill-label">
115                    <span class="toc-pill-title">{title}</span>
116                    <span class="toc-pill-current" data-toc-current="true"></span>
117                </span>
118                <span class="toc-pill-percent tabular-nums" data-toc-percent="true"></span>
119            </button>
120            <div class="toc-scrim" aria-hidden="true" on:click=move |_| close.run(true)></div>
121            <nav id=id class="toc-sidebar" aria-label=title tabindex="-1">
122                <div class="toc-sidebar-head">
123                    <span class="toc-sidebar-title">{title}</span>
124                    <button
125                        class="toc-sidebar-close btn btn-xs btn-icon btn-ghost"
126                        type="button"
127                        aria-label="关闭目录"
128                        on:click=move |_| close.run(true)
129                    >
130                        <Icon name=IconName::X size=16/>
131                    </button>
132                </div>
133                // 选中小节后收起浮层。锚点点击在捕获阶段被全局处理拦下并停止传播,
134                // 收不到 click,这里改听 pointerup 与 Enter;浮层的 visibility 延迟 150ms 才隐藏,
135                // 随后的 click 仍落在链接上。
136                <div
137                    class="toc-sidebar-body scroll-fade-y"
138                    on:pointerup=move |_: PointerEvent| close.run(false)
139                    on:keydown=move |event: KeyboardEvent| {
140                        if event.key() == "Enter" {
141                            close.run(false);
142                        }
143                    }
144                    inner_html=toc_html
145                ></div>
146                <div class="toc-sidebar-progress">
147                    <div class="toc-sidebar-progress-text">
148                        <span>"已读"</span>
149                        <span class="tabular-nums" data-toc-percent="true"></span>
150                    </div>
151                    <div class="toc-sidebar-progress-track" aria-hidden="true">
152                        <div class="toc-sidebar-progress-bar"></div>
153                    </div>
154                </div>
155            </nav>
156        </div>
157    }
158}
159
160#[cfg(target_arch = "wasm32")]
161fn focus_by_id(id: &str) {
162    use wasm_bindgen::JsCast;
163    if let Some(el) = web_sys::window()
164        .and_then(|w| w.document())
165        .and_then(|d| d.get_element_by_id(id))
166        .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
167    {
168        let _ = el.focus();
169    }
170}
171
172#[cfg(not(target_arch = "wasm32"))]
173fn focus_by_id(_id: &str) {}