import I18nKey from "@i18n/i18nKey"; import { i18n } from "@i18n/translation"; /** * 代码块折叠(长代码块自动折叠 + 折叠/展开按钮)。 * 运行时为 `.expressive-code` 代码块注入折叠按钮,行数达到阈值时默认折叠。 */ const DEFAULT_CONFIG = { enabled: true, lineThreshold: 20, previewLines: 10, defaultCollapsed: true, } as const; function positiveInteger(value: string | undefined, fallback: number): number { const parsed = Number.parseInt(value ?? "", 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } function readConfig(carrier: HTMLElement | null) { const dataset = carrier?.dataset ?? {}; return { enabled: dataset.codeCollapseEnabled === undefined ? DEFAULT_CONFIG.enabled : dataset.codeCollapseEnabled === "true", lineThreshold: positiveInteger( dataset.codeCollapseLineThreshold, DEFAULT_CONFIG.lineThreshold, ), previewLines: positiveInteger( dataset.codeCollapsePreviewLines, DEFAULT_CONFIG.previewLines, ), defaultCollapsed: dataset.codeCollapseDefaultCollapsed === undefined ? DEFAULT_CONFIG.defaultCollapsed : dataset.codeCollapseDefaultCollapsed === "true", }; } export class CodeBlockCollapser { private processedBlocks = new WeakSet(); private config: ReturnType; constructor() { this.config = readConfig(document.getElementById("config-carrier")); } setupCodeBlocks(root: ParentNode = document) { if (!this.config.enabled) return; requestAnimationFrame(() => { for (const codeBlock of root.querySelectorAll(".expressive-code")) { if (this.processedBlocks.has(codeBlock)) continue; this.processedBlocks.add(codeBlock); this.enhanceCodeBlock(codeBlock as HTMLElement); } }); } private enhanceCodeBlock(codeBlock: HTMLElement) { // Code Tree owns a fixed-height scroll viewport. Collapsing an embedded // block creates a second, conflicting height model and leaves dead space. if (codeBlock.closest(".m3-code-tree")) return; const frame = codeBlock.querySelector(":scope > .frame"); const pre = frame?.querySelector(":scope > pre"); const lineCount = pre?.querySelectorAll(".ec-line").length ?? 0; if (!frame || !pre || lineCount < this.config.lineThreshold) return; const collapsed = this.config.defaultCollapsed; codeBlock.classList.add( "collapsible", "auto-collapsible", collapsed ? "collapsed" : "expanded", ); codeBlock.dataset.codeLineCount = String(lineCount); codeBlock.style.setProperty( "--code-collapse-preview-height", this.calculatePreviewHeight(pre), ); const button = this.createToggleButton(); frame.append(button); this.updateButton(button, collapsed); button.addEventListener("click", () => this.toggleCollapse(codeBlock, button), ); } private calculatePreviewHeight(pre: Element): string { const line = pre.querySelector(".ec-line"); const lineHeight = Number.parseFloat( line ? getComputedStyle(line).lineHeight : "", ); const preStyle = getComputedStyle(pre); const verticalPadding = Number.parseFloat(preStyle.paddingTop) + Number.parseFloat(preStyle.paddingBottom); const height = (Number.isFinite(lineHeight) ? lineHeight : 24) * this.config.previewLines + (Number.isFinite(verticalPadding) ? verticalPadding : 0); return `${height}px`; } private createToggleButton(): HTMLButtonElement { const button = document.createElement("button"); button.className = "collapse-toggle-btn"; button.type = "button"; button.innerHTML = ` `; return button; } private updateButton(button: HTMLButtonElement, collapsed: boolean) { button.setAttribute("aria-expanded", String(!collapsed)); const label = i18n( collapsed ? I18nKey.codeBlockExpand : I18nKey.codeBlockCollapse, ); button.setAttribute("aria-label", label); button.title = label; } private toggleCollapse(codeBlock: HTMLElement, button: HTMLButtonElement) { const collapsed = !codeBlock.classList.contains("collapsed"); codeBlock.classList.toggle("collapsed", collapsed); codeBlock.classList.toggle("expanded", !collapsed); this.updateButton(button, collapsed); } } let codeBlockCollapser: CodeBlockCollapser | undefined; /** * Enhances code blocks inside the supplied Markdown root. Swup lifecycle * ownership remains with `markdown-runtime` so this module stays lazy. */ export function initCodeBlockCollapsing(root: ParentNode = document): void { codeBlockCollapser ??= new CodeBlockCollapser(); codeBlockCollapser.setupCodeBlocks(root); }