diff --git a/src/alpine/lineNumbers.ts b/src/alpine/lineNumbers.ts new file mode 100644 index 0000000..3ea036b --- /dev/null +++ b/src/alpine/lineNumbers.ts @@ -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`; + } +}); diff --git a/src/main.ts b/src/main.ts index d22c7dc..f83f20a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import Alpine from 'alpinejs' import {upvote} from './alpine/upvote' import {themeMode} from './alpine/themeMode' import {menu} from './alpine/menu' +import {lineNumbers} from './alpine/lineNumbers' import {typewriterEffect} from './utils' window.Alpine = Alpine @@ -13,6 +14,7 @@ window.Alpine = Alpine Alpine.data('upvote', upvote) Alpine.data('themeMode', themeMode) Alpine.data('menu', menu) +Alpine.data('lineNumbers', lineNumbers) Alpine.start() diff --git a/src/styles/main.scss b/src/styles/main.scss index a6f9c3b..7b47a78 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -173,15 +173,6 @@ blockquote { padding-right: 0; } - &:before { - content: '”'; - font-family: Georgia, serif; - font-size: 3.875rem; - position: absolute; - left: -40px; - top: -20px; - } - p:first-of-type { margin-top: 0; } diff --git a/src/styles/post.scss b/src/styles/post.scss index 425def2..83b8b2e 100644 --- a/src/styles/post.scss +++ b/src/styles/post.scss @@ -128,9 +128,43 @@ } } - &-content { + &-body { + position: relative; margin-top: 30px; + } + + &-line-gutter { + position: absolute; + left: -60px; + top: 0; + width: 40px; + user-select: none; + opacity: 0.5; + border-right: 1px solid var(--foreground); + + @media (max-width: $tablet-max-width) { + display: none; + } + + .line-number { + position: absolute; + right: 8px; + display: flex; + align-items: center; + justify-content: flex-end; + font-size: 0.75rem; + color: var(--foreground); + font-family: Hack, Monaco, Consolas, monospace; + } + + .line-number--gap { + opacity: 0.6; + } + } + + &-content { font-family: Hack, Monaco, Consolas, 'Ubuntu Mono', PingHei, 'PingFang SC', 'Microsoft YaHei', monospace; + line-height: 1.54; } &-cover { diff --git a/templates/index.html b/templates/index.html index 98aaa85..f2357fe 100644 --- a/templates/index.html +++ b/templates/index.html @@ -12,7 +12,7 @@

- Post Title + Post Title