diff --git a/frontend/src/assets/styles/variables.css b/frontend/src/assets/styles/variables.css index d24c602..a413247 100644 --- a/frontend/src/assets/styles/variables.css +++ b/frontend/src/assets/styles/variables.css @@ -119,7 +119,7 @@ --voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%); /* Markdown 代码块样式 - 亮色主题 */ - --cm-codeblock-bg: oklch(92.9% 0.013 255.508); + --cm-codeblock-bg: #f3f3f3; --cm-codeblock-radius: 0.4rem; /* Markdown 内联代码样式 */ diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts b/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts index 59f2044..6234f77 100644 --- a/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts @@ -13,6 +13,7 @@ import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight"; import {Insert} from "@/views/editor/extensions/markdown/syntax/insert"; import {Math} from "@/views/editor/extensions/markdown/syntax/math"; import {Footnote} from "@/views/editor/extensions/markdown/syntax/footnote"; +import {Emoji} from "@/views/editor/extensions/markdown/syntax/emoji"; import {javaLanguage} from "@codemirror/lang-java"; import {phpLanguage} from "@codemirror/lang-php"; import {cssLanguage} from "@codemirror/lang-css"; @@ -118,7 +119,7 @@ export const LANGUAGES: LanguageInfo[] = [ }), new LanguageInfo("md", "Markdown", markdown({ base: markdownLanguage, - extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table], + extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table, Emoji], completeHTMLTags: true, pasteURLAsLink: true, htmlTagLanguage: html({ diff --git a/frontend/src/views/editor/extensions/hyperlink/index.ts b/frontend/src/views/editor/extensions/hyperlink/index.ts index 2806888..4c707ab 100644 --- a/frontend/src/views/editor/extensions/hyperlink/index.ts +++ b/frontend/src/views/editor/extensions/hyperlink/index.ts @@ -8,10 +8,36 @@ import { ViewUpdate, } from '@codemirror/view'; import { Extension, Range } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; import * as runtime from "@wailsio/runtime"; const pathStr = ``; const defaultRegexp = /\b(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/gi; +// Markdown link parent nodes that should be excluded from hyperlink decoration +const MARKDOWN_LINK_PARENTS = new Set(['Link', 'Image', 'URL']); + +/** + * Check if a position is inside a markdown link syntax node. + * This prevents hyperlink decorations from conflicting with markdown rendering. + */ +function isInMarkdownLink(view: EditorView, from: number, to: number): boolean { + const tree = syntaxTree(view.state); + let inLink = false; + + tree.iterate({ + from, + to, + enter: (node) => { + if (MARKDOWN_LINK_PARENTS.has(node.name)) { + inLink = true; + return false; // Stop iteration + } + } + }); + + return inLink; +} + export interface HyperLinkState { at: number; url: string; @@ -53,6 +79,11 @@ function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptio const from = match.index; const to = from + match[0].length; + // Skip URLs that are inside markdown link syntax + if (isInMarkdownLink(view, from, to)) { + continue; + } + const linkMark = Decoration.mark({ class: 'cm-hyper-link-text' }); @@ -80,7 +111,12 @@ const linkDecorator = ( ) => new MatchDecorator({ regexp: regexp || defaultRegexp, - decorate: (add, from, to, match, _view) => { + decorate: (add, from, to, match, view) => { + // Skip URLs that are inside markdown link syntax + if (isInMarkdownLink(view, from, to)) { + return; + } + const url = match[0]; let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url; if (matchData && matchData[url]) { diff --git a/frontend/src/views/editor/extensions/markdown/index.ts b/frontend/src/views/editor/extensions/markdown/index.ts index 0331747..dfd398e 100644 --- a/frontend/src/views/editor/extensions/markdown/index.ts +++ b/frontend/src/views/editor/extensions/markdown/index.ts @@ -1,45 +1,19 @@ import { Extension } from '@codemirror/state'; -import { blockquote } from './plugins/blockquote'; -import { codeblock } from './plugins/code-block'; -import { headings } from './plugins/heading'; -import { hideMarks } from './plugins/hide-mark'; import { image } from './plugins/image'; -import { links } from './plugins/link'; -import { lists } from './plugins/list'; import { headingSlugField } from './state/heading-slug'; -import { emoji } from './plugins/emoji'; -import { horizontalRule } from './plugins/horizontal-rule'; -import { inlineCode } from './plugins/inline-code'; -import { subscriptSuperscript } from './plugins/subscript-superscript'; -import { highlight } from './plugins/highlight'; -import { insert } from './plugins/insert'; -import { math } from './plugins/math'; -import { footnote } from './plugins/footnote'; -import table from "./plugins/table"; -import {htmlBlockExtension} from "./plugins/html"; +import {html} from './plugins/html'; +import { render } from './plugins/render'; +import { Theme } from './plugins/theme'; /** - * markdown extensions + * Markdown extensions. */ export const markdownExtensions: Extension = [ headingSlugField, - blockquote(), - codeblock(), - headings(), - hideMarks(), - lists(), - links(), + render(), + Theme, image(), - emoji(), - horizontalRule(), - inlineCode(), - subscriptSuperscript(), - highlight(), - insert(), - math(), - footnote(), - table(), - htmlBlockExtension + html() ]; 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 f48053a..ed85025 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts @@ -1,173 +1,56 @@ -import { - Decoration, - DecorationSet, - EditorView, - ViewPlugin, - ViewUpdate -} from '@codemirror/view'; -import { RangeSetBuilder } from '@codemirror/state'; -import { syntaxTree } from '@codemirror/language'; -import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; +/** + * Blockquote handler and theme. + */ -/** Pre-computed line decoration */ -const LINE_DECO = Decoration.line({ class: 'cm-blockquote' }); +import { Decoration, EditorView } from '@codemirror/view'; +import { invisibleDecoration, RangeTuple } from '../util'; +import { SyntaxNode } from '@lezer/common'; +import { BuildContext } from './types'; + +const DECO_BLOCKQUOTE_LINE = Decoration.line({ class: 'cm-blockquote' }); /** - * Blockquote plugin. - * - * Features: - * - Decorates blockquote with left border - * - Hides quote marks (>) when cursor is outside - * - Supports nested blockquotes + * Handle Blockquote node. */ -export function blockquote() { - return [blockQuotePlugin, baseTheme]; -} +export function handleBlockquote( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): boolean { + if (ctx.seen.has(nf)) return false; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return false; -/** - * Collect blockquote ranges in visible viewport. - */ -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(); - - 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 inBlock = checkRangeOverlap( - [node.from, node.to], - [view.state.selection.main.from, view.state.selection.main.to] - ); - if (inBlock) return false; - - // Line decorations - const startLine = view.state.doc.lineAt(node.from).number; - const endLine = view.state.doc.lineAt(node.to).number; - - for (let i = startLine; i <= endLine; i++) { - if (!processedLines.has(i)) { - processedLines.add(i); - const line = view.state.doc.line(i); - items.push({ pos: line.from, deco: LINE_DECO }); - } - } - - // Hide quote marks - const cursor = node.node.cursor(); - cursor.iterate((child) => { - if (child.type.name === 'QuoteMark') { - items.push({ pos: child.from, endPos: child.to, deco: invisibleDecoration }); - } - }); - - 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); + const startLine = ctx.view.state.doc.lineAt(nf).number; + const endLine = ctx.view.state.doc.lineAt(nt).number; + for (let i = startLine; i <= endLine; i++) { + if (!ctx.processedLines.has(i)) { + ctx.processedLines.add(i); + ctx.items.push({ from: ctx.view.state.doc.line(i).from, to: ctx.view.state.doc.line(i).from, deco: DECO_BLOCKQUOTE_LINE }); } } - return builder.finish(); + // Use TreeCursor to traverse all descendant QuoteMarks + // getChildren() only returns direct children, but QuoteMarks may be nested + // deeper in the syntax tree (e.g., in nested blockquotes for empty lines) + // cursor.next() is the official Lezer API for depth-first tree traversal + const cursor = node.cursor(); + while (cursor.next() && cursor.to <= nt) { + if (cursor.name === 'QuoteMark') { + ctx.items.push({ from: cursor.from, to: cursor.to, deco: invisibleDecoration }); + } + } + return false; } /** - * Blockquote plugin with optimized updates. + * Theme for blockquotes. */ -class BlockQuotePlugin { - decorations: DecorationSet; - private blockRanges: RangeTuple[] = []; - private cursorBlockPos = -1; - - constructor(view: EditorView) { - this.blockRanges = collectBlockquoteRanges(view); - this.cursorBlockPos = getCursorBlockquotePos(view, this.blockRanges); - this.decorations = buildDecorations(view); - } - - update(update: ViewUpdate) { - 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); - } - } - } -} - -const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, { - decorations: (v) => v.decorations -}); - -/** - * Base theme for blockquotes. - */ -const baseTheme = EditorView.baseTheme({ +export const blockquoteTheme = EditorView.baseTheme({ '.cm-blockquote': { borderLeft: '4px solid var(--cm-blockquote-border, #ccc)', color: 'var(--cm-blockquote-color, #666)' 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 42995d5..939f1e7 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts @@ -1,331 +1,107 @@ -import { Extension, RangeSetBuilder } from '@codemirror/state'; -import { - ViewPlugin, - DecorationSet, - Decoration, - EditorView, - ViewUpdate, - WidgetType -} from '@codemirror/view'; -import { syntaxTree } from '@codemirror/language'; -import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; +/** + * Code block handler and theme. + */ -/** Code block node types in syntax tree */ -const CODE_BLOCK_TYPES = new Set(['FencedCode', 'CodeBlock']); +import { Decoration, EditorView, WidgetType } from '@codemirror/view'; +import { invisibleDecoration, RangeTuple } from '../util'; +import { SyntaxNode } from '@lezer/common'; +import { BuildContext } from './types'; + +const DECO_CODEBLOCK_LINE = Decoration.line({ class: 'cm-codeblock' }); +const DECO_CODEBLOCK_BEGIN = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin' }); +const DECO_CODEBLOCK_END = Decoration.line({ class: 'cm-codeblock cm-codeblock-end' }); +const DECO_CODEBLOCK_SINGLE = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin cm-codeblock-end' }); -/** Copy button icon SVGs */ const ICON_COPY = ``; const ICON_CHECK = ``; -/** 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; -} - -/** - * Code block extension with language label and copy button. - * - * Features: - * - Adds background styling to code blocks - * - Shows language label + copy button when language is specified - * - Hides markers when cursor is outside block - * - Optimized with viewport-only rendering and minimal rebuilds - */ -export const codeblock = (): Extension => [codeBlockPlugin, baseTheme]; - -/** - * Widget for displaying language label and copy button. - * Content is computed lazily on copy action. - */ class CodeBlockInfoWidget extends WidgetType { - constructor(readonly meta: CodeBlockMeta) { - super(); - } - - eq(other: CodeBlockInfoWidget): boolean { - return other.meta.from === this.meta.from && - other.meta.language === this.meta.language; - } - + constructor(readonly from: number, readonly to: number, readonly language: string | null) { super(); } + eq(other: CodeBlockInfoWidget) { return other.from === this.from && other.language === this.language; } toDOM(view: EditorView): HTMLElement { const container = document.createElement('span'); container.className = 'cm-code-block-info'; - - if (this.meta.language) { + if (this.language) { const lang = document.createElement('span'); lang.className = 'cm-code-block-lang'; - lang.textContent = this.meta.language; + lang.textContent = this.language; container.append(lang); } - const btn = document.createElement('button'); btn.className = 'cm-code-block-copy-btn'; btn.title = 'Copy'; btn.innerHTML = ICON_COPY; - btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - this.copyContent(view, btn); + const text = view.state.doc.sliceString(this.from, this.to); + const lines = text.split('\n'); + const content = lines.length >= 2 ? lines.slice(1, -1).join('\n') : ''; + if (content) { + navigator.clipboard.writeText(content).then(() => { + btn.innerHTML = ICON_CHECK; + setTimeout(() => { btn.innerHTML = ICON_COPY; }, 1500); + }); + } }); - - btn.addEventListener('mousedown', (e) => { - e.preventDefault(); - e.stopPropagation(); - }); - + btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); }); container.append(btn); return container; } - - /** 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(() => { - btn.innerHTML = ICON_CHECK; - setTimeout(() => { - btn.innerHTML = ICON_COPY; - }, 1500); - }); - } - - ignoreEvent(): boolean { - return true; - } -} - -/** 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 + ignoreEvent() { return true; } } /** - * Parse a code block node in a single traversal. - * Extracts language and mark positions together. + * Handle FencedCode / CodeBlock node. */ -function parseCodeBlock(view: EditorView, nodeFrom: number, nodeTo: number, node: any): ParsedBlock { - let language: string | null = null; - const marks: RangeTuple[] = []; +export function handleCodeBlock( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); - 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 - } - } + const startLine = ctx.view.state.doc.lineAt(nf); + const endLine = ctx.view.state.doc.lineAt(nt); + for (let num = startLine.number; num <= endLine.number; num++) { + const line = ctx.view.state.doc.line(num); + let deco = DECO_CODEBLOCK_LINE; + if (startLine.number === endLine.number) deco = DECO_CODEBLOCK_SINGLE; + else if (num === startLine.number) deco = DECO_CODEBLOCK_BEGIN; + else if (num === endLine.number) deco = DECO_CODEBLOCK_END; + ctx.items.push({ from: line.from, to: line.from, deco }); } - 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): 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.has(type.name)) return; - if (seen.has(nodeFrom)) return; - seen.add(nodeFrom); - - // 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; - - // 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); - 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 }); - } - - // 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 - }), - isWidget: true - }); - - // Hide marks - for (const [mFrom, mTo] of block.marks) { - items.push({ pos: mFrom, endPos: mTo, deco: invisibleDecoration, isReplace: true }); - } - } - }); - } - - // 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 update detection. - */ -class CodeBlockPluginClass { - decorations: DecorationSet; - private blockRanges: RangeTuple[] = []; - private cursorBlockPos = -1; // Which block the cursor is in (-1 = none) - - constructor(view: EditorView) { - this.blockRanges = collectCodeBlockRanges(view); - this.cursorBlockPos = getCursorBlockPosition(view, this.blockRanges); - this.decorations = buildDecorations(view); - } - - update(update: ViewUpdate): void { - const { docChanged, viewportChanged, selectionSet } = update; - - // 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; - } - - // 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); - } - } + if (!inCursor) { + const codeInfo = node.getChild('CodeInfo'); + const codeMarks = node.getChildren('CodeMark'); + const language = codeInfo ? ctx.view.state.doc.sliceString(codeInfo.from, codeInfo.to).trim() : null; + ctx.items.push({ from: startLine.to, to: startLine.to, deco: Decoration.widget({ widget: new CodeBlockInfoWidget(nf, nt, language), side: 1 }), priority: 1 }); + if (codeInfo) ctx.items.push({ from: codeInfo.from, to: codeInfo.to, deco: invisibleDecoration }); + for (const mark of codeMarks) ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration }); } } -const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, { - decorations: (v) => v.decorations -}); - /** - * Base theme for code blocks. + * Theme for code blocks. */ -const baseTheme = EditorView.baseTheme({ +export const codeBlockTheme = EditorView.baseTheme({ '.cm-codeblock': { backgroundColor: 'var(--cm-codeblock-bg)', - fontFamily: 'inherit', + fontFamily: 'inherit' }, '.cm-codeblock-begin': { borderTopLeftRadius: 'var(--cm-codeblock-radius)', borderTopRightRadius: 'var(--cm-codeblock-radius)', - position: 'relative', + position: 'relative' }, '.cm-codeblock-end': { borderBottomLeftRadius: 'var(--cm-codeblock-radius)', - borderBottomRightRadius: 'var(--cm-codeblock-radius)', + borderBottomRightRadius: 'var(--cm-codeblock-radius)' }, '.cm-code-block-info': { position: 'absolute', @@ -339,9 +115,7 @@ const baseTheme = EditorView.baseTheme({ opacity: '0.5', transition: 'opacity 0.15s' }, - '.cm-code-block-info:hover': { - opacity: '1' - }, + '.cm-code-block-info:hover': { opacity: '1' }, '.cm-code-block-lang': { color: 'var(--cm-codeblock-lang, var(--cm-foreground))', textTransform: 'lowercase', @@ -360,12 +134,6 @@ const baseTheme = EditorView.baseTheme({ opacity: '0.7', transition: 'opacity 0.15s, background 0.15s' }, - '.cm-code-block-copy-btn:hover': { - opacity: '1', - background: 'rgba(128, 128, 128, 0.2)' - }, - '.cm-code-block-copy-btn svg': { - width: '1em', - height: '1em' - } + '.cm-code-block-copy-btn:hover': { opacity: '1', background: 'rgba(128, 128, 128, 0.2)' }, + '.cm-code-block-copy-btn svg': { width: '1em', height: '1em' } }); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts b/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts index 5572f28..ca552da 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts @@ -1,44 +1,16 @@ -import { Extension, RangeSetBuilder } from '@codemirror/state'; -import { - ViewPlugin, - DecorationSet, - Decoration, - EditorView, - ViewUpdate, - WidgetType -} from '@codemirror/view'; -import { checkRangeOverlap, RangeTuple } from '../util'; +/** + * Emoji handler and theme. + */ + +import { Decoration, EditorView, WidgetType } from '@codemirror/view'; +import { RangeTuple } from '../util'; +import { SyntaxNode } from '@lezer/common'; +import { BuildContext } from './types'; import { emojies } from '@/common/constant/emojies'; -/** - * Emoji plugin that converts :emoji_name: to actual emoji characters. - * - * Features: - * - Detects emoji patterns like :smile:, :heart:, etc. - * - Replaces them with actual emoji characters - * - Shows the original text when cursor is nearby - * - Optimized with cached matches and minimal rebuilds - */ -export const emoji = (): Extension => [emojiPlugin, baseTheme]; - -/** Non-global regex for matchAll (more efficient than global with lastIndex reset) */ -const EMOJI_REGEX = /:([a-z0-9_+\-]+):/gi; - -/** - * Emoji widget with optimized rendering. - */ class EmojiWidget extends WidgetType { - constructor( - readonly emoji: string, - readonly name: string - ) { - super(); - } - - eq(other: EmojiWidget): boolean { - return other.emoji === this.emoji; - } - + constructor(readonly emoji: string, readonly name: string) { super(); } + eq(other: EmojiWidget) { return other.emoji === this.emoji; } toDOM(): HTMLElement { const span = document.createElement('span'); span.className = 'cm-emoji'; @@ -49,148 +21,37 @@ class EmojiWidget extends WidgetType { } /** - * Cached emoji match. + * Handle Emoji node (:emoji:). */ -interface EmojiMatch { - from: number; - to: number; - name: string; - emoji: string; -} +export function handleEmoji( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; -/** - * Find all emoji matches in visible ranges. - */ -function findAllEmojiMatches(view: EditorView): EmojiMatch[] { - const matches: EmojiMatch[] = []; - const doc = view.state.doc; - - 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]; - - if (emojiChar) { - matches.push({ - from: from + match.index, - to: from + match.index + match[0].length, - name, - emoji: emojiChar - }); - } - } - } - - return matches; -} - -/** - * Get which emoji the cursor is in (-1 if none). - */ -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 cached matches and optimized updates. - */ -class EmojiPlugin { - decorations: DecorationSet; - private matches: EmojiMatch[] = []; - private cursorEmojiIdx = -1; - - constructor(view: EditorView) { - 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) { - 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, only rebuild if cursor enters/leaves an emoji - if (selectionSet) { - const { from, to } = update.state.selection.main; - const newIdx = getCursorEmojiIndex(this.matches, from, to); - - if (newIdx !== this.cursorEmojiIdx) { - this.cursorEmojiIdx = newIdx; - this.decorations = buildDecorations(this.matches, from, to); - } - } + const nameNode = node.getChild('EmojiName'); + if (!nameNode) return; + const name = ctx.view.state.sliceDoc(nameNode.from, nameNode.to).toLowerCase(); + const emojiChar = emojies[name]; + if (emojiChar) { + ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new EmojiWidget(emojiChar, name) }) }); } } -const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, { - decorations: (v) => v.decorations -}); - /** - * Base theme for emoji. + * Theme for emoji. */ -const baseTheme = EditorView.baseTheme({ +export const emojiTheme = EditorView.baseTheme({ '.cm-emoji': { - verticalAlign: 'middle', - cursor: 'default' + cursor: 'default', + fontSize: 'inherit', + lineHeight: 'inherit' } }); - -/** - * Get all available emoji names. - */ -export function getEmojiNames(): string[] { - return Object.keys(emojies); -} - -/** - * Get emoji by name. - */ -export function getEmoji(name: string): string | undefined { - return emojies[name.toLowerCase()]; -} diff --git a/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts b/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts index 908614b..91a4451 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts @@ -1,621 +1,152 @@ /** - * Footnote plugin for CodeMirror. - * - * Features: - * - Renders footnote references as superscript numbers/labels - * - Renders inline footnotes as superscript numbers with embedded content - * - Shows footnote content on hover (tooltip) - * - Click to jump between reference and definition - * - Hides syntax marks when cursor is outside + * Footnote handlers and theme. + * Handles: FootnoteDefinition, FootnoteReference, InlineFootnote */ -import { Extension, RangeSetBuilder, EditorState } from '@codemirror/state'; -import { syntaxTree } from '@codemirror/language'; -import { - ViewPlugin, - DecorationSet, - Decoration, - EditorView, - ViewUpdate, - WidgetType, - hoverTooltip, - Tooltip, -} from '@codemirror/view'; -import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; +import { Decoration, EditorView, WidgetType } from '@codemirror/view'; +import { invisibleDecoration, RangeTuple } from '../util'; +import { SyntaxNode } from '@lezer/common'; +import { BuildContext } from './types'; -// ============================================================================ -// Types -// ============================================================================ - -interface FootnoteDefinition { - id: string; - content: string; - from: number; - to: number; +/** Extended context for footnotes */ +export interface FootnoteContext extends BuildContext { + definitionIds: Set; + pendingRefs: { from: number; to: number; id: string; index: number }[]; + pendingInlines: { from: number; to: number; index: number }[]; + seenIds: Map; + inlineFootnoteIdx: number; } -interface FootnoteReference { - id: string; - from: number; - to: number; - index: number; -} - -interface InlineFootnoteInfo { - content: string; - from: number; - to: number; - index: number; -} - -/** - * Collected footnote data with O(1) lookup indexes. - */ -interface FootnoteData { - definitions: Map; - references: FootnoteReference[]; - inlineFootnotes: InlineFootnoteInfo[]; - referencesByPos: Map; - inlineByPos: Map; - definitionByPos: Map; // For position-based lookup - firstRefById: Map; - // All footnote ranges for cursor detection - allRanges: RangeTuple[]; -} - -// ============================================================================ -// Footnote Collection (cached via closure) -// ============================================================================ - -let cachedData: FootnoteData | null = null; -let cachedDocLength = -1; - -/** - * 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[] = []; - 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; - - syntaxTree(state).iterate({ - enter: ({ type, from, to, node }) => { - if (type.name === 'FootnoteDefinition') { - const labelNode = node.getChild('FootnoteDefinitionLabel'); - const contentNode = node.getChild('FootnoteDefinitionContent'); - - if (labelNode) { - const id = state.sliceDoc(labelNode.from, labelNode.to); - const content = contentNode - ? state.sliceDoc(contentNode.from, contentNode.to).trim() - : ''; - - 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'); - - if (labelNode) { - const id = state.sliceDoc(labelNode.from, labelNode.to); - - if (!seenIds.has(id)) { - seenIds.set(id, seenIds.size + 1); - } - - const ref: FootnoteReference = { - id, - from, - to, - index: seenIds.get(id)!, - }; - - references.push(ref); - referencesByPos.set(from, ref); - allRanges.push([from, to]); - - if (!firstRefById.has(id)) { - firstRefById.set(id, ref); - } - } - } else if (type.name === 'InlineFootnote') { - const contentNode = node.getChild('InlineFootnoteContent'); - - if (contentNode) { - const content = state.sliceDoc(contentNode.from, contentNode.to).trim(); - inlineIndex++; - - const info: InlineFootnoteInfo = { - content, - from, - to, - index: inlineIndex, - }; - - inlineFootnotes.push(info); - inlineByPos.set(from, info); - allRanges.push([from, to]); - } - } - }, - }); - - cachedData = { - definitions, - references, - inlineFootnotes, - referencesByPos, - inlineByPos, - definitionByPos, - firstRefById, - allRanges, - }; - cachedDocLength = state.doc.length; - - return cachedData; -} - -// ============================================================================ -// Widgets -// ============================================================================ - class FootnoteRefWidget extends WidgetType { - constructor( - readonly id: string, - readonly index: number, - readonly hasDefinition: boolean - ) { - super(); - } - + constructor(readonly index: number, readonly hasDefinition: boolean) { super(); } + eq(other: FootnoteRefWidget) { return this.index === other.index && this.hasDefinition === other.hasDefinition; } toDOM(): HTMLElement { const span = document.createElement('span'); span.className = 'cm-footnote-ref'; span.textContent = `[${this.index}]`; - span.dataset.footnoteId = this.id; - - if (!this.hasDefinition) { - span.classList.add('cm-footnote-ref-undefined'); - } - + if (!this.hasDefinition) span.classList.add('cm-footnote-ref-undefined'); return span; } - - eq(other: FootnoteRefWidget): boolean { - return this.id === other.id && this.index === other.index; - } - - ignoreEvent(): boolean { - return false; - } + ignoreEvent() { return false; } } class InlineFootnoteWidget extends WidgetType { - constructor( - readonly content: string, - readonly index: number - ) { - super(); - } - + constructor(readonly index: number) { super(); } + eq(other: InlineFootnoteWidget) { return this.index === other.index; } toDOM(): HTMLElement { const span = document.createElement('span'); span.className = 'cm-inline-footnote-ref'; span.textContent = `[${this.index}]`; - span.dataset.footnoteContent = this.content; - span.dataset.footnoteIndex = String(this.index); - return span; } - - eq(other: InlineFootnoteWidget): boolean { - return this.content === other.content && this.index === other.index; - } - - ignoreEvent(): boolean { - return false; - } + ignoreEvent() { return false; } } class FootnoteDefLabelWidget extends WidgetType { - constructor(readonly id: string) { - super(); - } - + constructor(readonly id: string) { super(); } + eq(other: FootnoteDefLabelWidget) { return this.id === other.id; } toDOM(): HTMLElement { const span = document.createElement('span'); span.className = 'cm-footnote-def-label'; span.textContent = `[${this.id}]`; - span.dataset.footnoteId = this.id; return span; } - - eq(other: FootnoteDefLabelWidget): boolean { - return this.id === other.id; - } - - ignoreEvent(): boolean { - return false; - } + ignoreEvent() { return false; } } -// ============================================================================ -// Cursor Detection -// ============================================================================ - /** - * Get which footnote range the cursor is in (returns start position, -1 if none). + * Handle FootnoteDefinition node. */ -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; -} +export function handleFootnoteDefinition( + ctx: FootnoteContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; -// ============================================================================ -// Decorations -// ============================================================================ + const marks = node.getChildren('FootnoteDefinitionMark'); + const labelNode = node.getChild('FootnoteDefinitionLabel'); + if (marks.length >= 2 && labelNode) { + const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to); + ctx.definitionIds.add(id); + ctx.items.push({ from: marks[0].from, to: marks[1].to, deco: invisibleDecoration }); + ctx.items.push({ from: marks[1].to, to: marks[1].to, deco: Decoration.widget({ widget: new FootnoteDefLabelWidget(id), side: 1 }), priority: 1 }); + } +} /** - * Build decorations using RangeSetBuilder. + * Handle FootnoteReference node. */ -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]; +export function handleFootnoteReference( + ctx: FootnoteContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; - for (const { from, to } of view.visibleRanges) { - syntaxTree(view.state).iterate({ - from, - to, - enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { - const inCursor = checkRangeOverlap([nodeFrom, nodeTo], selRange); - - // Footnote References - if (type.name === 'FootnoteReference') { - const labelNode = node.getChild('FootnoteReferenceLabel'); - const marks = node.getChildren('FootnoteReferenceMark'); - - if (!labelNode || marks.length < 2) return; - - const id = view.state.sliceDoc(labelNode.from, labelNode.to); - const ref = data.referencesByPos.get(nodeFrom); - - 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, - }), - priority: 1 - }); - } - } - - // Footnote Definitions - if (type.name === 'FootnoteDefinition') { - const marks = node.getChildren('FootnoteDefinitionMark'); - const labelNode = node.getChild('FootnoteDefinitionLabel'); - - if (!inCursor && marks.length >= 2 && labelNode) { - const id = view.state.sliceDoc(labelNode.from, labelNode.to); - - 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, - }), - priority: 1 - }); - } - } - - // Inline Footnotes - if (type.name === 'InlineFootnote') { - const contentNode = node.getChild('InlineFootnoteContent'); - const marks = node.getChildren('InlineFootnoteMark'); - - if (!contentNode || marks.length < 2) return; - - const inlineNote = data.inlineByPos.get(nodeFrom); - - 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, - }), - priority: 1 - }); - } - } - }, - }); - } - - // 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 FootnotePlugin { - decorations: DecorationSet; - private data: FootnoteData; - private cursorFootnotePos = -1; - - constructor(view: EditorView) { - 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) { - 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 (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); - } - } + const labelNode = node.getChild('FootnoteReferenceLabel'); + const marks = node.getChildren('FootnoteReferenceMark'); + if (labelNode && marks.length >= 2) { + const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to); + if (!ctx.seenIds.has(id)) ctx.seenIds.set(id, ctx.seenIds.size + 1); + ctx.pendingRefs.push({ from: nf, to: nt, id, index: ctx.seenIds.get(id)! }); } } -const footnotePlugin = ViewPlugin.fromClass(FootnotePlugin, { - decorations: (v) => v.decorations, -}); +/** + * Handle InlineFootnote node. + */ +export function handleInlineFootnote( + ctx: FootnoteContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; -// ============================================================================ -// Hover Tooltip -// ============================================================================ - -const footnoteHoverTooltip = hoverTooltip( - (view, pos): Tooltip | null => { - const data = collectFootnotes(view.state); - - // 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) { - 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: () => createInlineTooltipDom(parseInt(index), content), - }; - } - } - } - - // Check by position using indexed data - const ref = data.referencesByPos.get(pos); - if (ref) { - const def = data.definitions.get(ref.id); - if (def) { - return { - pos: ref.to, - above: true, - arrow: true, - create: () => createTooltipDom(ref.id, def.content), - }; - } - } - - const inline = data.inlineByPos.get(pos); - if (inline) { - return { - pos: inline.to, - above: true, - arrow: true, - 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 } -); - -function createTooltipDom(id: string, content: string): { dom: HTMLElement } { - const dom = document.createElement('div'); - dom.className = 'cm-footnote-tooltip'; - - const header = document.createElement('div'); - header.className = 'cm-footnote-tooltip-header'; - header.textContent = `[^${id}]`; - - const body = document.createElement('div'); - body.className = 'cm-footnote-tooltip-body'; - body.textContent = content || '(Empty footnote)'; - - dom.appendChild(header); - dom.appendChild(body); - - return { dom }; + const contentNode = node.getChild('InlineFootnoteContent'); + const marks = node.getChildren('InlineFootnoteMark'); + if (contentNode && marks.length >= 2) { + ctx.inlineFootnoteIdx++; + ctx.pendingInlines.push({ from: nf, to: nt, index: ctx.inlineFootnoteIdx }); + } } -function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } { - const dom = document.createElement('div'); - dom.className = 'cm-footnote-tooltip'; - - const header = document.createElement('div'); - header.className = 'cm-footnote-tooltip-header'; - header.textContent = `Inline Footnote [${index}]`; - - const body = document.createElement('div'); - body.className = 'cm-footnote-tooltip-body'; - body.textContent = content || '(Empty footnote)'; - - dom.appendChild(header); - dom.appendChild(body); - - return { dom }; +/** + * Process pending footnote refs after all definitions are collected. + */ +export function processPendingFootnotes(ctx: FootnoteContext): void { + for (const ref of ctx.pendingRefs) { + ctx.items.push({ from: ref.from, to: ref.to, deco: invisibleDecoration }); + ctx.items.push({ from: ref.to, to: ref.to, deco: Decoration.widget({ widget: new FootnoteRefWidget(ref.index, ctx.definitionIds.has(ref.id)), side: 1 }), priority: 1 }); + } + for (const inline of ctx.pendingInlines) { + ctx.items.push({ from: inline.from, to: inline.to, deco: invisibleDecoration }); + ctx.items.push({ from: inline.to, to: inline.to, deco: Decoration.widget({ widget: new InlineFootnoteWidget(inline.index), side: 1 }), priority: 1 }); + } } -// ============================================================================ -// Click Handler -// ============================================================================ - -const footnoteClickHandler = EditorView.domEventHandlers({ - mousedown(event, view) { - const target = event.target as HTMLElement; - - // Click on footnote reference → jump to definition - if (target.classList.contains('cm-footnote-ref')) { - const id = target.dataset.footnoteId; - if (id) { - const data = collectFootnotes(view.state); - const def = data.definitions.get(id); - if (def) { - event.preventDefault(); - setTimeout(() => { - view.dispatch({ - selection: { anchor: def.from }, - scrollIntoView: true, - }); - view.focus(); - }, 0); - return true; - } - } - } - - // Click on definition label → jump to first reference - if (target.classList.contains('cm-footnote-def-label')) { - 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; - } - } - } - - return false; - }, -}); - -// ============================================================================ -// Theme -// ============================================================================ - -const baseTheme = EditorView.baseTheme({ +/** + * Theme for footnotes. + */ +export const footnoteTheme = EditorView.baseTheme({ '.cm-footnote-ref': { display: 'inline-flex', alignItems: 'center', @@ -630,20 +161,12 @@ const baseTheme = EditorView.baseTheme({ verticalAlign: 'super', color: 'var(--cm-footnote-color, #1a73e8)', backgroundColor: 'var(--cm-footnote-bg, rgba(26, 115, 232, 0.1))', - borderRadius: '3px', - cursor: 'pointer', - transition: 'all 0.15s ease', - textDecoration: 'none', - }, - '.cm-footnote-ref:hover': { - color: 'var(--cm-footnote-hover-color, #1557b0)', - backgroundColor: 'var(--cm-footnote-hover-bg, rgba(26, 115, 232, 0.2))', + borderRadius: '3px' }, '.cm-footnote-ref-undefined': { color: 'var(--cm-footnote-undefined-color, #d93025)', - backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))', + backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))' }, - '.cm-inline-footnote-ref': { display: 'inline-flex', alignItems: 'center', @@ -658,79 +181,10 @@ const baseTheme = EditorView.baseTheme({ verticalAlign: 'super', color: 'var(--cm-inline-footnote-color, #e67e22)', backgroundColor: 'var(--cm-inline-footnote-bg, rgba(230, 126, 34, 0.1))', - borderRadius: '3px', - cursor: 'pointer', - transition: 'all 0.15s ease', - textDecoration: 'none', + borderRadius: '3px' }, - '.cm-inline-footnote-ref:hover': { - color: 'var(--cm-inline-footnote-hover-color, #d35400)', - backgroundColor: 'var(--cm-inline-footnote-hover-bg, rgba(230, 126, 34, 0.2))', - }, - '.cm-footnote-def-label': { color: 'var(--cm-footnote-def-color, #1a73e8)', - fontWeight: '600', - cursor: 'pointer', - }, - '.cm-footnote-def-label:hover': { - textDecoration: 'underline', - }, - - '.cm-footnote-tooltip': { - maxWidth: '400px', - padding: '0', - backgroundColor: 'var(--bg-secondary)', - border: '1px solid var(--border-color)', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', - overflow: 'hidden', - }, - '.cm-footnote-tooltip-header': { - padding: '6px 12px', - fontSize: '0.8em', - fontWeight: '600', - fontFamily: 'monospace', - color: 'var(--cm-footnote-color, #1a73e8)', - backgroundColor: 'var(--bg-tertiary, rgba(0, 0, 0, 0.05))', - borderBottom: '1px solid var(--border-color)', - }, - '.cm-footnote-tooltip-body': { - padding: '10px 12px', - fontSize: '0.9em', - lineHeight: '1.5', - color: 'var(--text-primary)', - whiteSpace: 'pre-wrap', - wordBreak: 'break-word', - }, - - '.cm-tooltip:has(.cm-footnote-tooltip)': { - animation: 'cm-footnote-fade-in 0.15s ease-out', - }, - '@keyframes cm-footnote-fade-in': { - from: { opacity: '0', transform: 'translateY(4px)' }, - to: { opacity: '1', transform: 'translateY(0)' }, - }, + fontWeight: '600' + } }); - -// ============================================================================ -// Export -// ============================================================================ - -/** - * Footnote extension. - */ -export const footnote = (): Extension => [ - footnotePlugin, - footnoteHoverTooltip, - footnoteClickHandler, - baseTheme, -]; - -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 7fba5b6..c11f840 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/heading.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/heading.ts @@ -1,168 +1,63 @@ -import { syntaxTree } from '@codemirror/language'; -import { Extension, RangeSetBuilder } from '@codemirror/state'; -import { - Decoration, - DecorationSet, - EditorView, - ViewPlugin, - ViewUpdate -} from '@codemirror/view'; -import { checkRangeOverlap, RangeTuple } from '../util'; +/** + * Heading handler and theme. + */ -/** Hidden mark decoration */ -const hiddenMarkDecoration = Decoration.mark({ - class: 'cm-heading-mark-hidden' -}); +import { Decoration, EditorView } from '@codemirror/view'; +import { RangeTuple } from '../util'; +import { SyntaxNode } from '@lezer/common'; +import { BuildContext } from './types'; + +const DECO_HEADING_HIDDEN = Decoration.mark({ class: 'cm-heading-mark-hidden' }); /** - * Collect all heading ranges in visible viewport. + * Handle ATXHeading node (# Heading). */ -function collectHeadingRanges(view: EditorView): RangeTuple[] { - const ranges: RangeTuple[] = []; - const seen = new Set(); +export function handleATXHeading( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; - 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]); - } - }); + const header = node.firstChild; + if (header && header.type.name === 'HeaderMark') { + ctx.items.push({ from: header.from, to: Math.min(header.to + 1, nt), deco: DECO_HEADING_HIDDEN }); } - - return ranges; } /** - * Get which heading the cursor is in (-1 if none). + * Handle SetextHeading node (underline style). */ -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]; - } +export function handleSetextHeading( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; + + const headerMarks = node.getChildren('HeaderMark'); + for (const mark of headerMarks) { + ctx.items.push({ from: mark.from, to: mark.to, deco: DECO_HEADING_HIDDEN }); } - return -1; } /** - * Build heading decorations using RangeSetBuilder. + * Theme for headings. */ -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. - */ -const headingTheme = EditorView.baseTheme({ +export const headingTheme = EditorView.baseTheme({ '.cm-heading-mark-hidden': { fontSize: '0' } }); - -/** - * Headings plugin. - */ -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 deleted file mode 100644 index e82fdc7..0000000 --- a/frontend/src/views/editor/extensions/markdown/plugins/hide-mark.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { - Decoration, - DecorationSet, - EditorView, - ViewPlugin, - ViewUpdate -} from '@codemirror/view'; -import { Extension, RangeSetBuilder } from '@codemirror/state'; -import { syntaxTree } from '@codemirror/language'; -import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; - -/** - * Node types that contain markers to hide. - * Note: InlineCode is handled by inline-code.ts - */ -const TYPES_WITH_MARKS = new Set([ - 'Emphasis', - 'StrongEmphasis', - 'Strikethrough' -]); - -/** - * Marker node types to hide. - */ -const MARK_TYPES = new Set([ - 'EmphasisMark', - 'StrikethroughMark' -]); - -// Export for external use -export const typesWithMarks = Array.from(TYPES_WITH_MARKS); -export const markTypes = Array.from(MARK_TYPES); - -/** - * Collect all mark ranges in visible viewport. - */ -function collectMarkRanges(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 (!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({ - from, - to, - enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { - if (!TYPES_WITH_MARKS.has(type.name)) return; - if (seen.has(nodeFrom)) return; - seen.add(nodeFrom); - - // Skip if cursor is in this range - if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return; - - // Collect mark positions - const innerTree = node.toTree(); - innerTree.iterate({ - enter({ type: markType, from: markFrom, to: markTo }) { - if (!MARK_TYPES.has(markType.name)) return; - 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 updates. - * - * Hides emphasis marks (*, **, ~~) when cursor is outside. - * Note: InlineCode backticks are handled by inline-code.ts - */ -class HideMarkPlugin { - decorations: DecorationSet; - private markRanges: RangeTuple[] = []; - private cursorMarkPos = -1; - - constructor(view: EditorView) { - 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) { - 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; - } - - 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); - } - } - } -} - -/** - * Hide marks plugin. - * Hides marks for emphasis, strong, and strikethrough. - */ -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 deleted file mode 100644 index 1a1a54a..0000000 --- a/frontend/src/views/editor/extensions/markdown/plugins/highlight.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { Extension, RangeSetBuilder } from '@codemirror/state'; -import { syntaxTree } from '@codemirror/language'; -import { - ViewPlugin, - DecorationSet, - Decoration, - EditorView, - ViewUpdate -} from '@codemirror/view'; -import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; - -/** Mark decoration for highlighted content */ -const highlightMarkDecoration = Decoration.mark({ class: 'cm-highlight' }); - -/** - * Highlight plugin using syntax tree. - * - * Detects ==text== and renders as highlighted text. - */ -export const highlight = (): Extension => [highlightPlugin, baseTheme]; - -/** - * 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 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 }) => { - if (type.name !== 'Highlight') return; - if (seen.has(nodeFrom)) return; - seen.add(nodeFrom); - - // Skip if cursor is in this highlight - if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return; - - const marks = node.getChildren('HighlightMark'); - if (marks.length < 2) return; - - // 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 }); - } - }); - } - - // 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(); -} - -/** - * Highlight plugin with optimized updates. - */ -class HighlightPlugin { - decorations: DecorationSet; - 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); - } - - update(update: ViewUpdate) { - 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); - return; - } - - 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); - } - } - } -} - -const highlightPlugin = ViewPlugin.fromClass(HighlightPlugin, { - decorations: (v) => v.decorations -}); - -/** - * Base theme for highlight. - */ -const baseTheme = EditorView.baseTheme({ - '.cm-highlight': { - backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))', - 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 e0de9f5..d97f905 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/horizontal-rule.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/horizontal-rule.ts @@ -1,172 +1,48 @@ -import { Extension, RangeSetBuilder } from '@codemirror/state'; -import { - DecorationSet, - Decoration, - EditorView, - ViewPlugin, - ViewUpdate, - WidgetType -} from '@codemirror/view'; -import { checkRangeOverlap, RangeTuple } from '../util'; -import { syntaxTree } from '@codemirror/language'; - /** - * Horizontal rule plugin that renders beautiful horizontal lines. - * - * 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 + * Horizontal rule handler and theme. */ -export const horizontalRule = (): Extension => [horizontalRulePlugin, baseTheme]; -/** - * Widget to display a horizontal rule. - */ +import { Decoration, EditorView, WidgetType } from '@codemirror/view'; +import { RangeTuple } from '../util'; +import { BuildContext } from './types'; + class HorizontalRuleWidget extends WidgetType { toDOM(): HTMLElement { const span = document.createElement('span'); span.className = 'cm-horizontal-rule-widget'; - const hr = document.createElement('hr'); hr.className = 'cm-horizontal-rule'; span.appendChild(hr); - return span; } - - eq(_other: HorizontalRuleWidget) { - return true; - } - - ignoreEvent(): boolean { - return false; - } + eq() { return true; } + ignoreEvent() { return false; } } -/** Shared widget instance (all HR widgets are identical) */ const hrWidget = new HorizontalRuleWidget(); /** - * Collect all horizontal rule ranges in visible viewport. + * Handle HorizontalRule node. */ -function collectHRRanges(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 !== 'HorizontalRule') return; - if (seen.has(nodeFrom)) return; - seen.add(nodeFrom); - ranges.push([nodeFrom, nodeTo]); - } - }); +export function handleHorizontalRule( + ctx: BuildContext, + nf: number, + nt: number, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (!inCursor) { + ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: hrWidget }) }); } - - return ranges; } /** - * Get which HR the cursor is in (-1 if none). + * Theme for horizontal rules. */ -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 -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. - */ -const baseTheme = EditorView.baseTheme({ +export const horizontalRuleTheme = EditorView.baseTheme({ '.cm-horizontal-rule-widget': { display: 'inline-block', width: '100%', diff --git a/frontend/src/views/editor/extensions/markdown/plugins/html.ts b/frontend/src/views/editor/extensions/markdown/plugins/html.ts index 3743c34..dec62dc 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/html.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/html.ts @@ -340,7 +340,7 @@ const theme = EditorView.baseTheme({ * - Shows indicator icon at the end * - Click to preview rendered HTML */ -export const htmlBlockExtension: Extension = [ +export const html = (): Extension => [ htmlBlockPlugin, htmlTooltipState, clickOutsideHandler, diff --git a/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts b/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts deleted file mode 100644 index dce323a..0000000 --- a/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { Extension, RangeSetBuilder } from '@codemirror/state'; -import { - Decoration, - DecorationSet, - EditorView, - ViewPlugin, - ViewUpdate -} from '@codemirror/view'; -import { syntaxTree } from '@codemirror/language'; -import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; - -/** Mark decoration for code content */ -const codeMarkDecoration = Decoration.mark({ class: 'cm-inline-code' }); - -/** - * Inline code styling plugin. - * - * Features: - * - Adds background color, border radius, padding to code content - * - Hides backtick markers when cursor is outside - */ -export const inlineCode = (): Extension => [inlineCodePlugin, baseTheme]; - -/** - * Collect all inline code ranges in visible viewport. - */ -function collectCodeRanges(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 !== 'InlineCode') return; - if (seen.has(nodeFrom)) return; - seen.add(nodeFrom); - ranges.push([nodeFrom, nodeTo]); - } - }); - } - - 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; - - const text = view.state.doc.sliceString(nodeFrom, nodeTo); - - // Find backtick boundaries - let codeStart = nodeFrom; - let codeEnd = nodeTo; - - // Count opening backticks - let i = 0; - while (i < text.length && text[i] === '`') { - i++; - } - codeStart = nodeFrom + i; - - // Count closing backticks - let j = text.length - 1; - while (j >= 0 && text[j] === '`') { - j--; - } - codeEnd = nodeFrom + j + 1; - - // Hide opening backticks - if (nodeFrom < codeStart) { - items.push({ from: nodeFrom, to: codeStart, deco: invisibleDecoration }); - } - - // Add style to code content - if (codeStart < codeEnd) { - items.push({ from: codeStart, to: codeEnd, deco: codeMarkDecoration }); - } - - // Hide closing backticks - if (codeEnd < nodeTo) { - items.push({ from: codeEnd, to: nodeTo, deco: invisibleDecoration }); - } - } - }); - } - - // 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 with optimized updates. - */ -class InlineCodePlugin { - decorations: DecorationSet; - private codeRanges: RangeTuple[] = []; - private cursorCodePos = -1; - - constructor(view: EditorView) { - 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) { - 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); - } - } - } -} - -const inlineCodePlugin = ViewPlugin.fromClass(InlineCodePlugin, { - decorations: (v) => v.decorations -}); - -/** - * Base theme for inline code. - */ -const baseTheme = EditorView.baseTheme({ - '.cm-inline-code': { - backgroundColor: 'var(--cm-inline-code-bg)', - borderRadius: '0.25rem', - padding: '0.1rem 0.3rem', - fontFamily: 'var(--voidraft-font-mono)' - } -}); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/inline-styles.ts b/frontend/src/views/editor/extensions/markdown/plugins/inline-styles.ts new file mode 100644 index 0000000..8a804c7 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/inline-styles.ts @@ -0,0 +1,181 @@ +/** + * Inline styles handlers and theme. + * Handles: Highlight, InlineCode, Emphasis, StrongEmphasis, Strikethrough, Insert, Superscript, Subscript + */ + +import { Decoration, EditorView } from '@codemirror/view'; +import { invisibleDecoration, RangeTuple } from '../util'; +import { SyntaxNode } from '@lezer/common'; +import { BuildContext } from './types'; + +const DECO_HIGHLIGHT = Decoration.mark({ class: 'cm-highlight' }); +const DECO_INLINE_CODE = Decoration.mark({ class: 'cm-inline-code' }); +const DECO_INSERT = Decoration.mark({ class: 'cm-insert' }); +const DECO_SUPERSCRIPT = Decoration.mark({ class: 'cm-superscript' }); +const DECO_SUBSCRIPT = Decoration.mark({ class: 'cm-subscript' }); + +const MARK_TYPES: Record = { + 'Emphasis': 'EmphasisMark', + 'StrongEmphasis': 'EmphasisMark', + 'Strikethrough': 'StrikethroughMark' +}; + +/** + * Handle Highlight node (==text==). + */ +export function handleHighlight( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; + + const marks = node.getChildren('HighlightMark'); + if (marks.length >= 2) { + ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration }); + if (marks[0].to < marks[marks.length - 1].from) { + ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: DECO_HIGHLIGHT }); + } + ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration }); + } +} + +/** + * Handle InlineCode node (`code`). + */ +export function handleInlineCode( + ctx: BuildContext, + nf: number, + nt: number, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; + + const text = ctx.view.state.doc.sliceString(nf, nt); + let i = 0; while (i < text.length && text[i] === '`') i++; + let j = text.length - 1; while (j >= 0 && text[j] === '`') j--; + const codeStart = nf + i, codeEnd = nf + j + 1; + if (nf < codeStart) ctx.items.push({ from: nf, to: codeStart, deco: invisibleDecoration }); + if (codeStart < codeEnd) ctx.items.push({ from: codeStart, to: codeEnd, deco: DECO_INLINE_CODE }); + if (codeEnd < nt) ctx.items.push({ from: codeEnd, to: nt, deco: invisibleDecoration }); +} + +/** + * Handle Emphasis, StrongEmphasis, Strikethrough nodes. + */ +export function handleEmphasis( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + typeName: string, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; + + const markType = MARK_TYPES[typeName]; + if (markType) { + const marks = node.getChildren(markType); + for (const mark of marks) { + ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration }); + } + } +} + +/** + * Handle Insert node (++text++). + */ +export function handleInsert( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; + + const marks = node.getChildren('InsertMark'); + if (marks.length >= 2) { + ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration }); + if (marks[0].to < marks[marks.length - 1].from) { + ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: DECO_INSERT }); + } + ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration }); + } +} + +/** + * Handle Superscript / Subscript nodes. + */ +export function handleScript( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + typeName: string, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; + + const isSuper = typeName === 'Superscript'; + const markName = isSuper ? 'SuperscriptMark' : 'SubscriptMark'; + const marks = node.getChildren(markName); + if (marks.length >= 2) { + ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration }); + if (marks[0].to < marks[marks.length - 1].from) { + ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: isSuper ? DECO_SUPERSCRIPT : DECO_SUBSCRIPT }); + } + ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration }); + } +} + +/** + * Theme for inline styles. + */ +export const inlineStylesTheme = EditorView.baseTheme({ + '.cm-highlight': { + backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))', + borderRadius: '2px' + }, + '.cm-inline-code': { + backgroundColor: 'var(--cm-inline-code-bg)', + borderRadius: '0.25rem', + padding: '0.1rem 0.3rem', + fontFamily: 'var(--voidraft-font-mono)' + }, + '.cm-insert': { + textDecoration: 'underline' + }, + '.cm-superscript': { + verticalAlign: 'super', + fontSize: '0.75em', + color: 'var(--cm-superscript-color, inherit)' + }, + '.cm-subscript': { + verticalAlign: 'sub', + fontSize: '0.75em', + color: 'var(--cm-subscript-color, inherit)' + } +}); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/insert.ts b/frontend/src/views/editor/extensions/markdown/plugins/insert.ts deleted file mode 100644 index 9689786..0000000 --- a/frontend/src/views/editor/extensions/markdown/plugins/insert.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { Extension, RangeSetBuilder } from '@codemirror/state'; -import { syntaxTree } from '@codemirror/language'; -import { - ViewPlugin, - DecorationSet, - Decoration, - EditorView, - ViewUpdate -} from '@codemirror/view'; -import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; - -/** Mark decoration for inserted content */ -const insertMarkDecoration = Decoration.mark({ class: 'cm-insert' }); - -/** - * Insert plugin using syntax tree. - * - * Detects ++text++ and renders as inserted text (underline). - */ -export const insert = (): Extension => [insertPlugin, baseTheme]; - -/** - * 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 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 }) => { - if (type.name !== 'Insert') return; - if (seen.has(nodeFrom)) return; - seen.add(nodeFrom); - - // Skip if cursor is in this insert - if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return; - - const marks = node.getChildren('InsertMark'); - if (marks.length < 2) return; - - // 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 }); - } - }); - } - - // 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(); -} - -/** - * Insert plugin with optimized updates. - */ -class InsertPlugin { - decorations: DecorationSet; - 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); - } - - update(update: ViewUpdate) { - 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); - return; - } - - 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); - } - } - } -} - -const insertPlugin = ViewPlugin.fromClass(InsertPlugin, { - decorations: (v) => v.decorations -}); - -/** - * Base theme for insert. - */ -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 e4e9467..c4eee25 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/link.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/link.ts @@ -1,202 +1,111 @@ -import { syntaxTree } from '@codemirror/language'; -import { Extension, RangeSetBuilder } from '@codemirror/state'; -import { - Decoration, - DecorationSet, - EditorView, - ViewPlugin, - ViewUpdate -} from '@codemirror/view'; +/** + * Link handler with underline and clickable icon. + */ + +import { Decoration, EditorView, WidgetType } from '@codemirror/view'; import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; +import { SyntaxNode } from '@lezer/common'; +import { BuildContext } from './types'; +import * as runtime from "@wailsio/runtime"; -/** - * Parent node types that should not process. - * - Image: handled by image plugin - * - LinkReference: reference link definitions should be fully visible - */ -const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']); +const BLACKLISTED_LINK_PARENTS = new Set(['Image', 'LinkReference']); -/** - * Links plugin. - * - * Features: - * - Hides link markup when cursor is outside - * - Link icons and click events are handled by hyperlink extension - */ -export const links = (): Extension => [goToLinkPlugin]; +/** Link text decoration with underline */ +const linkTextDecoration = Decoration.mark({ class: 'cm-md-link-text' }); -/** - * 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; +/** Link icon widget - clickable to open URL */ +class LinkIconWidget extends WidgetType { + constructor(readonly url: string) { super(); } + eq(other: LinkIconWidget) { return this.url === other.url; } + toDOM(): HTMLElement { + const span = document.createElement('span'); + span.className = 'cm-md-link-icon'; + span.textContent = '🔗'; + span.title = this.url; + span.onmousedown = (e) => { + e.preventDefault(); + e.stopPropagation(); + runtime.Browser.OpenURL(this.url); + }; + return span; + } + ignoreEvent(e: Event) { return e.type === 'mousedown'; } } /** - * Collect all link ranges in visible viewport. + * Handle URL node (within Link). */ -function collectLinkRanges(view: EditorView): RangeTuple[] { - const ranges: RangeTuple[] = []; - const seen = new Set(); +export function handleURL( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + ranges: RangeTuple[] +): void { + const parent = node.parent; + if (!parent || BLACKLISTED_LINK_PARENTS.has(parent.name)) return; + if (ctx.seen.has(parent.from)) return; + ctx.seen.add(parent.from); + ranges.push([parent.from, parent.to]); + if (checkRangeOverlap([parent.from, parent.to], ctx.selRange)) return; - for (const { from, to } of view.visibleRanges) { - syntaxTree(view.state).iterate({ - from, - to, - enter: ({ type, node }) => { - if (type.name !== 'URL') return; + // Get link text node (content between first [ and ]) + const linkText = parent.getChild('LinkLabel'); + const marks = parent.getChildren('LinkMark'); + const linkTitle = parent.getChild('LinkTitle'); + const closeBracket = marks.find(m => ctx.view.state.sliceDoc(m.from, m.to) === ']'); + + if (closeBracket && nf < closeBracket.from) return; - const parent = node.parent; - if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return; - if (seen.has(parent.from)) return; - seen.add(parent.from); + // Get URL for the icon + const url = ctx.view.state.sliceDoc(nf, nt); - ranges.push([parent.from, parent.to]); - } + // Add underline decoration to link text + if (linkText) { + ctx.items.push({ from: linkText.from, to: linkText.to, deco: linkTextDecoration }); + } + + // Hide markdown syntax marks + for (const m of marks) { + ctx.items.push({ from: m.from, to: m.to, deco: invisibleDecoration }); + } + + // Hide URL + ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration }); + + // Hide link title if present + if (linkTitle) { + ctx.items.push({ from: linkTitle.from, to: linkTitle.to, deco: invisibleDecoration }); + } + + // Add clickable icon widget after link text (at close bracket position) + if (closeBracket) { + ctx.items.push({ + from: closeBracket.from, + to: closeBracket.from, + deco: Decoration.widget({ widget: new LinkIconWidget(url), side: 1 }), + priority: 1 }); } - - return ranges; } /** - * Get which link the cursor is in (-1 if none). + * Theme for markdown links. */ -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]; +export const linkTheme = EditorView.baseTheme({ + '.cm-md-link-text': { + color: 'var(--cm-link-color, #0969da)', + textDecoration: 'underline', + textUnderlineOffset: '2px', + cursor: 'text' + }, + '.cm-md-link-icon': { + cursor: 'pointer', + marginLeft: '0.2em', + opacity: '0.7', + transition: 'opacity 0.15s ease', + '&:hover': { + opacity: '1' } } - return -1; -} - -/** - * Build link 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, node }) => { - if (type.name !== 'URL') return; - - 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 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 display text, don't hide - if (closeBracketMark && nodeFrom < closeBracketMark.from) { - return; - } - - // Check if cursor overlaps with the parent link - if (checkRangeOverlap([parent.from, parent.to], selRange)) { - return; - } - - // Hide link marks and URL - if (marks.length > 0) { - for (const mark of marks) { - items.push({ from: mark.from, to: mark.to }); - } - items.push({ from: nodeFrom, to: nodeTo }); - - if (linkTitle) { - items.push({ from: linkTitle.from, to: linkTitle.to }); - } - } - - // Handle auto-links with < > markers - const linkContent = view.state.sliceDoc(nodeFrom, nodeTo); - if (linkContent.startsWith('<') && linkContent.endsWith('>')) { - // Already hidden the whole URL above, no extra handling needed - } - } - }); - } - - // 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 updates. - */ -class LinkPlugin { - decorations: DecorationSet; - private linkRanges: RangeTuple[] = []; - private cursorLinkPos = -1; - - constructor(view: EditorView) { - 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) { - 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; - } - - 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); - } - } - } -} - -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 a346d64..749e7af 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/list.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/list.ts @@ -1,40 +1,18 @@ -import { - Decoration, - DecorationSet, - EditorView, - ViewPlugin, - ViewUpdate, - WidgetType -} from '@codemirror/view'; -import { Range, RangeSetBuilder, EditorState } from '@codemirror/state'; -import { syntaxTree } from '@codemirror/language'; -import { checkRangeOverlap, RangeTuple } from '../util'; - -/** Bullet list marker pattern */ -const BULLET_LIST_MARKER_RE = /^[-+*]$/; - /** - * Lists plugin. - * - * Features: - * - Custom bullet mark rendering (- → •) - * - Interactive task list checkboxes + * List handlers and theme. + * Handles: ListMark (bullets), Task (checkboxes) */ -export const lists = () => [listBulletPlugin, taskListPlugin, baseTheme]; -// ============================================================================ -// List Bullet Plugin -// ============================================================================ +import { Decoration, EditorView, WidgetType } from '@codemirror/view'; +import { checkRangeOverlap, RangeTuple } from '../util'; +import { SyntaxNode } from '@lezer/common'; +import { BuildContext } from './types'; + +const BULLET_RE = /^[-+*]$/; class ListBulletWidget extends WidgetType { - constructor(readonly bullet: string) { - super(); - } - - eq(other: ListBulletWidget): boolean { - return other.bullet === this.bullet; - } - + constructor(readonly bullet: string) { super(); } + eq(other: ListBulletWidget) { return other.bullet === this.bullet; } toDOM(): HTMLElement { const span = document.createElement('span'); span.className = 'cm-list-bullet'; @@ -43,360 +21,84 @@ class ListBulletWidget extends WidgetType { } } -/** - * Collect all list mark ranges in visible viewport. - */ -function collectBulletRanges(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, node }) => { - if (type.name !== 'ListMark') return; - - // Skip task list items - const parent = node.parent; - if (parent?.getChild('Task')) return; - - // Only bullet markers - const text = view.state.sliceDoc(nodeFrom, nodeTo); - if (!BULLET_LIST_MARKER_RE.test(text)) return; - - if (seen.has(nodeFrom)) return; - seen.add(nodeFrom); - ranges.push([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 }); - } - } - }); - } - - // 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 with optimized updates. - */ -class ListBulletPlugin { - decorations: DecorationSet; - private bulletRanges: RangeTuple[] = []; - private cursorBulletPos = -1; - - constructor(view: EditorView) { - 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) { - 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 (selectionSet) { - const { from, to } = update.state.selection.main; - const newPos = getCursorBulletPos(this.bulletRanges, from, to); - - if (newPos !== this.cursorBulletPos) { - this.cursorBulletPos = newPos; - this.decorations = buildBulletDecorations(update.view); - } - } - } -} - -const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, { - decorations: (v) => v.decorations -}); - -// ============================================================================ -// Task List Plugin -// ============================================================================ - class TaskCheckboxWidget extends WidgetType { - constructor( - readonly checked: boolean, - readonly pos: number - ) { - super(); - } - - eq(other: TaskCheckboxWidget): boolean { - return other.checked === this.checked && other.pos === this.pos; - } - + constructor(readonly checked: boolean, readonly pos: number) { super(); } + eq(other: TaskCheckboxWidget) { return other.checked === this.checked && other.pos === this.pos; } toDOM(view: EditorView): HTMLElement { const wrap = document.createElement('span'); wrap.setAttribute('aria-hidden', 'true'); wrap.className = 'cm-task-checkbox'; - const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = this.checked; checkbox.tabIndex = -1; - checkbox.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); - - const newValue = !this.checked; - view.dispatch({ - changes: { - from: this.pos, - to: this.pos + 1, - insert: newValue ? 'x' : ' ' - } - }); + view.dispatch({ changes: { from: this.pos, to: this.pos + 1, insert: this.checked ? ' ' : 'x' } }); }); - wrap.appendChild(checkbox); return wrap; } + ignoreEvent() { return false; } +} - ignoreEvent(): boolean { - return false; +/** + * Handle ListMark node (bullet markers). + */ +export function handleListMark( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): void { + const parent = node.parent; + if (parent?.getChild('Task')) return; + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; + + const bullet = ctx.view.state.sliceDoc(nf, nt); + if (BULLET_RE.test(bullet)) { + ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new ListBulletWidget(bullet) }) }); } } /** - * Collect all task ranges in visible viewport. + * Handle Task node (checkboxes). */ -function collectTaskRanges(view: EditorView): RangeTuple[] { - const ranges: RangeTuple[] = []; - const seen = new Set(); +export function handleTask( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + ranges: RangeTuple[] +): void { + 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 (ctx.seen.has(listMark.from)) return; + ctx.seen.add(listMark.from); + ranges.push([listMark.from, taskMarker.to]); + if (checkRangeOverlap([listMark.from, taskMarker.to], ctx.selRange)) 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; - - 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]); - } - } - }); + const markerText = ctx.view.state.sliceDoc(taskMarker.from, taskMarker.to); + const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]); + if (isChecked) { + ctx.items.push({ from: nf, to: nt, deco: Decoration.mark({ class: 'cm-task-checked' }), priority: 0 }); } - - return ranges; + ctx.items.push({ from: listMark.from, to: taskMarker.to, deco: Decoration.replace({ widget: new TaskCheckboxWidget(isChecked, taskMarker.from + 1) }), priority: 1 }); } /** - * Get which task the cursor is in (-1 if none). + * Theme for lists. */ -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 -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 -// ============================================================================ - -const baseTheme = EditorView.baseTheme({ +export const listTheme = 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 287e917..239272a 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/math.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/math.ts @@ -1,359 +1,125 @@ /** - * Math plugin for CodeMirror using KaTeX. - * - * Features: - * - Renders inline math $...$ as inline formula - * - Renders block math $$...$$ as block formula - * - Block math: lines remain, content hidden, formula overlays on top - * - Shows source when cursor is inside + * Math handlers and theme. + * Handles: InlineMath, BlockMath */ -import { Extension, Range } from '@codemirror/state'; -import { syntaxTree } from '@codemirror/language'; -import { - ViewPlugin, - DecorationSet, - Decoration, - EditorView, - ViewUpdate, - WidgetType -} from '@codemirror/view'; +import { Decoration, EditorView, WidgetType } from '@codemirror/view'; +import { invisibleDecoration, RangeTuple } from '../util'; +import { SyntaxNode } from '@lezer/common'; +import { BuildContext } from './types'; import katex from 'katex'; import 'katex/dist/katex.min.css'; -import { isCursorInRange, invisibleDecoration } from '../util'; -import { LruCache } from '@/common/utils/lruCache'; -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 { - constructor(readonly latex: string) { - super(); - } - + constructor(readonly latex: string) { super(); } + eq(other: InlineMathWidget) { return this.latex === other.latex; } toDOM(): HTMLElement { const span = document.createElement('span'); span.className = 'cm-inline-math'; - - // Use cached render - const { html, error } = renderKatex(this.latex, false); - - if (error) { + try { + span.innerHTML = katex.renderToString(this.latex, { throwOnError: true, displayMode: false, output: 'html' }); + } catch (e) { span.textContent = this.latex; - span.title = error; - } else { - span.innerHTML = html; + span.title = e instanceof Error ? e.message : 'Render error'; } - return span; } - - eq(other: InlineMathWidget): boolean { - return this.latex === other.latex; - } - - ignoreEvent(): boolean { - return false; - } + ignoreEvent() { return false; } } -/** - * Widget to display block math formula. - * Uses absolute positioning to overlay on source lines. - */ class BlockMathWidget extends WidgetType { - constructor( - readonly latex: string, - readonly lineCount: number = 1, - readonly lineHeight: number = 22 - ) { - super(); - } - + constructor(readonly latex: string, readonly lineCount: number, readonly lineHeight: number) { super(); } + eq(other: BlockMathWidget) { return this.latex === other.latex && this.lineCount === other.lineCount; } toDOM(): HTMLElement { const container = document.createElement('div'); container.className = 'cm-block-math-container'; - // Set height to cover all source lines - const height = this.lineCount * this.lineHeight; - container.style.height = `${height}px`; - + container.style.height = `${this.lineCount * this.lineHeight}px`; const inner = document.createElement('div'); inner.className = 'cm-block-math'; - - // Use cached render - const { html, error } = renderKatex(this.latex, true); - - if (error) { + try { + inner.innerHTML = katex.renderToString(this.latex, { throwOnError: false, displayMode: true, output: 'html' }); + } catch (e) { inner.textContent = this.latex; - inner.title = error; - } else { - inner.innerHTML = html; + inner.title = e instanceof Error ? e.message : 'Render error'; } - container.appendChild(inner); return container; } + ignoreEvent() { return false; } +} - eq(other: BlockMathWidget): boolean { - return this.latex === other.latex && this.lineCount === other.lineCount; - } +const DECO_BLOCK_MATH_LINE = Decoration.line({ class: 'cm-block-math-line' }); +const DECO_BLOCK_MATH_HIDDEN = Decoration.mark({ class: 'cm-block-math-content-hidden' }); - ignoreEvent(): boolean { - return false; +/** + * Handle InlineMath node ($...$). + */ +export function handleInlineMath( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; + + const marks = node.getChildren('InlineMathMark'); + if (marks.length >= 2) { + const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from); + ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration }); + ctx.items.push({ from: nt, to: nt, deco: Decoration.widget({ widget: new InlineMathWidget(latex), side: 1 }), priority: 1 }); } } /** - * Represents a math region in the document. + * Handle BlockMath node ($$...$$). */ -interface MathRegion { - from: number; - to: number; -} +export function handleBlockMath( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (inCursor) return; -/** - * 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): BuildResult { - const decorations: Range[] = []; - const mathRegions: MathRegion[] = []; - - for (const { from, to } of view.visibleRanges) { - syntaxTree(view.state).iterate({ - from, - to, - 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'); - - if (!cursorInRange && marks.length >= 2) { - // Get latex content (without $ marks) - const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from); - - // Hide the entire syntax - decorations.push(invisibleDecoration.range(nodeFrom, nodeTo)); - - // Add widget at the end - decorations.push( - Decoration.widget({ - widget: new InlineMathWidget(latex), - side: 1 - }).range(nodeTo) - ); - } - } - - // 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'); - - if (!cursorInRange && marks.length >= 2) { - // Get latex content (without $$ marks) - const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim(); - - // Calculate line info - const startLine = view.state.doc.lineAt(nodeFrom); - const endLine = view.state.doc.lineAt(nodeTo); - const lineCount = endLine.number - startLine.number + 1; - const lineHeight = view.defaultLineHeight; - - // Check if block math spans multiple lines - const hasLineBreak = lineCount > 1; - - if (hasLineBreak) { - // For multi-line: use line decorations to hide content - for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) { - const line = view.state.doc.line(lineNum); - decorations.push( - Decoration.line({ - class: 'cm-block-math-line' - }).range(line.from) - ); - } - - // Add widget on the first line (positioned absolutely) - decorations.push( - Decoration.widget({ - widget: new BlockMathWidget(latex, lineCount, lineHeight), - side: -1 - }).range(startLine.from) - ); - } else { - // Single line: make content transparent, overlay widget - decorations.push( - Decoration.mark({ - class: 'cm-block-math-content-hidden' - }).range(nodeFrom, nodeTo) - ); - - // Add widget at the start (positioned absolutely) - decorations.push( - Decoration.widget({ - widget: new BlockMathWidget(latex, 1, lineHeight), - side: -1 - }).range(nodeFrom) - ); - } - } - } - } - }); - } - - return { - decorations: Decoration.set(decorations, true), - mathRegions - }; -} - -/** - * Math plugin with optimized update detection. - */ -class MathPlugin { - decorations: DecorationSet; - private mathRegions: MathRegion[] = []; - private lastSelectionHead: number = -1; - private lastMathRegionIndex: number = -1; - - constructor(view: EditorView) { - 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) { - 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) { - 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; + const marks = node.getChildren('BlockMathMark'); + if (marks.length >= 2) { + const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim(); + const startLine = ctx.view.state.doc.lineAt(nf); + const endLine = ctx.view.state.doc.lineAt(nt); + const lineCount = endLine.number - startLine.number + 1; + if (lineCount > 1) { + for (let num = startLine.number; num <= endLine.number; num++) { + ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_BLOCK_MATH_LINE }); } + ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new BlockMathWidget(latex, lineCount, ctx.lineHeight), side: -1 }), priority: -1 }); + } else { + ctx.items.push({ from: nf, to: nt, deco: DECO_BLOCK_MATH_HIDDEN }); + ctx.items.push({ from: nf, to: nf, deco: Decoration.widget({ widget: new BlockMathWidget(latex, 1, ctx.lineHeight), side: -1 }), priority: -1 }); } } } -const mathPlugin = ViewPlugin.fromClass( - MathPlugin, - { - decorations: (v) => v.decorations - } -); - /** - * Base theme for math. + * Theme for math. */ -const baseTheme = EditorView.baseTheme({ - // Inline math +export const mathTheme = EditorView.baseTheme({ '.cm-inline-math': { display: 'inline', - verticalAlign: 'baseline', + verticalAlign: 'baseline' }, '.cm-inline-math .katex': { - fontSize: 'inherit', + fontSize: 'inherit' }, - - // Block math container - absolute positioned to overlay on source '.cm-block-math-container': { position: 'absolute', left: '0', @@ -362,61 +128,36 @@ const baseTheme = EditorView.baseTheme({ justifyContent: 'center', alignItems: 'center', pointerEvents: 'none', - zIndex: '1', + zIndex: '1' }, - - // Block math inner '.cm-block-math': { display: 'inline-block', textAlign: 'center', - pointerEvents: 'auto', + pointerEvents: 'auto' }, '.cm-block-math .katex-display': { - margin: '0', + margin: '0' }, '.cm-block-math .katex': { - fontSize: '1.1em', + fontSize: '1.1em' }, - - // Hidden line content for block math (text transparent but line preserved) - // Use high specificity to override rainbow brackets and other plugins '.cm-line.cm-block-math-line': { color: 'transparent !important', - caretColor: 'transparent', + caretColor: 'transparent' }, '.cm-line.cm-block-math-line span': { - color: 'transparent !important', + color: 'transparent !important' }, - // Override rainbow brackets in hidden math lines '.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': { - color: 'transparent !important', + color: 'transparent !important' }, - - // Hidden content for single-line block math '.cm-block-math-content-hidden': { - color: 'transparent !important', + color: 'transparent !important' }, '.cm-block-math-content-hidden span': { - color: 'transparent !important', + color: 'transparent !important' }, '.cm-block-math-content-hidden [class*="cm-rainbow-bracket"]': { - color: 'transparent !important', - }, + color: 'transparent !important' + } }); - -/** - * Math extension. - * - * Features: - * - Parses inline math $...$ and block math $$...$$ - * - Renders formulas using KaTeX - * - Block math preserves line structure, overlays rendered formula - * - Shows source when cursor is inside - */ -export const math = (): Extension => [ - mathPlugin, - baseTheme -]; - -export default math; - diff --git a/frontend/src/views/editor/extensions/markdown/plugins/render.ts b/frontend/src/views/editor/extensions/markdown/plugins/render.ts new file mode 100644 index 0000000..a88b885 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/render.ts @@ -0,0 +1,253 @@ +import { Extension } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; +import { + ViewPlugin, + DecorationSet, + Decoration, + EditorView, + ViewUpdate +} from '@codemirror/view'; +import { SyntaxNodeRef } from '@lezer/common'; +import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util'; +import { DecoItem } from './types'; +import { blockState } from '@/views/editor/extensions/codeblock/state'; +import { Block } from '@/views/editor/extensions/codeblock/types'; + +import { handleBlockquote } from './blockquote'; +import { handleCodeBlock } from './code-block'; +import { handleATXHeading, handleSetextHeading } from './heading'; +import { handleHorizontalRule } from './horizontal-rule'; +import { handleHighlight, handleInlineCode, handleEmphasis, handleInsert, handleScript } from './inline-styles'; +import { handleURL } from './link'; +import { handleListMark, handleTask } from './list'; +import { handleFootnoteDefinition, handleFootnoteReference, handleInlineFootnote, processPendingFootnotes, FootnoteContext } from './footnote'; +import { handleInlineMath, handleBlockMath } from './math'; +import { handleEmoji } from './emoji'; +import { handleTable } from './table'; + + +interface BuildResult { + decorations: DecorationSet; + trackedRanges: RangeTuple[]; +} + +/** + * Get markdown block ranges from visible ranges. + * Only returns ranges that are within 'md' language blocks. + */ +function getMdBlockRanges(view: EditorView): { from: number; to: number }[] { + const blocks = view.state.field(blockState, false); + if (!blocks || blocks.length === 0) { + // No blocks, treat entire document as md + return view.visibleRanges.map(r => ({ from: r.from, to: r.to })); + } + + // Filter md blocks + const mdBlocks = blocks.filter((b: Block) => b.language.name === 'md'); + if (mdBlocks.length === 0) return []; + + // Intersect visible ranges with md block content ranges + const result: { from: number; to: number }[] = []; + for (const { from, to } of view.visibleRanges) { + for (const block of mdBlocks) { + const intersectFrom = Math.max(from, block.content.from); + const intersectTo = Math.min(to, block.content.to); + if (intersectFrom < intersectTo) { + result.push({ from: intersectFrom, to: intersectTo }); + } + } + } + return result; +} + + +function buildDecorationsAndRanges(view: EditorView): BuildResult { + const { from: selFrom, to: selTo } = view.state.selection.main; + + // Create context with footnote extensions + const ctx: FootnoteContext = { + view, + items: [], + selRange: [selFrom, selTo], + seen: new Set(), + processedLines: new Set(), + contentWidth: view.contentDOM.clientWidth - 10, + lineHeight: view.defaultLineHeight, + // Footnote state + definitionIds: new Set(), + pendingRefs: [], + pendingInlines: [], + seenIds: new Map(), + inlineFootnoteIdx: 0 + }; + + const trackedRanges: RangeTuple[] = []; + + // Only traverse md blocks (not other language blocks like js, py, etc.) + const mdRanges = getMdBlockRanges(view); + + // Single traversal - dispatch to all handlers + for (const { from, to } of mdRanges) { + syntaxTree(view.state).iterate({ + from, to, + enter: (nodeRef: SyntaxNodeRef) => { + const { type, from: nf, to: nt, node } = nodeRef; + const typeName = type.name; + const inCursor = checkRangeOverlap([nf, nt], ctx.selRange); + + // Dispatch to handlers + if (typeName === 'Blockquote') return handleBlockquote(ctx, nf, nt, node, inCursor, trackedRanges); + if (typeName === 'FencedCode' || typeName === 'CodeBlock') return handleCodeBlock(ctx, nf, nt, node, inCursor, trackedRanges); + if (typeName.startsWith('ATXHeading')) return handleATXHeading(ctx, nf, nt, node, inCursor, trackedRanges); + if (typeName.startsWith('SetextHeading')) return handleSetextHeading(ctx, nf, nt, node, inCursor, trackedRanges); + if (typeName === 'HorizontalRule') return handleHorizontalRule(ctx, nf, nt, inCursor, trackedRanges); + if (typeName === 'Highlight') return handleHighlight(ctx, nf, nt, node, inCursor, trackedRanges); + if (typeName === 'InlineCode') return handleInlineCode(ctx, nf, nt, inCursor, trackedRanges); + if (typeName === 'Emphasis' || typeName === 'StrongEmphasis' || typeName === 'Strikethrough') return handleEmphasis(ctx, nf, nt, node, typeName, inCursor, trackedRanges); + if (typeName === 'Insert') return handleInsert(ctx, nf, nt, node, inCursor, trackedRanges); + if (typeName === 'Superscript' || typeName === 'Subscript') return handleScript(ctx, nf, nt, node, typeName, inCursor, trackedRanges); + if (typeName === 'URL') return handleURL(ctx, nf, nt, node, trackedRanges); + if (typeName === 'ListMark') return handleListMark(ctx, nf, nt, node, inCursor, trackedRanges); + if (typeName === 'Task') return handleTask(ctx, nf, nt, node, trackedRanges); + if (typeName === 'FootnoteDefinition') return handleFootnoteDefinition(ctx, nf, nt, node, inCursor, trackedRanges); + if (typeName === 'FootnoteReference') return handleFootnoteReference(ctx, nf, nt, node, inCursor, trackedRanges); + if (typeName === 'InlineFootnote') return handleInlineFootnote(ctx, nf, nt, node, inCursor, trackedRanges); + if (typeName === 'InlineMath') return handleInlineMath(ctx, nf, nt, node, inCursor, trackedRanges); + if (typeName === 'BlockMath') return handleBlockMath(ctx, nf, nt, node, inCursor, trackedRanges); + if (typeName === 'Emoji') return handleEmoji(ctx, nf, nt, node, inCursor, trackedRanges); + if (typeName === 'Table') return handleTable(ctx, nf, nt, node, inCursor, trackedRanges); + } + }); + } + + // Process pending footnotes + processPendingFootnotes(ctx); + + // Sort and filter + ctx.items.sort((a, b) => { + if (a.from !== b.from) return a.from - b.from; + if (a.to !== b.to) return a.to - b.to; + return (a.priority || 0) - (b.priority || 0); + }); + + const result: DecoItem[] = []; + let replaceMaxTo = -1; + for (const item of ctx.items) { + const isReplace = item.deco.spec?.widget !== undefined || item.deco === invisibleDecoration; + if (item.from === item.to) { + result.push(item); + } else if (isReplace) { + if (item.from >= replaceMaxTo) { + result.push(item); + replaceMaxTo = item.to; + } + } else { + result.push(item); + } + } + + return { + decorations: Decoration.set(result.map(r => r.deco.range(r.from, r.to)), true), + trackedRanges + }; +} + + +class MarkdownRenderPlugin { + decorations: DecorationSet; + private trackedRanges: RangeTuple[] = []; + private lastSelFrom = -1; + private lastSelTo = -1; + private lastWidth = 0; + + constructor(view: EditorView) { + const result = buildDecorationsAndRanges(view); + this.decorations = result.decorations; + this.trackedRanges = result.trackedRanges; + const { from, to } = view.state.selection.main; + this.lastSelFrom = from; + this.lastSelTo = to; + this.lastWidth = view.contentDOM.clientWidth; + } + + update(update: ViewUpdate) { + const { docChanged, viewportChanged, selectionSet, geometryChanged } = update; + const widthChanged = Math.abs(update.view.contentDOM.clientWidth - this.lastWidth) > 1; + if (widthChanged) this.lastWidth = update.view.contentDOM.clientWidth; + + // Full rebuild for structural changes + if (docChanged || viewportChanged || geometryChanged || widthChanged) { + const result = buildDecorationsAndRanges(update.view); + this.decorations = result.decorations; + this.trackedRanges = result.trackedRanges; + const { from, to } = update.state.selection.main; + this.lastSelFrom = from; + this.lastSelTo = to; + return; + } + + // Selection change handling with fine-grained detection + if (selectionSet) { + const { from, to } = update.state.selection.main; + const isPointCursor = from === to; + const wasPointCursor = this.lastSelFrom === this.lastSelTo; + + // Optimization: Point cursor moving within same tracked range - no rebuild needed + if (isPointCursor && wasPointCursor) { + const oldRange = this.findContainingRange(this.lastSelFrom); + const newRange = this.findContainingRange(from); + + if (this.rangeSame(oldRange, newRange)) { + this.lastSelFrom = from; + this.lastSelTo = to; + return; + } + } + + // Check if overlapping ranges changed + const oldOverlaps = this.getOverlappingRanges(this.lastSelFrom, this.lastSelTo); + const newOverlaps = this.getOverlappingRanges(from, to); + + this.lastSelFrom = from; + this.lastSelTo = to; + + if (!this.rangesSame(oldOverlaps, newOverlaps)) { + const result = buildDecorationsAndRanges(update.view); + this.decorations = result.decorations; + this.trackedRanges = result.trackedRanges; + } + } + } + + private findContainingRange(pos: number): RangeTuple | null { + for (const range of this.trackedRanges) { + if (pos >= range[0] && pos <= range[1]) return range; + } + return null; + } + + private rangeSame(a: RangeTuple | null, b: RangeTuple | null): boolean { + if (a === null && b === null) return true; + if (a === null || b === null) return false; + return a[0] === b[0] && a[1] === b[1]; + } + + private getOverlappingRanges(from: number, to: number): RangeTuple[] { + const selRange: RangeTuple = [from, to]; + return this.trackedRanges.filter(r => checkRangeOverlap(r, selRange)); + } + + private rangesSame(a: RangeTuple[], b: RangeTuple[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false; + } + return true; + } +} + +const renderPlugin = ViewPlugin.fromClass(MarkdownRenderPlugin, { + decorations: (v) => v.decorations +}); + +export const render = (): Extension => [renderPlugin]; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/subscript-superscript.ts b/frontend/src/views/editor/extensions/markdown/plugins/subscript-superscript.ts deleted file mode 100644 index 16e192a..0000000 --- a/frontend/src/views/editor/extensions/markdown/plugins/subscript-superscript.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { Extension, RangeSetBuilder } from '@codemirror/state'; -import { syntaxTree } from '@codemirror/language'; -import { - ViewPlugin, - DecorationSet, - Decoration, - EditorView, - ViewUpdate -} from '@codemirror/view'; -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. - * - * - Superscript: ^text^ → renders as superscript - * - Subscript: ~text~ → renders as 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']); - -/** - * 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 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 }) => { - if (!SCRIPT_TYPES.has(type.name)) return; - if (seen.has(nodeFrom)) return; - seen.add(nodeFrom); - - // Skip if cursor is in this element - if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return; - - const isSuperscript = type.name === 'Superscript'; - const markName = isSuperscript ? 'SuperscriptMark' : 'SubscriptMark'; - const contentDeco = isSuperscript ? superscriptMarkDecoration : subscriptMarkDecoration; - - 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 }); - } - - // Hide closing mark - items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration }); - } - }); - } - - // 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(); -} - -/** - * Subscript/Superscript plugin with optimized updates. - */ -class SubscriptSuperscriptPlugin { - decorations: DecorationSet; - 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); - } - - update(update: ViewUpdate) { - 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); - return; - } - - 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); - } - } - } -} - -const subscriptSuperscriptPlugin = ViewPlugin.fromClass( - SubscriptSuperscriptPlugin, - { - decorations: (v) => v.decorations - } -); - -/** - * Base theme for subscript and superscript. - */ -const baseTheme = EditorView.baseTheme({ - '.cm-superscript': { - verticalAlign: 'super', - fontSize: '0.75em', - color: 'var(--cm-superscript-color, inherit)' - }, - '.cm-subscript': { - verticalAlign: 'sub', - fontSize: '0.75em', - 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 index 60ab062..0bf1ad3 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/table.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/table.ts @@ -1,262 +1,19 @@ /** - * 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) + * Table handler and theme. */ -import { Extension, Range } from '@codemirror/state'; -import { syntaxTree, foldedRanges } from '@codemirror/language'; -import { - ViewPlugin, - DecorationSet, - Decoration, - EditorView, - ViewUpdate, - WidgetType -} from '@codemirror/view'; +import { Decoration, EditorView, WidgetType } from '@codemirror/view'; +import { foldedRanges } from '@codemirror/language'; +import { RangeTuple } from '../util'; import { SyntaxNode } from '@lezer/common'; -import { isCursorInRange } from '../util'; -import { LruCache } from '@/common/utils/lruCache'; -import { generateContentHash } from '@/common/utils/hashUtils'; +import { BuildContext } from './types'; import DOMPurify from 'dompurify'; -// ============================================================================ -// Types and Interfaces -// ============================================================================ - -/** Cell alignment type */ type CellAlign = 'left' | 'center' | 'right'; +interface TableData { headers: string[]; alignments: CellAlign[]; rows: string[][]; } -/** Parsed table data */ -interface TableData { - headers: string[]; - alignments: CellAlign[]; - rows: string[][]; -} +const DECO_TABLE_LINE_HIDDEN = Decoration.line({ class: 'cm-table-line-hidden' }); -/** 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; @@ -264,426 +21,166 @@ 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); + if (HTML_TAG_RE.test(text)) { + html = html.replace(BOLD_STAR_RE, '$1').replace(BOLD_UNDER_RE, '$1'); + html = html.replace(ITALIC_STAR_RE, '$1').replace(ITALIC_UNDER_RE, '$1'); + if (!html.includes('')) html = html.replace(CODE_RE, '$1'); + html = html.replace(LINK_RE, '$1').replace(STRIKE_RE, '$1'); + html = DOMPurify.sanitize(html, { ADD_TAGS: ['code', 'strong', 'em', 'del', 'a'], ADD_ATTR: ['href', 'target'] }); } 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(BOLD_STAR_RE, '$1').replace(BOLD_UNDER_RE, '$1'); + html = html.replace(ITALIC_STAR_RE, '$1').replace(ITALIC_UNDER_RE, '$1'); html = html.replace(CODE_RE, '$1'); - - // Links: [text](url) - html = html.replace(LINK_RE, '$1'); - - // Strikethrough: ~~text~~ - html = html.replace(STRIKE_RE, '$1'); + html = html.replace(LINK_RE, '$1').replace(STRIKE_RE, '$1'); } - - // Cache result using LRU cache - inlineRenderCache.set(text, html); - return html; } +function parseRowText(rowText: string): string[] { + const cells: string[] = []; + let start = 0, end = rowText.length; + while (start < end && rowText.charCodeAt(start) <= 32) start++; + while (end > start && rowText.charCodeAt(end - 1) <= 32) end--; + if (start < end && rowText.charCodeAt(start) === 124) start++; + if (end > start && rowText.charCodeAt(end - 1) === 124) end--; + let cellStart = start; + for (let i = start; i <= end; i++) { + if (i === end || rowText.charCodeAt(i) === 124) { + let cs = cellStart, 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; +} + +function parseAlignment(text: string): CellAlign { + const len = text.length; + if (len === 0) return 'left'; + let start = 0, end = len - 1; + while (start < len && text.charCodeAt(start) === 32) start++; + while (end > start && text.charCodeAt(end) === 32) end--; + if (start > end) return 'left'; + const hasLeft = text.charCodeAt(start) === 58; + const hasRight = text.charCodeAt(end) === 58; + if (hasLeft && hasRight) return 'center'; + if (hasRight) return 'right'; + return 'left'; +} -/** - * 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(); + constructor(readonly data: TableData, readonly lineCount: number, readonly visualHeight: number, readonly contentWidth: number) { super(); } + eq(other: TableWidget) { + if (this.visualHeight !== other.visualHeight || this.contentWidth !== other.contentWidth) return false; + if (this.data === other.data) return true; + if (this.data.headers.length !== other.data.headers.length || this.data.rows.length !== other.data.rows.length) return false; + for (let i = 0; i < this.data.headers.length; i++) if (this.data.headers[i] !== other.data.headers[i]) return false; + for (let i = 0; i < this.data.rows.length; i++) { + if (this.data.rows[i].length !== other.data.rows[i].length) return false; + for (let j = 0; j < this.data.rows[i].length; j++) if (this.data.rows[i][j] !== other.data.rows[i][j]) return false; + } + return true; } - - /** - * 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); + const wrapper = document.createElement('div'); + wrapper.className = 'cm-table-wrapper'; + wrapper.style.maxWidth = `${this.contentWidth}px`; + wrapper.style.maxHeight = `${this.visualHeight}px`; + const headerRatio = 2 / this.lineCount, dataRowRatio = 1 / this.lineCount; + const headerHeight = this.visualHeight * headerRatio, dataRowHeight = this.visualHeight * dataRowRatio; + const headerCells = this.data.headers.map((h, i) => `${renderInlineMarkdown(h)}`).join(''); + const bodyRows = this.data.rows.map(row => `${row.map((c, i) => `${renderInlineMarkdown(c)}`).join('')}`).join(''); + wrapper.innerHTML = `${headerCells}${bodyRows}
`; + container.appendChild(wrapper); 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; - } + ignoreEvent() { 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; - } + 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. + * Handle Table node. */ -function buildDecorationsAndRanges(view: EditorView): BuildResult { - const decorations: Range[] = []; - const tableRanges: TableRange[] = []; - const contentWidth = view.contentDOM.clientWidth - 10; - const lineHeight = view.defaultLineHeight; +export function handleTable( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +): void { + if (ctx.seen.has(nf)) return; + ctx.seen.add(nf); + ranges.push([nf, nt]); + if (isInFoldedRange(ctx.view, nf, nt) || inCursor) return; - // 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) - ); - } - }); + const headerNode = node.getChild('TableHeader'); + if (!headerNode) return; + const headers: string[] = []; + const alignments: CellAlign[] = []; + const rows: string[][] = []; + const headerCells = headerNode.getChildren('TableCell'); + if (headerCells.length > 0) { + for (const cell of headerCells) headers.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim()); + } else { + headers.push(...parseRowText(ctx.view.state.sliceDoc(headerNode.from, headerNode.to))); } - - 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; + if (headers.length === 0) return; + let child = node.firstChild; + while (child) { + if (child.type.name === 'TableDelimiter') { + const delimText = ctx.view.state.sliceDoc(child.from, child.to); + if (delimText.includes('-')) { + for (const part of parseRowText(delimText)) if (part.includes('-')) alignments.push(parseAlignment(part)); + break; } } + child = child.nextSibling; } - return -1; + while (alignments.length < headers.length) alignments.push('left'); + for (const rowNode of node.getChildren('TableRow')) { + const rowData: string[] = []; + const cells = rowNode.getChildren('TableCell'); + if (cells.length > 0) { for (const cell of cells) rowData.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim()); } + else { rowData.push(...parseRowText(ctx.view.state.sliceDoc(rowNode.from, rowNode.to))); } + while (rowData.length < headers.length) rowData.push(''); + rows.push(rowData); + } + const startLine = ctx.view.state.doc.lineAt(nf); + const endLine = ctx.view.state.doc.lineAt(nt); + const lineCount = endLine.number - startLine.number + 1; + const startBlock = ctx.view.lineBlockAt(nf); + const endBlock = ctx.view.lineBlockAt(nt); + const visualHeight = endBlock.bottom - startBlock.top; + for (let num = startLine.number; num <= endLine.number; num++) { + ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_TABLE_LINE_HIDDEN }); + } + ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new TableWidget({ headers, alignments, rows }, lineCount, visualHeight, ctx.contentWidth), side: -1 }), priority: -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 + * Theme for tables. */ -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 +export const tableTheme = EditorView.baseTheme({ '.cm-table-container': { position: 'absolute', display: 'flex', @@ -691,19 +188,15 @@ const baseTheme = EditorView.baseTheme({ alignItems: 'flex-start', pointerEvents: 'none', zIndex: '2', - overflow: 'hidden', + overflow: 'hidden' }, - - // Table wrapper - scrollable when needed '.cm-table-wrapper': { display: 'inline-block', pointerEvents: 'auto', backgroundColor: 'var(--bg-primary)', overflowX: 'auto', - overflowY: 'auto', + overflowY: 'auto' }, - - // Table styles - use inset box-shadow for outer border (not clipped by overflow) '.cm-table': { borderCollapse: 'separate', borderSpacing: '0', @@ -713,9 +206,8 @@ const baseTheme = EditorView.baseTheme({ backgroundColor: 'var(--cm-table-bg)', border: 'none', boxShadow: 'inset 0 0 0 1px var(--cm-table-border)', - color: 'var(--text-primary) !important', + color: 'var(--text-primary) !important' }, - '.cm-table th, .cm-table td': { padding: '0 8px', border: 'none', @@ -725,109 +217,35 @@ const baseTheme = EditorView.baseTheme({ fontSize: 'inherit', fontFamily: 'inherit', lineHeight: 'inherit', - // Prevent text wrapping to maintain row height whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', - maxWidth: '300px', + 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 td': { boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)' }, + '.cm-table td:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' }, + '.cm-table tbody tr:last-child td': { boxShadow: '-1px 0 0 var(--cm-table-border)' }, + '.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)', + 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 th:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' }, + '.cm-table tbody tr:hover': { backgroundColor: 'var(--cm-table-row-hover)' }, + '.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' }, '.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', + fontFamily: 'var(--voidraft-font-mono)' }, + '.cm-table a': { color: 'var(--selection-text)', textDecoration: 'none' }, + '.cm-table a:hover': { textDecoration: 'underline' }, + '.cm-line.cm-table-line-hidden': { color: 'transparent !important', caretColor: 'transparent' }, + '.cm-line.cm-table-line-hidden span': { color: 'transparent !important' }, + '.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/extensions/markdown/plugins/theme.ts b/frontend/src/views/editor/extensions/markdown/plugins/theme.ts new file mode 100644 index 0000000..68ea3bf --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/theme.ts @@ -0,0 +1,33 @@ +/** + * Unified theme - combines all markdown plugin themes. + */ + +import { Extension } from '@codemirror/state'; +import { blockquoteTheme } from './blockquote'; +import { codeBlockTheme } from './code-block'; +import { headingTheme } from './heading'; +import { horizontalRuleTheme } from './horizontal-rule'; +import { inlineStylesTheme } from './inline-styles'; +import { linkTheme } from './link'; +import { listTheme } from './list'; +import { footnoteTheme } from './footnote'; +import { mathTheme } from './math'; +import { emojiTheme } from './emoji'; +import { tableTheme } from './table'; + +/** + * All markdown themes combined. + */ +export const Theme: Extension = [ + blockquoteTheme, + codeBlockTheme, + headingTheme, + horizontalRuleTheme, + inlineStylesTheme, + linkTheme, + listTheme, + footnoteTheme, + mathTheme, + emojiTheme, + tableTheme +]; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/types.ts b/frontend/src/views/editor/extensions/markdown/plugins/types.ts new file mode 100644 index 0000000..71fa045 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/types.ts @@ -0,0 +1,36 @@ +/** + * Shared types for unified markdown plugin handlers. + */ + +import { Decoration, EditorView } from '@codemirror/view'; +import { RangeTuple } from '../util'; +import { SyntaxNode } from '@lezer/common'; + +/** Decoration item to be added */ +export interface DecoItem { + from: number; + to: number; + deco: Decoration; + priority?: number; +} + +/** Shared build context passed to all handlers */ +export interface BuildContext { + view: EditorView; + items: DecoItem[]; + selRange: RangeTuple; + seen: Set; + processedLines: Set; + contentWidth: number; + lineHeight: number; +} + +/** Handler function type */ +export type NodeHandler = ( + ctx: BuildContext, + nf: number, + nt: number, + node: SyntaxNode, + inCursor: boolean, + ranges: RangeTuple[] +) => void | boolean; diff --git a/frontend/src/views/editor/extensions/markdown/syntax/emoji.ts b/frontend/src/views/editor/extensions/markdown/syntax/emoji.ts new file mode 100644 index 0000000..37d77f5 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/syntax/emoji.ts @@ -0,0 +1,127 @@ +/** + * Emoji extension for Lezer Markdown parser. + * + * Parses :emoji_name: syntax for emoji shortcodes. + * + * Syntax: :emoji_name: → renders as actual emoji character + * + * Examples: + * - :smile: → 😄 + * - :heart: → ❤️ + * - :+1: → 👍 + */ + +import { MarkdownConfig, InlineContext } from '@lezer/markdown'; +import { CharCode } from '../util'; +import { emojies } from '@/common/constant/emojies'; + +/** + * Pre-computed lookup table for emoji name characters. + * Valid characters: a-z, 0-9, _, +, - + * Uses Uint8Array for memory efficiency and O(1) lookup. + */ +const EMOJI_NAME_CHARS = new Uint8Array(128); +// Initialize lookup table +for (let i = 48; i <= 57; i++) EMOJI_NAME_CHARS[i] = 1; // 0-9 +for (let i = 97; i <= 122; i++) EMOJI_NAME_CHARS[i] = 1; // a-z +EMOJI_NAME_CHARS[95] = 1; // _ +EMOJI_NAME_CHARS[43] = 1; // + +EMOJI_NAME_CHARS[45] = 1; // - + +/** + * O(1) check if a character is valid for emoji name. + * @param code - ASCII character code + * @returns True if valid emoji name character + */ +function isEmojiNameChar(code: number): boolean { + return code < 128 && EMOJI_NAME_CHARS[code] === 1; +} + +/** + * Parse emoji :name: syntax. + * + * @param cx - Inline context + * @param pos - Start position (at :) + * @returns Position after element, or -1 if no match + */ +function parseEmoji(cx: InlineContext, pos: number): number { + const end = cx.end; + + // Minimum: : + name + : = at least 3 chars, name must be non-empty + if (end < pos + 2) return -1; + + // Track content for validation + let hasContent = false; + const contentStart = pos + 1; + + // Search for closing : + for (let i = contentStart; i < end; i++) { + const char = cx.char(i); + + // Found closing : + if (char === CharCode.Colon) { + // Must have content + if (!hasContent) return -1; + + // Extract and validate emoji name + const name = cx.slice(contentStart, i).toLowerCase(); + + // Check if this is a valid emoji + if (!emojies[name]) return -1; + + // Create element with marks and name + return cx.addElement(cx.elt('Emoji', pos, i + 1, [ + cx.elt('EmojiMark', pos, contentStart), + cx.elt('EmojiName', contentStart, i), + cx.elt('EmojiMark', i, i + 1) + ])); + } + + // Newline not allowed in emoji + if (char === CharCode.Newline) return -1; + + // Space not allowed in emoji name + if (char === CharCode.Space || char === CharCode.Tab) return -1; + + // Validate name character using O(1) lookup table + // Also check for uppercase A-Z (65-90) and convert mentally + const lowerChar = char >= 65 && char <= 90 ? char + 32 : char; + if (isEmojiNameChar(lowerChar)) { + hasContent = true; + } else { + return -1; + } + } + + return -1; +} + +/** + * Emoji extension for Lezer Markdown. + * + * Defines: + * - Emoji: The container node for emoji shortcode + * - EmojiMark: The : delimiter marks + * - EmojiName: The emoji name part + */ +export const Emoji: MarkdownConfig = { + defineNodes: [ + { name: 'Emoji' }, + { name: 'EmojiMark' }, + { name: 'EmojiName' } + ], + parseInline: [ + { + name: 'Emoji', + parse(cx, next, pos) { + // Fast path: must start with : + if (next !== CharCode.Colon) return -1; + return parseEmoji(cx, pos); + }, + // Parse after emphasis to avoid conflicts with other syntax + after: 'Emphasis' + } + ] +}; + +export default Emoji;