🚧 Refactor markdown preview extension
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
402
frontend/src/views/editor/extensions/markdown/plugins/mermaid.ts
Normal file
402
frontend/src/views/editor/extensions/markdown/plugins/mermaid.ts
Normal 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());
|
||||
}
|
||||
@@ -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)'
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user