diff --git a/frontend/src/assets/styles/variables.css b/frontend/src/assets/styles/variables.css index 83a03d5..d24c602 100644 --- a/frontend/src/assets/styles/variables.css +++ b/frontend/src/assets/styles/variables.css @@ -64,6 +64,12 @@ /* Markdown 高亮样式 */ --cm-highlight-background: rgba(250, 204, 21, 0.35); + + /* Markdown 表格样式 - 暗色主题 */ + --cm-table-bg: rgba(35, 40, 52, 0.5); + --cm-table-header-bg: rgba(46, 51, 69, 0.7); + --cm-table-border: rgba(75, 85, 99, 0.35); + --cm-table-row-hover: rgba(55, 62, 78, 0.5); } /* 亮色主题 */ @@ -125,6 +131,12 @@ /* Markdown 高亮样式 */ --cm-highlight-background: rgba(253, 224, 71, 0.45); + + /* Markdown 表格样式 - 亮色主题 */ + --cm-table-bg: oklch(97.5% 0.006 255); + --cm-table-header-bg: oklch(94% 0.01 255); + --cm-table-border: oklch(88% 0.008 255); + --cm-table-row-hover: oklch(95% 0.008 255); } /* 跟随系统的浅色偏好 */ @@ -187,5 +199,11 @@ /* Markdown 高亮样式 */ --cm-highlight-background: rgba(253, 224, 71, 0.45); + + /* Markdown 表格样式 - 亮色主题 */ + --cm-table-bg: oklch(97.5% 0.006 255); + --cm-table-header-bg: oklch(94% 0.01 255); + --cm-table-border: oklch(88% 0.008 255); + --cm-table-row-hover: oklch(95% 0.008 255); } } diff --git a/frontend/src/views/editor/extensions/markdown/index.ts b/frontend/src/views/editor/extensions/markdown/index.ts index ccac3aa..0331747 100644 --- a/frontend/src/views/editor/extensions/markdown/index.ts +++ b/frontend/src/views/editor/extensions/markdown/index.ts @@ -15,6 +15,8 @@ import { highlight } from './plugins/highlight'; import { insert } from './plugins/insert'; import { math } from './plugins/math'; import { footnote } from './plugins/footnote'; +import table from "./plugins/table"; +import {htmlBlockExtension} from "./plugins/html"; /** * markdown extensions @@ -36,6 +38,8 @@ export const markdownExtensions: Extension = [ insert(), math(), footnote(), + table(), + htmlBlockExtension ]; 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 6455840..f48053a 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts @@ -5,9 +5,12 @@ import { ViewPlugin, ViewUpdate } from '@codemirror/view'; -import { Range } from '@codemirror/state'; +import { RangeSetBuilder } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; -import { isCursorInRange, invisibleDecoration } from '../util'; +import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; + +/** Pre-computed line decoration */ +const LINE_DECO = Decoration.line({ class: 'cm-blockquote' }); /** * Blockquote plugin. @@ -22,22 +25,69 @@ export function blockquote() { } /** - * Build blockquote decorations. + * Collect blockquote ranges in visible viewport. */ -function buildBlockQuoteDecorations(view: EditorView): DecorationSet { - const decorations: Range[] = []; +function collectBlockquoteRanges(view: EditorView): RangeTuple[] { + const ranges: RangeTuple[] = []; + const seen = new Set(); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter(node) { + if (node.type.name !== 'Blockquote') return; + if (seen.has(node.from)) return; + seen.add(node.from); + ranges.push([node.from, node.to]); + return false; // Don't recurse into nested + } + }); + } + + return ranges; +} + +/** + * Get cursor's blockquote position (-1 if not in any). + */ +function getCursorBlockquotePos(view: EditorView, ranges: RangeTuple[]): number { + const sel = view.state.selection.main; + const selRange: RangeTuple = [sel.from, sel.to]; + + for (const range of ranges) { + if (checkRangeOverlap(selRange, range)) { + return range[0]; + } + } + return -1; +} + +/** + * Build blockquote decorations for visible viewport. + */ +function buildDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const items: { pos: number; endPos?: number; deco: Decoration }[] = []; const processedLines = new Set(); + const seen = new Set(); - syntaxTree(view.state).iterate({ - enter(node) { - if (node.type.name !== 'Blockquote') return; + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter(node) { + if (node.type.name !== 'Blockquote') return; + if (seen.has(node.from)) return; + seen.add(node.from); - const cursorInBlockquote = isCursorInRange(view.state, [node.from, node.to]); + const inBlock = checkRangeOverlap( + [node.from, node.to], + [view.state.selection.main.from, view.state.selection.main.to] + ); + if (inBlock) return false; - // 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 + // Line decorations const startLine = view.state.doc.lineAt(node.from).number; const endLine = view.state.doc.lineAt(node.to).number; @@ -45,44 +95,67 @@ function buildBlockQuoteDecorations(view: EditorView): DecorationSet { if (!processedLines.has(i)) { processedLines.add(i); const line = view.state.doc.line(i); - decorations.push( - Decoration.line({ class: 'cm-blockquote' }).range(line.from) - ); + items.push({ pos: line.from, deco: LINE_DECO }); } } - // Hide quote marks when cursor is outside + // Hide quote marks const cursor = node.node.cursor(); cursor.iterate((child) => { if (child.type.name === 'QuoteMark') { - decorations.push( - invisibleDecoration.range(child.from, child.to) - ); + items.push({ pos: child.from, endPos: child.to, deco: invisibleDecoration }); } }); + + return false; } + }); + } - // Don't recurse into nested blockquotes (handled by outer iteration) - return false; + // Sort and build + items.sort((a, b) => a.pos - b.pos); + + for (const item of items) { + if (item.endPos !== undefined) { + builder.add(item.pos, item.endPos, item.deco); + } else { + builder.add(item.pos, item.pos, item.deco); } - }); + } - return Decoration.set(decorations, true); + return builder.finish(); } /** - * Blockquote plugin class. + * Blockquote plugin with optimized updates. */ class BlockQuotePlugin { decorations: DecorationSet; + private blockRanges: RangeTuple[] = []; + private cursorBlockPos = -1; constructor(view: EditorView) { - this.decorations = buildBlockQuoteDecorations(view); + this.blockRanges = collectBlockquoteRanges(view); + this.cursorBlockPos = getCursorBlockquotePos(view, this.blockRanges); + this.decorations = buildDecorations(view); } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged || update.selectionSet) { - this.decorations = buildBlockQuoteDecorations(update.view); + const { docChanged, viewportChanged, selectionSet } = update; + + if (docChanged || viewportChanged) { + this.blockRanges = collectBlockquoteRanges(update.view); + this.cursorBlockPos = getCursorBlockquotePos(update.view, this.blockRanges); + this.decorations = buildDecorations(update.view); + return; + } + + if (selectionSet) { + const newPos = getCursorBlockquotePos(update.view, this.blockRanges); + if (newPos !== this.cursorBlockPos) { + this.cursorBlockPos = newPos; + this.decorations = buildDecorations(update.view); + } } } } 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 3a23f39..42995d5 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts @@ -1,4 +1,4 @@ -import { Extension, Range } from '@codemirror/state'; +import { Extension, RangeSetBuilder } from '@codemirror/state'; import { ViewPlugin, DecorationSet, @@ -8,21 +8,26 @@ import { WidgetType } from '@codemirror/view'; import { syntaxTree } from '@codemirror/language'; -import { isCursorInRange } from '../util'; +import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; /** Code block node types in syntax tree */ -const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const; +const CODE_BLOCK_TYPES = new Set(['FencedCode', 'CodeBlock']); -/** Copy button icon SVGs (size controlled by CSS) */ +/** Copy button icon SVGs */ const ICON_COPY = ``; const ICON_CHECK = ``; -/** Cache for code block metadata */ -interface CodeBlockData { +/** Pre-computed line decoration classes */ +const LINE_DECO_NORMAL = Decoration.line({ class: 'cm-codeblock' }); +const LINE_DECO_BEGIN = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin' }); +const LINE_DECO_END = Decoration.line({ class: 'cm-codeblock cm-codeblock-end' }); +const LINE_DECO_SINGLE = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin cm-codeblock-end' }); + +/** Code block metadata for widget */ +interface CodeBlockMeta { from: number; to: number; language: string | null; - content: string; } /** @@ -32,36 +37,32 @@ interface CodeBlockData { * - 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 + * - Optimized with viewport-only rendering and minimal rebuilds */ export const codeblock = (): Extension => [codeBlockPlugin, baseTheme]; /** * Widget for displaying language label and copy button. - * Handles click events directly on the button element. + * Content is computed lazily on copy action. */ class CodeBlockInfoWidget extends WidgetType { - constructor( - readonly data: CodeBlockData, - readonly view: EditorView - ) { + constructor(readonly meta: CodeBlockMeta) { super(); } eq(other: CodeBlockInfoWidget): boolean { - return other.data.from === this.data.from && - other.data.language === this.data.language; + return other.meta.from === this.meta.from && + other.meta.language === this.meta.language; } - toDOM(): HTMLElement { + toDOM(view: EditorView): HTMLElement { const container = document.createElement('span'); container.className = 'cm-code-block-info'; - // Only show language label if specified - if (this.data.language) { + if (this.meta.language) { const lang = document.createElement('span'); lang.className = 'cm-code-block-lang'; - lang.textContent = this.data.language; + lang.textContent = this.meta.language; container.append(lang); } @@ -70,14 +71,12 @@ class CodeBlockInfoWidget extends WidgetType { btn.title = 'Copy'; btn.innerHTML = ICON_COPY; - // Direct click handler - more reliable than eventHandlers btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - this.handleCopy(btn); + this.copyContent(view, btn); }); - // Prevent mousedown from affecting editor btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); @@ -87,8 +86,13 @@ class CodeBlockInfoWidget extends WidgetType { return container; } - private handleCopy(btn: HTMLButtonElement): void { - const content = getCodeContent(this.view, this.data.from, this.data.to); + /** Lazy content extraction and copy */ + private copyContent(view: EditorView, btn: HTMLButtonElement): void { + const { from, to } = this.meta; + const text = view.state.doc.sliceString(from, to); + const lines = text.split('\n'); + const content = lines.length >= 2 ? lines.slice(1, -1).join('\n') : ''; + if (!content) return; navigator.clipboard.writeText(content).then(() => { @@ -99,134 +103,205 @@ class CodeBlockInfoWidget extends WidgetType { }); } - // Ignore events to prevent editor focus changes 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; +/** Parsed code block info from single tree traversal */ +interface ParsedBlock { + from: number; + to: number; + language: string | null; + marks: RangeTuple[]; // CodeMark and CodeInfo positions to hide } /** - * Extract code content (without fence markers). + * Parse a code block node in a single traversal. + * Extracts language and mark positions together. */ -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') : ''; +function parseCodeBlock(view: EditorView, nodeFrom: number, nodeTo: number, node: any): ParsedBlock { + let language: string | null = null; + const marks: RangeTuple[] = []; + + node.toTree().iterate({ + enter: ({ type, from, to }) => { + const absFrom = nodeFrom + from; + const absTo = nodeFrom + to; + + if (type.name === 'CodeInfo') { + language = view.state.doc.sliceString(absFrom, absTo).trim(); + marks.push([absFrom, absTo]); + } else if (type.name === 'CodeMark') { + marks.push([absFrom, absTo]); + } + } + }); + + return { from: nodeFrom, to: nodeTo, language, marks }; +} + +/** + * Find which code block the cursor is in (returns block start position, or -1 if not in any). + */ +function getCursorBlockPosition(view: EditorView, blocks: RangeTuple[]): number { + const { ranges } = view.state.selection; + for (const sel of ranges) { + const selRange: RangeTuple = [sel.from, sel.to]; + for (const block of blocks) { + if (checkRangeOverlap(selRange, block)) { + return block[0]; // Return the block's start position as identifier + } + } + } + return -1; +} + +/** + * Collect all code block ranges in visible viewport. + */ +function collectCodeBlockRanges(view: EditorView): RangeTuple[] { + const ranges: RangeTuple[] = []; + const seen = new Set(); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo }) => { + if (!CODE_BLOCK_TYPES.has(type.name)) return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); + ranges.push([nodeFrom, nodeTo]); + } + }); + } + + return ranges; } /** * Build decorations for visible code blocks. + * Uses RangeSetBuilder for efficient sorted construction. */ -function buildDecorations(view: EditorView): { decorations: DecorationSet; blocks: Map } { - const decorations: Range[] = []; - const blocks = new Map(); - const seen = new Set(); +function buildDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const items: { pos: number; endPos?: number; deco: Decoration; isWidget?: boolean; isReplace?: boolean }[] = []; + const seen = new Set(); 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 any)) return; + if (!CODE_BLOCK_TYPES.has(type.name)) return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); - const key = `${nodeFrom}:${nodeTo}`; - if (seen.has(key)) return; - seen.add(key); - - const inBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]); + // Check if cursor is in this block + const inBlock = checkRangeOverlap( + [nodeFrom, nodeTo], + [view.state.selection.main.from, view.state.selection.main.to] + ); if (inBlock) return; - const language = getLanguage(view, node, nodeFrom); + // Parse block in single traversal + const block = parseCodeBlock(view, nodeFrom, nodeTo, node); const startLine = view.state.doc.lineAt(nodeFrom); const endLine = view.state.doc.lineAt(nodeTo); + // Add line decorations for (let num = startLine.number; num <= endLine.number; num++) { const line = view.state.doc.line(num); - const pos: string[] = ['cm-codeblock']; - if (num === startLine.number) pos.push('cm-codeblock-begin'); - if (num === endLine.number) pos.push('cm-codeblock-end'); - - decorations.push( - Decoration.line({ class: pos.join(' ') }).range(line.from) - ); + let deco: Decoration; + + if (startLine.number === endLine.number) { + deco = LINE_DECO_SINGLE; + } else if (num === startLine.number) { + deco = LINE_DECO_BEGIN; + } else if (num === endLine.number) { + deco = LINE_DECO_END; + } else { + deco = LINE_DECO_NORMAL; + } + + items.push({ pos: line.from, deco }); } - // Info widget with copy button (always show, language label only if specified) - 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, view), + // Add info widget + const meta: CodeBlockMeta = { + from: nodeFrom, + to: nodeTo, + language: block.language + }; + items.push({ + pos: startLine.to, + deco: Decoration.widget({ + widget: new CodeBlockInfoWidget(meta), 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)); - } - } + }), + isWidget: true }); + + // Hide marks + for (const [mFrom, mTo] of block.marks) { + items.push({ pos: mFrom, endPos: mTo, deco: invisibleDecoration, isReplace: true }); + } } }); } - return { decorations: Decoration.set(decorations, true), blocks }; + // Sort by position and add to builder + items.sort((a, b) => { + if (a.pos !== b.pos) return a.pos - b.pos; + // Widgets should come after line decorations at same position + return (a.isWidget ? 1 : 0) - (b.isWidget ? 1 : 0); + }); + + for (const item of items) { + if (item.isReplace && item.endPos !== undefined) { + builder.add(item.pos, item.endPos, item.deco); + } else { + builder.add(item.pos, item.pos, item.deco); + } + } + + return builder.finish(); } /** - * Code block plugin with optimized updates. + * Code block plugin with optimized update detection. */ class CodeBlockPluginClass { decorations: DecorationSet; - blocks: Map; - private lastHead = -1; + private blockRanges: RangeTuple[] = []; + private cursorBlockPos = -1; // Which block the cursor is in (-1 = none) constructor(view: EditorView) { - const result = buildDecorations(view); - this.decorations = result.decorations; - this.blocks = result.blocks; - this.lastHead = view.state.selection.main.head; + this.blockRanges = collectCodeBlockRanges(view); + this.cursorBlockPos = getCursorBlockPosition(view, this.blockRanges); + this.decorations = buildDecorations(view); } update(update: ViewUpdate): void { const { docChanged, viewportChanged, selectionSet } = update; - // Skip rebuild if cursor stayed on same line - if (selectionSet && !docChanged && !viewportChanged) { - const newHead = update.state.selection.main.head; - const oldLine = update.startState.doc.lineAt(this.lastHead).number; - const newLine = update.state.doc.lineAt(newHead).number; - - if (oldLine === newLine) { - this.lastHead = newHead; - return; - } + // Always rebuild on doc or viewport change + if (docChanged || viewportChanged) { + this.blockRanges = collectCodeBlockRanges(update.view); + this.cursorBlockPos = getCursorBlockPosition(update.view, this.blockRanges); + this.decorations = buildDecorations(update.view); + return; } - if (docChanged || viewportChanged || selectionSet) { - const result = buildDecorations(update.view); - this.decorations = result.decorations; - this.blocks = result.blocks; - this.lastHead = update.state.selection.main.head; + // For selection changes, only rebuild if cursor moves to a different block + if (selectionSet) { + const newBlockPos = getCursorBlockPosition(update.view, this.blockRanges); + + if (newBlockPos !== this.cursorBlockPos) { + this.cursorBlockPos = newBlockPos; + this.decorations = buildDecorations(update.view); + } } } } @@ -240,18 +315,17 @@ const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, { */ const baseTheme = EditorView.baseTheme({ '.cm-codeblock': { - backgroundColor: 'var(--cm-codeblock-bg)' + backgroundColor: 'var(--cm-codeblock-bg)', + fontFamily: 'inherit', }, '.cm-codeblock-begin': { borderTopLeftRadius: 'var(--cm-codeblock-radius)', borderTopRightRadius: 'var(--cm-codeblock-radius)', position: 'relative', - boxShadow: 'inset 0 1px 0 var(--text-primary)' }, '.cm-codeblock-end': { borderBottomLeftRadius: 'var(--cm-codeblock-radius)', borderBottomRightRadius: 'var(--cm-codeblock-radius)', - boxShadow: 'inset 0 -1px 0 var(--text-primary)' }, '.cm-code-block-info': { position: 'absolute', diff --git a/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts b/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts index 4eb7fe4..5572f28 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts @@ -7,7 +7,7 @@ import { ViewUpdate, WidgetType } from '@codemirror/view'; -import { isCursorInRange } from '../util'; +import { checkRangeOverlap, RangeTuple } from '../util'; import { emojies } from '@/common/constant/emojies'; /** @@ -17,14 +17,11 @@ import { emojies } from '@/common/constant/emojies'; * - Detects emoji patterns like :smile:, :heart:, etc. * - Replaces them with actual emoji characters * - Shows the original text when cursor is nearby - * - Uses RangeSetBuilder for optimal performance - * - Supports 1900+ emojis from the comprehensive emoji dictionary + * - Optimized with cached matches and minimal rebuilds */ export const emoji = (): Extension => [emojiPlugin, baseTheme]; -/** - * Emoji regex pattern for matching :emoji_name: syntax. - */ +/** Non-global regex for matchAll (more efficient than global with lastIndex reset) */ const EMOJI_REGEX = /:([a-z0-9_+\-]+):/gi; /** @@ -52,7 +49,7 @@ class EmojiWidget extends WidgetType { } /** - * Match result for emoji patterns. + * Cached emoji match. */ interface EmojiMatch { from: number; @@ -62,26 +59,29 @@ interface EmojiMatch { } /** - * Find all emoji matches in a text range. + * Find all emoji matches in visible ranges. */ -function findEmojiMatches(text: string, offset: number): EmojiMatch[] { +function findAllEmojiMatches(view: EditorView): EmojiMatch[] { const matches: EmojiMatch[] = []; - let match: RegExpExecArray | null; + const doc = view.state.doc; - // Reset regex state - EMOJI_REGEX.lastIndex = 0; + for (const { from, to } of view.visibleRanges) { + const text = doc.sliceString(from, to); + let match: RegExpExecArray | null; + + EMOJI_REGEX.lastIndex = 0; + while ((match = EMOJI_REGEX.exec(text)) !== null) { + const name = match[1].toLowerCase(); + const emojiChar = emojies[name]; - while ((match = EMOJI_REGEX.exec(text)) !== null) { - const name = match[1].toLowerCase(); - const emoji = emojies[name]; - - if (emoji) { - matches.push({ - from: offset + match.index, - to: offset + match.index + match[0].length, - name, - emoji - }); + if (emojiChar) { + matches.push({ + from: from + match.index, + to: from + match.index + match[0].length, + name, + emoji: emojiChar + }); + } } } @@ -89,63 +89,79 @@ function findEmojiMatches(text: string, offset: number): EmojiMatch[] { } /** - * Build emoji decorations using RangeSetBuilder. + * Get which emoji the cursor is in (-1 if none). */ -function buildEmojiDecorations(view: EditorView): DecorationSet { - const builder = new RangeSetBuilder(); - const doc = view.state.doc; - - for (const { from, to } of view.visibleRanges) { - const text = doc.sliceString(from, to); - const matches = findEmojiMatches(text, from); - - for (const match of matches) { - // Skip if cursor is in this range - if (isCursorInRange(view.state, [match.from, match.to])) { - continue; - } - - builder.add( - match.from, - match.to, - Decoration.replace({ - widget: new EmojiWidget(match.emoji, match.name) - }) - ); +function getCursorEmojiIndex(matches: EmojiMatch[], selFrom: number, selTo: number): number { + const selRange: RangeTuple = [selFrom, selTo]; + + for (let i = 0; i < matches.length; i++) { + if (checkRangeOverlap([matches[i].from, matches[i].to], selRange)) { + return i; } } + return -1; +} + +/** + * Build decorations from cached matches. + */ +function buildDecorations(matches: EmojiMatch[], selFrom: number, selTo: number): DecorationSet { + const builder = new RangeSetBuilder(); + const selRange: RangeTuple = [selFrom, selTo]; + + for (const match of matches) { + // Skip if cursor overlaps this emoji + if (checkRangeOverlap([match.from, match.to], selRange)) { + continue; + } + + builder.add( + match.from, + match.to, + Decoration.replace({ + widget: new EmojiWidget(match.emoji, match.name) + }) + ); + } return builder.finish(); } /** - * Emoji plugin with optimized update detection. + * Emoji plugin with cached matches and optimized updates. */ class EmojiPlugin { decorations: DecorationSet; - private lastSelectionHead: number = -1; + private matches: EmojiMatch[] = []; + private cursorEmojiIdx = -1; constructor(view: EditorView) { - this.decorations = buildEmojiDecorations(view); - this.lastSelectionHead = view.state.selection.main.head; + this.matches = findAllEmojiMatches(view); + const { from, to } = view.state.selection.main; + this.cursorEmojiIdx = getCursorEmojiIndex(this.matches, from, to); + this.decorations = buildDecorations(this.matches, from, to); } update(update: ViewUpdate) { - // Always rebuild on doc or viewport change - if (update.docChanged || update.viewportChanged) { - this.decorations = buildEmojiDecorations(update.view); - this.lastSelectionHead = update.state.selection.main.head; + const { docChanged, viewportChanged, selectionSet } = update; + + // Rebuild matches on doc or viewport change + if (docChanged || viewportChanged) { + this.matches = findAllEmojiMatches(update.view); + const { from, to } = update.state.selection.main; + this.cursorEmojiIdx = getCursorEmojiIndex(this.matches, from, to); + this.decorations = buildDecorations(this.matches, from, to); return; } - // For selection changes, check if we moved significantly - if (update.selectionSet) { - const newHead = update.state.selection.main.head; + // For selection changes, only rebuild if cursor enters/leaves an emoji + if (selectionSet) { + const { from, to } = update.state.selection.main; + const newIdx = getCursorEmojiIndex(this.matches, from, to); - // Only rebuild if cursor moved to a different position - if (newHead !== this.lastSelectionHead) { - this.decorations = buildEmojiDecorations(update.view); - this.lastSelectionHead = newHead; + if (newIdx !== this.cursorEmojiIdx) { + this.cursorEmojiIdx = newIdx; + this.decorations = buildDecorations(this.matches, from, to); } } } @@ -157,7 +173,6 @@ const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, { /** * Base theme for emoji. - * Inherits font size and line height from parent element. */ const baseTheme = EditorView.baseTheme({ '.cm-emoji': { diff --git a/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts b/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts index 2d24b31..908614b 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts @@ -7,14 +7,9 @@ * - Shows footnote content on hover (tooltip) * - Click to jump between reference and definition * - Hides syntax marks when cursor is outside - * - * Syntax (MultiMarkdown/PHP Markdown Extra): - * - Reference: [^id] → renders as superscript - * - Definition: [^id]: content - * - Inline footnote: ^[content] → renders as superscript with embedded content */ -import { Extension, Range, StateField, EditorState } from '@codemirror/state'; +import { Extension, RangeSetBuilder, EditorState } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; import { ViewPlugin, @@ -26,84 +21,72 @@ import { hoverTooltip, Tooltip, } from '@codemirror/view'; -import { isCursorInRange, invisibleDecoration } from '../util'; +import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; // ============================================================================ // Types // ============================================================================ -/** - * Information about a footnote definition. - */ interface FootnoteDefinition { - /** The footnote identifier (e.g., "1", "note") */ id: string; - /** The content of the footnote */ content: string; - /** Start position in document */ from: number; - /** End position in document */ to: number; } -/** - * Information about a footnote reference. - */ interface FootnoteReference { - /** The footnote identifier */ id: string; - /** Start position in document */ from: number; - /** End position in document */ to: number; - /** Numeric index (1-based, for display) */ index: number; } -/** - * Information about an inline footnote. - */ interface InlineFootnoteInfo { - /** The content of the inline footnote */ content: string; - /** Start position in document */ from: number; - /** End position in document */ to: number; - /** Numeric index (1-based, for display) */ index: number; } /** - * Collected footnote data from the document. - * Uses Maps for O(1) lookup by position and id. + * Collected footnote data with O(1) lookup indexes. */ interface FootnoteData { definitions: Map; references: FootnoteReference[]; inlineFootnotes: InlineFootnoteInfo[]; - // Index maps for O(1) lookup referencesByPos: Map; inlineByPos: Map; + definitionByPos: Map; // For position-based lookup firstRefById: Map; + // All footnote ranges for cursor detection + allRanges: RangeTuple[]; } // ============================================================================ -// Footnote Collection +// Footnote Collection (cached via closure) // ============================================================================ +let cachedData: FootnoteData | null = null; +let cachedDocLength = -1; + /** - * Collect all footnote definitions, references, and inline footnotes from the document. - * Builds index maps for O(1) lookup during decoration and tooltip handling. + * Collect all footnote data from the document. */ function collectFootnotes(state: EditorState): FootnoteData { + // Simple cache invalidation based on doc length + if (cachedData && cachedDocLength === state.doc.length) { + return cachedData; + } + const definitions = new Map(); const references: FootnoteReference[] = []; const inlineFootnotes: InlineFootnoteInfo[] = []; - // Index maps for fast lookup const referencesByPos = new Map(); const inlineByPos = new Map(); + const definitionByPos = new Map(); const firstRefById = new Map(); + const allRanges: RangeTuple[] = []; const seenIds = new Map(); let inlineIndex = 0; @@ -119,7 +102,10 @@ function collectFootnotes(state: EditorState): FootnoteData { ? state.sliceDoc(contentNode.from, contentNode.to).trim() : ''; - definitions.set(id, { id, content, from, to }); + const def: FootnoteDefinition = { id, content, from, to }; + definitions.set(id, def); + definitionByPos.set(from, def); + allRanges.push([from, to]); } } else if (type.name === 'FootnoteReference') { const labelNode = node.getChild('FootnoteReferenceLabel'); @@ -140,8 +126,8 @@ function collectFootnotes(state: EditorState): FootnoteData { references.push(ref); referencesByPos.set(from, ref); + allRanges.push([from, to]); - // Track first reference for each id if (!firstRefById.has(id)) { firstRefById.set(id, ref); } @@ -162,48 +148,31 @@ function collectFootnotes(state: EditorState): FootnoteData { inlineFootnotes.push(info); inlineByPos.set(from, info); + allRanges.push([from, to]); } } }, }); - return { + cachedData = { definitions, references, inlineFootnotes, referencesByPos, inlineByPos, + definitionByPos, firstRefById, + allRanges, }; + cachedDocLength = state.doc.length; + + return cachedData; } // ============================================================================ -// State Field +// Widgets // ============================================================================ -/** - * State field to track footnote data across the document. - * This allows efficient lookup for tooltips and navigation. - */ -export const footnoteDataField = StateField.define({ - create(state) { - return collectFootnotes(state); - }, - update(value, tr) { - if (tr.docChanged) { - return collectFootnotes(tr.state); - } - return value; - }, -}); - -// ============================================================================ -// Widget -// ============================================================================ - -/** - * Widget to display footnote reference as superscript. - */ class FootnoteRefWidget extends WidgetType { constructor( readonly id: string, @@ -235,9 +204,6 @@ class FootnoteRefWidget extends WidgetType { } } -/** - * Widget to display inline footnote as superscript. - */ class InlineFootnoteWidget extends WidgetType { constructor( readonly content: string, @@ -265,9 +231,6 @@ class InlineFootnoteWidget extends WidgetType { } } -/** - * Widget to display footnote definition label. - */ class FootnoteDefLabelWidget extends WidgetType { constructor(readonly id: string) { super(); @@ -290,25 +253,46 @@ class FootnoteDefLabelWidget extends WidgetType { } } +// ============================================================================ +// Cursor Detection +// ============================================================================ + +/** + * Get which footnote range the cursor is in (returns start position, -1 if none). + */ +function getCursorFootnotePos(ranges: RangeTuple[], selFrom: number, selTo: number): number { + const selRange: RangeTuple = [selFrom, selTo]; + + for (const range of ranges) { + if (checkRangeOverlap(range, selRange)) { + return range[0]; + } + } + return -1; +} + // ============================================================================ // Decorations // ============================================================================ /** - * Build decorations for footnote references and inline footnotes. + * Build decorations using RangeSetBuilder. */ -function buildDecorations(view: EditorView): DecorationSet { - const decorations: Range[] = []; - const data = view.state.field(footnoteDataField); +function buildDecorations(view: EditorView, data: FootnoteData): DecorationSet { + const builder = new RangeSetBuilder(); + const items: { pos: number; endPos?: number; deco: Decoration; priority?: number }[] = []; + const { from: selFrom, to: selTo } = view.state.selection.main; + const selRange: RangeTuple = [selFrom, selTo]; for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ from, to, enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { - // Handle footnote references + const inCursor = checkRangeOverlap([nodeFrom, nodeTo], selRange); + + // Footnote References if (type.name === 'FootnoteReference') { - const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); const labelNode = node.getChild('FootnoteReferenceLabel'); const marks = node.getChildren('FootnoteReferenceMark'); @@ -317,51 +301,41 @@ function buildDecorations(view: EditorView): DecorationSet { const id = view.state.sliceDoc(labelNode.from, labelNode.to); const ref = data.referencesByPos.get(nodeFrom); - if (!cursorInRange && ref && ref.id === id) { - // Hide the entire syntax and show widget - decorations.push(invisibleDecoration.range(nodeFrom, nodeTo)); - - // Add widget at the end - const widget = new FootnoteRefWidget( - id, - ref.index, - data.definitions.has(id) - ); - decorations.push( - Decoration.widget({ - widget, + if (!inCursor && ref && ref.id === id) { + items.push({ pos: nodeFrom, endPos: nodeTo, deco: invisibleDecoration }); + items.push({ + pos: nodeTo, + deco: Decoration.widget({ + widget: new FootnoteRefWidget(id, ref.index, data.definitions.has(id)), side: 1, - }).range(nodeTo) - ); + }), + priority: 1 + }); } } - // Handle footnote definitions + // Footnote Definitions if (type.name === 'FootnoteDefinition') { - const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); const marks = node.getChildren('FootnoteDefinitionMark'); const labelNode = node.getChild('FootnoteDefinitionLabel'); - if (!cursorInRange && marks.length >= 2 && labelNode) { + if (!inCursor && marks.length >= 2 && labelNode) { const id = view.state.sliceDoc(labelNode.from, labelNode.to); - // Hide the entire [^id]: part - decorations.push(invisibleDecoration.range(marks[0].from, marks[1].to)); - - // Add widget to show [id] - const widget = new FootnoteDefLabelWidget(id); - decorations.push( - Decoration.widget({ - widget, + items.push({ pos: marks[0].from, endPos: marks[1].to, deco: invisibleDecoration }); + items.push({ + pos: marks[1].to, + deco: Decoration.widget({ + widget: new FootnoteDefLabelWidget(id), side: 1, - }).range(marks[1].to) - ); + }), + priority: 1 + }); } } - // Handle inline footnotes + // Inline Footnotes if (type.name === 'InlineFootnote') { - const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); const contentNode = node.getChild('InlineFootnoteContent'); const marks = node.getChildren('InlineFootnoteMark'); @@ -369,58 +343,80 @@ function buildDecorations(view: EditorView): DecorationSet { const inlineNote = data.inlineByPos.get(nodeFrom); - if (!cursorInRange && inlineNote) { - // Hide the entire syntax and show widget - decorations.push(invisibleDecoration.range(nodeFrom, nodeTo)); - - // Add widget at the end - const widget = new InlineFootnoteWidget( - inlineNote.content, - inlineNote.index - ); - decorations.push( - Decoration.widget({ - widget, + if (!inCursor && inlineNote) { + items.push({ pos: nodeFrom, endPos: nodeTo, deco: invisibleDecoration }); + items.push({ + pos: nodeTo, + deco: Decoration.widget({ + widget: new InlineFootnoteWidget(inlineNote.content, inlineNote.index), side: 1, - }).range(nodeTo) - ); + }), + priority: 1 + }); } } }, }); } - return Decoration.set(decorations, true); + // Sort by position, widgets after replace at same position + items.sort((a, b) => { + if (a.pos !== b.pos) return a.pos - b.pos; + return (a.priority || 0) - (b.priority || 0); + }); + + for (const item of items) { + if (item.endPos !== undefined) { + builder.add(item.pos, item.endPos, item.deco); + } else { + builder.add(item.pos, item.pos, item.deco); + } + } + + return builder.finish(); } // ============================================================================ -// Plugin Class +// Plugin // ============================================================================ -/** - * Footnote view plugin with optimized update detection. - */ class FootnotePlugin { decorations: DecorationSet; - private lastSelectionHead: number = -1; + private data: FootnoteData; + private cursorFootnotePos = -1; constructor(view: EditorView) { - this.decorations = buildDecorations(view); - this.lastSelectionHead = view.state.selection.main.head; + this.data = collectFootnotes(view.state); + const { from, to } = view.state.selection.main; + this.cursorFootnotePos = getCursorFootnotePos(this.data.allRanges, from, to); + this.decorations = buildDecorations(view, this.data); } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { - this.decorations = buildDecorations(update.view); - this.lastSelectionHead = update.state.selection.main.head; + const { docChanged, viewportChanged, selectionSet } = update; + + if (docChanged) { + // Invalidate cache on doc change + cachedData = null; + this.data = collectFootnotes(update.state); + const { from, to } = update.state.selection.main; + this.cursorFootnotePos = getCursorFootnotePos(this.data.allRanges, from, to); + this.decorations = buildDecorations(update.view, this.data); return; } - if (update.selectionSet) { - const newHead = update.state.selection.main.head; - if (newHead !== this.lastSelectionHead) { - this.decorations = buildDecorations(update.view); - this.lastSelectionHead = newHead; + if (viewportChanged) { + this.decorations = buildDecorations(update.view, this.data); + return; + } + + if (selectionSet) { + const { from, to } = update.state.selection.main; + const newPos = getCursorFootnotePos(this.data.allRanges, from, to); + + if (newPos !== this.cursorFootnotePos) { + this.cursorFootnotePos = newPos; + this.decorations = buildDecorations(update.view, this.data); } } } @@ -434,112 +430,99 @@ const footnotePlugin = ViewPlugin.fromClass(FootnotePlugin, { // Hover Tooltip // ============================================================================ -/** - * Hover tooltip that shows footnote content. - */ const footnoteHoverTooltip = hoverTooltip( (view, pos): Tooltip | null => { - const data = view.state.field(footnoteDataField); + const data = collectFootnotes(view.state); - // Check if hovering over a footnote reference widget - const target = document.elementFromPoint( - view.coordsAtPos(pos)?.left ?? 0, - view.coordsAtPos(pos)?.top ?? 0 - ) as HTMLElement | null; + // Check widget elements first + const coords = view.coordsAtPos(pos); + if (coords) { + const target = document.elementFromPoint(coords.left, coords.top) as HTMLElement | null; - if (target?.classList.contains('cm-footnote-ref')) { - const id = target.dataset.footnoteId; - if (id) { - const def = data.definitions.get(id); - if (def) { + if (target?.classList.contains('cm-footnote-ref')) { + const id = target.dataset.footnoteId; + if (id) { + const def = data.definitions.get(id); + if (def) { + return { + pos, + above: true, + arrow: true, + create: () => createTooltipDom(id, def.content), + }; + } + } + } + + if (target?.classList.contains('cm-inline-footnote-ref')) { + const content = target.dataset.footnoteContent; + const index = target.dataset.footnoteIndex; + if (content && index) { return { pos, above: true, arrow: true, - create: () => createTooltipDom(id, def.content), + create: () => createInlineTooltipDom(parseInt(index), content), }; } } } - // Check if hovering over an inline footnote widget - if (target?.classList.contains('cm-inline-footnote-ref')) { - const content = target.dataset.footnoteContent; - const index = target.dataset.footnoteIndex; - if (content && index) { - return { - pos, - above: true, - arrow: true, - create: () => createInlineTooltipDom(parseInt(index), content), - }; - } - } - - // Check if position is within a footnote reference node - let foundId: string | null = null; - let foundPos: number = pos; - let foundInlineContent: string | null = null; - let foundInlineIndex: number | null = null; - - syntaxTree(view.state).iterate({ - from: pos, - to: pos, - enter: ({ type, from, to, node }) => { - if (type.name === 'FootnoteReference') { - const labelNode = node.getChild('FootnoteReferenceLabel'); - if (labelNode && pos >= from && pos <= to) { - foundId = view.state.sliceDoc(labelNode.from, labelNode.to); - foundPos = to; - } - } else if (type.name === 'InlineFootnote') { - const contentNode = node.getChild('InlineFootnoteContent'); - if (contentNode && pos >= from && pos <= to) { - foundInlineContent = view.state.sliceDoc(contentNode.from, contentNode.to); - const inlineNote = data.inlineByPos.get(from); - if (inlineNote) { - foundInlineIndex = inlineNote.index; - } - foundPos = to; - } - } - }, - }); - - if (foundId) { - const def = data.definitions.get(foundId); + // Check by position using indexed data + const ref = data.referencesByPos.get(pos); + if (ref) { + const def = data.definitions.get(ref.id); if (def) { - const tooltipId = foundId; - const tooltipPos = foundPos; return { - pos: tooltipPos, + pos: ref.to, above: true, arrow: true, - create: () => createTooltipDom(tooltipId, def.content), + create: () => createTooltipDom(ref.id, def.content), }; } } - if (foundInlineContent && foundInlineIndex !== null) { - const tooltipContent = foundInlineContent; - const tooltipIndex = foundInlineIndex; - const tooltipPos = foundPos; + const inline = data.inlineByPos.get(pos); + if (inline) { return { - pos: tooltipPos, + pos: inline.to, above: true, arrow: true, - create: () => createInlineTooltipDom(tooltipIndex, tooltipContent), + create: () => createInlineTooltipDom(inline.index, inline.content), }; } + // Fallback: check if pos is within any footnote range + for (const ref of data.references) { + if (pos >= ref.from && pos <= ref.to) { + const def = data.definitions.get(ref.id); + if (def) { + return { + pos: ref.to, + above: true, + arrow: true, + create: () => createTooltipDom(ref.id, def.content), + }; + } + } + } + + for (const inline of data.inlineFootnotes) { + if (pos >= inline.from && pos <= inline.to) { + return { + pos: inline.to, + above: true, + arrow: true, + create: () => createInlineTooltipDom(inline.index, inline.content), + }; + } + } + return null; }, { hoverTime: 300 } ); -/** - * Create tooltip DOM element for regular footnote. - */ function createTooltipDom(id: string, content: string): { dom: HTMLElement } { const dom = document.createElement('div'); dom.className = 'cm-footnote-tooltip'; @@ -558,9 +541,6 @@ function createTooltipDom(id: string, content: string): { dom: HTMLElement } { return { dom }; } -/** - * Create tooltip DOM element for inline footnote. - */ function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } { const dom = document.createElement('div'); dom.className = 'cm-footnote-tooltip'; @@ -583,26 +563,18 @@ function createInlineTooltipDom(index: number, content: string): { dom: HTMLElem // Click Handler // ============================================================================ -/** - * Click handler for footnote navigation. - * Uses mousedown to intercept before editor moves cursor. - * - Click on reference → jump to definition - * - Click on definition label → jump to first reference - */ const footnoteClickHandler = EditorView.domEventHandlers({ mousedown(event, view) { const target = event.target as HTMLElement; - // Handle click on footnote reference widget + // Click on footnote reference → jump to definition if (target.classList.contains('cm-footnote-ref')) { const id = target.dataset.footnoteId; if (id) { - const data = view.state.field(footnoteDataField); + const data = collectFootnotes(view.state); const def = data.definitions.get(id); if (def) { - // Prevent default to stop cursor from moving to widget position event.preventDefault(); - // Use setTimeout to dispatch after mousedown completes setTimeout(() => { view.dispatch({ selection: { anchor: def.from }, @@ -615,30 +587,22 @@ const footnoteClickHandler = EditorView.domEventHandlers({ } } - // Handle click on definition label + // Click on definition label → jump to first reference if (target.classList.contains('cm-footnote-def-label')) { - const pos = view.posAtDOM(target); - if (pos !== null) { - const data = view.state.field(footnoteDataField); - - // Find which definition this belongs to - for (const [id, def] of data.definitions) { - if (pos >= def.from && pos <= def.to) { - // O(1) lookup for first reference - const firstRef = data.firstRefById.get(id); - if (firstRef) { - event.preventDefault(); - setTimeout(() => { - view.dispatch({ - selection: { anchor: firstRef.from }, - scrollIntoView: true, - }); - view.focus(); - }, 0); - return true; - } - break; - } + const id = target.dataset.footnoteId; + if (id) { + const data = collectFootnotes(view.state); + const firstRef = data.firstRefById.get(id); + if (firstRef) { + event.preventDefault(); + setTimeout(() => { + view.dispatch({ + selection: { anchor: firstRef.from }, + scrollIntoView: true, + }); + view.focus(); + }, 0); + return true; } } } @@ -651,11 +615,7 @@ const footnoteClickHandler = EditorView.domEventHandlers({ // Theme // ============================================================================ -/** - * Base theme for footnotes. - */ const baseTheme = EditorView.baseTheme({ - // Footnote reference (superscript) '.cm-footnote-ref': { display: 'inline-flex', alignItems: 'center', @@ -684,7 +644,6 @@ const baseTheme = EditorView.baseTheme({ backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))', }, - // Inline footnote reference (superscript) - uses distinct color '.cm-inline-footnote-ref': { display: 'inline-flex', alignItems: 'center', @@ -709,7 +668,6 @@ const baseTheme = EditorView.baseTheme({ backgroundColor: 'var(--cm-inline-footnote-hover-bg, rgba(230, 126, 34, 0.2))', }, - // Footnote definition label '.cm-footnote-def-label': { color: 'var(--cm-footnote-def-color, #1a73e8)', fontWeight: '600', @@ -719,7 +677,6 @@ const baseTheme = EditorView.baseTheme({ textDecoration: 'underline', }, - // Tooltip '.cm-footnote-tooltip': { maxWidth: '400px', padding: '0', @@ -746,7 +703,6 @@ const baseTheme = EditorView.baseTheme({ wordBreak: 'break-word', }, - // Tooltip animation '.cm-tooltip:has(.cm-footnote-tooltip)': { animation: 'cm-footnote-fade-in 0.15s ease-out', }, @@ -762,16 +718,8 @@ const baseTheme = EditorView.baseTheme({ /** * Footnote extension. - * - * Features: - * - Parses footnote references [^id] and definitions [^id]: content - * - Parses inline footnotes ^[content] - * - Renders references and inline footnotes as superscript numbers - * - Shows definition/content on hover - * - Click to navigate between reference and definition */ export const footnote = (): Extension => [ - footnoteDataField, footnotePlugin, footnoteHoverTooltip, footnoteClickHandler, @@ -780,3 +728,9 @@ export const footnote = (): Extension => [ export default footnote; +/** + * Get footnote data for external use. + */ +export function getFootnoteData(state: EditorState): FootnoteData { + return collectFootnotes(state); +} diff --git a/frontend/src/views/editor/extensions/markdown/plugins/heading.ts b/frontend/src/views/editor/extensions/markdown/plugins/heading.ts index a8f89f0..7fba5b6 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/heading.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/heading.ts @@ -1,88 +1,160 @@ import { syntaxTree } from '@codemirror/language'; -import { EditorState, StateField, Range } from '@codemirror/state'; -import { Decoration, DecorationSet, EditorView } from '@codemirror/view'; +import { Extension, RangeSetBuilder } from '@codemirror/state'; +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate +} from '@codemirror/view'; +import { checkRangeOverlap, RangeTuple } from '../util'; -/** - * Hidden mark decoration - uses visibility: hidden to hide content - */ +/** Hidden mark decoration */ const hiddenMarkDecoration = Decoration.mark({ class: 'cm-heading-mark-hidden' }); /** - * Check if selection overlaps with a range. + * Collect all heading ranges in visible viewport. */ -function isSelectionInRange(state: EditorState, from: number, to: number): boolean { - return state.selection.ranges.some( - (range) => from <= range.to && to >= range.from - ); +function collectHeadingRanges(view: EditorView): RangeTuple[] { + const ranges: RangeTuple[] = []; + const seen = new Set(); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter(node) { + if (!node.type.name.startsWith('ATXHeading') && + !node.type.name.startsWith('SetextHeading')) { + return; + } + if (seen.has(node.from)) return; + seen.add(node.from); + ranges.push([node.from, node.to]); + } + }); } - /** - * Build heading decorations. - * Hides # marks when cursor is not on the heading line. - */ -function buildHeadingDecorations(state: EditorState): DecorationSet { - const decorations: Range[] = []; - - syntaxTree(state).iterate({ - enter(node) { - // Skip if cursor is in this node's range - if (isSelectionInRange(state, node.from, node.to)) return; - - // Handle ATX headings (# Heading) - if (node.type.name.startsWith('ATXHeading')) { - const header = node.node.firstChild; - if (header && header.type.name === 'HeaderMark') { - const from = header.from; - // Include the space after # - const to = Math.min(header.to + 1, node.to); - decorations.push(hiddenMarkDecoration.range(from, to)); - } - } - // Handle Setext headings (underline style) - else if (node.type.name.startsWith('SetextHeading')) { - // Hide the underline marks (=== or ---) - const cursor = node.node.cursor(); - cursor.iterate((child) => { - if (child.type.name === 'HeaderMark') { - decorations.push( - hiddenMarkDecoration.range(child.from, child.to) - ); - } - }); - } - } - }); - - return Decoration.set(decorations, true); + return ranges; } /** - * Heading StateField - manages # mark visibility. + * Get which heading the cursor is in (-1 if none). */ -const headingField = StateField.define({ - create(state) { - return buildHeadingDecorations(state); - }, - - update(deco, tr) { - if (tr.docChanged || tr.selection) { - return buildHeadingDecorations(tr.state); +function getCursorHeadingPos(ranges: RangeTuple[], selFrom: number, selTo: number): number { + const selRange: RangeTuple = [selFrom, selTo]; + + for (const range of ranges) { + if (checkRangeOverlap(range, selRange)) { + return range[0]; } - return deco.map(tr.changes); - }, + } + return -1; +} - provide: (f) => EditorView.decorations.from(f) +/** + * Build heading decorations using RangeSetBuilder. + */ +function buildDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const items: { from: number; to: number }[] = []; + const { from: selFrom, to: selTo } = view.state.selection.main; + const selRange: RangeTuple = [selFrom, selTo]; + const seen = new Set(); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter(node) { + // Skip if cursor is in this heading + if (checkRangeOverlap([node.from, node.to], selRange)) return; + + // ATX headings (# Heading) + if (node.type.name.startsWith('ATXHeading')) { + if (seen.has(node.from)) return; + seen.add(node.from); + + const header = node.node.firstChild; + if (header && header.type.name === 'HeaderMark') { + const markFrom = header.from; + // Include the space after # + const markTo = Math.min(header.to + 1, node.to); + items.push({ from: markFrom, to: markTo }); + } + } + // Setext headings (underline style) + else if (node.type.name.startsWith('SetextHeading')) { + if (seen.has(node.from)) return; + seen.add(node.from); + + const cursor = node.node.cursor(); + cursor.iterate((child) => { + if (child.type.name === 'HeaderMark') { + items.push({ from: child.from, to: child.to }); + } + }); + } + } + }); + } + + // Sort by position and add to builder + items.sort((a, b) => a.from - b.from); + + for (const item of items) { + builder.add(item.from, item.to, hiddenMarkDecoration); + } + + return builder.finish(); +} + +/** + * Heading plugin with optimized updates. + */ +class HeadingPlugin { + decorations: DecorationSet; + private headingRanges: RangeTuple[] = []; + private cursorHeadingPos = -1; + + constructor(view: EditorView) { + this.headingRanges = collectHeadingRanges(view); + const { from, to } = view.state.selection.main; + this.cursorHeadingPos = getCursorHeadingPos(this.headingRanges, from, to); + this.decorations = buildDecorations(view); + } + + update(update: ViewUpdate) { + const { docChanged, viewportChanged, selectionSet } = update; + + if (docChanged || viewportChanged) { + this.headingRanges = collectHeadingRanges(update.view); + const { from, to } = update.state.selection.main; + this.cursorHeadingPos = getCursorHeadingPos(this.headingRanges, from, to); + this.decorations = buildDecorations(update.view); + return; + } + + if (selectionSet) { + const { from, to } = update.state.selection.main; + const newPos = getCursorHeadingPos(this.headingRanges, from, to); + + if (newPos !== this.cursorHeadingPos) { + this.cursorHeadingPos = newPos; + this.decorations = buildDecorations(update.view); + } + } + } +} + +const headingPlugin = ViewPlugin.fromClass(HeadingPlugin, { + decorations: (v) => v.decorations }); /** * Theme for hidden heading marks. - * - * Uses fontSize: 0 to hide the # mark without leaving whitespace. - * This works correctly now because blockLayer uses lineBlockAt() - * which calculates coordinates based on the entire line, not - * individual characters, so fontSize: 0 doesn't affect boundaries. */ const headingTheme = EditorView.baseTheme({ '.cm-heading-mark-hidden': { @@ -93,4 +165,4 @@ const headingTheme = EditorView.baseTheme({ /** * Headings plugin. */ -export const headings = () => [headingField, headingTheme]; +export const headings = (): Extension => [headingPlugin, headingTheme]; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/hide-mark.ts b/frontend/src/views/editor/extensions/markdown/plugins/hide-mark.ts index dcd6939..e82fdc7 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/hide-mark.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/hide-mark.ts @@ -5,26 +5,25 @@ import { ViewPlugin, ViewUpdate } from '@codemirror/view'; -import { RangeSetBuilder } from '@codemirror/state'; +import { Extension, RangeSetBuilder } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; -import { checkRangeOverlap, isCursorInRange } from '../util'; +import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; /** - * Node types that contain markers as child elements. + * Node types that contain markers to hide. + * Note: InlineCode is handled by inline-code.ts */ const TYPES_WITH_MARKS = new Set([ 'Emphasis', 'StrongEmphasis', - 'InlineCode', 'Strikethrough' ]); /** - * Node types that are markers themselves. + * Marker node types to hide. */ const MARK_TYPES = new Set([ 'EmphasisMark', - 'CodeMark', 'StrikethroughMark' ]); @@ -33,14 +32,51 @@ export const typesWithMarks = Array.from(TYPES_WITH_MARKS); export const markTypes = Array.from(MARK_TYPES); /** - * Build mark hiding decorations using RangeSetBuilder for optimal performance. + * Collect all mark ranges in visible viewport. */ -function buildHideMarkDecorations(view: EditorView): DecorationSet { - const builder = new RangeSetBuilder(); - const replaceDecoration = Decoration.replace({}); +function collectMarkRanges(view: EditorView): RangeTuple[] { + const ranges: RangeTuple[] = []; + const seen = new Set(); - // Track processed ranges to avoid duplicate processing of nested marks - let currentParentRange: [number, number] | null = null; + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo }) => { + if (!TYPES_WITH_MARKS.has(type.name)) return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); + ranges.push([nodeFrom, nodeTo]); + } + }); + } + + return ranges; +} + +/** + * Get which mark range the cursor is in (-1 if none). + */ +function getCursorMarkPos(ranges: RangeTuple[], selFrom: number, selTo: number): number { + const selRange: RangeTuple = [selFrom, selTo]; + + for (const range of ranges) { + if (checkRangeOverlap(range, selRange)) { + return range[0]; + } + } + return -1; +} + +/** + * Build mark hiding decorations. + */ +function buildDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const items: { from: number; to: number }[] = []; + const { from: selFrom, to: selTo } = view.state.selection.main; + const selRange: RangeTuple = [selFrom, selTo]; + const seen = new Set(); for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ @@ -48,92 +84,83 @@ function buildHideMarkDecorations(view: EditorView): DecorationSet { to, enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { if (!TYPES_WITH_MARKS.has(type.name)) return; - - // Skip if this is a nested element within a parent we're already processing - if (currentParentRange && checkRangeOverlap([nodeFrom, nodeTo], currentParentRange)) { - return; - } - - // Update current parent range - currentParentRange = [nodeFrom, nodeTo]; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); // Skip if cursor is in this range - if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return; + if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return; - // Iterate through child marks + // Collect mark positions const innerTree = node.toTree(); innerTree.iterate({ enter({ type: markType, from: markFrom, to: markTo }) { if (!MARK_TYPES.has(markType.name)) return; - - // Add decoration to hide the mark - builder.add( - nodeFrom + markFrom, - nodeFrom + markTo, - replaceDecoration - ); + items.push({ + from: nodeFrom + markFrom, + to: nodeFrom + markTo + }); } }); } }); } + // Sort and add to builder + items.sort((a, b) => a.from - b.from); + + for (const item of items) { + builder.add(item.from, item.to, invisibleDecoration); + } + return builder.finish(); } /** - * Hide marks plugin with optimized update detection. - * - * This plugin: - * - Hides emphasis marks (*, **, ~~ etc.) when cursor is outside - * - Uses RangeSetBuilder for efficient decoration construction - * - Optimizes selection change detection + * Hide marks plugin with optimized updates. + * + * Hides emphasis marks (*, **, ~~) when cursor is outside. + * Note: InlineCode backticks are handled by inline-code.ts */ class HideMarkPlugin { decorations: DecorationSet; - private lastSelectionRanges: string = ''; + private markRanges: RangeTuple[] = []; + private cursorMarkPos = -1; constructor(view: EditorView) { - this.decorations = buildHideMarkDecorations(view); - this.lastSelectionRanges = this.serializeSelection(view); + this.markRanges = collectMarkRanges(view); + const { from, to } = view.state.selection.main; + this.cursorMarkPos = getCursorMarkPos(this.markRanges, from, to); + this.decorations = buildDecorations(view); } update(update: ViewUpdate) { - // Always rebuild on doc or viewport change - if (update.docChanged || update.viewportChanged) { - this.decorations = buildHideMarkDecorations(update.view); - this.lastSelectionRanges = this.serializeSelection(update.view); + const { docChanged, viewportChanged, selectionSet } = update; + + if (docChanged || viewportChanged) { + this.markRanges = collectMarkRanges(update.view); + const { from, to } = update.state.selection.main; + this.cursorMarkPos = getCursorMarkPos(this.markRanges, from, to); + this.decorations = buildDecorations(update.view); return; } - // For selection changes, check if selection actually changed positions - if (update.selectionSet) { - const newRanges = this.serializeSelection(update.view); - if (newRanges !== this.lastSelectionRanges) { - this.decorations = buildHideMarkDecorations(update.view); - this.lastSelectionRanges = newRanges; + if (selectionSet) { + const { from, to } = update.state.selection.main; + const newPos = getCursorMarkPos(this.markRanges, from, to); + + if (newPos !== this.cursorMarkPos) { + this.cursorMarkPos = newPos; + this.decorations = buildDecorations(update.view); } } } - - /** - * Serialize selection ranges for comparison. - */ - private serializeSelection(view: EditorView): string { - return view.state.selection.ranges - .map(r => `${r.from}:${r.to}`) - .join(','); - } } /** * Hide marks plugin. - * - * This plugin: - * - Hides marks when they are not in the editor selection - * - Supports emphasis, strong, inline code, and strikethrough + * Hides marks for emphasis, strong, and strikethrough. */ -export const hideMarks = () => [ +export const hideMarks = (): Extension => [ ViewPlugin.fromClass(HideMarkPlugin, { decorations: (v) => v.decorations }) diff --git a/frontend/src/views/editor/extensions/markdown/plugins/highlight.ts b/frontend/src/views/editor/extensions/markdown/plugins/highlight.ts index ca6b070..1a1a54a 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/highlight.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/highlight.ts @@ -1,4 +1,4 @@ -import { Extension, Range } from '@codemirror/state'; +import { Extension, RangeSetBuilder } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; import { ViewPlugin, @@ -7,104 +7,150 @@ import { EditorView, ViewUpdate } from '@codemirror/view'; -import { isCursorInRange, invisibleDecoration } from '../util'; +import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; + +/** Mark decoration for highlighted content */ +const highlightMarkDecoration = Decoration.mark({ class: 'cm-highlight' }); /** * Highlight plugin using syntax tree. * - * Uses the custom Highlight extension to detect: - * - Highlight: ==text== → renders as highlighted text - * - * Examples: - * - This is ==important== text → This is important text - * - Please ==review this section== carefully + * Detects ==text== and renders as highlighted text. */ -export const highlight = (): Extension => [ - highlightPlugin, - baseTheme -]; +export const highlight = (): Extension => [highlightPlugin, baseTheme]; /** - * Build decorations for highlight using syntax tree. + * Collect all highlight ranges in visible viewport. + */ +function collectHighlightRanges(view: EditorView): RangeTuple[] { + const ranges: RangeTuple[] = []; + const seen = new Set(); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo }) => { + if (type.name !== 'Highlight') return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); + ranges.push([nodeFrom, nodeTo]); + } + }); + } + + return ranges; +} + +/** + * Get which highlight the cursor is in (-1 if none). + */ +function getCursorHighlightPos(ranges: RangeTuple[], selFrom: number, selTo: number): number { + const selRange: RangeTuple = [selFrom, selTo]; + + for (const range of ranges) { + if (checkRangeOverlap(range, selRange)) { + return range[0]; + } + } + return -1; +} + +/** + * Build highlight decorations. */ function buildDecorations(view: EditorView): DecorationSet { - const decorations: Range[] = []; + const builder = new RangeSetBuilder(); + const items: { from: number; to: number; deco: Decoration }[] = []; + const { from: selFrom, to: selTo } = view.state.selection.main; + const selRange: RangeTuple = [selFrom, selTo]; + const seen = new Set(); for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ from, to, enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { - // Handle Highlight nodes - if (type.name === 'Highlight') { - const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); + if (type.name !== 'Highlight') return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); - // Get the mark nodes (the == characters) - const marks = node.getChildren('HighlightMark'); + // Skip if cursor is in this highlight + if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return; - if (!cursorInRange && marks.length >= 2) { - // Hide the opening and closing == marks - decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to)); - decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to)); + const marks = node.getChildren('HighlightMark'); + if (marks.length < 2) return; - // Apply highlight style to the content between marks - const contentStart = marks[0].to; - const contentEnd = marks[marks.length - 1].from; - if (contentStart < contentEnd) { - decorations.push( - Decoration.mark({ - class: 'cm-highlight' - }).range(contentStart, contentEnd) - ); - } - } + // Hide opening == + items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration }); + + // Apply highlight style to content + const contentStart = marks[0].to; + const contentEnd = marks[marks.length - 1].from; + if (contentStart < contentEnd) { + items.push({ from: contentStart, to: contentEnd, deco: highlightMarkDecoration }); } + + // Hide closing == + items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration }); } }); } - return Decoration.set(decorations, true); + // Sort and add to builder + items.sort((a, b) => a.from - b.from); + + for (const item of items) { + builder.add(item.from, item.to, item.deco); + } + + return builder.finish(); } /** - * Plugin class with optimized update detection. + * Highlight plugin with optimized updates. */ class HighlightPlugin { decorations: DecorationSet; - private lastSelectionHead: number = -1; + private highlightRanges: RangeTuple[] = []; + private cursorHighlightPos = -1; constructor(view: EditorView) { + this.highlightRanges = collectHighlightRanges(view); + const { from, to } = view.state.selection.main; + this.cursorHighlightPos = getCursorHighlightPos(this.highlightRanges, from, to); this.decorations = buildDecorations(view); - this.lastSelectionHead = view.state.selection.main.head; } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { + const { docChanged, viewportChanged, selectionSet } = update; + + if (docChanged || viewportChanged) { + this.highlightRanges = collectHighlightRanges(update.view); + const { from, to } = update.state.selection.main; + this.cursorHighlightPos = getCursorHighlightPos(this.highlightRanges, from, to); this.decorations = buildDecorations(update.view); - this.lastSelectionHead = update.state.selection.main.head; return; } - if (update.selectionSet) { - const newHead = update.state.selection.main.head; - if (newHead !== this.lastSelectionHead) { + if (selectionSet) { + const { from, to } = update.state.selection.main; + const newPos = getCursorHighlightPos(this.highlightRanges, from, to); + + if (newPos !== this.cursorHighlightPos) { + this.cursorHighlightPos = newPos; this.decorations = buildDecorations(update.view); - this.lastSelectionHead = newHead; } } } } -const highlightPlugin = ViewPlugin.fromClass( - HighlightPlugin, - { - decorations: (v) => v.decorations - } -); +const highlightPlugin = ViewPlugin.fromClass(HighlightPlugin, { + decorations: (v) => v.decorations +}); /** * Base theme for highlight. - * Uses mark decoration with a subtle background color. */ const baseTheme = EditorView.baseTheme({ '.cm-highlight': { @@ -112,4 +158,3 @@ const baseTheme = EditorView.baseTheme({ borderRadius: '2px', } }); - diff --git a/frontend/src/views/editor/extensions/markdown/plugins/horizontal-rule.ts b/frontend/src/views/editor/extensions/markdown/plugins/horizontal-rule.ts index 6ace7c2..e0de9f5 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/horizontal-rule.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/horizontal-rule.ts @@ -1,28 +1,27 @@ -import { Extension, StateField, EditorState, Range } from '@codemirror/state'; +import { Extension, RangeSetBuilder } from '@codemirror/state'; import { DecorationSet, Decoration, EditorView, + ViewPlugin, + ViewUpdate, WidgetType } from '@codemirror/view'; -import { isCursorInRange } from '../util'; +import { checkRangeOverlap, RangeTuple } from '../util'; import { syntaxTree } from '@codemirror/language'; /** * Horizontal rule plugin that renders beautiful horizontal lines. * - * This plugin: + * Features: * - Replaces markdown horizontal rules (---, ***, ___) with styled
elements * - Shows the original text when cursor is on the line * - Uses inline widget to avoid affecting block system boundaries */ -export const horizontalRule = (): Extension => [ - horizontalRuleField, - baseTheme -]; +export const horizontalRule = (): Extension => [horizontalRulePlugin, baseTheme]; /** - * Widget to display a horizontal rule (inline version). + * Widget to display a horizontal rule. */ class HorizontalRuleWidget extends WidgetType { toDOM(): HTMLElement { @@ -45,54 +44,127 @@ class HorizontalRuleWidget extends WidgetType { } } +/** Shared widget instance (all HR widgets are identical) */ +const hrWidget = new HorizontalRuleWidget(); + /** - * Build horizontal rule decorations. - * Uses Decoration.replace WITHOUT block: true to avoid affecting block system. + * Collect all horizontal rule ranges in visible viewport. */ -function buildHorizontalRuleDecorations(state: EditorState): DecorationSet { - const decorations: Range[] = []; +function collectHRRanges(view: EditorView): RangeTuple[] { + const ranges: RangeTuple[] = []; + const seen = new Set(); - syntaxTree(state).iterate({ - enter: ({ type, from, to }) => { - if (type.name !== 'HorizontalRule') return; + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo }) => { + if (type.name !== 'HorizontalRule') return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); + ranges.push([nodeFrom, nodeTo]); + } + }); + } - // Skip if cursor is on this line - if (isCursorInRange(state, [from, to])) return; - - // Replace the entire horizontal rule with a styled widget - // NOTE: NOT using block: true to avoid affecting codeblock boundaries - decorations.push( - Decoration.replace({ - widget: new HorizontalRuleWidget() - }).range(from, to) - ); - } - }); - - return Decoration.set(decorations, true); + return ranges; } /** - * StateField for horizontal rule decorations. + * Get which HR the cursor is in (-1 if none). */ -const horizontalRuleField = StateField.define({ - create(state) { - return buildHorizontalRuleDecorations(state); - }, - update(value, tx) { - if (tx.docChanged || tx.selection) { - return buildHorizontalRuleDecorations(tx.state); +function getCursorHRPos(ranges: RangeTuple[], selFrom: number, selTo: number): number { + const selRange: RangeTuple = [selFrom, selTo]; + + for (const range of ranges) { + if (checkRangeOverlap(range, selRange)) { + return range[0]; } - return value.map(tx.changes); - }, - provide(field) { - return EditorView.decorations.from(field); } + return -1; +} + +/** + * Build horizontal rule decorations. + */ +function buildDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const items: { from: number; to: number }[] = []; + const { from: selFrom, to: selTo } = view.state.selection.main; + const selRange: RangeTuple = [selFrom, selTo]; + const seen = new Set(); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo }) => { + if (type.name !== 'HorizontalRule') return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); + + // Skip if cursor is on this HR + if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return; + + items.push({ from: nodeFrom, to: nodeTo }); + } + }); + } + + // Sort and add to builder + items.sort((a, b) => a.from - b.from); + + for (const item of items) { + builder.add(item.from, item.to, Decoration.replace({ widget: hrWidget })); + } + + return builder.finish(); +} + +/** + * Horizontal rule plugin with optimized updates. + */ +class HorizontalRulePlugin { + decorations: DecorationSet; + private hrRanges: RangeTuple[] = []; + private cursorHRPos = -1; + + constructor(view: EditorView) { + this.hrRanges = collectHRRanges(view); + const { from, to } = view.state.selection.main; + this.cursorHRPos = getCursorHRPos(this.hrRanges, from, to); + this.decorations = buildDecorations(view); + } + + update(update: ViewUpdate) { + const { docChanged, viewportChanged, selectionSet } = update; + + if (docChanged || viewportChanged) { + this.hrRanges = collectHRRanges(update.view); + const { from, to } = update.state.selection.main; + this.cursorHRPos = getCursorHRPos(this.hrRanges, from, to); + this.decorations = buildDecorations(update.view); + return; + } + + if (selectionSet) { + const { from, to } = update.state.selection.main; + const newPos = getCursorHRPos(this.hrRanges, from, to); + + if (newPos !== this.cursorHRPos) { + this.cursorHRPos = newPos; + this.decorations = buildDecorations(update.view); + } + } + } +} + +const horizontalRulePlugin = ViewPlugin.fromClass(HorizontalRulePlugin, { + decorations: (v) => v.decorations }); /** * Base theme for horizontal rules. - * Uses inline-block display to render properly without block: true. */ const baseTheme = EditorView.baseTheme({ '.cm-horizontal-rule-widget': { diff --git a/frontend/src/views/editor/extensions/markdown/plugins/html.ts b/frontend/src/views/editor/extensions/markdown/plugins/html.ts index d270678..3743c34 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/html.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/html.ts @@ -1,208 +1,348 @@ +/** + * HTML plugin for CodeMirror. + * + * Features: + * - Identifies HTML blocks and tags (excluding those inside tables) + * - Shows indicator icon at the end + * - Click to preview rendered HTML + */ + import { syntaxTree } from '@codemirror/language'; -import { EditorState, Range } from '@codemirror/state'; +import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state'; import { - Decoration, DecorationSet, + Decoration, + WidgetType, EditorView, ViewPlugin, ViewUpdate, - WidgetType + showTooltip, + Tooltip } from '@codemirror/view'; import DOMPurify from 'dompurify'; -import { isCursorInRange } from '../util'; +import { LruCache } from '@/common/utils/lruCache'; -interface EmbedBlockData { +interface HTMLBlockInfo { from: number; to: number; content: string; + sanitized: string; } -/** - * Extract all HTML blocks from the document (both HTMLBlock and HTMLTag). - * Returns all blocks regardless of cursor position. - */ -function extractAllHTMLBlocks(state: EditorState): EmbedBlockData[] { - const blocks = new Array(); - syntaxTree(state).iterate({ - enter({ from, to, name }) { - // Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag) - if (name !== 'HTMLBlock' && name !== 'HTMLTag') return; - const html = state.sliceDoc(from, to); - const content = DOMPurify.sanitize(html); - - // Skip empty content after sanitization - if (!content.trim()) return; - - blocks.push({ from, to, content }); - } - }); - return blocks; -} +// HTML5 official logo +const HTML_ICON = ``; /** - * Build decorations for HTML blocks. - * Only shows preview for blocks where cursor is not inside. + * LRU cache for DOMPurify sanitize results. */ -function buildDecorations(state: EditorState, blocks: EmbedBlockData[]): DecorationSet { - const decorations: Range[] = []; - - for (const block of blocks) { - // Skip if cursor is in range - if (isCursorInRange(state, [block.from, block.to])) continue; - - // Hide the original HTML source code - decorations.push(Decoration.replace({}).range(block.from, block.to)); - - // Add the preview widget at the end - decorations.push( - Decoration.widget({ - widget: new HTMLBlockWidget(block), - side: 1 - }).range(block.to) - ); +const sanitizeCache = new LruCache(100); + +/** + * Sanitize HTML content with caching for performance. + */ +function sanitizeHTML(html: string): string { + const cached = sanitizeCache.get(html); + if (cached !== undefined) { + return cached; } - return Decoration.set(decorations, true); + const sanitized = DOMPurify.sanitize(html, { + ADD_TAGS: ['img'], + ADD_ATTR: ['src', 'alt', 'width', 'height', 'style', 'class', 'loading'], + ALLOW_DATA_ATTR: true + }); + + sanitizeCache.set(html, sanitized); + return sanitized; } /** - * Check if selection affects any HTML block (cursor moved in/out of a block). + * Check if document changes affect any of the given regions. */ -function selectionAffectsBlocks( - state: EditorState, - prevState: EditorState, - blocks: EmbedBlockData[] -): boolean { - for (const block of blocks) { - const wasInRange = isCursorInRange(prevState, [block.from, block.to]); - const isInRange = isCursorInRange(state, [block.from, block.to]); - if (wasInRange !== isInRange) return true; +function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean { + if (regions.length === 0) return true; + + let affected = false; + changes.iterChanges((fromA, toA) => { + if (affected) return; + for (const region of regions) { + if (fromA <= region.to && toA >= region.from) { + affected = true; + return; + } + } + }); + return affected; +} + +/** + * Check if a node is inside a table. + */ +function isInsideTable(node: { parent: { type: { name: string }; parent: unknown } | null }): boolean { + let current = node.parent; + while (current) { + const name = current.type.name; + if (name === 'Table' || name === 'TableHeader' || name === 'TableRow' || name === 'TableCell') { + return true; + } + current = current.parent as typeof current; } return false; } /** - * ViewPlugin for HTML block preview. - * Uses smart caching to avoid unnecessary updates during text selection. + * Extract all HTML blocks from visible ranges. + * Excludes HTML inside tables (tables have their own rendering). + */ +function extractHTMLBlocks(view: EditorView): HTMLBlockInfo[] { + const result: HTMLBlockInfo[] = []; + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: (nodeRef) => { + const { name, from: f, to: t, node } = nodeRef; + + // Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag) + if (name !== 'HTMLBlock' && name !== 'HTMLTag') return; + + // Skip HTML inside tables + if (isInsideTable(node)) return; + + const content = view.state.sliceDoc(f, t); + const sanitized = sanitizeHTML(content); + + // Skip empty content after sanitization + if (!sanitized.trim()) return; + + result.push({ from: f, to: t, content, sanitized }); + } + }); + } + + return result; +} + +/** Effect to toggle tooltip visibility */ +const toggleHTMLTooltip = StateEffect.define(); + +/** Effect to close tooltip */ +const closeHTMLTooltip = StateEffect.define(); + +/** StateField to track active tooltip */ +const htmlTooltipState = StateField.define({ + create: () => null, + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(toggleHTMLTooltip)) { + // Toggle: if same block, close; otherwise open new + if (value && effect.value && value.from === effect.value.from) { + return null; + } + return effect.value; + } + if (effect.is(closeHTMLTooltip)) { + return null; + } + } + // Close tooltip on document changes + if (tr.docChanged) { + return null; + } + return value; + }, + provide: (field) => + showTooltip.from(field, (block): Tooltip | null => { + if (!block) return null; + return { + pos: block.to, + above: true, + create: () => { + const dom = document.createElement('div'); + dom.className = 'cm-html-tooltip'; + dom.innerHTML = block.sanitized; + + // Prevent clicks inside tooltip from closing it + dom.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + return { dom }; + } + }; + }) +}); +/** + * Indicator widget shown at the end of HTML blocks. + * Clicking toggles the tooltip. + */ +class HTMLIndicatorWidget extends WidgetType { + constructor(readonly info: HTMLBlockInfo) { + super(); + } + + toDOM(view: EditorView): HTMLElement { + const el = document.createElement('span'); + el.className = 'cm-html-indicator'; + el.innerHTML = HTML_ICON; + el.title = 'Click to preview HTML'; + + // Click handler to toggle tooltip + el.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + view.dispatch({ + effects: toggleHTMLTooltip.of(this.info) + }); + }); + + return el; + } + + eq(other: HTMLIndicatorWidget): boolean { + return this.info.from === other.info.from && this.info.content === other.info.content; + } + + ignoreEvent(): boolean { + return false; + } +} + +/** + * Plugin to manage HTML block decorations. + * Optimized with incremental updates when changes don't affect HTML regions. */ class HTMLBlockPlugin { decorations: DecorationSet; - blocks: EmbedBlockData[]; + blocks: HTMLBlockInfo[] = []; constructor(view: EditorView) { - this.blocks = extractAllHTMLBlocks(view.state); - this.decorations = buildDecorations(view.state, this.blocks); + this.blocks = extractHTMLBlocks(view); + this.decorations = this.build(); } update(update: ViewUpdate) { - // If document changed, re-extract all blocks - if (update.docChanged) { - this.blocks = extractAllHTMLBlocks(update.state); - this.decorations = buildDecorations(update.state, this.blocks); + // Always rebuild on viewport change + if (update.viewportChanged) { + this.blocks = extractHTMLBlocks(update.view); + this.decorations = this.build(); return; } - - // If selection changed, only rebuild if cursor moved in/out of a block - if (update.selectionSet) { - if (selectionAffectsBlocks(update.state, update.startState, this.blocks)) { - this.decorations = buildDecorations(update.state, this.blocks); + + // For document changes, only rebuild if changes affect HTML regions + if (update.docChanged) { + const needsRebuild = changesAffectRegions(update.changes, this.blocks); + + if (needsRebuild) { + this.blocks = extractHTMLBlocks(update.view); + this.decorations = this.build(); + } else { + // Just update positions of existing decorations + this.decorations = this.decorations.map(update.changes); + this.blocks = this.blocks.map(block => ({ + ...block, + from: update.changes.mapPos(block.from), + to: update.changes.mapPos(block.to) + })); } } } + + private build(): DecorationSet { + const deco: Range[] = []; + for (const block of this.blocks) { + deco.push( + Decoration.widget({ + widget: new HTMLIndicatorWidget(block), + side: 1 + }).range(block.to) + ); + } + return Decoration.set(deco, true); + } } const htmlBlockPlugin = ViewPlugin.fromClass(HTMLBlockPlugin, { decorations: (v) => v.decorations }); -class HTMLBlockWidget extends WidgetType { - constructor(public data: EmbedBlockData) { - super(); - } - - toDOM(view: EditorView): HTMLElement { - const wrapper = document.createElement('span'); - wrapper.className = 'cm-html-block-widget'; - - // Content container - const content = document.createElement('span'); - content.className = 'cm-html-block-content'; - // This is sanitized! - content.innerHTML = this.data.content; - - // Edit button - const editBtn = document.createElement('button'); - editBtn.className = 'cm-html-block-edit-btn'; - editBtn.innerHTML = ` - - - `; - editBtn.title = 'Edit HTML'; - - editBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - view.dispatch({ - selection: { anchor: this.data.from } - }); - view.focus(); - }); - - wrapper.appendChild(content); - wrapper.appendChild(editBtn); - - return wrapper; - } - - eq(widget: HTMLBlockWidget): boolean { - return JSON.stringify(widget.data) === JSON.stringify(this.data); - } -} - /** - * Base theme for HTML blocks. + * Close tooltip when clicking outside. */ -const baseTheme = EditorView.baseTheme({ - '.cm-html-block-widget': { - display: 'inline-block', - position: 'relative', - maxWidth: '100%', - overflow: 'auto', - verticalAlign: 'middle' +const clickOutsideHandler = EditorView.domEventHandlers({ + click(event, view) { + const target = event.target as HTMLElement; + + // Don't close if clicking on indicator or inside tooltip + if (target.closest('.cm-html-indicator') || target.closest('.cm-html-tooltip')) { + return false; + } + + // Close tooltip if one is open + const currentTooltip = view.state.field(htmlTooltipState); + if (currentTooltip) { + view.dispatch({ + effects: closeHTMLTooltip.of(null) + }); + } + + return false; + } +}); + +const theme = EditorView.baseTheme({ + // Indicator icon + '.cm-html-indicator': { + display: 'inline-flex', + alignItems: 'center', + marginLeft: '4px', + verticalAlign: 'middle', + cursor: 'pointer', + opacity: '0.5', + color: 'var(--cm-html-color, #e44d26)', + transition: 'opacity 0.15s', + '& svg': { width: '14px', height: '14px' } }, - '.cm-html-block-content': { - display: 'inline-block' + '.cm-html-indicator:hover': { + opacity: '1' }, - // Ensure images are properly sized - '.cm-html-block-content img': { + + // Tooltip content + '.cm-html-tooltip': { + padding: '8px 12px', + maxWidth: '60vw', + maxHeight: '50vh', + overflow: 'auto' + }, + + // Images inside tooltip + '.cm-html-tooltip img': { maxWidth: '100%', height: 'auto', display: 'block' }, - '.cm-html-block-edit-btn': { - position: 'absolute', - top: '4px', - right: '4px', - padding: '4px', - border: 'none', + + // Style the parent tooltip container + '.cm-tooltip:has(.cm-html-tooltip)': { + background: 'var(--bg-primary, #fff)', + border: '1px solid var(--border-color, #ddd)', borderRadius: '4px', - background: 'rgba(128, 128, 128, 0.2)', - color: 'inherit', - cursor: 'pointer', - opacity: '0', - transition: 'opacity 0.2s, background 0.2s', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - zIndex: '10' - }, - '.cm-html-block-widget:hover .cm-html-block-edit-btn': { - opacity: '1' - }, - '.cm-html-block-edit-btn:hover': { - background: 'rgba(128, 128, 128, 0.4)' + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)' } }); -// Export the extension with theme -export const htmlBlockExtension = [htmlBlockPlugin, baseTheme]; +/** + * HTML block extension. + * + * Features: + * - Identifies HTML blocks and tags (excluding those inside tables) + * - Shows indicator icon at the end + * - Click to preview rendered HTML + */ +export const htmlBlockExtension: Extension = [ + htmlBlockPlugin, + htmlTooltipState, + clickOutsideHandler, + theme +]; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/image.ts b/frontend/src/views/editor/extensions/markdown/plugins/image.ts index 7be6a1e..29f1811 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/image.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/image.ts @@ -1,5 +1,14 @@ +/** + * Image plugin for CodeMirror. + * + * Features: + * - Identifies markdown images + * - Shows indicator icon at the end + * - Click to preview image + */ + import { syntaxTree } from '@codemirror/language'; -import { Extension, Range } from '@codemirror/state'; +import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state'; import { DecorationSet, Decoration, @@ -7,7 +16,7 @@ import { EditorView, ViewPlugin, ViewUpdate, - hoverTooltip, + showTooltip, Tooltip } from '@codemirror/view'; @@ -26,6 +35,25 @@ function isImageUrl(url: string): boolean { return IMAGE_EXT_RE.test(url) || url.startsWith('data:image/'); } +/** + * Check if document changes affect any of the given regions. + */ +function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean { + if (regions.length === 0) return true; + + let affected = false; + changes.iterChanges((fromA, toA) => { + if (affected) return; + for (const region of regions) { + if (fromA <= region.to && toA >= region.from) { + affected = true; + return; + } + } + }); + return affected; +} + function extractImages(view: EditorView): ImageInfo[] { const result: ImageInfo[] = []; for (const { from, to } of view.visibleRanges) { @@ -47,23 +75,115 @@ function extractImages(view: EditorView): ImageInfo[] { return result; } +/** Effect to toggle tooltip visibility */ +const toggleImageTooltip = StateEffect.define(); + +/** Effect to close tooltip */ +const closeImageTooltip = StateEffect.define(); + +/** StateField to track active tooltip */ +const imageTooltipState = StateField.define({ + create: () => null, + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(toggleImageTooltip)) { + // Toggle: if same image, close; otherwise open new + if (value && effect.value && value.from === effect.value.from) { + return null; + } + return effect.value; + } + if (effect.is(closeImageTooltip)) { + return null; + } + } + // Close tooltip on document changes + if (tr.docChanged) { + return null; + } + return value; + }, + provide: (field) => + showTooltip.from(field, (img): Tooltip | null => { + if (!img) return null; + return { + pos: img.to, + above: true, + create: () => { + const dom = document.createElement('div'); + dom.className = 'cm-image-tooltip cm-image-loading'; + + const spinner = document.createElement('span'); + spinner.className = 'cm-image-spinner'; + + const imgEl = document.createElement('img'); + imgEl.src = img.src; + imgEl.alt = img.alt; + + imgEl.onload = () => { + dom.classList.remove('cm-image-loading'); + }; + imgEl.onerror = () => { + spinner.remove(); + imgEl.remove(); + dom.textContent = 'Failed to load image'; + dom.classList.remove('cm-image-loading'); + dom.classList.add('cm-image-tooltip-error'); + }; + + dom.append(spinner, imgEl); + + // Prevent clicks inside tooltip from closing it + dom.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + return { dom }; + } + }; + }) +}); + +/** + * Indicator widget shown at the end of images. + * Clicking toggles the tooltip. + */ class IndicatorWidget extends WidgetType { constructor(readonly info: ImageInfo) { super(); } - toDOM(): HTMLElement { + toDOM(view: EditorView): HTMLElement { const el = document.createElement('span'); el.className = 'cm-image-indicator'; el.innerHTML = ICON; + el.title = 'Click to preview image'; + + // Click handler to toggle tooltip + el.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + view.dispatch({ + effects: toggleImageTooltip.of(this.info) + }); + }); + return el; } eq(other: IndicatorWidget): boolean { return this.info.from === other.info.from && this.info.src === other.info.src; } + + ignoreEvent(): boolean { + return false; + } } +/** + * Plugin to manage image decorations. + * Optimized with incremental updates when changes don't affect image regions. + */ class ImagePlugin { decorations: DecorationSet; images: ImageInfo[] = []; @@ -74,9 +194,29 @@ class ImagePlugin { } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { + // Always rebuild on viewport change + if (update.viewportChanged) { this.images = extractImages(update.view); this.decorations = this.build(); + return; + } + + // For document changes, only rebuild if changes affect image regions + if (update.docChanged) { + const needsRebuild = changesAffectRegions(update.changes, this.images); + + if (needsRebuild) { + this.images = extractImages(update.view); + this.decorations = this.build(); + } else { + // Just update positions of existing decorations + this.decorations = this.decorations.map(update.changes); + this.images = this.images.map(img => ({ + ...img, + from: update.changes.mapPos(img.from), + to: update.changes.mapPos(img.to) + })); + } } } @@ -87,62 +227,35 @@ class ImagePlugin { } return Decoration.set(deco, true); } - - getImageAt(pos: number): ImageInfo | null { - for (const img of this.images) { - if (pos >= img.to && pos <= img.to + 1) { - return img; - } - } - return null; - } } const imagePlugin = ViewPlugin.fromClass(ImagePlugin, { decorations: (v) => v.decorations }); -const imageHoverTooltip = hoverTooltip( - (view, pos): Tooltip | null => { - const plugin = view.plugin(imagePlugin); - if (!plugin) return null; +/** + * Close tooltip when clicking outside. + */ +const clickOutsideHandler = EditorView.domEventHandlers({ + click(event, view) { + const target = event.target as HTMLElement; - const img = plugin.getImageAt(pos); - if (!img) return null; + // Don't close if clicking on indicator or inside tooltip + if (target.closest('.cm-image-indicator') || target.closest('.cm-image-tooltip')) { + return false; + } - return { - pos: img.to, - above: true, - arrow: true, - create: () => { - const dom = document.createElement('div'); - dom.className = 'cm-image-tooltip cm-image-loading'; + // Close tooltip if one is open + const currentTooltip = view.state.field(imageTooltipState); + if (currentTooltip) { + view.dispatch({ + effects: closeImageTooltip.of(null) + }); + } - const spinner = document.createElement('span'); - spinner.className = 'cm-image-spinner'; - - const imgEl = document.createElement('img'); - imgEl.src = img.src; - imgEl.alt = img.alt; - - imgEl.onload = () => { - dom.classList.remove('cm-image-loading'); - }; - imgEl.onerror = () => { - spinner.remove(); - imgEl.remove(); - dom.textContent = 'Failed to load image'; - dom.classList.remove('cm-image-loading'); - dom.classList.add('cm-image-tooltip-error'); - }; - - dom.append(spinner, imgEl); - return { dom }; - } - }; - }, - { hoverTime: 300 } -); + return false; + } +}); const theme = EditorView.baseTheme({ '.cm-image-indicator': { @@ -157,6 +270,7 @@ const theme = EditorView.baseTheme({ '& svg': { width: '14px', height: '14px' } }, '.cm-image-indicator:hover': { opacity: '1' }, + '.cm-image-tooltip': { position: 'relative', background: ` @@ -205,16 +319,13 @@ const theme = EditorView.baseTheme({ '.cm-image-tooltip-error': { padding: '16px 24px', fontSize: '12px', - color: 'var(--text-muted)' - }, - '.cm-tooltip-arrow:before': { - borderTopColor: 'var(--border-color) !important', - borderBottomColor: 'var(--border-color) !important' - }, - '.cm-tooltip-arrow:after': { - borderTopColor: '#fff !important', - borderBottomColor: '#fff !important' + color: 'red' } }); -export const image = (): Extension => [imagePlugin, imageHoverTooltip, theme]; +export const image = (): Extension => [ + imagePlugin, + imageTooltipState, + clickOutsideHandler, + theme +]; 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 f766e5e..dce323a 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts @@ -1,4 +1,4 @@ -import { Extension, Range } from '@codemirror/state'; +import { Extension, RangeSetBuilder } from '@codemirror/state'; import { Decoration, DecorationSet, @@ -7,23 +7,26 @@ import { ViewUpdate } from '@codemirror/view'; import { syntaxTree } from '@codemirror/language'; -import { isCursorInRange } from '../util'; +import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; + +/** Mark decoration for code content */ +const codeMarkDecoration = Decoration.mark({ class: 'cm-inline-code' }); /** * Inline code styling plugin. * - * This plugin adds visual styling to inline code (`code`): - * - Background color - * - Border radius - * - Padding effect via marks + * Features: + * - Adds background color, border radius, padding to code content + * - Hides backtick markers when cursor is outside */ export const inlineCode = (): Extension => [inlineCodePlugin, baseTheme]; /** - * Build inline code decorations. + * Collect all inline code ranges in visible viewport. */ -function buildInlineCodeDecorations(view: EditorView): DecorationSet { - const decorations: Range[] = []; +function collectCodeRanges(view: EditorView): RangeTuple[] { + const ranges: RangeTuple[] = []; + const seen = new Set(); for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ @@ -31,63 +34,134 @@ function buildInlineCodeDecorations(view: EditorView): DecorationSet { to, enter: ({ type, from: nodeFrom, to: nodeTo }) => { if (type.name !== 'InlineCode') return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); + ranges.push([nodeFrom, nodeTo]); + } + }); + } - 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; + return ranges; +} + +/** + * Get which inline code the cursor is in (-1 if none). + */ +function getCursorCodePos(ranges: RangeTuple[], selFrom: number, selTo: number): number { + const selRange: RangeTuple = [selFrom, selTo]; + + for (const range of ranges) { + if (checkRangeOverlap(range, selRange)) { + return range[0]; + } + } + return -1; +} + +/** + * Build inline code decorations. + */ +function buildDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const items: { from: number; to: number; deco: Decoration }[] = []; + const { from: selFrom, to: selTo } = view.state.selection.main; + const selRange: RangeTuple = [selFrom, selTo]; + const seen = new Set(); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo }) => { + if (type.name !== 'InlineCode') return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); + + // Skip when cursor is in this code + if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return; - // Get the actual code content (excluding backticks) const text = view.state.doc.sliceString(nodeFrom, nodeTo); - // Find backtick positions + // Find backtick boundaries let codeStart = nodeFrom; let codeEnd = nodeTo; - // Skip opening backticks + // Count opening backticks let i = 0; while (i < text.length && text[i] === '`') { - codeStart++; i++; } + codeStart = nodeFrom + i; - // Skip closing backticks + // Count closing backticks let j = text.length - 1; while (j >= 0 && text[j] === '`') { - codeEnd--; j--; } + codeEnd = nodeFrom + j + 1; - // Only add decoration if there's actual content + // Hide opening backticks + if (nodeFrom < codeStart) { + items.push({ from: nodeFrom, to: codeStart, deco: invisibleDecoration }); + } + + // Add style to code content if (codeStart < codeEnd) { - // Add mark decoration for the code content - decorations.push( - Decoration.mark({ - class: 'cm-inline-code' - }).range(codeStart, codeEnd) - ); + items.push({ from: codeStart, to: codeEnd, deco: codeMarkDecoration }); + } + + // Hide closing backticks + if (codeEnd < nodeTo) { + items.push({ from: codeEnd, to: nodeTo, deco: invisibleDecoration }); } } }); } - return Decoration.set(decorations, true); + // Sort and add to builder + items.sort((a, b) => a.from - b.from); + + for (const item of items) { + builder.add(item.from, item.to, item.deco); + } + + return builder.finish(); } /** - * Inline code plugin class. + * Inline code plugin with optimized updates. */ class InlineCodePlugin { decorations: DecorationSet; + private codeRanges: RangeTuple[] = []; + private cursorCodePos = -1; constructor(view: EditorView) { - this.decorations = buildInlineCodeDecorations(view); + this.codeRanges = collectCodeRanges(view); + const { from, to } = view.state.selection.main; + this.cursorCodePos = getCursorCodePos(this.codeRanges, from, to); + this.decorations = buildDecorations(view); } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged || update.selectionSet) { - this.decorations = buildInlineCodeDecorations(update.view); + const { docChanged, viewportChanged, selectionSet } = update; + + if (docChanged || viewportChanged) { + this.codeRanges = collectCodeRanges(update.view); + const { from, to } = update.state.selection.main; + this.cursorCodePos = getCursorCodePos(this.codeRanges, from, to); + this.decorations = buildDecorations(update.view); + return; + } + + if (selectionSet) { + const { from, to } = update.state.selection.main; + const newPos = getCursorCodePos(this.codeRanges, from, to); + + if (newPos !== this.cursorCodePos) { + this.cursorCodePos = newPos; + this.decorations = buildDecorations(update.view); + } } } } @@ -98,7 +172,6 @@ const inlineCodePlugin = ViewPlugin.fromClass(InlineCodePlugin, { /** * Base theme for inline code. - * Uses CSS variables from variables.css for consistent theming. */ const baseTheme = EditorView.baseTheme({ '.cm-inline-code': { @@ -108,4 +181,3 @@ const baseTheme = EditorView.baseTheme({ fontFamily: 'var(--voidraft-font-mono)' } }); - diff --git a/frontend/src/views/editor/extensions/markdown/plugins/insert.ts b/frontend/src/views/editor/extensions/markdown/plugins/insert.ts index c5e42d3..9689786 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/insert.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/insert.ts @@ -1,4 +1,4 @@ -import { Extension, Range } from '@codemirror/state'; +import { Extension, RangeSetBuilder } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; import { ViewPlugin, @@ -7,108 +7,153 @@ import { EditorView, ViewUpdate } from '@codemirror/view'; -import { isCursorInRange, invisibleDecoration } from '../util'; +import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; + +/** Mark decoration for inserted content */ +const insertMarkDecoration = Decoration.mark({ class: 'cm-insert' }); /** * Insert plugin using syntax tree. * - * Uses the custom Insert extension to detect: - * - Insert: ++text++ → renders as inserted text (underline) - * - * Examples: - * - This is ++inserted++ text → This is inserted text - * - Please ++review this section++ carefully + * Detects ++text++ and renders as inserted text (underline). */ -export const insert = (): Extension => [ - insertPlugin, - baseTheme -]; +export const insert = (): Extension => [insertPlugin, baseTheme]; /** - * Build decorations for insert using syntax tree. + * Collect all insert ranges in visible viewport. + */ +function collectInsertRanges(view: EditorView): RangeTuple[] { + const ranges: RangeTuple[] = []; + const seen = new Set(); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo }) => { + if (type.name !== 'Insert') return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); + ranges.push([nodeFrom, nodeTo]); + } + }); + } + + return ranges; +} + +/** + * Get which insert the cursor is in (-1 if none). + */ +function getCursorInsertPos(ranges: RangeTuple[], selFrom: number, selTo: number): number { + const selRange: RangeTuple = [selFrom, selTo]; + + for (const range of ranges) { + if (checkRangeOverlap(range, selRange)) { + return range[0]; + } + } + return -1; +} + +/** + * Build insert decorations. */ function buildDecorations(view: EditorView): DecorationSet { - const decorations: Range[] = []; + const builder = new RangeSetBuilder(); + const items: { from: number; to: number; deco: Decoration }[] = []; + const { from: selFrom, to: selTo } = view.state.selection.main; + const selRange: RangeTuple = [selFrom, selTo]; + const seen = new Set(); for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ from, to, enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { - // Handle Insert nodes - if (type.name === 'Insert') { - const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); + if (type.name !== 'Insert') return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); - // Get the mark nodes (the ++ characters) - const marks = node.getChildren('InsertMark'); + // Skip if cursor is in this insert + if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return; - if (!cursorInRange && marks.length >= 2) { - // Hide the opening and closing ++ marks - decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to)); - decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to)); + const marks = node.getChildren('InsertMark'); + if (marks.length < 2) return; - // Apply insert style to the content between marks - const contentStart = marks[0].to; - const contentEnd = marks[marks.length - 1].from; - if (contentStart < contentEnd) { - decorations.push( - Decoration.mark({ - class: 'cm-insert' - }).range(contentStart, contentEnd) - ); - } - } + // Hide opening ++ + items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration }); + + // Apply insert style to content + const contentStart = marks[0].to; + const contentEnd = marks[marks.length - 1].from; + if (contentStart < contentEnd) { + items.push({ from: contentStart, to: contentEnd, deco: insertMarkDecoration }); } + + // Hide closing ++ + items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration }); } }); } - return Decoration.set(decorations, true); + // Sort and add to builder + items.sort((a, b) => a.from - b.from); + + for (const item of items) { + builder.add(item.from, item.to, item.deco); + } + + return builder.finish(); } /** - * Plugin class with optimized update detection. + * Insert plugin with optimized updates. */ class InsertPlugin { decorations: DecorationSet; - private lastSelectionHead: number = -1; + private insertRanges: RangeTuple[] = []; + private cursorInsertPos = -1; constructor(view: EditorView) { + this.insertRanges = collectInsertRanges(view); + const { from, to } = view.state.selection.main; + this.cursorInsertPos = getCursorInsertPos(this.insertRanges, from, to); this.decorations = buildDecorations(view); - this.lastSelectionHead = view.state.selection.main.head; } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { + const { docChanged, viewportChanged, selectionSet } = update; + + if (docChanged || viewportChanged) { + this.insertRanges = collectInsertRanges(update.view); + const { from, to } = update.state.selection.main; + this.cursorInsertPos = getCursorInsertPos(this.insertRanges, from, to); this.decorations = buildDecorations(update.view); - this.lastSelectionHead = update.state.selection.main.head; return; } - if (update.selectionSet) { - const newHead = update.state.selection.main.head; - if (newHead !== this.lastSelectionHead) { + if (selectionSet) { + const { from, to } = update.state.selection.main; + const newPos = getCursorInsertPos(this.insertRanges, from, to); + + if (newPos !== this.cursorInsertPos) { + this.cursorInsertPos = newPos; this.decorations = buildDecorations(update.view); - this.lastSelectionHead = newHead; } } } } -const insertPlugin = ViewPlugin.fromClass( - InsertPlugin, - { - decorations: (v) => v.decorations - } -); +const insertPlugin = ViewPlugin.fromClass(InsertPlugin, { + decorations: (v) => v.decorations +}); /** * Base theme for insert. - * Uses underline decoration for inserted text. */ const baseTheme = EditorView.baseTheme({ '.cm-insert': { textDecoration: 'underline', } }); - diff --git a/frontend/src/views/editor/extensions/markdown/plugins/link.ts b/frontend/src/views/editor/extensions/markdown/plugins/link.ts index 9acc0c5..e4e9467 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/link.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/link.ts @@ -1,5 +1,5 @@ import { syntaxTree } from '@codemirror/language'; -import { Range } from '@codemirror/state'; +import { Extension, RangeSetBuilder } from '@codemirror/state'; import { Decoration, DecorationSet, @@ -7,17 +7,12 @@ import { ViewPlugin, ViewUpdate } from '@codemirror/view'; -import { checkRangeOverlap, isCursorInRange, invisibleDecoration } from '../util'; - -/** - * Pattern for auto-link markers (< and >). - */ -const AUTO_LINK_MARK_RE = /^<|>$/g; +import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; /** * Parent node types that should not process. * - Image: handled by image plugin - * - LinkReference: reference link definitions like [label]: url should be fully visible + * - LinkReference: reference link definitions should be fully visible */ const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']); @@ -28,16 +23,71 @@ const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']); * - Hides link markup when cursor is outside * - Link icons and click events are handled by hyperlink extension */ -export const links = () => [goToLinkPlugin]; +export const links = (): Extension => [goToLinkPlugin]; + +/** + * Link info for tracking. + */ +interface LinkInfo { + parentFrom: number; + parentTo: number; + urlFrom: number; + urlTo: number; + marks: { from: number; to: number }[]; + linkTitle: { from: number; to: number } | null; + isAutoLink: boolean; +} + +/** + * Collect all link ranges in visible viewport. + */ +function collectLinkRanges(view: EditorView): RangeTuple[] { + const ranges: RangeTuple[] = []; + const seen = new Set(); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, node }) => { + if (type.name !== 'URL') return; + + const parent = node.parent; + if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return; + if (seen.has(parent.from)) return; + seen.add(parent.from); + + ranges.push([parent.from, parent.to]); + } + }); + } + + return ranges; +} + +/** + * Get which link the cursor is in (-1 if none). + */ +function getCursorLinkPos(ranges: RangeTuple[], selFrom: number, selTo: number): number { + const selRange: RangeTuple = [selFrom, selTo]; + + for (const range of ranges) { + if (checkRangeOverlap(range, selRange)) { + return range[0]; + } + } + return -1; +} /** * Build link decorations. - * Only hides markdown syntax marks, no icons added. - * Uses array + Decoration.set() for automatic sorting. */ -function buildLinkDecorations(view: EditorView): DecorationSet { - const decorations: Range[] = []; - const selectionRanges = view.state.selection.ranges; +function buildDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const items: { from: number; to: number }[] = []; + const { from: selFrom, to: selTo } = view.state.selection.main; + const selRange: RangeTuple = [selFrom, selTo]; + const seen = new Set(); for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ @@ -49,94 +99,104 @@ function buildLinkDecorations(view: EditorView): DecorationSet { const parent = node.parent; if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return; + // Use parent.from as unique key to handle multiple URLs in same link + if (seen.has(parent.from)) return; + seen.add(parent.from); + const marks = parent.getChildren('LinkMark'); const linkTitle = parent.getChild('LinkTitle'); - // Find the ']' mark position to distinguish between link text and link target - // Link structure: [display text](url) - // We should only hide the URL in the () part, not in the [] part + // Find the ']' mark to distinguish link text from URL const closeBracketMark = marks.find((mark) => { const text = view.state.sliceDoc(mark.from, mark.to); return text === ']'; }); - // If URL is before ']', it's part of the display text, don't hide it + // If URL is before ']', it's part of display text, don't hide if (closeBracketMark && nodeFrom < closeBracketMark.from) { return; } - // Check if cursor overlaps with the link - const cursorOverlaps = selectionRanges.some((range) => - checkRangeOverlap([range.from, range.to], [parent.from, parent.to]) - ); + // Check if cursor overlaps with the parent link + if (checkRangeOverlap([parent.from, parent.to], selRange)) { + return; + } - // Hide link marks and URL when cursor is outside - if (!cursorOverlaps && marks.length > 0) { + // Hide link marks and URL + if (marks.length > 0) { for (const mark of marks) { - decorations.push(invisibleDecoration.range(mark.from, mark.to)); + items.push({ from: mark.from, to: mark.to }); } - decorations.push(invisibleDecoration.range(nodeFrom, nodeTo)); + items.push({ from: nodeFrom, to: nodeTo }); if (linkTitle) { - decorations.push(invisibleDecoration.range(linkTitle.from, linkTitle.to)); + items.push({ from: linkTitle.from, to: linkTitle.to }); } } - // Get link content - const linkContent = view.state.sliceDoc(nodeFrom, nodeTo); - // Handle auto-links with < > markers - if (AUTO_LINK_MARK_RE.test(linkContent)) { - if (!isCursorInRange(view.state, [node.from, node.to])) { - decorations.push(invisibleDecoration.range(nodeFrom, nodeFrom + 1)); - decorations.push(invisibleDecoration.range(nodeTo - 1, nodeTo)); - } + const linkContent = view.state.sliceDoc(nodeFrom, nodeTo); + if (linkContent.startsWith('<') && linkContent.endsWith('>')) { + // Already hidden the whole URL above, no extra handling needed } } }); } - // Use Decoration.set with sort=true to handle unsorted ranges - return Decoration.set(decorations, true); + // Sort and add to builder + items.sort((a, b) => a.from - b.from); + + // Deduplicate overlapping ranges + let lastTo = -1; + for (const item of items) { + if (item.from >= lastTo) { + builder.add(item.from, item.to, invisibleDecoration); + lastTo = item.to; + } + } + + return builder.finish(); } /** - * Link plugin with optimized update detection. + * Link plugin with optimized updates. */ class LinkPlugin { decorations: DecorationSet; - private lastSelectionRanges: string = ''; + private linkRanges: RangeTuple[] = []; + private cursorLinkPos = -1; constructor(view: EditorView) { - this.decorations = buildLinkDecorations(view); - this.lastSelectionRanges = this.serializeSelection(view); + this.linkRanges = collectLinkRanges(view); + const { from, to } = view.state.selection.main; + this.cursorLinkPos = getCursorLinkPos(this.linkRanges, from, to); + this.decorations = buildDecorations(view); } update(update: ViewUpdate) { - // Always rebuild on doc or viewport change - if (update.docChanged || update.viewportChanged) { - this.decorations = buildLinkDecorations(update.view); - this.lastSelectionRanges = this.serializeSelection(update.view); + const { docChanged, viewportChanged, selectionSet } = update; + + if (docChanged || viewportChanged) { + this.linkRanges = collectLinkRanges(update.view); + const { from, to } = update.state.selection.main; + this.cursorLinkPos = getCursorLinkPos(this.linkRanges, from, to); + this.decorations = buildDecorations(update.view); return; } - // For selection changes, check if selection actually changed - if (update.selectionSet) { - const newRanges = this.serializeSelection(update.view); - if (newRanges !== this.lastSelectionRanges) { - this.decorations = buildLinkDecorations(update.view); - this.lastSelectionRanges = newRanges; + if (selectionSet) { + const { from, to } = update.state.selection.main; + const newPos = getCursorLinkPos(this.linkRanges, from, to); + + if (newPos !== this.cursorLinkPos) { + this.cursorLinkPos = newPos; + this.decorations = buildDecorations(update.view); } } } - - private serializeSelection(view: EditorView): string { - return view.state.selection.ranges - .map((r) => `${r.from}:${r.to}`) - .join(','); - } } export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, { decorations: (v) => v.decorations }); + diff --git a/frontend/src/views/editor/extensions/markdown/plugins/list.ts b/frontend/src/views/editor/extensions/markdown/plugins/list.ts index 0008dc1..a346d64 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/list.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/list.ts @@ -6,13 +6,11 @@ import { ViewUpdate, WidgetType } from '@codemirror/view'; -import { Range, StateField, Transaction } from '@codemirror/state'; +import { Range, RangeSetBuilder, EditorState } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; -import { isCursorInRange } from '../util'; +import { checkRangeOverlap, RangeTuple } from '../util'; -/** - * Pattern for bullet list markers. - */ +/** Bullet list marker pattern */ const BULLET_LIST_MARKER_RE = /^[-+*]$/; /** @@ -22,15 +20,12 @@ const BULLET_LIST_MARKER_RE = /^[-+*]$/; * - Custom bullet mark rendering (- → •) * - Interactive task list checkboxes */ -export const lists = () => [listBulletPlugin, taskListField, baseTheme]; +export const lists = () => [listBulletPlugin, taskListPlugin, baseTheme]; // ============================================================================ // List Bullet Plugin // ============================================================================ -/** - * Widget to render list bullet mark. - */ class ListBulletWidget extends WidgetType { constructor(readonly bullet: string) { super(); @@ -49,10 +44,11 @@ class ListBulletWidget extends WidgetType { } /** - * Build list bullet decorations. + * Collect all list mark ranges in visible viewport. */ -function buildListBulletDecorations(view: EditorView): DecorationSet { - const decorations: Range[] = []; +function collectBulletRanges(view: EditorView): RangeTuple[] { + const ranges: RangeTuple[] = []; + const seen = new Set(); for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ @@ -60,60 +56,120 @@ function buildListBulletDecorations(view: EditorView): DecorationSet { to, enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { if (type.name !== 'ListMark') return; - - // Skip if this is part of a task list (has Task sibling) + + // Skip task list items const parent = node.parent; - if (parent) { - const task = parent.getChild('Task'); - if (task) return; - } + if (parent?.getChild('Task')) return; + + // Only bullet markers + const text = view.state.sliceDoc(nodeFrom, nodeTo); + if (!BULLET_LIST_MARKER_RE.test(text)) return; - // Skip if cursor is in this range - if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); + ranges.push([nodeFrom, nodeTo]); + } + }); + } - const listMark = view.state.sliceDoc(nodeFrom, nodeTo); - if (BULLET_LIST_MARKER_RE.test(listMark)) { - decorations.push( - Decoration.replace({ - widget: new ListBulletWidget(listMark) - }).range(nodeFrom, nodeTo) - ); + return ranges; +} + +/** + * Get which bullet the cursor is in (-1 if none). + */ +function getCursorBulletPos(ranges: RangeTuple[], selFrom: number, selTo: number): number { + const selRange: RangeTuple = [selFrom, selTo]; + + for (const range of ranges) { + if (checkRangeOverlap(range, selRange)) { + return range[0]; + } + } + return -1; +} + +/** + * Build list bullet decorations. + */ +function buildBulletDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const items: { from: number; to: number; bullet: string }[] = []; + const { from: selFrom, to: selTo } = view.state.selection.main; + const selRange: RangeTuple = [selFrom, selTo]; + const seen = new Set(); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { + if (type.name !== 'ListMark') return; + + // Skip task list items + const parent = node.parent; + if (parent?.getChild('Task')) return; + + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); + + // Skip if cursor is in this mark + if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return; + + const bullet = view.state.sliceDoc(nodeFrom, nodeTo); + if (BULLET_LIST_MARKER_RE.test(bullet)) { + items.push({ from: nodeFrom, to: nodeTo, bullet }); } } }); } - return Decoration.set(decorations, true); + // Sort and add to builder + items.sort((a, b) => a.from - b.from); + + for (const item of items) { + builder.add(item.from, item.to, Decoration.replace({ + widget: new ListBulletWidget(item.bullet) + })); + } + + return builder.finish(); } /** - * List bullet plugin. + * List bullet plugin with optimized updates. */ class ListBulletPlugin { decorations: DecorationSet; - private lastSelectionHead: number = -1; + private bulletRanges: RangeTuple[] = []; + private cursorBulletPos = -1; constructor(view: EditorView) { - this.decorations = buildListBulletDecorations(view); - this.lastSelectionHead = view.state.selection.main.head; + this.bulletRanges = collectBulletRanges(view); + const { from, to } = view.state.selection.main; + this.cursorBulletPos = getCursorBulletPos(this.bulletRanges, from, to); + this.decorations = buildBulletDecorations(view); } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { - this.decorations = buildListBulletDecorations(update.view); - this.lastSelectionHead = update.state.selection.main.head; + const { docChanged, viewportChanged, selectionSet } = update; + + if (docChanged || viewportChanged) { + this.bulletRanges = collectBulletRanges(update.view); + const { from, to } = update.state.selection.main; + this.cursorBulletPos = getCursorBulletPos(this.bulletRanges, from, to); + this.decorations = buildBulletDecorations(update.view); return; } - if (update.selectionSet) { - const newHead = update.state.selection.main.head; - const oldLine = update.startState.doc.lineAt(this.lastSelectionHead); - const newLine = update.state.doc.lineAt(newHead); + if (selectionSet) { + const { from, to } = update.state.selection.main; + const newPos = getCursorBulletPos(this.bulletRanges, from, to); - if (oldLine.number !== newLine.number) { - this.decorations = buildListBulletDecorations(update.view); + if (newPos !== this.cursorBulletPos) { + this.cursorBulletPos = newPos; + this.decorations = buildBulletDecorations(update.view); } - this.lastSelectionHead = newHead; } } } @@ -123,16 +179,13 @@ const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, { }); // ============================================================================ -// Task List Plugin (using StateField to avoid flickering) +// Task List Plugin // ============================================================================ -/** - * Widget to render checkbox for a task list item. - */ class TaskCheckboxWidget extends WidgetType { constructor( readonly checked: boolean, - readonly pos: number // Position of the checkbox character in document + readonly pos: number ) { super(); } @@ -151,7 +204,6 @@ class TaskCheckboxWidget extends WidgetType { checkbox.checked = this.checked; checkbox.tabIndex = -1; - // Handle click directly in the widget checkbox.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); @@ -176,80 +228,174 @@ class TaskCheckboxWidget extends WidgetType { } /** - * Build task list decorations from state. + * Collect all task ranges in visible viewport. */ -function buildTaskListDecorations(state: import('@codemirror/state').EditorState): DecorationSet { - const decorations: Range[] = []; +function collectTaskRanges(view: EditorView): RangeTuple[] { + const ranges: RangeTuple[] = []; + const seen = new Set(); - syntaxTree(state).iterate({ - enter: ({ type, from: taskFrom, to: taskTo, node }) => { - if (type.name !== 'Task') return; + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { + if (type.name !== 'Task') return; + + const listItem = node.parent; + if (!listItem || listItem.type.name !== 'ListItem') return; + + const listMark = listItem.getChild('ListMark'); + if (!listMark) return; - const listItem = node.parent; - if (!listItem || listItem.type.name !== 'ListItem') return; - - const listMark = listItem.getChild('ListMark'); - const taskMarker = node.getChild('TaskMarker'); - - if (!listMark || !taskMarker) return; - - const replaceFrom = listMark.from; - const replaceTo = taskMarker.to; - - // Check if cursor is in this range - if (isCursorInRange(state, [replaceFrom, replaceTo])) return; - - // Check if task is checked - position of x or space is taskMarker.from + 1 - const markerText = state.sliceDoc(taskMarker.from, taskMarker.to); - const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]); - const checkboxPos = taskMarker.from + 1; // Position of the x or space - - if (isChecked) { - decorations.push( - Decoration.mark({ class: 'cm-task-checked' }).range(taskFrom, taskTo) - ); + if (seen.has(listMark.from)) return; + seen.add(listMark.from); + + // Track the full range from ListMark to TaskMarker + const taskMarker = node.getChild('TaskMarker'); + if (taskMarker) { + ranges.push([listMark.from, taskMarker.to]); + } } + }); + } - // Replace "- [x]" or "- [ ]" with checkbox widget - decorations.push( - Decoration.replace({ - widget: new TaskCheckboxWidget(isChecked, checkboxPos) - }).range(replaceFrom, replaceTo) - ); - } - }); - - return Decoration.set(decorations, true); + return ranges; } /** - * Task list StateField - uses incremental updates to avoid flickering. + * Get which task the cursor is in (-1 if none). */ -const taskListField = StateField.define({ - create(state) { - return buildTaskListDecorations(state); - }, - - update(value, tr: Transaction) { - // Only rebuild when document or selection changes - if (tr.docChanged || tr.selection) { - return buildTaskListDecorations(tr.state); +function getCursorTaskPos(ranges: RangeTuple[], selFrom: number, selTo: number): number { + const selRange: RangeTuple = [selFrom, selTo]; + + for (const range of ranges) { + if (checkRangeOverlap(range, selRange)) { + return range[0]; } - return value; - }, - - provide(field) { - return EditorView.decorations.from(field); } + return -1; +} + +/** + * Build task list decorations. + */ +function buildTaskDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const items: { from: number; to: number; deco: Decoration; priority: number }[] = []; + const { from: selFrom, to: selTo } = view.state.selection.main; + const selRange: RangeTuple = [selFrom, selTo]; + const seen = new Set(); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: taskFrom, to: taskTo, node }) => { + if (type.name !== 'Task') return; + + const listItem = node.parent; + if (!listItem || listItem.type.name !== 'ListItem') return; + + const listMark = listItem.getChild('ListMark'); + const taskMarker = node.getChild('TaskMarker'); + if (!listMark || !taskMarker) return; + + if (seen.has(listMark.from)) return; + seen.add(listMark.from); + + const replaceFrom = listMark.from; + const replaceTo = taskMarker.to; + + // Skip if cursor is in this range + if (checkRangeOverlap([replaceFrom, replaceTo], selRange)) return; + + // Check if task is checked + const markerText = view.state.sliceDoc(taskMarker.from, taskMarker.to); + const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]); + const checkboxPos = taskMarker.from + 1; + + // Add strikethrough for checked items + if (isChecked) { + items.push({ + from: taskFrom, + to: taskTo, + deco: Decoration.mark({ class: 'cm-task-checked' }), + priority: 0 + }); + } + + // Replace "- [x]" or "- [ ]" with checkbox widget + items.push({ + from: replaceFrom, + to: replaceTo, + deco: Decoration.replace({ + widget: new TaskCheckboxWidget(isChecked, checkboxPos) + }), + priority: 1 + }); + } + }); + } + + // Sort by position, then priority + items.sort((a, b) => { + if (a.from !== b.from) return a.from - b.from; + return a.priority - b.priority; + }); + + for (const item of items) { + builder.add(item.from, item.to, item.deco); + } + + return builder.finish(); +} + +/** + * Task list plugin with optimized updates. + */ +class TaskListPlugin { + decorations: DecorationSet; + private taskRanges: RangeTuple[] = []; + private cursorTaskPos = -1; + + constructor(view: EditorView) { + this.taskRanges = collectTaskRanges(view); + const { from, to } = view.state.selection.main; + this.cursorTaskPos = getCursorTaskPos(this.taskRanges, from, to); + this.decorations = buildTaskDecorations(view); + } + + update(update: ViewUpdate) { + const { docChanged, viewportChanged, selectionSet } = update; + + if (docChanged || viewportChanged) { + this.taskRanges = collectTaskRanges(update.view); + const { from, to } = update.state.selection.main; + this.cursorTaskPos = getCursorTaskPos(this.taskRanges, from, to); + this.decorations = buildTaskDecorations(update.view); + return; + } + + if (selectionSet) { + const { from, to } = update.state.selection.main; + const newPos = getCursorTaskPos(this.taskRanges, from, to); + + if (newPos !== this.cursorTaskPos) { + this.cursorTaskPos = newPos; + this.decorations = buildTaskDecorations(update.view); + } + } + } +} + +const taskListPlugin = ViewPlugin.fromClass(TaskListPlugin, { + decorations: (v) => v.decorations }); // ============================================================================ // Theme // ============================================================================ -/** - * Base theme for lists. - */ const baseTheme = EditorView.baseTheme({ '.cm-list-bullet': { color: 'var(--cm-list-bullet-color, inherit)' diff --git a/frontend/src/views/editor/extensions/markdown/plugins/math.ts b/frontend/src/views/editor/extensions/markdown/plugins/math.ts index f8f258d..287e917 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/math.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/math.ts @@ -21,41 +21,72 @@ import { import katex from 'katex'; import 'katex/dist/katex.min.css'; import { isCursorInRange, invisibleDecoration } from '../util'; +import { LruCache } from '@/common/utils/lruCache'; -// ============================================================================ -// Inline Math Widget -// ============================================================================ +interface KatexCacheValue { + html: string; + error: string | null; +} + +/** + * LRU cache for KaTeX rendering results. + * Key format: "inline:latex" or "block:latex" + */ +const katexCache = new LruCache(200); + +/** + * Get cached KaTeX render result or render and cache it. + */ +function renderKatex(latex: string, displayMode: boolean): KatexCacheValue { + const cacheKey = `${displayMode ? 'block' : 'inline'}:${latex}`; + + // Check cache first + const cached = katexCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + // Render and cache + let result: KatexCacheValue; + try { + const html = katex.renderToString(latex, { + throwOnError: !displayMode, // inline throws, block doesn't + displayMode, + output: 'html' + }); + result = { html, error: null }; + } catch (e) { + result = { + html: '', + error: e instanceof Error ? e.message : 'Render error' + }; + } + + katexCache.set(cacheKey, result); + return result; +} /** * Widget to display inline math formula. + * Uses cached KaTeX rendering for performance. */ class InlineMathWidget extends WidgetType { - private html: string; - private error: string | null = null; - constructor(readonly latex: string) { super(); - try { - this.html = katex.renderToString(latex, { - throwOnError: true, - displayMode: false, - output: 'html' - }); - } catch (e) { - this.error = e instanceof Error ? e.message : 'Render error'; - this.html = ''; - } } toDOM(): HTMLElement { const span = document.createElement('span'); span.className = 'cm-inline-math'; - if (this.error) { + // Use cached render + const { html, error } = renderKatex(this.latex, false); + + if (error) { span.textContent = this.latex; - span.title = this.error; + span.title = error; } else { - span.innerHTML = this.html; + span.innerHTML = html; } return span; @@ -70,34 +101,17 @@ class InlineMathWidget extends WidgetType { } } -// ============================================================================ -// Block Math Widget -// ============================================================================ - /** * Widget to display block math formula. * Uses absolute positioning to overlay on source lines. */ class BlockMathWidget extends WidgetType { - private html: string; - private error: string | null = null; - constructor( readonly latex: string, readonly lineCount: number = 1, readonly lineHeight: number = 22 ) { super(); - try { - this.html = katex.renderToString(latex, { - throwOnError: false, - displayMode: true, - output: 'html' - }); - } catch (e) { - this.error = e instanceof Error ? e.message : 'Render error'; - this.html = ''; - } } toDOM(): HTMLElement { @@ -110,11 +124,14 @@ class BlockMathWidget extends WidgetType { const inner = document.createElement('div'); inner.className = 'cm-block-math'; - if (this.error) { + // Use cached render + const { html, error } = renderKatex(this.latex, true); + + if (error) { inner.textContent = this.latex; - inner.title = this.error; + inner.title = error; } else { - inner.innerHTML = this.html; + inner.innerHTML = html; } container.appendChild(inner); @@ -130,15 +147,42 @@ class BlockMathWidget extends WidgetType { } } -// ============================================================================ -// Decorations -// ============================================================================ +/** + * Represents a math region in the document. + */ +interface MathRegion { + from: number; + to: number; +} + +/** + * Result of building decorations, includes math regions for cursor tracking. + */ +interface BuildResult { + decorations: DecorationSet; + mathRegions: MathRegion[]; +} + +/** + * Find the math region containing the given position. + * Returns the region index or -1 if not in any region. + */ +function findMathRegionIndex(pos: number, regions: MathRegion[]): number { + for (let i = 0; i < regions.length; i++) { + if (pos >= regions[i].from && pos <= regions[i].to) { + return i; + } + } + return -1; +} /** * Build decorations for math formulas. + * Also collects math regions for cursor tracking optimization. */ -function buildDecorations(view: EditorView): DecorationSet { +function buildDecorations(view: EditorView): BuildResult { const decorations: Range[] = []; + const mathRegions: MathRegion[] = []; for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ @@ -147,6 +191,9 @@ function buildDecorations(view: EditorView): DecorationSet { enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { // Handle inline math if (type.name === 'InlineMath') { + // Collect math region for cursor tracking + mathRegions.push({ from: nodeFrom, to: nodeTo }); + const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); const marks = node.getChildren('InlineMathMark'); @@ -169,6 +216,9 @@ function buildDecorations(view: EditorView): DecorationSet { // Handle block math ($$...$$) if (type.name === 'BlockMath') { + // Collect math region for cursor tracking + mathRegions.push({ from: nodeFrom, to: nodeTo }); + const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); const marks = node.getChildren('BlockMathMark'); @@ -225,36 +275,58 @@ function buildDecorations(view: EditorView): DecorationSet { }); } - return Decoration.set(decorations, true); + return { + decorations: Decoration.set(decorations, true), + mathRegions + }; } -// ============================================================================ -// Plugin -// ============================================================================ - /** * Math plugin with optimized update detection. */ class MathPlugin { decorations: DecorationSet; + private mathRegions: MathRegion[] = []; private lastSelectionHead: number = -1; + private lastMathRegionIndex: number = -1; constructor(view: EditorView) { - this.decorations = buildDecorations(view); + const result = buildDecorations(view); + this.decorations = result.decorations; + this.mathRegions = result.mathRegions; this.lastSelectionHead = view.state.selection.main.head; + this.lastMathRegionIndex = findMathRegionIndex(this.lastSelectionHead, this.mathRegions); } update(update: ViewUpdate) { + // Always rebuild on document change or viewport change if (update.docChanged || update.viewportChanged) { - this.decorations = buildDecorations(update.view); + const result = buildDecorations(update.view); + this.decorations = result.decorations; + this.mathRegions = result.mathRegions; this.lastSelectionHead = update.state.selection.main.head; + this.lastMathRegionIndex = findMathRegionIndex(this.lastSelectionHead, this.mathRegions); return; } + // For selection changes, only rebuild if cursor changes math region context if (update.selectionSet) { const newHead = update.state.selection.main.head; + if (newHead !== this.lastSelectionHead) { - this.decorations = buildDecorations(update.view); + const newRegionIndex = findMathRegionIndex(newHead, this.mathRegions); + + // Only rebuild if: + // 1. Cursor entered a math region (was outside, now inside) + // 2. Cursor left a math region (was inside, now outside) + // 3. Cursor moved to a different math region + if (newRegionIndex !== this.lastMathRegionIndex) { + const result = buildDecorations(update.view); + this.decorations = result.decorations; + this.mathRegions = result.mathRegions; + this.lastMathRegionIndex = findMathRegionIndex(newHead, this.mathRegions); + } + this.lastSelectionHead = newHead; } } @@ -268,10 +340,6 @@ const mathPlugin = ViewPlugin.fromClass( } ); -// ============================================================================ -// Theme -// ============================================================================ - /** * Base theme for math. */ @@ -336,10 +404,6 @@ const baseTheme = EditorView.baseTheme({ }, }); -// ============================================================================ -// Export -// ============================================================================ - /** * Math extension. * diff --git a/frontend/src/views/editor/extensions/markdown/plugins/subscript-superscript.ts b/frontend/src/views/editor/extensions/markdown/plugins/subscript-superscript.ts index 83f9d32..16e192a 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/subscript-superscript.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/subscript-superscript.ts @@ -1,4 +1,4 @@ -import { Extension, Range } from '@codemirror/state'; +import { Extension, RangeSetBuilder } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; import { ViewPlugin, @@ -7,120 +7,153 @@ import { EditorView, ViewUpdate } from '@codemirror/view'; -import { isCursorInRange, invisibleDecoration } from '../util'; +import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; + +/** Pre-computed mark decorations */ +const superscriptMarkDecoration = Decoration.mark({ class: 'cm-superscript' }); +const subscriptMarkDecoration = Decoration.mark({ class: 'cm-subscript' }); /** * Subscript and Superscript plugin using syntax tree. * - * Uses lezer-markdown's Subscript and Superscript extensions to detect: * - Superscript: ^text^ → renders as superscript * - Subscript: ~text~ → renders as subscript * - * Note: Inline footnotes ^[content] are handled by the Footnote extension - * which parses InlineFootnote before Superscript in the syntax tree. - * - * Examples: - * - 19^th^ → 19ᵗʰ (superscript) - * - H~2~O → H₂O (subscript) + * Note: Inline footnotes ^[content] are handled by the Footnote extension. */ export const subscriptSuperscript = (): Extension => [ subscriptSuperscriptPlugin, baseTheme ]; +/** Node types to handle */ +const SCRIPT_TYPES = new Set(['Superscript', 'Subscript']); + /** - * Build decorations for subscript and superscript using syntax tree. + * Collect all superscript/subscript ranges in visible viewport. + */ +function collectScriptRanges(view: EditorView): RangeTuple[] { + const ranges: RangeTuple[] = []; + const seen = new Set(); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo }) => { + if (!SCRIPT_TYPES.has(type.name)) return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); + ranges.push([nodeFrom, nodeTo]); + } + }); + } + + return ranges; +} + +/** + * Get which script element the cursor is in (-1 if none). + */ +function getCursorScriptPos(ranges: RangeTuple[], selFrom: number, selTo: number): number { + const selRange: RangeTuple = [selFrom, selTo]; + + for (const range of ranges) { + if (checkRangeOverlap(range, selRange)) { + return range[0]; + } + } + return -1; +} + +/** + * Build decorations for subscript and superscript. */ function buildDecorations(view: EditorView): DecorationSet { - const decorations: Range[] = []; + const builder = new RangeSetBuilder(); + const items: { from: number; to: number; deco: Decoration }[] = []; + const { from: selFrom, to: selTo } = view.state.selection.main; + const selRange: RangeTuple = [selFrom, selTo]; + const seen = new Set(); for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ from, to, enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { - // Handle Superscript nodes - // Note: InlineFootnote ^[content] is parsed before Superscript, - // so we don't need to check for bracket patterns here - if (type.name === 'Superscript') { - const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); + if (!SCRIPT_TYPES.has(type.name)) return; + if (seen.has(nodeFrom)) return; + seen.add(nodeFrom); - // Get the mark nodes (the ^ characters) - const marks = node.getChildren('SuperscriptMark'); + // Skip if cursor is in this element + if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return; - if (!cursorInRange && marks.length >= 2) { - // Hide the opening and closing ^ marks - decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to)); - decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to)); + const isSuperscript = type.name === 'Superscript'; + const markName = isSuperscript ? 'SuperscriptMark' : 'SubscriptMark'; + const contentDeco = isSuperscript ? superscriptMarkDecoration : subscriptMarkDecoration; - // Apply superscript style to the content between marks - const contentStart = marks[0].to; - const contentEnd = marks[marks.length - 1].from; - if (contentStart < contentEnd) { - decorations.push( - Decoration.mark({ - class: 'cm-superscript' - }).range(contentStart, contentEnd) - ); - } - } + const marks = node.getChildren(markName); + if (marks.length < 2) return; + + // Hide opening mark + items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration }); + + // Apply style to content + const contentStart = marks[0].to; + const contentEnd = marks[marks.length - 1].from; + if (contentStart < contentEnd) { + items.push({ from: contentStart, to: contentEnd, deco: contentDeco }); } - // Handle Subscript nodes - if (type.name === 'Subscript') { - const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); - - // Get the mark nodes (the ~ characters) - const marks = node.getChildren('SubscriptMark'); - - if (!cursorInRange && marks.length >= 2) { - // Hide the opening and closing ~ marks - decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to)); - decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to)); - - // Apply subscript style to the content between marks - const contentStart = marks[0].to; - const contentEnd = marks[marks.length - 1].from; - if (contentStart < contentEnd) { - decorations.push( - Decoration.mark({ - class: 'cm-subscript' - }).range(contentStart, contentEnd) - ); - } - } - } + // Hide closing mark + items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration }); } }); } - return Decoration.set(decorations, true); + // Sort and add to builder + items.sort((a, b) => a.from - b.from); + + for (const item of items) { + builder.add(item.from, item.to, item.deco); + } + + return builder.finish(); } /** - * Plugin class with optimized update detection. + * Subscript/Superscript plugin with optimized updates. */ class SubscriptSuperscriptPlugin { decorations: DecorationSet; - private lastSelectionHead: number = -1; + private scriptRanges: RangeTuple[] = []; + private cursorScriptPos = -1; constructor(view: EditorView) { + this.scriptRanges = collectScriptRanges(view); + const { from, to } = view.state.selection.main; + this.cursorScriptPos = getCursorScriptPos(this.scriptRanges, from, to); this.decorations = buildDecorations(view); - this.lastSelectionHead = view.state.selection.main.head; } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { + const { docChanged, viewportChanged, selectionSet } = update; + + if (docChanged || viewportChanged) { + this.scriptRanges = collectScriptRanges(update.view); + const { from, to } = update.state.selection.main; + this.cursorScriptPos = getCursorScriptPos(this.scriptRanges, from, to); this.decorations = buildDecorations(update.view); - this.lastSelectionHead = update.state.selection.main.head; return; } - if (update.selectionSet) { - const newHead = update.state.selection.main.head; - if (newHead !== this.lastSelectionHead) { + if (selectionSet) { + const { from, to } = update.state.selection.main; + const newPos = getCursorScriptPos(this.scriptRanges, from, to); + + if (newPos !== this.cursorScriptPos) { + this.cursorScriptPos = newPos; this.decorations = buildDecorations(update.view); - this.lastSelectionHead = newHead; } } } @@ -135,8 +168,6 @@ const subscriptSuperscriptPlugin = ViewPlugin.fromClass( /** * Base theme for subscript and superscript. - * Uses mark decoration instead of widget to avoid layout issues. - * fontSize uses smaller value as subscript/superscript are naturally smaller. */ const baseTheme = EditorView.baseTheme({ '.cm-superscript': { @@ -150,3 +181,4 @@ const baseTheme = EditorView.baseTheme({ color: 'var(--cm-subscript-color, inherit)' } }); + 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..60ab062 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/table.ts @@ -0,0 +1,833 @@ +/** + * Table plugin for CodeMirror. + * + * Features: + * - Renders markdown tables as beautiful HTML tables + * - Lines remain, content hidden, table overlays on top (same as math.ts) + * - Shows source when cursor is inside + * - Supports alignment (left, center, right) + * + * Table syntax tree structure from @lezer/markdown: + * - Table (root) + * - TableHeader (first row) + * - TableDelimiter (|) + * - TableCell (content) + * - TableDelimiter (separator row |---|---|) + * - TableRow (data rows) + * - TableCell (content) + */ + +import { Extension, Range } from '@codemirror/state'; +import { syntaxTree, foldedRanges } from '@codemirror/language'; +import { + ViewPlugin, + DecorationSet, + Decoration, + EditorView, + ViewUpdate, + WidgetType +} from '@codemirror/view'; +import { SyntaxNode } from '@lezer/common'; +import { isCursorInRange } from '../util'; +import { LruCache } from '@/common/utils/lruCache'; +import { generateContentHash } from '@/common/utils/hashUtils'; +import DOMPurify from 'dompurify'; + +// ============================================================================ +// Types and Interfaces +// ============================================================================ + +/** Cell alignment type */ +type CellAlign = 'left' | 'center' | 'right'; + +/** Parsed table data */ +interface TableData { + headers: string[]; + alignments: CellAlign[]; + rows: string[][]; +} + +/** Table range info for tracking */ +interface TableRange { + from: number; + to: number; +} + +// ============================================================================ +// Cache using LruCache from utils +// ============================================================================ + +/** LRU cache for parsed table data - keyed by position for fast lookup */ +const tableCacheByPos = new LruCache(50); + +/** LRU cache for inline markdown rendering */ +const inlineRenderCache = new LruCache(200); + +/** + * Get or parse table data with two-level caching. + * First checks position, then verifies content hash only if position matches. + * This avoids expensive hash computation on cache miss. + */ +function getCachedTableData( + state: import('@codemirror/state').EditorState, + tableNode: SyntaxNode +): TableData | null { + const posKey = `${tableNode.from}-${tableNode.to}`; + + // First level: check if we have data for this position + const cached = tableCacheByPos.get(posKey); + if (cached) { + // Second level: verify content hash matches (lazy hash computation) + const content = state.sliceDoc(tableNode.from, tableNode.to); + const contentHash = generateContentHash(content); + if (cached.hash === contentHash) { + return cached.data; + } + } + + // Cache miss - parse and cache + const content = state.sliceDoc(tableNode.from, tableNode.to); + const data = parseTableData(state, tableNode); + if (data) { + tableCacheByPos.set(posKey, { + hash: generateContentHash(content), + data + }); + } + return data; +} + + +// ============================================================================ +// Parsing Functions (Optimized) +// ============================================================================ + +/** + * Parse alignment from delimiter row. + * Optimized: early returns, minimal string operations. + */ +function parseAlignment(delimiterText: string): CellAlign { + const len = delimiterText.length; + if (len === 0) return 'left'; + + // Find first and last non-space characters + let start = 0; + let end = len - 1; + while (start < len && delimiterText.charCodeAt(start) === 32) start++; + while (end > start && delimiterText.charCodeAt(end) === 32) end--; + + if (start > end) return 'left'; + + const hasLeftColon = delimiterText.charCodeAt(start) === 58; // ':' + const hasRightColon = delimiterText.charCodeAt(end) === 58; + + if (hasLeftColon && hasRightColon) return 'center'; + if (hasRightColon) return 'right'; + return 'left'; +} + +/** + * Parse a row text into cells by splitting on | + * Optimized: single-pass parsing without multiple string operations. + */ +function parseRowText(rowText: string): string[] { + const cells: string[] = []; + const len = rowText.length; + + let start = 0; + let end = len; + + // Skip leading whitespace + while (start < len && rowText.charCodeAt(start) <= 32) start++; + // Skip trailing whitespace + while (end > start && rowText.charCodeAt(end - 1) <= 32) end--; + + // Skip leading | + if (start < end && rowText.charCodeAt(start) === 124) start++; + // Skip trailing | + if (end > start && rowText.charCodeAt(end - 1) === 124) end--; + + // Parse cells in single pass + let cellStart = start; + for (let i = start; i <= end; i++) { + if (i === end || rowText.charCodeAt(i) === 124) { + // Extract and trim cell + let cs = cellStart; + let ce = i; + while (cs < ce && rowText.charCodeAt(cs) <= 32) cs++; + while (ce > cs && rowText.charCodeAt(ce - 1) <= 32) ce--; + cells.push(rowText.substring(cs, ce)); + cellStart = i + 1; + } + } + + return cells; +} + +/** + * Parse table data from syntax tree node. + * + * Table syntax tree structure from @lezer/markdown: + * - Table (root) + * - TableHeader (contains TableCell children) + * - TableDelimiter (the |---|---| line) + * - TableRow (contains TableCell children) + */ +function parseTableData(state: import('@codemirror/state').EditorState, tableNode: SyntaxNode): TableData | null { + const headers: string[] = []; + const alignments: CellAlign[] = []; + const rows: string[][] = []; + + // Get TableHeader + const headerNode = tableNode.getChild('TableHeader'); + if (!headerNode) return null; + + // Get TableCell children from header + const headerCells = headerNode.getChildren('TableCell'); + + if (headerCells.length > 0) { + // Parse from TableCell nodes + for (const cell of headerCells) { + const text = state.sliceDoc(cell.from, cell.to).trim(); + headers.push(text); + } + } else { + // Fallback: parse the entire header row text + const headerText = state.sliceDoc(headerNode.from, headerNode.to); + const parsedHeaders = parseRowText(headerText); + headers.push(...parsedHeaders); + } + + if (headers.length === 0) return null; + + // Find delimiter row to get alignments + // The delimiter is a direct child of Table + let child = tableNode.firstChild; + while (child) { + if (child.type.name === 'TableDelimiter') { + const delimText = state.sliceDoc(child.from, child.to); + // Check if this contains --- (alignment row) + if (delimText.includes('-')) { + const parts = parseRowText(delimText); + for (const part of parts) { + if (part.includes('-')) { + alignments.push(parseAlignment(part)); + } + } + break; + } + } + child = child.nextSibling; + } + + // Fill missing alignments with 'left' + while (alignments.length < headers.length) { + alignments.push('left'); + } + + // Parse data rows + const rowNodes = tableNode.getChildren('TableRow'); + + for (const rowNode of rowNodes) { + const rowData: string[] = []; + const cells = rowNode.getChildren('TableCell'); + + if (cells.length > 0) { + // Parse from TableCell nodes + for (const cell of cells) { + const text = state.sliceDoc(cell.from, cell.to).trim(); + rowData.push(text); + } + } else { + // Fallback: parse the entire row text + const rowText = state.sliceDoc(rowNode.from, rowNode.to); + const parsedCells = parseRowText(rowText); + rowData.push(...parsedCells); + } + + // Fill missing cells with empty string + while (rowData.length < headers.length) { + rowData.push(''); + } + rows.push(rowData); + } + + return { headers, alignments, rows }; +} + + +// Pre-compiled regex patterns for better performance +const BOLD_STAR_RE = /\*\*(.+?)\*\*/g; +const BOLD_UNDER_RE = /__(.+?)__/g; +const ITALIC_STAR_RE = /\*([^*]+)\*/g; +const ITALIC_UNDER_RE = /(?]*>|<\/[a-zA-Z][^>]*>/; + +/** + * Sanitize HTML content with DOMPurify. + */ +function sanitizeHTML(html: string): string { + return DOMPurify.sanitize(html, { + ADD_TAGS: ['code', 'strong', 'em', 'del', 'a', 'img', 'br', 'span'], + ADD_ATTR: ['href', 'target', 'src', 'alt', 'class', 'style'], + ALLOW_DATA_ATTR: true + }); +} + +/** + * Convert inline markdown syntax to HTML. + * Handles: **bold**, *italic*, `code`, [link](url), ~~strikethrough~~, and HTML tags + * Optimized with pre-compiled regex and LRU caching. + */ +function renderInlineMarkdown(text: string): string { + // Check cache first + const cached = inlineRenderCache.get(text); + if (cached !== undefined) return cached; + + let html = text; + + // Check if text contains HTML tags + const hasHTMLTags = HTML_TAG_RE.test(text); + + if (hasHTMLTags) { + // If contains HTML tags, process markdown first without escaping < > + // Bold: **text** or __text__ + html = html.replace(BOLD_STAR_RE, '$1'); + html = html.replace(BOLD_UNDER_RE, '$1'); + + // Italic: *text* or _text_ (but not inside words for _) + html = html.replace(ITALIC_STAR_RE, '$1'); + html = html.replace(ITALIC_UNDER_RE, '$1'); + + // Inline code: `code` - but don't double-process if already has + if (!html.includes('')) { + html = html.replace(CODE_RE, '$1'); + } + + // Links: [text](url) + html = html.replace(LINK_RE, '$1'); + + // Strikethrough: ~~text~~ + html = html.replace(STRIKE_RE, '$1'); + + // Sanitize HTML for security + html = sanitizeHTML(html); + } else { + // No HTML tags - escape < > and process markdown + html = html.replace(//g, '>'); + + // Bold: **text** or __text__ + html = html.replace(BOLD_STAR_RE, '$1'); + html = html.replace(BOLD_UNDER_RE, '$1'); + + // Italic: *text* or _text_ (but not inside words for _) + html = html.replace(ITALIC_STAR_RE, '$1'); + html = html.replace(ITALIC_UNDER_RE, '$1'); + + // Inline code: `code` + html = html.replace(CODE_RE, '$1'); + + // Links: [text](url) + html = html.replace(LINK_RE, '$1'); + + // Strikethrough: ~~text~~ + html = html.replace(STRIKE_RE, '$1'); + } + + // Cache result using LRU cache + inlineRenderCache.set(text, html); + + return html; +} + + +/** + * Widget to display rendered table. + * Uses absolute positioning to overlay on source lines. + * Optimized with innerHTML for faster DOM creation. + */ +class TableWidget extends WidgetType { + // Cache the generated HTML to avoid regenerating on each toDOM call + private cachedHTML: string | null = null; + + constructor( + readonly tableData: TableData, + readonly lineCount: number, + readonly lineHeight: number, + readonly visualHeight: number, + readonly contentWidth: number + ) { + super(); + } + + /** + * Build table HTML string (much faster than DOM API for large tables). + */ + private buildTableHTML(): string { + if (this.cachedHTML) return this.cachedHTML; + + // Calculate row heights + const headerRatio = 2 / this.lineCount; + const dataRowRatio = 1 / this.lineCount; + const headerHeight = this.visualHeight * headerRatio; + const dataRowHeight = this.visualHeight * dataRowRatio; + + // Build header cells + const headerCells = this.tableData.headers.map((header, idx) => { + const align = this.tableData.alignments[idx] || 'left'; + const escapedTitle = header.replace(/"/g, '"'); + return `${renderInlineMarkdown(header)}`; + }).join(''); + + // Build body rows + const bodyRows = this.tableData.rows.map(row => { + const cells = row.map((cell, idx) => { + const align = this.tableData.alignments[idx] || 'left'; + const escapedTitle = cell.replace(/"/g, '"'); + return `${renderInlineMarkdown(cell)}`; + }).join(''); + return `${cells}`; + }).join(''); + + this.cachedHTML = `${headerCells}${bodyRows}
`; + return this.cachedHTML; + } + + toDOM(): HTMLElement { + const container = document.createElement('div'); + container.className = 'cm-table-container'; + container.style.height = `${this.visualHeight}px`; + + const tableWrapper = document.createElement('div'); + tableWrapper.className = 'cm-table-wrapper'; + tableWrapper.style.maxWidth = `${this.contentWidth}px`; + tableWrapper.style.maxHeight = `${this.visualHeight}px`; + + // Use innerHTML for faster DOM creation (single parse vs many createElement calls) + tableWrapper.innerHTML = this.buildTableHTML(); + + container.appendChild(tableWrapper); + return container; + } + + eq(other: TableWidget): boolean { + // Quick dimension checks first (most likely to differ) + if (this.visualHeight !== other.visualHeight || + this.contentWidth !== other.contentWidth || + this.lineCount !== other.lineCount) { + return false; + } + + // Use reference equality for tableData if same object + if (this.tableData === other.tableData) return true; + + // Quick length checks + const headers1 = this.tableData.headers; + const headers2 = other.tableData.headers; + const rows1 = this.tableData.rows; + const rows2 = other.tableData.rows; + + if (headers1.length !== headers2.length || rows1.length !== rows2.length) { + return false; + } + + // Compare headers (usually short) + for (let i = 0, len = headers1.length; i < len; i++) { + if (headers1[i] !== headers2[i]) return false; + } + + // Compare rows + for (let i = 0, rowLen = rows1.length; i < rowLen; i++) { + const row1 = rows1[i]; + const row2 = rows2[i]; + if (row1.length !== row2.length) return false; + for (let j = 0, cellLen = row1.length; j < cellLen; j++) { + if (row1[j] !== row2[j]) return false; + } + } + + return true; + } + + ignoreEvent(): boolean { + return false; + } +} + +// ============================================================================ +// Decorations +// ============================================================================ + +/** + * Check if a range overlaps with any folded region. + */ +function isInFoldedRange(view: EditorView, from: number, to: number): boolean { + const folded = foldedRanges(view.state); + const cursor = folded.iter(); + while (cursor.value) { + // Check if ranges overlap + if (cursor.from < to && cursor.to > from) { + return true; + } + cursor.next(); + } + return false; +} + +/** Result of building decorations - includes both decorations and table ranges */ +interface BuildResult { + decorations: DecorationSet; + tableRanges: TableRange[]; +} + +/** + * Build decorations for tables and collect table ranges in a single pass. + * Optimized: single syntax tree traversal instead of two separate ones. + */ +function buildDecorationsAndRanges(view: EditorView): BuildResult { + const decorations: Range[] = []; + const tableRanges: TableRange[] = []; + const contentWidth = view.contentDOM.clientWidth - 10; + const lineHeight = view.defaultLineHeight; + + // Pre-create the line decoration to reuse (same class for all hidden lines) + const hiddenLineDecoration = Decoration.line({ class: 'cm-table-line-hidden' }); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { + if (type.name !== 'Table') return; + + // Always collect table ranges for selection tracking + tableRanges.push({ from: nodeFrom, to: nodeTo }); + + // Skip rendering if table is in a folded region + if (isInFoldedRange(view, nodeFrom, nodeTo)) return; + + // Skip rendering if cursor/selection is in table range + if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return; + + // Get cached or parse table data + const tableData = getCachedTableData(view.state, node); + if (!tableData) return; + + // Calculate line info + const startLine = view.state.doc.lineAt(nodeFrom); + const endLine = view.state.doc.lineAt(nodeTo); + const lineCount = endLine.number - startLine.number + 1; + + // Get visual height using lineBlockAt (includes wrapped lines) + const startBlock = view.lineBlockAt(nodeFrom); + const endBlock = view.lineBlockAt(nodeTo); + const visualHeight = endBlock.bottom - startBlock.top; + + // Add line decorations to hide content (reuse decoration object) + for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) { + const line = view.state.doc.line(lineNum); + decorations.push(hiddenLineDecoration.range(line.from)); + } + + // Add widget on the first line (positioned absolutely) + decorations.push( + Decoration.widget({ + widget: new TableWidget(tableData, lineCount, lineHeight, visualHeight, contentWidth), + side: -1 + }).range(startLine.from) + ); + } + }); + } + + return { + decorations: Decoration.set(decorations, true), + tableRanges + }; +} + +// ============================================================================ +// Plugin +// ============================================================================ + +/** + * Find which table the selection is in (if any). + * Returns table index or -1 if not in any table. + * Optimized: early exit on first match. + */ +function findSelectionTableIndex( + selectionRanges: readonly { from: number; to: number }[], + tableRanges: TableRange[] +): number { + // Early exit if no tables + if (tableRanges.length === 0) return -1; + + for (const sel of selectionRanges) { + const selFrom = sel.from; + const selTo = sel.to; + for (let i = 0; i < tableRanges.length; i++) { + const table = tableRanges[i]; + // Inline overlap check (avoid function call overhead) + if (selFrom <= table.to && table.from <= selTo) { + return i; + } + } + } + return -1; +} + +/** + * Table plugin with optimized update detection. + * + * Performance optimizations: + * - Single syntax tree traversal (buildDecorationsAndRanges) + * - Tracks table ranges to minimize unnecessary rebuilds + * - Only rebuilds when selection enters/exits table OR switches between tables + * - Detects both cursor position AND selection range changes + */ +class TablePlugin { + decorations: DecorationSet; + private tableRanges: TableRange[] = []; + private lastContentWidth: number = 0; + // Track last selection state for comparison + private lastSelectionFrom: number = -1; + private lastSelectionTo: number = -1; + // Track which table the selection is in (-1 = not in any table) + private lastTableIndex: number = -1; + + constructor(view: EditorView) { + const result = buildDecorationsAndRanges(view); + this.decorations = result.decorations; + this.tableRanges = result.tableRanges; + this.lastContentWidth = view.contentDOM.clientWidth; + // Initialize selection tracking + const mainSel = view.state.selection.main; + this.lastSelectionFrom = mainSel.from; + this.lastSelectionTo = mainSel.to; + this.lastTableIndex = findSelectionTableIndex(view.state.selection.ranges, this.tableRanges); + } + + update(update: ViewUpdate) { + const view = update.view; + const currentContentWidth = view.contentDOM.clientWidth; + + // Check if content width changed (requires rebuild for proper sizing) + const widthChanged = Math.abs(currentContentWidth - this.lastContentWidth) > 1; + if (widthChanged) { + this.lastContentWidth = currentContentWidth; + } + + // Full rebuild needed for: + // - Document changes (table content may have changed) + // - Viewport changes (new tables may be visible) + // - Geometry changes (folding, line height changes) + // - Width changes (table needs resizing) + if (update.docChanged || update.viewportChanged || update.geometryChanged || widthChanged) { + const result = buildDecorationsAndRanges(view); + this.decorations = result.decorations; + this.tableRanges = result.tableRanges; + // Update selection tracking + const mainSel = update.state.selection.main; + this.lastSelectionFrom = mainSel.from; + this.lastSelectionTo = mainSel.to; + this.lastTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges); + return; + } + + // For selection changes, check if selection moved in/out of a table OR between tables + if (update.selectionSet) { + const mainSel = update.state.selection.main; + const selectionChanged = mainSel.from !== this.lastSelectionFrom || + mainSel.to !== this.lastSelectionTo; + + if (selectionChanged) { + // Find which table (if any) the selection is now in + const currentTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges); + + // Rebuild if selection moved to a different table (including in/out) + if (currentTableIndex !== this.lastTableIndex) { + const result = buildDecorationsAndRanges(view); + this.decorations = result.decorations; + this.tableRanges = result.tableRanges; + // Re-check after rebuild (table ranges may have changed) + this.lastTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges); + } else { + this.lastTableIndex = currentTableIndex; + } + + // Update tracking state + this.lastSelectionFrom = mainSel.from; + this.lastSelectionTo = mainSel.to; + } + } + } +} + +const tablePlugin = ViewPlugin.fromClass( + TablePlugin, + { + decorations: (v) => v.decorations + } +); + +// ============================================================================ +// Theme +// ============================================================================ + +/** + * Base theme for tables. + */ +const baseTheme = EditorView.baseTheme({ + // Table container - same as math.ts + '.cm-table-container': { + position: 'absolute', + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'flex-start', + pointerEvents: 'none', + zIndex: '2', + overflow: 'hidden', + }, + + // Table wrapper - scrollable when needed + '.cm-table-wrapper': { + display: 'inline-block', + pointerEvents: 'auto', + backgroundColor: 'var(--bg-primary)', + overflowX: 'auto', + overflowY: 'auto', + }, + + // Table styles - use inset box-shadow for outer border (not clipped by overflow) + '.cm-table': { + borderCollapse: 'separate', + borderSpacing: '0', + fontSize: 'inherit', + fontFamily: 'inherit', + lineHeight: 'inherit', + backgroundColor: 'var(--cm-table-bg)', + border: 'none', + boxShadow: 'inset 0 0 0 1px var(--cm-table-border)', + color: 'var(--text-primary) !important', + }, + + '.cm-table th, .cm-table td': { + padding: '0 8px', + border: 'none', + color: 'inherit !important', + verticalAlign: 'middle', + boxSizing: 'border-box', + fontSize: 'inherit', + fontFamily: 'inherit', + lineHeight: 'inherit', + // Prevent text wrapping to maintain row height + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + maxWidth: '300px', + }, + + // Data cells: left divider + bottom divider + '.cm-table td': { + boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)', + }, + + // First column data cells: only bottom divider + '.cm-table td:first-child': { + boxShadow: '0 1px 0 var(--cm-table-border)', + }, + + // Last row data cells: only left divider (no bottom) + '.cm-table tbody tr:last-child td': { + boxShadow: '-1px 0 0 var(--cm-table-border)', + }, + + // Last row first column: no dividers + '.cm-table tbody tr:last-child td:first-child': { + boxShadow: 'none', + }, + + '.cm-table th': { + backgroundColor: 'var(--cm-table-header-bg)', + fontWeight: '600', + // Header cells: left divider + bottom divider + boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)', + }, + + '.cm-table th:first-child': { + // First header cell: only bottom divider + boxShadow: '0 1px 0 var(--cm-table-border)', + }, + + '.cm-table tbody tr:hover': { + backgroundColor: 'var(--cm-table-row-hover)', + }, + + // Alignment classes - use higher specificity to override default + '.cm-table th.cm-table-align-left, .cm-table td.cm-table-align-left': { + textAlign: 'left', + }, + + '.cm-table th.cm-table-align-center, .cm-table td.cm-table-align-center': { + textAlign: 'center', + }, + + '.cm-table th.cm-table-align-right, .cm-table td.cm-table-align-right': { + textAlign: 'right', + }, + + // Inline elements in table cells + '.cm-table code': { + backgroundColor: 'var(--cm-inline-code-bg, var(--bg-hover))', + padding: '1px 4px', + borderRadius: '3px', + fontSize: 'inherit', + fontFamily: 'var(--voidraft-font-mono)', + }, + + '.cm-table a': { + color: 'var(--selection-text)', + textDecoration: 'none', + }, + + '.cm-table a:hover': { + textDecoration: 'underline', + }, + + // Hidden line content for table (text transparent but line preserved) + // Use high specificity to override rainbow brackets and other plugins + '.cm-line.cm-table-line-hidden': { + color: 'transparent !important', + caretColor: 'transparent', + }, + '.cm-line.cm-table-line-hidden span': { + color: 'transparent !important', + }, + // Override rainbow brackets in hidden table lines + '.cm-line.cm-table-line-hidden [class*="cm-rainbow-bracket"]': { + color: 'transparent !important', + }, +}); + + +/** + * Table extension. + * + * Features: + * - Parses markdown tables using syntax tree + * - Renders tables as beautiful HTML tables + * - Table preserves line structure, overlays rendered table + * - Shows source when cursor is inside + */ +export const table = (): Extension => [ + tablePlugin, + baseTheme +]; + +export default table; + diff --git a/frontend/src/views/editor/theme/light/default-light.ts b/frontend/src/views/editor/theme/light/default-light.ts index d57d964..74c0d1a 100644 --- a/frontend/src/views/editor/theme/light/default-light.ts +++ b/frontend/src/views/editor/theme/light/default-light.ts @@ -6,7 +6,7 @@ export const defaultLightColors: ThemeColors = { dark: false, background: '#ffffff', - backgroundSecondary: '#f4f7fb', + backgroundSecondary: '#f1faf1', foreground: '#24292e', cursor: '#000000', diff --git a/frontend/src/views/editor/theme/light/github-light.ts b/frontend/src/views/editor/theme/light/github-light.ts index 4e7e084..a1844ff 100644 --- a/frontend/src/views/editor/theme/light/github-light.ts +++ b/frontend/src/views/editor/theme/light/github-light.ts @@ -7,7 +7,7 @@ export const config: ThemeColors = { dark: false, background: '#ffffff', - backgroundSecondary: '#f1faf1', + backgroundSecondary: '##f4f7fb ', foreground: '#444d56', cursor: '#044289',