feat(toc): implement post table of contents and refactor line number component
这个提交包含在:
@@ -0,0 +1,110 @@
|
||||
// ============ 类型定义 ============
|
||||
|
||||
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`;
|
||||
}
|
||||
});
|
||||
在新议题中引用
屏蔽一个用户