diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 53915bda..43fe336f 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -120,6 +120,7 @@ import { mathBlockArrowKeymap } from '../lib/cm-math-nav' import { slashCommandSource, slashCommandRender } from '../lib/cm-slash-commands' import { calloutTypeSource } from '../lib/cm-callouts' import { dateShortcutSource } from '../lib/cm-date-shortcuts' +import { latexCommandSource } from '../lib/cm-latex-completions' import { wikilinkSource, wikilinkHeadingSource, atNoteSource } from '../lib/cm-wikilinks' import { linkRangeAtCursor, markdownLinkAt } from '../lib/internal-links' import { setBlockType, toggleWrap, wrapLink } from '../lib/cm-format' @@ -1779,6 +1780,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { slashCommandSource, calloutTypeSource, dateShortcutSource, + latexCommandSource, atNoteSource, hashtagSource, wikilinkSource, diff --git a/packages/app-core/src/lib/cm-latex-completions.test.ts b/packages/app-core/src/lib/cm-latex-completions.test.ts new file mode 100644 index 00000000..b3287bc5 --- /dev/null +++ b/packages/app-core/src/lib/cm-latex-completions.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { EditorState } from '@codemirror/state' +import { markdown } from '@codemirror/lang-markdown' +import { isInMathContext, latexTokenBefore } from './cm-latex-completions' + +function state(doc: string): EditorState { + return EditorState.create({ doc, extensions: [markdown()] }) +} + +/** Position right after the given marker's first occurrence. */ +function after(doc: string, marker: string): number { + const idx = doc.indexOf(marker) + if (idx === -1) throw new Error(`marker ${marker} not found`) + return idx + marker.length +} + +describe('isInMathContext', () => { + it('detects inline math, including a formula still being typed', () => { + const closed = 'before $a + b$ after' + expect(isInMathContext(state(closed), after(closed, '$a + '))).toBe(true) + expect(isInMathContext(state(closed), after(closed, 'after'))).toBe(false) + expect(isInMathContext(state(closed), after(closed, 'before'))).toBe(false) + + const open = 'text $\\su' + expect(isInMathContext(state(open), open.length)).toBe(true) + }) + + it('detects block math across lines, closed or not', () => { + const closed = 'a\n$$\nx = y\n$$\nb' + expect(isInMathContext(state(closed), after(closed, 'x ='))).toBe(true) + expect(isInMathContext(state(closed), closed.length)).toBe(false) + + const open = 'a\n$$\nx =' + expect(isInMathContext(state(open), open.length)).toBe(true) + }) + + it('treats ```math fences as math, other fences as code', () => { + const mathFence = 'a\n```math\n\\su\n```\nb' + expect(isInMathContext(state(mathFence), after(mathFence, '\\su'))).toBe(true) + + const jsFence = 'a\n```js\nconst x = 1\n```\nb' + expect(isInMathContext(state(jsFence), after(jsFence, 'const x'))).toBe(false) + + const bareFence = 'a\n```\n\\su\n```\nb' + expect(isInMathContext(state(bareFence), after(bareFence, '\\su'))).toBe(false) + }) + + it('ignores escaped dollars and code regions', () => { + const escaped = 'price \\$5 and \\$6 end' + expect(isInMathContext(state(escaped), escaped.length)).toBe(false) + + const fenced = '```\n$a + b$\n```\ntext' + expect(isInMathContext(state(fenced), after(fenced, '$a + '))).toBe(false) + + const inlineCode = 'use `$HOME` now' + expect(isInMathContext(state(inlineCode), after(inlineCode, '`$HO'))).toBe(false) + }) +}) + +describe('latexTokenBefore', () => { + it('matches a backslash command prefix ending at the cursor', () => { + const doc = '$\\sum' + const token = latexTokenBefore(state(doc), doc.length) + expect(token).not.toBeNull() + expect(token!.query).toBe('sum') + expect(token!.from).toBe(1) + }) + + it('matches a bare backslash and rejects non-command contexts', () => { + const bare = '$x + \\' + expect(latexTokenBefore(state(bare), bare.length)!.query).toBe('') + + const rowBreak = '$a \\\\' + expect(latexTokenBefore(state(rowBreak), rowBreak.length)).toBeNull() + + const plain = '$x + y' + expect(latexTokenBefore(state(plain), plain.length)).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/cm-latex-completions.ts b/packages/app-core/src/lib/cm-latex-completions.ts new file mode 100644 index 00000000..86f355b9 --- /dev/null +++ b/packages/app-core/src/lib/cm-latex-completions.ts @@ -0,0 +1,279 @@ +/** + * LaTeX command completion for math regions: typing `\su` inside `$…$` or + * `$$…$$` pops KaTeX commands (`\sum`, `\sqrt`, …) with a rendered preview. + * Commands that take arguments insert as snippets, so accepting `\frac` + * lands the cursor in the numerator and Tab moves to the denominator. + * + * Math-region detection mirrors cm-math-render's delimiters, but stays a + * cheap unmatched-delimiter scan: while the user is mid-formula the closing + * `$` usually does not exist yet, which is exactly when completion matters. + */ +import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete' +import { snippet } from '@codemirror/autocomplete' +import { syntaxTree } from '@codemirror/language' +import type { EditorState } from '@codemirror/state' +import katex from 'katex' + +interface LatexCommand { + /** Command as typed, with the backslash: `\sum`. */ + label: string + detail: string + /** Snippet template when the command takes arguments. */ + template?: string + /** LaTeX rendered in the popup preview; defaults to the label. */ + preview?: string + /** Ranking bump for everyday commands. */ + boost?: number +} + +const g = (name: string): LatexCommand => ({ label: `\\${name}`, detail: 'greek' }) +const rel = (name: string): LatexCommand => ({ label: `\\${name}`, detail: 'relation' }) +const arr = (name: string): LatexCommand => ({ label: `\\${name}`, detail: 'arrow' }) +const bin = (name: string): LatexCommand => ({ label: `\\${name}`, detail: 'operator' }) +const fnc = (name: string): LatexCommand => ({ label: `\\${name}`, detail: 'function', preview: `\\${name} x` }) +const sym = (name: string, detail = 'symbol'): LatexCommand => ({ label: `\\${name}`, detail }) +const env = (name: string, inner: string): LatexCommand => ({ + label: `\\${name}`, + detail: 'environment', + template: `\\begin{${name}}\n\t\${}\n\\end{${name}}`, + preview: `\\begin{${name}}${inner}\\end{${name}}` +}) + +const LATEX_COMMANDS: LatexCommand[] = [ + // Everyday constructs, boosted to the top. + { label: '\\frac', detail: 'fraction', template: '\\frac{${}}{${}}', preview: '\\frac{a}{b}', boost: 99 }, + { label: '\\sqrt', detail: 'square root', template: '\\sqrt{${}}', preview: '\\sqrt{x}', boost: 98 }, + { label: '\\sum', detail: 'sum', template: '\\sum_{${i=1}}^{${n}}', preview: '\\sum_{i=1}^{n}', boost: 97 }, + { label: '\\int', detail: 'integral', template: '\\int_{${a}}^{${b}}', preview: '\\int_{a}^{b}', boost: 96 }, + { label: '\\lim', detail: 'limit', template: '\\lim_{${x \\to 0}}', preview: '\\lim_{x \\to 0}', boost: 95 }, + { label: '\\prod', detail: 'product', template: '\\prod_{${i=1}}^{${n}}', preview: '\\prod_{i=1}^{n}', boost: 90 }, + { label: '\\infty', detail: 'infinity', boost: 90 }, + { label: '\\sqrt[n]', detail: 'nth root', template: '\\sqrt[${}]{${}}', preview: '\\sqrt[n]{x}' }, + { label: '\\dfrac', detail: 'display fraction', template: '\\dfrac{${}}{${}}', preview: '\\dfrac{a}{b}' }, + { label: '\\binom', detail: 'binomial', template: '\\binom{${}}{${}}', preview: '\\binom{n}{k}' }, + + // Greek. + ...[ + 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'varepsilon', 'zeta', 'eta', 'theta', 'vartheta', + 'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'pi', 'rho', 'sigma', 'varsigma', 'tau', 'upsilon', + 'phi', 'varphi', 'chi', 'psi', 'omega', + 'Gamma', 'Delta', 'Theta', 'Lambda', 'Xi', 'Pi', 'Sigma', 'Upsilon', 'Phi', 'Psi', 'Omega' + ].map(g), + + // Big operators, scaffolded with their usual bounds like `\sum` above. + { label: '\\coprod', detail: 'big operator', template: '\\coprod_{${i=1}}^{${n}}', preview: '\\coprod_{i=1}^{n}' }, + { label: '\\iint', detail: 'big operator', template: '\\iint_{${D}}', preview: '\\iint_{D}' }, + { label: '\\iiint', detail: 'big operator', template: '\\iiint_{${V}}', preview: '\\iiint_{V}' }, + { label: '\\oint', detail: 'big operator', template: '\\oint_{${C}}', preview: '\\oint_{C}' }, + { label: '\\limsup', detail: 'big operator', template: '\\limsup_{${n \\to \\infty}}', preview: '\\limsup_{n \\to \\infty}' }, + { label: '\\liminf', detail: 'big operator', template: '\\liminf_{${n \\to \\infty}}', preview: '\\liminf_{n \\to \\infty}' }, + ...['bigcup', 'bigcap', 'bigoplus', 'bigotimes', 'bigsqcup', 'bigvee', 'bigwedge'].map( + (name): LatexCommand => ({ + label: `\\${name}`, + detail: 'big operator', + template: `\\${name}_{\${i}}`, + preview: `\\${name}_{i}` + }) + ), + + // Accents and decorations. + ...[ + ['hat', '\\hat{x}'], ['bar', '\\bar{x}'], ['vec', '\\vec{x}'], ['dot', '\\dot{x}'], ['ddot', '\\ddot{x}'], + ['tilde', '\\tilde{x}'], ['widehat', '\\widehat{xy}'], ['widetilde', '\\widetilde{xy}'], + ['overline', '\\overline{xy}'], ['underline', '\\underline{xy}'], + ['overbrace', '\\overbrace{xy}'], ['underbrace', '\\underbrace{xy}'], ['boxed', '\\boxed{x}'], + ['cancel', '\\cancel{x}'] + ].map(([name, preview]): LatexCommand => ({ + label: `\\${name}`, + detail: 'accent', + template: `\\${name}{\${}}`, + preview + })), + + // Fonts and text. + ...[ + ['text', '\\text{if}'], ['mathrm', '\\mathrm{d}'], ['mathbb', '\\mathbb{R}'], ['mathcal', '\\mathcal{L}'], + ['mathfrak', '\\mathfrak{g}'], ['mathbf', '\\mathbf{v}'], ['mathit', '\\mathit{x}'], + ['mathsf', '\\mathsf{A}'], ['mathtt', '\\mathtt{x}'], ['operatorname', '\\operatorname{op}'] + ].map(([name, preview]): LatexCommand => ({ + label: `\\${name}`, + detail: 'font', + template: `\\${name}{\${}}`, + preview + })), + + // Stacked constructs. + { label: '\\overset', detail: 'stack above', template: '\\overset{${}}{${}}', preview: '\\overset{!}{=}' }, + { label: '\\underset', detail: 'stack below', template: '\\underset{${}}{${}}', preview: '\\underset{n}{\\max}' }, + { label: '\\stackrel', detail: 'stack relation', template: '\\stackrel{${}}{${}}', preview: '\\stackrel{def}{=}' }, + { label: '\\substack', detail: 'stacked subscript', template: '\\substack{${}}', preview: '\\sum_{\\substack{i0 \\\\ b & x\\le 0'), + env('aligned', 'a &= b \\\\ &= c'), + env('gathered', 'a=b \\\\ c=d') +].flat() + +type CodeContext = { kind: 'inline' } | { kind: 'fenced'; lang: string } | null + +function codeContext(state: EditorState, pos: number): CodeContext { + let node = syntaxTree(state).resolveInner(pos, 1) + for (;;) { + const n = node.name + if (n === 'InlineCode') return { kind: 'inline' } + if (n === 'FencedCode' || n === 'CodeBlock') { + const info = node.getChild('CodeInfo') + const lang = info ? state.doc.sliceString(info.from, info.to).trim().toLowerCase() : '' + return { kind: 'fenced', lang } + } + if (!node.parent) return null + node = node.parent + } +} + +/** Inside `$…$`, `$$…$$`, or a ```math fence at `pos`? Counts unmatched + * dollar delimiters so a formula still being typed (no closing `$` yet) + * already counts as math. */ +export function isInMathContext(state: EditorState, pos: number): boolean { + const code = codeContext(state, pos) + // A ```math fence is a math region in its own right (remark-math renders + // it as display math); every other code region shuts completion off. + if (code) return code.kind === 'fenced' && code.lang === 'math' + const before = state.doc.sliceString(0, pos) + const blockFences = before.match(/(? + ({ + label: cmd.label, + detail: cmd.detail, + type: 'keyword', + boost: cmd.boost ?? 0, + _kind: 'latex', + _preview: cmd.preview ?? cmd.label, + apply: cmd.template ? snippet(cmd.template) : undefined + }) as Completion & { _kind: string; _preview: string } + ) + return cachedOptions +} + +export function latexCommandSource(context: CompletionContext): CompletionResult | null { + const token = latexTokenBefore(context.state, context.pos) + if (!token) return null + if (!isInMathContext(context.state, token.from)) return null + return { + from: token.from, + options: buildOptions(), + validFor: /^\\[a-zA-Z]*$/ + } +} + +/** Full option row for a LaTeX completion — the KaTeX-rendered symbol sits in + * the icon slot, then label and detail reuse the slash-command layout. Called + * first from the shared `renderCompletion`; null for every other kind. */ +export function renderLatexCompletion(completion: Completion): HTMLElement | null { + const { _kind, _preview } = completion as Completion & { _kind?: string; _preview?: string } + if (_kind !== 'latex') return null + + const el = document.createElement('div') + el.className = 'slash-cmd-item' + + const icon = document.createElement('span') + icon.className = 'slash-cmd-icon latex-cmd-icon' + icon.style.fontSize = '0.72em' + icon.style.lineHeight = '1' + icon.style.display = 'inline-flex' + icon.style.alignItems = 'center' + icon.style.justifyContent = 'center' + try { + icon.innerHTML = katex.renderToString(_preview ?? completion.label, { throwOnError: false }) + } catch { + icon.textContent = '' + } + + const label = document.createElement('span') + label.className = 'slash-cmd-label' + label.textContent = completion.label + + const detail = document.createElement('span') + detail.className = 'slash-cmd-detail' + detail.textContent = completion.detail ?? '' + + el.appendChild(icon) + el.appendChild(label) + el.appendChild(detail) + return el +} diff --git a/packages/app-core/src/lib/cm-slash-commands.ts b/packages/app-core/src/lib/cm-slash-commands.ts index d387975e..53bdcccf 100644 --- a/packages/app-core/src/lib/cm-slash-commands.ts +++ b/packages/app-core/src/lib/cm-slash-commands.ts @@ -1,6 +1,7 @@ import type { CompletionContext, CompletionResult, Completion } from '@codemirror/autocomplete' import type { EditorView } from '@codemirror/view' import { useStore } from '../store' +import { renderLatexCompletion } from './cm-latex-completions' interface SlashCmd { label: string @@ -67,6 +68,8 @@ const COMMANDS: SlashCmd[] = [ /** Render a custom completion item matching the app theme. */ function renderCompletion(completion: Completion): HTMLElement { + const latex = renderLatexCompletion(completion) + if (latex) return latex const decorated = completion as DecoratedCompletion if (decorated._kind === 'callout') { const el = document.createElement('div')