Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/app-core/src/components/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1794,6 +1795,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
calloutTypeSource,
dateShortcutSource,
latexCommandSource,
typstCommandSource,
atNoteSource,
frontmatterTagSource,
hashtagSource,
Expand Down
3 changes: 3 additions & 0 deletions packages/app-core/src/lib/cm-slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
99 changes: 99 additions & 0 deletions packages/app-core/src/lib/cm-typst-completions.test.ts
Original file line number Diff line number Diff line change
@@ -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(/\$\{|\}/)
}
})
})
254 changes: 254 additions & 0 deletions packages/app-core/src/lib/cm-typst-completions.ts
Original file line number Diff line number Diff line change
@@ -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.]*$/
}
}