🚧 Refactor markdown preview extension

This commit is contained in:
2025-11-29 19:24:20 +08:00
parent 8d9bcdad7e
commit 3521e5787b
20 changed files with 1463 additions and 1149 deletions

View File

@@ -1,4 +1,4 @@
import { Extension } from '@codemirror/state';
import { Extension, Range } from '@codemirror/state';
import {
ViewPlugin,
DecorationSet,
@@ -6,92 +6,148 @@ import {
EditorView,
ViewUpdate
} from '@codemirror/view';
import {
isCursorInRange,
invisibleDecoration,
iterateTreeInVisibleRanges,
editorLines
} from '../util';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange } from '../util';
import { codeblock as classes } from '../classes';
/**
* Ixora code block plugin.
* Code block types to match in the syntax tree.
*/
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const;
/**
* Code block plugin with optimized decoration building.
*
* This plugin allows to:
* - Add default styling to code blocks
* - Customize visibility of code block markers and language
* This plugin:
* - Adds styling to code blocks (begin/end markers)
* - Hides code markers and language info when cursor is outside
*/
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
const codeBlockPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = decorateCodeBlocks(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
update.selectionSet
)
this.decorations = decorateCodeBlocks(update.view);
}
},
{ decorations: (v) => v.decorations }
);
/**
* Build code block decorations.
* Uses array + Decoration.set() for automatic sorting.
*/
function buildCodeBlockDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
const visited = new Set<string>();
function decorateCodeBlocks(view: EditorView) {
const widgets: Array<ReturnType<Decoration['range']>> = [];
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to, node }) => {
if (!['FencedCode', 'CodeBlock'].includes(type.name)) return;
editorLines(view, from, to).forEach((block, i) => {
const lineDec = Decoration.line({
class: [
classes.widget,
i === 0
? classes.widgetBegin
: block.to === to
? classes.widgetEnd
: ''
].join(' ')
});
widgets.push(lineDec.range(block.from));
});
if (isCursorInRange(view.state, [from, to])) return;
const codeBlock = node.toTree();
codeBlock.iterate({
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
switch (type.name) {
case 'CodeInfo':
case 'CodeMark':
// eslint-disable-next-line no-case-declarations
const decRange = invisibleDecoration.range(
from + nodeFrom,
from + nodeTo
);
widgets.push(decRange);
break;
}
// 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;
}
});
}
});
return Decoration.set(widgets, true);
// Avoid processing the same code block multiple times
const key = `${nodeFrom}:${nodeTo}`;
if (visited.has(key)) return;
visited.add(key);
const cursorInBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Add line decorations for each line in the code block
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
let positionClass = '';
if (lineNum === startLine.number) {
positionClass = classes.widgetBegin;
} else if (lineNum === endLine.number) {
positionClass = classes.widgetEnd;
}
decorations.push(
Decoration.line({
class: `${classes.widget} ${positionClass}`.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
)
);
}
}
});
}
}
});
}
// Use Decoration.set with sort=true to handle unsorted ranges
return Decoration.set(decorations, true);
}
/**
* Base theme for code block plugin.
* Code block plugin class with optimized update detection.
*/
class CodeBlockPlugin {
decorations: DecorationSet;
private lastSelection: number = -1;
constructor(view: EditorView) {
this.decorations = buildCodeBlockDecorations(view);
this.lastSelection = view.state.selection.main.head;
}
update(update: ViewUpdate) {
const docChanged = update.docChanged;
const viewportChanged = update.viewportChanged;
const selectionChanged = update.selectionSet;
// Optimization: check if selection moved to a different line
if (selectionChanged && !docChanged && !viewportChanged) {
const newHead = update.state.selection.main.head;
const oldHead = this.lastSelection;
const oldLine = update.startState.doc.lineAt(oldHead);
const newLine = update.state.doc.lineAt(newHead);
if (oldLine.number === newLine.number) {
this.lastSelection = newHead;
return;
}
}
if (docChanged || viewportChanged || selectionChanged) {
this.decorations = buildCodeBlockDecorations(update.view);
this.lastSelection = update.state.selection.main.head;
}
}
}
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for code blocks.
*/
const baseTheme = EditorView.baseTheme({
['.' + classes.widget]: {
backgroundColor: '#CCC7'
[`.${classes.widget}`]: {
backgroundColor: 'var(--cm-codeblock-bg, rgba(128, 128, 128, 0.1))'
},
['.' + classes.widgetBegin]: {
[`.${classes.widgetBegin}`]: {
borderRadius: '5px 5px 0 0'
},
['.' + classes.widgetEnd]: {
[`.${classes.widgetEnd}`]: {
borderRadius: '0 0 5px 5px'
}
});