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", "name": "theme-terminal",
"private": true, "private": true,
"version": "1.1.9", "version": "1.1.9a",
"description": "A terminal like theme for Halo.", "description": "A terminal like theme for Halo.",
"scripts": { "scripts": {
"dev": "vite build --watch", "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; const DEFAULT_LINE_HEIGHT_RATIO = 1.54;
// 工具函数:创建行号元素 // ============ 类型定义 ============
function createLineSpan(lineNum: number, top: number, height: number, isGap = false): HTMLSpanElement {
const span = document.createElement('span'); interface LineSpanOptions {
span.className = isGap ? 'line-number line-number--gap' : 'line-number'; lineNum: number;
span.style.top = `${top}px`; top: number;
span.style.height = `${height}px`; height: number;
span.style.lineHeight = `${height}px`; isGap?: boolean;
span.textContent = String(lineNum); isBreakpoint?: boolean;
return span;
} }
// 工具函数:获取元素的 padding interface ElementMetrics {
function getElementPadding(style: CSSStyleDeclaration): { top: number; bottom: number } { top: number;
return { height: number;
top: parseFloat(style.paddingTop) || 0, paddingTop: number;
bottom: parseFloat(style.paddingBottom) || 0 lineHeight: number;
}; lines: number;
isHeading: boolean;
} }
// 工具函数:计算默认行高 interface GapLinesOptions {
function getDefaultLineHeight(element: HTMLElement): number { fragment: DocumentFragment;
const style = getComputedStyle(element); gapStart: number;
const fontSize = parseFloat(style.fontSize); gapEnd: number;
return parseFloat(style.lineHeight) || fontSize * DEFAULT_LINE_HEIGHT_RATIO; lineHeight: number;
} startLine: number;
// 工具函数:判断是否为非文元素
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 { interface LineNumbersState {
@@ -103,38 +43,145 @@ interface LineNumbersState {
destroy(): void; 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 => ({ export const lineNumbers = (): LineNumbersState => ({
totalLines: 0, totalLines: 0,
resizeObserver: null, resizeObserver: null,
init() { init() {
const content = (this.$el as HTMLElement).querySelector('.post-content') as HTMLElement; const content = this.$el?.querySelector('.post-content') as HTMLElement;
if (!content) return; if (!content) return;
// 延迟计算确保 DOM 渲染完成 const recalculate = () => this.calculateLineNumbers();
requestAnimationFrame(() => {
setTimeout(() => this.calculateLineNumbers(), 50);
});
// 监听内容区域大小变化 // 延迟计算确保 DOM 渲染完成
requestAnimationFrame(() => setTimeout(recalculate, 50));
// 监听内容区域大小变化(带防抖)
if (typeof ResizeObserver !== 'undefined') { 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); this.resizeObserver.observe(content);
} }
// 窗口大小变化时重新计算(备用) // 窗口大小变化时重新计算
let resizeTimer: number; let windowResizeTimer: number;
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
clearTimeout(resizeTimer); clearTimeout(windowResizeTimer);
resizeTimer = window.setTimeout(() => this.calculateLineNumbers(), 200); windowResizeTimer = window.setTimeout(recalculate, 200);
}); });
// 监听图片加载 // 监听图片加载(使用 once 避免重复触发)
content.querySelectorAll('img').forEach((img: HTMLImageElement) => { content.querySelectorAll('img').forEach((img: HTMLImageElement) => {
if (!img.complete) { if (!img.complete) {
img.addEventListener('load', () => this.calculateLineNumbers()); img.addEventListener('load', recalculate, { once: true });
img.addEventListener('error', () => this.calculateLineNumbers()); img.addEventListener('error', recalculate, { once: true });
} }
}); });
}, },
@@ -146,35 +193,39 @@ export const lineNumbers = (): LineNumbersState => ({
calculateLineNumbers() { calculateLineNumbers() {
const content = this.$el?.querySelector('.post-content') as HTMLElement; const content = this.$el?.querySelector('.post-content') as HTMLElement;
const gutter = this.$el?.querySelector('.post-line-gutter') as HTMLElement; const gutter = this.$el?.querySelector('.post-line-gutter') as HTMLElement;
if (!content || !gutter) return; if (!content || !gutter) return;
const container = (content.querySelector(':scope > div') || content) as HTMLElement;
const containerRect = container.getBoundingClientRect();
const defaultLineHeight = getDefaultLineHeight(content); 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(); const fragment = document.createDocumentFragment();
let currentLine = 1; let currentLine = 1;
let lastBottom = 0; let lastBottom = 0;
blockElements.forEach((el: Element) => { for (const element of elements) {
const element = el as HTMLElement; const metrics = getElementMetrics(element, containerRect, defaultLineHeight);
const elementTop = element.offsetTop;
// 填充空白间隙 if (lastBottom > 0 && metrics.top > lastBottom) {
if (lastBottom > 0 && elementTop > lastBottom) { currentLine = appendGapLines({
currentLine = appendGapLines(fragment, lastBottom, elementTop, defaultLineHeight, currentLine); fragment,
gapStart: lastBottom,
gapEnd: metrics.top,
lineHeight: defaultLineHeight,
startLine: currentLine
});
} }
// 生成元素行号 currentLine = appendElementLines(fragment, metrics, currentLine);
currentLine = appendElementLines(fragment, element, defaultLineHeight, currentLine); lastBottom = metrics.top + metrics.height;
lastBottom = elementTop + element.offsetHeight; }
});
this.totalLines = currentLine - 1; gutter.innerHTML = '';
gutter.appendChild(fragment); gutter.appendChild(fragment);
gutter.style.height = `${container.scrollHeight}px`; gutter.style.height = `${container.scrollHeight}px`;
this.totalLines = currentLine - 1;
} }
}); });
+29 -4
查看文件
@@ -135,12 +135,12 @@
&-line-gutter { &-line-gutter {
position: absolute; position: absolute;
left: -60px; left: -80px;
top: 0; top: 0;
width: 40px; width: 60px;
user-select: none; user-select: none;
opacity: 0.5; 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) { @media (max-width: $tablet-max-width) {
display: none; display: none;
@@ -152,13 +152,38 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
padding-right: 10px;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--foreground); color: var(--foreground);
font-family: Hack, Monaco, Consolas, monospace; font-family: Hack, Monaco, Consolas, monospace;
transition: opacity 0.15s ease;
opacity: 0.8;
&:hover {
opacity: 1;
}
} }
.line-number--gap { .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 repo: https://git.dev.cm/theme-terminal
settingName: "theme-terminal-setting" settingName: "theme-terminal-setting"
configMapName: "theme-terminal-configMap" configMapName: "theme-terminal-configMap"
version: 1.1.9 version: 1.1.10
require: ">=2.22.0" require: ">=2.22.0"