111 行
2.8 KiB
TypeScript
111 行
2.8 KiB
TypeScript
// ============ 类型定义 ============
|
|
|
|
export interface PostTocItem {
|
|
id: string;
|
|
level: number;
|
|
title: string;
|
|
element: HTMLElement;
|
|
tagName: string;
|
|
}
|
|
|
|
// ============ 工具函数 ============
|
|
|
|
const generateId = (counter: number): string =>
|
|
`post-toc-${counter}-${Date.now()}`;
|
|
|
|
const ensureElementId = (element: HTMLElement, fallbackId: string): string => {
|
|
if (!element.id) element.id = fallbackId;
|
|
return element.id;
|
|
};
|
|
|
|
const findTopVisibleEntry = (entries: IntersectionObserverEntry[]): IntersectionObserverEntry | null => {
|
|
const visible = entries.filter(e => e.isIntersecting);
|
|
if (visible.length === 0) return null;
|
|
|
|
return visible.reduce((prev, curr) =>
|
|
curr.boundingClientRect.top < prev.boundingClientRect.top ? curr : prev
|
|
);
|
|
};
|
|
|
|
const createScrollObserver = (onActiveChange: (id: string) => void): IntersectionObserver =>
|
|
new IntersectionObserver(
|
|
(entries) => {
|
|
const topEntry = findTopVisibleEntry(entries);
|
|
if (topEntry) onActiveChange(topEntry.target.id);
|
|
},
|
|
{ rootMargin: '-80px 0px -70% 0px', threshold: [0, 0.5, 1] }
|
|
);
|
|
|
|
// ============ 事件常量 ============
|
|
|
|
export const TOC_EVENTS = {
|
|
CLEAR: 'post-toc:clear',
|
|
ADD: 'post-toc:add',
|
|
HIGHLIGHT: 'post-toc:init-highlight'
|
|
} as const;
|
|
|
|
// ============ Alpine 组件 ============
|
|
|
|
export const postToc = () => ({
|
|
items: [] as PostTocItem[],
|
|
activeId: '',
|
|
observer: null as IntersectionObserver | null,
|
|
|
|
// 私有属性
|
|
_isInitialized: false,
|
|
_counter: 0,
|
|
_minLevel: 6,
|
|
|
|
init() {
|
|
if (this._isInitialized) return;
|
|
|
|
document.addEventListener(TOC_EVENTS.CLEAR, () => this.clear());
|
|
|
|
document.addEventListener(TOC_EVENTS.ADD, (e) => {
|
|
const { level, title, element, tagName } = (e as CustomEvent).detail;
|
|
|
|
this.addItem(level, title, element, tagName);
|
|
});
|
|
|
|
document.addEventListener(TOC_EVENTS.HIGHLIGHT, () => this.initScrollHighlight());
|
|
|
|
this._isInitialized = true;
|
|
},
|
|
|
|
addItem(level: number, title: string, element: HTMLElement, tagName: string) {
|
|
this._counter++;
|
|
|
|
const id = ensureElementId(element, generateId(this._counter));
|
|
|
|
if (level < this._minLevel) this._minLevel = level;
|
|
|
|
this.items = [...this.items, { id, level, title, element, tagName }];
|
|
},
|
|
|
|
clear() {
|
|
this.items = [];
|
|
this.activeId = '';
|
|
this._counter = 0;
|
|
this._minLevel = 6;
|
|
this.observer?.disconnect();
|
|
},
|
|
|
|
initScrollHighlight() {
|
|
this.observer?.disconnect();
|
|
this.observer = createScrollObserver((id) => { this.activeId = id; });
|
|
this.items.forEach(item => this.observer?.observe(item.element));
|
|
},
|
|
|
|
scrollTo(id: string) {
|
|
const element = document.getElementById(id);
|
|
if (!element) return;
|
|
|
|
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
this.activeId = id;
|
|
},
|
|
|
|
getIndent(level: number): string {
|
|
return `${(level - this._minLevel) * 16}px`;
|
|
}
|
|
});
|