🚧 Refactor markdown preview extension
This commit is contained in:
@@ -53,11 +53,7 @@
|
||||
/* Markdown 代码块样式 - 暗色主题 */
|
||||
--cm-codeblock-bg: rgba(46, 51, 69, 0.8);
|
||||
--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 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(28% 0.02 255);
|
||||
@@ -68,12 +64,6 @@
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--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 代码块样式 - 亮色主题 */
|
||||
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
||||
--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 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||
@@ -140,12 +125,6 @@
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--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 代码块样式 - 亮色主题 */
|
||||
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
||||
--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 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||
@@ -213,11 +187,5 @@
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useConfigStore } from './configStore';
|
||||
import { useEditorStore } from './editorStore';
|
||||
import type { ThemeColors } from '@/views/editor/theme/types';
|
||||
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 };
|
||||
|
||||
@@ -139,10 +138,6 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
|
||||
const refreshEditorTheme = () => {
|
||||
applyThemeToDOM(currentTheme.value);
|
||||
|
||||
// Refresh mermaid diagrams with new theme
|
||||
refreshMermaidTheme();
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
editorStore?.applyThemeSettings();
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 {Subscript, Superscript, Table} from "@lezer/markdown";
|
||||
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
|
||||
import {javaLanguage} from "@codemirror/lang-java";
|
||||
import {phpLanguage} from "@codemirror/lang-php";
|
||||
@@ -115,7 +115,7 @@ export const LANGUAGES: LanguageInfo[] = [
|
||||
}),
|
||||
new LanguageInfo("md", "Markdown", markdown({
|
||||
base: markdownLanguage,
|
||||
extensions: [Subscript, Superscript, Highlight],
|
||||
extensions: [Subscript, Superscript, Highlight, Table],
|
||||
completeHTMLTags: true,
|
||||
pasteURLAsLink: true,
|
||||
htmlTagLanguage: html({
|
||||
|
||||
@@ -60,18 +60,35 @@ export const blockquote = {
|
||||
/** Copy button */
|
||||
copyBtn: 'cm-code-block-copy-btn'
|
||||
},
|
||||
/** Classes for emoji decorations. */
|
||||
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'
|
||||
/** Classes for table decorations. */
|
||||
table = {
|
||||
/** Table container wrapper */
|
||||
wrapper: 'cm-table-wrapper',
|
||||
/** The rendered table element */
|
||||
table: 'cm-table',
|
||||
/** Table header row */
|
||||
header: 'cm-table-header',
|
||||
/** Table header cell */
|
||||
headerCell: 'cm-table-header-cell',
|
||||
/** Table body */
|
||||
body: 'cm-table-body',
|
||||
/** 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'
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,12 @@ import { image } from './plugins/image';
|
||||
import { links } from './plugins/link';
|
||||
import { lists } from './plugins/list';
|
||||
import { headingSlugField } from './state/heading-slug';
|
||||
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';
|
||||
import { table } from './plugins/table';
|
||||
|
||||
|
||||
/**
|
||||
@@ -30,14 +29,12 @@ export const markdownExtensions: Extension = [
|
||||
links(),
|
||||
image(),
|
||||
htmlBlockExtension,
|
||||
// Enhanced features
|
||||
codeblockEnhanced(),
|
||||
emoji(),
|
||||
horizontalRule(),
|
||||
inlineCode(),
|
||||
subscriptSuperscript(),
|
||||
highlight(),
|
||||
mermaidPreview(),
|
||||
table(),
|
||||
];
|
||||
|
||||
export default markdownExtensions;
|
||||
|
||||
@@ -35,22 +35,24 @@ function buildBlockQuoteDecorations(view: EditorView): DecorationSet {
|
||||
|
||||
const cursorInBlockquote = isCursorInRange(view.state, [node.from, node.to]);
|
||||
|
||||
// Add line decoration for each line in the blockquote
|
||||
const startLine = view.state.doc.lineAt(node.from).number;
|
||||
const endLine = view.state.doc.lineAt(node.to).number;
|
||||
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
if (!processedLines.has(i)) {
|
||||
processedLines.add(i);
|
||||
const line = view.state.doc.line(i);
|
||||
decorations.push(
|
||||
Decoration.line({ class: classes.widget }).range(line.from)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide quote marks when cursor is outside
|
||||
// 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
|
||||
const startLine = view.state.doc.lineAt(node.from).number;
|
||||
const endLine = view.state.doc.lineAt(node.to).number;
|
||||
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
if (!processedLines.has(i)) {
|
||||
processedLines.add(i);
|
||||
const line = view.state.doc.line(i);
|
||||
decorations.push(
|
||||
Decoration.line({ class: classes.widget }).range(line.from)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide quote marks when cursor is outside
|
||||
const cursor = node.node.cursor();
|
||||
cursor.iterate((child) => {
|
||||
if (child.type.name === 'QuoteMark') {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,154 +4,315 @@ import {
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate
|
||||
ViewUpdate,
|
||||
WidgetType
|
||||
} from '@codemirror/view';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { isCursorInRange } from '../util';
|
||||
import { codeblock as classes } from '../classes';
|
||||
import { codeblock as classes, codeblockEnhanced as enhancedClasses } from '../classes';
|
||||
|
||||
/**
|
||||
* Code block types to match in the syntax tree.
|
||||
*/
|
||||
/** Code block node types in syntax tree */
|
||||
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const;
|
||||
|
||||
/** Copy button icon SVGs (size controlled by CSS) */
|
||||
const ICON_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
|
||||
const ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||||
|
||||
/** Cache for code block metadata */
|
||||
interface CodeBlockData {
|
||||
from: number;
|
||||
to: number;
|
||||
language: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Code block plugin with optimized decoration building.
|
||||
* Code block extension with language label and copy button.
|
||||
*
|
||||
* This plugin:
|
||||
* - Adds styling to code blocks (begin/end markers)
|
||||
* - Hides code markers and language info when cursor is outside
|
||||
* Features:
|
||||
* - Adds background styling to code blocks
|
||||
* - Shows language label + copy button when language is specified
|
||||
* - Hides markers when cursor is outside block
|
||||
* - Optimized with viewport-only rendering
|
||||
*/
|
||||
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
|
||||
|
||||
/**
|
||||
* Build code block decorations.
|
||||
* Uses array + Decoration.set() for automatic sorting.
|
||||
* Widget for displaying language label and copy button.
|
||||
* Uses ignoreEvent: true to prevent editor focus changes.
|
||||
*/
|
||||
function buildCodeBlockDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const visited = new Set<string>();
|
||||
class CodeBlockInfoWidget extends WidgetType {
|
||||
constructor(readonly data: CodeBlockData) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: CodeBlockInfoWidget): boolean {
|
||||
return other.data.from === this.data.from &&
|
||||
other.data.language === this.data.language;
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const container = document.createElement('span');
|
||||
container.className = enhancedClasses.info;
|
||||
container.dataset.codeFrom = String(this.data.from);
|
||||
|
||||
// Language label
|
||||
const lang = document.createElement('span');
|
||||
lang.className = enhancedClasses.lang;
|
||||
lang.textContent = this.data.language;
|
||||
|
||||
// Copy button
|
||||
const btn = document.createElement('button');
|
||||
btn.className = enhancedClasses.copyBtn;
|
||||
btn.title = 'Copy';
|
||||
btn.innerHTML = ICON_COPY;
|
||||
btn.dataset.codeContent = this.data.content;
|
||||
|
||||
container.append(lang, btn);
|
||||
return container;
|
||||
}
|
||||
|
||||
// Critical: ignore all events to prevent editor focus
|
||||
ignoreEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract language from code block node.
|
||||
*/
|
||||
function getLanguage(view: EditorView, node: any, offset: number): string | null {
|
||||
let lang: string | null = null;
|
||||
node.toTree().iterate({
|
||||
enter: ({ type, from, to }) => {
|
||||
if (type.name === 'CodeInfo') {
|
||||
lang = view.state.doc.sliceString(offset + from, offset + to).trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
return lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract code content (without fence markers).
|
||||
*/
|
||||
function getCodeContent(view: EditorView, from: number, to: number): string {
|
||||
const lines = view.state.doc.sliceString(from, to).split('\n');
|
||||
return lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build decorations for visible code blocks.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): { decorations: DecorationSet; blocks: Map<number, CodeBlockData> } {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const blocks = new Map<number, CodeBlockData>();
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Process only visible ranges
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
if (!CODE_BLOCK_TYPES.includes(type.name as typeof CODE_BLOCK_TYPES[number])) {
|
||||
return;
|
||||
}
|
||||
if (!CODE_BLOCK_TYPES.includes(type.name as any)) return;
|
||||
|
||||
// Avoid processing the same code block multiple times
|
||||
const key = `${nodeFrom}:${nodeTo}`;
|
||||
if (visited.has(key)) return;
|
||||
visited.add(key);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
|
||||
const cursorInBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
const inBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
if (inBlock) return;
|
||||
|
||||
// Add line decorations for each line in the code block
|
||||
const language = getLanguage(view, node, nodeFrom);
|
||||
const startLine = view.state.doc.lineAt(nodeFrom);
|
||||
const endLine = view.state.doc.lineAt(nodeTo);
|
||||
|
||||
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
|
||||
const line = view.state.doc.line(lineNum);
|
||||
|
||||
// Determine line position class(es)
|
||||
const isFirst = lineNum === startLine.number;
|
||||
const isLast = lineNum === endLine.number;
|
||||
|
||||
// Build class list - a single line block needs both begin and end classes
|
||||
const positionClasses: string[] = [];
|
||||
if (isFirst) positionClasses.push(classes.widgetBegin);
|
||||
if (isLast) positionClasses.push(classes.widgetEnd);
|
||||
// Line decorations
|
||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||
const line = view.state.doc.line(num);
|
||||
const pos: string[] = [];
|
||||
if (num === startLine.number) pos.push(classes.widgetBegin);
|
||||
if (num === endLine.number) pos.push(classes.widgetEnd);
|
||||
|
||||
decorations.push(
|
||||
Decoration.line({
|
||||
class: `${classes.widget} ${positionClasses.join(' ')}`.trim()
|
||||
class: `${classes.widget} ${pos.join(' ')}`.trim()
|
||||
}).range(line.from)
|
||||
);
|
||||
}
|
||||
|
||||
// Hide code markers when cursor is outside the block
|
||||
if (!cursorInBlock) {
|
||||
const codeBlock = node.toTree();
|
||||
codeBlock.iterate({
|
||||
enter: ({ type: childType, from: childFrom, to: childTo }) => {
|
||||
if (childType.name === 'CodeInfo' || childType.name === 'CodeMark') {
|
||||
decorations.push(
|
||||
Decoration.replace({}).range(
|
||||
nodeFrom + childFrom,
|
||||
nodeFrom + childTo
|
||||
)
|
||||
);
|
||||
}
|
||||
// Info widget (only if language specified)
|
||||
if (language) {
|
||||
const content = getCodeContent(view, nodeFrom, nodeTo);
|
||||
const data: CodeBlockData = { from: nodeFrom, to: nodeTo, language, content };
|
||||
blocks.set(nodeFrom, data);
|
||||
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new CodeBlockInfoWidget(data),
|
||||
side: 1
|
||||
}).range(startLine.to)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Hide markers
|
||||
node.toTree().iterate({
|
||||
enter: ({ type: t, from: f, to: t2 }) => {
|
||||
if (t.name === 'CodeInfo' || t.name === 'CodeMark') {
|
||||
decorations.push(Decoration.replace({}).range(nodeFrom + f, nodeFrom + t2));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Use Decoration.set with sort=true to handle unsorted ranges
|
||||
return Decoration.set(decorations, true);
|
||||
return { decorations: Decoration.set(decorations, true), blocks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Code block plugin class with optimized update detection.
|
||||
* Handle copy button click.
|
||||
*/
|
||||
class CodeBlockPlugin {
|
||||
function handleCopyClick(btn: HTMLButtonElement): void {
|
||||
const content = btn.dataset.codeContent;
|
||||
if (!content) return;
|
||||
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
btn.innerHTML = ICON_CHECK;
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = ICON_COPY;
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Code block plugin with optimized updates.
|
||||
*/
|
||||
class CodeBlockPluginClass {
|
||||
decorations: DecorationSet;
|
||||
private lastSelection: number = -1;
|
||||
blocks: Map<number, CodeBlockData>;
|
||||
private lastHead = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildCodeBlockDecorations(view);
|
||||
this.lastSelection = view.state.selection.main.head;
|
||||
const result = buildDecorations(view);
|
||||
this.decorations = result.decorations;
|
||||
this.blocks = result.blocks;
|
||||
this.lastHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const docChanged = update.docChanged;
|
||||
const viewportChanged = update.viewportChanged;
|
||||
const selectionChanged = update.selectionSet;
|
||||
update(update: ViewUpdate): void {
|
||||
const { docChanged, viewportChanged, selectionSet } = update;
|
||||
|
||||
// Optimization: check if selection moved to a different line
|
||||
if (selectionChanged && !docChanged && !viewportChanged) {
|
||||
// Skip rebuild if cursor stayed on same line
|
||||
if (selectionSet && !docChanged && !viewportChanged) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
const oldHead = this.lastSelection;
|
||||
const oldLine = update.startState.doc.lineAt(this.lastHead).number;
|
||||
const newLine = update.state.doc.lineAt(newHead).number;
|
||||
|
||||
const oldLine = update.startState.doc.lineAt(oldHead);
|
||||
const newLine = update.state.doc.lineAt(newHead);
|
||||
|
||||
if (oldLine.number === newLine.number) {
|
||||
this.lastSelection = newHead;
|
||||
if (oldLine === newLine) {
|
||||
this.lastHead = newHead;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (docChanged || viewportChanged || selectionChanged) {
|
||||
this.decorations = buildCodeBlockDecorations(update.view);
|
||||
this.lastSelection = update.state.selection.main.head;
|
||||
if (docChanged || viewportChanged || selectionSet) {
|
||||
const result = buildDecorations(update.view);
|
||||
this.decorations = result.decorations;
|
||||
this.blocks = result.blocks;
|
||||
this.lastHead = update.state.selection.main.head;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, {
|
||||
decorations: (v) => v.decorations,
|
||||
|
||||
eventHandlers: {
|
||||
// Handle copy button clicks without triggering editor focus
|
||||
mousedown(e: MouseEvent, view: EditorView) {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Check if clicked on copy button or its SVG child
|
||||
const btn = target.closest(`.${enhancedClasses.copyBtn}`) as HTMLButtonElement;
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyClick(btn);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if clicked on info container (language label)
|
||||
if (target.closest(`.${enhancedClasses.info}`)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Base theme for code blocks.
|
||||
* Uses CSS variables from variables.css for consistent theming.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
[`.${classes.widget}`]: {
|
||||
backgroundColor: 'var(--cm-codeblock-bg)',
|
||||
backgroundColor: 'var(--cm-codeblock-bg)'
|
||||
},
|
||||
[`.${classes.widgetBegin}`]: {
|
||||
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderTopRightRadius: 'var(--cm-codeblock-radius)'
|
||||
borderTopRightRadius: 'var(--cm-codeblock-radius)',
|
||||
position: 'relative',
|
||||
borderTop: '1px solid var(--text-primary)'
|
||||
},
|
||||
[`.${classes.widgetEnd}`]: {
|
||||
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderBottomRightRadius: 'var(--cm-codeblock-radius)'
|
||||
borderBottomRightRadius: 'var(--cm-codeblock-radius)',
|
||||
borderBottom: '1px solid var(--text-primary)'
|
||||
},
|
||||
// Info container
|
||||
[`.${enhancedClasses.info}`]: {
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5em',
|
||||
zIndex: '5',
|
||||
opacity: '0.5',
|
||||
transition: 'opacity 0.15s'
|
||||
},
|
||||
[`.${enhancedClasses.info}:hover`]: {
|
||||
opacity: '1'
|
||||
},
|
||||
// Language label
|
||||
[`.${enhancedClasses.lang}`]: {
|
||||
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||
textTransform: 'lowercase',
|
||||
userSelect: 'none'
|
||||
},
|
||||
// Copy button
|
||||
[`.${enhancedClasses.copyBtn}`]: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0.15em',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
background: 'transparent',
|
||||
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||
cursor: 'pointer',
|
||||
opacity: '0.7',
|
||||
transition: 'opacity 0.15s, background 0.15s'
|
||||
},
|
||||
[`.${enhancedClasses.copyBtn}:hover`]: {
|
||||
opacity: '1',
|
||||
background: 'rgba(128, 128, 128, 0.2)'
|
||||
},
|
||||
[`.${enhancedClasses.copyBtn} svg`]: {
|
||||
width: '1em',
|
||||
height: '1em'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,6 +32,12 @@ function buildInlineCodeDecorations(view: EditorView): DecorationSet {
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||
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)
|
||||
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
|
||||
if (codeStart < codeEnd) {
|
||||
const cursorInCode = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
|
||||
// Add mark decoration for the code content
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: cursorInCode ? 'cm-inline-code cm-inline-code-active' : 'cm-inline-code'
|
||||
class: 'cm-inline-code'
|
||||
}).range(codeStart, codeEnd)
|
||||
);
|
||||
}
|
||||
@@ -103,10 +107,6 @@ const baseTheme = EditorView.baseTheme({
|
||||
padding: '0.1rem 0.3rem',
|
||||
fontFamily: 'var(--voidraft-font-mono)',
|
||||
fontSize: '0.9em'
|
||||
},
|
||||
'.cm-inline-code-active': {
|
||||
// Slightly different style when cursor is inside
|
||||
backgroundColor: 'var(--cm-inline-code-bg)'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
145
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal file
145
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
42
frontend/src/views/editor/extensions/markdown/state/table.ts
Normal file
42
frontend/src/views/editor/extensions/markdown/state/table.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user