🚧 Refactor markdown preview extension

This commit is contained in:
2025-12-01 00:00:05 +08:00
parent 60d1494d45
commit dd3dd4ddb2
12 changed files with 487 additions and 769 deletions

View File

@@ -4,154 +4,315 @@ import {
DecorationSet,
Decoration,
EditorView,
ViewUpdate
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange } from '../util';
import { codeblock as classes } from '../classes';
import { codeblock as classes, codeblockEnhanced as enhancedClasses } from '../classes';
/**
* Code block types to match in the syntax tree.
*/
/** Code block node types in syntax tree */
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const;
/** Copy button icon SVGs (size controlled by CSS) */
const ICON_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
const ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
/** Cache for code block metadata */
interface CodeBlockData {
from: number;
to: number;
language: string;
content: string;
}
/**
* Code block plugin with optimized decoration building.
*
* This plugin:
* - Adds styling to code blocks (begin/end markers)
* - Hides code markers and language info when cursor is outside
* Code block extension with language label and copy button.
*
* Features:
* - Adds background styling to code blocks
* - Shows language label + copy button when language is specified
* - Hides markers when cursor is outside block
* - Optimized with viewport-only rendering
*/
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
/**
* Build code block decorations.
* Uses array + Decoration.set() for automatic sorting.
* Widget for displaying language label and copy button.
* Uses ignoreEvent: true to prevent editor focus changes.
*/
function buildCodeBlockDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
const visited = new Set<string>();
class CodeBlockInfoWidget extends WidgetType {
constructor(readonly data: CodeBlockData) {
super();
}
eq(other: CodeBlockInfoWidget): boolean {
return other.data.from === this.data.from &&
other.data.language === this.data.language;
}
toDOM(): HTMLElement {
const container = document.createElement('span');
container.className = enhancedClasses.info;
container.dataset.codeFrom = String(this.data.from);
// Language label
const lang = document.createElement('span');
lang.className = enhancedClasses.lang;
lang.textContent = this.data.language;
// Copy button
const btn = document.createElement('button');
btn.className = enhancedClasses.copyBtn;
btn.title = 'Copy';
btn.innerHTML = ICON_COPY;
btn.dataset.codeContent = this.data.content;
container.append(lang, btn);
return container;
}
// Critical: ignore all events to prevent editor focus
ignoreEvent(): boolean {
return true;
}
}
/**
* Extract language from code block node.
*/
function getLanguage(view: EditorView, node: any, offset: number): string | null {
let lang: string | null = null;
node.toTree().iterate({
enter: ({ type, from, to }) => {
if (type.name === 'CodeInfo') {
lang = view.state.doc.sliceString(offset + from, offset + to).trim();
}
}
});
return lang;
}
/**
* Extract code content (without fence markers).
*/
function getCodeContent(view: EditorView, from: number, to: number): string {
const lines = view.state.doc.sliceString(from, to).split('\n');
return lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
}
/**
* Build decorations for visible code blocks.
*/
function buildDecorations(view: EditorView): { decorations: DecorationSet; blocks: Map<number, CodeBlockData> } {
const decorations: Range<Decoration>[] = [];
const blocks = new Map<number, CodeBlockData>();
const seen = new Set<string>();
// Process only visible ranges
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (!CODE_BLOCK_TYPES.includes(type.name as typeof CODE_BLOCK_TYPES[number])) {
return;
}
if (!CODE_BLOCK_TYPES.includes(type.name as any)) return;
// Avoid processing the same code block multiple times
const key = `${nodeFrom}:${nodeTo}`;
if (visited.has(key)) return;
visited.add(key);
if (seen.has(key)) return;
seen.add(key);
const cursorInBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]);
const inBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]);
if (inBlock) return;
// Add line decorations for each line in the code block
const language = getLanguage(view, node, nodeFrom);
const startLine = view.state.doc.lineAt(nodeFrom);
const endLine = view.state.doc.lineAt(nodeTo);
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
const line = view.state.doc.line(lineNum);
// Determine line position class(es)
const isFirst = lineNum === startLine.number;
const isLast = lineNum === endLine.number;
// Build class list - a single line block needs both begin and end classes
const positionClasses: string[] = [];
if (isFirst) positionClasses.push(classes.widgetBegin);
if (isLast) positionClasses.push(classes.widgetEnd);
// Line decorations
for (let num = startLine.number; num <= endLine.number; num++) {
const line = view.state.doc.line(num);
const pos: string[] = [];
if (num === startLine.number) pos.push(classes.widgetBegin);
if (num === endLine.number) pos.push(classes.widgetEnd);
decorations.push(
Decoration.line({
class: `${classes.widget} ${positionClasses.join(' ')}`.trim()
class: `${classes.widget} ${pos.join(' ')}`.trim()
}).range(line.from)
);
}
// Hide code markers when cursor is outside the block
if (!cursorInBlock) {
const codeBlock = node.toTree();
codeBlock.iterate({
enter: ({ type: childType, from: childFrom, to: childTo }) => {
if (childType.name === 'CodeInfo' || childType.name === 'CodeMark') {
decorations.push(
Decoration.replace({}).range(
nodeFrom + childFrom,
nodeFrom + childTo
)
);
}
// Info widget (only if language specified)
if (language) {
const content = getCodeContent(view, nodeFrom, nodeTo);
const data: CodeBlockData = { from: nodeFrom, to: nodeTo, language, content };
blocks.set(nodeFrom, data);
decorations.push(
Decoration.widget({
widget: new CodeBlockInfoWidget(data),
side: 1
}).range(startLine.to)
);
}
});
}
// Hide markers
node.toTree().iterate({
enter: ({ type: t, from: f, to: t2 }) => {
if (t.name === 'CodeInfo' || t.name === 'CodeMark') {
decorations.push(Decoration.replace({}).range(nodeFrom + f, nodeFrom + t2));
}
}
});
}
});
});
}
// Use Decoration.set with sort=true to handle unsorted ranges
return Decoration.set(decorations, true);
return { decorations: Decoration.set(decorations, true), blocks };
}
/**
* Code block plugin class with optimized update detection.
* Handle copy button click.
*/
class CodeBlockPlugin {
function handleCopyClick(btn: HTMLButtonElement): void {
const content = btn.dataset.codeContent;
if (!content) return;
navigator.clipboard.writeText(content).then(() => {
btn.innerHTML = ICON_CHECK;
setTimeout(() => {
btn.innerHTML = ICON_COPY;
}, 1500);
});
}
/**
* Code block plugin with optimized updates.
*/
class CodeBlockPluginClass {
decorations: DecorationSet;
private lastSelection: number = -1;
blocks: Map<number, CodeBlockData>;
private lastHead = -1;
constructor(view: EditorView) {
this.decorations = buildCodeBlockDecorations(view);
this.lastSelection = view.state.selection.main.head;
const result = buildDecorations(view);
this.decorations = result.decorations;
this.blocks = result.blocks;
this.lastHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
const docChanged = update.docChanged;
const viewportChanged = update.viewportChanged;
const selectionChanged = update.selectionSet;
update(update: ViewUpdate): void {
const { docChanged, viewportChanged, selectionSet } = update;
// Optimization: check if selection moved to a different line
if (selectionChanged && !docChanged && !viewportChanged) {
// Skip rebuild if cursor stayed on same line
if (selectionSet && !docChanged && !viewportChanged) {
const newHead = update.state.selection.main.head;
const oldHead = this.lastSelection;
const oldLine = update.startState.doc.lineAt(this.lastHead).number;
const newLine = update.state.doc.lineAt(newHead).number;
const oldLine = update.startState.doc.lineAt(oldHead);
const newLine = update.state.doc.lineAt(newHead);
if (oldLine.number === newLine.number) {
this.lastSelection = newHead;
if (oldLine === newLine) {
this.lastHead = newHead;
return;
}
}
if (docChanged || viewportChanged || selectionChanged) {
this.decorations = buildCodeBlockDecorations(update.view);
this.lastSelection = update.state.selection.main.head;
if (docChanged || viewportChanged || selectionSet) {
const result = buildDecorations(update.view);
this.decorations = result.decorations;
this.blocks = result.blocks;
this.lastHead = update.state.selection.main.head;
}
}
}
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPlugin, {
decorations: (v) => v.decorations
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, {
decorations: (v) => v.decorations,
eventHandlers: {
// Handle copy button clicks without triggering editor focus
mousedown(e: MouseEvent, view: EditorView) {
const target = e.target as HTMLElement;
// Check if clicked on copy button or its SVG child
const btn = target.closest(`.${enhancedClasses.copyBtn}`) as HTMLButtonElement;
if (btn) {
e.preventDefault();
e.stopPropagation();
handleCopyClick(btn);
return true;
}
// Check if clicked on info container (language label)
if (target.closest(`.${enhancedClasses.info}`)) {
e.preventDefault();
e.stopPropagation();
return true;
}
return false;
}
}
});
/**
* Base theme for code blocks.
* Uses CSS variables from variables.css for consistent theming.
*/
const baseTheme = EditorView.baseTheme({
[`.${classes.widget}`]: {
backgroundColor: 'var(--cm-codeblock-bg)',
backgroundColor: 'var(--cm-codeblock-bg)'
},
[`.${classes.widgetBegin}`]: {
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
borderTopRightRadius: 'var(--cm-codeblock-radius)'
borderTopRightRadius: 'var(--cm-codeblock-radius)',
position: 'relative',
borderTop: '1px solid var(--text-primary)'
},
[`.${classes.widgetEnd}`]: {
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
borderBottomRightRadius: 'var(--cm-codeblock-radius)'
borderBottomRightRadius: 'var(--cm-codeblock-radius)',
borderBottom: '1px solid var(--text-primary)'
},
// Info container
[`.${enhancedClasses.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'
},
[`.${enhancedClasses.info}:hover`]: {
opacity: '1'
},
// Language label
[`.${enhancedClasses.lang}`]: {
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
textTransform: 'lowercase',
userSelect: 'none'
},
// Copy button
[`.${enhancedClasses.copyBtn}`]: {
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'
},
[`.${enhancedClasses.copyBtn}:hover`]: {
opacity: '1',
background: 'rgba(128, 128, 128, 0.2)'
},
[`.${enhancedClasses.copyBtn} svg`]: {
width: '1em',
height: '1em'
}
});