比较提交
1 次代码提交
+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"
|
||||||
|
|||||||
在新议题中引用
屏蔽一个用户