From 913e2958e60588f4039a19d7ebbc340791b2634c Mon Sep 17 00:00:00 2001 From: rohow Date: Fri, 30 Jan 2026 12:43:03 +0800 Subject: [PATCH] feat(lineNumbers): refactor line number generation and enhance element metrics handling --- package.json | 2 +- src/alpine/lineNumbers.ts | 287 ++++++++++++++++++++++---------------- src/styles/post.scss | 33 ++++- theme.yaml | 2 +- 4 files changed, 200 insertions(+), 124 deletions(-) diff --git a/package.json b/package.json index 892cad8..9652c32 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/alpine/lineNumbers.ts b/src/alpine/lineNumbers.ts index fffe600..acba739 100644 --- a/src/alpine/lineNumbers.ts +++ b/src/alpine/lineNumbers.ts @@ -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; } }); diff --git a/src/styles/post.scss b/src/styles/post.scss index 83b8b2e..c9defad 100644 --- a/src/styles/post.scss +++ b/src/styles/post.scss @@ -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); + } } } diff --git a/theme.yaml b/theme.yaml index ded9d45..17ad549 100644 --- a/theme.yaml +++ b/theme.yaml @@ -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"