diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 68bc135..4a88277 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -44,6 +44,7 @@ "@replit/codemirror-lang-svelte": "^6.0.0", "@toml-tools/lexer": "^1.0.0", "@toml-tools/parser": "^1.0.0", + "@types/katex": "^0.16.7", "codemirror": "^6.0.2", "codemirror-lang-elixir": "^4.0.0", "colors-named": "^1.0.2", @@ -51,6 +52,7 @@ "groovy-beautify": "^0.0.17", "hsl-matcher": "^1.2.4", "java-parser": "^3.0.1", + "katex": "^0.16.25", "linguist-languages": "^9.1.0", "marked": "^17.0.1", "mermaid": "^11.12.1", @@ -2831,6 +2833,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.7", + "resolved": "https://registry.npmmirror.com/@types/katex/-/katex-0.16.7.tgz", + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "license": "MIT" + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 49b79d3..36658ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -58,6 +58,7 @@ "@replit/codemirror-lang-svelte": "^6.0.0", "@toml-tools/lexer": "^1.0.0", "@toml-tools/parser": "^1.0.0", + "@types/katex": "^0.16.7", "codemirror": "^6.0.2", "codemirror-lang-elixir": "^4.0.0", "colors-named": "^1.0.2", @@ -65,6 +66,7 @@ "groovy-beautify": "^0.0.17", "hsl-matcher": "^1.2.4", "java-parser": "^3.0.1", + "katex": "^0.16.25", "linguist-languages": "^9.1.0", "marked": "^17.0.1", "mermaid": "^11.12.1", 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 abffd4d..59f2044 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,8 @@ 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 {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 {javaLanguage} from "@codemirror/lang-java"; import {phpLanguage} from "@codemirror/lang-php"; @@ -116,7 +118,7 @@ export const LANGUAGES: LanguageInfo[] = [ }), new LanguageInfo("md", "Markdown", markdown({ base: markdownLanguage, - extensions: [Subscript, Superscript, Highlight, Footnote, Table], + extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table], completeHTMLTags: true, pasteURLAsLink: true, htmlTagLanguage: html({ diff --git a/frontend/src/views/editor/extensions/markdown/index.ts b/frontend/src/views/editor/extensions/markdown/index.ts index 8274587..ccac3aa 100644 --- a/frontend/src/views/editor/extensions/markdown/index.ts +++ b/frontend/src/views/editor/extensions/markdown/index.ts @@ -12,6 +12,8 @@ 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'; /** @@ -31,6 +33,8 @@ export const markdownExtensions: Extension = [ inlineCode(), subscriptSuperscript(), highlight(), + insert(), + math(), footnote(), ]; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts b/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts index 6f5e943..2d24b31 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/footnote.ts @@ -216,7 +216,7 @@ class FootnoteRefWidget extends WidgetType { toDOM(): HTMLElement { const span = document.createElement('span'); span.className = 'cm-footnote-ref'; - span.textContent = String(this.index); + span.textContent = `[${this.index}]`; span.dataset.footnoteId = this.id; if (!this.hasDefinition) { @@ -249,7 +249,7 @@ class InlineFootnoteWidget extends WidgetType { toDOM(): HTMLElement { const span = document.createElement('span'); span.className = 'cm-inline-footnote-ref'; - span.textContent = String(this.index); + span.textContent = `[${this.index}]`; span.dataset.footnoteContent = this.content; span.dataset.footnoteIndex = String(this.index); @@ -265,6 +265,31 @@ class InlineFootnoteWidget extends WidgetType { } } +/** + * Widget to display footnote definition label. + */ +class FootnoteDefLabelWidget extends WidgetType { + constructor(readonly id: string) { + super(); + } + + 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; + } +} + // ============================================================================ // Decorations // ============================================================================ @@ -318,15 +343,18 @@ function buildDecorations(view: EditorView): DecorationSet { 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)); + const id = view.state.sliceDoc(labelNode.from, labelNode.to); + + // Hide the entire [^id]: part + decorations.push(invisibleDecoration.range(marks[0].from, marks[1].to)); - // Style the label as definition marker + // Add widget to show [id] + const widget = new FootnoteDefLabelWidget(id); decorations.push( - Decoration.mark({ - class: 'cm-footnote-def-label', - }).range(labelNode.from, labelNode.to) + Decoration.widget({ + widget, + side: 1, + }).range(marks[1].to) ); } } diff --git a/frontend/src/views/editor/extensions/markdown/plugins/frontmatter.ts b/frontend/src/views/editor/extensions/markdown/plugins/frontmatter.ts deleted file mode 100644 index d2d041a..0000000 --- a/frontend/src/views/editor/extensions/markdown/plugins/frontmatter.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { parseMixed } from '@lezer/common'; -import { yaml } from '@codemirror/legacy-modes/mode/yaml'; -import { Element, MarkdownExtension } from '@lezer/markdown'; -import { foldInside, foldNodeProp, StreamLanguage } from '@codemirror/language'; -import { styleTags, tags } from '@lezer/highlight'; - -// A frontmatter fence usually consists of a seperator used three times. -// These can be: ---, +++, ===, etc. -// FIXME: make this configurable -const frontMatterFence = /^---\s*$/m; - -/** - * Ixora frontmatter plugin for Markdown. - */ -export const frontmatter: MarkdownExtension = { - defineNodes: [{ name: 'Frontmatter', block: true }, 'FrontmatterMark'], - props: [ - styleTags({ - Frontmatter: [tags.documentMeta, tags.monospace], - FrontmatterMark: tags.processingInstruction - }), - foldNodeProp.add({ - Frontmatter: foldInside, - // Marks don't need to be folded - FrontmatterMark: () => null - }) - ], - wrap: parseMixed((node) => { - const { parser } = StreamLanguage.define(yaml); - if (node.type.name === 'Frontmatter') { - return { - parser, - overlay: [{ from: node.from + 4, to: node.to - 4 }] - }; - } else { - return null; - } - }), - parseBlock: [ - { - name: 'Fronmatter', - before: 'HorizontalRule', - parse: (cx, line) => { - let end: number = 0; - const children = new Array(); - if (cx.lineStart === 0 && frontMatterFence.test(line.text)) { - // 4 is the length of the frontmatter fence (---\n). - children.push(cx.elt('FrontmatterMark', 0, 4)); - while (cx.nextLine()) { - if (frontMatterFence.test(line.text)) { - end = cx.lineStart + 4; - break; - } - } - if (end > 0) { - children.push(cx.elt('FrontmatterMark', end - 4, end)); - cx.addElement(cx.elt('Frontmatter', 0, end, children)); - } - return true; - } else { - return false; - } - } - } - ] -}; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/image.ts b/frontend/src/views/editor/extensions/markdown/plugins/image.ts index 0a4bd9c..7be6a1e 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/image.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/image.ts @@ -116,19 +116,27 @@ const imageHoverTooltip = hoverTooltip( arrow: true, create: () => { const dom = document.createElement('div'); - dom.className = 'cm-image-tooltip'; + dom.className = 'cm-image-tooltip cm-image-loading'; + + const spinner = document.createElement('span'); + spinner.className = 'cm-image-spinner'; const imgEl = document.createElement('img'); imgEl.src = img.src; imgEl.alt = img.alt; + imgEl.onload = () => { + dom.classList.remove('cm-image-loading'); + }; imgEl.onerror = () => { + spinner.remove(); imgEl.remove(); dom.textContent = 'Failed to load image'; + dom.classList.remove('cm-image-loading'); dom.classList.add('cm-image-tooltip-error'); }; - dom.append(imgEl); + dom.append(spinner, imgEl); return { dom }; } }; @@ -150,15 +158,50 @@ const theme = EditorView.baseTheme({ }, '.cm-image-indicator:hover': { opacity: '1' }, '.cm-image-tooltip': { - background: 'var(--bg-secondary)', + position: 'relative', + background: ` + linear-gradient(45deg, #e0e0e0 25%, transparent 25%), + linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #e0e0e0 75%), + linear-gradient(-45deg, transparent 75%, #e0e0e0 75%) + `, + backgroundColor: '#fff', + backgroundSize: '12px 12px', + backgroundPosition: '0 0, 0 6px, 6px -6px, -6px 0px', border: '1px solid var(--border-color)', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', '& img': { display: 'block', maxWidth: '60vw', - maxHeight: '50vh' + maxHeight: '50vh', + opacity: '1', + transition: 'opacity 0.15s ease-out' } }, + '.cm-image-loading': { + minWidth: '48px', + minHeight: '48px', + '& img': { opacity: '0' } + }, + '.cm-image-spinner': { + position: 'absolute', + top: '50%', + left: '50%', + width: '16px', + height: '16px', + marginTop: '-8px', + marginLeft: '-8px', + border: '2px solid #ccc', + borderTopColor: '#666', + borderRadius: '50%', + animation: 'cm-spin 0.5s linear infinite' + }, + '.cm-image-tooltip:not(.cm-image-loading) .cm-image-spinner': { + display: 'none' + }, + '@keyframes cm-spin': { + to: { transform: 'rotate(360deg)' } + }, '.cm-image-tooltip-error': { padding: '16px 24px', fontSize: '12px', @@ -169,8 +212,8 @@ const theme = EditorView.baseTheme({ borderBottomColor: 'var(--border-color) !important' }, '.cm-tooltip-arrow:after': { - borderTopColor: 'var(--bg-secondary) !important', - borderBottomColor: 'var(--bg-secondary) !important' + borderTopColor: '#fff !important', + borderBottomColor: '#fff !important' } }); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/insert.ts b/frontend/src/views/editor/extensions/markdown/plugins/insert.ts new file mode 100644 index 0000000..c5e42d3 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/insert.ts @@ -0,0 +1,114 @@ +import { Extension, Range } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; +import { + ViewPlugin, + DecorationSet, + Decoration, + EditorView, + ViewUpdate +} from '@codemirror/view'; +import { isCursorInRange, invisibleDecoration } from '../util'; + +/** + * Insert plugin using syntax tree. + * + * Uses the custom Insert extension to detect: + * - Insert: ++text++ → renders as inserted text (underline) + * + * Examples: + * - This is ++inserted++ text → This is inserted text + * - Please ++review this section++ carefully + */ +export const insert = (): Extension => [ + insertPlugin, + baseTheme +]; + +/** + * Build decorations for insert using syntax tree. + */ +function buildDecorations(view: EditorView): DecorationSet { + const decorations: Range[] = []; + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { + // Handle Insert nodes + if (type.name === 'Insert') { + const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); + + // Get the mark nodes (the ++ characters) + const marks = node.getChildren('InsertMark'); + + if (!cursorInRange && marks.length >= 2) { + // Hide the opening and closing ++ marks + decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to)); + decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to)); + + // Apply insert style to the content between marks + const contentStart = marks[0].to; + const contentEnd = marks[marks.length - 1].from; + if (contentStart < contentEnd) { + decorations.push( + Decoration.mark({ + class: 'cm-insert' + }).range(contentStart, contentEnd) + ); + } + } + } + } + }); + } + + return Decoration.set(decorations, true); +} + +/** + * Plugin class with optimized update detection. + */ +class InsertPlugin { + 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 insertPlugin = ViewPlugin.fromClass( + InsertPlugin, + { + decorations: (v) => v.decorations + } +); + +/** + * Base theme for insert. + * Uses underline decoration for inserted text. + */ +const baseTheme = EditorView.baseTheme({ + '.cm-insert': { + textDecoration: 'underline', + } +}); + diff --git a/frontend/src/views/editor/extensions/markdown/plugins/math.ts b/frontend/src/views/editor/extensions/markdown/plugins/math.ts new file mode 100644 index 0000000..f8f258d --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/math.ts @@ -0,0 +1,358 @@ +/** + * 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 + */ + +import { Extension, Range } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; +import { + ViewPlugin, + DecorationSet, + Decoration, + EditorView, + ViewUpdate, + WidgetType +} from '@codemirror/view'; +import katex from 'katex'; +import 'katex/dist/katex.min.css'; +import { isCursorInRange, invisibleDecoration } from '../util'; + +// ============================================================================ +// Inline Math Widget +// ============================================================================ + +/** + * Widget to display inline math formula. + */ +class InlineMathWidget extends WidgetType { + private html: string; + private error: string | null = null; + + constructor(readonly latex: string) { + super(); + try { + this.html = katex.renderToString(latex, { + throwOnError: true, + displayMode: false, + output: 'html' + }); + } catch (e) { + this.error = e instanceof Error ? e.message : 'Render error'; + this.html = ''; + } + } + + toDOM(): HTMLElement { + const span = document.createElement('span'); + span.className = 'cm-inline-math'; + + if (this.error) { + span.textContent = this.latex; + span.title = this.error; + } else { + span.innerHTML = this.html; + } + + return span; + } + + eq(other: InlineMathWidget): boolean { + return this.latex === other.latex; + } + + ignoreEvent(): boolean { + return false; + } +} + +// ============================================================================ +// Block Math Widget +// ============================================================================ + +/** + * Widget to display block math formula. + * Uses absolute positioning to overlay on source lines. + */ +class BlockMathWidget extends WidgetType { + private html: string; + private error: string | null = null; + + constructor( + readonly latex: string, + readonly lineCount: number = 1, + readonly lineHeight: number = 22 + ) { + super(); + try { + this.html = katex.renderToString(latex, { + throwOnError: false, + displayMode: true, + output: 'html' + }); + } catch (e) { + this.error = e instanceof Error ? e.message : 'Render error'; + this.html = ''; + } + } + + toDOM(): HTMLElement { + 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`; + + const inner = document.createElement('div'); + inner.className = 'cm-block-math'; + + if (this.error) { + inner.textContent = this.latex; + inner.title = this.error; + } else { + inner.innerHTML = this.html; + } + + container.appendChild(inner); + return container; + } + + eq(other: BlockMathWidget): boolean { + return this.latex === other.latex && this.lineCount === other.lineCount; + } + + ignoreEvent(): boolean { + return false; + } +} + +// ============================================================================ +// Decorations +// ============================================================================ + +/** + * Build decorations for math formulas. + */ +function buildDecorations(view: EditorView): DecorationSet { + const decorations: Range[] = []; + + 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') { + 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') { + 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 Decoration.set(decorations, true); +} + +// ============================================================================ +// Plugin +// ============================================================================ + +/** + * Math plugin with optimized update detection. + */ +class MathPlugin { + 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 mathPlugin = ViewPlugin.fromClass( + MathPlugin, + { + decorations: (v) => v.decorations + } +); + +// ============================================================================ +// Theme +// ============================================================================ + +/** + * Base theme for math. + */ +const baseTheme = EditorView.baseTheme({ + // Inline math + '.cm-inline-math': { + display: 'inline', + verticalAlign: 'baseline', + }, + '.cm-inline-math .katex': { + fontSize: 'inherit', + }, + + // Block math container - absolute positioned to overlay on source + '.cm-block-math-container': { + position: 'absolute', + left: '0', + right: '0', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + pointerEvents: 'none', + zIndex: '1', + }, + + // Block math inner + '.cm-block-math': { + display: 'inline-block', + textAlign: 'center', + pointerEvents: 'auto', + }, + '.cm-block-math .katex-display': { + margin: '0', + }, + '.cm-block-math .katex': { + 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', + }, + '.cm-line.cm-block-math-line span': { + color: 'transparent !important', + }, + // Override rainbow brackets in hidden math lines + '.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': { + color: 'transparent !important', + }, + + // Hidden content for single-line block math + '.cm-block-math-content-hidden': { + color: 'transparent !important', + }, + '.cm-block-math-content-hidden span': { + color: 'transparent !important', + }, + '.cm-block-math-content-hidden [class*="cm-rainbow-bracket"]': { + color: 'transparent !important', + }, +}); + +// ============================================================================ +// Export +// ============================================================================ + +/** + * 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/state/table.ts b/frontend/src/views/editor/extensions/markdown/state/table.ts deleted file mode 100644 index 13ea099..0000000 --- a/frontend/src/views/editor/extensions/markdown/state/table.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { syntaxTree } from '@codemirror/language'; -import { EditorState } from '@codemirror/state'; - -/** - * Basic table information extracted from syntax tree. - */ -export interface TableInfo { - /** Starting position in document */ - from: number; - /** End position in document */ - to: number; - /** Raw markdown text */ - rawText: string; -} - -/** - * Extract all tables from the editor state. - */ -export function extractTablesFromState(state: EditorState): TableInfo[] { - const tables: TableInfo[] = []; - const seen = new Set(); - - syntaxTree(state).iterate({ - enter: ({ type, from, to }) => { - if (type.name !== 'Table') return; - - // Deduplicate - const key = `${from}:${to}`; - if (seen.has(key)) return; - seen.add(key); - - const rawText = state.doc.sliceString(from, to); - - // Need at least 2 lines (header + delimiter) - if (rawText.split('\n').length < 2) return; - - tables.push({ from, to, rawText }); - } - }); - - return tables; -} diff --git a/frontend/src/views/editor/extensions/markdown/syntax/footnote.ts b/frontend/src/views/editor/extensions/markdown/syntax/footnote.ts index 0f914a6..d3b7536 100644 --- a/frontend/src/views/editor/extensions/markdown/syntax/footnote.ts +++ b/frontend/src/views/editor/extensions/markdown/syntax/footnote.ts @@ -14,33 +14,173 @@ * - This is text^[inline footnote content] with inline footnote. */ -import { MarkdownConfig, Line, BlockContext } from '@lezer/markdown'; +import { MarkdownConfig, Line, BlockContext, InlineContext } from '@lezer/markdown'; +import { CharCode, isFootnoteIdChar } from '../util'; /** - * ASCII character codes for parsing. + * Parse inline footnote ^[content]. + * + * @param cx - Inline context + * @param pos - Start position (at ^) + * @returns Position after element, or -1 if no match */ -const enum Ch { - OpenBracket = 91, // [ - CloseBracket = 93, // ] - Caret = 94, // ^ - Colon = 58, // : - Space = 32, - Tab = 9, - Newline = 10, +function parseInlineFootnote(cx: InlineContext, pos: number): number { + const end = cx.end; + + // Minimum: ^[ + content + ] = at least 4 chars + if (end < pos + 3) return -1; + + // Track bracket depth for nested brackets + let bracketDepth = 1; + let hasContent = false; + const contentStart = pos + 2; + + for (let i = contentStart; i < end; i++) { + const char = cx.char(i); + + // Don't allow newlines + if (char === CharCode.Newline) return -1; + + // Track bracket depth + if (char === CharCode.OpenBracket) { + bracketDepth++; + } else if (char === CharCode.CloseBracket) { + bracketDepth--; + if (bracketDepth === 0) { + // Found closing bracket - must have content + if (!hasContent) return -1; + + // Create element with marks and content + return cx.addElement(cx.elt('InlineFootnote', pos, i + 1, [ + cx.elt('InlineFootnoteMark', pos, contentStart), + cx.elt('InlineFootnoteContent', contentStart, i), + cx.elt('InlineFootnoteMark', i, i + 1) + ])); + } + } else { + hasContent = true; + } + } + + return -1; } /** - * Check if a character is valid for footnote ID. - * Allows: letters, numbers, underscore, hyphen + * Parse footnote reference [^id]. + * + * @param cx - Inline context + * @param pos - Start position (at [) + * @returns Position after element, or -1 if no match */ -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 // - - ); +function parseFootnoteReference(cx: InlineContext, pos: number): number { + const end = cx.end; + + // Minimum: [^ + id + ] = at least 4 chars + if (end < pos + 3) return -1; + + let hasValidId = false; + const labelStart = pos + 2; + + for (let i = labelStart; i < end; i++) { + const char = cx.char(i); + + // Found closing bracket + if (char === CharCode.CloseBracket) { + if (!hasValidId) return -1; + + // Create element with marks and label + return cx.addElement(cx.elt('FootnoteReference', pos, i + 1, [ + cx.elt('FootnoteReferenceMark', pos, labelStart), + cx.elt('FootnoteReferenceLabel', labelStart, i), + cx.elt('FootnoteReferenceMark', i, i + 1) + ])); + } + + // Don't allow newlines + if (char === CharCode.Newline) return -1; + + // Validate id character using O(1) lookup table + if (isFootnoteIdChar(char)) { + hasValidId = true; + } else { + return -1; + } + } + + return -1; +} + +/** + * Parse footnote definition [^id]: content. + * + * @param cx - Block context + * @param line - Current line + * @returns True if parsed successfully + */ +function parseFootnoteDefinition(cx: BlockContext, line: Line): boolean { + const text = line.text; + const len = text.length; + + // Minimum: [^id]: = at least 5 chars + if (len < 5) return false; + + // Find ]: pattern - use O(1) lookup for ID chars + let labelEnd = 2; + while (labelEnd < len) { + const char = text.charCodeAt(labelEnd); + + if (char === CharCode.CloseBracket) { + // Check for : after ] + if (labelEnd + 1 < len && text.charCodeAt(labelEnd + 1) === CharCode.Colon) { + break; + } + return false; + } + + // Use O(1) lookup table + if (!isFootnoteIdChar(char)) return false; + + labelEnd++; + } + + // Validate ]: was found + if (labelEnd >= len || + text.charCodeAt(labelEnd) !== CharCode.CloseBracket || + text.charCodeAt(labelEnd + 1) !== CharCode.Colon) { + return false; + } + + // Calculate positions (all at once to avoid repeated arithmetic) + const start = cx.lineStart; + const openMarkEnd = start + 2; + const labelEndPos = start + labelEnd; + const closeMarkEnd = start + labelEnd + 2; + + // Skip optional space after : + let contentOffset = labelEnd + 2; + if (contentOffset < len) { + const spaceChar = text.charCodeAt(contentOffset); + if (spaceChar === CharCode.Space || spaceChar === CharCode.Tab) { + contentOffset++; + } + } + + // Build children array + const children = [ + cx.elt('FootnoteDefinitionMark', start, openMarkEnd), + cx.elt('FootnoteDefinitionLabel', openMarkEnd, labelEndPos), + cx.elt('FootnoteDefinitionMark', labelEndPos, closeMarkEnd) + ]; + + // Add content if present + if (contentOffset < len) { + children.push(cx.elt('FootnoteDefinitionContent', start + contentOffset, start + len)); + } + + // Create and add block element + cx.addElement(cx.elt('FootnoteDefinition', start, start + len, children)); + cx.nextLine(); + return true; } /** @@ -76,119 +216,26 @@ export const Footnote: MarkdownConfig = { ], 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) { + // Fast path: must start with ^[ + if (next !== CharCode.Caret || cx.char(pos + 1) !== CharCode.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; + return parseInlineFootnote(cx, pos); }, - // 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) { + // Fast path: must start with [^ + if (next !== CharCode.OpenBracket || cx.char(pos + 1) !== CharCode.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; + return parseFootnoteReference(cx, pos); }, - // Parse before links to avoid conflicts before: 'Link', }, ], @@ -197,90 +244,16 @@ export const Footnote: MarkdownConfig = { { 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) { + // Fast path: must start with [^ + if (line.text.charCodeAt(0) !== CharCode.OpenBracket || + line.text.charCodeAt(1) !== CharCode.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; + return parseFootnoteDefinition(cx, line); }, - // Parse before other block elements before: 'LinkReference', }, ], }; export default Footnote; - diff --git a/frontend/src/views/editor/extensions/markdown/syntax/highlight.ts b/frontend/src/views/editor/extensions/markdown/syntax/highlight.ts index f340d81..d20bc15 100644 --- a/frontend/src/views/editor/extensions/markdown/syntax/highlight.ts +++ b/frontend/src/views/editor/extensions/markdown/syntax/highlight.ts @@ -10,10 +10,10 @@ */ import { MarkdownConfig } from '@lezer/markdown'; +import { CharCode, createPairedDelimiterParser } from '../util'; /** * Highlight extension for Lezer Markdown. - * * Defines: * - Highlight: The container node for highlighted content * - HighlightMark: The == delimiter marks @@ -23,50 +23,16 @@ export const Highlight: MarkdownConfig = { { name: 'Highlight' }, { name: 'HighlightMark' } ], - parseInline: [{ - name: 'Highlight', - parse(cx, next, pos) { - // Check for == delimiter (= is ASCII 61) - if (next !== 61 || cx.char(pos + 1) !== 61) { - return -1; - } - - // Don't match === or more (horizontal rule or other constructs) - if (cx.char(pos + 2) === 61) { - return -1; - } - - // Look for closing == delimiter - for (let i = pos + 2; i < cx.end - 1; i++) { - const char = cx.char(i); - - // Don't allow newlines within highlight - if (char === 10) { // newline - return -1; - } - - // Found potential closing == - if (char === 61 && cx.char(i + 1) === 61) { - // Make sure it's not === - if (i + 2 < cx.end && cx.char(i + 2) === 61) { - continue; - } - - // Create the element with marks - const element = cx.elt('Highlight', pos, i + 2, [ - cx.elt('HighlightMark', pos, pos + 2), - cx.elt('HighlightMark', i, i + 2) - ]); - return cx.addElement(element); - } - } - - return -1; - }, - // Parse after emphasis to avoid conflicts with other inline parsers - after: 'Emphasis' - }] + parseInline: [ + createPairedDelimiterParser({ + name: 'Highlight', + nodeName: 'Highlight', + markName: 'HighlightMark', + delimChar: CharCode.Equal, + isDouble: true, + after: 'Emphasis' + }) + ] }; export default Highlight; - diff --git a/frontend/src/views/editor/extensions/markdown/syntax/insert.ts b/frontend/src/views/editor/extensions/markdown/syntax/insert.ts new file mode 100644 index 0000000..db0268a --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/syntax/insert.ts @@ -0,0 +1,41 @@ +/** + * Insert extension for Lezer Markdown parser. + * + * Parses ++insert++ syntax for inserted/underlined text. + * + * Syntax: ++text++ → renders as inserted text (underline) + * + * Example: + * - This is ++inserted++ text → This is inserted text + */ + +import { MarkdownConfig } from '@lezer/markdown'; +import { CharCode, createPairedDelimiterParser } from '../util'; + +/** + * Insert extension for Lezer Markdown. + * + * Uses optimized factory function for O(n) single-pass parsing. + * + * Defines: + * - Insert: The container node for inserted content + * - InsertMark: The ++ delimiter marks + */ +export const Insert: MarkdownConfig = { + defineNodes: [ + { name: 'Insert' }, + { name: 'InsertMark' } + ], + parseInline: [ + createPairedDelimiterParser({ + name: 'Insert', + nodeName: 'Insert', + markName: 'InsertMark', + delimChar: CharCode.Plus, + isDouble: true, + after: 'Emphasis' + }) + ] +}; + +export default Insert; diff --git a/frontend/src/views/editor/extensions/markdown/syntax/math.ts b/frontend/src/views/editor/extensions/markdown/syntax/math.ts new file mode 100644 index 0000000..b6f2548 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/syntax/math.ts @@ -0,0 +1,146 @@ +/** + * Math extension for Lezer Markdown parser. + * + * Parses LaTeX math syntax: + * - Inline math: $E=mc^2$ → renders as inline formula + * - Block math: $$...$$ → renders as block formula (can be multi-line) + */ + +import { MarkdownConfig, InlineContext } from '@lezer/markdown'; +import { CharCode } from '../util'; + +/** + * Parse block math ($$...$$). + * Allows multi-line content and handles escaped $. + * + * @param cx - Inline context + * @param pos - Start position (at first $) + * @returns Position after element, or -1 if no match + */ +function parseBlockMath(cx: InlineContext, pos: number): number { + const end = cx.end; + + // Don't match $$$ or more + if (cx.char(pos + 2) === CharCode.Dollar) return -1; + + // Minimum: $$ + content + $$ = at least 5 chars + const minEnd = pos + 4; + if (end < minEnd) return -1; + + // Search for closing $$ + const searchEnd = end - 1; + for (let i = pos + 2; i < searchEnd; i++) { + const char = cx.char(i); + + // Skip escaped $ (backslash followed by any char) + if (char === CharCode.Backslash) { + i++; // Skip next char + continue; + } + + // Found potential closing $$ + if (char === CharCode.Dollar) { + const nextChar = cx.char(i + 1); + if (nextChar !== CharCode.Dollar) continue; + + // Don't match $$$ + if (i + 2 < end && cx.char(i + 2) === CharCode.Dollar) continue; + + // Ensure content exists + if (i === pos + 2) return -1; + + // Create element with marks + return cx.addElement(cx.elt('BlockMath', pos, i + 2, [ + cx.elt('BlockMathMark', pos, pos + 2), + cx.elt('BlockMathMark', i, i + 2) + ])); + } + } + + return -1; +} + +/** + * Parse inline math ($...$). + * Single line only, handles escaped $. + * + * @param cx - Inline context + * @param pos - Start position (at $) + * @returns Position after element, or -1 if no match + */ +function parseInlineMath(cx: InlineContext, pos: number): number { + const end = cx.end; + + // Don't match if preceded by backslash (escaped) + if (pos > 0 && cx.char(pos - 1) === CharCode.Backslash) return -1; + + // Minimum: $ + content + $ = at least 3 chars + if (end < pos + 2) return -1; + + // Search for closing $ + for (let i = pos + 1; i < end; i++) { + const char = cx.char(i); + + // Newline not allowed in inline math + if (char === CharCode.Newline) return -1; + + // Skip escaped $ + if (char === CharCode.Backslash && i + 1 < end && cx.char(i + 1) === CharCode.Dollar) { + i++; // Skip next char + continue; + } + + // Found potential closing $ + if (char === CharCode.Dollar) { + // Don't match $$ + if (i + 1 < end && cx.char(i + 1) === CharCode.Dollar) continue; + + // Ensure content exists + if (i === pos + 1) return -1; + + // Create element with marks + return cx.addElement(cx.elt('InlineMath', pos, i + 1, [ + cx.elt('InlineMathMark', pos, pos + 1), + cx.elt('InlineMathMark', i, i + 1) + ])); + } + } + + return -1; +} + +/** + * Math extension for Lezer Markdown. + * + * Defines: + * - InlineMath: Inline math formula $...$ + * - InlineMathMark: The $ delimiter marks for inline + * - BlockMath: Block math formula $$...$$ + * - BlockMathMark: The $$ delimiter marks for block + */ +export const Math: MarkdownConfig = { + defineNodes: [ + { name: 'InlineMath' }, + { name: 'InlineMathMark' }, + { name: 'BlockMath' }, + { name: 'BlockMathMark' } + ], + parseInline: [ + { + name: 'Math', + parse(cx, next, pos) { + // Fast path: must start with $ + if (next !== CharCode.Dollar) return -1; + + // Check for $$ (block math) vs $ (inline math) + const isBlock = cx.char(pos + 1) === CharCode.Dollar; + + return isBlock ? parseBlockMath(cx, pos) : parseInlineMath(cx, pos); + }, + // Parse after emphasis to avoid conflicts + after: 'Emphasis' + } + ] +}; + +export default Math; diff --git a/frontend/src/views/editor/extensions/markdown/util.ts b/frontend/src/views/editor/extensions/markdown/util.ts index c46ac7f..2fb2a86 100644 --- a/frontend/src/views/editor/extensions/markdown/util.ts +++ b/frontend/src/views/editor/extensions/markdown/util.ts @@ -1,34 +1,133 @@ -import { foldedRanges, syntaxTree } from '@codemirror/language'; -import type { SyntaxNodeRef, TreeCursor } from '@lezer/common'; -import { Decoration, EditorView } from '@codemirror/view'; -import { - EditorState, - SelectionRange, - CharCategory, - findClusterBreak -} from '@codemirror/state'; - -// ============================================================================ -// Type Definitions (ProseMark style) -// ============================================================================ +import { Decoration } from '@codemirror/view'; +import { EditorState } from '@codemirror/state'; +import type { InlineContext, InlineParser } from '@lezer/markdown'; /** - * A range-like object with from and to properties. + * ASCII character codes for common delimiters. */ -export interface RangeLike { - from: number; - to: number; +export const enum CharCode { + Space = 32, + Tab = 9, + Newline = 10, + Backslash = 92, + Dollar = 36, // $ + Plus = 43, // + + Equal = 61, // = + OpenBracket = 91, // [ + CloseBracket = 93, // ] + Caret = 94, // ^ + Colon = 58, // : + Hyphen = 45, // - + Underscore = 95, // _ } +/** + * Pre-computed lookup table for footnote ID characters. + * Valid characters: 0-9, A-Z, a-z, _, - + * Uses Uint8Array for memory efficiency and O(1) lookup. + */ +const FOOTNOTE_ID_CHARS = new Uint8Array(128); +// Initialize lookup table (0-9: 48-57, A-Z: 65-90, a-z: 97-122, _: 95, -: 45) +for (let i = 48; i <= 57; i++) FOOTNOTE_ID_CHARS[i] = 1; // 0-9 +for (let i = 65; i <= 90; i++) FOOTNOTE_ID_CHARS[i] = 1; // A-Z +for (let i = 97; i <= 122; i++) FOOTNOTE_ID_CHARS[i] = 1; // a-z +FOOTNOTE_ID_CHARS[95] = 1; // _ +FOOTNOTE_ID_CHARS[45] = 1; // - + +/** + * O(1) check if a character is valid for footnote ID. + * @param code - ASCII character code + * @returns True if valid footnote ID character + */ +export function isFootnoteIdChar(code: number): boolean { + return code < 128 && FOOTNOTE_ID_CHARS[code] === 1; +} + +/** + * Configuration for paired delimiter parser factory. + */ +export interface PairedDelimiterConfig { + /** Parser name */ + name: string; + /** Node name for the container element */ + nodeName: string; + /** Node name for the delimiter marks */ + markName: string; + /** First delimiter character code */ + delimChar: number; + /** Whether delimiter is doubled (e.g., == vs =) */ + isDouble: true; + /** Whether to allow newlines in content */ + allowNewlines?: boolean; + /** Parse order - after which parser */ + after?: string; + /** Parse order - before which parser */ + before?: string; +} + +/** + * Factory function to create a paired delimiter inline parser. + * Optimized with: + * - Fast path early return + * - Minimal function calls in loop + * - Pre-computed delimiter length + * + * @param config - Parser configuration + * @returns InlineParser for MarkdownConfig + */ +export function createPairedDelimiterParser(config: PairedDelimiterConfig): InlineParser { + const { name, nodeName, markName, delimChar, allowNewlines = false, after, before } = config; + const delimLen = 2; // Always double delimiter for these parsers + + return { + name, + parse(cx: InlineContext, next: number, pos: number): number { + // Fast path: check first character + if (next !== delimChar) return -1; + + // Check second delimiter character + if (cx.char(pos + 1) !== delimChar) return -1; + + // Don't match triple delimiter (e.g., ===, +++) + if (cx.char(pos + 2) === delimChar) return -1; + + // Calculate search bounds + const searchEnd = cx.end - 1; + const contentStart = pos + delimLen; + + // Look for closing delimiter + for (let i = contentStart; i < searchEnd; i++) { + const char = cx.char(i); + + // Check for newline (unless allowed) + if (!allowNewlines && char === CharCode.Newline) return -1; + + // Found potential closing delimiter + if (char === delimChar && cx.char(i + 1) === delimChar) { + // Don't match triple delimiter + if (i + 2 < cx.end && cx.char(i + 2) === delimChar) continue; + + // Create element with marks + return cx.addElement(cx.elt(nodeName, pos, i + delimLen, [ + cx.elt(markName, pos, contentStart), + cx.elt(markName, i, i + delimLen) + ])); + } + } + + return -1; + }, + ...(after && { after }), + ...(before && { before }) + }; +} + + /** * Tuple representation of a range [from, to]. */ export type RangeTuple = [number, number]; -// ============================================================================ -// Range Utilities -// ============================================================================ - /** * Check if two ranges overlap (touch or intersect). * Based on the visual diagram on https://stackoverflow.com/a/25369187 @@ -44,46 +143,6 @@ export function checkRangeOverlap( return range1[0] <= range2[1] && range2[0] <= range1[1]; } -/** - * Check if two range-like objects touch or overlap. - * ProseMark-style range comparison. - * - * @param a - First range - * @param b - Second range - * @returns True if ranges touch - */ -export function rangeTouchesRange(a: RangeLike, b: RangeLike): boolean { - return a.from <= b.to && b.from <= a.to; -} - -/** - * Check if a selection touches a range. - * - * @param selection - Array of selection ranges - * @param range - Range to check against - * @returns True if any selection touches the range - */ -export function selectionTouchesRange( - selection: readonly SelectionRange[], - range: RangeLike -): boolean { - return selection.some((sel) => rangeTouchesRange(sel, range)); -} - -/** - * Check if a range is inside another range (subset). - * - * @param parent - Parent (bigger) range - * @param child - Child (smaller) range - * @returns True if child is inside parent - */ -export function checkRangeSubset( - parent: RangeTuple, - child: RangeTuple -): boolean { - return child[0] >= parent[0] && child[1] <= parent[1]; -} - /** * Check if any of the editor cursors is in the given range. * @@ -100,159 +159,12 @@ export function isCursorInRange( ); } -// ============================================================================ -// Tree Iteration Utilities -// ============================================================================ - -/** - * Iterate over the syntax tree in the visible ranges of the document. - * - * @param view - Editor view - * @param iterateFns - Object with `enter` and `leave` iterate function - */ -export function iterateTreeInVisibleRanges( - view: EditorView, - iterateFns: { - enter(node: SyntaxNodeRef): boolean | void; - leave?(node: SyntaxNodeRef): void; - } -): void { - for (const { from, to } of view.visibleRanges) { - syntaxTree(view.state).iterate({ ...iterateFns, from, to }); - } -} - -/** - * Iterate through child nodes of a cursor. - * ProseMark-style tree traversal. - * - * @param cursor - Tree cursor to iterate - * @param enter - Callback function, return true to stop iteration - */ -export function iterChildren( - cursor: TreeCursor, - enter: (cursor: TreeCursor) => boolean | undefined -): void { - if (!cursor.firstChild()) return; - do { - if (enter(cursor)) break; - } while (cursor.nextSibling()); - cursor.parent(); -} - -// ============================================================================ -// Line Utilities -// ============================================================================ - -/** - * Returns the lines of the editor that are in the given range and not folded. - * This function is useful for adding line decorations to each line of a block node. - * - * @param view - Editor view - * @param from - Start of the range - * @param to - End of the range - * @returns A list of line blocks that are in the range - */ -export function editorLines( - view: EditorView, - from: number, - to: number -) { - let lines = view.viewportLineBlocks.filter((block) => - checkRangeOverlap([block.from, block.to], [from, to]) - ); - - const folded = foldedRanges(view.state).iter(); - while (folded.value) { - lines = lines.filter( - (line) => - !checkRangeOverlap( - [folded.from, folded.to], - [line.from, line.to] - ) - ); - folded.next(); - } - - return lines; -} - -/** - * Get line numbers for a range. - * - * @param state - Editor state - * @param from - Start position - * @param to - End position - * @returns Array of line numbers - */ -export function getLineNumbers( - state: EditorState, - from: number, - to: number -): number[] { - const startLine = state.doc.lineAt(from).number; - const endLine = state.doc.lineAt(to).number; - const lines: number[] = []; - - for (let i = startLine; i <= endLine; i++) { - lines.push(i); - } - - return lines; -} - -// ============================================================================ -// Word Utilities (ProseMark style) -// ============================================================================ - -/** - * Get the "WORD" at a position (vim-style WORD, including non-whitespace). - * - * @param state - Editor state - * @param pos - Position in document - * @returns Selection range of the WORD, or null if at whitespace - */ -export function stateWORDAt( - state: EditorState, - pos: number -): SelectionRange | null { - const { text, from, length } = state.doc.lineAt(pos); - const cat = state.charCategorizer(pos); - let start = pos - from; - let end = pos - from; - - while (start > 0) { - const prev = findClusterBreak(text, start, false); - if (cat(text.slice(prev, start)) === CharCategory.Space) break; - start = prev; - } - - while (end < length) { - const next = findClusterBreak(text, end); - if (cat(text.slice(end, next)) === CharCategory.Space) break; - end = next; - } - - return start === end - ? null - : { from: start + from, to: end + from } as SelectionRange; -} - -// ============================================================================ -// Decoration Utilities -// ============================================================================ - /** * Decoration to simply hide anything (replace with nothing). */ export const invisibleDecoration = Decoration.replace({}); - -// ============================================================================ -// Slug Generation -// ============================================================================ - /** * Class for generating unique slugs from heading contents. */ @@ -288,5 +200,3 @@ export class Slugger { this.occurrences.clear(); } } - -