// ============ 类型定义 ============ 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`; } });