diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 9d21d6af..96781ddd 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -123,6 +123,7 @@ 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 { typstCommandSource } from '../lib/cm-typst-completions' import { wikilinkSource, wikilinkHeadingSource, @@ -1794,6 +1795,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { calloutTypeSource, dateShortcutSource, latexCommandSource, + typstCommandSource, atNoteSource, frontmatterTagSource, hashtagSource, diff --git a/packages/app-core/src/lib/cm-slash-commands.ts b/packages/app-core/src/lib/cm-slash-commands.ts index 53bdcccf..24a47f8e 100644 --- a/packages/app-core/src/lib/cm-slash-commands.ts +++ b/packages/app-core/src/lib/cm-slash-commands.ts @@ -2,6 +2,7 @@ import type { CompletionContext, CompletionResult, Completion } from '@codemirro import type { EditorView } from '@codemirror/view' import { useStore } from '../store' import { renderLatexCompletion } from './cm-latex-completions' +import { renderTypstCompletion } from './cm-typst-completions' interface SlashCmd { label: string @@ -70,6 +71,8 @@ const COMMANDS: SlashCmd[] = [ function renderCompletion(completion: Completion): HTMLElement { const latex = renderLatexCompletion(completion) if (latex) return latex + const typst = renderTypstCompletion(completion) + if (typst) return typst const decorated = completion as DecoratedCompletion if (decorated._kind === 'callout') { const el = document.createElement('div') diff --git a/packages/app-core/src/lib/cm-typst-completions.test.ts b/packages/app-core/src/lib/cm-typst-completions.test.ts new file mode 100644 index 00000000..7b4d24bb --- /dev/null +++ b/packages/app-core/src/lib/cm-typst-completions.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { EditorState } from '@codemirror/state' +import { markdown } from '@codemirror/lang-markdown' +import { CompletionContext } from '@codemirror/autocomplete' +import { previewSourceOf, typstCommandSource, typstTokenBefore } from './cm-typst-completions' +import { mathRenderExtension } from './cm-math-render' + +function state(doc: string, renderer: 'katex' | 'typst' = 'typst'): EditorState { + return EditorState.create({ + doc, + extensions: [markdown(), mathRenderExtension(renderer)] + }) +} + +function sourceAt(doc: string, renderer: 'katex' | 'typst' = 'typst', explicit = false) { + return typstCommandSource(new CompletionContext(state(doc, renderer), doc.length, explicit)) +} + +describe('typstTokenBefore', () => { + it('matches the identifier being typed, from two letters on', () => { + const doc = '$su' + const token = typstTokenBefore(state(doc), doc.length) + expect(token).not.toBeNull() + expect(token!.query).toBe('su') + expect(token!.from).toBe(1) + }) + + it('stays silent on a single letter unless summoned explicitly', () => { + // One-letter variables are the normal case in math, not a prefix. + const doc = '$x' + expect(typstTokenBefore(state(doc), doc.length)).toBeNull() + expect(typstTokenBefore(state(doc), doc.length, true)).not.toBeNull() + }) + + it('matches dotted names and rejects non-identifiers', () => { + const dotted = '$dots.h' + expect(typstTokenBefore(state(dotted), dotted.length)!.query).toBe('dots.h') + + const afterDigits = '$12' + expect(typstTokenBefore(state(afterDigits), afterDigits.length)).toBeNull() + }) +}) + +describe('typstCommandSource', () => { + it('offers Typst words inside math when the note compiles as Typst', () => { + const result = sourceAt('formule $su') + expect(result).not.toBeNull() + const labels = result!.options.map((o) => o.label) + expect(labels).toContain('sum') + expect(labels).toContain('alpha') + }) + + it('stays out of the way when the note compiles as KaTeX', () => { + // The exact mirror of the LaTeX source's Typst gate: `sum_(i=1)^(n)` is + // not KaTeX, so offering it there would be wrong every time. + expect(sourceAt('formule $su', 'katex')).toBeNull() + }) + + it('stays out of prose and code even with Typst selected', () => { + expect(sourceAt('prose without math: su')).toBeNull() + expect(sourceAt('```bash\necho su')).toBeNull() + }) + + it('works in display math still being typed', () => { + expect(sourceAt('$$\nx = su')).not.toBeNull() + }) + + it('covers the slice-3 families: arrows, comparisons, sets, styles', () => { + const labels = sourceAt('formule $ar')!.options.map((o) => o.label) + for (const word of ['arrow.r.double', 'lt.eq', 'inter', 'nothing', 'dif', 'bold', 'bb']) { + expect(labels, word).toContain(word) + } + }) + + it('the token matcher reaches doubly-dotted names like arrow.l.r.double', () => { + const doc = '$arrow.l.r.double' + expect(typstTokenBefore(state(doc), doc.length)!.query).toBe('arrow.l.r.double') + }) +}) + +describe('compiled previews', () => { + it('derives the preview by unwrapping the snippet fields', () => { + expect(previewSourceOf({ template: 'frac(${a}, ${b})' })).toBe('frac(a, b)') + expect(previewSourceOf({ template: 'sum_(${i=1})^(${n})' })).toBe('sum_(i=1)^(n)') + expect(previewSourceOf({})).toBeNull() + expect(previewSourceOf({ preview: 'mat(1;2)', template: 'mat(${})' })).toBe('mat(1;2)') + }) + + it('every templated option carries a well-formed preview', () => { + const result = sourceAt('formule $su')! + for (const option of result.options) { + const preview = (option as { _preview?: string | null })._preview + if ((option as { apply?: unknown }).apply === undefined) continue + expect(preview, option.label).toBeTruthy() + // No snippet syntax may leak into what the compiler will typeset. + expect(preview, option.label).not.toMatch(/\$\{|\}/) + } + }) +}) diff --git a/packages/app-core/src/lib/cm-typst-completions.ts b/packages/app-core/src/lib/cm-typst-completions.ts new file mode 100644 index 00000000..4ce65208 --- /dev/null +++ b/packages/app-core/src/lib/cm-typst-completions.ts @@ -0,0 +1,254 @@ +/** + * Typst math completion, the sibling of cm-latex-completions for notes whose + * typesetter is Typst. Typst has no backslash: commands are bare words + * (`sum`, `alpha`, `frac(a, b)`), so the trigger is the identifier being + * typed — from two letters on, to stay out of the way of one-letter + * variables — inside the same `$…$` / `$$…$$` regions. + * + * Where LaTeX previews need KaTeX, most Typst entries are single glyphs with + * an exact Unicode form (α, ∑, ∫, ℝ, ∀ …), shown directly in the icon slot. + */ +import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete' +import { snippet } from '@codemirror/autocomplete' +import type { EditorState } from '@codemirror/state' +import { isInMathContext } from './cm-latex-completions' +import { mathRendererOf } from './cm-math-render' +import { peekTypstMathSvg, renderTypstMathToSvg } from './typst-math-render' + +interface TypstCommand { + /** The word as typed: `sum`, `alpha`, `frac`. */ + label: string + detail: string + /** Snippet template when the function takes arguments. */ + template?: string + /** Unicode glyph (or short sketch) for the icon slot. */ + icon: string + /** Typst math compiled for the icon slot; the glyph paints while it loads. + * Constructs need this — no single glyph says `mat(1, 2; 3, 4)`. */ + preview?: string + boost?: number +} + +const GREEK: Array<[string, string]> = [ + ['alpha', 'α'], ['beta', 'β'], ['gamma', 'γ'], ['delta', 'δ'], ['epsilon', 'ε'], + ['zeta', 'ζ'], ['eta', 'η'], ['theta', 'θ'], ['iota', 'ι'], ['kappa', 'κ'], + ['lambda', 'λ'], ['mu', 'μ'], ['nu', 'ν'], ['xi', 'ξ'], ['pi', 'π'], ['rho', 'ρ'], + ['sigma', 'σ'], ['tau', 'τ'], ['upsilon', 'υ'], ['phi', 'φ'], ['chi', 'χ'], + ['psi', 'ψ'], ['omega', 'ω'], + ['Gamma', 'Γ'], ['Delta', 'Δ'], ['Theta', 'Θ'], ['Lambda', 'Λ'], ['Xi', 'Ξ'], + ['Pi', 'Π'], ['Sigma', 'Σ'], ['Phi', 'Φ'], ['Psi', 'Ψ'], ['Omega', 'Ω'] +] + +const SYMBOLS: Array<[string, string, string]> = [ + // [word, glyph, detail] + ['oo', '∞', 'infinity'], + ['diff', '∂', 'partial'], + ['nabla', '∇', 'nabla'], + ['forall', '∀', 'for all'], + ['exists', '∃', 'exists'], + ['in', '∈', 'element of'], + ['union', '∪', 'set union'], + ['subset', '⊂', 'subset'], + ['supset', '⊃', 'superset'], + ['approx', '≈', 'approximately'], + ['equiv', '≡', 'equivalent'], + ['prop', '∝', 'proportional'], + ['times', '×', 'times'], + ['dot.op', '⋅', 'dot operator'], + ['plus.minus', '±', 'plus-minus'], + ['RR', 'ℝ', 'reals'], + ['NN', 'ℕ', 'naturals'], + ['ZZ', 'ℤ', 'integers'], + ['QQ', 'ℚ', 'rationals'], + ['CC', 'ℂ', 'complexes'], + ['dots.h', '⋯', 'horizontal dots'], + ['dots.v', '⋮', 'vertical dots'], + + // Arrows — Typst also accepts the ASCII shorthands noted in the detail. + ['arrow.r', '→', 'right arrow — or ->'], + ['arrow.l', '←', 'left arrow — or <-'], + ['arrow.l.r', '↔', 'left-right arrow — or <->'], + ['arrow.r.double', '⇒', 'implies — or =>'], + ['arrow.l.r.double', '⇔', 'if and only if — or <=>'], + ['arrow.r.bar', '↦', 'maps to — or |->'], + ['arrow.t', '↑', 'up arrow'], + ['arrow.b', '↓', 'down arrow'], + + // Comparisons. + ['lt.eq', '≤', 'less or equal — or <='], + ['gt.eq', '≥', 'greater or equal — or >='], + ['eq.not', '≠', 'not equal — or !='], + + // Sets. + ['inter', '∩', 'set intersection'], + ['nothing', '∅', 'empty set'], + ['subset.eq', '⊆', 'subset or equal'], + ['supset.eq', '⊇', 'superset or equal'], + ['in.not', '∉', 'not element of'], + + // Operators. + ['compose', '∘', 'function composition'], + ['plus.o', '⊕', 'direct sum'], + ['times.o', '⊗', 'tensor product'], + ['dif', 'd', 'differential — dif x in integrals'] +] + +const FUNCTIONS = [ + 'sin', 'cos', 'tan', 'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'tanh', + 'exp', 'log', 'ln', 'det', 'max', 'min', 'sup', 'inf', 'arg', 'gcd', 'mod' +] + +const TYPST_COMMANDS: TypstCommand[] = [ + // Everyday constructs, boosted to the top — templates are valid Typst math. + { label: 'frac', detail: 'fraction — or just a/b', template: 'frac(${a}, ${b})', icon: '⅟', boost: 99 }, + { label: 'sqrt', detail: 'square root', template: 'sqrt(${x})', icon: '√', boost: 98 }, + { label: 'root', detail: 'nth root', template: 'root(${n}, ${x})', icon: '∛' }, + { label: 'sum', detail: 'sum', template: 'sum_(${i=1})^(${n})', icon: '∑', boost: 97 }, + { label: 'integral', detail: 'integral', template: 'integral_(${a})^(${b})', icon: '∫', boost: 96 }, + { label: 'lim', detail: 'limit', template: 'lim_(${x -> 0})', icon: 'lim', boost: 95 }, + { label: 'product', detail: 'product', template: 'product_(${i=1})^(${n})', icon: '∏', boost: 90 }, + { label: 'binom', detail: 'binomial', template: 'binom(${n}, ${k})', icon: '(ⁿₖ)' }, + { label: 'mat', detail: 'matrix — ; ends a row', template: 'mat(${1, 2; 3, 4})', icon: '⊞' }, + { label: 'vec', detail: 'column vector', template: 'vec(${a}, ${b})', icon: '⇣' }, + { label: 'cases', detail: 'case distinction', template: 'cases(${x &"if" x > 0}, ${-x &"else"})', icon: '{' }, + { label: 'abs', detail: 'absolute value', template: 'abs(${x})', icon: '|x|' }, + { label: 'norm', detail: 'norm', template: 'norm(${x})', icon: '‖x‖' }, + { label: 'floor', detail: 'floor', template: 'floor(${x})', icon: '⌊x⌋' }, + { label: 'ceil', detail: 'ceiling', template: 'ceil(${x})', icon: '⌈x⌉' }, + + // Accents. + { label: 'hat', detail: 'hat accent', template: 'hat(${x})', icon: 'x̂' }, + { label: 'tilde', detail: 'tilde accent', template: 'tilde(${x})', icon: 'x̃' }, + { label: 'dot', detail: 'dot accent', template: 'dot(${x})', icon: 'ẋ' }, + { label: 'arrow', detail: 'vector arrow', template: 'arrow(${x})', icon: 'x⃗' }, + { label: 'overline', detail: 'overline', template: 'overline(${x})', icon: 'x̄' }, + { label: 'underline', detail: 'underline', template: 'underline(${x})', icon: 'x̲' }, + + // Text styles. + { label: 'bold', detail: 'bold', template: 'bold(${x})', icon: '𝐱' }, + { label: 'upright', detail: 'upright (non-italic)', template: 'upright(${x})', icon: 'x' }, + { label: 'cal', detail: 'calligraphic', template: 'cal(${A})', icon: '𝒜' }, + { label: 'bb', detail: 'blackboard bold', template: 'bb(${A})', icon: '𝔸' }, + + ...GREEK.map(([label, icon]): TypstCommand => ({ label, icon, detail: 'greek' })), + ...SYMBOLS.map(([label, icon, detail]): TypstCommand => ({ label, icon, detail })), + ...FUNCTIONS.map((label): TypstCommand => ({ label, icon: 'ƒ', detail: 'function' })) +] + +/** The word being typed at `pos`, or null. Two letters minimum unless the + * completion was summoned explicitly — one-letter variables are the normal + * case in math and must not pop a menu. Dotted names (`dots.h`) match too. */ +export function typstTokenBefore( + state: EditorState, + pos: number, + explicit = false +): { from: number; query: string } | null { + const line = state.doc.lineAt(pos) + const textBefore = state.doc.sliceString(line.from, pos) + const match = textBefore.match(/[A-Za-z][A-Za-z.]*$/) + if (!match) return null + if (!explicit && match[0].length < 2) return null + return { from: pos - match[0].length, query: match[0] } +} + +/** The compiled preview is the template with its `${…}` fields unwrapped: + * `frac(${a}, ${b})` previews as `frac(a, b)`. Deriving it keeps preview and + * insertion from ever drifting apart. Exported for tests. */ +export function previewSourceOf(cmd: { template?: string; preview?: string }): string | null { + if (cmd.preview) return cmd.preview + if (!cmd.template) return null + return cmd.template.replace(/\$\{([^}]*)\}/g, '$1') +} + +let cachedOptions: Completion[] | null = null + +function buildOptions(): Completion[] { + cachedOptions ??= TYPST_COMMANDS.map( + (cmd): Completion => + ({ + label: cmd.label, + detail: cmd.detail, + type: 'keyword', + boost: cmd.boost ?? 0, + _kind: 'typst', + _icon: cmd.icon, + _preview: previewSourceOf(cmd), + apply: cmd.template ? snippet(cmd.template) : undefined + }) as Completion & { _kind: string; _icon: string; _preview: string | null } + ) + return cachedOptions +} + +/** Full option row for a Typst completion. The Unicode glyph paints + * immediately; entries with arguments swap in the compiled Typst preview as + * soon as the shared render queue produces it (cached across popups, so the + * swap only happens the first time). Null for every other completion kind. */ +export function renderTypstCompletion(completion: Completion): HTMLElement | null { + const { _kind, _icon, _preview } = completion as Completion & { + _kind?: string + _icon?: string + _preview?: string | null + } + if (_kind !== 'typst') return null + + const el = document.createElement('div') + el.className = 'slash-cmd-item' + + const icon = document.createElement('span') + icon.className = 'slash-cmd-icon typst-cmd-icon' + icon.style.fontSize = '0.8em' + icon.style.lineHeight = '1' + icon.style.display = 'inline-flex' + icon.style.alignItems = 'center' + icon.style.justifyContent = 'center' + icon.style.overflow = 'hidden' + icon.textContent = _icon ?? '' + if (_preview) { + const showSvg = (svg: string): void => { + icon.innerHTML = svg + const svgEl = icon.querySelector('svg') + if (svgEl) { + svgEl.style.maxWidth = '2.6em' + svgEl.style.maxHeight = '2.2em' + } + } + const cached = peekTypstMathSvg(_preview, false) + if (cached?.ok) { + showSvg(cached.svg) + } else if (!cached) { + renderTypstMathToSvg(_preview, false) + .then((res) => { + // A closed popup leaves the node detached; the warm cache still + // pays off on the next open. + if (res.ok && icon.isConnected) showSvg(res.svg) + }) + .catch(() => undefined) + } + // A cached error keeps the glyph: it said all it had to say once. + } + + 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 +} + +export function typstCommandSource(context: CompletionContext): CompletionResult | null { + if (mathRendererOf(context.state) !== 'typst') return null + const token = typstTokenBefore(context.state, context.pos, context.explicit) + if (!token) return null + if (!isInMathContext(context.state, token.from)) return null + return { + from: token.from, + options: buildOptions(), + validFor: /^[A-Za-z][A-Za-z.]*$/ + } +}