feat(ui): implement line numbering feature for post content with dynamic updates

这个提交包含在:
2026-01-29 17:55:54 +08:00 未验证
父节点 d3af9f09d9
当前提交 347c12de80
修改 6 个文件,包含 223 行新增15 行删除
+178
查看文件
@@ -0,0 +1,178 @@
// 非文字元素列表
const NON_TEXT_ELEMENTS = ['img', 'hr', 'table', 'figure', 'video', 'audio', 'canvas', 'svg', 'iframe'];
// 默认行高倍数
const DEFAULT_LINE_HEIGHT_RATIO = 1.54;
// 工具函数:创建行号元素
function createLineSpan(lineNum: number, top: number, height: number, isGap = false): HTMLSpanElement {
const span = document.createElement('span');
span.className = isGap ? 'line-number line-number--gap' : 'line-number';
span.style.top = `${top}px`;
span.style.height = `${height}px`;
span.style.lineHeight = `${height}px`;
span.textContent = String(lineNum);
return span;
}
// 工具函数:获取元素的 padding
function getElementPadding(style: CSSStyleDeclaration): { top: number; bottom: number } {
return {
top: parseFloat(style.paddingTop) || 0,
bottom: parseFloat(style.paddingBottom) || 0
};
}
// 工具函数:计算默认行高
function getDefaultLineHeight(element: HTMLElement): number {
const style = getComputedStyle(element);
const fontSize = parseFloat(style.fontSize);
return parseFloat(style.lineHeight) || fontSize * DEFAULT_LINE_HEIGHT_RATIO;
}
// 工具函数:判断是否为非文元素
function isNonTextElement(tagName: string): boolean {
return NON_TEXT_ELEMENTS.includes(tagName.toLowerCase());
}
// 工具函数:计算元素占用的行数
function calculateElementLines(element: HTMLElement, defaultLineHeight: number): number {
const tagName = element.tagName.toLowerCase();
const style = getComputedStyle(element);
const padding = getElementPadding(style);
const contentHeight = element.offsetHeight - padding.top - padding.bottom;
// 非文字元素整体算作一行
if (isNonTextElement(tagName)) return 1;
// 获取行高
const lineHeight = parseFloat(style.lineHeight) || defaultLineHeight;
return Math.max(1, Math.round(contentHeight / lineHeight));
}
// 填充空白间隙的行号
function appendGapLines(
fragment: DocumentFragment,
gapStart: number,
gapEnd: number,
lineHeight: number,
startLine: number
): number {
const gapLines = Math.round((gapEnd - gapStart) / lineHeight);
for (let i = 0; i < gapLines; i++) {
fragment.appendChild(
createLineSpan(startLine + i, gapStart + i * lineHeight, lineHeight, true)
);
}
return startLine + gapLines;
}
// 生成元素内容的行号
function appendElementLines(
fragment: DocumentFragment,
element: HTMLElement,
defaultLineHeight: number,
startLine: number
): number {
const style = getComputedStyle(element);
const padding = getElementPadding(style);
const elementTop = element.offsetTop;
const elementHeight = element.offsetHeight;
const lines = calculateElementLines(element, defaultLineHeight);
const contentHeight = elementHeight - padding.top - padding.bottom;
const lineHeight = contentHeight / lines;
const startTop = elementTop + padding.top;
for (let i = 0; i < lines; i++) {
fragment.appendChild(
createLineSpan(startLine + i, startTop + i * lineHeight, lineHeight)
);
}
return startLine + lines;
}
interface LineNumbersState {
totalLines: number;
resizeObserver: ResizeObserver | null;
init(): void;
calculateLineNumbers(): void;
destroy(): void;
}
export const lineNumbers = (): LineNumbersState => ({
totalLines: 0,
resizeObserver: null,
init() {
const content = (this.$el as HTMLElement).querySelector('.post-content') as HTMLElement;
if (!content) return;
// 延迟计算确保 DOM 渲染完成
requestAnimationFrame(() => {
setTimeout(() => this.calculateLineNumbers(), 50);
});
// 监听内容区域大小变化
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => this.calculateLineNumbers());
this.resizeObserver.observe(content);
}
// 窗口大小变化时重新计算(备用)
let resizeTimer: number;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => this.calculateLineNumbers(), 200);
});
// 监听图片加载
content.querySelectorAll('img').forEach((img: HTMLImageElement) => {
if (!img.complete) {
img.addEventListener('load', () => this.calculateLineNumbers());
img.addEventListener('error', () => this.calculateLineNumbers());
}
});
},
destroy() {
this.resizeObserver?.disconnect();
},
calculateLineNumbers() {
const content = (this.$el as HTMLElement).querySelector('.post-content') as HTMLElement;
const gutter = (this.$el as HTMLElement).querySelector('.post-line-gutter') as HTMLElement;
if (!content || !gutter) return;
const defaultLineHeight = getDefaultLineHeight(content);
const container = content.querySelector(':scope > div') as HTMLElement || content;
const blockElements = container.querySelectorAll(':scope > *');
gutter.innerHTML = '';
const fragment = document.createDocumentFragment();
let currentLine = 1;
let lastBottom = 0;
blockElements.forEach((el: Element) => {
const element = el as HTMLElement;
const elementTop = element.offsetTop;
// 填充空白间隙
if (lastBottom > 0 && elementTop > lastBottom) {
currentLine = appendGapLines(fragment, lastBottom, elementTop, defaultLineHeight, currentLine);
}
// 生成元素行号
currentLine = appendElementLines(fragment, element, defaultLineHeight, currentLine);
lastBottom = elementTop + element.offsetHeight;
});
this.totalLines = currentLine - 1;
gutter.appendChild(fragment);
gutter.style.height = `${container.scrollHeight}px`;
}
});