feat(lineNumbers): refactor line number generation and enhance element metrics handling
Theme CD / Package Theme (release) Successful in 33s
Theme CD / Release (release) Successful in 12s

这个提交包含在:
rohow
2026-01-30 12:43:03 +08:00
未验证
父节点 69891119d8
当前提交 913e2958e6
修改 4 个文件,包含 200 行新增124 行删除
+1 -1
查看文件
@@ -1,7 +1,7 @@
{
"name": "theme-terminal",
"private": true,
"version": "1.1.9",
"version": "1.1.9a",
"description": "A terminal like theme for Halo.",
"scripts": {
"dev": "vite build --watch",
+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);
}
}
}
+1 -1
查看文件
@@ -13,5 +13,5 @@ spec:
repo: https://git.dev.cm/theme-terminal
settingName: "theme-terminal-setting"
configMapName: "theme-terminal-configMap"
version: 1.1.9
version: 1.1.10
require: ">=2.22.0"