diff --git a/frontend/src/assets/styles/variables.css b/frontend/src/assets/styles/variables.css
index 896362e..83a03d5 100644
--- a/frontend/src/assets/styles/variables.css
+++ b/frontend/src/assets/styles/variables.css
@@ -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;
}
}
diff --git a/frontend/src/stores/themeStore.ts b/frontend/src/stores/themeStore.ts
index 6aba44e..a6bae00 100644
--- a/frontend/src/stores/themeStore.ts
+++ b/frontend/src/stores/themeStore.ts
@@ -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();
};
diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts b/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts
index 8e951c1..b7a333b 100644
--- a/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts
+++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts
@@ -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({
diff --git a/frontend/src/views/editor/extensions/markdown/classes.ts b/frontend/src/views/editor/extensions/markdown/classes.ts
index 9d33d73..5fb115d 100644
--- a/frontend/src/views/editor/extensions/markdown/classes.ts
+++ b/frontend/src/views/editor/extensions/markdown/classes.ts
@@ -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'
}
diff --git a/frontend/src/views/editor/extensions/markdown/index.ts b/frontend/src/views/editor/extensions/markdown/index.ts
index dfab0a2..7c1a4fe 100644
--- a/frontend/src/views/editor/extensions/markdown/index.ts
+++ b/frontend/src/views/editor/extensions/markdown/index.ts
@@ -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;
diff --git a/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts b/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts
index 2c337cc..ac630e0 100644
--- a/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts
+++ b/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts
@@ -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') {
diff --git a/frontend/src/views/editor/extensions/markdown/plugins/code-block-enhanced.ts b/frontend/src/views/editor/extensions/markdown/plugins/code-block-enhanced.ts
deleted file mode 100644
index 97c406b..0000000
--- a/frontend/src/views/editor/extensions/markdown/plugins/code-block-enhanced.ts
+++ /dev/null
@@ -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 = `
-
- `;
-
- copyButton.onclick = async (e) => {
- e.preventDefault();
- e.stopPropagation();
- try {
- await navigator.clipboard.writeText(this.code);
- // Visual feedback
- copyButton.innerHTML = `
-
- `;
- setTimeout(() => {
- copyButton.innerHTML = `
-
- `;
- }, 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> = [];
-
- 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'
- }
-});
-
diff --git a/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts b/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts
index fc4b6cb..b0cd6d5 100644
--- a/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts
+++ b/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts
@@ -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 = ``;
+const ICON_CHECK = ``;
+
+/** Cache for code block metadata */
+interface CodeBlockData {
+ from: number;
+ to: number;
+ language: string;
+ content: string;
+}
+
/**
- * Code block plugin with optimized decoration building.
- *
- * This plugin:
- * - Adds styling to code blocks (begin/end markers)
- * - Hides code markers and language info when cursor is outside
+ * Code block extension with language label and copy button.
+ *
+ * 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[] = [];
- const visited = new Set();
+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 } {
+ const decorations: Range[] = [];
+ const blocks = new Map();
+ const seen = new Set();
- // 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;
+ 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'
}
});
diff --git a/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts b/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts
index 2ef2e04..ba0d78b 100644
--- a/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts
+++ b/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts
@@ -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)'
}
});
diff --git a/frontend/src/views/editor/extensions/markdown/plugins/mermaid.ts b/frontend/src/views/editor/extensions/markdown/plugins/mermaid.ts
deleted file mode 100644
index a41c189..0000000
--- a/frontend/src/views/editor/extensions/markdown/plugins/mermaid.ts
+++ /dev/null
@@ -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();
-
-/**
- * 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[] = [];
- 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());
-}
diff --git a/frontend/src/views/editor/extensions/markdown/plugins/table.ts b/frontend/src/views/editor/extensions/markdown/plugins/table.ts
new file mode 100644
index 0000000..61bfdda
--- /dev/null
+++ b/frontend/src/views/editor/extensions/markdown/plugins/table.ts
@@ -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[] = [];
+ 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'
+ }
+});
diff --git a/frontend/src/views/editor/extensions/markdown/state/table.ts b/frontend/src/views/editor/extensions/markdown/state/table.ts
new file mode 100644
index 0000000..13ea099
--- /dev/null
+++ b/frontend/src/views/editor/extensions/markdown/state/table.ts
@@ -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();
+
+ 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;
+}