import tocbot from 'tocbot';

/**
 * 文章阅读模块
 */
export class ArticleRead {
    private readonly _fab = document.querySelector<HTMLElement>('.js-toc-fab');
    private readonly _toc = document.querySelector<HTMLElement>('.js-toc');
    private _toggleToc: ((event: Event) => void) | null = null;
    private _outsideClickHandler: ((event: MouseEvent) => void) | null = null;
    private _tocClickHandler: ((event: MouseEvent) => void) | null = null;
    private _tocInitialized: boolean = false;

    constructor() {
        this.init();
    }

    public destroy(): void {
        if (this._fab && this._toggleToc) {
            this._fab.removeEventListener('click', this._toggleToc);
        }
        if (this._outsideClickHandler) {
            document.removeEventListener('click', this._outsideClickHandler);
        }
        if (this._toc && this._tocClickHandler) {
            this._toc.removeEventListener('click', this._tocClickHandler);
        }
        if (this._tocInitialized) {
            tocbot.destroy();
            this._tocInitialized = false;
        }

        this._toggleToc = null;
        this._outsideClickHandler = null;
        this._tocClickHandler = null;
    }

    private init() {
        // 1. 图片简单预处理
        document.querySelectorAll('.article-content img').forEach((img) => {
            img.classList.add('img-fluid');
        });

        // 2. 目录生成(Tocbot)
        this.generateToc();
        this.initMobileEvents();
    }

    private initMobileEvents(): void {
        const fab = this._fab;
        const toc = this._toc;

        if (!fab || !toc) {
            return;
        }

        this._toggleToc = (event: Event) => {
            event.preventDefault();
            event.stopPropagation();
            toc.classList.toggle('is-open-mobile');
        };
        fab.addEventListener('click', this._toggleToc);

        // 点击外部时关闭
        this._outsideClickHandler = (event: MouseEvent) => {
            if (window.innerWidth > 1200) {
                return;
            }

            const target = event.target as Node;
            // 如果点击位置在TOC和FAB之外,则关闭
            if (
                !toc.contains(target) &&
                !fab.contains(target) &&
                toc.classList.contains('is-open-mobile')
            ) {
                toc.classList.remove('is-open-mobile');
            }
        };
        document.addEventListener('click', this._outsideClickHandler);

        // 点击TOC内部链接时关闭
        this._tocClickHandler = (event: MouseEvent) => {
            const target = event.target as HTMLElement;
            if (target.tagName === 'A' || target.closest('a')) {
                // 如果是移动端视图
                if (window.innerWidth <= 1200) {
                    toc.classList.remove('is-open-mobile');
                }
            }
        };
        toc.addEventListener('click', this._tocClickHandler);
    }

    private generateToc(): void {
        const tocContainer = document.querySelectorAll('.js-toc');
        const content = document.querySelectorAll(
            '.article-content h1, .article-content h2, .article-content h3'
        );

        if (tocContainer.length === 0 || content.length === 0) {
            tocContainer.forEach((container) => {
                const containerElement = container as HTMLElement;
                if (containerElement != null) {
                    containerElement.style.display = 'none';
                }
            });
            return;
        }

        tocbot.init({
            tocSelector: '.js-toc',
            contentSelector: '.article-content',
            headingSelector: 'h1, h2, h3',
            hasInnerContainers: true,
            scrollSmooth: true,
            scrollSmoothDuration: 420,
            // 适配固定头部
            headingsOffset: 80,
            scrollSmoothOffset: -80,
        });
        this._tocInitialized = true;

        // 带动画显示目录
        tocContainer.forEach((container) => container.classList.add('is-visible'));
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

ArticleRead.ts 分析报告

一、文件定位

ArticleRead.ts 是 Dpz.Core.Web 前端脚本中「文章阅读页」的页面级功能模块,职责有两件事:

  1. 给正文图片补 Bootstrap 的 img-fluid 响应式类;
  2. 基于第三方库 tocbot 生成文章目录(TOC),并处理移动端目录抽屉的开关交互。

它属于「核心壳 + 按需特性」架构下的一个 feature chunk,只在文章阅读页被动态加载。依赖声明可见 package.json(tocbot: ^4.36.4)。

二、生命周期与外部协作

该模块没有被 App.ts 静态引入,而是通过特性注册表挂载:

  • features.ts 中注册了 article-read,匹配条件是页面存在 .article-content 或 .js-toc;load() 里 await import('../ArticleRead.ts'),工厂函数 new ArticleRead() 并返回 { destroy }。
  • FeatureLoader.ts 的 refreshFeatures() 在 PJAX 跳转时先 disposeFeatures()(逐个调用 destroy)再按新 DOM 重新 activateFeatures()。因此本文件的 destroy() 是配合 PJAX 无刷新导航所必需的清理逻辑,用于避免监听器累积和 tocbot 残留实例。

DOM 契约(由视图与样式提供,不是本文件定义):

  • Read.cshtml:<nav class="js-toc toc">、<button class="toc-fab js-toc-fab">、<div class="article-content markdown-body">(正文容器,标题选择器的根)。
  • tocbot.css:.toc 默认 display:none,.toc.is-visible 才显示;≤1200px 时为浮动抽屉,靠 .toc.is-open-mobile 打开,FAB 靠 .toc.is-visible ~ .toc-fab 显示。也就是说 JS 只负责加/去类,显隐规则全在 CSS。

另需注意:Views/Code/README.md 明确说明代码浏览页刻意避开 js-toc / js-toc-fab 类名,因为本模块的选择器是全局的(document.querySelector),会抢走代码页的 TOC 容器与 FAB 事件(CodeExplorer.ts 自己另用别的类名并自行调用 tocbot)。

三、字段与状态

成员含义
_fab / _toc构造时一次性 document.querySelector 抓取的 FAB 与 TOC 容器(只取第一个匹配元素)
_toggleToc / _outsideClickHandler / _tocClickHandler保存的具名监听器引用,供 destroy() 精确移除(否则匿名函数无法解绑)
_tocInitialized记录 tocbot 是否已初始化,决定 destroy() 是否需要 tocbot.destroy()

构造函数直接调用 init(),即"实例化即生效",无显式 init 对外方法。

四、执行流程

flowchart TD
    A["new ArticleRead()"] --> B["init()"]
    B --> C["document.querySelectorAll('.article-content img')<br/>逐个 add('img-fluid')"]
    C --> D["generateToc()"]
    D --> E{"存在 .js-toc 容器<br/>且存在 h1/h2/h3 ?"}
    E -->|否| F["对所有 .js-toc 设 display:none<br/>并 return(目录区彻底隐藏)"]
    E -->|是| G["tocbot.init({tocSelector:'.js-toc',<br/>contentSelector:'.article-content', headingsOffset:80,<br/>scrollSmoothOffset:-80, ...})"]
    G --> H["_tocInitialized = true"]
    H --> I["容器 add('is-visible')(CSS 过渡淡入)"]
    I --> J["initMobileEvents()"]
    J --> K{"_fab 与 _toc 都存在 ?"}
    K -->|否| L["return(无移动端交互)"]
    K -->|是| M["绑定 3 个监听器:<br/>fab.click / document.click / toc.click"]

    M --> N["destroy()"]
    N --> O["移除 fab click / document click / toc click"]
    O --> P{"_tocInitialized ?"}
    P -->|是| Q["tocbot.destroy(); _tocInitialized=false"]
    P -->|否| R["跳过"]
    Q --> S["三个监听器字段置 null"]
    R --> S

移动端三个监听器的分工:

  • FAB 点击:preventDefault + stopPropagation,切换 is-open-mobile(也是阻止冒泡到 document 处理器、避免"开了立刻被关"的关键)。
  • document 点击:仅当 window.innerWidth <= 1200 才判定;若点击点在 TOC 和 FAB 之外且 TOC 当前是打开状态,则移除 is-open-mobile,实现"点空白处收起"。
  • TOC 内部点击:若点中的是 <a> 或其后代,且处于移动端宽度,则关闭抽屉——点击目录跳转后自动收起。

generateToc() 中 headingsOffset: 80 与 scrollSmoothOffset: -80 是为固定头部做的偏移补偿,scrollSmooth 打开平滑滚动。

五、几点值得留意的实现细节(基于代码的观察)

  • 选择器不对称:_fab / _toc 用 querySelector(单元素),而 generateToc() 用 querySelectorAll 且对全部容器加 is-visible / display:none。若页面存在多个 .js-toc,tocbot 会渲染进所有匹配容器,但移动端开关事件只绑在第一个 TOC 与第一个 FAB 上。
  • 无幂等保护:模块没有"已初始化则跳过"的判断。重复 new ArticleRead() 会导致监听器与 tocbot 实例叠加;当前靠 FeatureLoader 的 active 集合 + disposeFeatures() 保证不重复激活。
  • 销毁不还原类名:destroy() 移除了监听器并调用 tocbot.destroy(),但没有移除 is-visible,也未清理加在图片上的 img-fluid。由于 PJAX 会替换 DOM 节点,这通常不造成问题;但若同一 DOM 被复用,is-visible 会残留。
  • init() 未标注返回类型(private init()),而 destroy() / initMobileEvents() 等均有显式标注,属风格上的小不一致。

六、总结

一句话概括:这是文章详情页的"阅读体验"模块——用 tocbot 把 .article-content 的 h1/h2/h3 渲染成右侧目录,负责目录的显示时机与移动端抽屉式交互,并通过实现 destroy() 接入 FeatureLoader.ts 的 PJAX 生命周期,由 features.ts 按 .article-content / .js-toc 的存在与否动态加载。

评论加载中...