Formula EditingMOL
Formula input as you would write it on paper. A <textarea class="math-editor-source"> holds the TeX; the official MathLive editor takes over and edits the formula in place - fractions, powers, roots, a virtual keyboard. The textarea stays the form control, so submission, reset, required and disabled work as for any field. Without JavaScript, or until MathLive arrives, the textarea edits the TeX.
On this page (11)
§A formula field in a form
Type into the field, or use the toolbar: Fraction wraps what is before the caret. Submit with an empty placeholder left and the editor says what is missing. Reset restores the formula - the textarea is the form control.
§Readonly and disabled
The authored textarea names the starting state: readonly shows a formula that can be selected and copied, not edited (and is not validated); disabled is neither editable nor submitted. The toolbar follows.
§Given and locked
§The virtual keyboard
MathLive has one virtual keyboard per window. The keyboard command (or the keyboard-open state) opens it in the editor's own container, below the field, in the theme's colors and radius; when the second editor opens it, the first one closes its own.
§Two editors, one keyboard
§Following the formula
data-math-preview="<id>" keeps a Formula Rendering element in step with the editor (empty placeholders left out), as in the first example. Every edit also dispatches math-input (bubbles) with the TeX and whether it is complete; df$.shadcn.mathEditor.setValue(el, latex) sets the formula from code - an unchanged value keeps the caret and the undo history.
§Loading
The first editor on a page imports https://cdn.jsdelivr.net/npm/mathlive@0.111.0/mathlive.min.mjs - pinned, never @latest; its fonts load from the fonts/ folder beside it. To self-host, copy mathlive.min.mjs and fonts/ and point the component at the module:
<meta name="math-editor-module" content="/vendor/mathlive/mathlive.min.mjs" />§States
Named states via the shared State API, bound on each editor:
default- editable (the field once MathLive loaded, else the textarea)readonly- selectable and copyable, not editabledisabled- not editable, not focusable, not submittedinvalid- failed validation:aria-invalidand the message (config.message)keyboard-open- editable, with the virtual keyboard open below the field
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/math-editor-{state}.png.
Machine contract - verified against math-editor.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
readonly | boolean | true, false | false | Selectable and copyable, not editable (the textarea's readonly). |
disabled | boolean | true, false | false | Not editable, not focusable, not submitted (the textarea's disabled). |
invalid | boolean | true, false | false | Failed validation: aria-invalid and an output role="alert" message. |
keyboard-open | boolean | true, false | false | Editable, with the virtual keyboard open in the editor's container. |
§API
Generated from math-editor.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type MathEditorState = 'default' | 'readonly' | 'disabled' | 'invalid' | 'keyboard-open' - setState(name, config) takes the config of the state it names.
| State | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
default | Editable - the MathLive field once it loaded (data-ready on the editor), else the textarea.
| |||||||||
readonly | Selectable and copyable, not editable (the textarea's readonly; not validated).
| |||||||||
disabled | Not editable, not focusable, not submitted (the textarea's disabled).
| |||||||||
invalid | Failed validation: aria-invalid on the textarea and the message under the field.
| |||||||||
keyboard-open | Editable, with the virtual keyboard open below the field.
|
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends MathEditorState>(name: S, config?: MathEditorStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: MathEditorState; config: MathEditorStateConfigs[MathEditorState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: MathEditorState; config: MathEditorStateConfigs[MathEditorState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: MathEditorState; config: MathEditorStateConfigs[MathEditorState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.mathEditorApi.setState<S extends MathEditorState>(el: HTMLElement, name: S, config?: MathEditorStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.mathEditorApi.getState(el: HTMLElement): { name: MathEditorState; config: MathEditorStateConfigs[MathEditorState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.mathEditorApi.render(state: { name: MathEditorState; config: MathEditorStateConfigs[MathEditorState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.mathEditorApi.store(el: HTMLElement): Store<{ name: MathEditorState; config: MathEditorStateConfigs[MathEditorState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.mathEditorApi.commit<S extends MathEditorState>(el: HTMLElement, name: S, config?: MathEditorStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.mathEditorStates: MathEditorState[] | The declared states, 'default' first: default, readonly, disabled, invalid, keyboard-open. |
df$.shadcn.mathEditor
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
load(url?: string): Promise<MathliveLike> | Import the official MathLive ESM once. Without `url`: <meta name="math-editor-module">, else the pinned jsDelivr build. A failed import can be retried (the next call imports again).
Returns | |||||||||
setValue(el: HTMLElement | string, latex: string): void | Set the formula (an unchanged value keeps the caret and the undo history). Reports `math-input`.
| |||||||||
command(el: HTMLElement | string, name: string): void | Run an editing command on the field - the toolbar's `data-math-command` buttons call it. Focus returns to the field; an editor that is readonly or disabled ignores it.
| |||||||||
validate(el: HTMLElement | string): boolean | Check the formula like a form submission would: an invalid one enters 'invalid' with the message.
Returns | |||||||||
url: string | The pinned official MathLive build the component loads (never @latest); its fonts load relative to it. |
Events
| Event | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
math-input | the formula changed - a preview, a form or a compute panel follows it
|
Types
| Type | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
MathEditorConfig | An editor's state: every field optional in setState() (merged into the current one); getState() reports both.
| |||||||||
MathInputDetail | The `math-input` event's detail: the formula after an edit.
| |||||||||
MathliveLike | The part of the official MathLive module this component uses.
|
§CSS view file
/* * Formula Editing (math-editor): a MathLive <math-field> over a native * textarea. The field is themed through MathLive's documented custom * properties and ::part() hooks; its virtual keyboard through the keyboard's * documented --keyboard-* / --keycap-* properties. One private hook is pinned * to mathlive@0.111.0: .MLK__keycap's radius (MathLive hard-codes it). */@layer components { .math-editor { display: grid; gap: 0.375rem; min-inline-size: 0; /* the textarea: the editor without JavaScript (or when MathLive cannot load) */ & > .math-editor-source { inline-size: 100%; min-block-size: 2.5rem; field-sizing: content; padding: 0.5rem 0.75rem; border: 1px solid var(--input); border-radius: var(--radius-md); background: transparent; color: var(--foreground); font-family: var(--font-mono); font-size: 0.875rem; resize: vertical; &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } } /* MathLive took over: the textarea steps aside but stays the form control */ &[data-ready] > .math-editor-source { display: none; } /* the field: an input of the system, math inside */ & > .math-editor-field { display: block; inline-size: 100%; min-block-size: 2.75rem; box-sizing: border-box; padding: 0.25rem 0.75rem; border: 1px solid var(--input); border-radius: var(--radius-md); background: var(--background); color: var(--foreground); font-size: 1.25rem; --caret-color: var(--foreground); --primary-color: var(--primary); --selection-background-color: color-mix(in oklch, var(--ring) 28%, transparent); --selection-color: var(--foreground); --contains-highlight-background-color: var(--muted); --placeholder-color: var(--muted-foreground); --smart-fence-color: var(--muted-foreground); --latex-color: var(--muted-foreground); --text-font-family: var(--font-sans); --ui-font-family: var(--font-sans); transition: border-color 120ms ease, box-shadow 120ms ease; &:focus-within { border-color: var(--ring); box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 30%, transparent); outline: none; } /* the keyboard and the menu are the component's (toolbar, states), not MathLive's toggles */ &::part(virtual-keyboard-toggle), &::part(menu-toggle) { display: none; } } /* the toolbar: data-math-command buttons */ & > .math-editor-tools { display: flex; flex-wrap: wrap; gap: 0.25rem; } & > .math-editor-message { color: var(--destructive); font-size: 0.8125rem; line-height: 1.4; } /* states */ &[data-state="invalid"] > .math-editor-field, &[data-state="invalid"] > .math-editor-source { border-color: var(--destructive); &:focus-within, &:focus-visible { box-shadow: 0 0 0 3px color-mix(in oklch, var(--destructive) 25%, transparent); } } &[data-state="readonly"] > .math-editor-field { background: var(--muted); } &[data-state="disabled"] > .math-editor-field, &[data-state="disabled"] > .math-editor-source { opacity: 0.5; cursor: not-allowed; } /* the virtual keyboard, leased into the editor (MathLive's documented properties) */ & > .math-editor-keyboard { position: relative; /* in a container MathLive takes its backdrop padding out of the measured height - the bottom row was clipped */ --keyboard-padding-top: 0px; --keyboard-padding-bottom: 0px; --keyboard-background: var(--muted); --keyboard-border: var(--border); --keyboard-accent-color: var(--primary); --keyboard-horizontal-rule: var(--border); --keyboard-toolbar-text: var(--muted-foreground); --keyboard-toolbar-text-active: var(--foreground); --keyboard-toolbar-background-hover: var(--accent); --keyboard-toolbar-background-selected: var(--background); --keycap-background: var(--background); --keycap-background-hover: var(--accent); --keycap-background-active: var(--primary); --keycap-background-pressed: var(--primary); --keycap-border: var(--border); --keycap-border-bottom: var(--border); --keycap-text: var(--foreground); --keycap-text-active: var(--primary-foreground); --keycap-text-pressed: var(--primary-foreground); --keycap-secondary-background: var(--secondary); --keycap-secondary-background-hover: var(--accent); --keycap-secondary-text: var(--secondary-foreground); --keycap-secondary-border: var(--border); --keycap-secondary-border-bottom: var(--border); --keycap-primary-background: var(--primary); --keycap-primary-background-hover: color-mix(in oklch, var(--primary) 88%, var(--background)); --keycap-primary-text: var(--primary-foreground); border-radius: var(--radius-lg); overflow: hidden; &:empty { display: none; } /* private, pinned to mathlive@0.111.0 (tests/e2e/math/math-editor.e2e.ts reads it). MathLive injects its sheet unlayered, which beats any normal declaration in a layer - only !important wins from here */ & .MLK__keycap { border-radius: var(--radius-sm) !important; } } @media (prefers-reduced-motion: reduce) { & > .math-editor-field { transition: none; } } @media (prefers-contrast: more) { & > .math-editor-field, & > .math-editor-source { border-color: var(--foreground); } } @media (forced-colors: active) { & > .math-editor-field { border-color: CanvasText; &:focus-within { outline: 2px solid Highlight; } } &[data-state="invalid"] > .math-editor-field { border-color: Mark; } } @media print { & > .math-editor-tools, & > .math-editor-keyboard { display: none; } } }}§JavaScript view file
// -- Formula Editing (math-editor) --------------------------------------------// A thin adapter around the OFFICIAL MathLive editor (arnog/mathlive, MIT) -// zero MathLive bytes ship here. The markup is a native <textarea> holding the// formula's TeX: without JavaScript, or when MathLive cannot load, it IS the// editor. With MathLive the textarea steps aside (CSS) and a <math-field>// edits the formula; the textarea keeps its value, so it stays the form// control - submission, reset, required and disabled are the browser's.//// - Lazy: a page without an editor never requests MathLive. The first editor// imports the pinned ESM build ONCE (MATHLIVE_URL, or <meta name=// "math-editor-module"> / df$.shadcn.mathEditor.load(url) to self-host);// its fonts load relative to the module. No sounds, no Compute Engine.// - Validity is native: an empty placeholder or a syntax error sets the// textarea's custom validity; the browser's `invalid` event (a submit, or// validate()) puts the editor into 'invalid' with the message - typing never// flags an unfinished formula on its own.// - One virtual keyboard per window (MathLive's): the editor in// 'keyboard-open' leases it into its own container, themed from the tokens;// another editor taking it closes the first one's.// - data-math-preview="<formula id>" keeps a Formula Rendering element// showing the formula as it is typed (through df$.shadcn.mathRenderer).//// State lives ON the element (data-state + the store); the bound `api` is the// only state mutator (AGENTS.md "State API").import { defussGlobals, defussQuery, componentState, bindComponent, mathText } from '../../../shared/state-api.js';import type { MathfieldElement, VirtualKeyboardInterface } from 'mathlive';const df$ = defussGlobals();const dfDollar = defussQuery();// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type// this file's API docs state - arguments, return values, event details - holds against its code.const mathEditorStates = ['default', 'readonly', 'disabled', 'invalid', 'keyboard-open'];/** An editor's state: every field optional in setState() (merged into the current one); getState() reports both. */export interface MathEditorConfig { /** the formula as TeX (default: the textarea's text) */ latex?: string; /** 'invalid': the message shown (default: "Enter a complete formula.") */ message?: string;}/** setState() configs per state. */export interface MathEditorStateConfigs { /** Editable - the MathLive field once it loaded (data-ready on the editor), else the textarea. */ default: MathEditorConfig; /** Selectable and copyable, not editable (the textarea's readonly; not validated). */ readonly: MathEditorConfig; /** Not editable, not focusable, not submitted (the textarea's disabled). */ disabled: MathEditorConfig; /** Failed validation: aria-invalid on the textarea and the message under the field. */ invalid: MathEditorConfig; /** Editable, with the virtual keyboard open below the field. */ 'keyboard-open': MathEditorConfig;}/** The `math-input` event's detail: the formula after an edit. */export interface MathInputDetail { /** the formula as TeX (empty placeholders included) */ latex: string; /** false while an empty placeholder is left */ complete: boolean;}/** The pinned official MathLive build the component loads (never @latest); its fonts load relative to it. */export const MATHLIVE_URL = 'https://cdn.jsdelivr.net/npm/mathlive@0.111.0/mathlive.min.mjs';/** The part of the official MathLive module this component uses. */interface MathliveLike { /** the <math-field> element class (its statics configure fonts, sounds and the Compute Engine) */ MathfieldElement: typeof MathfieldElement; /** give this browsing context its own virtual keyboard - in a frame MathLive otherwise proxies to the top window's */ initVirtualKeyboardInCurrentBrowsingContext: () => void;}// -- loading ----------------------------------------------------------------let mathlivePromise: Promise<MathliveLike> | null = null;let mathliveLoaded = '';const LOAD_MS = 15_000;/** * Import the official MathLive ESM once. Without `url`: <meta name="math-editor-module">, else the pinned * jsDelivr build. A failed import can be retried (the next call imports again). * @param url - a module URL to import instead (the same build, self-hosted - its fonts/ folder beside it) * @returns MathLive, imported once per URL */function load(url?: string): Promise<MathliveLike> { const mathliveUrl = url || ((dfDollar('meta[name="math-editor-module"]').get(0) ?? null) as HTMLMetaElement | null)?.content || MATHLIVE_URL; if (!mathlivePromise || mathliveUrl !== mathliveLoaded) { mathliveLoaded = mathliveUrl; // in a frame MathLive defines globalThis.mathVirtualKeyboard as a proxy that posts to the top window, with the // frame's own origin as target - an opaque-origin sandbox ('null') cannot post, and the first keyboard access // throws. MathLive skips that proxy when the name exists: a configurable placeholder, which its public // initVirtualKeyboardInCurrentBrowsingContext() replaces with a keyboard of this frame (shown in the editor). // VERIFIED: (tmp/probe-mathlive-frame.ts) plain import → "Invalid target origin 'null'"; placeholder + init → // the field mounts, the keyboard shows in its container const framed = globalThis.self !== globalThis.top; if (framed && !('mathVirtualKeyboard' in globalThis)) Reflect.defineProperty(globalThis, 'mathVirtualKeyboard', { value: undefined, configurable: true, writable: true, }); // the ONE vendor import (verify: VENDOR_IMPORTS) - the official editor, never our code // a microtask later: a failing import is a rejection (data-error), never an exception out of load() const pending = Promise.resolve() .then(() => import(/* @vite-ignore */ mathliveUrl)) .then((m) => { const vendor = m as MathliveLike; // the fonts folder beside the module - MathLive's own default resolves to "<module>.mjs/fonts/" (404s) // VERIFIED: (tmp/probe-editor.ts) without this every KaTeX_* face request named mathlive.min.mjs/fonts/... vendor.MathfieldElement.fontsDirectory = new URL('fonts/', new URL(mathliveUrl, document.baseURI)).href; vendor.MathfieldElement.soundsDirectory = null; vendor.MathfieldElement.computeEngine = null; if (framed) vendor.initVirtualKeyboardInCurrentBrowsingContext(); return vendor; }); pending.catch(() => { if (mathlivePromise === pending) mathlivePromise = null; }); mathlivePromise = pending; } let timer = 0; const deadline = new Promise<never>((_, reject) => { timer = setTimeout( () => reject(new Error(`MathLive did not load within ${LOAD_MS / 1000} s`)), LOAD_MS, ) as unknown as number; }); return Promise.race([mathlivePromise, deadline]).finally(() => clearTimeout(timer));}// -- the parts ------------------------------------------------------------------const sourceOf = (el: HTMLElement): HTMLTextAreaElement | null => (dfDollar(el).children('textarea.math-editor-source').get(0) ?? null) as HTMLTextAreaElement | null;/** the field once MathLive mounted it (data-ready) - before that its methods throw */const fieldOf = (el: HTMLElement): MathfieldElement | null => el.hasAttribute('data-ready') ? ((dfDollar(el).children('math-field.math-editor-field').get(0) ?? null) as MathfieldElement | null) : null;const keyboard = (): VirtualKeyboardInterface | null => (Reflect.get(globalThis, 'mathVirtualKeyboard') as VirtualKeyboardInterface | undefined) ?? null;const DEFAULT_INVALID = 'Enter a complete formula.';const INCOMPLETE = 'Fill in every placeholder of the formula.';const SYNTAX = 'The formula has a syntax error.';const EDITABLE = new Set(['default', 'invalid', 'keyboard-open']);/** the label text that names the editor: the textarea's <label>, else the editor's aria-label */function labelOf(el: HTMLElement, source: HTMLTextAreaElement): string { const label = source.id ? dfDollar(el) .find(`label[for="${CSS.escape(source.id)}"]`) .get(0) : null; return (label?.textContent ?? '').trim() || el.getAttribute('aria-label') || 'Formula';}// -- markup -------------------------------------------------------------------/** * The markup of a state, on the live element and on render()'s detached copy: data-state, the textarea's * readonly / disabled / aria-invalid, the toolbar's disabled commands, and in 'invalid' the message. The * <math-field> and the keyboard are MathLive's (data-ce-chrome) and follow the textarea. */function applyMarkup(el: HTMLElement, name: string, config: MathEditorConfig): void { dfDollar(el).attr('data-state', name === 'default' ? null : name); const source = sourceOf(el); if (source) dfDollar(source) .attr('readonly', name === 'readonly' ? '' : null) .attr('disabled', name === 'disabled' ? '' : null) .attr('aria-invalid', name === 'invalid' ? 'true' : null); dfDollar(el) .find('[data-math-command]') .each((_, b) => { dfDollar(b).attr('disabled', EDITABLE.has(name) ? null : ''); }); dfDollar(el).children('.math-editor-message').remove(); if (name === 'invalid') { const out = dfDollar('<output class="math-editor-message" role="alert" data-ce-chrome></output>').text( config.message || DEFAULT_INVALID, ); if (source?.id) out.attr('id', `${source.id}-message`); if (source) dfDollar(source).after(out); else dfDollar(el).append(out); }}// -- the virtual keyboard: one per window, leased by one editor at a time ---------let keyboardOwner: HTMLElement | null = null;function hostOf(el: HTMLElement): HTMLElement { let host = (dfDollar(el).children('.math-editor-keyboard').get(0) ?? null) as HTMLElement | null; if (!host) { host = document.createElement('div'); host.className = 'math-editor-keyboard'; host.setAttribute('data-ce-chrome', ''); const tools = dfDollar(el).children('.math-editor-tools').get(0); if (tools) dfDollar(tools).before(host); else dfDollar(el).append(host); } return host;}/** * The container takes the keyboard's height: in a container other than <body> MathLive lays the keyboard * out at the container's bottom edge, so an auto-height container shows nothing. boundingRect and * `geometrychange` are MathLive's public geometry API. */function fitHost(): void { const kb = keyboard(); if (!kb || !keyboardOwner) return; hostOf(keyboardOwner).style.blockSize = `${Math.ceil(kb.boundingRect.height)}px`;}/** open the keyboard in this editor's container (the previous owner leaves 'keyboard-open') */function lease(el: HTMLElement): void { const kb = keyboard(); if (!kb || !fieldOf(el)) return; const previous = keyboardOwner; keyboardOwner = el; if (previous && previous !== el) { const old = dfDollar(previous).children('.math-editor-keyboard').get(0) as HTMLElement | undefined; if (old) old.style.blockSize = ''; // a visible keyboard stays in its container when the container changes - hidden, it moves on show() kb.hide({ animate: false }); if (previous.dataset.stateName === 'keyboard-open') void mathEditorApi.setState(previous, 'default'); } if (!document.__mathEditorKeyboardInit) { document.__mathEditorKeyboardInit = true; // window.mathVirtualKeyboard is an EventTarget (MathLive's docs: listen for geometrychange on it); its type omits that (kb as unknown as EventTarget).addEventListener('geometrychange', fitHost); } kb.container = hostOf(el); kb.show({ animate: false }); fitHost();}/** close the keyboard if this editor holds it */function release(el: HTMLElement): void { if (keyboardOwner !== el) return; keyboardOwner = null; hostOf(el).style.blockSize = ''; const kb = keyboard(); if (!kb) return; kb.hide({ animate: false }); kb.container = document.body;}// -- the live field -----------------------------------------------------------------/** * Focus the field. MathLive's focus() does nothing while MathLive still believes the field has focus - and * after a click on a button outside the field it does (its blur bookkeeping misses that move), so its * keyboard sink (the part="keyboard-sink" element, pinned to mathlive@0.111.0) takes the focus directly. * VERIFIED: (tmp/probe-editor.ts) at the invalid event of a submit click hasFocus() was true, document focus * on the button, and focus(), blur() + focus() and a deferred focus() all left it there. */function focusField(field: MathfieldElement): void { field.focus(); if (document.activeElement === field) return; const sink = dfDollar(field.shadowRoot as unknown as HTMLElement) .find('[part="keyboard-sink"]') .get(0) as HTMLElement | undefined; sink?.focus();}/** the textarea's validity from the formula: an empty placeholder or a syntax error is a custom error */function validity(el: HTMLElement, latex: string): boolean { const source = sourceOf(el); const field = fieldOf(el); const complete = !/\\placeholder\b/.test(latex); const message = !complete ? INCOMPLETE : field?.errors.length ? SYNTAX : ''; source?.setCustomValidity(message); return complete;}/** after every edit: the textarea follows, the preview follows, `math-input` reports it */function edited(el: HTMLElement): void { const field = fieldOf(el); const source = sourceOf(el); if (!field || !source) return; const latex = mathText(field.value); source.value = latex; const complete = validity(el, latex); preview(el, field.getValue('latex-without-placeholders')); const name = el.dataset.stateName || 'default'; // a fixed formula leaves 'invalid' as soon as it is valid again - read the flag: checkValidity() would fire // `invalid` on every keystroke of a still unfinished formula, and its handler moves the caret if (name === 'invalid' && source.validity.valid) void mathEditorApi.setState(el, 'default', { latex, message: '' }); else mathEditorApi.commit(el, name, { ...(mathEditorApi.getState(el).config as MathEditorConfig), latex }); // the formula changed - a preview, a form or a compute panel follows it el.dispatchEvent(new CustomEvent<MathInputDetail>('math-input', { bubbles: true, detail: { latex, complete } }));}/** a linked Formula Rendering element shows the formula (placeholders left out) */function preview(el: HTMLElement, latex: string): void { const ids = (el.dataset.mathPreview ?? '').split(/\s+/).filter(Boolean); const renderer = df$.mathRenderer; for (const id of ids) { const target = dfDollar(`#${CSS.escape(id)}`).get(0) as HTMLElement | undefined; if (target?.api && renderer) void renderer.setSource(target, { format: 'latex', text: latex }); }}/** create the <math-field> once MathLive loaded; once it mounted, the textarea steps aside */async function mount(el: HTMLElement): Promise<void> { const source = sourceOf(el); if (!source || dfDollar(el).children('math-field').get(0)) return; const vendor = await load(); if (!el.isConnected || dfDollar(el).children('math-field').get(0)) return; const field = new vendor.MathfieldElement(); field.className = 'math-editor-field'; field.setAttribute('data-ce-chrome', ''); field.setAttribute('aria-label', labelOf(el, source)); if (source.id) field.setAttribute('aria-describedby', `${source.id}-message`); // MathLive configures a field only after its `mount` event (documented) - its methods throw before const mounted = new Promise<void>((resolve) => field.addEventListener('mount', () => resolve(), { once: true })); dfDollar(source).before(field); await mounted; if (!el.isConnected) return; field.mathVirtualKeyboardPolicy = 'manual'; field.menuItems = []; field.setValue(source.value, { silenceNotifications: true }); dfDollar(field).on('input', () => edited(el)); dfDollar(el).attr('data-ready', ''); // the state may have moved while MathLive loaded - the field takes the current one const name = el.dataset.stateName || 'default'; field.readOnly = name === 'readonly'; field.disabled = name === 'disabled'; validity(el, source.value); if (name === 'keyboard-open') lease(el);}// -- states ---------------------------------------------------------------------/** UI side of setState - the only function that switches an editor's state */function triggerStateChange(el: HTMLElement, name: string, config: MathEditorConfig): void { const source = sourceOf(el); const field = fieldOf(el); if (typeof config.latex === 'string' && source) { const latex = mathText(config.latex); if (source.value !== latex) source.value = latex; // an unchanged value keeps the field, its caret and its undo history if (field && field.value !== latex) field.setValue(latex, { silenceNotifications: true }); validity(el, latex); } applyMarkup(el, name, config); if (field) { field.readOnly = name === 'readonly'; field.disabled = name === 'disabled'; if (name === 'invalid') field.setAttribute('aria-invalid', 'true'); else field.removeAttribute('aria-invalid'); } if (name === 'keyboard-open') lease(el); else release(el);}/** Registry-level API; pass the element explicitly. Unknown names throw. */export const mathEditorApi = componentState({ component: 'math-editor', states: mathEditorStates, mergeConfig: true, apply: (el, state) => triggerStateChange(el as HTMLElement, state.name, state.config as MathEditorConfig), // reflect reality: the formula as typed read: (el, state) => ({ name: (el as HTMLElement).dataset.stateName || state.name, config: { ...state.config, latex: sourceOf(el as HTMLElement)?.value ?? '' }, }), markup: (el, state) => { applyMarkup(el as HTMLElement, state.name, state.config as MathEditorConfig); // a textarea's markup holds its value as text const source = sourceOf(el as HTMLElement); const latex = (state.config as MathEditorConfig).latex; if (source && typeof latex === 'string' && source.textContent !== latex) dfDollar(source).text(latex); },});// -- public API -------------------------------------------------------------------const editor = (el: HTMLElement | string): HTMLElement => { const node = (typeof el === 'string' ? dfDollar(el).get(0) : el) as HTMLElement | undefined; if (!node?.matches?.('.math-editor') || !node.api) throw new Error('not an initialized .math-editor'); return node;};/** * Set the formula (an unchanged value keeps the caret and the undo history). Reports `math-input`. * @param el - the .math-editor element, or a selector for it * @param latex - the formula as TeX */function setValue(el: HTMLElement | string, latex: string): void { const node = editor(el); const name = node.dataset.stateName || 'default'; void mathEditorApi.setState(node, name === 'invalid' ? 'default' : name, { latex: mathText(latex), message: '' }); preview(node, latex); node.dispatchEvent( new CustomEvent<MathInputDetail>('math-input', { bubbles: true, detail: { latex, complete: !/\\placeholder\b/.test(latex) }, }), );}const INSERTS: Record<string, string> = { fraction: '\\frac{#@}{#?}', power: '#@^{#?}', subscript: '#@_{#?}', root: '\\sqrt{#@}',};/** * Run an editing command on the field - the toolbar's `data-math-command` buttons call it. Focus returns to * the field; an editor that is readonly or disabled ignores it. * @param el - the .math-editor element, or a selector for it * @param name - 'fraction', 'power', 'subscript', 'root' (wrap the selection), 'undo', 'redo' or 'keyboard' (toggle the virtual keyboard) */function command(el: HTMLElement | string, name: string): void { const node = editor(el); const field = fieldOf(node); const state = node.dataset.stateName || 'default'; if (!field || !EDITABLE.has(state)) return; if (Object.hasOwn(INSERTS, name)) field.insert(INSERTS[name], { focus: true, selectionMode: 'placeholder' }); else if (name === 'undo' || name === 'redo') field.executeCommand(name); else if (name === 'keyboard') void mathEditorApi.setState(node, state === 'keyboard-open' ? 'default' : 'keyboard-open'); else throw new Error(`unknown editor command "${name}" (fraction, power, subscript, root, undo, redo, keyboard)`); focusField(field); if (name !== 'keyboard') edited(node);}/** * Check the formula like a form submission would: an invalid one enters 'invalid' with the message. * @param el - the .math-editor element, or a selector for it * @returns true when the formula is valid (required and complete, no syntax error) */function validate(el: HTMLElement | string): boolean { const node = editor(el); return sourceOf(node)?.checkValidity() ?? true;}df$.mathEditorApi = mathEditorApi;df$.mathEditorStates = mathEditorStates;// public imperative API (AGENTS.md "No window globals": df$.shadcn.mathEditor)df$.mathEditor = { load, setValue, command, validate, url: MATHLIVE_URL };// -- document-level behavior ------------------------------------------------------function bindDocument(): void { if (document.__mathEditorInit) return; document.__mathEditorInit = true; // the toolbar: data-math-command buttons inside an editor dfDollar(document).on('click', (e: Event) => { const button = e.target instanceof Element ? (dfDollar(e.target).closest('[data-math-command]').get(0) as HTMLElement | undefined) : undefined; const el = button ? (dfDollar(button).closest('.math-editor').get(0) as HTMLElement | undefined) : undefined; if (button && el?.api) command(el, button.dataset.mathCommand ?? ''); }); // a submit (or validate()) found the formula invalid: the editor says so - no native bubble on the hidden textarea document.addEventListener( 'invalid', (e) => { const source = e.target instanceof HTMLTextAreaElement && e.target.matches('.math-editor > textarea.math-editor-source') ? e.target : null; const el = source?.parentElement; if (!source || !el?.api) return; e.preventDefault(); void mathEditorApi.setState(el, 'invalid', { message: source.validationMessage || DEFAULT_INVALID }); const field = fieldOf(el); if (!field) return; focusField(field); // an unfinished formula: the caret goes into the first empty placeholder - typing fills it if (/\\placeholder\b/.test(field.value)) { field.position = 0; field.executeCommand('moveToNextPlaceholder'); } }, true, ); // a form reset restores the textarea's text; the field follows it document.addEventListener('reset', (e) => { const form = e.target instanceof HTMLFormElement ? e.target : null; if (!form) return; setTimeout(() => { (dfDollar(form).find('.math-editor').toArray() as HTMLElement[]).forEach((el) => { const source = sourceOf(el); const field = fieldOf(el); if (!source || !el.api) return; const name = el.dataset.stateName || 'default'; void mathEditorApi.setState(el, name === 'invalid' || name === 'keyboard-open' ? 'default' : name, { latex: source.value, message: '', }); field?.resetUndo(); preview(el, source.value); }); }); });}// -- init ------------------------------------------------------------------------function init(): void { if (keyboardOwner && !keyboardOwner.isConnected) release(keyboardOwner); (dfDollar('.math-editor:not([data-init])').toArray() as HTMLElement[]).forEach((el) => { el.dataset.init = ''; const source = sourceOf(el); if (!source) return; bindDocument(); // the authored textarea names the starting state: disabled, readonly, else editable const name = source.disabled ? 'disabled' : source.readOnly ? 'readonly' : 'default'; // el.store + el.api (AGENTS.md "State through stores") bindComponent(el, mathEditorApi, { name, config: { latex: source.value, message: '' } }); void mathEditorApi.setState(el, name, { latex: source.value, message: '' }); mount(el).catch((err) => { // the textarea stays the editor - readable, editable, submitted dfDollar(el).attr('data-error', err instanceof Error ? err.message : String(err)); }); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub