Skip to content

Math

The @domternal/extension-math package adds two LaTeX equation nodes - MathInline (inline, inside a paragraph) and MathBlock (a centered display equation) - rendered through a pluggable renderer. The package does not bundle a math engine: you pass a MathRenderer, and a createKatexRenderer adapter for KaTeX ships in the box. Equations are authored with $...$ / $$ input rules, the slash menu, the toolbar, or the text bubble menu, and edited in a shared popover with a live preview.

Choose Math when you need:

  • Inline and display LaTeX in the same document, both stored as plain latex source
  • A rendering engine you control (KaTeX by default, or any engine that turns LaTeX into an HTML string)
  • A keyboard-accessible edit experience (open the editor on a selected equation with Enter)
  • MathML output for screen readers and RTL-safe rendering out of the box

Skip it if:

  • You only need superscripts/subscripts for simple notation (use the Subscript and Superscript marks)
  • You cannot ship a math renderer to the client (the nodes still store and round-trip LaTeX, but render the raw source until a renderer is supplied)

The renderer is a peer dependency. Install the extension alongside katex (the default engine):

Terminal window
pnpm add @domternal/extension-math katex

Both nodes take a renderer. Build one from KaTeX with createKatexRenderer, then pass it to each node via configure. The shared edit popover (MathEditing) is added automatically by either node and uses the same renderer for its preview.

import katex from 'katex';
import 'katex/dist/katex.min.css';
import { Document, Paragraph, Text } from '@domternal/core';
import { MathInline, MathBlock, createKatexRenderer } from '@domternal/extension-math';
import { DomternalEditor } from '@domternal/vanilla';
import '@domternal/theme';
const renderer = createKatexRenderer(katex);
const dm = new DomternalEditor(document.getElementById('editor')!, {
extensions: [
Document, Paragraph, Text,
MathInline.configure({ renderer }),
MathBlock.configure({ renderer }),
],
content: '<p>Euler: <span data-type="math-inline" data-latex="e^{i\\pi}+1=0"></span></p>',
});

Both nodes are atoms: their content is the latex attribute, never editable child text. The visual math is produced by the node view from the renderer.

PropertyValue
ProseMirror namemathInline
TypeNode
Groupinline
InlineYes
AtomYes
SelectableYes
DraggableNo
HTML tag<span data-type="math-inline" data-latex="...">
PropertyValue
ProseMirror namemathBlock
TypeNode
Groupblock
AtomYes
SelectableYes
DraggableYes
HTML tag<div data-type="math-block" data-latex="...">
AttributeTypeDefaultDescription
latexstring''Raw LaTeX source (without $/$$ delimiters), serialized as data-latex. An empty value renders the “New equation” placeholder

Both nodes share the same options object.

OptionTypeDefaultDescription
rendererMathRenderer | nullnullEngine that turns LaTeX into HTML inside the node view and the edit preview. When null, the node shows the raw LaTeX (or the placeholder when empty) and editing still works, but no math is displayed
HTMLAttributesRecord<string, unknown>{}Extra HTML attributes merged onto the rendered element

A MathRenderer is any object with a single method that turns a LaTeX string into an HTML string:

interface MathRenderer {
renderToString(latex: string, options: { displayMode: boolean }): string;
}

displayMode is true for block (display) math and false for inline. Renderers should not throw on invalid input - they should return markup describing the error instead.

createKatexRenderer wraps a KaTeX module as a MathRenderer:

import katex from 'katex';
import { createKatexRenderer } from '@domternal/extension-math';
const renderer = createKatexRenderer(katex, {
throwOnError: false, // default: render an error node instead of throwing
output: 'htmlAndMathml', // default: emit a hidden MathML annotation for a11y
});
OptionTypeDefaultDescription
throwOnErrorbooleanfalseThrow on invalid LaTeX instead of rendering an error node
output'html' | 'mathml' | 'htmlAndMathml''htmlAndMathml'KaTeX output format. 'htmlAndMathml' emits a hidden MathML annotation alongside the visual HTML for screen readers

Any engine that returns an HTML string works. For example, wrapping MathJax or a server-rendered image:

import { MathInline } from '@domternal/extension-math';
const mathjaxRenderer = {
renderToString(latex, { displayMode }) {
return renderWithMathJax(latex, { display: displayMode }); // returns an HTML string
},
};
MathInline.configure({ renderer: mathjaxRenderer });

Insert an inline equation at the cursor.

editor.commands.insertMathInline('x^2');
// With text selected and no argument, the selection becomes the LaTeX source
// (Notion-style): select "a^2 + b^2" then run insertMathInline().
editor.commands.insertMathInline();

With no latex and an empty selection, an empty equation is inserted and the edit popover opens immediately. Returns false inside a code block.

Insert a display equation as its own block.

editor.commands.insertMathBlock('\\int_0^1 x^2\\,dx');
// Empty -> inserts a placeholder block and opens the editor
editor.commands.insertMathBlock();

On an empty line the block replaces that line (so no empty paragraph is left behind), except inside a list item or other container that requires a leading paragraph, where it is inserted after instead. Returns false inside a code block.

InputResult
$x^2$Inline equation with source x^2
$$Empty block equation, opens the editor

The inline rule fires when you close a $...$ span; the block rule fires when you type $$ at the start of an empty textblock. Neither fires inside a code block.

KeyContextAction
EnterA math node is selected (NodeSelection)Open the edit popover (WCAG 2.1.1: a keyboard path to the same action as a click)

Inside the popover, Enter applies, Shift+Enter inserts a newline, and Escape cancels.

ItemCommandIconLabelGroup
mathInlineinsertMathInlineradicalInline equationinsert
mathBlockinsertMathBlocksigmaEquationinsert

The inline item is also surfaced in the text bubble menu (bubbleMenu: 'text'), so selecting text and clicking it turns the selection into an equation, while the button stays in the main toolbar for the classic editor.

Both nodes register items in the Advanced group of the slash command and floating menu, matched by the keywords math, latex, equation, formula (plus inline / block):

ItemCommandDescription
Inline equationinsertMathInlineInsert an inline LaTeX formula
EquationinsertMathBlockInsert a block LaTeX formula

Editing is owned by MathEditing, a companion extension that both nodes add automatically (deduplicated by name, so adding both MathInline and MathBlock registers a single shared popover). It is a LaTeX <textarea> with a live preview:

  • Open by clicking an equation, or selecting it and pressing Enter.
  • Live preview renders as you type, using the same renderer.
  • Apply on Enter, on blur, or on a click outside the popover.
  • Cancel on Escape. A freshly inserted, still-empty equation is removed on cancel so no dangling “New equation” atom is left behind.
  • Empty value deletes the node on apply.

The popover re-anchors to a new node if you click a second equation while one is open, committing the in-flight edit first. Positions are confirmed against the live document before mutating, so an edit never writes onto the wrong node after intervening changes.

  • Keyboard editing. A node-selected equation opens the editor with Enter, matching the click affordance (WCAG 2.1.1).
  • MathML. The default KaTeX adapter uses output: 'htmlAndMathml', so a hidden MathML annotation accompanies the visual math for screen readers.
  • RTL. LaTeX source is inherently left-to-right; the theme isolates math (direction: ltr; unicode-bidi: isolate) so placeholder, raw-source, and error states stay readable in RTL documents.

Wrapper, state, and popover styles ship in @domternal/theme (_math.scss). The math glyphs themselves come from the renderer’s stylesheet (KaTeX’s katex.min.css).

ClassDescription
.dm-mathBase class on both nodes
.dm-math-inlineInline wrapper (display: inline-block)
.dm-math-blockBlock wrapper (centered, full-width)
.dm-math-emptyPlaceholder state (no LaTeX yet)
.dm-math-errorRender error state (renderer threw)
.dm-math-popoverThe shared edit popover (mounted to document.body)
.dm-math-popover-inputLaTeX <textarea>
.dm-math-popover-previewLive preview area
import {
MathInline, MathBlock, MathEditing,
createKatexRenderer,
mathEditPluginKey,
MATH_INLINE_NAME, MATH_BLOCK_NAME,
} from '@domternal/extension-math';
import type {
MathOptions, MathInlineOptions, MathBlockOptions,
MathRenderer, KatexLike, KatexRendererOptions,
MathEditingOptions, MathEditEvent,
} from '@domternal/extension-math';
ExportTypeDescription
MathInline / MathBlockNode extensionsThe inline and block equation nodes
MathEditingExtensionShared edit popover (auto-added by both nodes)
createKatexRendererfunctionBuild a MathRenderer from a KaTeX module
MathRenderertypeThe renderer interface (renderToString)
mathEditPluginKeyPluginKeyPlugin key that carries the “edit this node” signal
MATH_INLINE_NAME / MATH_BLOCK_NAMEstring constsThe ProseMirror node names

A paragraph containing an inline equation, followed by a block equation:

{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Euler's identity: " },
{ "type": "mathInline", "attrs": { "latex": "e^{i\\pi} + 1 = 0" } }
]
},
{
"type": "mathBlock",
"attrs": { "latex": "\\int_0^1 x^2\\,dx = \\tfrac{1}{3}" }
}
]
}

@domternal/extension-math - MathInline.ts, MathBlock.ts, MathEditing.ts