/** * Code block handler and theme. */ import { Decoration, EditorView, WidgetType } from '@codemirror/view'; import { invisibleDecoration, RangeTuple } from '../util'; import { SyntaxNode } from '@lezer/common'; import { BuildContext } from './types'; const DECO_CODEBLOCK_LINE = Decoration.line({ class: 'cm-codeblock' }); const DECO_CODEBLOCK_BEGIN = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin' }); const DECO_CODEBLOCK_END = Decoration.line({ class: 'cm-codeblock cm-codeblock-end' }); const DECO_CODEBLOCK_SINGLE = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin cm-codeblock-end' }); const ICON_COPY = ``; const ICON_CHECK = ``; class CodeBlockInfoWidget extends WidgetType { constructor(readonly from: number, readonly to: number, readonly language: string | null) { super(); } eq(other: CodeBlockInfoWidget) { return other.from === this.from && other.language === this.language; } toDOM(view: EditorView): HTMLElement { const container = document.createElement('span'); container.className = 'cm-code-block-info'; if (this.language) { const lang = document.createElement('span'); lang.className = 'cm-code-block-lang'; lang.textContent = this.language; container.append(lang); } const btn = document.createElement('button'); btn.className = 'cm-code-block-copy-btn'; btn.title = 'Copy'; btn.innerHTML = ICON_COPY; btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const text = view.state.doc.sliceString(this.from, this.to); const lines = text.split('\n'); const content = lines.length >= 2 ? lines.slice(1, -1).join('\n') : ''; if (content) { navigator.clipboard.writeText(content).then(() => { btn.innerHTML = ICON_CHECK; setTimeout(() => { btn.innerHTML = ICON_COPY; }, 1500); }); } }); btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); }); container.append(btn); return container; } ignoreEvent() { return true; } } /** * Handle FencedCode / CodeBlock node. */ export function handleCodeBlock( ctx: BuildContext, nf: number, nt: number, node: SyntaxNode, inCursor: boolean, ranges: RangeTuple[] ): void { if (ctx.seen.has(nf)) return; ctx.seen.add(nf); ranges.push([nf, nt]); // When cursor/selection is in this code block, don't add any decorations // This allows the selection background to be visible if (inCursor) return; const startLine = ctx.view.state.doc.lineAt(nf); const endLine = ctx.view.state.doc.lineAt(nt); // Add background decorations for each line for (let num = startLine.number; num <= endLine.number; num++) { const line = ctx.view.state.doc.line(num); let deco = DECO_CODEBLOCK_LINE; if (startLine.number === endLine.number) deco = DECO_CODEBLOCK_SINGLE; else if (num === startLine.number) deco = DECO_CODEBLOCK_BEGIN; else if (num === endLine.number) deco = DECO_CODEBLOCK_END; ctx.items.push({ from: line.from, to: line.from, deco }); } // Add language info widget and hide code marks const codeInfo = node.getChild('CodeInfo'); const codeMarks = node.getChildren('CodeMark'); const language = codeInfo ? ctx.view.state.doc.sliceString(codeInfo.from, codeInfo.to).trim() : null; ctx.items.push({ from: startLine.to, to: startLine.to, deco: Decoration.widget({ widget: new CodeBlockInfoWidget(nf, nt, language), side: 1 }), priority: 1 }); if (codeInfo) ctx.items.push({ from: codeInfo.from, to: codeInfo.to, deco: invisibleDecoration }); for (const mark of codeMarks) ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration }); } /** * Theme for code blocks. */ export const codeBlockTheme = EditorView.baseTheme({ '.cm-codeblock': { backgroundColor: 'var(--cm-codeblock-bg)', fontFamily: 'inherit' }, '.cm-codeblock-begin': { borderTopLeftRadius: 'var(--cm-codeblock-radius)', borderTopRightRadius: 'var(--cm-codeblock-radius)', position: 'relative' }, '.cm-codeblock-end': { borderBottomLeftRadius: 'var(--cm-codeblock-radius)', borderBottomRightRadius: 'var(--cm-codeblock-radius)' }, '.cm-code-block-info': { position: 'absolute', right: '8px', top: '50%', transform: 'translateY(-50%)', display: 'inline-flex', alignItems: 'center', gap: '0.5em', zIndex: '5', opacity: '0.5', transition: 'opacity 0.15s' }, '.cm-code-block-info:hover': { opacity: '1' }, '.cm-code-block-lang': { color: 'var(--cm-codeblock-lang, var(--cm-foreground))', textTransform: 'lowercase', userSelect: 'none' }, '.cm-code-block-copy-btn': { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: '0.15em', border: 'none', borderRadius: '2px', background: 'transparent', color: 'var(--cm-codeblock-lang, var(--cm-foreground))', cursor: 'pointer', opacity: '0.7', transition: 'opacity 0.15s, background 0.15s' }, '.cm-code-block-copy-btn:hover': { opacity: '1', background: 'rgba(128, 128, 128, 0.2)' }, '.cm-code-block-copy-btn svg': { width: '1em', height: '1em' } });