🚧 Refactor markdown preview extension

This commit is contained in:
2025-11-30 01:09:31 +08:00
parent 1ef5350b3f
commit 60d1494d45
34 changed files with 3109 additions and 6758 deletions

View File

@@ -8,6 +8,8 @@ import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascrip
import {html, htmlLanguage} from "@codemirror/lang-html";
import {StandardSQL} from "@codemirror/lang-sql";
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
import {Subscript, Superscript} from "@lezer/markdown";
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
import {javaLanguage} from "@codemirror/lang-java";
import {phpLanguage} from "@codemirror/lang-php";
import {cssLanguage} from "@codemirror/lang-css";
@@ -113,7 +115,7 @@ export const LANGUAGES: LanguageInfo[] = [
}),
new LanguageInfo("md", "Markdown", markdown({
base: markdownLanguage,
extensions: [],
extensions: [Subscript, Superscript, Highlight],
completeHTMLTags: true,
pasteURLAsLink: true,
htmlTagLanguage: html({

View File

@@ -64,5 +64,14 @@ export const blockquote = {
emoji = {
/** Emoji widget */
widget: 'cm-emoji'
},
/** Classes for mermaid diagram decorations. */
mermaid = {
/** Mermaid preview container */
preview: 'cm-mermaid-preview',
/** Loading state */
loading: 'cm-mermaid-loading',
/** Error state */
error: 'cm-mermaid-error'
}

View File

@@ -12,6 +12,9 @@ import { codeblockEnhanced } from './plugins/code-block-enhanced';
import { emoji } from './plugins/emoji';
import { horizontalRule } from './plugins/horizontal-rule';
import { inlineCode } from './plugins/inline-code';
import { subscriptSuperscript } from './plugins/subscript-superscript';
import { highlight } from './plugins/highlight';
import { mermaidPreview } from './plugins/mermaid';
/**
@@ -32,6 +35,9 @@ export const markdownExtensions: Extension = [
emoji(),
horizontalRule(),
inlineCode(),
subscriptSuperscript(),
highlight(),
mermaidPreview(),
];
export default markdownExtensions;

View File

@@ -8,6 +8,7 @@ import {
WidgetType
} from '@codemirror/view';
import { isCursorInRange } from '../util';
import { emojies } from '@/common/constant/emojies';
/**
* Emoji plugin that converts :emoji_name: to actual emoji characters.
@@ -17,133 +18,14 @@ import { isCursorInRange } from '../util';
* - Replaces them with actual emoji characters
* - Shows the original text when cursor is nearby
* - Uses RangeSetBuilder for optimal performance
* - Supports 1900+ emojis from the comprehensive emoji dictionary
*/
export const emoji = (): Extension => [emojiPlugin, baseTheme];
/**
* Emoji regex pattern for matching :emoji_name: syntax.
*/
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/g;
/**
* Common emoji mappings.
*/
const EMOJI_MAP: Map<string, string> = new Map([
// Smileys & Emotion
['smile', '😄'],
['smiley', '😃'],
['grin', '😁'],
['laughing', '😆'],
['satisfied', '😆'],
['sweat_smile', '😅'],
['rofl', '🤣'],
['joy', '😂'],
['slightly_smiling_face', '🙂'],
['upside_down_face', '🙃'],
['wink', '😉'],
['blush', '😊'],
['innocent', '😇'],
['smiling_face_with_three_hearts', '🥰'],
['heart_eyes', '😍'],
['star_struck', '🤩'],
['kissing_heart', '😘'],
['kissing', '😗'],
['relaxed', '☺️'],
['kissing_closed_eyes', '😚'],
['kissing_smiling_eyes', '😙'],
['smiling_face_with_tear', '🥲'],
['yum', '😋'],
['stuck_out_tongue', '😛'],
['stuck_out_tongue_winking_eye', '😜'],
['zany_face', '🤪'],
['stuck_out_tongue_closed_eyes', '😝'],
['money_mouth_face', '🤑'],
['hugs', '🤗'],
['hand_over_mouth', '🤭'],
['shushing_face', '🤫'],
['thinking', '🤔'],
['zipper_mouth_face', '🤐'],
['raised_eyebrow', '🤨'],
['neutral_face', '😐'],
['expressionless', '😑'],
['no_mouth', '😶'],
['smirk', '😏'],
['unamused', '😒'],
['roll_eyes', '🙄'],
['grimacing', '😬'],
['lying_face', '🤥'],
['relieved', '😌'],
['pensive', '😔'],
['sleepy', '😪'],
['drooling_face', '🤤'],
['sleeping', '😴'],
// Hearts
['heart', '❤️'],
['orange_heart', '🧡'],
['yellow_heart', '💛'],
['green_heart', '💚'],
['blue_heart', '💙'],
['purple_heart', '💜'],
['brown_heart', '🤎'],
['black_heart', '🖤'],
['white_heart', '🤍'],
// Gestures
['+1', '👍'],
['thumbsup', '👍'],
['-1', '👎'],
['thumbsdown', '👎'],
['fist', '✊'],
['facepunch', '👊'],
['punch', '👊'],
['wave', '👋'],
['clap', '👏'],
['raised_hands', '🙌'],
['pray', '🙏'],
['handshake', '🤝'],
// Nature
['sun', '☀️'],
['moon', '🌙'],
['star', '⭐'],
['fire', '🔥'],
['zap', '⚡'],
['sparkles', '✨'],
['tada', '🎉'],
['rocket', '🚀'],
['trophy', '🏆'],
// Symbols
['check', '✔️'],
['x', '❌'],
['warning', '⚠️'],
['bulb', '💡'],
['question', '❓'],
['exclamation', '❗'],
['heavy_check_mark', '✔️'],
// Common
['eyes', '👀'],
['eye', '👁️'],
['brain', '🧠'],
['muscle', '💪'],
['ok_hand', '👌'],
['point_right', '👉'],
['point_left', '👈'],
['point_up', '☝️'],
['point_down', '👇'],
]);
/**
* Reverse lookup map for emoji to name.
*/
const EMOJI_REVERSE_MAP = new Map<string, string>();
EMOJI_MAP.forEach((emoji, name) => {
if (!EMOJI_REVERSE_MAP.has(emoji)) {
EMOJI_REVERSE_MAP.set(emoji, name);
}
});
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/gi;
/**
* Emoji widget with optimized rendering.
@@ -190,8 +72,8 @@ function findEmojiMatches(text: string, offset: number): EmojiMatch[] {
EMOJI_REGEX.lastIndex = 0;
while ((match = EMOJI_REGEX.exec(text)) !== null) {
const name = match[1];
const emoji = EMOJI_MAP.get(name);
const name = match[1].toLowerCase();
const emoji = emojies[name];
if (emoji) {
matches.push({
@@ -285,26 +167,16 @@ const baseTheme = EditorView.baseTheme({
}
});
/**
* Add custom emoji to the map.
* @param name - Emoji name (without colons)
* @param emoji - Emoji character
*/
export function addEmoji(name: string, emoji: string): void {
EMOJI_MAP.set(name, emoji);
EMOJI_REVERSE_MAP.set(emoji, name);
}
/**
* Get all available emoji names.
*/
export function getEmojiNames(): string[] {
return Array.from(EMOJI_MAP.keys());
return Object.keys(emojies);
}
/**
* Get emoji by name.
*/
export function getEmoji(name: string): string | undefined {
return EMOJI_MAP.get(name);
return emojies[name.toLowerCase()];
}

View File

@@ -0,0 +1,115 @@
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange, invisibleDecoration } from '../util';
/**
* Highlight plugin using syntax tree.
*
* Uses the custom Highlight extension to detect:
* - Highlight: ==text== → renders as highlighted text
*
* Examples:
* - This is ==important== text → This is <mark>important</mark> text
* - Please ==review this section== carefully
*/
export const highlight = (): Extension => [
highlightPlugin,
baseTheme
];
/**
* Build decorations for highlight using syntax tree.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle Highlight nodes
if (type.name === 'Highlight') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the == characters)
const marks = node.getChildren('HighlightMark');
if (!cursorInRange && marks.length >= 2) {
// Hide the opening and closing == marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply highlight style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-highlight'
}).range(contentStart, contentEnd)
);
}
}
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* Plugin class with optimized update detection.
*/
class HighlightPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const highlightPlugin = ViewPlugin.fromClass(
HighlightPlugin,
{
decorations: (v) => v.decorations
}
);
/**
* Base theme for highlight.
* Uses mark decoration with a subtle background color.
*/
const baseTheme = EditorView.baseTheme({
'.cm-highlight': {
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
borderRadius: '2px',
}
});

View File

@@ -1,9 +1,11 @@
import { syntaxTree } from '@codemirror/language';
import { EditorState, StateField, Range } from '@codemirror/state';
import { EditorState, Range } from '@codemirror/state';
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import DOMPurify from 'dompurify';
@@ -15,34 +17,41 @@ interface EmbedBlockData {
content: string;
}
function extractHTMLBlocks(state: EditorState) {
/**
* Extract all HTML blocks from the document (both HTMLBlock and HTMLTag).
* Returns all blocks regardless of cursor position.
*/
function extractAllHTMLBlocks(state: EditorState): EmbedBlockData[] {
const blocks = new Array<EmbedBlockData>();
syntaxTree(state).iterate({
enter({ from, to, name }) {
if (name !== 'HTMLBlock') return;
if (isCursorInRange(state, [from, to])) return;
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
const html = state.sliceDoc(from, to);
const content = DOMPurify.sanitize(html);
blocks.push({
from,
to,
content
});
// Skip empty content after sanitization
if (!content.trim()) return;
blocks.push({ from, to, content });
}
});
return blocks;
}
// Decoration to hide the original HTML source code
const hideDecoration = Decoration.replace({});
function blockToDecoration(blocks: EmbedBlockData[]): Range<Decoration>[] {
/**
* Build decorations for HTML blocks.
* Only shows preview for blocks where cursor is not inside.
*/
function buildDecorations(state: EditorState, blocks: EmbedBlockData[]): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const block of blocks) {
// Skip if cursor is in range
if (isCursorInRange(state, [block.from, block.to])) continue;
// Hide the original HTML source code
decorations.push(hideDecoration.range(block.from, block.to));
decorations.push(Decoration.replace({}).range(block.from, block.to));
// Add the preview widget at the end
decorations.push(
@@ -53,25 +62,57 @@ function blockToDecoration(blocks: EmbedBlockData[]): Range<Decoration>[] {
);
}
return decorations;
return Decoration.set(decorations, true);
}
export const htmlBlock = StateField.define<DecorationSet>({
create(state) {
return Decoration.set(blockToDecoration(extractHTMLBlocks(state)), true);
},
update(value, tx) {
if (tx.docChanged || tx.selection) {
return Decoration.set(
blockToDecoration(extractHTMLBlocks(tx.state)),
true
);
}
return value.map(tx.changes);
},
provide(field) {
return EditorView.decorations.from(field);
/**
* Check if selection affects any HTML block (cursor moved in/out of a block).
*/
function selectionAffectsBlocks(
state: EditorState,
prevState: EditorState,
blocks: EmbedBlockData[]
): boolean {
for (const block of blocks) {
const wasInRange = isCursorInRange(prevState, [block.from, block.to]);
const isInRange = isCursorInRange(state, [block.from, block.to]);
if (wasInRange !== isInRange) return true;
}
return false;
}
/**
* ViewPlugin for HTML block preview.
* Uses smart caching to avoid unnecessary updates during text selection.
*/
class HTMLBlockPlugin {
decorations: DecorationSet;
blocks: EmbedBlockData[];
constructor(view: EditorView) {
this.blocks = extractAllHTMLBlocks(view.state);
this.decorations = buildDecorations(view.state, this.blocks);
}
update(update: ViewUpdate) {
// If document changed, re-extract all blocks
if (update.docChanged) {
this.blocks = extractAllHTMLBlocks(update.state);
this.decorations = buildDecorations(update.state, this.blocks);
return;
}
// If selection changed, only rebuild if cursor moved in/out of a block
if (update.selectionSet) {
if (selectionAffectsBlocks(update.state, update.startState, this.blocks)) {
this.decorations = buildDecorations(update.state, this.blocks);
}
}
}
}
const htmlBlockPlugin = ViewPlugin.fromClass(HTMLBlockPlugin, {
decorations: (v) => v.decorations
});
class HTMLBlockWidget extends WidgetType {
@@ -123,12 +164,19 @@ class HTMLBlockWidget extends WidgetType {
*/
const baseTheme = EditorView.baseTheme({
'.cm-html-block-widget': {
display: 'block',
display: 'inline-block',
position: 'relative',
width: '100%',
overflow: 'auto'
maxWidth: '100%',
overflow: 'auto',
verticalAlign: 'middle'
},
'.cm-html-block-content': {
display: 'inline-block'
},
// Ensure images are properly sized
'.cm-html-block-content img': {
maxWidth: '100%',
height: 'auto',
display: 'block'
},
'.cm-html-block-edit-btn': {
@@ -157,4 +205,4 @@ const baseTheme = EditorView.baseTheme({
});
// Export the extension with theme
export const htmlBlockExtension = [htmlBlock, baseTheme];
export const htmlBlockExtension = [htmlBlockPlugin, baseTheme];

View File

@@ -0,0 +1,402 @@
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { isCursorInRange } from '../util';
import mermaid from 'mermaid';
/**
* Mermaid diagram preview plugin.
*
* This plugin detects mermaid code blocks and renders them as SVG diagrams.
* Features:
* - Detects ```mermaid code blocks
* - Renders mermaid diagrams as inline SVG
* - Shows the original code when cursor is in the block
* - Caches rendered diagrams for performance
* - Supports theme switching (dark/light)
* - Supports all mermaid diagram types (flowchart, sequence, etc.)
*/
export const mermaidPreview = (): Extension => [
mermaidPlugin,
baseTheme
];
// Current mermaid theme
let currentMermaidTheme: 'default' | 'dark' = 'default';
let mermaidInitialized = false;
/**
* Detect the current theme from the DOM.
*/
function detectTheme(): 'default' | 'dark' {
const dataTheme = document.documentElement.getAttribute('data-theme');
if (dataTheme === 'light') {
return 'default';
}
if (dataTheme === 'dark') {
return 'dark';
}
// For 'auto', check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'default';
}
/**
* Initialize mermaid with the specified theme.
*/
function initMermaid(theme: 'default' | 'dark' = currentMermaidTheme) {
mermaid.initialize({
startOnLoad: false,
theme,
securityLevel: 'strict',
flowchart: {
htmlLabels: true,
curve: 'basis'
},
sequence: {
showSequenceNumbers: false
},
logLevel: 'error'
});
currentMermaidTheme = theme;
mermaidInitialized = true;
}
/**
* Information about a mermaid code block.
*/
interface MermaidBlockInfo {
/** Start position of the code block */
from: number;
/** End position of the code block */
to: number;
/** The mermaid code content */
code: string;
/** Unique ID for rendering */
id: string;
}
/**
* Cache for rendered mermaid diagrams.
* Key is `${theme}:${code}` to support theme-specific caching.
*/
const renderCache = new Map<string, string>();
/**
* Generate cache key for a diagram.
*/
function getCacheKey(code: string): string {
return `${currentMermaidTheme}:${code}`;
}
/**
* Generate a unique ID for a mermaid diagram.
*/
let idCounter = 0;
function generateId(): string {
return `mermaid-${Date.now()}-${idCounter++}`;
}
/**
* Extract mermaid code blocks from the visible ranges.
*/
function extractMermaidBlocks(view: EditorView): MermaidBlockInfo[] {
const blocks: MermaidBlockInfo[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: (node) => {
if (node.name !== 'FencedCode') return;
// Check if this is a mermaid code block
const codeInfoNode = node.node.getChild('CodeInfo');
if (!codeInfoNode) return;
const language = view.state.doc
.sliceString(codeInfoNode.from, codeInfoNode.to)
.trim()
.toLowerCase();
if (language !== 'mermaid') return;
// Extract the code content
const firstLine = view.state.doc.lineAt(node.from);
const lastLine = view.state.doc.lineAt(node.to);
const codeStart = firstLine.to + 1;
const codeEnd = lastLine.from - 1;
if (codeStart >= codeEnd) return;
const code = view.state.doc.sliceString(codeStart, codeEnd).trim();
if (code) {
blocks.push({
from: node.from,
to: node.to,
code,
id: generateId()
});
}
}
});
}
return blocks;
}
/**
* Mermaid preview widget that renders the diagram.
*/
class MermaidPreviewWidget extends WidgetType {
private svg: string | null = null;
private error: string | null = null;
private rendering = false;
constructor(
readonly code: string,
readonly blockId: string
) {
super();
// Check cache first (theme-specific)
const cached = renderCache.get(getCacheKey(code));
if (cached) {
this.svg = cached;
}
}
eq(other: MermaidPreviewWidget): boolean {
return other.code === this.code;
}
toDOM(view: EditorView): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-mermaid-preview';
if (this.svg) {
// Use cached SVG
container.innerHTML = this.svg;
this.setupSvgStyles(container);
} else if (this.error) {
// Show error
const errorEl = document.createElement('div');
errorEl.className = 'cm-mermaid-error';
errorEl.textContent = `Mermaid Error: ${this.error}`;
container.appendChild(errorEl);
} else {
// Show loading and start rendering
const loading = document.createElement('div');
loading.className = 'cm-mermaid-loading';
loading.textContent = 'Rendering diagram...';
container.appendChild(loading);
// Render asynchronously
if (!this.rendering) {
this.rendering = true;
this.renderMermaid(container, view);
}
}
return container;
}
private async renderMermaid(container: HTMLElement, view: EditorView) {
// Ensure mermaid is initialized with current theme
const theme = detectTheme();
if (!mermaidInitialized || currentMermaidTheme !== theme) {
initMermaid(theme);
}
try {
const { svg } = await mermaid.render(this.blockId, this.code);
// Cache the result with theme-specific key
renderCache.set(getCacheKey(this.code), svg);
this.svg = svg;
// Update the container
container.innerHTML = svg;
container.classList.remove('cm-mermaid-loading');
this.setupSvgStyles(container);
// Trigger a re-render to update decorations
view.dispatch({});
} catch (err) {
this.error = err instanceof Error ? err.message : String(err);
// Clear the loading state and show error
container.innerHTML = '';
const errorEl = document.createElement('div');
errorEl.className = 'cm-mermaid-error';
errorEl.textContent = `Mermaid Error: ${this.error}`;
container.appendChild(errorEl);
}
}
private setupSvgStyles(container: HTMLElement) {
const svg = container.querySelector('svg');
if (svg) {
svg.style.maxWidth = '100%';
svg.style.height = 'auto';
svg.removeAttribute('height');
}
}
ignoreEvent(): boolean {
return true;
}
}
/**
* Build decorations for mermaid code blocks.
*/
function buildMermaidDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
const blocks = extractMermaidBlocks(view);
for (const block of blocks) {
// Skip if cursor is in this code block
if (isCursorInRange(view.state, [block.from, block.to])) {
continue;
}
// Add preview widget after the code block
decorations.push(
Decoration.widget({
widget: new MermaidPreviewWidget(block.code, block.id),
side: 1
}).range(block.to)
);
}
return Decoration.set(decorations, true);
}
/**
* Track the last known theme for change detection.
*/
let lastTheme: 'default' | 'dark' = detectTheme();
/**
* Mermaid preview plugin class.
*/
class MermaidPreviewPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
// Initialize mermaid with detected theme
lastTheme = detectTheme();
initMermaid(lastTheme);
this.decorations = buildMermaidDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
// Check if theme changed
const currentTheme = detectTheme();
if (currentTheme !== lastTheme) {
lastTheme = currentTheme;
// Theme changed, clear cache and reinitialize
renderCache.clear();
initMermaid(currentTheme);
this.decorations = buildMermaidDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.docChanged || update.viewportChanged) {
this.decorations = buildMermaidDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildMermaidDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const mermaidPlugin = ViewPlugin.fromClass(MermaidPreviewPlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for mermaid preview.
*/
const baseTheme = EditorView.baseTheme({
'.cm-mermaid-preview': {
display: 'block',
backgroundColor: 'var(--cm-mermaid-bg, rgba(128, 128, 128, 0.05))',
borderRadius: '0.5rem',
overflow: 'auto',
textAlign: 'center'
},
'.cm-mermaid-preview svg': {
maxWidth: '100%',
height: 'auto'
},
'.cm-mermaid-loading': {
color: 'var(--cm-foreground)',
opacity: '0.6',
fontStyle: 'italic',
},
'.cm-mermaid-error': {
color: 'var(--cm-error, #ef4444)',
backgroundColor: 'var(--cm-error-bg, rgba(239, 68, 68, 0.1))',
borderRadius: '0.25rem',
fontSize: '0.875rem',
textAlign: 'left',
fontFamily: 'var(--voidraft-font-mono)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}
});
/**
* Clear the mermaid render cache.
* Call this when theme changes to re-render diagrams.
*/
export function clearMermaidCache(): void {
renderCache.clear();
}
/**
* Update mermaid theme based on current system theme.
* Call this when the application theme changes.
*/
export function refreshMermaidTheme(): void {
const theme = detectTheme();
if (theme !== currentMermaidTheme) {
renderCache.clear();
initMermaid(theme);
}
}
/**
* Force refresh all mermaid diagrams.
* Clears cache and reinitializes with current theme.
*/
export function forceRefreshMermaid(): void {
renderCache.clear();
initMermaid(detectTheme());
}

View File

@@ -0,0 +1,163 @@
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange, invisibleDecoration } from '../util';
/**
* Subscript and Superscript plugin using syntax tree.
*
* Uses lezer-markdown's Subscript and Superscript extensions to detect:
* - Superscript: ^text^ → renders as superscript
* - Subscript: ~text~ → renders as subscript
*
* Examples:
* - 19^th^ → 19ᵗʰ (superscript)
* - H~2~O → H₂O (subscript)
*/
export const subscriptSuperscript = (): Extension => [
subscriptSuperscriptPlugin,
baseTheme
];
/**
* Build decorations for subscript and superscript using syntax tree.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle Superscript nodes
if (type.name === 'Superscript') {
// Get the full content including marks
const fullContent = view.state.doc.sliceString(nodeFrom, nodeTo);
// Skip if this contains inline footnote pattern ^[
// This catches ^[text] being misinterpreted as superscript
if (fullContent.includes('^[') || fullContent.includes('[') && fullContent.includes(']')) {
return;
}
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the ^ characters)
const marks = node.getChildren('SuperscriptMark');
if (!cursorInRange && marks.length >= 2) {
// Get inner content between marks
const innerContent = view.state.doc.sliceString(marks[0].to, marks[marks.length - 1].from);
// Skip if inner content looks like footnote (starts with [ or contains brackets)
if (innerContent.startsWith('[') || innerContent.includes('[') || innerContent.includes(']')) {
return;
}
// Hide the opening and closing ^ marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply superscript style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-superscript'
}).range(contentStart, contentEnd)
);
}
}
}
// Handle Subscript nodes
if (type.name === 'Subscript') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the ~ characters)
const marks = node.getChildren('SubscriptMark');
if (!cursorInRange && marks.length >= 2) {
// Hide the opening and closing ~ marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply subscript style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-subscript'
}).range(contentStart, contentEnd)
);
}
}
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* Plugin class with optimized update detection.
*/
class SubscriptSuperscriptPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const subscriptSuperscriptPlugin = ViewPlugin.fromClass(
SubscriptSuperscriptPlugin,
{
decorations: (v) => v.decorations
}
);
/**
* Base theme for subscript and superscript.
* Uses mark decoration instead of widget to avoid layout issues.
*/
const baseTheme = EditorView.baseTheme({
'.cm-superscript': {
verticalAlign: 'super',
fontSize: '0.8em',
color: 'var(--cm-superscript-color, inherit)'
},
'.cm-subscript': {
verticalAlign: 'sub',
fontSize: '0.8em',
color: 'var(--cm-subscript-color, inherit)'
}
});

View File

@@ -0,0 +1,72 @@
/**
* Highlight extension for Lezer Markdown parser.
*
* Parses ==highlight== syntax similar to Obsidian/Mark style.
*
* Syntax: ==text== → renders as highlighted text
*
* Example:
* - This is ==important== text → This is <mark>important</mark> text
*/
import { MarkdownConfig } from '@lezer/markdown';
/**
* Highlight extension for Lezer Markdown.
*
* Defines:
* - Highlight: The container node for highlighted content
* - HighlightMark: The == delimiter marks
*/
export const Highlight: MarkdownConfig = {
defineNodes: [
{ name: 'Highlight' },
{ name: 'HighlightMark' }
],
parseInline: [{
name: 'Highlight',
parse(cx, next, pos) {
// Check for == delimiter (= is ASCII 61)
if (next !== 61 || cx.char(pos + 1) !== 61) {
return -1;
}
// Don't match === or more (horizontal rule or other constructs)
if (cx.char(pos + 2) === 61) {
return -1;
}
// Look for closing == delimiter
for (let i = pos + 2; i < cx.end - 1; i++) {
const char = cx.char(i);
// Don't allow newlines within highlight
if (char === 10) { // newline
return -1;
}
// Found potential closing ==
if (char === 61 && cx.char(i + 1) === 61) {
// Make sure it's not ===
if (i + 2 < cx.end && cx.char(i + 2) === 61) {
continue;
}
// Create the element with marks
const element = cx.elt('Highlight', pos, i + 2, [
cx.elt('HighlightMark', pos, pos + 2),
cx.elt('HighlightMark', i, i + 2)
]);
return cx.addElement(element);
}
}
return -1;
},
// Parse after emphasis to avoid conflicts with other inline parsers
after: 'Emphasis'
}]
};
export default Highlight;