advanced-texteditor-md

Feature plugins#

Imports. The main entry exports only the authoring helpers (definePlugin, defineInlineSyntax, defineBlockSyntax). Every ready-made plugin, its pure helpers and its CSS string come from advanced-texteditor-md/plugins, so the editor's first download never carries them.

Six optional plugins. Each is a named export, tree-shakable, has no dependencies, and takes its options as one object. Install them through EditorOptions.plugins:

import { createEditor } from "advanced-texteditor-md";
import {
  createFindReplacePlugin, createDraftsPlugin, createTocPlugin,
  createTextStylePlugin, createSmartTypographyPlugin, createShortcodesPlugin,
} from "advanced-texteditor-md/plugins";

createEditor(el, {
  plugins: [
    createFindReplacePlugin(),
    createDraftsPlugin({ key: "post-42" }),
    createTocPlugin(),
    createTextStylePlugin({ underline: true }),
    createSmartTypographyPlugin({ locale: "de" }),
    createShortcodesPlugin({ shortcodes: { smile: "๐Ÿ˜€" } }),
  ],
});

Every plugin injects its own stylesheet when installed (once per document, removed with the last editor that used it). If you bundle CSS yourself, import "advanced-texteditor-md/plugins.css" holds all of them (the build checks it equals the strings the plugins inject), and each module exports its string (FIND_REPLACE_CSS, DRAFTS_CSS, TOC_CSS, TEXT_STYLE_CSS, SHORTCODES_CSS). Colours come from the --atm-* theme variables, each with a fallback. The find plugin's ::highlight() rules are the exception: their names are per editor, so that plugin adds them itself.

What the plugins rely on in the DOM editor.element exposes (they use nothing else of the editor's internals):

ElementMeaning
.atm-surfacethe WYSIWYG surface (created on first use, so it may not exist in Markdown mode)
textareathe Markdown source pane (loaded on demand: right after a switch to Markdown mode it may not exist yet)
.atm-previewthe split-mode preview
.atm-statusbarthe status bar, if the layout has one
.atm-readonly (class on editor.element)the editor is read-only

Every label is overridable through labels. Every control has an accessible name and works from the keyboard.


find-replace#

createFindReplacePlugin(options?) โ€” commands find, findNext, findPrevious, findClose, replaceAll.

Mod-f while the editor has focus opens a find bar docked at the top of the editor (role="search"). Mod-g / Shift-Mod-g / Enter / Shift-Enter step through the matches, Escape closes the bar, selects the current match and returns focus. Toggles: match case, whole word, regular expression. The counter ("3 of 12") is a polite live region. Replace and Replace all; Replace all is one undo step. In a regular expression the replacement understands $&, $1..$9, $$, \n, \t.

OptionDefaultItem
replacetrueshow the Replace row
includeCodetruesearch inline code and code blocks
caseSensitive wholeWord regexfalseinitial state of the toggles
maxMatches5000collect at most this many (the counter shows 5000+)
timeBudgetMs150stop scanning after this long
debounceMs60wait after typing before searching
highlightApi"auto"false forces overlay boxes instead of CSS.highlights
labelsfind, replace, count(current, total, capped), ...

Matches are found in the text of each block; they can cross bold/italic runs but never blocks, and are never found inside chips, math or other non-editable atoms. Markdown mode searches the source and selects the match in the textarea; a regular expression matches within one line there.

Invalid patterns are reported ("Invalid pattern"), never thrown. Patterns of the classic catastrophic shape ((a+)+, (.*)*) are refused ("Pattern too complex"). See docs/DECISIONS.md for what that does and does not guarantee.

Highlights: ::highlight(atm-find-<id>) and ::highlight(atm-find-<id>-current) when the browser has the CSS Custom Highlight API, where <id> is unique per editor, so two editors on a page never clobber each other's highlights (the plugin injects the matching ::highlight() rules and removes them with the editor); otherwise .atm-find-mark boxes inside .atm-find-overlay, which is not in the editable surface. When the Markdown pane arrives after a mode switch the plugin searches again on the editor's pane event (no polling). Replace all runs inside editor.transact.

Pure helpers: findMatches, compileFindQuery, scanFindMatches, isRiskyRegex, expandReplacement, collectFindRuns.

drafts#

createDraftsPlugin(options?) โ€” commands draft:clear, draft:save.

Autosaves editor.getValue() after a pause, offers it back on the next visit.

OptionDefaultItem
key"atm-draft"storage key; use one per document
storageguarded localStorage{ get(key), set(key, value), remove(key) }
debounceMs800
restorePrompt"ask""auto" restores at once, "ask" shows a banner, "never" ignores the draft
ttlMs7 daysolder drafts are dropped (Infinity keeps them)
maxBytes1_000_000UTF-8 bytes of the Markdown; larger is neither saved nor loaded
statusItemtrueshow the status in the status bar
subscribestorage event(key, notify) => unsubscribe, for stores other than localStorage
onStatus onRestore onErrorcallbacks

Stored form: {"v":1,"savedAt":<ms>,"value":"<markdown>"}. A draft equal to the starting value, an expired or corrupt one and one with another v are dropped without asking.

  • While the restore question is open nothing is written.
  • Another tab changing the key raises a "Sync / Ignore" banner; text is never replaced silently.
  • Status: "saved" | "saving" | "unsaved", in the status bar (.atm-draft-status[data-status]), to onStatus, as the editor event plugin:drafts:status (editor.on("plugin:drafts:status", ({ status, savedAt }) => ...), also exported as DRAFT_EDITOR_EVENT), and, for hosts that listen on the element, as a bubbling CustomEvent atm-draft-status (detail: { status, savedAt }) on editor.element.
  • A full or blocked store never throws: onError("quota" | "too-large" | "error" | "unavailable"), status unsaved.
  • setValue (restore, sync) does not call your onChange: use onRestore.
  • Call editor.exec("draft:clear") after a successful submit.

toc#

createTocPlugin(options?) โ€” command insertToc; also a slash item and a toolbar button.

Markdown (all that is stored):

::: toc
:::

::: toc min=2 max=3 limits the levels for one block. The outline is generated live (re-rendered debounceMs, default 150 ms, after a change) and is not part of the stored Markdown: in the editor it lives in the shadow root of a non-editable block. Each entry is a link; Tab reaches them, the arrow keys, Home and End move between them, Enter jumps to the heading and puts the caret there.

Headings get stable slugs: Hello, World! -> hello-world, duplicates -2, -3. In the editor the slug is the data-atm-slug attribute of the heading.

OptionDefaultItem
minLevel maxLevel1 6levels listed (a block's own min= / max= wins)
debounceMs150
labelstitle, empty, insert

For a rendered or read-only view of your own, static HTML cannot generate content. The plugin's postRender hook fills it in:

import { parse, renderHtml, renderDom } from "advanced-texteditor-md";
import { createTocPlugin, hydrateAll } from "advanced-texteditor-md/plugins";

const toc = createTocPlugin();
const syntax = toc.syntax;

// 1. DOM output: pass the hook to renderDom.
view.replaceChildren(renderDom(markdown, { syntax, postRender: [toc.postRender!] }));

// 2. An HTML string: insert it, then hydrate (a Doc or the Markdown itself as the third argument).
view.innerHTML = renderHtml(markdown, { syntax });
hydrateAll(view, [toc], markdown);

// 3. By hand: heading ids + the lists.
hydrateToc(view, parse(markdown, { syntax }));

getToc(editor, { minLevel?, maxLevel? }) returns { level, text, slug }[] for hosts that draw their own outline, in any mode. Also: buildOutline(doc), slugifyHeading, createHeadingSlugger, renderTocHtml.

text-style#

createTextStylePlugin(options?) โ€” command textStyle; a toolbar item (a swatch grid with names, plus "Clear").

Markdown:

[text]{.c-red}              text colour
[text]{.bg-yellow}          highlight
[text]{.c-red .bg-yellow}   both (colour first)
++text++                    underline (option underline)

The text between the brackets is Markdown: [**bold** and *italic*]{.c-red} and **bold with [a colour]{.c-red}** both survive.
OptionDefaultItem
colorsred, orange, yellow, green, blue, purple, pink, grayallowed .c-<name>
backgroundssame as colorsallowed .bg-<name>; [] disables
underlinefalsealso register ++text++ underline with Mod-u (the kbd plugin now uses [[Ctrl]], so both can be installed)
labels

The names are an allow-list fixed when the plugin is created; a class that is not on it is shown as plain text ([x]{.evil} is just text), and nothing is ever copied from the Markdown into a class or style. Names are lower-case letters, digits and - (RangeError otherwise). Colours are the CSS variables --atm-ts-<name> and --atm-ts-bg-<name>; override them (or add your own names and define their variables).

editor.exec("textStyle", { kind: "c" | "bg" | "all", name: "red" | null }) applies, changes or clears (a null name clears that kind; "all" clears both) on the selection, or on the whole span when the caret is inside one. Works in WYSIWYG and Markdown mode.

Colouring goes through editor.getSelectionMarkdown() and editor.replaceSelectionMarkdown(), so the selection's inline formatting (bold, italic, code, strikethrough) is kept; an existing span in the selection has its classes merged. Block markers, table rows and code fences are never wrapped.

Limitations: a link, image or chip cannot sit inside a coloured span (their brackets clash with the span's), so a selection holding one colours the text around it and leaves it alone; if one ends up inside a span anyway the colour is dropped on save rather than corrupting the link. When the selection lies inside bold, the new span repeats the bold (**one [**two**]{.c-blue} three**), which renders the same. Typing [x]{.c-red} by hand is not converted live; it becomes a span the next time the document is parsed (mode switch, setValue).

smart-typography#

createSmartTypographyPlugin(options?). WYSIWYG only.

OptionDefaultRule
locale"en""en" โ€œ โ€ โ€˜ โ€™, "de" โ€ž โ€œ โ€š โ€˜, "fr" ยซ ยป with narrow no-break spaces; or an object { doubleOpen, doubleClose, singleOpen, singleClose }
quotestrue"x" 'x' don't
dashestrue-- -> โ€“ , --- -> โ€” , never at the start of a line (a rule stays a rule), never in a table delimiter row
ellipsistrue... -> โ€ฆ
symbolstrue(c) (r) (tm) -> ยฉ ยฎ โ„ข (not after a letter or digit)
arrowstrue-> <- => <-> -->
fractionsfalse1/2 1/3 2/3 1/4 3/4, once the next space or punctuation is typed
multiplicationfalse3x4 -> 3ร—4 (0x4 is hex and is left alone)

The stored Markdown holds the real characters. Backspace right after a replacement restores exactly what was typed (one undo step); the next Backspace is an ordinary one. Not applied inside inline code, code blocks, links, math, chips or any non-editable atom.

It runs from the plugin hooks afterInput (so it also sees characters the surface inserts itself) and keydown (the Backspace revert); it keeps no DOM listeners.

Pure helpers: typographyRule(textBeforeCaret, resolveTypography(options)), simulateTyping(text, options), TYPOGRAPHY_LOCALES.

shortcodes#

createShortcodesPlugin({ shortcodes }) โ€” command insertShortcode (editor.exec("insertShortcode", "smile")).

You supply the table; no emoji data ships with the library and the values may be any text.

OptionDefaultItem
shortcodesrequired{ smile: "๐Ÿ˜€" }; names use letters, digits, _, +, -
minChars2characters after the colon before the menu opens
maxResults8
storage recentKey maxRecentguarded localStorage, "atm-shortcodes-recent", 20where recently used names are kept
labelsmenu (the list's accessible name). noResults is accepted but no longer shown

Type : and two characters and a listbox (the same menu as @mentions) lists matches: Up/Down move, Enter or Tab completes, Escape closes. Typing the closing colon of a known :name: replaces it at once. The colon must start the text or follow a space or punctuation, so 10:30 and http:// never open it. Recently used names rank first within the same kind of match (exact > prefix > word start > substring). The menu stays closed while nothing matches (MentionOptions.hideWhenEmpty), so a colon that is only punctuation never flashes a "no results" row. In Markdown mode there is no menu, but the closing colon and insertShortcode work.

kbd#

kbd (a ready-made plugin object, not a factory) renders [[Ctrl]] as <kbd class="atm-kbd">Ctrl</kbd>. It used to use ++Ctrl++, which is the underline marker of createTextStylePlugin({ underline: true }); see the changelog. The text between the brackets is literal.

Writing your own plugin#

A plugin is plain data. Every field is optional except name.

import { createEditor, definePlugin } from "advanced-texteditor-md";

const wordGoal = definePlugin({
  name: "word-goal",
  syntax: { inline: [{ name: "mark", open: "==", tag: "mark" }] },           // see CUSTOM_SYNTAX.md
  toolbar: [{ id: "mark", label: "Highlight", command: "custom:mark", shortcut: "Mod-Shift-h" }],
  commands: { insertDate: (editor) => (editor.insertText(new Date().toISOString().slice(0, 10)), true) },
  keymap: { "Mod-Alt-d": "insertDate" },
  slash: [{ id: "date", label: "Today", run: (editor) => editor.exec("insertDate") }],
  css: ".atm mark { background: var(--atm-mark-bg); }",                       // injected once per document, removed with the last editor
  setup(editor) {
    const off = editor.on("change", () => { /* ... */ });
    return () => off();                                                       // cleanup, called on destroy
  },
});

createEditor(el, { plugins: [wordGoal] });
  • commands live in the same map as built-ins; a host registerCommand("bold", ...) wins over a built-in.
  • highlight: LanguageDef[] registers code languages. css should read --atm-* variables rather than selecting on a theme name.
  • Plugins are tree-shakable named exports; they never import the editor internals.

Built-in syntax and commands a plugin can rely on#

  • details is a built-in block syntax (::: details Summary โ€ฆ :::). A plugin or host that defines its own block syntax named details replaces the built-in one; features.details: false (or ParseOptions.details: false) turns it off.
  • Commands available to editor.exec and toolbar items: details (insert a collapsible section), submit (dispatch atm:submit, then onSubmit), and the table commands tableAddRow, tableAddColumn, tableDeleteRow, tableDeleteColumn, tableAlignLeft, tableAlignCenter, tableAlignRight and tableDeleteTable (they act on the cell holding the caret).
  • Editing overlays (image frame, block handle, table toolbar) live outside the content, so postRender and the serialiser never see them. A plugin that adds its own overlay should do the same.

Plugin hooks#

Besides setup, a plugin can hook the editor without attaching DOM listeners. One plugin object may be installed in several editors, so keep per-editor state in a WeakMap<EditorInstance, State> (the hooks receive the editor).

FieldCalledContract
keydown(ev, editor)for every keydown in the active pane (surface or Markdown), after the menus and the layout, before the keymapreturn true to consume: the pane then calls preventDefault() and stopPropagation() itself (the same contract as beforeKeyDown). Check ev.isComposing if it matters. First plugin to return true wins
afterInput(editor, info?)after every content change the user makes: typed characters (including the ones the surface inserts itself and cancels beforeinput for, such as the first character of an empty block or one that replaces a selection), Enter, deletions, paste and drop; in both panesnot called for setValue; an edit the hook makes does not re-enter it. info is { inputType, data } when known (typing, Enter, deletions), undefined for paste and drop
postRender(root, { doc, mode })after the surface drew the document (setValue, undo and redo, late renderers) with mode: "editor", and after the split preview re-rendered with mode: "view"; also from renderDom(..., { postRender }) and hydrateAll with "view"idempotent; do not add content to the surface (see below)

postRender must not put anything into the editor's light DOM that is not content: the surface serialises its DOM to Markdown, so generated output goes into a shadow root or a contenteditable="false" element the serialiser ignores (the toc plugin keeps its list in a shadow root). A hook that throws is logged and the rest still run.

Editor API for plugin authors#

MemberWhat it does
editor.transact(fn)runs fn and makes every edit in it one undo step and one change / onChange, fired after fn returns with the final value (and not at all when nothing changed). Nested calls fold into the outermost. Returns what fn returns; if fn throws, what was done is still committed as one step and the error is re-thrown. getValue() inside already shows the edits. Works in both panes; direct DOM edits followed by an input event join the batch
editor.getPane()the active Pane (el, getValue, getSelectionText, getSelectionMarkdown, ...): the surface in wysiwyg, the Markdown pane in markdown and split. null after destroy() and while the lazily loaded Markdown pane has not arrived
editor.on("pane", fn)fn("wysiwyg" | "markdown") whenever the active pane is (re)mounted: on a mode switch that changes the kind of pane, and when the lazy Markdown pane arrives. Not fired between Markdown and split (same pane). Replaces polling for the textarea
editor.emit(type, payload?) / editor.on(type, fn)your own events, named "plugin:<plugin name>:<event>". The built-in names (change, mode, focus, blur, selection, mentions, pane) are reserved: emitting one is ignored. A throwing listener is logged and does not stop the others
editor.isReadOnly()true for readOnly, disabled and setReadOnly(true)
editor.getSelectionMarkdown()the selection as Markdown with its inline formatting (**bold** and [a link](url)), "" with no selection. In Markdown mode, the selected source
editor.replaceSelectionMarkdown(md)replaces the selection with parsed Markdown (a single paragraph goes in inline); one undo step; a no-op when read-only
MentionOptions.hideWhenEmptythe menu stays closed while there is nothing to list: no "No results" or "Searching..." row. For triggers that are usually ordinary text (:)
InlineSyntax.serialize(inner, data)inner is the node's children as Markdown when nested !== false (a coloured span holding bold writes **x**), and the literal text when nested: false
const counter = definePlugin({
  name: "counter",
  afterInput: (editor) => editor.emit("plugin:counter:words", editor.getStats().words),
  keydown: (ev, editor) => ev.key === "F2" && (editor.transact(() => { editor.insertText("a"); editor.insertText("b"); }), true),
});
editor.on("plugin:counter:words", (n) => console.log(n));

Feature subpaths#

Each feature below ships as its own subpath (advanced-texteditor-md/<name>), so the editor entry never carries it: import it, pass its plugin to plugins, and its commands, toolbar items, slash items and keys appear in the chrome like any other plugin's. Their stylesheet is part of style.css (src/styles/features/*.css); nothing is injected. Every one stores plain Markdown that other renderers show sensibly.

Alerts (advanced-texteditor-md/alerts)#

GitHub's alert syntax, rendered as coloured callouts with an icon and edited in place:

> [!NOTE]
> Useful information that users should know.
import { createAlertsPlugin } from "advanced-texteditor-md/alerts";

const alerts = createAlertsPlugin({ kinds: [{ name: "BUG", label: "Bug", color: "#a40e26" }] });
createEditor(el, { plugins: [alerts] });

// Read-only views: the same syntax, then the hook (or nothing: the CSS styles it alone).
view.innerHTML = renderHtml(md, { syntax: alerts.syntax });
hydrateAll(view, [alerts], md);

Kinds: NOTE, TIP, IMPORTANT, WARNING, CAUTION (GitHub's five) plus your own.

OptionDefaultItem
gfmtrueinclude the five GitHub kinds
kinds[]{ name, label?, color?, keywords? }: name is upper-case letters, digits and _ (BUG); color any plain CSS colour (anything else is dropped); a built-in name overrides its label
completiontruethe [! list inside a quote
toolbartruethe type switcher (alertType, a <select>)
labelsEnglishtypeSwitcher, none, menu, insert ("{label} alert"), and one title per kind (NOTE: "Note", ...)

Editing:

  • Type > and then [!: a list of kinds opens (the same listbox as mentions: Up/Down, Enter or Tab, Escape). It only opens at the very start of a quote, where GitHub reads a marker. Typing the whole marker ([!TIP]) converts it when ] is typed.
  • The marker is one non-editable piece: the caret steps over it and one Backspace removes it. Text typed right after it, and Enter right after it, go on the next line, so the body stays in the marker's paragraph (> [!NOTE]\n> text, GitHub's documented form).
  • Slash items "Note alert", "Tip alert", ... insert a new alert; the toolbar switcher shows the kind of the quote at the caret and changes it ("Plain quote" turns the alert back into a quote; a block that is not quoted is quoted first).
  • Commands: exec("alert", "WARNING") sets the kind at the caret, exec("alert", null) removes it, exec("insertAlert", "TIP") inserts one. Each is one undo step and works in the Markdown pane too (where the [! list is not available: the text is typed as is).

Accessibility: in read-only views each alert gets role="note" and starts with its title text ("Warning"), so a screen reader announces the kind before the body; the title colour is the accent mixed toward the text colour, which keeps AA contrast on the tinted background in every theme. Icons are CSS masks (no extra nodes), hidden in forced-colours mode where the border carries the meaning.

Pure helpers: alertSyntax(names), alertKindOf(block), findAlerts(doc), alertMarkdown(kind, body), setAlertInSource(src, caret, kind), customKindCss(kinds), GFM_ALERT_KINDS, ALERT_NODE.

Limits: a marker followed by text on the same line (> [!NOTE] Title) is not an alert, exactly as on GitHub; GitHub does not nest alerts, this library renders a nested one anyway. The callout plugin (::: note) is a different, older syntax and still works.

Code blocks (advanced-texteditor-md/code-blocks)#

import { createCodeBlocksPlugin } from "advanced-texteditor-md/code-blocks";
import diff from "advanced-texteditor-md/highlight/diff"; // also registered by the plugin's `highlight` field

const code = createCodeBlocksPlugin({ tabSize: 2 });
createEditor(el, { highlight, plugins: [code] });
view.replaceChildren(renderDom(md, { highlight, postRender: [code.postRender!] }));

The info string after the fence's language is kept (codeBlock.meta) and read:

```ts title="app.ts" {1,3-5} showLineNumbers=10 wrap
TokenMeaning
title="app.ts" (or title=app.ts, filename=)a file name shown above the block
{1,3-5}highlighted lines (1-based, inclusive, relative to the block)
showLineNumbers / showLineNumbers=10line numbers, optionally from 10
wrapwrap long lines by default
anything elsekept verbatim, in order
OptionDefaultItem
languagesDEFAULT_LANGUAGES (about 40)the picker's list (you can type any name)
copytrueCopy buttons (code bar, views)
lineNumbersfalsenumber every block, not only showLineNumbers ones
wrapfalsewrap every block (a view setting, never stored)
tabSize2tab width and indent unit (--atm-code-tab-size)
indentWith"spaces"or "tab"
autoIndenttrueEnter keeps the indentation, one level more after { ( [ : =>; Enter between {} opens an indented line
bracketstrue( [ { " ' \`` close themselves, a typed closer steps over, Backspace in ()` removes both, a selection is wrapped
tabIndenttrueTab / Shift+Tab indent / outdent the selected lines inside a code block
bartruethe code bar in the editor
difftrueregister the diff language
labelsEnglishbar, language, languageHint, title, copy, copied, copyFailed, wrap, lineNumbers, formatJson, formatted, invalidJson, code, tabHint

The code bar. While the caret is in a block, a small group (role="group", "Code block") floats above it: a searchable language field (<input list> over languages), the file name, Copy, Wrap (aria-pressed, per session), Line numbers (aria-pressed, writes showLineNumbers), and Format JSON (only for json blocks; invalid JSON is announced, nothing changes). Alt+F10 moves focus into it, Escape returns to the code. It is drawn outside the content.

Keyboard and focus. Tab inside a code block indents; to leave the editor with the keyboard press Escape and then Tab (the block says so through aria-description). Everywhere else Tab behaves as before. Nothing runs during an IME composition or while the editor is read-only.

Commands: codeCopy, codeWrap, codeLineNumbers, codeTitle (arg: the name; "" removes it), codeFormatJson, codeIndent, codeOutdent, codeLanguagePicker (focus the language field). codeTitle and codeLineNumbers also work in the Markdown pane (they rewrite the fence line).

Views (split preview, renderDom(..., { postRender }), hydrateAll, or decorateCodeBlocks(root, options) directly) get a header above each block with the title, the language and a Copy button (with a polite "Copied" announcement).

Diffs. ```diff (or patch) blocks get + / - line backgrounds and token colours (atm-tok-inserted, -deleted, -meta, -header) from the diff language, also published as advanced-texteditor-md/highlight/diff.

How it is drawn: only attributes on the <pre> (which dom-to-doc ignores) and pseudo-elements: the line numbers are pre::before with content: attr(data-atm-lines), highlighted and diff lines a background gradient in lh units, the editor's file name pre::after. So none of it can reach the Markdown, the caret never enters it, and it needs no layout measurement. Line numbers and bands are hidden while a block wraps (they cannot follow a wrapped line).

Pure helpers: parseCodeInfo, formatCodeMeta, parseRanges, formatRanges, lineBands, diffBands, pairAction, nextIndent, indentLines, formatJson, decoratePre, infoOfPre.

Tables v2 (advanced-texteditor-md/tables)#

Column resizing, sortable read-only tables, spreadsheet paste, CSV / TSV import, row and column moves, a header-row toggle and alignment shortcuts. The stored form stays the GFM pipe table; nothing the plugin draws ever reaches the Markdown. About 14 kB gzipped, no dependencies.

import { createEditor } from "advanced-texteditor-md"; import { renderDom } from "advanced-texteditor-md/render"; import { createTablesPlugin } from "advanced-texteditor-md/tables";

const tables = createTablesPlugin({ sortable: true }); createEditor(el, { plugins: [tables] });

// A read-only view with sortable columns: view.append(renderDom(markdown, { postRender: [tables.postRender!] })); // or, for renderHtml output: hydrateAll(view, [tables], markdown) from advanced-texteditor-md/plugins
OptionDefaultWhat it does
resizable"editor"Column resizing in the editor; true also in read-only views; false off.
sortablefalseHeader cells of read-only tables (views, and the editor while read-only) become sort buttons.
sort{}{ locale, dates }: the collator locale, and whether ISO dates (2024-01-31) compare as dates.
gripstrueA row grip and a column grip in the editor, for moving by drag.
pastetrueSpreadsheet clipboard data becomes a table.
limits{ maxBytes: 1e6, maxRows: 1000, maxColumns: 50 }Import refuses above them; paste keeps the first rows and columns.
import{ delimiter: "auto", header: true }CSV delimiter (",", ";", "\t", "auto") and whether the first row is the header.
onImportErrornone(error, editor) when an import is refused (also the plugin:tables:import-rejected event).
toolbar / slashtrueThe toolbar buttons and slash items.
keysTABLE_KEYSOverride or disable (false) any shortcut.
labelsEnglishEvery visible string and accessible name (TABLE_LABELS).

Commands: tableMoveRowUp, tableMoveRowDown, tableMoveColumnLeft, tableMoveColumnRight, tableToggleHeader, tableImport (arg: CSV text, a File / Blob, or nothing for a file picker accept=".csv,.tsv,text/csv,text/tab-separated-values"), tableFocusResize. Each edit is one undo step, keeps the caret in the moved cell, and is announced politely. The moves, the header toggle, alignment and import also work in the Markdown pane, on the table around the caret (not inside a block quote or a list item: there they return false).

Keys (Mod = Cmd on Apple platforms, Ctrl elsewhere; they act only in a table):

KeysAction
Mod-Alt-Shift-ArrowUp / ArrowDownMove the row up / down
Mod-Alt-Shift-ArrowLeft / ArrowRightMove the column left / right (its alignment moves with it)
Mod-Alt-Shift-L / E / RAlign the column left / centre / right (again: clear)
Mod-Alt-Shift-WFocus the caret column's resize handle
on a resize handle: ArrowLeft / ArrowRight (Shift: x5), PageUp / PageDown, Home10 px narrower / wider (50, 100), back to automatic
on a resize handle: Escape or EnterBack to the cell

Toolbar and slash: Import CSV (always), Toggle header row (pressed while the table has a header), Move row up / down, Move column left / right (enabled only with the caret in a table).

Resizing. Drag the right edge of a header cell, or use the handle from the keyboard (role="separator", aria-orientation="vertical", aria-valuenow in px). Widths are VIEW-ONLY: GFM cannot store them, so they live in a <colgroup> the plugin owns for as long as the page lives (they follow a moved column, and are gone after a reload). In the editor the handles are drawn in a layer outside the surface; in views (resizable: true) inside the header cells.

Sorting (sortable: true) cycles ascending, descending, original order; the sorted header cell carries aria-sort; numbers compare as numbers (1,234.5, $12, 40 %), text with a numeric-aware Intl.Collator; empty cells go last; the sort is stable; each change is announced politely. Only the view's row order changes. A headerless table is not sortable.

Spreadsheet paste. Excel, Google Sheets and Numbers put TSV in text/plain (and a <table> in text/html). It is taken as a table when every non-empty line has at least two tab-separated cells and there are two lines or more (or one line when the HTML holds a table); quoted cells may hold tabs, line breaks and quotes. Tab-indented text is not a table. Spreadsheet HTML without usable TSV (a single column, for instance) is read cell by cell (<br> becomes a space, <colgroup> and styles are dropped, empty trailing rows go). Never in a code block. Inside a table cell the values fill consecutive cells from the caret (rows and columns are added). In the Markdown pane a paste inside a table or a fence stays text. Rich HTML that is not a table is left to the editor's own paste.

Header toggle. GFM always has a header row. "Off" moves the header row into the body and leaves an empty header row (| | |); "on" promotes the first body row back. A table with an empty header row gets data-atm-tables-headless: "editor" shows it as a thin dashed row the caret can still reach, "view" hides it visually and keeps it for assistive technology. Views get the attribute from postRender / hydrateAll.

Values are literal. CSV and pasted values are escaped (\*, \|, \<, \[, \$, \&, ...) so **x**, a|b or <img onerror> show as typed and a pipe can never add a column. A line break inside a value becomes a space (a pipe-table cell is one line). An import over a limit is refused with a visible role="alert" notice, an announcement, the event and onImportError.

Cell merge is not supported: GFM has no colspan / rowspan, and every encoding of one would turn into a broken table in every other renderer (see DECISIONS).

Pure helpers: csvToTable, convertCsv, parseDelimited, sniffDelimiter, rowsToTable, escapeCell, tsvRows, moveRow, moveColumn, toggleHeader, toggleAlign, headerIsEmpty, findTable, formatTable, cellStart, splitCells, sortOrder, parseNumber, resizeKey, inFence. Events: plugin:tables:paste, plugin:tables:import, plugin:tables:import-rejected.

Diagrams and embeds (advanced-texteditor-md/diagrams)#

Fenced blocks such as ```mermaid, ```chart or ```tex are drawn by renderers you register. No diagram library is bundled, imported or loaded from a CDN; the entry is about 6 kB gzipped.

import { createDiagramsPlugin, renderDiagrams } from "advanced-texteditor-md/diagrams";

const diagrams = createDiagramsPlugin({ renderers: { // An element (or SVG element) is YOUR dom and is inserted as is. mermaid: async (code, ctx) => { const { svg } = await myMermaid.render(ctx.id, code, { theme: ctx.theme }); return svg; // a string: untrusted, so it goes into <iframe sandbox="">, see below }, chart: (code, { signal }) => drawChart(JSON.parse(code), signal), // returns an SVGElement }, debounceMs: 300, mode: "replace", }); createEditor(el, { plugins: [diagrams] });

Markdown stored: a plain fenced block, ```mermaid title="Login flow" ... ```. Nothing about the diagram is ever written into it. GitHub draws mermaid blocks itself; every other renderer shows the code.

Options#

OptionDefaultItem
renderersrequired{ lang: (code, ctx) => HTMLElement | SVGElement | string | Promise<...> }. Keys are matched lower-case, as plain keys (nothing is inherited, __proto__ is just a name).
trustfalsetrue, or { lang: true }: strings from that renderer are parsed as markup (see below) instead of sandboxed.
debounceMs300Wait after the last keystroke in a block before the preview re-renders.
mode"replace"Read-only views. "replace": the diagram instead of the code, with a "Show source" toggle. "below": the diagram, and the code behind a "Show code" toggle under it. The editor always shows code with its preview under it.
cacheSize50Results kept by (language, theme, meta, code), least recently used evicted first.
frame{ height: 240 } CSS default{ height?: number; aspectRatio?: "16 / 9" } for the sandboxed iframe (its content cannot be measured).
lazytrueRender a block only once it is on screen (IntersectionObserver; immediately when it does not exist).
labelsEnglishdiagram ("{lang} diagram"), loading, error, stale, showSource, hideSource, showCode, hideCode, insert.
onError(error, ctx), when a renderer throws or rejects (not for an aborted render).

ctx is { lang, meta, signal, theme, id }: meta the parsed info string (parseDiagramMeta), signal an AbortSignal aborted when the code changes before the render finished and on destroy, theme "light" | "dark" from the nearest data-atm-theme, else prefers-color-scheme, id unique and stable per block.

What you can return#

  • An element or SVG element: it is your DOM and is inserted as is. You are responsible for it (the library does not sanitise your own nodes). A cached result is cloned with cloneNode(true), so event listeners you attached do not survive a cache hit: attach them from the renderer's output only if you can tolerate that, or return fresh nodes (cache misses always run your renderer).
  • A string is untrusted by default and goes into <iframe sandbox="" srcdoc="..." loading="lazy" referrerpolicy="no-referrer" title="...">: no scripts, no same-origin, no forms, no popups, plus a Content-Security-Policy meta (default-src 'none'; img-src data:; style-src 'unsafe-inline') so it cannot make requests. It is inert whatever it contains, at the cost of a fixed size (frame). With trust the string is parsed through a <template> and inserted into the page after stripping <script>, <iframe>, <object>, <embed>, <base>, <meta>, <link>, on* attributes, srcdoc, javascript: / vbscript: / non-image data: URLs, an href animated by <set> / <animate>, and CSS with @import / expression(). trust means you vouch for the renderer: it is hygiene, not a sandbox.
  • Anything else (including a <script> element) is shown as an error.

In the editor#

A preview appears under every code block whose language is registered, updated (debounced) while you type in it, with a loading state; during an IME composition nothing is re-rendered until it ends. When the renderer throws, the error text (escaped) shows under the diagram in a polite live region (not role="alert", and it is only re-announced when the message changes), and the previous good render stays visible, dimmed, marked data-stale.

The preview is not part of the document: it lives in a layer laid over the surface (a sibling of .atm-surface, clipped to it) and is placed under its code block by measurement; the room it needs is reserved with a bottom margin on the block (--atm-diagram-reserve, class atm-diagram-host, both removed on destroy). getValue() and the Markdown never change, Enter, Backspace and the arrow keys around the block behave exactly as without the plugin (the e2e spec compares both, in all four engines), and clicks on the preview do not take the caret.

Read-only views#

const view = renderDiagrams(root, { renderers }); // root already holds renderDom / renderHtml output view.destroy();                                   // puts the code blocks back

Also reachable as the plugin's postRender (renderDom(md, { postRender: [diagrams.postRender] }), hydrateAll(root, [diagrams], doc), the split preview). Calling it again on the same root undoes the previous run first. Each diagram is div.atm-diagram holding div[role="img"] (named by title= or "<Lang> diagram") and a toggle button[aria-expanded][aria-controls] that reveals the source <pre>.

Commands, toolbar, slash#

editor.exec("insertDiagram", lang?) turns the current block into (or inserts) a fenced block of that language (the first registered by default; unknown names return false). Toolbar item diagram (group insert); one slash item diagram-<lang> per registered language.

Limits#

No diagram is drawn in the Markdown source pane (the split mode's preview is a read-only view and is drawn). The theme is read when a block renders, not on a later theme switch (edit the block or reload). The editor preview is positioned by script from measured rectangles, so in jsdom (no layout) it is created but not placed; real browsers follow scroll, resize and edits. Printing hides the preview layer.

Compare and version history (advanced-texteditor-md/diff)#

import { createDiffView, diffBlocks, diffWords, diffArrays, createHistoryStore, createHistoryPlugin } from "advanced-texteditor-md/diff";

Two Markdown documents in, a labelled comparison out: side by side or inline, with accept / reject per change. Nothing here touches the editor unless you use the history plugin. It needs the parser and renderer, so the entry's gzip size (about 24 kB) is mostly those two, which the editor already ships; its own code is about 5 kB.

The diff core (pure, no DOM)#

FunctionDoes
diffArrays(a, b, eq?, limits?)Myers O(ND) over any arrays. Returns ops { type: "equal" | "insert" | "delete", a: [start, end), b: [start, end) } that tile both arrays. diffArraysDetailed also reports capped.
diffWords(a, b, limits?)Tokenises (words with \p{L}\p{N}\p{M}, runs of whitespace, single punctuation marks, one token per Han / Hiragana / Katakana character) and diffs the tokens. Returns { a, b, ops, capped }. Whitespace between two changes joins them.
diffBlocks(mdA, mdB, parseOptions?, { granularity, similarity, limits })Normalises both (stringify(parse(x))), splits them into top-level blocks, diffs the block lists, then pairs the deleted and inserted blocks of a change by type and word similarity (default 0.5): a pair is one modify row with a word diff, the rest are plain delete / insert rows. Returns { a, b, segments, hunks, capped }.
mergeBlocks(diff, decide)The merged Markdown: equal blocks, and for each hunk the first ("a") or second ("b") document's blocks. Computed on the Markdown source and normalised, so it round-trips.
normalizeMarkdown(md, parseOptions?), splitBlocks, joinBlocksThe pieces the above are built from.

A hunk is one decision: every paired (modified) block is its own hunk, and the unpaired deletes and inserts between two of them form another. A list or a table is one block, so an edited item makes the whole block "modified" and the word diff points at the item.

Limits. The search stops when the edit distance passes limits.maxEdits (default 1500) or the step count passes limits.maxWork (default 4,000,000); the changed middle is then reported as one delete plus one insert and capped is true. A common prefix and suffix are trimmed first, so an edited document costs about O(n). The cap, not the input size, bounds the cost: two 50,000-token inputs with nothing in common are answered "everything changed" after at most a few million steps.

createDiffView(a, b, options)#

const view = createDiffView(original, edited, { mode: "split",            // "split" (default) | "inline" container: document.querySelector("#diff")!, render: { syntax: { inline: [...] }, highlight, chips },   // the library's RenderOptions; also used to parse both sides granularity: "word",      // "word" (default) | "block" pending: "a",             // what getMerged() uses for an undecided change: "a" (default) keeps the original, "b" takes the new onAccept: (hunk, i) => {}, onReject: (hunk, i) => {}, onChange: (merged) => {}, // after every decision labels: { region: "Comparison" /* see DiffLabels */ }, }); view.element;        // the region; append it yourself when you pass no container view.hunks; view.summary;   // { changes, insertions, deletions } view.getMerged();    // Markdown: unchanged blocks + per change B (accepted), A (rejected) or `pending` view.accept(i); view.reject(i); view.clear(i); view.acceptAll(); view.rejectAll(); view.next(); view.previous();    // move focus to a change (wraps) view.setMode("inline"); view.destroy();

acceptAll() gives the normalised B and rejectAll() the normalised A (property-tested on random edits), and any mix is a fixed point of stringify(parse(x)).

Accessibility. The view is a role="region" named by labels.region; each change is a role="group" named "Change 2 of 5, modified"; every inserted and deleted run starts with a visually hidden "Inserted:" / "Deleted:", and whole blocks also get a visible + / โˆ’ marker, an underline (insertions) or a strike-through (deletions), so colour is never the only signal. A live region announces navigation and decisions; the summary ("3 changes: 2 insertions, 1 deletion") is plain text. Buttons are named "Accept change 2 of 5" / "Reject change 2 of 5" and toggle aria-pressed (pressing the active one clears the decision). Keys, when focus is inside the view and not in a text field: <kbd>N</kbd> / <kbd>P</kbd> or <kbd>Alt</kbd>+<kbd>โ†“</kbd> / <kbd>โ†‘</kbd>; keys are ignored during IME composition. In the side-by-side layout the unchanged text in the right column is hidden from assistive technology (it repeats the left one). Below 640 px the two columns stack and name themselves.

Version history#

const store = createHistoryStore({ key: "doc-42-history", max: 50, maxBytes: 2_000_000 }); store.add(editor.getValue(), "Before review");   // Snapshot | null (null: equal to the newest, or too large) store.list();                                     // newest first: { id, label?, at, value }[] store.get(id); store.remove(id); store.clear(); store.restore(id, editor);                        // see below const view = store.compare(idA, "current", editor, { mode: "inline" });   // a DiffView

Options: storage ({ get, set, remove }; default guarded localStorage; null = memory only), key, max, maxBytes (UTF-8 bytes of Markdown, oldest dropped first), now, onError(kind). What is stored is a versioned envelope { v: 1, snapshots: [...] }, like the drafts plugin's. Every read rebuilds each snapshot field by field: a corrupt or hostile envelope reads as empty (and calls onError), bad snapshots and __proto__ keys are dropped, ids must match [\w.:-]{1,80}, labels are cut to 200 characters (always show them as text). On a full store the oldest snapshots are dropped and the write is retried. Two stores on one key (two tabs) see each other's snapshots.

restore and undo. restore runs transact(() => setValue(value, { keepHistory: true })): the editor emits change / onChange once (a bare setValue emits neither), so the drafts plugin saves the restored text. In Markdown mode keepHistory keeps the undo stack and one undo brings the old text back. The WYSIWYG surface ignores keepHistory and clears its undo stack on any setValue, so by default restore first saves the current text as a snapshot labelled "Before restore" (skipped if it equals the newest; { backup: false } turns it off, a string relabels it).

createHistoryPlugin({ store, autoSnapshotMs?, labels?, diff?, onCompare? }):

CommandDoes
history:snapshotSaves a snapshot, no prompt. Argument: a label string or { label }. Returns false when it equals the newest. Emits plugin:history:snapshot.
history:compareOpens a labelled panel (role="dialog", Escape closes it and returns focus, a "Restore this version" button) with the diff of { a, b } (snapshot ids or "current"; default the newest snapshot against the current text). With onCompare you get the DiffView instead and place it yourself.
history:restoreRestores { id } (or an id string; default the newest). Emits plugin:history:restore.

Toolbar buttons "Save version" and "Compare versions" are added to the tools group. autoSnapshotMs takes a snapshot that long after the last change when the text differs from the newest snapshot (labelled "Automatic"); it waits while an IME composition is open. Next to the drafts plugin: drafts keep one unsaved copy to survive a crash; history keeps many named versions on purpose. Use different storage keys (the defaults differ) and list both in plugins; a restore counts as an edit, so the draft follows it.

Markdown stored: none of its own; the plugin only reads and writes the document text. In GitHub or any CommonMark viewer the document is just the document.

Limits: a changed list or table is compared as one block; text-level marks inside code blocks and math follow the rendered text, not the source; the merged Markdown is always the normalised form (stringify(parse(x))), so unchanged blocks can come back with different but equivalent spelling (* bullets become -).

Export and import (advanced-texteditor-md/export)#

Copy, download, print and import. Everything is built from the Markdown the editor stores.

import { createEditor } from "advanced-texteditor-md"; import { createExportPlugin, exportHtml } from "advanced-texteditor-md/export";

const editor = createEditor(host, { value: "# Plan\n\nText", plugins: [createExportPlugin({ importMode: "ask" })], });

editor.exec("copyRich");           // text/plain + text/html on the clipboard editor.exec("downloadHtml");       // plan.html, a complete self-contained document const page = exportHtml(editor, { standalone: true }); // the same document as a string
CommandWhat it does
copyMarkdownMarkdown of the selection, or of the whole document when nothing is selected.
copyHtmlThe HTML source as text (a fragment; { standalone: true } for a whole document).
copyTextThe visible text.
copyRichOne clipboard item with BOTH text/plain and text/html, so a rich target (a mail editor, a word processor) gets formatting and a plain one gets text.
downloadMarkdown, downloadHtmlDownload .md / .html. Argument: a file name (string) or { filename }.
printPrint only the document.
importFileOpen the file picker (.md .markdown .txt .html .htm), or import the File you pass.

Copy commands take { selection: false } to copy the whole document even when something is selected. A copy returns true when it started (it returns false for an empty document); the outcome arrives as the event plugin:export:copied ({ format, scope: "selection" | "document", ok, method, length }, method is "clipboard-item", "write-text" or "copy-event") and in a polite live region. When the Clipboard API is missing or refused (no permission, an insecure page, an older browser) a copy event is run on a hidden textarea and its clipboardData is filled, then the selection and focus are restored.

Other events: plugin:export:downloaded ({ format, filename, size }), plugin:export:printed ({ mode }), plugin:export:imported ({ ok, kind, name, size, mode, source } or { ok: false, reason, ... }, reason is unsupported | too-large | binary | empty | unreadable | cancelled).

Options#

OptionDefaultMeaning
selectiontrueCopy the selection when there is one.
filenamefirst heading's slug, else documentA string or (editor) => string. Always sanitised: no path separators, control, bidi or zero-width characters, none of \ / : * ? " < > | %, no leading dot or trailing dot / space, Windows device names (CON, NUL, ...) renamed, at most 100 characters, one extension.
html{}title, lang, dir, css, theme, render for the HTML export (see exportHtml).
richText"text"What a rich copy carries as text/plain: "text" or "markdown".
importMode"ask""replace", "insert" or "ask" for a file imported into a document that already has content.
maxImportBytes2 MBLarger files are refused with a reason.
txt"text"A .txt file is inserted as escaped text; "markdown" parses it instead.
droptrueDrop a single .md, .markdown or .txt file (by extension or MIME type) onto the editor to load it.
confirmReplacethe bar(file) => boolean | Promise<boolean>: true replaces, false cancels. Replaces the built-in bar.
printMode, print"iframe""window" prints the page itself; print(win) replaces the print call (tests, a custom dialog).
toolbar, slash, statustrueExport menu button, slash items, the live region.
onStatusnoneCalled with every status line.
labelsEnglishEvery visible string ({name}, {limit}, {what} are replaced).

exportHtml(markdown | editor, options)#

A fragment by default (the library's renderHtml with the editor's render options: syntax, chips, highlight, ...). With standalone: true, one complete document: doctype, <meta charset>, viewport, an escaped <title>, lang and dir, a Content-Security-Policy (default-src 'none'; img-src https: data:; style-src 'unsafe-inline') and one inlined stylesheet. No script and no external request, so an exported file is inert wherever it is opened. Options: title, lang, dir, css (a string is added after the built-in sheet; false leaves the document unstyled), theme ("light", "dark", "auto"), render.

The palette: given an editor, the document carries that editor's resolved --atm-* values (see THEMING), with every value checked against a deny-list (anything with ;, braces, quotes, backslashes, url(, expression, @import or javascript: is dropped and the default stays). Given Markdown, it carries the light palette and a prefers-color-scheme: dark block. The print command always uses the light palette.

Print#

print opens a hidden, sandboxed iframe with the standalone document, prints it, and removes it on afterprint (or shortly after the call returns). If a frame cannot be made it prints the page with atm-printing on <html> and atm-print-root on the editor, which hides everything else. The stylesheet (@media print in style.css) hides the toolbar, status bar, menus, popovers, handles and the drop bar, prints black on white, and avoids breaking inside code blocks, figures, table rows and quotes. Add the class atm-print-urls to the editor (or an ancestor) to print each link's address after it.

Import and drop#

Files are read as UTF-8 (a BOM is dropped). A file with NUL bytes (binary), over the size cap, empty or of an unsupported type is refused with a reason shown in the status line. HTML goes through the library's inert HTML-to-Markdown converter and the editor's link policy (the same one paste uses). .docx is not supported: it is a zipped OOXML package, which needs an unzip and an OOXML parser, far beyond the size budget of this entry and a dependency this library does not take.

A file dropped on the editor loads at once when the editor is empty. When it has content, the bar (role="alertdialog", inside the editor, focus moves to Cancel, Tab cycles Replace / Insert / Cancel, Escape cancels, focus returns to where it was) asks first, or confirmReplace does. Any other drop (an image, several files, a PDF) is not touched and reaches the editor's upload path. Replace runs as one transaction, so one undo step restores the old document.

Toolbar and slash#

The toolbar item is a menu button, "Export", with all eight commands (arrow keys, Home, End, Escape; entries that cannot work are disabled: nothing to export in an empty editor, no import in a read-only one). On a narrow screen the toolbar's overflow menu adopts it. Slash items: one per command.

Accessibility#

The status line is role="status", polite. The bar is an alertdialog with an accessible name and description; the menu follows the menu-button pattern. Nothing acts during an IME composition (the plugin has no key handler and no input hook). The plugin's UI lives outside the surface or is removed on destroy, and never reaches the Markdown.

What other renderers show#

Nothing: export never changes the stored Markdown. An imported .txt is stored as Markdown with escapes (\#, \*) that GitHub and CommonMark viewers render as the same text.

Mentions v2 and chips v2 (advanced-texteditor-md/chips)#

import { createChipCardsPlugin, createGroupMentions, expandGroupMentions, createTagTrigger, createChannelTrigger, createCommandTrigger, rankMentions, createMentionRanker, createMarkdownMentionsPlugin, createChipDecorPlugin, createChipPickerPlugin, chipMarkdown, } from "advanced-texteditor-md/chips";

The wire format does not change: a chip is stored as [@Label](scheme:kind/id?k=v). Everything below is a plugin or a pure helper; the subpath's eager download is about 12 kB gzip, and the menus, cards and dialogs are lazy chunks fetched when a plugin is set up or first used.

A Plugin cannot add mention triggers or chip definitions (both are fixed when the editor is created). The presets therefore return the pieces and you spread them in yourself:

const people = { search: (q) => api.people(q) }; const groups = createGroupMentions({ groups: [{ id: "team", label: "team", members: () => api.team() }] }); const ranker = createMentionRanker(); const tags = createTagTrigger({ tags: ["design", "bug"] }); const channels = createChannelTrigger({ channels: [{ id: "c1", label: "general" }] }); const commands = createCommandTrigger({ commands: [{ id: "sig", label: "Signature", run: (ed) => ed.insertText("-- me") }] });

createEditor(el, { mentions: [ranker.wrap(groups.wrap(people)), tags.mentions, channels.mentions], chips: [...tags.chips, ...channels.chips], plugins: [ ranker.plugin, tags.plugin, commands.plugin, createMarkdownMentionsPlugin(), createChipCardsPlugin({ getCard: (chip, { signal }) => (chip.kind === "group" ? groups.card(chip, { signal }) : api.card(chip, signal)) }), createChipDecorPlugin({ removable: true }), createChipPickerPlugin({ id: "people", label: "Insert person", scheme: "mention", search: people.search }), ], });

Hover cards: createChipCardsPlugin(options)#

OptionDefaultItem
getCard(chip, { signal })requiredReturns an HTMLElement (your markup, used as is), a data object { title, subtitle?, avatarUrl?, fields?: {label, value}[], list?: { label?, items: string[] }, links?: {label, href}[] }, null, or a Promise of one.
delayMs350Hover delay. The caret opens a card after at most 150 ms.
graceMs200Time the pointer has to move from the chip into the card.
schemesallOnly chips of these schemes get cards.
cacheSize100Results are cached per chip (scheme:kind:id).
labelsEnglishcard, editorHint, viewHint, more(n).
  • Mouse: hover a chip; the card stays while the pointer moves into it. Not on touch screens (hover: none).
  • Keyboard, editor: chips are atoms, so when the caret sits right next to a chip (or the chip is selected) its card opens. A card with links is a non-modal dialog: Alt+ArrowDown moves focus into it, Escape returns focus to the editor at the same caret. Escape with the caret beside a chip closes the card (and is consumed only then).
  • Read-only views (postRender with mode "view": the split preview, renderDom(md, { postRender: [p.postRender] }), hydrateAll): chips of the configured schemes get tabindex="0" and role="button" (never inside the editing surface). Focus opens the card; Enter or Space moves focus into a card with links; Escape closes and focus returns to the chip.
  • Touch: a press held for 500 ms on a chip opens the card (the system context menu is suppressed for that press); a tap anywhere else closes it. A mouse press is not a long-press.
  • Pointer and states: a chip with a card gets data-atm-interactive, cursor: pointer, a hover tint and underline, an active tint and a :focus-visible ring (a 1px ButtonText border and Highlight hover in forced-colors mode; no transition under prefers-reduced-motion). In the editor the attribute is set on first hover and when a card exists; a chip the host answers null for loses it and keeps the default cursor. In views every chip of the schemes is marked at once and loses the mark when getCard answers null. Cards are RTL-safe: they copy the editor's dir, theme and density.
  • A card with nothing to reach is role="tooltip", referenced by the chip's aria-describedby. A card with links is role="dialog" with aria-modal="false"; in views the chip also gets aria-haspopup="dialog" and aria-expanded.
  • Data is drawn with textContent. avatarUrl and links[].href must pass urlAllowed with http/https, anything else is dropped; strings are capped (200 / 500 characters), at most 20 fields, 50 list items and 10 links are shown.
  • The card is a child of document.body, position: fixed, and copies the editor's data-atm-theme. The signal aborts when the card closes before the data arrived and when the editor is destroyed.

Cards in any read-only markup: enhanceChipCards(root, options)#

options is the same object createChipCardsPlugin takes. It binds the chips inside root (markup you rendered yourself: renderHtml output, innerHTML, a framework's DOM) and returns { refresh(), destroy() }.

const h = enhanceChipCards(container, { getCard, delayMs: 200 }); h.refresh();  // after the markup changed: binds chips that appeared since (a bound chip is skipped) h.destroy();  // closes the card, unbinds, and removes the tabindex / role / data-atm-interactive it added

Chips get tabindex="0", role="button" and data-atm-interactive only when they have none (a host's own tabindex or role is kept and left alone on destroy). Chips inside a contenteditable are skipped. It does nothing without a DOM element, so it is safe to call during server rendering. The React wrapper calls it from an effect for <MarkdownView cards={...} />.

Group mentions: createGroupMentions({ groups, section?, maxGroups?, labels? })#

A group is a chip of kind "group": [@team](mention:group/team). Groups are { id, label, description?, members?: ({ signal }) => MentionItem[] | Promise }.

  • groups.wrap(mentionOptions) merges the matching groups into a search: ranked first, or under their own heading with section: true (groupBy, labels groups / others). A group the host search also returned is not listed twice.
  • groups.card(chip, { signal }) is a ready getCard for group chips: title, "Group ยท N members" and the member list.
  • expandGroupMentions(chips, resolver) fans group chips out for notifications: direct mentions first, then members, each scheme:kind:id once with the groups it came through in via; nested groups are expanded, cycles and depth (8) are bounded. resolver(groupChip) returns the members or null.
  • Group chips are styled by CSS alone from the renderer's class atm-chip-kind-group (a distinct colour and a leading icon), so renderHtml output shows them too.

Presets#

PresetReturnsDefaults
createTagTrigger({ tags? | search?, trigger?, scheme?, allowCreate?, pattern?, maxResults?, labels? }){ mentions, chips, plugin }trigger #, scheme tag, allowCreate: true, no spaces in a tag
createChannelTrigger({ channels? | search?, trigger?, scheme? }){ mentions, chips }trigger ~, scheme channel
createCommandTrigger({ commands, trigger?, slash?, labels? }){ plugin, slash }trigger >, also listed in the slash menu
  • Tags: with allowCreate, the menu ends with a "Create #name" row for a tag that is not in the list, and typing #newtag followed by a space turns it into a chip ([#newtag](tag:newtag)) in both panes, as one undo step. A new tag must match pattern (default: letters, digits, _, -, /, 1-50 characters, at least one letter), so #1 and a#b stay text. Ids are the lower-cased name.
  • Channels: ~ by default because # belongs to tags (and is a Markdown heading at the start of a line); pass trigger: "#" when you do not use tags. The stored text escapes the tilde: [\~general](channel:c1).
  • Commands: / is the editor's slash menu, so the command trigger is > (a trigger: "/" is ignored). Picking a command removes the typed >query and runs command.run(editor) as one undo step, in either pane. slash: false leaves the commands out of the slash menu; trigger: false keeps only the slash items.

Ranking: rankMentions(items, query, ctx) and createMentionRanker(options)#

rankMentions is pure: match class first (exact, prefix, word start, substring, then whatever the host returned that does not contain the query), then, inside a class, the most recent pick, then the highest decayed frequency (a pick's weight halves every halfLifeMs, default 14 days), then the host's order.

createMentionRanker({ storage?, key?, max?, maxRecent?, halfLifeMs? }) keeps that history (default localStorage, guarded; key atm-mention-rank, 200 keys, 10 recent). ranker.wrap(mentionOptions) or ranker.ranked(search, scheme) re-ranks a host search (sync or async). Picks are recorded by ranker.record(item) and, with ranker.plugin installed, from the editor's mentions event: every chip that newly appears counts once per editor session; chips that a full render drew (setValue, undo, redo) are not picks.

Markdown pane: createMarkdownMentionsPlugin({ mentions?, labels? })#

The editor's own typeahead exists only in the WYSIWYG view. This plugin gives the Markdown and split modes the same menu, from the same editor.options.mentions (one option or an array; triggers, schemes, minChars, debounceMs, hideWhenEmpty, allowSpaces, groupBy, renderItem, async search with AbortSignal). The menu uses the same classes (atm-mention-menu, -list, -option, ...) and is placed at the caret with the pane's mirror measurement. Keys: Up/Down, Home/End, Enter/Tab pick, Escape closes (and stops propagation only while open). A pick replaces @query with the wire text, the label escaped exactly as stringify escapes it ([, ], \, | in a table row, $ beside another dollar, a ! before it becomes \!), as one undo step. The textarea gets aria-controls and aria-activedescendant while the list is open (never aria-expanded: it is a textbox). Nothing happens during an IME composition.

Decorations: createChipDecorPlugin(options)#

OptionDefaultItem
icon(chip)noneSVG markup you wrote, inserted as is (trusted by contract: never build it from a chip's label, id or refs).
avatar(chip)noneAn http(s) image URL; anything else is dropped. Ignored when icon returned markup.
removablefalsetrue or (chip) => boolean: an "x" button inside the chip (aria-label "Remove @Jane", not in the tab order: Backspace and Delete already remove a chip). A click removes it, with one adjacent space, as one undo step. Editor only, never while read-only.
editableLabelfalsetrue or (chip) => boolean: a click, or Enter on a selected chip, opens a small dialog with a text field. Enter applies (same scheme, kind, id and refs, new label, one undo step), Escape cancels; focus returns to the editor with the chip selected.
schemes, labelsall, Englishremove(label), edit(label), field, apply, cancel.

Decorations carry contenteditable="false" and data-atm-preview-card and hold no text, so getValue() is the same with and without them. They follow new chips (a MutationObserver on the surface) and are drawn again after undo, redo and setValue (postRender); in read-only views only icons and avatars are drawn.

Picker: createChipPickerPlugin({ id, label, icon?, search, scheme, trigger?, kind?, debounceMs?, maxResults?, groupBy?, renderItem?, labels? })#

Adds a toolbar button and the command chipPicker:<id>. It opens a modal dialog with a search field (role="combobox", aria-autocomplete="list", aria-controls, aria-activedescendant) and a listbox. Up/Down move, Enter picks, Escape or a click outside closes; focus stays in the dialog while it is open and returns to the editor at the selection it had. The pick goes in with editor.insertChip in the WYSIWYG view and as escaped wire text in the Markdown pane, one undo step either way. On a narrow toolbar the button may sit in the More menu.

What is stored, and what other renderers show#

Only ordinary chips: [@Jane Doe](mention:person/u1?crm=101), [@team](mention:group/team), [#design](tag:design). A plain CommonMark viewer shows them as links with an unknown scheme; GitHub's sanitiser removes hrefs with schemes it does not allow, so there the label (@team, #design) reads as text. Cards, decorations and ranking are never stored.

Limits#

  • Cards in a read-only editor open on hover only: its surface has no caret and its chips are not focusable. Render read-only content with renderDom + postRender for keyboard cards.
  • The Markdown-pane menu reads at most 200 characters before the caret, and a trigger inside link text that is already closed ([@Jane](...)) never opens it.
  • The command trigger does not add chips; tag creation on space needs createTagTrigger(...).plugin.

Content blocks (advanced-texteditor-md/blocks)#

Columns, a footnote dialog, rule styles, emoji-table helpers, date chips, file cards and image galleries. Each is its own factory (unused ones are tree-shaken); createContentBlocksPlugins builds them all. The entry is about 12 kB gzipped; the footnote dialog's parser half is a lazy chunk the editor already holds.

import { createContentBlocksPlugins } from "advanced-texteditor-md/blocks";

const blocks = createContentBlocksPlugins({ hrStyle: "ornament", dates: { locale: "en-GB" } }); createEditor(el, { plugins: blocks.plugins, chips: blocks.chips, // the date chip: a plugin cannot register a chip definition itself upload: { handler: blocks.wrapUploadHandler(myUploadHandler) }, // remembers each upload's size });

// Read-only views view.appendChild(renderDom(md, { syntax: blocks.syntax, chips: blocks.chips, postRender: blocks.plugins.map((p) => p.postRender!).filter(Boolean) })); // or: view.innerHTML = renderHtml(md, { syntax: blocks.syntax, chips: blocks.chips }); hydrateAll(view, blocks.plugins, md);

createContentBlocksPlugins(options) returns { plugins, chips, syntax, wrapUploadHandler }. Options: columns, footnotes, dates, files (each the feature's own options, or false), hrStyle (a style name; omitted or false adds no plugin), gallery (false leaves it out). Every visible string is in the feature's labels option (English defaults: COLUMNS_LABELS, FOOTNOTES_LABELS, DATE_LABELS).

Columns: createColumnsPlugin({ maxColumns?, labels? })#

::: columns widths="2 1"
::: col
Left
:::

::: col
Right
:::
:::

Both names are registered block syntaxes (COLUMNS_SYNTAX; pass it as syntax.block to the renderer), so the nested ::: col openers are counted and each ::: closes the nearest. Rendered as div.atm-custom-columns (a CSS grid, repeat(auto-fit, minmax(min(100%, 12rem), 1fr)), one column below 40rem) holding div.atm-custom-col. Optional data: n=1..6 (equal columns, CSS only) and widths="2 1" (relative widths: 1 to 6 numbers up to 12, set as --atm-columns-template by postRender; anything else is ignored and never reaches a style).

ItemItem
Commandscolumns (arg: 2 to maxColumns, default 2) inserts empty columns after the caret's block (in place of an empty paragraph), caret in the first; addColumn / removeColumn while the caret is in a column (removing the last column keeps its content and drops the block)
Toolbar / slashcolumns; columns-2 "Two columns", columns-3 "Three columns"
KeysEnter in a non-empty line splits it as usual; Enter on an empty line adds a line in the same column (it never splits the column); Enter on the empty last line of the last column leaves the block. Backspace at the start of a column never merges it into the column before (an empty first line with more below is removed; an empty column stays). Delete at the end of a column never pulls the next column in. Tab is not taken: it leaves the editor as everywhere else
Editing aidsEmpty columns show a placeholder (labels.placeholder) and always hold a paragraph the caret can enter; neither is stored (::: col + ::: is an empty column)

Limits: a fenced code block inside a column must not contain a line that is just ::: (the container grammar does not look inside code). Deleting a selection that spans two columns merges them like any range deletion; undo restores them. Other renderers show the ::: lines as text with the content between them (GitHub, plain CommonMark).

Footnotes: createFootnotesPlugin({ labels?, tooltips? })#

ItemItem
CommandsinsertFootnote opens a dialog at the caret; Save (or Enter) inserts [^n] there (n = one more than the highest numeric label) and [^n]: text at the end, as ONE undo step; Escape or Cancel inserts nothing. In Markdown mode the reference and an empty definition are inserted and the caret moves to the definition. editFootnote (arg: label)
Toolbar / slashfootnote
KeysClick a reference, or Enter while it is selected, opens the same dialog with the definition as Markdown. In the field Enter saves, Shift+Enter starts a new line, Escape cancels (focus returns to the editor)
Views (postRender mode "view")Repeated references get unique ids (fnref-<label>, fnref-<label>-2, ...; labels sanitised the renderer's way), each definition one "โ†ฉ" back link per reference, references an accessible name ("Footnote 1"), and a tooltip (role="tooltip", aria-describedby) with the footnote text on hover and focus; Escape hides it. tooltips: false turns tooltips off. Also enhanceFootnotes(root)

A multi-paragraph footnote is edited as Markdown: paragraphs separated by a blank line in the field are stored as one definition with indented continuation paragraphs ([^1]: First, a blank line, Second). Lists, quotes and code round-trip the same way. A [^x] typed inside a footnote's text stays text unless [^x] is defined. Helpers: nextFootnoteLabel(doc), footnoteLabels(doc), findFootnote(doc, label).

Horizontal-rule styles: createHrStylePlugin({ style })#

style is "line" (default), "dots", "fade", "ornament" or "wave", for the WHOLE document: Markdown has one thematic break (---, *** and ___ are the same node and are written back as ---), so a per-rule style could not be stored. The plugin sets data-atm-hr on the editor element and, in views, on each <hr>; the rest is CSS. hrStyleAttribute(style) gives the attribute for a view root of your own. In forced-colors mode every style is a plain line.

Emoji shortcode tables: createShortcodes(table, { aliases?, normalize?, maxValueLength? })#

No emoji data ships. Load your own table (a JSON file, an API) and clean it for createShortcodesPlugin({ shortcodes }):

const table = createShortcodes(await (await fetch("/emoji.json")).json(), { aliases: { thumbs_up: ["+1", "like"] } }); createShortcodesPlugin({ shortcodes: mergeShortcodes(table, { party: "๐ŸŽ‰" }) });

Names are lower-cased with spaces turned into _ (normalize: false keeps them, a function is your own rule) and must match the plugin's rule (SHORTCODE_NAME: letters, digits, _, +, -). Invalid names, empty or non-string values, values longer than 64 characters and prototype keys (__proto__, constructor, ...) are dropped; an alias never replaces a real entry. The result has no prototype. mergeShortcodes(...tables): later tables win.

Date chips: createDateChips({ locale?, format?, today?, triggers?, relative?, labels? })#

Returns { plugin, chip, todayIso }. Pass plugins: [d.plugin] AND chips: [d.chip] (also chips for views): a Plugin has no field for a chip definition.

Markdown: [2026-10-02](date:2026-10-02). The id is an ISO calendar date; the stored label is the ISO date too, unless the author wrote another label, which is then shown as written. What the chip shows is display only: <time datetime="2026-10-02">Oct 2, 2026</time>, formatted with Intl.DateTimeFormat(locale, format ?? { dateStyle: "medium" }) in UTC (a calendar date never shifts by a day). relative: true (or a number of days) shows "today", "tomorrow", "yesterday" (Intl.RelativeTimeFormat) instead; that text is computed when drawn. An id that is not a real YYYY-MM-DD date renders as a plain chip with its label.

ItemItem
Typing@today then Space or Enter becomes today's chip (triggers: { today, tomorrow, yesterday }, each a word or false; default only @today). Not inside code, links or chips, not mid-word, never during an IME composition. In Markdown mode the source text is replaced
CommandsinsertDate (arg: an ISO string or a Date; none = today; anything invalid returns false), pickDate (the picker for the selected chip)
Toolbar / slashdate: inserts today's chip and opens the picker
PickerClick a chip (or Enter while it is selected): a dialog with a native <input type="date">, "Today" and "Set date". Set, Enter or Today replaces the chip (one undo step); Escape or a click outside cancels. Not while read-only, and not in the split preview

If the host also has an @ mention trigger, its menu sees @today first; choose another trigger word then.

File cards: createFileCards({ locale?, download? })#

Returns { plugin, wrapUploadHandler, sizeOf, decorate }. An uploaded file is still the editor's plain link; with upload: { handler: f.wrapUploadHandler(handler) } its size goes into the link title, in the same undo step as the insertion: [report.pdf](https://x/report.pdf "2.4 MB") (other renderers show the size as a tooltip). In Markdown mode no title is added.

A link is a card (editor and views) when its text is a file name with a non-image extension, or its title is a size (formatBytes output in any locale): class atm-file, data-atm-file (icon group: pdf, doc, sheet, slide, archive, audio, video, code, text, generic), data-ext and data-atm-size. The icon is a CSS mask; the size and a "โ†“" are CSS generated content. Same-origin and relative links get a download attribute in views (download: false turns that off). The href and the link's text are never changed; it stays a real, focusable link, and none of it reaches the Markdown. decorate(root) does the same for renderHtml output.

A paragraph whose only content is two or more images (separated by spaces or soft line breaks) gets the class atm-gallery: a grid of thumbnails (aspect-ratio, object-fit: cover). The images stay <img> with their alt and pass the link policy as before, so the lightbox (attachLightbox, the editor's image zoom) steps through them as usual. A caption (image title) does not make a figure here. Other renderers show the images side by side.

![Front](front.jpg) ![Back](back.jpg) ![Side](side.jpg)

Not supported: definition lists (Term + : definition); see DECISIONS.md.

Tasks (advanced-texteditor-md/tasks)#

Tasks v2 on top of GFM task lists (- [ ] text): due dates, assignees, a progress block, moving completed items and a filter for views. The entry is about 13 kB gzipped and imports the date chip of /blocks directly, so only that module is bundled.

import { createTasks } from "advanced-texteditor-md/tasks";

const tasks = createTasks({ locale: "en-GB", today: "2026-10-02" /* tests only */ }); createEditor(el, { plugins: tasks.plugins, chips: tasks.chips, mentions: { search } }); view.appendChild(renderDom(md, { syntax: tasks.syntax, chips: tasks.chips, postRender: [tasks.postRender] })); // or: view.innerHTML = renderHtml(md, { syntax: tasks.syntax, chips: tasks.chips }); hydrateAll(view, tasks.plugins, md);

createTasks(options) returns { plugins, plugin, chips, syntax, postRender, dates, todayIso }. plugins is the date chip plugin followed by the tasks plugin (the date chip plugin is left out when you pass your own dates: createDateChips() result or dates: false).

OptionMeaning
localeBCP 47 locale for dates and the relative wording ("yesterday", "in 3 days"). Default: the runtime's
todayAn ISO date, a Date or a function; "today" for overdue and the picker. Default: now. Tests and screenshots fix it
datesDateChipsOptions (a chip is created here), an existing createDateChips() result, or false (no due dates)
assign{ trigger?, command? }. trigger (default @) is typed at the end of the item; command (for example "chipPicker:people") runs instead. false removes the command
assigneeSchemesChip schemes that count as an assignee. Default ["mention"]
progressProgress blocks. Default true
autoProgressKeep the stored sentence true (see below). false, or { delayMs } (default 400)
filterThe filter control in views and in a read-only editor. Default true
keys{ dueDate, assign, moveCompleted }, each a binding or false
labelsEvery visible string (TASKS_LABELS)

Due dates#

setDueDate puts ONE date chip at the end of the item text at the caret: given an ISO string (or a Date) it sets it at once, otherwise it opens a dialog with a native <input type="date">, "Today", "Remove" and "Set"; an existing chip is replaced, not added to. clearDueDate removes it. One undo step each. In the Markdown pane the task line's last date chip is edited as text.

An open task with a due date is marked when drawn: data-atm-due="overdue|today|soon|later" on the item and on the chip, an accessible description on the chip (aria-description, also its tooltip: "Overdue, yesterday", "Due today", "Due in 2 days", Intl.RelativeTimeFormat), and a second visual mark (wavy or plain underline) so colour is not the only signal. A done task is not marked. None of this is stored: getValue() is the same with or without it.

Assignees#

assignTask moves the caret to the end of the item and types the mention trigger (or runs assign.command); the host's mentions do the picking. The result is an ordinary mention chip in the item.

Progress block#

::: progress
3 of 5 tasks done (60%)
:::

::: progress scope=section
1 of 2 tasks done (50%)
:::

The editor and views draw a bar and the sentence from the real counts of all task items in the document, or with scope=section of the section under the nearest heading (up to the next heading of the same or a higher level; with no heading before it, the whole document). The sentence between the fences is the plain-text fallback, so it must be true in the stored Markdown too:

  • A checkbox click, Mod-Enter on a task, moveCompleted*, insertProgress and updateProgress rewrite the sentence inside the SAME undo step as the change. While a progress block exists the checkbox click is handled by this plugin (the core handler is not run) so that both changes are one step.
  • Any other change (typing a task, Enter, delete, paste, the toolbar's own task toggle) rewrites the sentence in a step of its own after delayMs of quiet. Undo and redo never trigger a rewrite, so the redo stack survives. A block with the caret in it is never rewritten.
  • Views compute the sentence from the document and show that, whatever the stored text says.

Other renderers. GitHub and plain CommonMark show the lines ::: progress and ::: and the sentence between them as ordinary paragraphs. Due dates show as links [2026-10-05](date:2026-10-05) that do not resolve; mentions likewise.

Move completed to bottom#

moveCompleted reorders the innermost list at the caret (completed items after open ones, stable inside each group); moveCompletedAll does it for every task list. Nested items move with their parent and each nested list is ordered on its own. One undo step; the caret stays in its item. In the Markdown pane the same is done on the text (ordered lists are renumbered, loose and tight spacing kept, fenced code untouched). moveCompletedInMarkdown(md) and moveCompletedAt(md, line) are the pure functions.

Filtering#

filterTasks(root, "all" | "open" | "done" | "overdue") hides items by the class atm-task-hidden (a parent of a match stays; a list with nothing visible gets atm-task-list-hidden) and returns { shown, total }. The document is never touched. In views a control (createTaskFilter) is added above the content: a group of toggle buttons with aria-pressed and a polite live region ("Showing 2 of 5 tasks"). In a read-only editor the same control sits between the toolbar and the page and goes away when editing is allowed again. The command filterTasks takes the mode as its argument.

Pure model (server-safe, takes a parsed Doc)#

FunctionReturns
taskItems(doc, options?){ text, checked, due?, assignees[], path, depth }[] for every task item
tasksSummary(doc, { today?, ... }){ total, done, open, overdue, dueToday, percent, byAssignee: { id, label, total, done, overdue }[] }
progressBlocks(doc)each ::: progress block with its done, total, percent and scope
progressText(done, total, labels?)the sentence

Parse with chipSchemes: ["date"] so date chips are recognised.

Keyboard#

KeysCommand
Mod-Alt-Shift-DsetDueDate
Mod-Alt-Shift-AassignTask
Mod-Alt-Shift-MmoveCompleted
Mod-Enterthe core toggleTask (with a progress block, handled by this plugin so the sentence follows)

None clashes with the default keymap or the table plugin (Mod-Alt-Shift with arrows, L, E, R, W). Events: the change event is the only one used; nothing new is emitted.

Limits#

Overdue is computed against the clock of the page that draws it, so a stored page reads differently tomorrow. A task has one due date for the model (the last date chip); a second chip is left alone. The filter is not offered in Markdown mode. The progress bar is a CSS background on the block (no extra node), so a renderer without style.css shows only the sentence.

Writing aids (advanced-texteditor-md/writing)#

Ghost-text completion, selection actions, spellcheck and language, a word goal and lint hooks. All intelligence is yours: every suggestion, transform and check is a function you pass. Nothing talks to a network, and no model or dictionary is bundled. Each plugin is its own factory, and the entry is about 12.6 kB gzipped.

import { createSuggestPlugin, createSelectionActionsPlugin, createLanguagePlugin, createWordGoalPlugin, createLintPlugin, readingStats } from "advanced-texteditor-md/writing";

createEditor(el, { plugins: [ createSuggestPlugin({ onSuggest: ({ before, signal }) => myComplete(before, { signal }) }), createSelectionActionsPlugin({ actions: [{ id: "shorten", label: "Shorten", run: ({ markdown }, { signal }) => myShorten(markdown, signal) }] }), createLanguagePlugin({ lang: "en-GB" }), createWordGoalPlugin({ goal: 800 }), createLintPlugin({ lint: ({ text }, { signal }) => myCheck(text, signal) }), ], });

Markdown stored: nothing new. Ghost text, squiggles, popovers, the goal bar and the menus are drawn outside the editable surface and never reach the Markdown; what you accept or apply is ordinary text (or, for selection actions, ordinary Markdown). Any Markdown viewer shows exactly what was typed.

Ghost-text completion: createSuggestPlugin(options)#

OptionDefaultItem
onSuggestrequired(ctx) => string | null | Promise<...>. The text to show after the caret. Used as TEXT only: control and bidi override characters are removed, line breaks become spaces.
debounceMs400Pause after typing before asking.
minChars1Ask only with at least this many non-space characters before the caret in the block.
acceptKey"Tab"The key that accepts the whole suggestion.
inCodefalseAlso suggest in code blocks and inline code.
anywherefalseAlso suggest when text follows the caret in the block (the overlay is drawn over it: it cannot push text aside).
maxLength2000Longest suggestion kept.
labelsEnglishavailable(suggestion): the polite announcement, default "Suggestion available, press Tab to accept".

ctx is { before, after, markdown, blockType, signal, mode }: the block's text before and after the caret (the current line in the Markdown pane), the whole document, "paragraph" | "heading" | "listItem" | "quote" | "table" | "code" | "other", an AbortSignal, and "wysiwyg" | "markdown".

Keys: Tab accepts (one undo step), Mod-ArrowRight accepts one word, Escape dismisses. Typing, a caret or selection move, blur, a mode switch, read-only and an IME composition dismiss it and abort the pending request; a late answer is ignored. With no suggestion shown, Tab keeps its meaning (indent a list item, next table cell, leave the editor) and Escape is not consumed. Commands suggest:accept, suggest:dismiss.

The ghost text is div.atm-ghost (aria-hidden), a child of editor.element, placed at the caret with the block's font and wrapping inside the block's width. Screen readers hear the announcement once per focus of the editor, not on every keystroke.

Selection actions: createSelectionActionsPlugin(options)#

OptionDefaultItem
actionsrequired{ id, label, icon?, run(selection, { signal }), replace? }[]. selection is { text, markdown }; run returns Markdown (or a promise of it). replace: "insertAfter" inserts after the selection instead of replacing it. Ids are letters, digits, -, _.
menufalseAlso draw a small floating menu (role="toolbar") over a non-empty selection.
toolbartrueOne toolbar item per action (selectionAction:<id>, group writing), enabled while text is selected.
maxResultLength100000Longest result applied.
labelsEnglishmenu, busy(label), cancel, cancelled, done(label), failed(label), changed, result, copy, copied, dismiss.

Commands selectionAction:<id> (false when nothing is selected, read-only, or another action runs) and selectionAction:cancel. While an action runs, a panel under the editor shows its state with Cancel (aborts the signal) and the editable carries aria-busy="true". The result replaces the saved selection through editor.transact + replaceSelectionMarkdown: one undo step, parsed like any insertMarkdown, so links go through your link policy (javascript: never survives). The user may click elsewhere meanwhile; the selection is restored first. If the selected text changed under it (an edit, an undo, setValue, a mode switch), nothing is replaced: the panel says so and offers the result in a read-only field with Copy result. An error or a non-string result is announced politely and nothing changes. In the Markdown pane the result replaces the selected source verbatim.

Spellcheck and language: createLanguagePlugin(options)#

spellcheck?: boolean, lang?: string (BCP 47, canonicalised with Intl.getCanonicalLocales; an invalid tag is ignored with a warning), toolbar?: boolean (a "Spellcheck" toggle, default false), labels: { spellcheck }. Sets spellcheck and lang on the surface, the Markdown textarea and the split preview, again on every pane / mode event. The attributes are only touched when an option is given or a command asks (other chrome may manage spellcheck too), and every write is idempotent. Commands: toggleSpellcheck (args: true / false to set, nothing to flip) and setLanguage (args: a tag; "" removes lang; an invalid tag returns false). Events: plugin:writing:spellcheck { spellcheck } and plugin:writing:lang { lang }. Direction (dir) belongs to the bidi plugin.

Reading statistics: readingStats(markdownOrDoc, options?)#

A pure function: { words, spacedWords, cjkCharacters, characters, charactersNoSpaces, readingMinutes, readingSeconds }. Options: wpm (default 230), cjkCpm (default 500), includeCode (default true; false leaves out code blocks and inline code). Markdown syntax is not counted, nor are images, math and footnote references. Han, Hiragana and Katakana count per character (and words = spaced words + CJK characters); Hangul counts in words. Pass a parsed Doc (editor.getAst(), or parse(md) from advanced-texteditor-md/parser) for exact counts; a string goes through a small line-based stripper so this entry does not carry the parser (exact on everyday Markdown, approximate on exotic input). Also exported: countText(text), docText(doc), markdownText(md).

Word goal: createWordGoalPlugin(options)#

OptionDefaultItem
goalrequiredA positive number.
unit"words"or "characters".
onReached(info), once per crossing.
showInStatusBartrueAppend the item to .atm-statusbar when the layout has one.
badgetrueWithout a status bar, show a floating badge in the editor's corner.
statsPassed to readingStats (includeCode: false, ...).
debounceMs150
labelsEnglishname ("Word goal"), units, value(count, goal, unit) ("120 of 500 words"), reached(goal, unit).

A <progress> named "Word goal" with aria-valuetext="120 of 500 words". Reaching the goal is announced once (a document that loads above the goal is not announced; it re-arms below 90%). Event plugin:writing:goal { count, goal, unit, reached, fraction } on every count change. Command setWordGoal (args: a number).

This plugin is the goal tracker only. A reading-time or word-count item that other chrome draws is separate and is not duplicated here; both can call readingStats.

Lint hooks: createLintPlugin(options)#

OptionDefaultItem
lintrequired(input, { signal }) => LintIssue[] | Promise<LintIssue[]>.
debounceMs500Pause after an edit before checking.
includeCodefalseCheck code blocks and inline code too.
highlightApi"auto"The CSS Custom Highlight API when present; false always uses overlay boxes.
maxIssues1000
statusItemtrueThe issue count in the status bar.
labelsEnglishstatus(count), issue(index, count, message, fixes), none, popover, severity, markdownLimit.

input is { text, markdown, blocks }. text is the plain text of the document's blocks, one run per block (paragraph, heading, list item, table cell, ...) joined by "\n". Inside a run, a hard line break is "\n" and an atom (a chip, inline math, an image, and inline code unless includeCode) is one U+FFFC character; code blocks are left out unless includeCode. blocks is [{ text, offset }], one per run. A LintIssue is { from, to, message, severity?: "error" | "warning" | "info", fixes?: { label, replacement }[] } with offsets into text (UTF-16). Offsets are clamped into the text; NaN, reversed and empty ranges are dropped; overlapping issues are allowed; messages and labels are shown as text.

Squiggles use CSS.highlights (names atm-lint-<n>-error|warning|info, unique per editor, with ::highlight() rules injected per editor) or, without it, boxes in .atm-lint-overlay (outside the surface). The popover (role="dialog", named "Issue") opens when the caret enters an issue and on hover; it shows the message and one button per fix. A fix replaces exactly that range (one undo step) and the document is checked again. Edits made before the next check carry issues along or drop the ones they touched; a running check is aborted on the next edit and on destroy, and its answer is ignored.

Keys (no clash with the built-in keymap): Alt+F8 / Shift+Alt+F8 next / previous issue (the caret selects it, the popover opens, the issue is announced), Alt+Enter moves focus to the fix buttons (arrow keys between them), Escape returns to the text. Commands lint:next, lint:previous, lint:fixes. The status item ("3 issues; Alt+F8 next") is also the surface's aria-describedby. Event plugin:writing:lint { count, issues } after each check.

Limits#

  • Ghost text needs the caret at the end of the block by default (anywhere: true draws it over the following text). Multi-line suggestions are folded into one line.
  • Lint runs in the Write (WYSIWYG) view only: a textarea cannot show highlights, and its offsets would be Markdown source offsets. In Markdown and split mode the status item says "Checks are shown in the Write view".
  • On macOS keyboards F8 may be a media key: press Fn+Alt+F8, or bind lint:next to another key with the editor's keymap option.
  • The floating selection menu is for the pointer; keyboard users run actions from the toolbar items or the commands.
  • readingStats on a string is approximate on exotic Markdown (pass a Doc to be exact); scripts written without spaces other than CJK (Thai, Khmer, ...) count per space-delimited run.

Languages and right-to-left text (advanced-texteditor-md/i18n)#

Label bundles for 13 languages, locale matching, and a bidi plugin. Nothing is loaded until you ask for it.

import { createEditor } from "advanced-texteditor-md"; import { loadLabels, resolveLocale, createBidiPlugin } from "advanced-texteditor-md/i18n";

const lang = resolveLocale(navigator.languages); // "pt-BR" -> "pt", "zh-Hant" -> "zh", unknown -> "en" const labels = await loadLabels(lang);            // one small lazy chunk (about 1.5 kB gzip) createEditor(el, { labels, plugins: [createBidiPlugin()] });

A single language can also be imported directly: import ar from "advanced-texteditor-md/i18n/ar" (default and named export, a plain object of strings).

Label bundles#

en es fr de pt it nl ru ja zh ar hi tr, one module each at advanced-texteditor-md/i18n/<code>. A bundle holds every label the editor has: all of DEFAULT_LABELS (the labels option's type), the extra chrome strings, and the strings of the lazy chunks (popovers, uploads, the slash menu). Pass it as the labels option; a host can still override single keys (labels: { ...ar, bold: "ุนุฑูŠุถ" }). English equals the built-in defaults exactly (a test enforces it).

  • Placeholders ({shortcut}, {n}, {name}, {count}, {max}, {rows}, {cols}, {reason}) are the same in every language. No label is a function: the editor merges strings only, so a bundle is a JSON-like object.
  • A test builds the key set from the editor's own label objects, so adding a label to the editor fails the build until every language has it.
  • Wording follows the usual UI terms of mainstream software in each language. A native-speaker review is advisable before you ship a language to end users; send corrections as a pull request to the one file.
  • words / characters follow the number in the status bar; languages with complex plurals (Russian, Arabic) use one neutral form there.
  • Traditional Chinese (zh-TW, zh-Hant) falls back to the Simplified zh bundle.
ExportWhat it does
LOCALESthe supported codes
loadLabels(lang?)Promise of the bundle. Falls back through BCP 47 (pt-BR to pt, zh-CN / zh-Hans to zh), anything unknown, malformed or hostile gives English. Never rejects for a bad code.
normalizeLocale(tag)a supported code or null
resolveLocale(requested, supported?, fallback?)first requested language that is supported (navigator.languages, a string, an array), else fallback / "en"
isRtl(lang)true for Arabic, Hebrew, Persian, Urdu, Pashto, Sindhi, Uyghur, Yiddish, Dhivehi, Central Kurdish and for RTL script subtags (ku-Arab); ur-Latn is false
createBidiPlugin(options)the plugin below
getDirection(editor)the editor's current direction setting

createBidiPlugin(options)#

OptionDefaultItem
dir"auto""auto", "ltr" or "rtl" for the editing areas (the WYSIWYG surface, the Markdown textarea, the split preview). Anything else is "auto".
perBlocktruewith dir: "auto", every block follows its own first strong character instead of the whole document sharing one. An explicit "ltr" / "rtl" always wins.
labels.direction"Text direction"accessible name of the toolbar button
  • What it touches. Only dir and data-atm-bidi on the editing areas and on the blocks of a rendered view. The toolbar, status bar and the editor's root element are never touched (the chrome mirrors itself). The Markdown is never touched: direction is a property of the view, not of the document, so nothing is stored.
  • Per-block auto. The leaf blocks (paragraph, list item, heading, quote, table cell, summary) follow their text through CSS (unicode-bidi: plaintext; text-align: start, shipped in style.css). The plugin adds dir="auto" to the top-level blocks and to the outermost list, quote or table, so a list's markers and indent, a quote's bar and a table's column order switch sides with the first strong character. A nested list or a paragraph inside a quote carries no dir (HTML ignores descendants that have one when it resolves auto). New blocks (Enter, paste, a list typed with - ) are picked up by a MutationObserver on child changes; plain typing costs nothing. Code blocks stay left to right.
  • Textarea. One dir for the whole field: auto resolves from the first strong character of the whole text. A textarea cannot do per-line direction.
  • Read-only views. renderDom(md, { postRender: [plugin.postRender] }) and hydrateAll(root, [plugin], doc) give a view the same directions. For a static renderHtml string with no script, wrap it in an element with data-atm-bidi="blocks" to get the per-block CSS alone.
  • IME safety. Nothing is written between compositionstart and compositionend: blocks added meanwhile and a setDirection call are applied right after the composition commits.
  • Commands. setDirection (argument "ltr" | "rtl" | "auto"; any other value returns false and changes nothing) and toggleDirection (rtl to ltr, anything else to rtl). Both are also in editor.exec.
  • Event. editor.on("plugin:i18n:direction", ({ dir }) => ...).
  • Toolbar. One toggle button (i18n:direction, group i18n). Its pressed state refreshes with the next selection change when the direction was changed from code.
  • Other renderers. GitHub and plain CommonMark viewers show the Markdown with their own direction (they ignore this plugin; the stored text is identical).

Limits: the editor's own stylesheet still has a few physical-side rules, listed in docs/DECISIONS.md (2026-10-02, "Internationalisation"); the plugin's CSS fixes the quote bar and the collapsible-section arrow for RTL.

Definition lists (advanced-texteditor-md/deflists)#

Term followed by : Definition lines (PHP Markdown Extra and Pandoc style). It is a plugin block syntax, not a parse option: the parser calls BlockSyntax.match at the start of each block, so nothing is added to the parser, the render-only entry or the editor entry. The subpath is about 6 kB bundled.

import { createDefinitionListsPlugin, DEFINITION_LIST_SYNTAX, upgradeDefinitionLists } from "advanced-texteditor-md/deflists";

createEditor(el, { plugins: [createDefinitionListsPlugin()] });               // registers the syntax itself const opts = { syntax: { block: DEFINITION_LIST_SYNTAX } };                   // parse / stringify / renderHtml / renderDom view.appendChild(renderDom(md, { ...opts, postRender: [createDefinitionListsPlugin().postRender!] })); // real <dl> // or, for markup you inserted yourself: renderHtml(md, opts) then upgradeDefinitionLists(view)

DEFINITION_LIST_SYNTAX is an array of three syntaxes (deflist, dt, dd), like COLUMNS_SYNTAX; dt and dd only carry the ARIA roles and never open a ::: dt container.

Syntax#

Term : Definition on one line

Another term Its alias : A definition that continues on indented lines (2-4 spaces), or lazily without one.

    A second paragraph, a list, a code fence: anything indented four spaces.

Loose term

: A blank line between term and definition (or between definitions or groups) makes the whole list loose.
  • Term lines: one or more non-blank lines that do not start another block (heading, quote, list item, fence, $$, reference or footnote definition, rule or table delimiter row, a line starting with :::). At most 16 term lines; more stay a paragraph. Each term line is its own dt.
  • Definition: : then 1-3 spaces (or a bare :), at column 0. Continuation lines are indented 2-4 spaces or are lazy paragraph lines; a definition holds several blocks when the continuation is indented. Lists, code, quotes and further definition lists nest.
  • Several term/definition groups in a row form one list. The term must start a block: every non-blank line from the block's start up to the first : line is a term (so para line directly above Term is also a term). Put a blank line before the list to keep a paragraph apart.
  • Document shape: custom "deflist" (data.loose = "" when loose) with children custom "dt" (one paragraph) and custom "dd" (blocks).

Stored form#

stringify writes Term / : Definition, continuation indented four spaces, a definition that starts as indented code under a bare :. Tight lists have no blank lines; a loose list has one between every part except consecutive terms. The form is a fixed point (property test over 2500 seeded cases, test/extensions/deflists/roundtrip.test.ts). A term with no definition is written as a paragraph after the list; a definition with no term gets the term &nbsp;. A term that would read as something else (starting with :, a rule, ...) has its first character written as a character reference.

What other renderers show: GitHub and CommonMark render the lines as one paragraph (Term newline : Definition); most Markdown Extra / Pandoc based tools render a real dl.

Editor#

ItemItem
CommanddefinitionList: an empty list in place of an empty paragraph; the paragraph holding the caret becomes the first term; otherwise inserted below the block. Does nothing inside a list. One undo step.
Slash / toolbar"Definition list" (/definition, /glossary), toolbar group blocks
Enter at the end of a terminto its definition (made if missing)
Enter in the middle / at the start of a termsplits the term / adds an empty term above
Enter at the end of a one-paragraph definitiona new term
Enter in an empty definition or empty last termleaves the list (an empty paragraph after it)
Enter elsewhere in a definitionthe surface's own split (a second paragraph of the definition)
Backspace at the start of the first termlifts that line out of the list
Backspace at the start of a definition or later termremoves it when empty, otherwise joins the line above
Shift/Mod/Alt combos, IME composition, read-onlynot taken

Options: labels (insert, description, term, definition; placeholders of empty parts, never stored), classPrefix. Markdown mode is plain text.

Views#

The renderer's tag allow-list has no dl, so renderHtml emits div.atm-custom-deflist > div[role=term] / div[role=definition]. upgradeDefinitionLists(root, classPrefix?) (also run by the plugin's postRender in every non-editor render) rebuilds them as <dl><dt><dd> by moving nodes; the editor surface keeps divs because DT/DD are leaf blocks there and a definition may hold several blocks. Styling: --atm-dl-term, --atm-dl-definition, --atm-dl-indent, --atm-dl-rule, --atm-dl-placeholder; logical properties, print and forced-colors rules included.

Limits#

  • A paragraph line that starts with : (typed text, \: x in source, or text after a hard break) is read as a definition after a reparse when term-like lines come before it. The parser's escaper (lineStarts) does not know about it, and this module cannot extend it without a core change; stringify converges on the list reading after one more pass. Avoid starting a line with : .
  • Terms are one line each (hard breaks fold to a space). Definitions do not take attributes. No Tab nesting.

Dictation and read aloud (advanced-texteditor-md/speech)#

Speak to type, and have the selection or the document read aloud, with the browser's Web Speech API. Nothing is bundled; no audio or text goes anywhere except through the browser's own speech service (Chrome and Safari send dictation audio to one; Firefox has no recognition at all). Neither plugin starts by itself: each command runs only from a user gesture (a click, a key, a palette entry). The entry is about 4 kB gzipped.

import { createDictationPlugin, createReadAloudPlugin } from "advanced-texteditor-md/speech";

createEditor(el, { plugins: [createDictationPlugin({ lang: "en-US", punctuationCommands: true }), createReadAloudPlugin({ rate: 1 })], });

Markdown stored: nothing new. Interim text, the highlight, the status strip and the buttons are drawn outside the editable surface; dictated final text is ordinary text, so every Markdown viewer shows exactly what was typed. getValue() is the same with or without the plugins, except for the words that were dictated.

Feature detection. window.SpeechRecognition (or webkitSpeechRecognition) and window.speechSynthesis plus SpeechSynthesisUtterance are looked up when a command runs and when the toolbar is built, never at import (the module is safe on the server). When one is missing its toolbar item is disabled (aria-disabled), its label becomes "Dictation (not supported in this browser)" (same for "Read aloud"; this is also the palette entry's text), and running the command anyway shows that sentence in the alert region and calls onError({ code: "unsupported" }). Nothing throws.

Dictation: createDictationPlugin(options)#

OptionDefaultItem
langthe surface / textarea lang, else the closest [lang], else <html lang>, else navigator.languageRecognition language (BCP 47).
continuoustrueKeep listening across pauses; the plugin starts the next session when the browser ends one by itself. false: one phrase per start.
punctuationCommandsfalseUnderstand spoken "new line", "new paragraph", "period", "comma", "question mark", ... Only for languages with a table (en, es, fr, de); other languages insert the words as spoken.
onDevicefalseSet processLocally where the browser has it (recognition on the device only; an unavailable language pack then ends in language-not-supported).
onError({ code, message }) for every error, with the browser's code (not-allowed, service-not-allowed, no-speech, audio-capture, network, language-not-supported, or unsupported, restart, other).
labelsEnglishname, unsupported, readOnly, listening, stopped, stop, dismiss and errors (text per code; {lang} is replaced).

Commands: dictation (toggle; toolbar item dictation, type toggle, aria-pressed), dictationStart, dictationStop. Shortcut: Mod-Shift-. Escape stops (and is consumed only while listening). Dictation also stops on blur (after a 0.5 s grace for the palette handing focus back), on a mode switch and on destroy. Event plugin:speech-dictation:state with { listening }.

How results are applied:

  • Interim results replace each other as div.atm-speech-ghost (aria-hidden), a child of editor.element placed at the caret with the block's font; right-to-left blocks are indented from the right. It is not content: the caret, the undo history and getValue() never see it.
  • Each final result is inserted with editor.insertText (text, never parsed: **x** and <b> stay literal) as one undo step. A space is added unless the caret follows whitespace or an opening bracket, or the text starts with closing punctuation; no spaces are added for scripts written without them (Chinese, Japanese, Thai, ...). The first letter is capitalised, with the language's case rules, at the start of a block and after ., !, ?. Control and bidi override characters are dropped.
  • Spoken commands (when enabled) turn "comma" into ,, "period" into ., and so on, attached to the previous word. "New line" and "new paragraph" insert a line break: in the Write view both start a new paragraph (insertText cannot make a hard break); in the Markdown pane "new paragraph" inserts a blank line.

Status and errors: the strip at the bottom of the editor shows "Listeningโ€ฆ" / "Stopped" in a polite live region (role="status") with a Stop button. An error is a separate role="alert" (aria-live="assertive") message with a Dismiss button; it never takes focus and does not trap it. Permission denied tells the user to allow the microphone for the site in the address bar or site settings.

Read aloud: createReadAloudPlugin(options)#

OptionDefaultItem
langeach block's lang, else the editor's, else the page'sLanguage of the speech.
voicebest match for the languageA voice name or voiceURI, or (voices, lang) => voice. Voices are read at speak time, so a list that loads late (voiceschanged) is used.
rate10.1 to 10.
pitch10 to 2.
maxChunk200Longest piece handed to the engine, in UTF-16 units; long blocks split at sentence ends (some engines cut off long utterances).
highlightApi"auto"false always uses the overlay boxes.
labelsEnglishname, unsupported, unsupportedMessage, reading, paused, finished, stopped, nothing, pause, resume, stop, dismiss, failed.

Commands: readAloud (toggle; toolbar item readAloud), readAloudPause (pause / resume), readAloudStop. Shortcut: Mod-Shift-, Escape stops. Event plugin:speech-read-aloud:state with { reading }.

What is read: the selection if there is one; otherwise the document from the caret (from the top when the caret is at the end or not in the text). The text is the plain text of the blocks (the same text model as the lint hook): no Markdown syntax, code blocks and inline code left out, chips, images and formulas left out, one piece per block or per sentence group. One utterance is spoken at a time. In the Markdown pane the Markdown source is stripped of its syntax and spoken, but a textarea cannot show a highlight.

Highlight: a faint highlight on the block being spoken, and a stronger one on the word from each boundary event. Names are per editor (atm-speech-<n>-block, atm-speech-<n>-word) with a ::highlight() style element per editor; colours come from --atm-speech-block-bg, --atm-speech-word-bg / --atm-speech-word-fg, then the theme's --atm-surface and --atm-mark-bg / --atm-mark-fg; forced-colors uses Highlight. Without the Custom Highlight API the same two highlights are boxes in div.atm-speech-overlay outside the surface. Several engines send no boundary events, or only sentence ones: the block highlight (and scrolling) still works. The spoken text scrolls into view when it is outside the viewport, without animation when the user prefers reduced motion.

Pause / Resume (a toggle button with aria-pressed) and Stop are buttons in the strip and commands. Editing the document or switching mode stops reading. Destroying the editor cancels speech.

What other renderers show#

Nothing: neither plugin writes syntax. Dictated words are plain text.

Limits#

  • The toolbar disables every item of a read-only editor, so the read-aloud button is greyed there; the readAloud command and its shortcut still work.
  • Dictation stops when the page loses focus, by design. Chrome ends silent sessions after a few seconds (no-speech); that is reported, not restarted.
  • Android Chrome may repeat words in continuous mode (a browser behaviour); use continuous: false there if it bothers.
  • Voices differ per device and some engines never send boundary events or ignore pause(); the block highlight is the fallback.
  • Spoken commands exist for English, Spanish, French and German only; the tables are in SPOKEN_COMMANDS.
  • The pure helpers (fitSpoken, parseSpoken, readResults, buildChunks, wordSpan, markdownBlocks, pickVoice) are exported for hosts that build their own UI.

Present and reader views (advanced-texteditor-md/present, advanced-texteditor-md/reader)#

Two read-only views of a document, both lazy and sharing src/extensions/_view.ts (theme, direction, ::: notes).

import { createPresentView, createPresentPlugin } from "advanced-texteditor-md/present"; import { createReaderView, createReaderPlugin } from "advanced-texteditor-md/reader";

const slides = createPresentView(el, markdown, { split: "auto", presenter: false, hash: true }); slides.next(); slides.goTo(3); slides.update(newMarkdown); const article = createReaderView(el2, markdown, { outlineDepth: 3, notes: "hide" });

createEditor(host, { plugins: [createPresentPlugin({ split: "h2" }), createReaderPlugin()] });  // commands `present` and `reader`
Option (both)Meaning
renderthe library's RenderOptions (syntax, chips, highlight, links, math, postRender of other extensions)
theme, dir, labelstheme name (default: nearest data-atm-theme), direction (default: nearest dir), every visible string
container, onExitwhere to mount; a callback that adds an exit button

Present only: split ("rule" default, "h1", "h2", "auto"), presenter (speaker panel with current, next, notes and timer; the S key), start, hash (#slide-n), fullscreen (true, "overlay", false), minScale, clickNavigation, swipe. Keys: arrows, PageUp/PageDown, Space, Home/End, a typed number then Enter, F, S, Escape; in RTL the arrows swap. Reader only: scroll ("window" or "element"), outline, outlineDepth, readingTime, wordsPerMinute, notes ("show"/"hide"), maxWidth, locale.

::: notes ... ::: is an ordinary custom block (NOTES_SYNTAX, registered by the views): the present view removes it from the slide and shows it in the speaker panel, the reader shows an aside or hides it. splitSlides(doc, mode) is the pure splitter. The handles expose element, update, focus, destroy plus getIndex / goTo / next / previous / setPresenter / setFullscreen (present) and outline, stats, getProgress, getCurrent, scrollTo (reader). Content comes from the same renderer as every other view, so links, images and raw HTML follow the editor's LinkPolicy; no view writes back into the Markdown.

Snippets and templates (advanced-texteditor-md/snippets)#

Text expanders and templates for the editor. The entry is about 9.5 kB gzipped; the picker dialog is a lazy chunk fetched on the first open. Server-safe at import.

import { createSnippets, localStorageSnippets } from "advanced-texteditor-md/snippets"; const snippets = createSnippets({ storage: localStorageSnippets("my-app"), defaults: [...], variables: { who: async () => "Ada" } }); createEditor(el, { plugins: [snippets.plugin] });

Markdown stored: nothing new. Expanding a snippet inserts its body as ordinary Markdown (parsed like insertMarkdown); the typed trigger is gone from the document.

A snippet#

{ id, name, trigger?, body, scope: "inline" | "block", description?, keywords? }. id is a short slug ([a-z0-9_-], at most 80), trigger at most 32 characters without whitespace and unique, body at most 20 000 characters, at most 1000 snippets. validateSnippet and normalizeSnippets apply these rules to data from anywhere; an invalid entry is skipped, never repaired.

Expansion#

Typed: the trigger must end at a word boundary (start of the block, after whitespace, or after an opening bracket or quote) and the key must be one of expandOn (default Space, Tab, Enter; [] turns it off). It never fires inside code, a link, a kbd, math or a chip. The whole replacement is one undo step; Backspace right after restores the typed trigger and the next Space is an ordinary space. In the Markdown pane the body goes in verbatim. Block snippets are for the slash menu and the picker; inline snippets are inserted inside the current block.

Variables#

{{date}} (ISO), {{date:long}} (locale), {{time}}, {{cursor}} (where the caret ends), {{selection}} (the selected text, empty when none) and any variables the host supplies: a string, a function (sync or async), or { value, markdown: true }. Values are escaped as literal text unless markdown is set, so a name such as *Ada* cannot add formatting. A variable that throws, rejects or takes longer than variableTimeoutMs (2000) stays literal ({{name}}), as does an unknown name.

Store, import and export#

createSnippetStore (used by createSnippets) keeps the list in localStorageSnippets(key) or memorySnippets(); a storage error goes to onError, and a list that was never stored starts from defaults. exportSnippets writes { format, version, snippets }. importSnippets(json, { mode }) takes "merge" (add and update by id) or "replace" (the file becomes the list) and returns { mode, added, updated, removed, skipped, applied }; one bad entry is listed in skipped ("2: bad-id") and the rest still apply; text that is not a snippets file changes nothing and reports bad-json.

UI#

Slash menu group "Templates", the command plugin:snippets:picker and palette entry "Insert templateโ€ฆ", toolbar: true for a button. The picker is an ARIA combobox (the field keeps focus, aria-activedescendant follows Arrow keys, Enter inserts, Escape closes and returns the caret). It carries the editor theme and direction. Labels take labels; the plugin:snippets:insert event reports { id, via } (trigger, api, picker, slash).

[[ page links with broken-page marking, and a dialog to review every link in a document. The entry is about 13 kB gzipped; the dialog is a lazy chunk fetched on first open. Server-safe at import.

import { createWikiLinks, createLinkManager, findLinks, findBacklinks } from "advanced-texteditor-md/links"; const wiki = createWikiLinks({ search, resolve, create, onOpen }); createEditor(el, { chips: wiki.chips, plugins: [wiki.plugin, createLinkManager({ wiki, check, upgradeHosts: "all" })] }); renderDom(md, { chips: wiki.chips, postRender: [wiki.postRender] }); // read-only views

Markdown stored: [Title](wiki:id), a plain link with the scheme wiki (option scheme). Nothing else is written; the broken mark, the dialog and the picker are drawn outside the document. An old viewer shows a link whose address is wiki:id.

createWikiLinks(options)#

search(query, { signal }) returns { id, label, description? } items for the text typed after the trigger (default [[, two characters or more; a single [ would fight [text](url)). resolve(ids, { signal }) answers { [id]: { exists, title? } } for a batch (up to batchSize 50 ids, cache cacheSize 500, answers trusted ttlMs 300 000; an id missing from the answer counts as not found); it runs resolveDelayMs (400) after an edit and is aborted when the last editor is destroyed. Without resolve nothing is marked broken. create(query) adds a "Create page" row for text no page matches. onOpen(id, chip) runs on click or Enter. Returned: chips, plugin, status(id), lookup(ids), refresh(), onStatus(fn), postRender, destroy(). A [[ inside code does not open the picker; the query cannot contain [, ] or a newline. Labels: labels.

createLinkManager(options)#

Lists every link (text, address, kind: link, image, wiki, autolink, reference, chip) with its state: ok, broken (the page does not exist, or check said the address does not work), insecure (an http: address), refused (the link policy would not render it, such as javascript:) or checking. Per row: Go to (selects it in the editor and closes the dialog), Edit (text and address), Remove link (the text stays). "Check links" runs your check(url) four at a time (checkConcurrency) and aborts when the dialog closes. "Upgrade N http links to https" asks first, skips hosts outside upgradeHosts (exact names or *.example.com; default "all") and localhost, and is one undo step. The edits are pure Markdown rewrites (applyEdits, editLink, removeLink, upgradeEdits), applied through the pane so undo works. The dialog is a modal with aria-modal, a focus trap, Escape to close and focus returned to the opener; it carries the editor theme. shortcut (default none), toolbar (default false) and slash (default true) control how it opens.

createLinkAffordances(options) and heading anchors#

import { createLinkAffordances, createHeadingAnchors, enhanceHeadingAnchors } from "advanced-texteditor-md/links"; createEditor(el, { plugins: [createLinkAffordances({ onOpen: (href) => router.open(href) })] }); renderDom(md, { postRender: [createHeadingAnchors({ levels: [2, 3] }).postRender] }); const off = enhanceHeadingAnchors(viewEl); // any read-only markup; off() removes the buttons

createLinkAffordances({ onOpen?, hint?, delayMs?, labels? }): in the WYSIWYG surface a plain click on a link places the caret, so Ctrl/Cmd+click opens it (window.open(href, "_blank", "noopener,noreferrer") for http, https, mailto and tel; onOpen(href, ev) returning true takes over). While Ctrl or Cmd is down the surface carries atm-mod-down and links show a pointer. Hovering (after delayMs, 450) or focusing a link shows a role="tooltip" with the address and "Ctrl+click to open" ("Cmd" on Apple platforms), referenced by the link's aria-describedby; Escape and mouse-out hide it. The tooltip is off with hint: false and when the editor has linkPreview (that card is the hint). The code is a lazy chunk.

createHeadingAnchors({ levels?, url?, onCopy?, labels?, classPrefix? }) and enhanceHeadingAnchors(root, options): a <a class="atm-h-anchor" href="#id"> button (aria-label "Copy link to this section") is appended to each heading of levels 1 to 4 in read-only output (never inside a contenteditable). Headings without an id get a GitHub-style one (headingSlug: letters and digits of any script, dashes, -2 for duplicates). The button is hidden until the heading is hovered or the button has keyboard focus (always faint on touch screens), copies url(id) (default: the page address with #id) and announces "Link copied" in a role="status" region. Opt-in: nothing is added unless you install it.

Pure helpers#

findLinks(markdownOrDoc) returns { kind, href, text, title?, scheme?, id?, bare?, line / column / offset (Markdown input) or block (Doc input) }[]; findWikiIds the distinct page ids; findBacklinks(docs, targetId) which of { id, markdown } documents link to a page. All are linear in the input.

Comments (advanced-texteditor-md/comments)#

Inline comments anchored to text. The library stores the anchor; the host stores the conversation. The entry is about 21 kB gzipped (it carries the parser it needs to read marks; budget 28 kB) and is a lazy subpath. Server-safe at import.

import { createCommentsPlugin, commentSyntaxes, findComments } from "advanced-texteditor-md/comments"; const comments = createCommentsPlugin({ onCreate, render, isResolved, onOpen, onClose, onRemove }); createEditor(el, { plugins: [comments] });

Markdown stored: [anchored text](comment:ID). The anchored text keeps its formatting ([**bold** and code](comment:c1)), may contain one level of a link, mention chip, image, footnote reference or a second comment mark (overlapping comments nest), and stays editable. The id is [\w.:-]{1,80}; anything else is not a comment. The mark is an inline pattern syntax with a nested body, not a link: it never becomes a link node, so the link policy does not apply to it and it carries no URL. Everything the plugin draws (state classes, markers, the panel, the live region) is decoration outside the document.

Literal ! before a mark. ![x](comment:c1) would be an image, so commentSyntaxes() returns two syntaxes: the mark and a helper (comment-bang) that takes a ! (escaped or not) right before a mark and writes it back as \!. The stable stringify output of ! + a mark is therefore \![x](comment:c1). Pass both syntaxes to parse, stringify and renderDom (the editor does it for you).

Options#

onCreate({ text, markdown }, { editor }) returns the new id (a promise is fine), or null / undefined to cancel; the mark is added when it resolves, if the selected text is still there ("The text changed before the comment was added" otherwise). render(id) returns the panel content: an element is inserted as is, a string is text, never markup; it runs again on update(). isResolved(id) and setState(id, state) decide the resolved look. onOpen(id, { editor, source }) (caret, key, marker, click, api), onClose, onRemove. gutter: "auto" (default; the document and sidebar layouts, when there is room beside the page and the pointer is fine), true, false. openOnCaret (default true). keymap (add: "Mod-Alt-m", next: "Alt-F9", previous: "Shift-Alt-F9", or false). toolbar (default true). labels.

Commands and handles#

Commands: addComment, comment (add, or focus the thread when the caret is in one), removeComment (the text stays), nextComment, previousComment. Handles on the plugin: update(), setState, getState, add(editor), open(editor, id), activeId(editor). A comment covers text in one block; code blocks and inline code cannot be commented on; a read-only editor adds nothing; each refusal is announced in a polite live region. In the Markdown view a comment is added by wrapping the selection in the wire text.

Read-only views#

renderDom(md, { syntax: { inline: commentSyntaxes() }, postRender: [comments.postRender] }) makes each mark a button (role="button", Enter or Space) that opens the same panel on <body> with the view's theme (mirrorTheme). A mark that already contains a link or control keeps its own behaviour.

Helpers#

findComments(markdownOrDoc) returns { id, text, runs }[] in document order (one entry per id; runs counts the pieces when formatting splits a mark); commentIds; removeCommentMarks(markdown, ids?, syntax?) removes the marks of the given ids (or all) and keeps the text, returning canonical Markdown; wrapComment(inner, id), isCommentId, commentPattern.

Keyboard and accessibility#

The panel is a non-modal role="dialog" named from the excerpt; Escape closes it and returns the caret; the close button is 44 px on coarse pointers. The gutter is a role="group" of buttons with a roving tab stop (arrows, Home, End) whose names carry the excerpt and ", resolved". Resolved is shown by a dashed border and the word, not by colour alone; forced-colors and print are handled.

Front matter (advanced-texteditor-md/frontmatter)#

A properties panel for the YAML block at the top of a Markdown file, and a safe reader and writer for it. The entry is about 15 kB gzipped including the panel's stylesheet (budget 15 kB). It uses the block-syntax hooks BlockSyntax.match and serialize (so the block is not a ::: name container). Server-safe at import.

import { createFrontMatterPlugin, FRONT_MATTER_SYNTAX, getFrontMatter, setFrontMatter, removeFrontMatter, writeFrontMatter, hydrateFrontMatter } from "advanced-texteditor-md/frontmatter"; createEditor(el, { plugins: [createFrontMatterPlugin({ collapsed: false })] });

Markdown stored: the block as written, byte for byte (the fences, even ... as a closer, the line endings, comments). The parser node is { type: "custom", name: "frontmatter", data: { yaml, open?, close? } }; stringify writes data.yaml back unchanged. A block is front matter only when it starts on the first line, a closing --- or ... exists, and its first non-blank, non-comment line is a key: line, so a horizontal rule followed by a setext heading stays what it was. Only the first block counts.

The YAML subset#

Read: top-level key: value with a scalar (plain, 'single' or "double" quoted, number, true/false, null/~, ISO date), a list (flow [a, b] on one line, or block - a lines) or a flat map one level deep. Everything else (anchors, aliases, tags, block scalars, deeper nesting, flow maps, multi-line scalars, duplicate keys, lines that are not key: lines) is a read-only entry kept byte for byte; nothing is expanded, so *alias and !!tag cannot do anything. Results have null prototypes (__proto__ is a key like any other). Limits (YAML_LIMITS): over them the whole block is kept and edits are refused (tooLarge).

Write (updateYaml, writeFrontMatter, setFrontMatter): only the lines of an edited entry change; an edited entry keeps its key spelling, spacing, quoting style and trailing comment, and an edited list reuses the source text of unchanged items. Strings that would read back as something else ("yes", "n", "2026-10-02", anything with : or a leading -) are quoted. A value with a newline or a line that is a fence cannot end the block early. undefined removes a key.

In the editor#

One atomic contenteditable=false block; the panel lives in a shadow root, so nothing it draws is ever read as content (the Markdown comes from the block's data-atm-data). Fields: text, number, date (<input type=date>), switch (checkbox with role="switch"), list (chips with remove buttons and an add field), add property (name and type; a taken name is refused), remove, rename. Each commit is one undo step. Commands: frontMatter (create when missing, then focus), focusFrontMatter (Mod-Alt-Shift-P), removeFrontMatter; slash item "Properties". ArrowUp at the start of the first block after the panel enters it, Backspace there never deletes it, Escape in the panel returns to the document. The collapsed state is per editor and never stored. A read-only editor shows the read-only list.

API#

getFrontMatter(editorOrMarkdown) โ†’ { data, raw, entries, tooLarge } or null; setFrontMatter(editor, patch | null, options?) (works in every mode; false when nothing changed, the editor is read-only or the YAML is too large); removeFrontMatter(editor); writeFrontMatter(markdown, patch | null) and splitFrontMatter, findFrontMatter, frontMatterBlock for strings; hydrateFrontMatter(root, doc?) fills renderHtml output with the read-only list; FRONT_MATTER_LABELS for translation; FRONT_MATTER_CSS is the stylesheet of the panel.

Styling#

Colours read --atm-fm-* variables (bg, fg, border, muted, accent, ring, danger, field-bg, field-border) and fall back to the theme's --atm-*; custom properties cross the shadow boundary. A read-only view is drawn in the page with the same rules from style.css; a test keeps the two copies equal. Reduced motion, coarse pointers (44 px targets), forced colors and print are handled.

Source pane (advanced-texteditor-md/source)#

The Markdown pane as a small source editor. The entry is about 11.6 kB gzipped. It works on the textarea of the markdown and split modes and does nothing in Write mode. Server-safe at import.

import { createSourcePanePlugin } from "advanced-texteditor-md/source"; createEditor(el, { plugins: [createSourcePanePlugin({ tint: true, lineNumbers: true, wrap: true })] });

Markdown stored: nothing new. The text is the textarea's text, byte for byte; the layer, the numbers and the find boxes are decoration outside the textarea.

How the tint works#

A textarea cannot colour parts of its text, and the CSS Custom Highlight API needs DOM ranges that a textarea's text does not have. So the plugin keeps a mirror: an aria-hidden, pointer-events: none layer behind the textarea holding the same text as real DOM (one block per source line, the same font, padding, wrapping and direction, copied from the textarea's computed style), coloured with spans. The textarea above it keeps its caret, selection and input, and its own text is made transparent. Because each line block wraps exactly like its line in the textarea, the line numbers (a CSS counter per block), the current-line band (the block's background) and the find boxes (ranges over the mirror's text) need no measuring. Text goes in with textContent, never as markup. Updates are incremental (only the lines between the common prefix and suffix are rebuilt); documents over eagerLines (1500) lines are tinted near the viewport only. Forced-colors mode turns the layer off and shows the textarea's own text.

Options#

tint (default true; line numbers and the band need it), lineNumbers, wrap, currentLine, autoPair, tabIndent, lineCommands, findHighlights, toolbar (two toggles, group "view"), eagerLines, labels. Commands: source:wrap, source:lineNumbers (a boolean argument sets, none toggles), source:indent, source:outdent, source:moveLineUp, source:moveLineDown, source:duplicateLine. The pure helpers are exported for hosts that want their own keys: tintLine, lineStates, nextState, indentLines, moveLines, duplicateLines, pairAction, backspacePair, applyEdit.

Keys#

Tab / Shift+Tab indent and outdent the selected lines (two spaces; a list item moves under the previous item); Alt+ArrowUp / Alt+ArrowDown move them; Mod-D duplicates; a typed opener adds its closer, typing the closer over one the plugin inserted just steps over it, and Backspace in an empty pair removes both. Tab is a trap for keyboard users, so the textarea says so (aria-description) and Escape, then Tab, leaves the pane. Every edit goes through editor.transact and editor.insertText over the selection: one undo step, one change. A read-only editor ignores all of them. IME composition is never intercepted.

Find and replace#

With the find plugin's bar open, its matches (the same compileQuery and scan functions, the same limits) are drawn over the tinted text; at most 2000 boxes are drawn.

Not offered#

Code folding. A folded region is text that exists in the textarea but not on screen, which the mirror cannot reproduce without moving the caret and selection into hidden text; the cost in complexity and in screen-reader behaviour was judged larger than the benefit (see DECISIONS.md, "Source pane v2").

Styling#

Token colours use the theme's chip palette (--atm-chip-*, which is checked against each theme's background) and can be overridden with --atm-source-* (heading, link, url, code, math, chip, image, key, muted, fg, band, caret, select, gutter-fg). Meaning never rests on colour alone: strong is bold, emphasis italic, strike struck, urls underlined.