blog / src /utils /tocUtils.ts
cacode's picture
Upload 434 files
96dd062 verified
/**
* 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<HTMLElement> {
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<HTMLElement>): 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>): 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 '<div class="transition w-2 h-2 rounded-[0.1875rem] bg-(--toc-badge-bg)"></div>';
}
return '<div class="transition w-1.5 h-1.5 rounded-xs bg-black/5 dark:bg-white/10"></div>';
}
/**
* 生成TOC HTML
*/
public generateTOCHTML(): string {
const headings = this.getAllHeadings();
if (headings.length === 0) {
return '<div class="text-center py-8 text-gray-500 dark:text-gray-400"><p>当前页面没有目录</p></div>';
}
this.minDepth = this.calculateMinDepth(headings);
const filteredHeadings = this.filterHeadings(headings);
if (filteredHeadings.length === 0) {
return '<div class="text-center py-8 text-gray-500 dark:text-gray-400"><p>当前页面没有目录</p></div>';
}
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 += `
<a
href="#${heading.id}"
class="px-2 flex gap-2 relative transition w-full min-h-9 rounded-xl hover:bg-(--toc-btn-hover) active:bg-(--toc-btn-active) py-2 ${depthClass}"
data-heading-id="${heading.id}"
aria-label="${headingText}"
>
<div class="transition w-5 h-5 shrink-0 rounded-lg text-xs flex items-center justify-center font-bold ${depth === this.minDepth ? "bg-(--toc-badge-bg) text-(--btn-content)" : ""}">
${badgeContent}
</div>
<div class="transition text-sm ${depth <= this.minDepth + 1 ? "text-50" : "text-30"} flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">${headingText}</div>
</a>
`;
});
tocHTML += `<div id="${this.indicatorId}" style="opacity: 0;" class="-z-10 absolute bg-(--toc-btn-hover) left-0 right-0 rounded-xl transition-all"></div>`;
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/");
}