import { Decoration, EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { syntaxTree } from "@codemirror/language"; import { RangeSetBuilder } from "@codemirror/state"; import type { DecorationSet } from "@codemirror/view"; import { WidgetType } from "@codemirror/view"; import { type Extension } from "@codemirror/state"; import { FRONTMATTER_LANGUAGE_LABEL, isFrontmatterNode } from "./markdown/frontmatter"; const fallbackMonospaceCodeFont = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace"; const codeFontFamily = `var(--pm-code-font, ${fallbackMonospaceCodeFont})`; const editorFontSize = "var(--writer-editor-font-size, 16px)"; const codeBlockDecorations = (view: EditorView) => { const builder = new RangeSetBuilder(); // If there are multiple visible ranges, it's possible to see // the same code block multiple times const visited = new Set(); for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ from, to, enter: (node) => { const isFencedCode = node.name === "FencedCode"; const isFrontmatter = isFrontmatterNode(node); if (isFencedCode || isFrontmatter) { const key = JSON.stringify([node.from, node.to]); if (visited.has(key)) return; visited.add(key); let lang = ""; let code = ""; if (isFrontmatter) { lang = FRONTMATTER_LANGUAGE_LABEL; const contentNode = node.node.getChild("FrontmatterContent"); code = contentNode ? view.state.doc.sliceString(contentNode.from, contentNode.to) : ""; } else { const codeInfoNode = node.node.getChild("CodeInfo"); if (codeInfoNode) { lang = view.state.doc.sliceString(codeInfoNode.from, codeInfoNode.to).toUpperCase(); } const firstLine = view.state.doc.lineAt(node.from); const codeStart = firstLine.to + 1; const codeEnd = Math.max(codeStart, node.to - 4); code = view.state.doc.sliceString(codeStart, codeEnd); } for (let pos = node.from; pos <= node.to; ) { const line = view.state.doc.lineAt(pos); const isFirstLine = pos === node.from; const isLastLine = line.to >= node.to; builder.add( line.from, line.from, Decoration.line({ class: `cm-fenced-code-line ${ isFirstLine ? "cm-fenced-code-line-first" : "" } ${isLastLine ? "cm-fenced-code-line-last" : ""}`, }), ); if (isFirstLine) { builder.add( line.from, line.from, Decoration.widget({ widget: new CodeBlockInfoWidget(lang, code), }), ); } pos = line.to + 1; } } }, }); } return builder.finish(); }; class CodeBlockInfoWidget extends WidgetType { constructor( readonly lang: string, readonly code: string, ) { super(); } eq(other: CodeBlockInfoWidget) { return other.lang === this.lang && other.code === this.code; } toDOM() { const container = document.createElement("span"); container.className = "cm-code-block-info"; container.setAttribute("contenteditable", "false"); const langContainer = document.createElement("span"); langContainer.className = "cm-code-block-lang-container"; langContainer.innerText = this.lang; container.appendChild(langContainer); const copyButton = document.createElement("button"); copyButton.className = "cm-code-block-copy-button"; // Copy icon from Lucide copyButton.innerHTML = ` `; copyButton.onclick = () => { void navigator.clipboard.writeText(this.code); }; container.appendChild(copyButton); return container; } ignoreEvent(_event: Event): boolean { return true; } } export const codeBlockDecorationsExtension: Extension = ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(view: EditorView) { this.decorations = codeBlockDecorations(view); } update(update: ViewUpdate) { if (update.docChanged || update.viewportChanged) { this.decorations = codeBlockDecorations(update.view); } } }, { decorations: (v) => v.decorations, }, ); const codeFenceThemeSpec = { ".cm-fenced-code-line": { display: "block", marginLeft: "6px", backgroundColor: "var(--pm-code-background-color)", fontFamily: codeFontFamily, fontSize: editorFontSize, fontVariantLigatures: "none", fontFeatureSettings: '"calt" 0', fontKerning: "none", }, // In case the active line color changes ".cm-activeLine.cm-fenced-code-line": { backgroundColor: "var(--pm-code-background-color)", }, ".cm-fenced-code-line-first": { borderTopLeftRadius: "0.4rem", borderTopRightRadius: "0.4rem", }, ".cm-fenced-code-line-last": { borderBottomLeftRadius: "0.4rem", borderBottomRightRadius: "0.4rem", }, ".cm-code-block-info": { float: "right", padding: "0.2rem", display: "flex", gap: "0.3rem", alignItems: "center", }, ".cm-code-block-lang-container": { fontSize: "0.8rem", color: "var(--pm-muted-color)", }, ".cm-code-block-copy-button": { display: "flex", alignItems: "center", justifyContent: "center", border: "none", padding: "0.2rem", borderRadius: "0.2rem", cursor: "pointer", backgroundColor: "var(--pm-code-btn-background-color)", color: "var(--pm-muted-color)", }, ".cm-code-block-copy-button:hover": { backgroundColor: "var(--pm-code-btn-hover-background-color)", }, ".cm-code-block-copy-button svg": { width: "16px", height: "16px", }, }; export const codeFenceTheme = EditorView.theme(codeFenceThemeSpec); export const __testCodeFenceExtension = { codeFenceThemeSpec, };