/** * TOC (Table of Contents) 工具类 * 用于 SidebarTOC 和 FloatingTOC 的共享逻辑 */ export interface TOCConfig { contentId: string; indicatorId: string; maxLevel?: number; scrollOffset?: number; } export class TOCManager { private tocItems: HTMLElement[] = []; private observer: IntersectionObserver | null = null; private minDepth = 10; private maxLevel: number; private scrollTimeout: number | null = null; private contentId: string; private indicatorId: string; private scrollOffset: number; constructor(config: TOCConfig) { this.contentId = config.contentId; this.indicatorId = config.indicatorId; this.maxLevel = config.maxLevel || 3; this.scrollOffset = config.scrollOffset || 80; } /** * 查找文章内容容器 */ private getContentContainer(): Element | null { return ( document.querySelector(".custom-md") || document.querySelector(".prose") || document.querySelector(".markdown-content") ); } /** * 查找所有标题 */ private getAllHeadings(): NodeListOf { const contentContainer = this.getContentContainer(); if (contentContainer) { return contentContainer.querySelectorAll("h1, h2, h3, h4, h5, h6"); } return document.querySelectorAll("h1, h2, h3, h4, h5, h6"); } /** * 计算最小深度 */ private calculateMinDepth(headings: NodeListOf): number { let minDepth = 10; headings.forEach((heading) => { const depth = Number.parseInt(heading.tagName.charAt(1), 10); minDepth = Math.min(minDepth, depth); }); return minDepth; } /** * 过滤标题 */ private filterHeadings(headings: NodeListOf): HTMLElement[] { return Array.from(headings).filter((heading) => { const depth = Number.parseInt(heading.tagName.charAt(1), 10); return depth < this.minDepth + this.maxLevel; }); } /** * 生成徽章内容 */ private generateBadgeContent(depth: number, heading1Count: number): string { if (depth === this.minDepth) { return heading1Count.toString(); } if (depth === this.minDepth + 1) { return '
'; } return '
'; } /** * 生成TOC HTML */ public generateTOCHTML(): string { const headings = this.getAllHeadings(); if (headings.length === 0) { return '

当前页面没有目录

'; } this.minDepth = this.calculateMinDepth(headings); const filteredHeadings = this.filterHeadings(headings); if (filteredHeadings.length === 0) { return '

当前页面没有目录

'; } let tocHTML = ""; let heading1Count = 1; filteredHeadings.forEach((heading) => { const depth = Number.parseInt(heading.tagName.charAt(1), 10); const depthClass = depth === this.minDepth ? "" : depth === this.minDepth + 1 ? "pl-4" : "pl-8"; if (!heading.id) { return; } const badgeContent = this.generateBadgeContent(depth, heading1Count); if (depth === this.minDepth) { heading1Count++; } let headingText = (heading.textContent || "") .replace(/#+\s*$/, "") .trim(); // Fallback for empty text (e.g. dynamic subtitle) if (!headingText) { const dataSubtitles = heading.getAttribute("data-subtitles"); if (dataSubtitles) { try { const subtitles = JSON.parse(dataSubtitles); headingText = Array.isArray(subtitles) ? subtitles[0] : subtitles; } catch { // ignore } } } if (!headingText) { headingText = heading.id === "banner-subtitle" ? "Banner Subtitle" : heading.id || "Heading"; } tocHTML += `
${badgeContent}
${headingText}
`; }); tocHTML += `
`; return tocHTML; } /** * 更新TOC内容 */ public updateTOCContent(): void { const tocContent = document.getElementById(this.contentId); if (!tocContent) return; tocContent.innerHTML = this.generateTOCHTML(); this.tocItems = Array.from( document.querySelectorAll(`#${this.contentId} a`), ); } /** * 获取可见的标题ID */ private getVisibleHeadingIds(): string[] { const headings = this.getAllHeadings(); const visibleHeadingIds: string[] = []; headings.forEach((heading) => { if (heading.id) { const rect = heading.getBoundingClientRect(); const isVisible = rect.top < window.innerHeight && rect.bottom > 0; if (isVisible) { visibleHeadingIds.push(heading.id); } } }); // 如果没有可见标题,选择最接近屏幕顶部的标题 if (visibleHeadingIds.length === 0 && headings.length > 0) { let closestHeading: string | null = null; let minDistance = Number.POSITIVE_INFINITY; headings.forEach((heading) => { if (heading.id) { const rect = heading.getBoundingClientRect(); const distance = Math.abs(rect.top); if (distance < minDistance) { minDistance = distance; closestHeading = heading.id; } } }); if (closestHeading) { visibleHeadingIds.push(closestHeading); } } return visibleHeadingIds; } /** * 更新活动状态 */ public updateActiveState(): void { if (!this.tocItems || this.tocItems.length === 0) return; // 移除所有活动状态 this.tocItems.forEach((item) => { item.classList.remove("visible"); }); const visibleHeadingIds = this.getVisibleHeadingIds(); // 找到对应的TOC项并添加活动状态 const activeItems = this.tocItems.filter((item) => { const headingId = item.dataset.headingId; return headingId && visibleHeadingIds.includes(headingId); }); // 添加活动状态 activeItems.forEach((item) => { item.classList.add("visible"); }); // 更新活动指示器 this.updateActiveIndicator(activeItems); } /** * 更新活动指示器 */ private updateActiveIndicator(activeItems: HTMLElement[]): void { const indicator = document.getElementById(this.indicatorId); if (!indicator || !this.tocItems.length) return; if (activeItems.length === 0) { indicator.style.opacity = "0"; return; } const tocContent = document.getElementById(this.contentId); if (!tocContent) return; const contentRect = tocContent.getBoundingClientRect(); const firstActive = activeItems[0]; const lastActive = activeItems[activeItems.length - 1]; const firstRect = firstActive.getBoundingClientRect(); const lastRect = lastActive.getBoundingClientRect(); const top = firstRect.top - contentRect.top; const height = lastRect.bottom - firstRect.top; indicator.style.top = `${top}px`; indicator.style.height = `${height}px`; indicator.style.opacity = "1"; // 自动滚动到活动项 if (firstActive) { this.scrollToActiveItem(firstActive); } } /** * 滚动到活动项 */ private scrollToActiveItem(activeItem: HTMLElement): void { if (!activeItem) return; const tocContainer = document .querySelector(`#${this.contentId}`) ?.closest(".toc-scroll-container"); if (!tocContainer) return; // 清除之前的定时器 if (this.scrollTimeout) { clearTimeout(this.scrollTimeout); } // 使用节流机制 this.scrollTimeout = window.setTimeout(() => { const containerRect = tocContainer.getBoundingClientRect(); const itemRect = activeItem.getBoundingClientRect(); // 只在元素不在可视区域时才滚动 const isVisible = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom; if (!isVisible) { const itemOffsetTop = (activeItem as HTMLElement).offsetTop; const containerHeight = tocContainer.clientHeight; const itemHeight = activeItem.clientHeight; // 计算目标滚动位置,将元素居中显示 const targetScroll = itemOffsetTop - containerHeight / 2 + itemHeight / 2; tocContainer.scrollTo({ top: targetScroll, behavior: "smooth", }); } }, 100); } /** * 处理点击事件 */ public handleClick(event: Event): void { event.preventDefault(); const target = event.currentTarget as HTMLAnchorElement; const id = decodeURIComponent( target.getAttribute("href")?.substring(1) || "", ); const targetElement = document.getElementById(id); if (targetElement) { const targetTop = targetElement.getBoundingClientRect().top + window.pageYOffset - this.scrollOffset; window.scrollTo({ top: targetTop, behavior: "smooth", }); } } /** * 设置IntersectionObserver */ public setupObserver(): void { const headings = this.getAllHeadings(); if (this.observer) { this.observer.disconnect(); } this.observer = new IntersectionObserver( () => { this.updateActiveState(); }, { rootMargin: "0px 0px 0px 0px", threshold: 0, }, ); headings.forEach((heading) => { if (heading.id) { this.observer?.observe(heading); } }); } /** * 绑定点击事件 */ public bindClickEvents(): void { this.tocItems.forEach((item) => { item.addEventListener("click", this.handleClick.bind(this)); }); } /** * 清理 */ public cleanup(): void { if (this.observer) { this.observer.disconnect(); this.observer = null; } if (this.scrollTimeout) { clearTimeout(this.scrollTimeout); this.scrollTimeout = null; } } /** * 初始化 */ public init(): void { this.updateTOCContent(); this.bindClickEvents(); this.setupObserver(); this.updateActiveState(); } } /** * 检查是否为文章页面 */ export function isPostPage(): boolean { return window.location.pathname.includes("/posts/"); }