🚧 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

@@ -53,11 +53,7 @@
/* Markdown 代码块样式 - 暗色主题 */ /* Markdown 代码块样式 - 暗色主题 */
--cm-codeblock-bg: rgba(46, 51, 69, 0.8); --cm-codeblock-bg: rgba(46, 51, 69, 0.8);
--cm-codeblock-radius: 0.4rem; --cm-codeblock-radius: 0.4rem;
--cm-codeblock-lang-color: oklch(65% 0.03 257);
--cm-codeblock-btn-bg: oklch(28% 0.02 253);
--cm-codeblock-btn-hover-bg: oklch(38% 0.035 257);
--cm-codeblock-btn-color: oklch(65% 0.03 257);
--cm-codeblock-btn-hover-color: oklch(85% 0.015 255);
/* Markdown 内联代码样式 */ /* Markdown 内联代码样式 */
--cm-inline-code-bg: oklch(28% 0.02 255); --cm-inline-code-bg: oklch(28% 0.02 255);
@@ -68,12 +64,6 @@
/* Markdown 高亮样式 */ /* Markdown 高亮样式 */
--cm-highlight-background: rgba(250, 204, 21, 0.35); --cm-highlight-background: rgba(250, 204, 21, 0.35);
/* Markdown 脚注样式 */
--cm-footnote-ref-color: #818cf8;
--cm-footnote-ref-hover-bg: rgba(129, 140, 248, 0.15);
--cm-footnote-undefined-color: #f87171;
--cm-footnote-def-color: #818cf8;
} }
/* 亮色主题 */ /* 亮色主题 */
@@ -125,11 +115,6 @@
/* Markdown 代码块样式 - 亮色主题 */ /* Markdown 代码块样式 - 亮色主题 */
--cm-codeblock-bg: oklch(92.9% 0.013 255.508); --cm-codeblock-bg: oklch(92.9% 0.013 255.508);
--cm-codeblock-radius: 0.4rem; --cm-codeblock-radius: 0.4rem;
--cm-codeblock-lang-color: oklch(37.2% 0.044 257.287);
--cm-codeblock-btn-bg: oklch(86.9% 0.022 252.894);
--cm-codeblock-btn-hover-bg: oklch(70.4% 0.04 256.788);
--cm-codeblock-btn-color: oklch(37.2% 0.044 257.287);
--cm-codeblock-btn-hover-color: oklch(20% 0.044 257);
/* Markdown 内联代码样式 */ /* Markdown 内联代码样式 */
--cm-inline-code-bg: oklch(92.9% 0.013 255.508); --cm-inline-code-bg: oklch(92.9% 0.013 255.508);
@@ -140,12 +125,6 @@
/* Markdown 高亮样式 */ /* Markdown 高亮样式 */
--cm-highlight-background: rgba(253, 224, 71, 0.45); --cm-highlight-background: rgba(253, 224, 71, 0.45);
/* Markdown 脚注样式 */
--cm-footnote-ref-color: #6366f1;
--cm-footnote-ref-hover-bg: rgba(99, 102, 241, 0.15);
--cm-footnote-undefined-color: #ef4444;
--cm-footnote-def-color: #6366f1;
} }
/* 跟随系统的浅色偏好 */ /* 跟随系统的浅色偏好 */
@@ -198,11 +177,6 @@
/* Markdown 代码块样式 - 亮色主题 */ /* Markdown 代码块样式 - 亮色主题 */
--cm-codeblock-bg: oklch(92.9% 0.013 255.508); --cm-codeblock-bg: oklch(92.9% 0.013 255.508);
--cm-codeblock-radius: 0.4rem; --cm-codeblock-radius: 0.4rem;
--cm-codeblock-lang-color: oklch(37.2% 0.044 257.287);
--cm-codeblock-btn-bg: oklch(86.9% 0.022 252.894);
--cm-codeblock-btn-hover-bg: oklch(70.4% 0.04 256.788);
--cm-codeblock-btn-color: oklch(37.2% 0.044 257.287);
--cm-codeblock-btn-hover-color: oklch(20% 0.044 257);
/* Markdown 内联代码样式 */ /* Markdown 内联代码样式 */
--cm-inline-code-bg: oklch(92.9% 0.013 255.508); --cm-inline-code-bg: oklch(92.9% 0.013 255.508);
@@ -213,11 +187,5 @@
/* Markdown 高亮样式 */ /* Markdown 高亮样式 */
--cm-highlight-background: rgba(253, 224, 71, 0.45); --cm-highlight-background: rgba(253, 224, 71, 0.45);
/* Markdown 脚注样式 */
--cm-footnote-ref-color: #6366f1;
--cm-footnote-ref-hover-bg: rgba(99, 102, 241, 0.15);
--cm-footnote-undefined-color: #ef4444;
--cm-footnote-def-color: #6366f1;
} }
} }

View File

@@ -6,7 +6,6 @@ import { useConfigStore } from './configStore';
import { useEditorStore } from './editorStore'; import { useEditorStore } from './editorStore';
import type { ThemeColors } from '@/views/editor/theme/types'; import type { ThemeColors } from '@/views/editor/theme/types';
import { cloneThemeColors, FALLBACK_THEME_NAME, themePresetList, themePresetMap } from '@/views/editor/theme/presets'; import { cloneThemeColors, FALLBACK_THEME_NAME, themePresetList, themePresetMap } from '@/views/editor/theme/presets';
import { refreshMermaidTheme } from '@/views/editor/extensions/markdown/plugins/mermaid';
type ThemeOption = { name: string; type: ThemeType }; type ThemeOption = { name: string; type: ThemeType };
@@ -139,10 +138,6 @@ export const useThemeStore = defineStore('theme', () => {
const refreshEditorTheme = () => { const refreshEditorTheme = () => {
applyThemeToDOM(currentTheme.value); applyThemeToDOM(currentTheme.value);
// Refresh mermaid diagrams with new theme
refreshMermaidTheme();
const editorStore = useEditorStore(); const editorStore = useEditorStore();
editorStore?.applyThemeSettings(); editorStore?.applyThemeSettings();
}; };

View File

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

View File

@@ -60,18 +60,35 @@ export const blockquote = {
/** Copy button */ /** Copy button */
copyBtn: 'cm-code-block-copy-btn' copyBtn: 'cm-code-block-copy-btn'
}, },
/** Classes for emoji decorations. */ /** Classes for table decorations. */
emoji = { table = {
/** Emoji widget */ /** Table container wrapper */
widget: 'cm-emoji' wrapper: 'cm-table-wrapper',
}, /** The rendered table element */
/** Classes for mermaid diagram decorations. */ table: 'cm-table',
mermaid = { /** Table header row */
/** Mermaid preview container */ header: 'cm-table-header',
preview: 'cm-mermaid-preview', /** Table header cell */
/** Loading state */ headerCell: 'cm-table-header-cell',
loading: 'cm-mermaid-loading', /** Table body */
/** Error state */ body: 'cm-table-body',
error: 'cm-mermaid-error' /** Table data row */
row: 'cm-table-row',
/** Table data cell */
cell: 'cm-table-cell',
/** Cell alignment classes */
alignLeft: 'cm-table-align-left',
alignCenter: 'cm-table-align-center',
alignRight: 'cm-table-align-right',
/** Cell content wrapper (for editing) */
cellContent: 'cm-table-cell-content',
/** Resize handle */
resizeHandle: 'cm-table-resize-handle',
/** Active editing cell */
cellActive: 'cm-table-cell-active',
/** Row hover state */
rowHover: 'cm-table-row-hover',
/** Selected cell */
cellSelected: 'cm-table-cell-selected'
} }

View File

@@ -8,13 +8,12 @@ import { image } from './plugins/image';
import { links } from './plugins/link'; import { links } from './plugins/link';
import { lists } from './plugins/list'; import { lists } from './plugins/list';
import { headingSlugField } from './state/heading-slug'; import { headingSlugField } from './state/heading-slug';
import { codeblockEnhanced } from './plugins/code-block-enhanced';
import { emoji } from './plugins/emoji'; import { emoji } from './plugins/emoji';
import { horizontalRule } from './plugins/horizontal-rule'; import { horizontalRule } from './plugins/horizontal-rule';
import { inlineCode } from './plugins/inline-code'; import { inlineCode } from './plugins/inline-code';
import { subscriptSuperscript } from './plugins/subscript-superscript'; import { subscriptSuperscript } from './plugins/subscript-superscript';
import { highlight } from './plugins/highlight'; import { highlight } from './plugins/highlight';
import { mermaidPreview } from './plugins/mermaid'; import { table } from './plugins/table';
/** /**
@@ -30,14 +29,12 @@ export const markdownExtensions: Extension = [
links(), links(),
image(), image(),
htmlBlockExtension, htmlBlockExtension,
// Enhanced features
codeblockEnhanced(),
emoji(), emoji(),
horizontalRule(), horizontalRule(),
inlineCode(), inlineCode(),
subscriptSuperscript(), subscriptSuperscript(),
highlight(), highlight(),
mermaidPreview(), table(),
]; ];
export default markdownExtensions; export default markdownExtensions;

View File

@@ -35,6 +35,9 @@ function buildBlockQuoteDecorations(view: EditorView): DecorationSet {
const cursorInBlockquote = isCursorInRange(view.state, [node.from, node.to]); const cursorInBlockquote = isCursorInRange(view.state, [node.from, node.to]);
// Only add decorations when cursor is outside the blockquote
// This allows selection highlighting to be visible when editing
if (!cursorInBlockquote) {
// Add line decoration for each line in the blockquote // Add line decoration for each line in the blockquote
const startLine = view.state.doc.lineAt(node.from).number; const startLine = view.state.doc.lineAt(node.from).number;
const endLine = view.state.doc.lineAt(node.to).number; const endLine = view.state.doc.lineAt(node.to).number;
@@ -50,7 +53,6 @@ function buildBlockQuoteDecorations(view: EditorView): DecorationSet {
} }
// Hide quote marks when cursor is outside // Hide quote marks when cursor is outside
if (!cursorInBlockquote) {
const cursor = node.node.cursor(); const cursor = node.node.cursor();
cursor.iterate((child) => { cursor.iterate((child) => {
if (child.type.name === 'QuoteMark') { if (child.type.name === 'QuoteMark') {

View File

@@ -1,207 +0,0 @@
import { Extension } from '@codemirror/state';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange } from '../util';
/**
* Enhanced code block plugin with copy button and language label.
*
* This plugin adds:
* - Language label display in the top-right corner
* - Copy to clipboard button
* - Enhanced visual styling for code blocks
*/
export const codeblockEnhanced = (): Extension => [
codeBlockEnhancedPlugin,
enhancedTheme
];
/**
* Widget for code block info bar (language + copy button).
*/
class CodeBlockInfoWidget extends WidgetType {
constructor(
readonly language: string,
readonly code: string
) {
super();
}
eq(other: CodeBlockInfoWidget) {
return other.language === this.language && other.code === this.code;
}
toDOM(): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-code-block-info';
// Language label
if (this.language) {
const langLabel = document.createElement('span');
langLabel.className = 'cm-code-block-lang';
langLabel.textContent = this.language.toUpperCase();
container.appendChild(langLabel);
}
// Copy button
const copyButton = document.createElement('button');
copyButton.className = 'cm-code-block-copy-btn';
copyButton.title = 'Copy';
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
</svg>
`;
copyButton.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(this.code);
// Visual feedback
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>
`;
setTimeout(() => {
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
</svg>
`;
}, 2000);
} catch (err) {
console.error('Failed to copy code:', err);
}
};
container.appendChild(copyButton);
return container;
}
ignoreEvent(): boolean {
return true;
}
}
/**
* Plugin to add enhanced code block features.
*/
class CodeBlockEnhancedPlugin {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = this.buildDecorations(update.view);
}
}
private buildDecorations(view: EditorView): DecorationSet {
const widgets: Array<ReturnType<Decoration['range']>> = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: (node) => {
if (node.name !== 'FencedCode') return;
// Skip if cursor is in this code block
if (isCursorInRange(view.state, [node.from, node.to])) return;
// Extract language
let language = '';
const codeInfoNode = node.node.getChild('CodeInfo');
if (codeInfoNode) {
language = view.state.doc
.sliceString(codeInfoNode.from, codeInfoNode.to)
.trim();
}
// Extract code content (excluding fence markers)
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;
const code = view.state.doc.sliceString(codeStart, codeEnd);
// Add info widget at the first line
const infoWidget = Decoration.widget({
widget: new CodeBlockInfoWidget(language, code),
side: 1
});
widgets.push(infoWidget.range(firstLine.to));
}
});
}
return Decoration.set(widgets, true);
}
}
const codeBlockEnhancedPlugin = ViewPlugin.fromClass(CodeBlockEnhancedPlugin, {
decorations: (v) => v.decorations
});
/**
* Enhanced theme for code blocks.
* Uses CSS variables from variables.css for consistent theming.
*/
const enhancedTheme = EditorView.baseTheme({
'.cm-code-block-info': {
float: 'right',
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
padding: '0.15rem 0.3rem',
opacity: '0.6',
transition: 'opacity 0.15s ease'
},
'.cm-code-block-info:hover': {
opacity: '1'
},
'.cm-code-block-lang': {
fontFamily: 'var(--voidraft-font-mono)',
fontSize: '0.7rem',
fontWeight: '500',
letterSpacing: '0.02em',
color: 'var(--cm-codeblock-lang-color)'
},
'.cm-code-block-copy-btn': {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
backgroundColor: 'transparent',
borderRadius: '0.2rem',
cursor: 'pointer',
color: 'var(--cm-codeblock-btn-color)',
transition: 'background-color 0.15s ease, color 0.15s ease'
},
'.cm-code-block-copy-btn:hover': {
// backgroundColor: 'var(--cm-codeblock-btn-hover-bg)',
color: 'var(--cm-codeblock-btn-hover-color)'
},
'.cm-code-block-copy-btn svg': {
width: '14px',
height: '14px'
}
});

View File

@@ -4,154 +4,315 @@ import {
DecorationSet, DecorationSet,
Decoration, Decoration,
EditorView, EditorView,
ViewUpdate ViewUpdate,
WidgetType
} from '@codemirror/view'; } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language'; import { syntaxTree } from '@codemirror/language';
import { isCursorInRange } from '../util'; import { isCursorInRange } from '../util';
import { codeblock as classes } from '../classes'; import { codeblock as classes, codeblockEnhanced as enhancedClasses } from '../classes';
/** /** Code block node types in syntax tree */
* Code block types to match in the syntax tree.
*/
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const; 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. * Code block extension with language label and copy button.
* *
* This plugin: * Features:
* - Adds styling to code blocks (begin/end markers) * - Adds background styling to code blocks
* - Hides code markers and language info when cursor is outside * - 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]; export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
/** /**
* Build code block decorations. * Widget for displaying language label and copy button.
* Uses array + Decoration.set() for automatic sorting. * Uses ignoreEvent: true to prevent editor focus changes.
*/ */
function buildCodeBlockDecorations(view: EditorView): DecorationSet { class CodeBlockInfoWidget extends WidgetType {
const decorations: Range<Decoration>[] = []; constructor(readonly data: CodeBlockData) {
const visited = new Set<string>(); 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) { for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({ syntaxTree(view.state).iterate({
from, from,
to, to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (!CODE_BLOCK_TYPES.includes(type.name as typeof CODE_BLOCK_TYPES[number])) { if (!CODE_BLOCK_TYPES.includes(type.name as any)) return;
return;
}
// Avoid processing the same code block multiple times
const key = `${nodeFrom}:${nodeTo}`; const key = `${nodeFrom}:${nodeTo}`;
if (visited.has(key)) return; if (seen.has(key)) return;
visited.add(key); 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 startLine = view.state.doc.lineAt(nodeFrom);
const endLine = view.state.doc.lineAt(nodeTo); const endLine = view.state.doc.lineAt(nodeTo);
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) { // Line decorations
const line = view.state.doc.line(lineNum); for (let num = startLine.number; num <= endLine.number; num++) {
const line = view.state.doc.line(num);
// Determine line position class(es) const pos: string[] = [];
const isFirst = lineNum === startLine.number; if (num === startLine.number) pos.push(classes.widgetBegin);
const isLast = lineNum === endLine.number; if (num === endLine.number) pos.push(classes.widgetEnd);
// 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);
decorations.push( decorations.push(
Decoration.line({ Decoration.line({
class: `${classes.widget} ${positionClasses.join(' ')}`.trim() class: `${classes.widget} ${pos.join(' ')}`.trim()
}).range(line.from) }).range(line.from)
); );
} }
// Hide code markers when cursor is outside the block // Info widget (only if language specified)
if (!cursorInBlock) { if (language) {
const codeBlock = node.toTree(); const content = getCodeContent(view, nodeFrom, nodeTo);
codeBlock.iterate({ const data: CodeBlockData = { from: nodeFrom, to: nodeTo, language, content };
enter: ({ type: childType, from: childFrom, to: childTo }) => { blocks.set(nodeFrom, data);
if (childType.name === 'CodeInfo' || childType.name === 'CodeMark') {
decorations.push( decorations.push(
Decoration.replace({}).range( Decoration.widget({
nodeFrom + childFrom, widget: new CodeBlockInfoWidget(data),
nodeFrom + childTo 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 { decorations: Decoration.set(decorations, true), blocks };
return Decoration.set(decorations, true);
} }
/** /**
* 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; decorations: DecorationSet;
private lastSelection: number = -1; blocks: Map<number, CodeBlockData>;
private lastHead = -1;
constructor(view: EditorView) { constructor(view: EditorView) {
this.decorations = buildCodeBlockDecorations(view); const result = buildDecorations(view);
this.lastSelection = view.state.selection.main.head; this.decorations = result.decorations;
this.blocks = result.blocks;
this.lastHead = view.state.selection.main.head;
} }
update(update: ViewUpdate) { update(update: ViewUpdate): void {
const docChanged = update.docChanged; const { docChanged, viewportChanged, selectionSet } = update;
const viewportChanged = update.viewportChanged;
const selectionChanged = update.selectionSet;
// Optimization: check if selection moved to a different line // Skip rebuild if cursor stayed on same line
if (selectionChanged && !docChanged && !viewportChanged) { if (selectionSet && !docChanged && !viewportChanged) {
const newHead = update.state.selection.main.head; 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); if (oldLine === newLine) {
const newLine = update.state.doc.lineAt(newHead); this.lastHead = newHead;
if (oldLine.number === newLine.number) {
this.lastSelection = newHead;
return; return;
} }
} }
if (docChanged || viewportChanged || selectionChanged) { if (docChanged || viewportChanged || selectionSet) {
this.decorations = buildCodeBlockDecorations(update.view); const result = buildDecorations(update.view);
this.lastSelection = update.state.selection.main.head; this.decorations = result.decorations;
this.blocks = result.blocks;
this.lastHead = update.state.selection.main.head;
} }
} }
} }
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPlugin, { const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, {
decorations: (v) => v.decorations 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. * Base theme for code blocks.
* Uses CSS variables from variables.css for consistent theming.
*/ */
const baseTheme = EditorView.baseTheme({ const baseTheme = EditorView.baseTheme({
[`.${classes.widget}`]: { [`.${classes.widget}`]: {
backgroundColor: 'var(--cm-codeblock-bg)', backgroundColor: 'var(--cm-codeblock-bg)'
}, },
[`.${classes.widgetBegin}`]: { [`.${classes.widgetBegin}`]: {
borderTopLeftRadius: 'var(--cm-codeblock-radius)', 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}`]: { [`.${classes.widgetEnd}`]: {
borderBottomLeftRadius: 'var(--cm-codeblock-radius)', 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'
} }
}); });

View File

@@ -32,6 +32,12 @@ function buildInlineCodeDecorations(view: EditorView): DecorationSet {
enter: ({ type, from: nodeFrom, to: nodeTo }) => { enter: ({ type, from: nodeFrom, to: nodeTo }) => {
if (type.name !== 'InlineCode') return; if (type.name !== 'InlineCode') return;
const cursorInCode = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Skip background decoration when cursor is in the code
// This allows selection highlighting to be visible when editing
if (cursorInCode) return;
// Get the actual code content (excluding backticks) // Get the actual code content (excluding backticks)
const text = view.state.doc.sliceString(nodeFrom, nodeTo); const text = view.state.doc.sliceString(nodeFrom, nodeTo);
@@ -55,12 +61,10 @@ function buildInlineCodeDecorations(view: EditorView): DecorationSet {
// Only add decoration if there's actual content // Only add decoration if there's actual content
if (codeStart < codeEnd) { if (codeStart < codeEnd) {
const cursorInCode = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Add mark decoration for the code content // Add mark decoration for the code content
decorations.push( decorations.push(
Decoration.mark({ Decoration.mark({
class: cursorInCode ? 'cm-inline-code cm-inline-code-active' : 'cm-inline-code' class: 'cm-inline-code'
}).range(codeStart, codeEnd) }).range(codeStart, codeEnd)
); );
} }
@@ -103,10 +107,6 @@ const baseTheme = EditorView.baseTheme({
padding: '0.1rem 0.3rem', padding: '0.1rem 0.3rem',
fontFamily: 'var(--voidraft-font-mono)', fontFamily: 'var(--voidraft-font-mono)',
fontSize: '0.9em' fontSize: '0.9em'
},
'.cm-inline-code-active': {
// Slightly different style when cursor is inside
backgroundColor: 'var(--cm-inline-code-bg)'
} }
}); });

View File

@@ -1,402 +0,0 @@
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,145 @@
import { Extension, Range } from '@codemirror/state';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange } from '../util';
import { extractTablesFromState } from '../state/table';
/**
* Table styling extension for Markdown.
*
* - Adds background styling and hides syntax when cursor is OUTSIDE table
* - Shows raw markdown with NO styling when cursor is INSIDE table (edit mode)
*/
export const table = (): Extension => [tablePlugin, baseTheme];
/** Line decorations - only applied when NOT editing */
const headerLine = Decoration.line({ attributes: { class: 'cm-table-header' } });
const delimiterLine = Decoration.line({ attributes: { class: 'cm-table-delimiter' } });
const dataLine = Decoration.line({ attributes: { class: 'cm-table-data' } });
/** Mark to hide pipe characters */
const pipeHidden = Decoration.mark({ attributes: { class: 'cm-table-pipe-hidden' } });
/** Mark to hide delimiter row content */
const delimiterHidden = Decoration.mark({ attributes: { class: 'cm-table-delimiter-hidden' } });
/** Delimiter row regex */
const DELIMITER_REGEX = /^\s*\|?\s*[-:]+/;
/**
* Build decorations for tables.
* Only adds decorations when cursor is OUTSIDE the table.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
const tables = extractTablesFromState(view.state);
for (const table of tables) {
// Skip all decorations if cursor is inside table (edit mode)
if (isCursorInRange(view.state, [table.from, table.to])) {
continue;
}
const startLine = view.state.doc.lineAt(table.from);
const endLine = view.state.doc.lineAt(table.to);
const lines = table.rawText.split('\n');
for (let i = 0; i < lines.length; i++) {
const lineNum = startLine.number + i;
if (lineNum > endLine.number) break;
const line = view.state.doc.line(lineNum);
const text = lines[i];
const isDelimiter = i === 1 && DELIMITER_REGEX.test(text);
// Add line decoration
if (i === 0) {
decorations.push(headerLine.range(line.from));
} else if (isDelimiter) {
decorations.push(delimiterLine.range(line.from));
} else {
decorations.push(dataLine.range(line.from));
}
// Hide syntax elements
if (isDelimiter) {
// Hide entire delimiter content
decorations.push(delimiterHidden.range(line.from, line.to));
} else {
// Hide pipe characters
for (let j = 0; j < text.length; j++) {
if (text[j] === '|') {
decorations.push(pipeHidden.range(line.from + j, line.from + j + 1));
}
}
}
}
}
return Decoration.set(decorations, true);
}
/**
* Table ViewPlugin.
*/
const tablePlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
}
}
},
{ decorations: v => v.decorations }
);
/**
* Base theme for table styling.
*/
const baseTheme = EditorView.baseTheme({
// Header row
'.cm-table-header': {
backgroundColor: 'var(--cm-table-header-bg, rgba(128, 128, 128, 0.12))',
borderTopLeftRadius: '4px',
borderTopRightRadius: '4px'
},
// Delimiter row
'.cm-table-delimiter': {
backgroundColor: 'var(--cm-table-bg, rgba(128, 128, 128, 0.06))',
lineHeight: '0.5'
},
// Data rows
'.cm-table-data': {
backgroundColor: 'var(--cm-table-bg, rgba(128, 128, 128, 0.06))'
},
'.cm-table-data:last-of-type': {
borderBottomLeftRadius: '4px',
borderBottomRightRadius: '4px'
},
// Hidden pipe characters
'.cm-table-pipe-hidden': {
fontSize: '0',
color: 'transparent'
},
// Hidden delimiter content
'.cm-table-delimiter-hidden': {
fontSize: '0',
color: 'transparent'
}
});

View File

@@ -0,0 +1,42 @@
import { syntaxTree } from '@codemirror/language';
import { EditorState } from '@codemirror/state';
/**
* Basic table information extracted from syntax tree.
*/
export interface TableInfo {
/** Starting position in document */
from: number;
/** End position in document */
to: number;
/** Raw markdown text */
rawText: string;
}
/**
* Extract all tables from the editor state.
*/
export function extractTablesFromState(state: EditorState): TableInfo[] {
const tables: TableInfo[] = [];
const seen = new Set<string>();
syntaxTree(state).iterate({
enter: ({ type, from, to }) => {
if (type.name !== 'Table') return;
// Deduplicate
const key = `${from}:${to}`;
if (seen.has(key)) return;
seen.add(key);
const rawText = state.doc.sliceString(from, to);
// Need at least 2 lines (header + delimiter)
if (rawText.split('\n').length < 2) return;
tables.push({ from, to, rawText });
}
});
return tables;
}