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 b7a333b..abffd4d 100644 --- a/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts @@ -10,6 +10,7 @@ import {StandardSQL} from "@codemirror/lang-sql"; import {markdown, markdownLanguage} from "@codemirror/lang-markdown"; import {Subscript, Superscript, Table} from "@lezer/markdown"; import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight"; +import {Footnote} from "@/views/editor/extensions/markdown/syntax/footnote"; import {javaLanguage} from "@codemirror/lang-java"; import {phpLanguage} from "@codemirror/lang-php"; import {cssLanguage} from "@codemirror/lang-css"; @@ -115,7 +116,7 @@ export const LANGUAGES: LanguageInfo[] = [ }), new LanguageInfo("md", "Markdown", markdown({ base: markdownLanguage, - extensions: [Subscript, Superscript, Highlight, Table], + extensions: [Subscript, Superscript, Highlight, Footnote, Table], completeHTMLTags: true, pasteURLAsLink: true, htmlTagLanguage: html({ diff --git a/frontend/src/views/editor/extensions/markdown/classes.ts b/frontend/src/views/editor/extensions/markdown/classes.ts deleted file mode 100644 index 5fb115d..0000000 --- a/frontend/src/views/editor/extensions/markdown/classes.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * A single source of truth for all the classes used for decorations in Ixora. - * These are kept together here to simplify changing/adding classes later - * and serve as a reference. - * - * Exports under this file don't need to follow any particular naming schema, - * naming which can give an intuition on what the class is for is preferred. - */ - -/** Classes for blockquote decorations. */ -export const blockquote = { - /** Blockquote widget */ - widget: 'cm-blockquote', - /** Replace decoration for the quote mark */ - mark: 'cm-blockquote-border' - }, - /** Classes for codeblock decorations. */ - codeblock = { - /** Codeblock widget */ - widget: 'cm-codeblock', - /** First line of the codeblock widget */ - widgetBegin: 'cm-codeblock-begin', - /** Last line of the codeblock widget */ - widgetEnd: 'cm-codeblock-end' - }, - /** Classes for heading decorations. */ - heading = { - /** Heading decoration class */ - heading: 'cm-heading', - /** Heading levels (h1, h2, etc) */ - level: (level: number) => `cm-heading-${level}`, - /** Heading slug */ - slug: (slug: string) => `cm-heading-slug-${slug}` - }, - /** Classes for link (URL) widgets. */ - link = { - /** URL widget */ - widget: 'cm-link' - }, - /** Classes for list widgets. */ - list = { - /** List bullet */ - bullet: 'cm-list-bullet', - /** List task checkbox */ - taskCheckbox: 'cm-task-marker-checkbox', - /** Task list item with checkbox checked */ - taskChecked: 'cm-task-checked' - }, - /** Classes for image widgets. */ - image = { - /** Image preview */ - widget: 'cm-image' - }, - /** Classes for enhanced code block decorations. */ - codeblockEnhanced = { - /** Code block info container */ - info: 'cm-code-block-info', - /** Language label */ - lang: 'cm-code-block-lang', - /** Copy button */ - copyBtn: 'cm-code-block-copy-btn' - }, - /** Classes for table decorations. */ - table = { - /** Table container wrapper */ - wrapper: 'cm-table-wrapper', - /** The rendered table element */ - table: 'cm-table', - /** Table header row */ - header: 'cm-table-header', - /** Table header cell */ - headerCell: 'cm-table-header-cell', - /** Table body */ - body: 'cm-table-body', - /** Table data row */ - row: 'cm-table-row', - /** Table data cell */ - cell: 'cm-table-cell', - /** Cell alignment classes */ - alignLeft: 'cm-table-align-left', - alignCenter: 'cm-table-align-center', - alignRight: 'cm-table-align-right', - /** Cell content wrapper (for editing) */ - cellContent: 'cm-table-cell-content', - /** Resize handle */ - resizeHandle: 'cm-table-resize-handle', - /** Active editing cell */ - cellActive: 'cm-table-cell-active', - /** Row hover state */ - rowHover: 'cm-table-row-hover', - /** Selected cell */ - cellSelected: 'cm-table-cell-selected' - } - diff --git a/frontend/src/views/editor/extensions/markdown/index.ts b/frontend/src/views/editor/extensions/markdown/index.ts index 7c1a4fe..8274587 100644 --- a/frontend/src/views/editor/extensions/markdown/index.ts +++ b/frontend/src/views/editor/extensions/markdown/index.ts @@ -3,7 +3,6 @@ import { blockquote } from './plugins/blockquote'; import { codeblock } from './plugins/code-block'; import { headings } from './plugins/heading'; import { hideMarks } from './plugins/hide-mark'; -import { htmlBlockExtension } from './plugins/html'; import { image } from './plugins/image'; import { links } from './plugins/link'; import { lists } from './plugins/list'; @@ -13,8 +12,7 @@ import { horizontalRule } from './plugins/horizontal-rule'; import { inlineCode } from './plugins/inline-code'; import { subscriptSuperscript } from './plugins/subscript-superscript'; import { highlight } from './plugins/highlight'; -import { table } from './plugins/table'; - +import { footnote } from './plugins/footnote'; /** * markdown extensions @@ -28,13 +26,12 @@ export const markdownExtensions: Extension = [ lists(), links(), image(), - htmlBlockExtension, emoji(), horizontalRule(), inlineCode(), subscriptSuperscript(), highlight(), - table(), + footnote(), ]; 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 ac630e0..6455840 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts @@ -8,7 +8,6 @@ import { import { Range } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; import { isCursorInRange, invisibleDecoration } from '../util'; -import { blockquote as classes } from '../classes'; /** * Blockquote plugin. @@ -47,7 +46,7 @@ function buildBlockQuoteDecorations(view: EditorView): DecorationSet { processedLines.add(i); const line = view.state.doc.line(i); decorations.push( - Decoration.line({ class: classes.widget }).range(line.from) + Decoration.line({ class: 'cm-blockquote' }).range(line.from) ); } } @@ -96,7 +95,7 @@ const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, { * Base theme for blockquotes. */ const baseTheme = EditorView.baseTheme({ - [`.${classes.widget}`]: { + '.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 b0cd6d5..3a23f39 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts @@ -9,7 +9,6 @@ import { } from '@codemirror/view'; import { syntaxTree } from '@codemirror/language'; import { isCursorInRange } from '../util'; -import { codeblock as classes, codeblockEnhanced as enhancedClasses } from '../classes'; /** Code block node types in syntax tree */ const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const; @@ -22,7 +21,7 @@ const ICON_CHECK = ` [codeBlockPlugin, baseTheme]; /** * Widget for displaying language label and copy button. - * Uses ignoreEvent: true to prevent editor focus changes. + * Handles click events directly on the button element. */ class CodeBlockInfoWidget extends WidgetType { - constructor(readonly data: CodeBlockData) { + constructor( + readonly data: CodeBlockData, + readonly view: EditorView + ) { super(); } @@ -53,26 +55,51 @@ class CodeBlockInfoWidget extends WidgetType { toDOM(): HTMLElement { const container = document.createElement('span'); - container.className = enhancedClasses.info; - container.dataset.codeFrom = String(this.data.from); + container.className = 'cm-code-block-info'; - // Language label - const lang = document.createElement('span'); - lang.className = enhancedClasses.lang; - lang.textContent = this.data.language; + // Only show language label if specified + if (this.data.language) { + const lang = document.createElement('span'); + lang.className = 'cm-code-block-lang'; + lang.textContent = this.data.language; + container.append(lang); + } - // Copy button const btn = document.createElement('button'); - btn.className = enhancedClasses.copyBtn; + btn.className = 'cm-code-block-copy-btn'; btn.title = 'Copy'; btn.innerHTML = ICON_COPY; - btn.dataset.codeContent = this.data.content; - container.append(lang, btn); + // Direct click handler - more reliable than eventHandlers + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.handleCopy(btn); + }); + + // Prevent mousedown from affecting editor + btn.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + + container.append(btn); return container; } - // Critical: ignore all events to prevent editor focus + private handleCopy(btn: HTMLButtonElement): void { + const content = getCodeContent(this.view, this.data.from, this.data.to); + if (!content) return; + + navigator.clipboard.writeText(content).then(() => { + btn.innerHTML = ICON_CHECK; + setTimeout(() => { + btn.innerHTML = ICON_COPY; + }, 1500); + }); + } + + // Ignore events to prevent editor focus changes ignoreEvent(): boolean { return true; } @@ -127,33 +154,28 @@ function buildDecorations(view: EditorView): { decorations: DecorationSet; block const startLine = view.state.doc.lineAt(nodeFrom); const endLine = view.state.doc.lineAt(nodeTo); - // Line decorations for (let num = startLine.number; num <= endLine.number; num++) { const line = view.state.doc.line(num); - const pos: string[] = []; - if (num === startLine.number) pos.push(classes.widgetBegin); - if (num === endLine.number) pos.push(classes.widgetEnd); + const pos: string[] = ['cm-codeblock']; + if (num === startLine.number) pos.push('cm-codeblock-begin'); + if (num === endLine.number) pos.push('cm-codeblock-end'); decorations.push( - Decoration.line({ - class: `${classes.widget} ${pos.join(' ')}`.trim() - }).range(line.from) + Decoration.line({ class: pos.join(' ') }).range(line.from) ); } - // Info widget (only if language specified) - if (language) { - const content = getCodeContent(view, nodeFrom, nodeTo); - const data: CodeBlockData = { from: nodeFrom, to: nodeTo, language, content }; - blocks.set(nodeFrom, data); + // Info widget with copy button (always show, language label only if specified) + const content = getCodeContent(view, nodeFrom, nodeTo); + const data: CodeBlockData = { from: nodeFrom, to: nodeTo, language, content }; + blocks.set(nodeFrom, data); - decorations.push( - Decoration.widget({ - widget: new CodeBlockInfoWidget(data), - side: 1 - }).range(startLine.to) - ); - } + decorations.push( + Decoration.widget({ + widget: new CodeBlockInfoWidget(data, view), + side: 1 + }).range(startLine.to) + ); // Hide markers node.toTree().iterate({ @@ -170,21 +192,6 @@ function buildDecorations(view: EditorView): { decorations: DecorationSet; block return { decorations: Decoration.set(decorations, true), blocks }; } -/** - * Handle copy button click. - */ -function handleCopyClick(btn: HTMLButtonElement): void { - const content = btn.dataset.codeContent; - if (!content) return; - - navigator.clipboard.writeText(content).then(() => { - btn.innerHTML = ICON_CHECK; - setTimeout(() => { - btn.innerHTML = ICON_COPY; - }, 1500); - }); -} - /** * Code block plugin with optimized updates. */ @@ -225,54 +232,28 @@ class CodeBlockPluginClass { } const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, { - decorations: (v) => v.decorations, - - eventHandlers: { - // Handle copy button clicks without triggering editor focus - mousedown(e: MouseEvent, view: EditorView) { - const target = e.target as HTMLElement; - - // Check if clicked on copy button or its SVG child - const btn = target.closest(`.${enhancedClasses.copyBtn}`) as HTMLButtonElement; - if (btn) { - e.preventDefault(); - e.stopPropagation(); - handleCopyClick(btn); - return true; - } - - // Check if clicked on info container (language label) - if (target.closest(`.${enhancedClasses.info}`)) { - e.preventDefault(); - e.stopPropagation(); - return true; - } - - return false; - } - } + decorations: (v) => v.decorations }); /** * Base theme for code blocks. */ const baseTheme = EditorView.baseTheme({ - [`.${classes.widget}`]: { + '.cm-codeblock': { backgroundColor: 'var(--cm-codeblock-bg)' }, - [`.${classes.widgetBegin}`]: { + '.cm-codeblock-begin': { borderTopLeftRadius: 'var(--cm-codeblock-radius)', borderTopRightRadius: 'var(--cm-codeblock-radius)', position: 'relative', - borderTop: '1px solid var(--text-primary)' + boxShadow: 'inset 0 1px 0 var(--text-primary)' }, - [`.${classes.widgetEnd}`]: { + '.cm-codeblock-end': { borderBottomLeftRadius: 'var(--cm-codeblock-radius)', borderBottomRightRadius: 'var(--cm-codeblock-radius)', - borderBottom: '1px solid var(--text-primary)' + boxShadow: 'inset 0 -1px 0 var(--text-primary)' }, - // Info container - [`.${enhancedClasses.info}`]: { + '.cm-code-block-info': { position: 'absolute', right: '8px', top: '50%', @@ -284,17 +265,15 @@ const baseTheme = EditorView.baseTheme({ opacity: '0.5', transition: 'opacity 0.15s' }, - [`.${enhancedClasses.info}:hover`]: { + '.cm-code-block-info:hover': { opacity: '1' }, - // Language label - [`.${enhancedClasses.lang}`]: { + '.cm-code-block-lang': { color: 'var(--cm-codeblock-lang, var(--cm-foreground))', textTransform: 'lowercase', userSelect: 'none' }, - // Copy button - [`.${enhancedClasses.copyBtn}`]: { + '.cm-code-block-copy-btn': { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -307,11 +286,11 @@ const baseTheme = EditorView.baseTheme({ opacity: '0.7', transition: 'opacity 0.15s, background 0.15s' }, - [`.${enhancedClasses.copyBtn}:hover`]: { + '.cm-code-block-copy-btn:hover': { opacity: '1', background: 'rgba(128, 128, 128, 0.2)' }, - [`.${enhancedClasses.copyBtn} svg`]: { + '.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 5a48d4e..4eb7fe4 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts @@ -157,11 +157,10 @@ const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, { /** * Base theme for emoji. + * Inherits font size and line height from parent element. */ const baseTheme = EditorView.baseTheme({ '.cm-emoji': { - fontSize: '1.2em', - lineHeight: '1', verticalAlign: 'middle', cursor: 'default' } diff --git a/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts b/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts new file mode 100644 index 0000000..6f5e943 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts @@ -0,0 +1,754 @@ +/** + * 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 + * + * Syntax (MultiMarkdown/PHP Markdown Extra): + * - Reference: [^id] → renders as superscript + * - Definition: [^id]: content + * - Inline footnote: ^[content] → renders as superscript with embedded content + */ + +import { Extension, Range, StateField, EditorState } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; +import { + ViewPlugin, + DecorationSet, + Decoration, + EditorView, + ViewUpdate, + WidgetType, + hoverTooltip, + Tooltip, +} from '@codemirror/view'; +import { isCursorInRange, invisibleDecoration } from '../util'; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Information about a footnote definition. + */ +interface FootnoteDefinition { + /** The footnote identifier (e.g., "1", "note") */ + id: string; + /** The content of the footnote */ + content: string; + /** Start position in document */ + from: number; + /** End position in document */ + to: number; +} + +/** + * Information about a footnote reference. + */ +interface FootnoteReference { + /** The footnote identifier */ + id: string; + /** Start position in document */ + from: number; + /** End position in document */ + to: number; + /** Numeric index (1-based, for display) */ + index: number; +} + +/** + * Information about an inline footnote. + */ +interface InlineFootnoteInfo { + /** The content of the inline footnote */ + content: string; + /** Start position in document */ + from: number; + /** End position in document */ + to: number; + /** Numeric index (1-based, for display) */ + index: number; +} + +/** + * Collected footnote data from the document. + * Uses Maps for O(1) lookup by position and id. + */ +interface FootnoteData { + definitions: Map; + references: FootnoteReference[]; + inlineFootnotes: InlineFootnoteInfo[]; + // Index maps for O(1) lookup + referencesByPos: Map; + inlineByPos: Map; + firstRefById: Map; +} + +// ============================================================================ +// Footnote Collection +// ============================================================================ + +/** + * Collect all footnote definitions, references, and inline footnotes from the document. + * Builds index maps for O(1) lookup during decoration and tooltip handling. + */ +function collectFootnotes(state: EditorState): FootnoteData { + const definitions = new Map(); + const references: FootnoteReference[] = []; + const inlineFootnotes: InlineFootnoteInfo[] = []; + // Index maps for fast lookup + const referencesByPos = new Map(); + const inlineByPos = new Map(); + const firstRefById = new Map(); + 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() + : ''; + + definitions.set(id, { id, content, 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); + + // Track first reference for each id + 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); + } + } + }, + }); + + return { + definitions, + references, + inlineFootnotes, + referencesByPos, + inlineByPos, + firstRefById, + }; +} + +// ============================================================================ +// State Field +// ============================================================================ + +/** + * State field to track footnote data across the document. + * This allows efficient lookup for tooltips and navigation. + */ +export const footnoteDataField = StateField.define({ + create(state) { + return collectFootnotes(state); + }, + update(value, tr) { + if (tr.docChanged) { + return collectFootnotes(tr.state); + } + return value; + }, +}); + +// ============================================================================ +// Widget +// ============================================================================ + +/** + * Widget to display footnote reference as superscript. + */ +class FootnoteRefWidget extends WidgetType { + constructor( + readonly id: string, + readonly index: number, + readonly hasDefinition: boolean + ) { + super(); + } + + toDOM(): HTMLElement { + const span = document.createElement('span'); + span.className = 'cm-footnote-ref'; + span.textContent = String(this.index); + span.dataset.footnoteId = this.id; + + 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; + } +} + +/** + * Widget to display inline footnote as superscript. + */ +class InlineFootnoteWidget extends WidgetType { + constructor( + readonly content: string, + readonly index: number + ) { + super(); + } + + toDOM(): HTMLElement { + const span = document.createElement('span'); + span.className = 'cm-inline-footnote-ref'; + span.textContent = String(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; + } +} + +// ============================================================================ +// Decorations +// ============================================================================ + +/** + * Build decorations for footnote references and inline footnotes. + */ +function buildDecorations(view: EditorView): DecorationSet { + const decorations: Range[] = []; + const data = view.state.field(footnoteDataField); + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { + // Handle footnote references + if (type.name === 'FootnoteReference') { + const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); + 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 (!cursorInRange && ref && ref.id === id) { + // Hide the entire syntax and show widget + decorations.push(invisibleDecoration.range(nodeFrom, nodeTo)); + + // Add widget at the end + const widget = new FootnoteRefWidget( + id, + ref.index, + data.definitions.has(id) + ); + decorations.push( + Decoration.widget({ + widget, + side: 1, + }).range(nodeTo) + ); + } + } + + // Handle footnote definitions + if (type.name === 'FootnoteDefinition') { + const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); + const marks = node.getChildren('FootnoteDefinitionMark'); + const labelNode = node.getChild('FootnoteDefinitionLabel'); + + if (!cursorInRange && marks.length >= 2 && labelNode) { + // Hide the [^ and ]: marks + decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to)); + decorations.push(invisibleDecoration.range(marks[1].from, marks[1].to)); + + // Style the label as definition marker + decorations.push( + Decoration.mark({ + class: 'cm-footnote-def-label', + }).range(labelNode.from, labelNode.to) + ); + } + } + + // Handle inline footnotes + if (type.name === 'InlineFootnote') { + const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); + const contentNode = node.getChild('InlineFootnoteContent'); + const marks = node.getChildren('InlineFootnoteMark'); + + if (!contentNode || marks.length < 2) return; + + const inlineNote = data.inlineByPos.get(nodeFrom); + + if (!cursorInRange && inlineNote) { + // Hide the entire syntax and show widget + decorations.push(invisibleDecoration.range(nodeFrom, nodeTo)); + + // Add widget at the end + const widget = new InlineFootnoteWidget( + inlineNote.content, + inlineNote.index + ); + decorations.push( + Decoration.widget({ + widget, + side: 1, + }).range(nodeTo) + ); + } + } + }, + }); + } + + return Decoration.set(decorations, true); +} + +// ============================================================================ +// Plugin Class +// ============================================================================ + +/** + * Footnote view plugin with optimized update detection. + */ +class FootnotePlugin { + decorations: DecorationSet; + private lastSelectionHead: number = -1; + + constructor(view: EditorView) { + this.decorations = buildDecorations(view); + this.lastSelectionHead = view.state.selection.main.head; + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildDecorations(update.view); + this.lastSelectionHead = update.state.selection.main.head; + return; + } + + if (update.selectionSet) { + const newHead = update.state.selection.main.head; + if (newHead !== this.lastSelectionHead) { + this.decorations = buildDecorations(update.view); + this.lastSelectionHead = newHead; + } + } + } +} + +const footnotePlugin = ViewPlugin.fromClass(FootnotePlugin, { + decorations: (v) => v.decorations, +}); + +// ============================================================================ +// Hover Tooltip +// ============================================================================ + +/** + * Hover tooltip that shows footnote content. + */ +const footnoteHoverTooltip = hoverTooltip( + (view, pos): Tooltip | null => { + const data = view.state.field(footnoteDataField); + + // Check if hovering over a footnote reference widget + const target = document.elementFromPoint( + view.coordsAtPos(pos)?.left ?? 0, + view.coordsAtPos(pos)?.top ?? 0 + ) as HTMLElement | null; + + 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), + }; + } + } + } + + // Check if hovering over an inline footnote widget + if (target?.classList.contains('cm-inline-footnote-ref')) { + const content = target.dataset.footnoteContent; + const index = target.dataset.footnoteIndex; + if (content && index) { + return { + pos, + above: true, + arrow: true, + create: () => createInlineTooltipDom(parseInt(index), content), + }; + } + } + + // Check if position is within a footnote reference node + let foundId: string | null = null; + let foundPos: number = pos; + let foundInlineContent: string | null = null; + let foundInlineIndex: number | null = null; + + syntaxTree(view.state).iterate({ + from: pos, + to: pos, + enter: ({ type, from, to, node }) => { + if (type.name === 'FootnoteReference') { + const labelNode = node.getChild('FootnoteReferenceLabel'); + if (labelNode && pos >= from && pos <= to) { + foundId = view.state.sliceDoc(labelNode.from, labelNode.to); + foundPos = to; + } + } else if (type.name === 'InlineFootnote') { + const contentNode = node.getChild('InlineFootnoteContent'); + if (contentNode && pos >= from && pos <= to) { + foundInlineContent = view.state.sliceDoc(contentNode.from, contentNode.to); + const inlineNote = data.inlineByPos.get(from); + if (inlineNote) { + foundInlineIndex = inlineNote.index; + } + foundPos = to; + } + } + }, + }); + + if (foundId) { + const def = data.definitions.get(foundId); + if (def) { + const tooltipId = foundId; + const tooltipPos = foundPos; + return { + pos: tooltipPos, + above: true, + arrow: true, + create: () => createTooltipDom(tooltipId, def.content), + }; + } + } + + if (foundInlineContent && foundInlineIndex !== null) { + const tooltipContent = foundInlineContent; + const tooltipIndex = foundInlineIndex; + const tooltipPos = foundPos; + return { + pos: tooltipPos, + above: true, + arrow: true, + create: () => createInlineTooltipDom(tooltipIndex, tooltipContent), + }; + } + + return null; + }, + { hoverTime: 300 } +); + +/** + * Create tooltip DOM element for regular footnote. + */ +function createTooltipDom(id: string, content: string): { dom: HTMLElement } { + const dom = document.createElement('div'); + dom.className = 'cm-footnote-tooltip'; + + 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 }; +} + +/** + * Create tooltip DOM element for inline footnote. + */ +function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } { + const dom = document.createElement('div'); + dom.className = 'cm-footnote-tooltip'; + + 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 }; +} + +// ============================================================================ +// Click Handler +// ============================================================================ + +/** + * Click handler for footnote navigation. + * Uses mousedown to intercept before editor moves cursor. + * - Click on reference → jump to definition + * - Click on definition label → jump to first reference + */ +const footnoteClickHandler = EditorView.domEventHandlers({ + mousedown(event, view) { + const target = event.target as HTMLElement; + + // Handle click on footnote reference widget + if (target.classList.contains('cm-footnote-ref')) { + const id = target.dataset.footnoteId; + if (id) { + const data = view.state.field(footnoteDataField); + const def = data.definitions.get(id); + if (def) { + // Prevent default to stop cursor from moving to widget position + event.preventDefault(); + // Use setTimeout to dispatch after mousedown completes + setTimeout(() => { + view.dispatch({ + selection: { anchor: def.from }, + scrollIntoView: true, + }); + view.focus(); + }, 0); + return true; + } + } + } + + // Handle click on definition label + if (target.classList.contains('cm-footnote-def-label')) { + const pos = view.posAtDOM(target); + if (pos !== null) { + const data = view.state.field(footnoteDataField); + + // Find which definition this belongs to + for (const [id, def] of data.definitions) { + if (pos >= def.from && pos <= def.to) { + // O(1) lookup for first reference + const firstRef = data.firstRefById.get(id); + if (firstRef) { + event.preventDefault(); + setTimeout(() => { + view.dispatch({ + selection: { anchor: firstRef.from }, + scrollIntoView: true, + }); + view.focus(); + }, 0); + return true; + } + break; + } + } + } + } + + return false; + }, +}); + +// ============================================================================ +// Theme +// ============================================================================ + +/** + * Base theme for footnotes. + */ +const baseTheme = EditorView.baseTheme({ + // Footnote reference (superscript) + '.cm-footnote-ref': { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: '1em', + height: '1.2em', + padding: '0 0.25em', + marginLeft: '1px', + fontSize: '0.75em', + fontWeight: '500', + lineHeight: '1', + 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))', + }, + '.cm-footnote-ref-undefined': { + color: 'var(--cm-footnote-undefined-color, #d93025)', + backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))', + }, + + // Inline footnote reference (superscript) - uses distinct color + '.cm-inline-footnote-ref': { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: '1em', + height: '1.2em', + padding: '0 0.25em', + marginLeft: '1px', + fontSize: '0.75em', + fontWeight: '500', + lineHeight: '1', + 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', + }, + '.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))', + }, + + // Footnote definition label + '.cm-footnote-def-label': { + color: 'var(--cm-footnote-def-color, #1a73e8)', + fontWeight: '600', + cursor: 'pointer', + }, + '.cm-footnote-def-label:hover': { + textDecoration: 'underline', + }, + + // Tooltip + '.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', + }, + + // Tooltip animation + '.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)' }, + }, +}); + +// ============================================================================ +// Export +// ============================================================================ + +/** + * Footnote extension. + * + * Features: + * - Parses footnote references [^id] and definitions [^id]: content + * - Parses inline footnotes ^[content] + * - Renders references and inline footnotes as superscript numbers + * - Shows definition/content on hover + * - Click to navigate between reference and definition + */ +export const footnote = (): Extension => [ + footnoteDataField, + footnotePlugin, + footnoteHoverTooltip, + footnoteClickHandler, + baseTheme, +]; + +export default footnote; + diff --git a/frontend/src/views/editor/extensions/markdown/plugins/image.ts b/frontend/src/views/editor/extensions/markdown/plugins/image.ts index 53ced6b..0a4bd9c 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/image.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/image.ts @@ -1,73 +1,177 @@ +import { syntaxTree } from '@codemirror/language'; import { Extension, Range } from '@codemirror/state'; -import { EditorView } from 'codemirror'; -import { imagePreview } from '../state/image'; -import { image as classes } from '../classes'; import { - Decoration, DecorationSet, + Decoration, + WidgetType, + EditorView, ViewPlugin, - ViewUpdate + ViewUpdate, + hoverTooltip, + Tooltip } from '@codemirror/view'; -import { - iterateTreeInVisibleRanges, - isCursorInRange, - invisibleDecoration -} from '../util'; -/** - * Build decorations to hide image markdown syntax. - * Only hides when cursor is outside the image range. - */ -function hideImageNodes(view: EditorView) { - const widgets = new Array>(); - iterateTreeInVisibleRanges(view, { - enter(node) { - if ( - node.name === 'Image' && - !isCursorInRange(view.state, [node.from, node.to]) - ) { - widgets.push(invisibleDecoration.range(node.from, node.to)); - } - } - }); - return Decoration.set(widgets, true); +interface ImageInfo { + src: string; + from: number; + to: number; + alt: string; } -/** - * Plugin to hide image markdown syntax when cursor is outside. - */ -const hideImageNodePlugin = ViewPlugin.fromClass( - class { - decorations: DecorationSet; +const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif|apng|tiff?)(\?.*)?$/i; +const IMAGE_ALT_RE = /(?:!\[)(.*?)(?:\])/; +const ICON = ``; - constructor(view: EditorView) { - this.decorations = hideImageNodes(view); +function isImageUrl(url: string): boolean { + return IMAGE_EXT_RE.test(url) || url.startsWith('data:image/'); +} + +function extractImages(view: EditorView): ImageInfo[] { + const result: ImageInfo[] = []; + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ name, node, from: f, to: t }) => { + if (name !== 'Image') return; + const urlNode = node.getChild('URL'); + if (!urlNode) return; + const src = view.state.sliceDoc(urlNode.from, urlNode.to); + if (!isImageUrl(src)) return; + const text = view.state.sliceDoc(f, t); + const alt = text.match(IMAGE_ALT_RE)?.[1] ?? ''; + result.push({ src, from: f, to: t, alt }); + } + }); + } + return result; +} + +class IndicatorWidget extends WidgetType { + constructor(readonly info: ImageInfo) { + super(); + } + + toDOM(): HTMLElement { + const el = document.createElement('span'); + el.className = 'cm-image-indicator'; + el.innerHTML = ICON; + return el; + } + + eq(other: IndicatorWidget): boolean { + return this.info.from === other.info.from && this.info.src === other.info.src; + } +} + +class ImagePlugin { + decorations: DecorationSet; + images: ImageInfo[] = []; + + constructor(view: EditorView) { + this.images = extractImages(view); + this.decorations = this.build(); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.images = extractImages(update.view); + this.decorations = this.build(); } + } - update(update: ViewUpdate) { - if (update.docChanged || update.selectionSet || update.viewportChanged) { - this.decorations = hideImageNodes(update.view); + private build(): DecorationSet { + const deco: Range[] = []; + for (const img of this.images) { + deco.push(Decoration.widget({ widget: new IndicatorWidget(img), side: 1 }).range(img.to)); + } + return Decoration.set(deco, true); + } + + getImageAt(pos: number): ImageInfo | null { + for (const img of this.images) { + if (pos >= img.to && pos <= img.to + 1) { + return img; } } + return null; + } +} + +const imagePlugin = ViewPlugin.fromClass(ImagePlugin, { + decorations: (v) => v.decorations +}); + +const imageHoverTooltip = hoverTooltip( + (view, pos): Tooltip | null => { + const plugin = view.plugin(imagePlugin); + if (!plugin) return null; + + const img = plugin.getImageAt(pos); + if (!img) return null; + + return { + pos: img.to, + above: true, + arrow: true, + create: () => { + const dom = document.createElement('div'); + dom.className = 'cm-image-tooltip'; + + const imgEl = document.createElement('img'); + imgEl.src = img.src; + imgEl.alt = img.alt; + + imgEl.onerror = () => { + imgEl.remove(); + dom.textContent = 'Failed to load image'; + dom.classList.add('cm-image-tooltip-error'); + }; + + dom.append(imgEl); + return { dom }; + } + }; }, - { decorations: (v) => v.decorations } + { hoverTime: 300 } ); -/** - * Image plugin. - */ -export const image = (): Extension => [ - imagePreview(), - hideImageNodePlugin, - baseTheme -]; - -const baseTheme = EditorView.baseTheme({ - ['.' + classes.widget]: { - display: 'block', - objectFit: 'contain', - maxWidth: '100%', - maxHeight: '100%', - userSelect: 'none' +const theme = EditorView.baseTheme({ + '.cm-image-indicator': { + display: 'inline-flex', + alignItems: 'center', + marginLeft: '4px', + verticalAlign: 'middle', + cursor: 'pointer', + opacity: '0.5', + color: 'var(--cm-link-color, #1a73e8)', + transition: 'opacity 0.15s', + '& svg': { width: '14px', height: '14px' } + }, + '.cm-image-indicator:hover': { opacity: '1' }, + '.cm-image-tooltip': { + background: 'var(--bg-secondary)', + border: '1px solid var(--border-color)', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + '& img': { + display: 'block', + maxWidth: '60vw', + maxHeight: '50vh' + } + }, + '.cm-image-tooltip-error': { + padding: '16px 24px', + fontSize: '12px', + color: 'var(--text-muted)' + }, + '.cm-tooltip-arrow:before': { + borderTopColor: 'var(--border-color) !important', + borderBottomColor: 'var(--border-color) !important' + }, + '.cm-tooltip-arrow:after': { + borderTopColor: 'var(--bg-secondary) !important', + borderBottomColor: 'var(--bg-secondary) !important' } }); + +export const image = (): Extension => [imagePlugin, imageHoverTooltip, theme]; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts b/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts index ba0d78b..f766e5e 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts @@ -105,8 +105,7 @@ const baseTheme = EditorView.baseTheme({ backgroundColor: 'var(--cm-inline-code-bg)', borderRadius: '0.25rem', padding: '0.1rem 0.3rem', - fontFamily: 'var(--voidraft-font-mono)', - fontSize: '0.9em' + fontFamily: 'var(--voidraft-font-mono)' } }); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/link.ts b/frontend/src/views/editor/extensions/markdown/plugins/link.ts index 4cb0235..9acc0c5 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/link.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/link.ts @@ -16,8 +16,10 @@ const AUTO_LINK_MARK_RE = /^<|>$/g; /** * Parent node types that should not process. + * - Image: handled by image plugin + * - LinkReference: reference link definitions like [label]: url should be fully visible */ -const BLACKLISTED_PARENTS = new Set(['Image']); +const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']); /** * Links plugin. @@ -50,6 +52,19 @@ function buildLinkDecorations(view: EditorView): DecorationSet { const marks = parent.getChildren('LinkMark'); const linkTitle = parent.getChild('LinkTitle'); + // Find the ']' mark position to distinguish between link text and link target + // Link structure: [display text](url) + // We should only hide the URL in the () part, not in the [] part + const closeBracketMark = marks.find((mark) => { + const text = view.state.sliceDoc(mark.from, mark.to); + return text === ']'; + }); + + // If URL is before ']', it's part of the display text, don't hide it + if (closeBracketMark && nodeFrom < closeBracketMark.from) { + return; + } + // Check if cursor overlaps with the link const cursorOverlaps = selectionRanges.some((range) => checkRangeOverlap([range.from, range.to], [parent.from, parent.to]) diff --git a/frontend/src/views/editor/extensions/markdown/plugins/list.ts b/frontend/src/views/editor/extensions/markdown/plugins/list.ts index 3681977..0008dc1 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/list.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/list.ts @@ -9,7 +9,6 @@ import { import { Range, StateField, Transaction } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; import { isCursorInRange } from '../util'; -import { list as classes } from '../classes'; /** * Pattern for bullet list markers. @@ -43,7 +42,7 @@ class ListBulletWidget extends WidgetType { toDOM(): HTMLElement { const span = document.createElement('span'); - span.className = classes.bullet; + span.className = 'cm-list-bullet'; span.textContent = '•'; return span; } @@ -145,7 +144,7 @@ class TaskCheckboxWidget extends WidgetType { toDOM(view: EditorView): HTMLElement { const wrap = document.createElement('span'); wrap.setAttribute('aria-hidden', 'true'); - wrap.className = classes.taskCheckbox; + wrap.className = 'cm-task-checkbox'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; @@ -205,12 +204,9 @@ function buildTaskListDecorations(state: import('@codemirror/state').EditorState const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]); const checkboxPos = taskMarker.from + 1; // Position of the x or space - // Add checked style to the entire task content if (isChecked) { decorations.push( - Decoration.mark({ - class: classes.taskChecked - }).range(taskFrom, taskTo) + Decoration.mark({ class: 'cm-task-checked' }).range(taskFrom, taskTo) ); } @@ -255,19 +251,18 @@ const taskListField = StateField.define({ * Base theme for lists. */ const baseTheme = EditorView.baseTheme({ - [`.${classes.bullet}`]: { - // No extra width - just replace the character + '.cm-list-bullet': { color: 'var(--cm-list-bullet-color, inherit)' }, - [`.${classes.taskChecked}`]: { + '.cm-task-checked': { textDecoration: 'line-through', opacity: '0.6' }, - [`.${classes.taskCheckbox}`]: { + '.cm-task-checkbox': { display: 'inline-block', verticalAlign: 'baseline' }, - [`.${classes.taskCheckbox} input`]: { + '.cm-task-checkbox input': { cursor: 'pointer', margin: '0', width: '1em', diff --git a/frontend/src/views/editor/extensions/markdown/plugins/subscript-superscript.ts b/frontend/src/views/editor/extensions/markdown/plugins/subscript-superscript.ts index 3ce9d65..83f9d32 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/subscript-superscript.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/subscript-superscript.ts @@ -16,6 +16,9 @@ import { isCursorInRange, invisibleDecoration } from '../util'; * - Superscript: ^text^ → renders as superscript * - Subscript: ~text~ → renders as subscript * + * Note: Inline footnotes ^[content] are handled by the Footnote extension + * which parses InlineFootnote before Superscript in the syntax tree. + * * Examples: * - 19^th^ → 19ᵗʰ (superscript) * - H~2~O → H₂O (subscript) @@ -37,30 +40,15 @@ function buildDecorations(view: EditorView): DecorationSet { to, enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { // Handle Superscript nodes + // Note: InlineFootnote ^[content] is parsed before Superscript, + // so we don't need to check for bracket patterns here if (type.name === 'Superscript') { - // Get the full content including marks - const fullContent = view.state.doc.sliceString(nodeFrom, nodeTo); - - // Skip if this contains inline footnote pattern ^[ - // This catches ^[text] being misinterpreted as superscript - if (fullContent.includes('^[') || fullContent.includes('[') && fullContent.includes(']')) { - return; - } - const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); // Get the mark nodes (the ^ characters) const marks = node.getChildren('SuperscriptMark'); if (!cursorInRange && marks.length >= 2) { - // Get inner content between marks - const innerContent = view.state.doc.sliceString(marks[0].to, marks[marks.length - 1].from); - - // Skip if inner content looks like footnote (starts with [ or contains brackets) - if (innerContent.startsWith('[') || innerContent.includes('[') || innerContent.includes(']')) { - return; - } - // Hide the opening and closing ^ marks decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to)); decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to)); @@ -148,16 +136,17 @@ const subscriptSuperscriptPlugin = ViewPlugin.fromClass( /** * Base theme for subscript and superscript. * Uses mark decoration instead of widget to avoid layout issues. + * fontSize uses smaller value as subscript/superscript are naturally smaller. */ const baseTheme = EditorView.baseTheme({ '.cm-superscript': { verticalAlign: 'super', - fontSize: '0.8em', + fontSize: '0.75em', color: 'var(--cm-superscript-color, inherit)' }, '.cm-subscript': { verticalAlign: 'sub', - fontSize: '0.8em', + 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 deleted file mode 100644 index 61bfdda..0000000 --- a/frontend/src/views/editor/extensions/markdown/plugins/table.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Extension, Range } from '@codemirror/state'; -import { - ViewPlugin, - DecorationSet, - Decoration, - EditorView, - ViewUpdate -} from '@codemirror/view'; -import { isCursorInRange } from '../util'; -import { extractTablesFromState } from '../state/table'; - -/** - * Table styling extension for Markdown. - * - * - Adds background styling and hides syntax when cursor is OUTSIDE table - * - Shows raw markdown with NO styling when cursor is INSIDE table (edit mode) - */ -export const table = (): Extension => [tablePlugin, baseTheme]; - -/** Line decorations - only applied when NOT editing */ -const headerLine = Decoration.line({ attributes: { class: 'cm-table-header' } }); -const delimiterLine = Decoration.line({ attributes: { class: 'cm-table-delimiter' } }); -const dataLine = Decoration.line({ attributes: { class: 'cm-table-data' } }); - -/** Mark to hide pipe characters */ -const pipeHidden = Decoration.mark({ attributes: { class: 'cm-table-pipe-hidden' } }); - -/** Mark to hide delimiter row content */ -const delimiterHidden = Decoration.mark({ attributes: { class: 'cm-table-delimiter-hidden' } }); - -/** Delimiter row regex */ -const DELIMITER_REGEX = /^\s*\|?\s*[-:]+/; - -/** - * Build decorations for tables. - * Only adds decorations when cursor is OUTSIDE the table. - */ -function buildDecorations(view: EditorView): DecorationSet { - const decorations: Range[] = []; - const tables = extractTablesFromState(view.state); - - for (const table of tables) { - // Skip all decorations if cursor is inside table (edit mode) - if (isCursorInRange(view.state, [table.from, table.to])) { - continue; - } - - const startLine = view.state.doc.lineAt(table.from); - const endLine = view.state.doc.lineAt(table.to); - const lines = table.rawText.split('\n'); - - for (let i = 0; i < lines.length; i++) { - const lineNum = startLine.number + i; - if (lineNum > endLine.number) break; - - const line = view.state.doc.line(lineNum); - const text = lines[i]; - const isDelimiter = i === 1 && DELIMITER_REGEX.test(text); - - // Add line decoration - if (i === 0) { - decorations.push(headerLine.range(line.from)); - } else if (isDelimiter) { - decorations.push(delimiterLine.range(line.from)); - } else { - decorations.push(dataLine.range(line.from)); - } - - // Hide syntax elements - if (isDelimiter) { - // Hide entire delimiter content - decorations.push(delimiterHidden.range(line.from, line.to)); - } else { - // Hide pipe characters - for (let j = 0; j < text.length; j++) { - if (text[j] === '|') { - decorations.push(pipeHidden.range(line.from + j, line.from + j + 1)); - } - } - } - } - } - - return Decoration.set(decorations, true); -} - -/** - * Table ViewPlugin. - */ -const tablePlugin = ViewPlugin.fromClass( - class { - decorations: DecorationSet; - - constructor(view: EditorView) { - this.decorations = buildDecorations(view); - } - - update(update: ViewUpdate) { - if (update.docChanged || update.selectionSet || update.viewportChanged) { - this.decorations = buildDecorations(update.view); - } - } - }, - { decorations: v => v.decorations } -); - -/** - * Base theme for table styling. - */ -const baseTheme = EditorView.baseTheme({ - // Header row - '.cm-table-header': { - backgroundColor: 'var(--cm-table-header-bg, rgba(128, 128, 128, 0.12))', - borderTopLeftRadius: '4px', - borderTopRightRadius: '4px' - }, - - // Delimiter row - '.cm-table-delimiter': { - backgroundColor: 'var(--cm-table-bg, rgba(128, 128, 128, 0.06))', - lineHeight: '0.5' - }, - - // Data rows - '.cm-table-data': { - backgroundColor: 'var(--cm-table-bg, rgba(128, 128, 128, 0.06))' - }, - - '.cm-table-data:last-of-type': { - borderBottomLeftRadius: '4px', - borderBottomRightRadius: '4px' - }, - - // Hidden pipe characters - '.cm-table-pipe-hidden': { - fontSize: '0', - color: 'transparent' - }, - - // Hidden delimiter content - '.cm-table-delimiter-hidden': { - fontSize: '0', - color: 'transparent' - } -}); diff --git a/frontend/src/views/editor/extensions/markdown/state/image.ts b/frontend/src/views/editor/extensions/markdown/state/image.ts deleted file mode 100644 index 7e43d2b..0000000 --- a/frontend/src/views/editor/extensions/markdown/state/image.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { syntaxTree } from '@codemirror/language'; -import { Extension, Range } from '@codemirror/state'; -import { - DecorationSet, - Decoration, - WidgetType, - EditorView, - ViewPlugin, - ViewUpdate -} from '@codemirror/view'; -import { isCursorInRange } from '../util'; -import { image as classes } from '../classes'; - -/** - * Representation of image data extracted from the syntax tree. - */ -export interface ImageInfo { - /** The source URL of the image. */ - src: string; - /** The starting position of the image element in the document. */ - from: number; - /** The end position of the image element in the document. */ - to: number; - /** The alt text of the image. */ - alt: string; -} - -/** - * Capture everything in square brackets of a markdown image, after - * the exclamation mark. - */ -const IMAGE_TEXT_RE = /(?:!\[)(.*?)(?:\])/; - -/** - * Extract images from the syntax tree. - */ -function extractImages(view: EditorView): ImageInfo[] { - const images: ImageInfo[] = []; - - for (const { from, to } of view.visibleRanges) { - syntaxTree(view.state).iterate({ - from, - to, - enter: ({ name, node, from: nodeFrom, to: nodeTo }) => { - if (name !== 'Image') return; - const altMatch = view.state.sliceDoc(nodeFrom, nodeTo).match(IMAGE_TEXT_RE); - const alt: string = altMatch?.pop() ?? ''; - const urlNode = node.getChild('URL'); - if (urlNode) { - const url: string = view.state.sliceDoc(urlNode.from, urlNode.to); - images.push({ src: url, from: nodeFrom, to: nodeTo, alt }); - } - } - }); - } - - return images; -} - -/** - * Build image preview decorations. - * Only shows preview when cursor is outside the image syntax. - */ -function buildImageDecorations(view: EditorView, loadedImages: Set): DecorationSet { - const decorations: Range[] = []; - const images = extractImages(view); - - for (const img of images) { - const cursorInImage = isCursorInRange(view.state, [img.from, img.to]); - - // Only show preview when cursor is outside - if (!cursorInImage) { - const isLoaded = loadedImages.has(img.src); - decorations.push( - Decoration.widget({ - widget: new ImagePreviewWidget(img, isLoaded, loadedImages), - side: 1 - }).range(img.to) - ); - } - } - - return Decoration.set(decorations, true); -} - -/** - * Image preview widget that displays the actual image. - */ -class ImagePreviewWidget extends WidgetType { - constructor( - private readonly info: ImageInfo, - private readonly isLoaded: boolean, - private readonly loadedImages: Set - ) { - super(); - } - - toDOM(view: EditorView): HTMLElement { - const wrapper = document.createElement('span'); - wrapper.className = 'cm-image-preview-wrapper'; - - const img = new Image(); - img.classList.add(classes.widget); - img.src = this.info.src; - img.alt = this.info.alt; - - if (!this.isLoaded) { - img.addEventListener('load', () => { - this.loadedImages.add(this.info.src); - view.dispatch({}); - }); - } - - if (this.isLoaded) { - wrapper.appendChild(img); - } else { - const placeholder = document.createElement('span'); - placeholder.className = 'cm-image-loading'; - placeholder.textContent = '🖼️'; - wrapper.appendChild(placeholder); - img.style.display = 'none'; - wrapper.appendChild(img); - } - - return wrapper; - } - - eq(widget: ImagePreviewWidget): boolean { - return ( - widget.info.src === this.info.src && - widget.info.from === this.info.from && - widget.info.to === this.info.to && - widget.isLoaded === this.isLoaded - ); - } - - ignoreEvent(): boolean { - return false; - } -} - -/** - * Image preview plugin class. - */ -class ImagePreviewPlugin { - decorations: DecorationSet; - private loadedImages: Set = new Set(); - private lastSelectionRanges: string = ''; - - constructor(view: EditorView) { - this.decorations = buildImageDecorations(view, this.loadedImages); - this.lastSelectionRanges = this.serializeSelection(view); - } - - update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { - this.decorations = buildImageDecorations(update.view, this.loadedImages); - this.lastSelectionRanges = this.serializeSelection(update.view); - return; - } - - if (update.selectionSet) { - const newRanges = this.serializeSelection(update.view); - if (newRanges !== this.lastSelectionRanges) { - this.decorations = buildImageDecorations(update.view, this.loadedImages); - this.lastSelectionRanges = newRanges; - } - return; - } - - if (!update.docChanged && !update.selectionSet && !update.viewportChanged) { - this.decorations = buildImageDecorations(update.view, this.loadedImages); - } - } - - private serializeSelection(view: EditorView): string { - return view.state.selection.ranges - .map((r) => `${r.from}:${r.to}`) - .join(','); - } -} - -/** - * Image preview extension. - * Only handles displaying image preview widget. - */ -export const imagePreview = (): Extension => [ - ViewPlugin.fromClass(ImagePreviewPlugin, { - decorations: (v) => v.decorations - }), - baseTheme -]; - -const baseTheme = EditorView.baseTheme({ - '.cm-image-preview-wrapper': { - display: 'block', - margin: '0.5rem 0' - }, - [`.${classes.widget}`]: { - maxWidth: '100%', - height: 'auto', - borderRadius: '0.25rem' - }, - '.cm-image-loading': { - display: 'inline-block', - color: 'var(--cm-foreground)', - opacity: '0.6' - } -}); diff --git a/frontend/src/views/editor/extensions/markdown/syntax/footnote.ts b/frontend/src/views/editor/extensions/markdown/syntax/footnote.ts new file mode 100644 index 0000000..0f914a6 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/syntax/footnote.ts @@ -0,0 +1,286 @@ +/** + * Footnote extension for Lezer Markdown parser. + * + * Parses footnote syntax compatible with MultiMarkdown/PHP Markdown Extra. + * + * Syntax: + * - Footnote reference: [^id] or [^1] + * - Footnote definition: [^id]: content (at line start) + * - Inline footnote: ^[content] (content is inline, no separate definition needed) + * + * Examples: + * - This is text[^1] with a footnote. + * - [^1]: This is the footnote content. + * - This is text^[inline footnote content] with inline footnote. + */ + +import { MarkdownConfig, Line, BlockContext } from '@lezer/markdown'; + +/** + * ASCII character codes for parsing. + */ +const enum Ch { + OpenBracket = 91, // [ + CloseBracket = 93, // ] + Caret = 94, // ^ + Colon = 58, // : + Space = 32, + Tab = 9, + Newline = 10, +} + +/** + * Check if a character is valid for footnote ID. + * Allows: letters, numbers, underscore, hyphen + */ +function isFootnoteIdChar(code: number): boolean { + return ( + (code >= 48 && code <= 57) || // 0-9 + (code >= 65 && code <= 90) || // A-Z + (code >= 97 && code <= 122) || // a-z + code === 95 || // _ + code === 45 // - + ); +} + +/** + * Footnote extension for Lezer Markdown. + * + * Defines nodes: + * - FootnoteReference: Inline reference [^id] + * - FootnoteReferenceMark: The [^ and ] delimiters + * - FootnoteReferenceLabel: The id part + * - FootnoteDefinition: Block definition [^id]: content + * - FootnoteDefinitionMark: The [^, ]: delimiters + * - FootnoteDefinitionLabel: The id part in definition + * - FootnoteDefinitionContent: The content part + * - InlineFootnote: Inline footnote ^[content] + * - InlineFootnoteMark: The ^[ and ] delimiters + * - InlineFootnoteContent: The content part + */ +export const Footnote: MarkdownConfig = { + defineNodes: [ + // Inline reference nodes + { name: 'FootnoteReference' }, + { name: 'FootnoteReferenceMark' }, + { name: 'FootnoteReferenceLabel' }, + // Block definition nodes + { name: 'FootnoteDefinition', block: true }, + { name: 'FootnoteDefinitionMark' }, + { name: 'FootnoteDefinitionLabel' }, + { name: 'FootnoteDefinitionContent' }, + // Inline footnote nodes + { name: 'InlineFootnote' }, + { name: 'InlineFootnoteMark' }, + { name: 'InlineFootnoteContent' }, + ], + + parseInline: [ + // Inline footnote must be parsed before Superscript to handle ^[ pattern + { + name: 'InlineFootnote', + parse(cx, next, pos) { + // Check for ^[ pattern + if (next !== Ch.Caret || cx.char(pos + 1) !== Ch.OpenBracket) { + return -1; + } + + // Find the closing ] + // Content can contain any characters except unbalanced brackets and newlines + let end = pos + 2; + let bracketDepth = 1; // We're inside one [ + let hasContent = false; + + while (end < cx.end) { + const char = cx.char(end); + + // Don't allow newlines in inline footnotes + if (char === Ch.Newline) { + return -1; + } + + // Track bracket depth for nested brackets + if (char === Ch.OpenBracket) { + bracketDepth++; + } else if (char === Ch.CloseBracket) { + bracketDepth--; + if (bracketDepth === 0) { + // Found the closing bracket + if (!hasContent) { + return -1; // Empty inline footnote + } + + // Create the element with marks and content + const children = [ + // Opening mark ^[ + cx.elt('InlineFootnoteMark', pos, pos + 2), + // Content + cx.elt('InlineFootnoteContent', pos + 2, end), + // Closing mark ] + cx.elt('InlineFootnoteMark', end, end + 1), + ]; + + const element = cx.elt('InlineFootnote', pos, end + 1, children); + return cx.addElement(element); + } + } else { + hasContent = true; + } + + end++; + } + + return -1; + }, + // Parse before Superscript to avoid ^[ being misinterpreted + before: 'Superscript', + }, + { + name: 'FootnoteReference', + parse(cx, next, pos) { + // Check for [^ pattern + if (next !== Ch.OpenBracket || cx.char(pos + 1) !== Ch.Caret) { + return -1; + } + + // Find the closing ] + let end = pos + 2; + let hasValidId = false; + + while (end < cx.end) { + const char = cx.char(end); + + // Found closing bracket + if (char === Ch.CloseBracket) { + if (!hasValidId) { + return -1; // Empty footnote reference + } + + // Create the element with marks and label + const children = [ + // Opening mark [^ + cx.elt('FootnoteReferenceMark', pos, pos + 2), + // Label (the id) + cx.elt('FootnoteReferenceLabel', pos + 2, end), + // Closing mark ] + cx.elt('FootnoteReferenceMark', end, end + 1), + ]; + + const element = cx.elt('FootnoteReference', pos, end + 1, children); + return cx.addElement(element); + } + + // Don't allow newlines + if (char === Ch.Newline) { + return -1; + } + + // Validate id characters + if (isFootnoteIdChar(char)) { + hasValidId = true; + } else { + // Invalid character in footnote id + return -1; + } + + end++; + } + + return -1; + }, + // Parse before links to avoid conflicts + before: 'Link', + }, + ], + + parseBlock: [ + { + name: 'FootnoteDefinition', + parse(cx: BlockContext, line: Line): boolean { + // Must start at the beginning of a line + // Check for [^ pattern + const text = line.text; + if (text.charCodeAt(0) !== Ch.OpenBracket || + text.charCodeAt(1) !== Ch.Caret) { + return false; + } + + // Find ]: pattern + let labelEnd = 2; + while (labelEnd < text.length) { + const char = text.charCodeAt(labelEnd); + + if (char === Ch.CloseBracket) { + // Check for : after ] + if (labelEnd + 1 < text.length && + text.charCodeAt(labelEnd + 1) === Ch.Colon) { + break; + } + return false; + } + + if (!isFootnoteIdChar(char)) { + return false; + } + + labelEnd++; + } + + // Must have found ]: + if (labelEnd >= text.length || + text.charCodeAt(labelEnd) !== Ch.CloseBracket || + text.charCodeAt(labelEnd + 1) !== Ch.Colon) { + return false; + } + + // Calculate positions + const start = cx.lineStart; + const openMarkEnd = start + 2; // [^ + const labelStart = openMarkEnd; + const labelEndPos = start + labelEnd; + const closeMarkStart = labelEndPos; + const closeMarkEnd = start + labelEnd + 2; // ]: + const contentStart = closeMarkEnd; + + // Skip optional space after : + let contentOffset = labelEnd + 2; + if (contentOffset < text.length && + (text.charCodeAt(contentOffset) === Ch.Space || + text.charCodeAt(contentOffset) === Ch.Tab)) { + contentOffset++; + } + + // Build the element + const children = [ + // Opening mark [^ + cx.elt('FootnoteDefinitionMark', start, openMarkEnd), + // Label + cx.elt('FootnoteDefinitionLabel', labelStart, labelEndPos), + // Closing mark ]: + cx.elt('FootnoteDefinitionMark', closeMarkStart, closeMarkEnd), + ]; + + // Add content if present + const contentText = text.slice(contentOffset); + if (contentText.length > 0) { + children.push( + cx.elt('FootnoteDefinitionContent', start + contentOffset, start + text.length) + ); + } + + // Create the block element + const element = cx.elt('FootnoteDefinition', start, start + text.length, children); + cx.addElement(element); + + // Move to next line + cx.nextLine(); + return true; + }, + // Parse before other block elements + before: 'LinkReference', + }, + ], +}; + +export default Footnote; +