feat(lineNumbers): refactor line number generation and enhance element metrics handling
这个提交包含在:
+169
-118
@@ -1,97 +1,37 @@
|
||||
// 非文字元素列表
|
||||
const NON_TEXT_ELEMENTS = ['img', 'hr', 'table', 'figure', 'video', 'audio', 'canvas', 'svg', 'iframe'];
|
||||
// ============ 常量配置 ============
|
||||
|
||||
// 默认行高倍数
|
||||
const TEXT_ELEMENTS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'li'];
|
||||
const HEADING_ELEMENTS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
|
||||
const NON_TEXT_ELEMENTS = ['img', 'table', 'video', 'audio', 'canvas', 'svg', 'iframe', 'pre'];
|
||||
const ALL_ELEMENTS = [...TEXT_ELEMENTS, ...NON_TEXT_ELEMENTS];
|
||||
const DIRECT_CHILD_SELECTOR = ALL_ELEMENTS.map(tag => `div > ${tag}`).join(', ');
|
||||
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;
|
||||
// ============ 类型定义 ============
|
||||
|
||||
interface LineSpanOptions {
|
||||
lineNum: number;
|
||||
top: number;
|
||||
height: number;
|
||||
isGap?: boolean;
|
||||
isBreakpoint?: boolean;
|
||||
}
|
||||
|
||||
// 工具函数:获取元素的 padding
|
||||
function getElementPadding(style: CSSStyleDeclaration): { top: number; bottom: number } {
|
||||
return {
|
||||
top: parseFloat(style.paddingTop) || 0,
|
||||
bottom: parseFloat(style.paddingBottom) || 0
|
||||
};
|
||||
interface ElementMetrics {
|
||||
top: number;
|
||||
height: number;
|
||||
paddingTop: number;
|
||||
lineHeight: number;
|
||||
lines: number;
|
||||
isHeading: boolean;
|
||||
}
|
||||
|
||||
// 工具函数:计算默认行高
|
||||
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 GapLinesOptions {
|
||||
fragment: DocumentFragment;
|
||||
gapStart: number;
|
||||
gapEnd: number;
|
||||
lineHeight: number;
|
||||
startLine: number;
|
||||
}
|
||||
|
||||
interface LineNumbersState {
|
||||
@@ -103,38 +43,145 @@ interface LineNumbersState {
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
// ============ 工具函数 ============
|
||||
|
||||
const isNonTextElement = (tagName: string): boolean =>
|
||||
NON_TEXT_ELEMENTS.includes(tagName.toLowerCase());
|
||||
|
||||
const isHeadingElement = (tagName: string): boolean =>
|
||||
HEADING_ELEMENTS.includes(tagName.toLowerCase());
|
||||
|
||||
const isEmptyElement = (element: HTMLElement): boolean => {
|
||||
if (isNonTextElement(element.tagName.toLowerCase())) return false;
|
||||
return !element.textContent?.trim();
|
||||
};
|
||||
|
||||
const getStyleValue = (style: CSSStyleDeclaration, prop: 'paddingTop' | 'paddingBottom' | 'lineHeight' | 'fontSize'): number =>
|
||||
parseFloat(style[prop]) || 0;
|
||||
|
||||
const getDefaultLineHeight = (element: HTMLElement): number => {
|
||||
const style = getComputedStyle(element);
|
||||
return getStyleValue(style, 'lineHeight') || getStyleValue(style, 'fontSize') * DEFAULT_LINE_HEIGHT_RATIO;
|
||||
};
|
||||
|
||||
// 获取元素度量信息(合并多次 getComputedStyle 调用)
|
||||
const getElementMetrics = (element: HTMLElement, containerRect: DOMRect, defaultLineHeight: number): ElementMetrics => {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const style = getComputedStyle(element);
|
||||
const paddingTop = getStyleValue(style, 'paddingTop');
|
||||
const paddingBottom = getStyleValue(style, 'paddingBottom');
|
||||
const height = element.offsetHeight;
|
||||
const contentHeight = height - paddingTop - paddingBottom;
|
||||
const isNonText = isNonTextElement(tagName);
|
||||
|
||||
const lineHeight = isNonText ? contentHeight : (getStyleValue(style, 'lineHeight') || defaultLineHeight);
|
||||
const lines = isNonText ? 1 : Math.max(1, Math.round(contentHeight / lineHeight));
|
||||
|
||||
return {
|
||||
top: element.getBoundingClientRect().top - containerRect.top,
|
||||
height,
|
||||
paddingTop,
|
||||
lineHeight: contentHeight / lines,
|
||||
lines,
|
||||
isHeading: isHeadingElement(tagName)
|
||||
};
|
||||
};
|
||||
|
||||
// ============ DOM 创建 ============
|
||||
|
||||
const createLineSpan = (options: LineSpanOptions): HTMLSpanElement => {
|
||||
const { lineNum, top, height, isGap = false, isBreakpoint = false } = options;
|
||||
const span = document.createElement('span');
|
||||
|
||||
span.className = `line-number${isGap ? ' line-number--gap' : ''}${isBreakpoint ? ' line-number--breakpoint' : ''}`;
|
||||
span.style.cssText = `top:${top}px;height:${height}px;line-height:${height}px`;
|
||||
span.textContent = String(lineNum);
|
||||
|
||||
if (isBreakpoint) {
|
||||
const breakpoint = document.createElement('span');
|
||||
breakpoint.className = 'line-breakpoint';
|
||||
breakpoint.textContent = '●';
|
||||
span.appendChild(breakpoint);
|
||||
}
|
||||
|
||||
return span;
|
||||
};
|
||||
|
||||
// ============ 行号生成 ============
|
||||
|
||||
const appendGapLines = (options: GapLinesOptions): number => {
|
||||
const { fragment, gapStart, gapEnd, lineHeight, startLine } = options;
|
||||
const gapHeight = gapEnd - gapStart;
|
||||
|
||||
if (gapHeight < lineHeight) return startLine;
|
||||
|
||||
const gapLines = Math.round(gapHeight / lineHeight);
|
||||
|
||||
for (let i = 0; i < gapLines; i++) {
|
||||
fragment.appendChild(createLineSpan({
|
||||
lineNum: startLine + i,
|
||||
top: gapStart + i * lineHeight,
|
||||
height: lineHeight,
|
||||
isGap: true
|
||||
}));
|
||||
}
|
||||
|
||||
return startLine + gapLines;
|
||||
};
|
||||
|
||||
const appendElementLines = (fragment: DocumentFragment, metrics: ElementMetrics, startLine: number): number => {
|
||||
const { top, paddingTop, lineHeight, lines, isHeading } = metrics;
|
||||
const startTop = top + paddingTop;
|
||||
|
||||
for (let i = 0; i < lines; i++) {
|
||||
fragment.appendChild(createLineSpan({
|
||||
lineNum: startLine + i,
|
||||
top: startTop + i * lineHeight,
|
||||
height: lineHeight,
|
||||
isBreakpoint: i === 0 && isHeading
|
||||
}));
|
||||
}
|
||||
|
||||
return startLine + lines;
|
||||
};
|
||||
|
||||
// ============ 主组件 ============
|
||||
|
||||
export const lineNumbers = (): LineNumbersState => ({
|
||||
totalLines: 0,
|
||||
resizeObserver: null,
|
||||
|
||||
init() {
|
||||
const content = (this.$el as HTMLElement).querySelector('.post-content') as HTMLElement;
|
||||
|
||||
const content = this.$el?.querySelector('.post-content') as HTMLElement;
|
||||
if (!content) return;
|
||||
|
||||
// 延迟计算确保 DOM 渲染完成
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => this.calculateLineNumbers(), 50);
|
||||
});
|
||||
const recalculate = () => this.calculateLineNumbers();
|
||||
|
||||
// 监听内容区域大小变化
|
||||
// 延迟计算确保 DOM 渲染完成
|
||||
requestAnimationFrame(() => setTimeout(recalculate, 50));
|
||||
|
||||
// 监听内容区域大小变化(带防抖)
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
this.resizeObserver = new ResizeObserver(() => this.calculateLineNumbers());
|
||||
let resizeTimer: number;
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(recalculate, 100);
|
||||
});
|
||||
this.resizeObserver.observe(content);
|
||||
}
|
||||
|
||||
// 窗口大小变化时重新计算(备用)
|
||||
let resizeTimer: number;
|
||||
// 窗口大小变化时重新计算
|
||||
let windowResizeTimer: number;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(() => this.calculateLineNumbers(), 200);
|
||||
clearTimeout(windowResizeTimer);
|
||||
windowResizeTimer = window.setTimeout(recalculate, 200);
|
||||
});
|
||||
|
||||
// 监听图片加载
|
||||
// 监听图片加载(使用 once 避免重复触发)
|
||||
content.querySelectorAll('img').forEach((img: HTMLImageElement) => {
|
||||
if (!img.complete) {
|
||||
img.addEventListener('load', () => this.calculateLineNumbers());
|
||||
img.addEventListener('error', () => this.calculateLineNumbers());
|
||||
img.addEventListener('load', recalculate, { once: true });
|
||||
img.addEventListener('error', recalculate, { once: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -146,35 +193,39 @@ export const lineNumbers = (): LineNumbersState => ({
|
||||
calculateLineNumbers() {
|
||||
const content = this.$el?.querySelector('.post-content') as HTMLElement;
|
||||
const gutter = this.$el?.querySelector('.post-line-gutter') as HTMLElement;
|
||||
|
||||
if (!content || !gutter) return;
|
||||
|
||||
const container = (content.querySelector(':scope > div') || content) as HTMLElement;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const defaultLineHeight = getDefaultLineHeight(content);
|
||||
const container = content.querySelector(':scope > div') || content;
|
||||
const blockElements = container.querySelectorAll(':scope > *');
|
||||
|
||||
gutter.innerHTML = '';
|
||||
const elements = Array.from(container.querySelectorAll(DIRECT_CHILD_SELECTOR))
|
||||
.filter(el => !isEmptyElement(el as HTMLElement)) as HTMLElement[];
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
let currentLine = 1;
|
||||
let lastBottom = 0;
|
||||
|
||||
blockElements.forEach((el: Element) => {
|
||||
const element = el as HTMLElement;
|
||||
const elementTop = element.offsetTop;
|
||||
for (const element of elements) {
|
||||
const metrics = getElementMetrics(element, containerRect, defaultLineHeight);
|
||||
|
||||
// 填充空白间隙
|
||||
if (lastBottom > 0 && elementTop > lastBottom) {
|
||||
currentLine = appendGapLines(fragment, lastBottom, elementTop, defaultLineHeight, currentLine);
|
||||
if (lastBottom > 0 && metrics.top > lastBottom) {
|
||||
currentLine = appendGapLines({
|
||||
fragment,
|
||||
gapStart: lastBottom,
|
||||
gapEnd: metrics.top,
|
||||
lineHeight: defaultLineHeight,
|
||||
startLine: currentLine
|
||||
});
|
||||
}
|
||||
|
||||
// 生成元素行号
|
||||
currentLine = appendElementLines(fragment, element, defaultLineHeight, currentLine);
|
||||
lastBottom = elementTop + element.offsetHeight;
|
||||
});
|
||||
currentLine = appendElementLines(fragment, metrics, currentLine);
|
||||
lastBottom = metrics.top + metrics.height;
|
||||
}
|
||||
|
||||
this.totalLines = currentLine - 1;
|
||||
gutter.innerHTML = '';
|
||||
gutter.appendChild(fragment);
|
||||
gutter.style.height = `${container.scrollHeight}px`;
|
||||
this.totalLines = currentLine - 1;
|
||||
}
|
||||
});
|
||||
|
||||
+29
-4
@@ -135,12 +135,12 @@
|
||||
|
||||
&-line-gutter {
|
||||
position: absolute;
|
||||
left: -60px;
|
||||
left: -80px;
|
||||
top: 0;
|
||||
width: 40px;
|
||||
width: 60px;
|
||||
user-select: none;
|
||||
opacity: 0.5;
|
||||
border-right: 1px solid var(--foreground);
|
||||
border-right: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
|
||||
@media (max-width: $tablet-max-width) {
|
||||
display: none;
|
||||
@@ -152,13 +152,38 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 10px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--foreground);
|
||||
font-family: Hack, Monaco, Consolas, monospace;
|
||||
transition: opacity 0.15s ease;
|
||||
opacity: 0.8;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.line-number--gap {
|
||||
opacity: 0.6;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.line-number--breakpoint {
|
||||
cursor: pointer;
|
||||
|
||||
.line-breakpoint {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -3px;
|
||||
color: #e51400;
|
||||
font-size: 0.875rem;
|
||||
transition: transform 0.15s ease, filter 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover .line-breakpoint {
|
||||
transform: scale(1.2);
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在新议题中引用
屏蔽一个用户