From dd3dd4ddb23f1265878a6d6626ffd0cd565ef2dd Mon Sep 17 00:00:00 2001 From: landaiqing Date: Mon, 1 Dec 2025 00:00:05 +0800 Subject: [PATCH] :construction: Refactor markdown preview extension --- frontend/src/assets/styles/variables.css | 34 +- frontend/src/stores/themeStore.ts | 5 - .../codeblock/lang-parser/languages.ts | 4 +- .../editor/extensions/markdown/classes.ts | 43 +- .../views/editor/extensions/markdown/index.ts | 7 +- .../extensions/markdown/plugins/blockquote.ts | 32 +- .../markdown/plugins/code-block-enhanced.ts | 207 --------- .../extensions/markdown/plugins/code-block.ts | 321 ++++++++++---- .../markdown/plugins/inline-code.ts | 14 +- .../extensions/markdown/plugins/mermaid.ts | 402 ------------------ .../extensions/markdown/plugins/table.ts | 145 +++++++ .../editor/extensions/markdown/state/table.ts | 42 ++ 12 files changed, 487 insertions(+), 769 deletions(-) delete mode 100644 frontend/src/views/editor/extensions/markdown/plugins/code-block-enhanced.ts delete mode 100644 frontend/src/views/editor/extensions/markdown/plugins/mermaid.ts create mode 100644 frontend/src/views/editor/extensions/markdown/plugins/table.ts create mode 100644 frontend/src/views/editor/extensions/markdown/state/table.ts 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; +}