# Matra — full documentation > A headless rich text editor framework for the web, with a first-class > extension model. Every page of the documentation follows, in reading > order. Source: https://matrajs.com --- # Matra A headless rich text editor framework with a first-class extension API. - **No engine leakage** — the document model is plain JSON; no ProseMirror type appears in a public signature - **Plain objects, plain functions** — no `this`, no classes, no inheritance chains - **Inferred types** — adding an extension adds its commands, fully typed, with no module augmentation - **Async-safe** — position mapping is built in, so a late AI response cannot corrupt the document See [DESIGN.md](./DESIGN.md) for the API rationale, [CHANGELOG.md](./CHANGELOG.md) for what changed when, and [CONTRIBUTING.md](./CONTRIBUTING.md) before a pull request. ## Packages | Package | Purpose | Licence | |---|---|---| | `@matrajs/core` | Engine, document model, extension API, starter kit | MIT | | `@matrajs/react` | `useEditor`, `useEditorState`, `useEditorFocus`, `EditorContent` | MIT | | `@matrajs/vue` | `useEditor`, `useEditorState`, `useEditorFocus`, `EditorContent` | MIT | | `@matrajs/svelte` | `matra` — a `use:` action, the editor, and a state store | MIT | | `@matrajs/solid` | `createMatra` — the editor, a `mount` ref, and a state signal | MIT | | `@matrajs/ai` | Streaming edits that survive concurrent typing | Commercial | | `@matrajs/collab` | Authority, step rebasing, remote cursors | Commercial | | `@matrajs/versions` | Snapshots, a real diff between them, restore as one undo step | Commercial | Matra is মাত্রা — the horizontal line that runs across the top of Bengali script and holds a word together. Packages live under the `@matrajs` scope, matching matrajs.com. **Installing a binding installs the engine with it.** For a React application `pnpm add @matrajs/react` is the entire install: one package, and no third-party dependency arrives behind it. ## Quick start ```ts import { createEditor, starterKit } from '@matrajs/core' const editor = createEditor({ extensions: starterKit, content: '

Hello

', }) editor.mount(document.querySelector('#editor')!) editor.commands.toggleBold() ``` Every command comes from the array you passed. Nothing else is on `editor.commands`, and calling something that is not there is a compile error. ## The packages in detail Eight packages, one version number, released together. Every other package depends on `@matrajs/core` and on nothing else, so installing a binding installs the whole editor — there is no second package to remember, no `@matrajs/pm` to keep in step, and no peer range to resolve by hand. --- ### `@matrajs/core` — MIT The engine, and the only package that is not optional. The document model, transforms, position mapping, editor state and the editable view are written here, with **zero runtime dependencies**. ```sh pnpm add @matrajs/core ``` **Entry points** | Export | What it is | |---|---| | `createEditor(options)` | Builds an editor. The `extensions` array decides everything else about it. | | `buildSchema(extensions)` | The schema alone, for validating a document with no view and no DOM. | | `pos(…)`, `range(…)` | Constructors for the two position types. | | `starterKit` | Seventeen extensions in one array — document, paragraph, text, heading, blockquote, code block, bullet/ordered/list item, horizontal rule, hard break, bold, italic, strike, code, link, history. | | 79 named extensions | Every entry in [Extensions](#extensions), each importable on its own. | | Helpers | `tableOfContents(doc)`, `assignIds(doc)`, `commentRanges(doc)`, `activeSuggestion(editor)`, `searchEmoji(query)`, `youtubeId(url)`, `normalizeUrl(text)`, `fieldsIn(doc)`, `fillFieldsIn(doc, values)`, `hashtagsIn(doc)`, `parseDelimited(text)`, `dictationSupported()` — plain functions, not extensions. | | `toMarkdown`, `fromMarkdown` | Pure string work, so they run in Node, in a worker and at the edge. | | `…CSS` helpers | `placeholderCSS`, `commentCSS`, `taskListCSS`, `dragHandleCSS`, `suggestionCSS`, `searchCSS`, `lockedCSS`, `fieldsCSS`, `columnsCSS`, `footnotesCSS` and the rest — stylesheets to paste into an app rather than a stylesheet to import. | **`EditorOptions`** | Field | Type | Notes | |---|---|---| | `extensions` | `readonly AnyDef[]` | Declare it `as const`. The tuple is what makes the commands infer. | | `content` | `DocNode \| string` | Document JSON, or HTML to parse. | | `editable` | `boolean` | | | `autofocus` | `boolean \| 'start' \| 'end'` | | | `element` | `HTMLElement` | Mount as soon as the editor exists, instead of calling `mount` yourself. | **The editor** | Member | Signature | | |---|---|---| | `commands` | `CommandsOf & CoreCommands` | Only what the extensions you passed provide. Anything else is a compile error. | | `can` | same shape | Asks instead of does, so a button can be disabled rather than dead. | | `batch(run)` | `=> boolean` | Several commands, one undo step. Rolls back entirely if any returns `false`. | | `isActive(name, attrs?)` | `=> boolean` | Marks first, then nodes · `isActive('heading', { level: 2 })` reads naturally. | | `getJSON()` | `=> DocNode` | | | `getHTML()` | `=> string` | Answers without a DOM. | | `getText()` | `=> string` | | | `setContent(content)` | `=> void` | | | `selection` | `Selection` | | | `editable` / `setEditable(v)` | | | | `on(event, fn)` | `=> () => void` | `change`, `focus`, `blur`, `selectionChange`. Returns its own unsubscribe. | | `extensionState(name)` | `=> S \| undefined` | How a toolbar reads a character count or a collab version without a global. | | `mount(el)` / `destroy()` | | | | `unsafe` | `{ view, state, schema }` | Excluded from semver. Needing it means the public API has a gap — open an issue. | **Core commands**, present whatever you pass: `select`, `insert`, `replace`, `remove`, `moveBlock`, `focus`. `insert` and `replace` accept blocks at a caret inside a paragraph and split the paragraph around them, which is what a rule or a table asked for at the caret means. **What an extension may declare**, beyond commands, keys and input rules: | Field | On | What it does | |---|---|---| | `attributes` | extension | Add attributes to nodes and marks defined elsewhere · `[{ types: ['paragraph', 'heading'], attrs: { indent: { default: 0, render, parse } } }]`. How `textAlign`, `indent` and `uniqueId` work without the paragraph knowing about them. | | `handlePaste(ctx, { html, text, files })` | extension | Claim a paste before the editor parses it. Return `true` to keep it. | | `handleDrop(ctx, { html, text, files, pos })` | extension | The same for something dropped from outside. Block drags inside the editor never reach it. | | `filterChange(ctx)` | extension | Veto a change before it lands. Return `false` and the document, the selection and the undo history stay as they were · how `locked()` refuses a keystroke, a paste and a drag alike. `editor.can` asks it too. | | `nodeViews` | extension | Render nodes defined elsewhere with your own DOM · `{ image: ({ node, getPos, editor }) => … }`. How `imageResize()` puts a handle on the stock image. | | `decorations(ctx)` | extension | Draw over the document · highlights, widgets, a class on the current block. | | `state` | extension | Reduced on every transaction · read with `editor.extensionState(name)`. | | `code` | node | Whitespace inside is literal, so a pasted function keeps its line breaks. | | `listItem` | node | Enter splits, Tab nests, Backspace at the start lifts. | | `marks` | node | Which marks the text may carry · `''` for none. | | `nodeView` | node | Render with your own DOM and keep it across edits. | --- ### `@matrajs/react` — MIT ```sh pnpm add @matrajs/react ``` | Export | Signature | |---|---| | `useEditor(options)` | `Editor` — created lazily on first render, destroyed on unmount. | | `useEditorState(editor, select)` | `S` — a `useSyncExternalStore` subscription to `change` and `selectionChange`. | | `useEditorFocus(editor)` | `boolean` | | `EditorContent` | `{ editor }` plus every `div` attribute. | ```tsx import { starterKit } from '@matrajs/core' import { EditorContent, useEditor, useEditorState } from '@matrajs/react' export function Notes() { const editor = useEditor({ extensions: starterKit, content: '

Hello

' }) const bold = useEditorState(editor, (e) => e.isActive('bold')) return ( <> ) } ``` Options are read once. Changing them later does not recreate the editor, because tearing down a live document on a prop change loses the user's work — use the commands instead. The mount is guarded on `unsafe.view`, so StrictMode's double invoke cannot leave two views fighting over one element. --- ### `@matrajs/vue` — MIT The same four names as React, returning refs. ```sh pnpm add @matrajs/vue ``` | Export | Signature | |---|---| | `useEditor(options)` | `Editor`, `markRaw`ped · works in a component or a bare effect scope. | | `useEditorState(editor, select)` | `Readonly>` | | `useEditorFocus(editor)` | `Readonly>` | | `EditorContent` | Component with an `editor` prop. | ```vue ``` The mount is guarded, so a `` remount does not attach a second view. --- ### `@matrajs/svelte` — MIT Svelte already has the right shape — an action runs when the element exists and is told when it goes away — so the binding is thin on purpose. Written with stores rather than runes, so it behaves identically on Svelte 4 and 5. ```sh pnpm add @matrajs/svelte ``` | Export | Signature | |---|---| | `matra(options)` | `{ action, editor, state }` | | `editorState(editor)` | `Readable>` — republishes on change and selection. | ```svelte
``` The editor exists before the element does, so commands, `content` and `getJSON()` all work before anything is on screen — which is what a server render and a test both need. --- ### `@matrajs/solid` — MIT Solid's reactivity is not a render loop, so there is no `useSyncExternalStore` shape to reach for: a signal that bumps on every change is enough. ```sh pnpm add @matrajs/solid ``` | Export | Signature | |---|---| | `createMatra(options)` | `{ editor, mount, state }` — bound to the component's lifetime. | ```tsx import { starterKit } from '@matrajs/core' import { createMatra } from '@matrajs/solid' const { editor, mount, state } = createMatra({ extensions: starterKit }) return ( <>
) ``` `state()` returns the editor itself rather than a copy: a toolbar asks `isActive` at render time, and cloning a document to answer that would be the expensive way to do nothing. --- ### `@matrajs/ai` — Commercial Streaming edits that survive concurrent typing. The range being rewritten is re-resolved against the current document on every chunk, so a user who keeps typing while the model streams does not end up with a corrupted paragraph. ```sh pnpm add @matrajs/ai ``` | Export | What it is | |---|---| | `ai(options)` | The extension. `{ stream, onStatus? }`. | | `AiStream` | `(request: AiRequest) => AsyncIterable` — yours to implement. | | `AiRequest` | `{ text, instruction, signal }` | | `AiSession` | `{ id, status, range, received, error? }` | | `AiStatus` | `'idle' \| 'streaming' \| 'done' \| 'error' \| 'cancelled'` | Commands: `askAi(instruction)`, `cancelAi()`, `acceptAi()`, `rejectAi()`. ```ts import { createEditor, starterKit } from '@matrajs/core' import { ai } from '@matrajs/ai' const editor = createEditor({ extensions: [ ...starterKit, ai({ async *stream({ text, instruction, signal }) { const response = await fetch('/api/rewrite', { method: 'POST', body: JSON.stringify({ text, instruction }), signal, }) for await (const chunk of response.body!.pipeThrough(new TextDecoderStream())) yield chunk }, onStatus: (session) => setSpinner(session.status === 'streaming'), }), ] as const, }) editor.commands.askAi('make this shorter') ``` `stream` runs in your application, so the model key stays on your server. The extension never talks to us. --- ### `@matrajs/collab` — Commercial Step exchange, rebasing and presence, with **no CRDT dependency**. Another client's work rebases over unsent local work without either being lost. ```sh pnpm add @matrajs/collab ``` | Export | What it is | |---|---| | `collab(options)` | The extension. `{ clientId, version? }`. | | `Authority` | The server side · `receive(version, steps)` and `since(version)`. Transport-agnostic. | | `sendableSteps(editor)` | `Sendable \| null` — what to put on the wire. | | `getVersion(editor)` | `number` | | `remoteCursors()` | The presence extension. | | `colorFor(clientId)` | A stable colour per client. | | `remoteCursorCSS` | The stylesheet the cursor decorations expect. | | `CollabStep`, `Presence`, `Sendable`, `CollabState` | Wire types. | Command: `receiveCollabSteps(steps)` — steps this client sent are skipped, and a step that no longer applies is dropped rather than thrown, because one bad message from a peer must not take the editor down. ```ts import { createEditor, starterKit } from '@matrajs/core' import { collab, remoteCursors, sendableSteps } from '@matrajs/collab' const editor = createEditor({ extensions: [...starterKit, collab({ clientId: 'me' }), remoteCursors()] as const, }) editor.on('change', () => { const sendable = sendableSteps(editor) if (sendable) socket.send(JSON.stringify(sendable)) }) socket.onmessage = (event) => editor.commands.receiveCollabSteps(JSON.parse(event.data)) ``` `Authority` is a plain class with no server attached — run it in a WebSocket handler, a Durable Object, or a test. --- ### `@matrajs/versions` — Commercial Snapshots, a real diff between them, and restore as one undo step. ```sh pnpm add @matrajs/versions ``` | Export | What it is | |---|---| | `versions(options)` | The extension. `{ now?, idleMs?, keep?, onChange?, store? }`. | | `versionList(editor)` | `Version[]` | | `localVersionStore(key)` | A `VersionStore` on `localStorage`. | | `diffDocs(a, b)` | `DocDiff` — block-level changes between two documents. | | `diffWords(a, b)` | `WordRun[]` | | `blockStarts`, `sizeOf`, `textOf` | The primitives the diff is built from. | | `versionClasses`, `versionDiffCSS` | Class names and the stylesheet for preview decorations. | | `Version` | `{ id, label, at, doc, size }` | Commands: `snapshotVersion(label?)`, `restoreVersion(id)`, `previewVersion(id | null)`, `forgetVersion(id)`. ```ts import { createEditor, starterKit } from '@matrajs/core' import { localVersionStore, versionList, versions } from '@matrajs/versions' const editor = createEditor({ extensions: [ ...starterKit, versions({ idleMs: 30_000, keep: 50, store: localVersionStore('doc-42'), onChange: (state) => render(state.versions, state.diff), }), ] as const, }) editor.commands.snapshotVersion('before the rewrite') editor.commands.previewVersion(versionList(editor)[0].id) ``` `idleMs: null` turns automatic snapshots off and leaves them to `snapshotVersion`. A version per keystroke is not history, it is a keylogger with a nicer name. `now` is injected rather than reached for, so a test does not have to sleep to make two versions differ. --- ## Security Document JSON, pasted HTML and collaborative steps are all treated as hostile, and the rendering path is the gate they all pass through: executable attributes are never set, URL attributes are scheme-checked, undeclared attributes are dropped, and commands report failure rather than throwing. See [SECURITY.md](./SECURITY.md). ## Development ```bash pnpm install pnpm dev # playground at localhost:5173 pnpm test # vitest pnpm typecheck # tsc, including the type-level tests pnpm check # biome, and prettier for .astro pnpm build # tsup, all packages pnpm size # the bundle ladder the site quotes pnpm bench:check # the performance ratchet, against the recorded baseline pnpm links # no dead internal links on the site pnpm packaging # every built package imports and requires (run after build) pnpm wiring # every script on the site finds the markup it asks for pnpm exercise # drive every extension through the built package in a DOM: every command, rule and paste pnpm install:matrix # pack every package, npm-install it into fresh Vite apps for each framework, build and run them pnpm facts # the counts the site prints — tests, adversarial tests, extensions ``` ## Status 1.0 — the Matra engine, end to end. Document model, transforms, position mapping, editor state and the editable view are written from scratch, with **zero runtime dependencies**. 779 tests, 68 of them adversarial, and every package is installed with plain npm into a fresh React, Vue, Svelte, Solid and vanilla Vite app and built there before a release (`pnpm install:matrix`). An app on the starter kit bundles **31 kB gzipped**, because nothing arrives that the editor does not use — seventy-nine extensions ship in the package and none of them is in the bundle until it is in the array. The whole ladder, from an empty extension array upwards, is measured by `pnpm size` and checked in CI. It was 25 kB at 0.16; what the five kilobytes bought is listed in [CHANGELOG.md](./CHANGELOG.md). Drag and drop landed in 0.9.0: blocks drag with a handle, a line shows where they will land, and the move is one undo step. The view passes its tests but has not yet met real IME users on iOS Safari or Android Chrome. See [ENGINE.md](./ENGINE.md) for where the risk actually sits, and [harness/ime](./harness/ime) for the page that checks it on a real device. ## Extensions Everything in the box, and everything free unless marked. | | | | |---|---|---| | **Text** | bold, italic, strike, code, underline, highlight, subscript, superscript, link, **text style**, **kbd** | colour, background, font family and size, as one mark | | **Blocks** | paragraph, heading, blockquote, code block, horizontal rule, hard break, image, **callout**, **details** | a Notion callout and a collapsible toggle | | **Embeds** | **YouTube**, **any embed page** in a sandboxed frame, **image resize** with a handle | allowlisted hosts only; the width lands in the HTML | | **Templates** | **locked blocks**, **fields**, **snippets** | a contract with fixed clauses, a mail merge with no editor, words that expand as typed | | **Layout** | **columns**, **page break**, **line height**, **text direction** | two to six columns, a real break in print, right-to-left detected from the text | | **Scholarly** | **footnotes**, **math** inline and display | numbered by position; KaTeX or MathJax plug in, or the source shows | | **Lists** | bulleted, ordered, **task lists** with real checkboxes | | | **Tables** | insert, delete, header rows, colspan and rowspan, **add and remove rows and columns, Tab between cells** | spanning cells widen rather than split | | **Writing** | placeholder, character count, text align, **indent**, **typography**, **emoji shortcodes**, **autolink**, **clear formatting**, **text case**, **invisible characters**, **selection highlight**, **typewriter scrolling**, **autosave**, **smart paste**, **hashtags** | smart quotes, dashes, arrows · `:tada:` · URLs link as you type · tab-separated text becomes a table | | **Finding** | **search and replace** | incremental: typing rescans one paragraph | | **Code** | **syntax highlighting** as decorations | a built-in tokeniser, or plug in Shiki, Prism or lowlight | | **Structure** | **table of contents**, **unique block ids**, **focus class**, **trailing node** | derived from the document, never stored beside it | | **Interchange** | **Markdown in and out**, with no DOM | runs on a server | | **Dragging** | block drag and drop, **drag handle**, drop cursor, **files dropped or pasted** | the drop cursor is in the engine, not an extension | | **Review** | threaded comments anchored to ranges | free here · Tiptap's Comments needs a subscription | | **Menus** | `@` mentions and `/` commands, detection only, **bubble and floating menus** for your element | the popup is yours | | **Assistance** | **ghost text** completion from any source, **dictation** through the browser's recogniser | Tab takes the suggestion; nothing is sent anywhere the browser does not already send it | | **Paid** | AI streaming, collaboration with remote cursors, version history | | Tiptap 3 moved most of its old Pro extensions to MIT — a table of contents, unique ids, the drag handle, the file handler, emoji, details, invisible characters and mathematics are all free there now, and it is worth saying so rather than repeating a comparison that was true of Tiptap 2. What is still behind a Tiptap subscription is comments, snapshots and version history, the AI toolkit, track changes, DOCX import and export, and pagination. Of those, comments are free here. Version history, collaboration and AI are the three packages this project charges for, and the shape is deliberate: the things that take a week are free and drive adoption, and the ones that took months are what you pay for. ### Adding one, step by step Every extension follows the same four steps. Search and replace, as the example: 1. **Import it** from `@matrajs/core` — the binding you installed already depends on it, so there is nothing to add to `package.json`. 2. **Put it in the array.** Extensions that take options are functions; the rest are plain objects. 3. **Call its commands.** They are on `editor.commands`, typed from the array, so a typo is a compile error. 4. **Paste its CSS** if it has any. Extensions that draw something export a `…CSS` string; the editor ships no appearance of its own. ```ts import { createEditor, search, searchCSS, starterKit } from '@matrajs/core' const editor = createEditor({ extensions: [...starterKit, search()] as const }) editor.commands.setSearch({ query: 'colour', wholeWord: true }) editor.commands.nextMatch() // selects it, so the view scrolls there editor.commands.replaceMatch('color') editor.commands.replaceAllMatches('color') // one undo step editor.extensionState('search') // { matches, current, query, … } for a panel document.head.appendChild(Object.assign(document.createElement('style'), { textContent: searchCSS })) ``` The same shape for the rest: `textStyle` then `editor.commands.setColor('#c00')`; `callout` then `toggleCallout('warning')`; `...detailsKit` then `insertDetails()`; `youtube` then `insertYoutube({ src: url })`; `fileHandler({ accept: ['image/'], onDrop })` then upload in `onDrop` and insert at `marker.map(pos)`; `...tableKit` then `insertTable(3, 3)` and `addRowAfter()`. Each is one row in the directory on [matrajs.com/extensions](https://matrajs.com/extensions), with the line you would write. `toMarkdown` and `fromMarkdown` are pure string work rather than a trip through HTML, so they run in Node, in a worker, and at the edge. Turning a document into Markdown on a server does not need a DOM polyfill. ## Against the alternatives Measured, not asserted — see [BENCHMARKS.md](./BENCHMARKS.md) for the method and what the numbers are not. Package counts are what npm resolves for a React install of each, measured by [`scripts/rivals.mjs`](./scripts/rivals.mjs) on 2026-09-07 against Tiptap 3.31.3, Lexical 0.50.0 and Slate 0.126.2, with React and `@types/*` left out of the count. | | Matra | Tiptap | Lexical | Slate | |---|---|---|---|---| | Bundle, gzipped | **31 kB** | 117 kB | ~35 kB | ~50 kB | | Packages installed | **2** | 50 | 34 | 12 | | Of those, third-party | **0** | 22 | 10 | 8 | | Engine types in your code | **none** | ProseMirror | Lexical | Slate | | Command types | **inferred** | module augmentation | manual | manual | | Async position safety | **built in** | manual | manual | manual | | Vue binding | first-class | first-class | community | community | | Svelte and Solid bindings | **first-class** | community | community | community | | Comments | **free** | subscription | build it | build it | | Runtime licence check or phone-home | **never** | none | n/a | n/a | Rows that used to be here and are no longer true: Tiptap 3 publishes its table of contents, unique ids, drag handle, file handler, emoji, details, invisible characters and mathematics extensions as MIT, and `@tiptap/markdown` parses and serialises Markdown in bare Node. Tiptap also ships an official Vue binding, which an earlier version of this table called community. Where the alternatives win, and it is worth saying so: ProseMirror's ecosystem is a decade deep and Tiptap inherits all of it, Lexical has been hardened by Meta's traffic, and both have met far more real IME users than this has. If you need a mature extension for something exotic today, they have it and this does not. ## Releasing One registry, and an order that matters. See [RELEASING.md](./RELEASING.md). Every release is recorded in [CHANGELOG.md](./CHANGELOG.md). ## Licence **The core is MIT and stays that way.** `@matrajs/core` and every framework binding — `@matrajs/react`, `@matrajs/vue`, `@matrajs/svelte` and `@matrajs/solid` — the engine, the document model, the extension API, the starter kit, tables, comments, every mark and node that ships in the box. No open-core asterisk on any of it, no feature removed later to sell back. **AI, collaboration and version history are paid.** `@matrajs/ai`, `@matrajs/collab` and `@matrajs/versions` are source-available under the [Matra Commercial License](./packages/ai/LICENSE): free to evaluate, develop against, test, teach with, and use in personal projects and small internal tools; paid per developer in production. They are the things here that took months rather than days — streaming edits that survive concurrent typing, rebasing another client's work over unsent local work without losing either, and a real diff between two snapshots of a document. **Nothing phones home and there is no runtime licence check.** Your editor never talks to us, in development or in production, and a lapsed subscription cannot switch anything off in an app you already shipped. There is no download gate either. The source is in this repository and the packages install from public npm — the licence is the boundary, as with the Business Source Licence. What a subscription buys is the right to run them in production, plus updates and support. **Versions up to 0.5.0 shipped under MIT, including `ai` and `collab`, and that grant cannot be withdrawn.** Anyone already on 0.5.0 may stay there under MIT forever. The commercial licence starts at 0.6.0. --- # The Matra engine Matra runs on its own engine, called the Matra engine the way Tiptap's is called ProseMirror and Lexical's is Lexical: the document model, content expressions, transforms, position mapping, editor state, history, and the editable view, all under `packages/core/src/engine/`. Nothing in it depends on another editor framework, and no type from it appears in a public signature. This file is its history and its notes. It began as a plan to remove ProseMirror layer by layer, and the plan is kept as written because the reasoning still holds. ## Removing ProseMirror Decision: Matra owns its engine. ProseMirror is being replaced layer by layer, not ripped out — the public API leaks no engine type, so each layer can be swapped without users noticing. ## Method Phase 1 was a true strangler: keymap, input rules, history and list commands each replaced a package and each dropped a dependency, with the suite green throughout. **Phases 2–5 cannot work that way.** The remaining packages are mutually coupled through the document model: prosemirror-transform → prosemirror-model prosemirror-state → prosemirror-model, transform, view prosemirror-view → prosemirror-model, state, transform PM's transform builds and consumes PM `Node` instances, so our model cannot be handed to it. Model, transform, state and view therefore land together as a parallel engine and flip in one cutover. That means no dependency count moves until the whole thing is done, and the cutover is the risky moment rather than a series of small ones. To keep it honest: 1. Build the parallel engine under `packages/core/src/engine/`. 2. Test each layer directly, in isolation, as it is written. 3. Before the flip, run the entire existing suite against the new engine behind a switch — both engines pass, or the flip does not happen. 4. Keep the ProseMirror view available behind a flag until the new view has survived real users on iOS Safari and Android Chrome. ## Phases | Phase | Layer | Lines | gz | Status | |---|---|---|---|---| | 1 | keymap, input rules, history, list commands | ~1,700 | 21 kB | **done** | | 2 | model — nodes, marks, fragments, schema, content expressions, DOM parse/serialize | ~3,500 | 31 kB | **done** | | 3 | transform — steps, position mapping, rebasing | ~2,200 | 19 kB | **done** | | 4 | state — transactions, selection, plugins | ~1,000 | 9 kB | **done** | | 5 | view — contenteditable, IME, selection sync | ~6,000 | 59 kB | **done** | ## Phase 2 notes `engine/model/content-expression.ts` is done: a tokenizer and parser for the content language (`paragraph block*`, `(text | image)+`, `heading{1,3}`), an NFA compiler, and a subset construction to a DFA of `ContentMatch` states. `fillBefore` is the piece worth pointing at. When a match cannot legally end, it breadth-first searches for the shortest run of fillable types that would close it — which is how the editor repairs a document instead of refusing an edit. A type marked `fillable: false` is never used to repair, so a node that needs real attributes is never invented out of nothing. Written since: `mark.ts` (mark sets, rank ordering, exclusion), `fragment.ts` (immutable runs, text joining, cutting, boundary-correct `findIndex`), `node.ts` (sizes, classification, text extraction, descendant walking) and `schema.ts` (NodeType, content compilation, `createAndFill`). Two decisions worth remembering: - **Text nodes are canonicalised on construction.** Adjacent text carrying identical marks is merged and empty text is dropped, so two documents that mean the same thing compare equal. - **`createAndFill` returns null rather than guessing.** If closing a content gap would need a node whose attributes have no defaults, the caller is told the edit is impossible instead of receiving a malformed document. Phase 2 is complete: `resolved-pos.ts` (ancestor chains, neighbours, marks at a position, shared depth, block ranges) and the DOM layer. Two behaviours in the DOM layer are deliberate and worth keeping: - **An unrecognised element is transparent.** The parser descends into it rather than dropping it, so pasting from a word processor keeps the text instead of losing it to a `
` nobody wrote a rule for. - **Loose inline content gets wrapped.** Pasting bare text produces inline nodes with no parent block; they are wrapped in the default textblock rather than discarded, because discarding them loses the paste. Next: phase 3, transform — steps and position mapping. ## Phase 3 notes `step-map.ts` is the crown jewel: flat `[start, oldSize, newSize]` triples, an `assoc` argument deciding which side of an insertion point a position lands on, and `deleted` reporting when a position was inside a span that no longer exists. It is fuzzed, not just sampled — 500 deterministic seeds asserting that mapping never moves a position backwards past an earlier one, that inverting returns every position outside a change exactly, and that a chain of maps equals applying them one at a time. **A property the fuzz forced us to state honestly:** a deletion collapses both edges of its span onto one point. Deleting `[5,6)` sends both 5 and 6 to 5, and inverting cannot know which it came from — that information is genuinely gone. Round-trip is exact only for positions strictly outside the changed span; on the boundary, `assoc` picks a side. The first version of the test asserted a stronger property than reality allows and had to be corrected, not the code. `step.ts` covers replace, addMark and removeMark, each able to invert itself. The replacement planner handles all four shapes a cross-block range can take — both ends inside blocks (the blocks join, which is what backspace at a boundary means), one end inside, or both on boundaries. Anything deeper than one level of nesting returns null so the step fails loudly rather than producing a malformed document. Rebasing is done. `Step.map` moves a step over changes made underneath it and returns null when there is nothing left to act on. One rule there is worth keeping: a step that meant *replace this text* whose text has since been deleted must not degrade into *insert this text here*. Without that check, a rebased AI rewrite pastes itself into a paragraph the user already deleted. ## Phase 4 notes `selection.ts`, `transaction.ts`, `state.ts` and `plugin.ts`. - Selections snap to positions text can actually occupy, so nothing downstream has to re-check. A NodeSelection whose node is deleted degrades to a caret rather than pointing at nothing. - A transaction remaps its own selection after every step, so the caret stays where the user would expect as the document moves under it. - Setting the selection clears stored marks: typing after moving the caret should not inherit bold from somewhere else. - `state.apply` returns *the same state object* when a plugin vetoes, so callers can compare by identity to know whether anything happened. ## Phase 5 notes — and the cutover The view is built on `beforeinput` rather than mutation reconciliation. The browser announces what it is about to do, the view cancels it, applies the equivalent change to the model, and re-renders. The DOM is therefore a projection of the document rather than a second source of truth that has to be diffed back. Composition is the deliberate exception. While an IME candidate window is open the browser is left completely alone — cancelling input mid-composition breaks Japanese, Chinese and Korean entry outright — and the affected content is read back when composition ends. **One behavioural difference from ProseMirror:** the view takes over the element it is given rather than creating a child. `editor.mount(el)` makes `el` itself the editable surface. ### Cutover, done dependencies: {} All four ProseMirror packages are gone and the entire suite passes on our engine: 178 tests, unchanged in intent from when they ran against ProseMirror. That was the contract, and it held. Measured at the cutover: | | before | after | |---|---|---| | runtime dependencies | 9 | **0** | | `@matrajs/core` gzipped | 5.9 kB | 25.9 kB | | full app bundle gzipped | 66.4 kB | **18.4 kB** | The core package grew because the engine is now inside it. What matters to a user is the last row: an app ships a fraction of what it did, because nothing is pulled in that the editor does not use. Extensions have landed since, so that last number is not today's. The current figure is whatever `pnpm size` prints — **31 kB** for the starter kit as of 1.0 — and it is checked in CI rather than quoted from here. ## What 1.0.1 changed underneath - **A replace step can say what it kept.** `ReplaceStep` takes optional `ranges` — `[start, oldSize, newSize]` triples over the old document — and its map tells that story instead of treating the whole range as gone. `Transform.rebuild(from, to, content, kept)` derives the triples from the runs of content an operation leaves in place. Retyping a block, wrapping, lifting, splitting, nesting and un-nesting list items all go through it, which is why a caret now survives every one of them. - **Lists are one command.** `toggleList` in `list-commands.ts` wraps blocks one item each, takes items out of a top-level list, outdents from a nested one, and changes a list's kind in place. - **The renderer remembers what it drew.** Two weak maps — node decorations written onto an element, decorations drawn inside a content element — are what a patch is compared against, rather than last render's set mapped through the edit, which loses a decoration whose range the edit replaced. ## What 1.0 changed underneath Measured first, then changed; the numbers are in BENCHMARKS.md. The shape of each change, and why it is that shape: - **Fragments past twenty-four children keep a prefix index.** `findIndex` bisects it, and `replaceChild` carries it across by shifting the tail rather than rebuilding it. Resolving a position near the end of a long document stopped costing the document. - **`nodesBetween` walks only what a range touches**, jumping to the first child with the index and stopping at the last. `hasMark`, `removeMark`, `setBlockType` and every extension that used to `descendants` its way through the document to find a selected word use it. - **Mark steps rebuild locally.** A block level swaps only the run of children that changed, via `Fragment.replaceRange`; a textblock is rebuilt in canonical form because text merges. Untouched blocks stay the same object, which is what lets the renderer skip them. - **The command context is a class with a lazy transaction.** `isActive`, `can` and decoration hooks read the state and never start one. - **The transaction's selection is taken as it is.** It was mapped through the whole mapping a second time in `EditorState.apply`, after the transaction had already moved it step by step — so `insert('X')` left the caret one past its own text. Found by a probe, pinned by a test. - **A block inserted at a caret inside a paragraph splits the paragraph.** The rule button and the `---` shortcut both asked for that and were refused. - **Decorations are compared after mapping**, and the span where they differ joins the span the edit touched. A textblock whose runs kept their shape has its text nodes updated in place. A node decoration that moved rebuilds the element it left as well as the one it reached. - **The parser compiles each selector once** and indexes rules by tag. Inside `
`, and inside any node that says `code`, whitespace is literal.
- **Two editors from one extension array share the compiled schema**, the
  parser, the serializer, the command table and the input rules.
- **Position markers are held weakly**, and the mapping log is swept every
  256 changes for what no live marker still needs.
- **Attributes can be added to nodes another extension owns**, rendered onto
  the element and read back on parse. `textAlign` now works on the paragraph
  in the box; before, it silently did nothing unless you wrote your own.

## What typing costs

A keystroke is the operation everything else is measured against, and it took
three rounds of profiling to stop it costing the length of the document. The
current shape, and why each piece is that shape:

- **The document is rebuilt around one child, not by cutting.** An edit inside a
  paragraph rebuilds every ancestor between it and the root. Doing that by
  cutting the ancestor's children in two and appending the replacement back into
  the middle walks the whole run three times and re-adds every child's size to
  reach a total that differs from the old one by exactly one child.
  `Fragment.replaceChild` copies the array once and does the size arithmetic in
  a subtraction. Text is the exception — text nodes merge with their neighbours,
  so the canonical form still has to be rebuilt when either side is text.
- **The diff asks before it touches the DOM.** `childNodes` is a live list, and
  the patch loop used to index it for every child before deciding whether that
  child was inside the edit at all. On two thousand blocks, 1999 of those reads
  were thrown away.
- **The position map reuses its entries.** Re-recording is what a patch does to
  every node whose subtree it kept, and a fresh entry object per node per edit is
  garbage generated to say what the old object already said.
- **A full position-map backlog drops the backlog, not the document.** The map
  absorbs each edit's mapping rather than rewriting every entry, and replays the
  backlog when a cold entry is read. Past sixty-four pending edits the replay
  costs more than saying where everything is again — which used to mean
  rebuilding the whole document's DOM, at the cost of a rebuild every
  sixty-fourth keystroke and the silent loss of every mounted node view's state.
  The re-record happens after the patch, because before it the positions are
  still in the coordinates the edit moved away from.

Measured in Node against happy-dom, that takes a keystroke on a
2000-paragraph document from 0.464 ms to 0.062 ms, and stops it tracking the
document's length: 0.045 ms at 20 blocks against 0.062 ms at 2000. In a browser
it is what put Matra ahead of Lexical on the row it used to lose.

The first render is a different problem with a different answer. It is within
about 15% of the floor — the cost of the browser creating the same elements with
no editor involved — so there is very little of it that is ours to remove. What
was ours: building into a document fragment and attaching it once rather than
appending block by block into a live tree, skipping the mark stack for children
that have no marks, and taking a direct path for the `[tag, 0]` shape most nodes
render as. Together, 0.77 ms to 0.59 ms for two hundred blocks in Node.

## What is deliberately not built yet

Honesty about the gaps, since "no dependencies" can read as "complete":

- ~~**Collaborative editing.**~~ Done in `@matrajs/collab`: an authority, step
  exchange, rebasing of unsent work over remote edits, and remote cursors drawn
  as decorations, each one mapped through local steps rather than clamped.
- ~~**Node views.**~~ Done. A node type may declare `nodeView`, returning its
  own DOM plus an optional `contentDOM` for children. `stopEvent` keeps the
  editor's hands off interactions inside the view.

  Node views forced a real fix underneath: the renderer used to call
  `replaceChildren()` on every keystroke, which is O(document) per character and
  would have destroyed a view's focus, scroll position and any half-finished
  interaction. It now patches. Because nodes are immutable, an edit inside one
  paragraph leaves every other paragraph as literally the same object, so
  identity alone skips most of the tree. Inline content inside a textblock is
  still rebuilt whole — it is small, and mark wrappers make its DOM shape
  diverge from the fragment.
- **Decorations.** No inline highlights or widgets independent of the document.
- **Drag and drop**, and **tables**.
- **Deep nesting in replace.** A cross-block range nested more than one level
  deep returns null rather than guessing; it fails loudly, but it fails.

## Where the risk actually is

**Phase 3 is the correctness risk.** Position mapping is what makes a late AI
edit land on the right words. A subtle bug there corrupts documents silently,
which is the exact failure Matra is sold against. It needs property-based tests:
invert-and-reapply round-trips, mapping associativity, and fuzzed step sequences
compared against a reference implementation.

**Phase 5 remains the schedule risk, and shipping it does not end that.** The
view passes its tests in happy-dom, which is not a browser. IME composition for
CJK input, Android GBoard's after-the-fact corrections, spellcheck and
autocorrect mutating the DOM, and browser-specific selection bugs are found by
real users on real devices, not by unit tests. Treat the current view as
working-but-unproven until it has survived iOS Safari and Android Chrome, and
expect a tail of fixes there rather than a clean finish.

`harness/ime` is where that gets checked: a page to open on a real phone that
watches the document and the screen for the moment they disagree, logs the
composition events the browser actually sent, and walks a checklist of the cases
that break editors. Deliberately manual — the value is in the keyboards a device
farm does not have installed.

## Rules while this is in progress

- No ProseMirror type may enter a public signature. `types.ts` stays clean.
- Every phase keeps the full suite green; no phase lands with skipped tests.
- Bundle size is measured at each phase and recorded here, not estimated.

---



# Matra — core API design

Status: draft. Nothing published beyond reserved package names.

## Principles

1. **The engine never leaks.** No ProseMirror type appears in a public signature.
   Raw access exists at `editor.unsafe`, is excluded from semver, and every use of
   it is a bug report against this API.
2. **Plain data, plain functions.** Definitions are object literals. Commands are
   ordinary functions. No `this`, no classes, no `.extend()` inheritance chains.
3. **Types are inferred, never declared twice.** Adding an extension to the array
   adds its commands to `editor.commands` with full argument types. There is no
   module augmentation step and no interface to keep in sync.
4. **Async is a first-class problem.** Positions drift while an AI call is in
   flight. The API makes that safe by default rather than leaving it to callers.

## Three primitives

```ts
const heading = defineNode({
  name: 'heading',
  content: 'inline*',
  group: 'block',
  attrs: { level: { default: 1 } },
  parseHTML: [{ tag: 'h1' }, { tag: 'h2' }, { tag: 'h3' }],
  toDOM: (node) => [`h${node.attrs?.level}`, 0],
  commands: {
    setHeading: (ctx, level: 1 | 2 | 3) => ctx.setBlockType('heading', { level }),
  },
  keys: { 'Mod-Alt-1': 'setHeading' },
})

const bold = defineMark({
  name: 'bold',
  parseHTML: [{ tag: 'strong' }, { style: 'font-weight=bold' }],
  toDOM: () => ['strong', 0],
  commands: { toggleBold: (ctx) => ctx.toggleMark('bold') },
  keys: { 'Mod-b': 'toggleBold' },
})

const editor = createEditor({ extensions: [heading, bold] })
editor.commands.setHeading(2)   // typed
editor.commands.setHeading(9)   // compile error
```

`defineExtension` is the third: no schema contribution, just commands, keys,
state and lifecycle. Everything that isn't a node or a mark.

## What differs from TipTap, and why

| TipTap | Matra | Reason |
|---|---|---|
| `addCommands() { return { cmd: () => ({ commands }) => ... } }` | `commands: { cmd: (ctx, ...args) => boolean }` | Three levels of currying collapse to one function. Arguments get real types instead of being erased. |
| `this.editor`, `this.options`, `this.storage` | everything passed as arguments | `this` is bound differently per hook and resists typing. Explicit arguments always work. |
| `Extension.create().extend()` | plain objects, composed | Inheritance chains make it impossible to know what a definition finally contains. |
| Commands merged into one global namespace | same, but collisions are a **compile error** | Two extensions declaring `toggleBold` should not silently shadow. |
| `declare module` augmentation for types | inferred from the extensions array | The augmentation step is the most common source of broken types in TipTap projects. |
| PM types in public API (`Node`, `Mark`, `EditorState`) | plain JSON `DocNode` | Swapping or upgrading the engine becomes possible without a breaking release. |

## Async and position drift

The hard problem in an AI editor: you send a paragraph to a model, the user keeps
typing, the response arrives three seconds later, and every position you captured
is now wrong. Written naively this corrupts documents.

`ctx.mark()` takes a marker that maps positions through every intervening change:

```ts
const rewrite = defineExtension({
  name: 'ai-rewrite',
  commands: {
    rewrite: (ctx) => {
      const marker = ctx.mark()
      const range = ctx.selection
      const text = ctx.doc // read what we need now

      void ai.rewrite(text).then((result) => {
        // the user may have typed anywhere in the meantime
        editor.commands.replaceRange(marker.mapRange(range), result)
      })
      return true
    },
  },
})
```

This is why the AI layer belongs in core's design even though it ships as a
separate package. Retrofitting position mapping later is not possible.

## Open questions

- **Collaboration.** Y.js maps positions through its own type. `PosMarker` and
  Y.js relative positions need to be one concept, not two. Decide before 0.1.
- **`batch()` rollback.** Rolling back when any command returns false is stated
  in the types but interacts with input rules in ways not yet worked out.
- **Node views.** Framework-specific by nature. Core should expose a renderer
  interface that `@matrajs/vue` and `@matrajs/react` implement, but the shape of
  that interface is undecided.
- **Schema ordering.** ProseMirror's first node becomes the doc's default content.
  Currently implicit via `priority`; may need to be explicit.

## Verified

`packages/core/src/types.test-d.ts` is a compile-time test. It asserts that valid
calls typecheck and that wrong arity, wrong argument types, and unknown commands
all fail. Run with `tsc --noEmit --strict`.

---



# Benchmarks

Run them yourself: `node bench/bench.mjs`.

## Bundle

An app importing the editor and the starter kit, bundled with esbuild,
minified, gzipped:

| | minified | gzipped |
|---|---|---|
| **Matra** | 94.6 kB | **31 kB** |
| Tiptap 3.30 | 370.5 kB | 117.2 kB |

**3.8× smaller.** Matra has no runtime dependencies; a React install of Tiptap
resolves 50 packages, 22 of them outside the `@tiptap` scope — mostly
ProseMirror. Counted by [`scripts/rivals.mjs`](./scripts/rivals.mjs), which
asks npm to resolve the install rather than trusting a number typed here. The figure was 25 kB at 0.16; 1.0
spent 3.6 kB on the engine doing more — attributes one extension adds to
another's nodes, paste and drop hooks, files and text dropped from outside,
blocks inserted into the middle of a paragraph, decorations compared after
mapping, paragraphs patched in place, positions found by bisection — and
every one of those is on the runtime side of this file.

## Speed, in a browser

Four editors mounted in the same page, in the same run, in Chrome 152 on
2026-09-05 — Matra 1.0.1 against Tiptap 3.31, Lexical 0.50 and Slate 0.126.
Each cell is the median of three runs of a median of seven samples.
Milliseconds, lower is better. `bench/browser` builds and runs this.

**No editor** is the same paragraphs built by hand into a `contenteditable`
div, with nothing else in the page — the floor none of these can go under.

| operation | No editor | Matra | Tiptap | Lexical | Slate |
|---|---|---|---|---|---|
| parse a document, 2000 ¶ | — | **2.2** | 8.1 | 35.8 | — |
| `getHTML()`, 2000 ¶ | — | **0.3** | 1.7 | 5.3 | — |
| keystroke, 200 ¶ | — | **0.073** | 0.158 | 0.102 | — |
| keystroke, 2000 ¶ | — | **0.480** | 0.622 | 0.547 | — |
| mount + first render, 200 ¶ | 1.0 | **1.2** | 4.6 | 2.3 | 5.0 |
| mount + first render, 2000 ¶ | 12.2 | **14.6** | 34.1 | 21.6 | 50.2 |

**Absolute milliseconds only mean anything within one run.** Same harness, same
browser, a different day: Lexical parsed the same document in 34.7 ms one week
and 71.6 ms the next, without a line of it changing. That is why all four are
mounted in one page and measured in one pass, and why comparing a number here
against a number from an older copy of this file is comparing two machines.

**Every mount is checked, not trusted.** The harness mounts once more outside
the timing and looks for the last paragraph's text on screen. A reconciler that
returns before its DOM exists is the cheapest possible way to win the mount row,
and an earlier version of this harness reported a number for Slate when nothing
had been drawn at all.

**The keystroke row changed hands.** It used to be the row Matra lost. Measured
back to back against the same rivals in the same session:

| keystroke | before | after |
|---|---|---|
| 200 paragraphs | 0.246 | **0.127** |
| 2000 paragraphs | 1.465 | **0.847** |

What was costing it, all of it found by profiling rather than by reading:

- **Every ancestor of the edit was rebuilt by cutting.** A paragraph changes and
  each ancestor up to the document is rebuilt around it — by cutting the run of
  children in two, appending the replacement, appending the rest, and re-adding
  every child's size to get a total that differed from the old one by exactly
  one child. On a 2000-block document that is four walks of two thousand
  children per character. `Fragment.replaceChild` swaps the one child that moved
  and does the arithmetic in one subtraction.
- **The diff reached into the DOM for blocks it had already decided to skip.**
  The loop read `childNodes[i]` before asking whether child `i` was inside the
  edit at all. `childNodes` is a live list, and for 1999 of 2000 blocks the
  answer was thrown away. Asking first, indexing second.
- **Every sixty-fourth keystroke threw the rendered document away.** The
  position map absorbs each edit's mapping instead of rewriting itself, and
  capped the backlog at 64 — past which the renderer rebuilt the entire
  document's DOM. It was the backlog that had gone stale, not the DOM, so now
  only the backlog is dropped and the positions are recorded again in place. A
  rebuild also silently dropped every mounted node view's state, which is a
  correctness bug wearing a performance bug's clothes.
- **The position map allocated an entry per node per edit** to record a position
  it already held. It reuses the entry now.

In Node, against happy-dom, where none of the browser's layout cost is in the
way, those take a keystroke on a 2000-paragraph document from 0.464 ms to
0.062 ms, and the cost stops tracking the length of the document: 0.045 ms at
20 blocks against 0.062 ms at 2000.

**The mount row was wrong, and the harness was why.** This file used to say
Lexical put an editor on screen faster. It does not. Teardown ran *inside* the
timed function and the layout read came after it, so an editor whose teardown
detaches its DOM had taken its document off screen before the browser was asked
to lay anything out. Lexical's teardown does that; Matra's drops listeners and
leaves the document where it is. Matra was paying for two thousand paragraphs of
layout and Lexical was not, on a row where layout is most of the number.
Teardown now runs after the clock stops and both mount rows changed hands. Same
class of mistake the harness already refused to make for Slate, one level up.

The first render did get faster while this was being chased — 0.77 ms to 0.59 ms
for two hundred blocks in Node — by building into a document fragment and
attaching it once rather than appending block by block into a live tree,
dropping the mark stack's three arrays per child for marks that blocks never
have, and taking a direct path for the `['p', 0]` shape most nodes render as.

**What is missing and why.** Slate's keystroke goes through a React render that
has not happened by the time the timer stops, and the harness checks whether the
text on screen changed during the measurement — when it did not, it reports
`NOT MEASURED` instead of a number. An earlier version of the harness cheerfully
reported Slate at 0.02 ms per keystroke, which was the model update with nothing
drawn behind it.

**What is not like-for-like.** Each editor is driven through its own idiomatic
API. Lexical and Slate carry the rich-text behaviour their own quick-starts
prescribe, which is not the same feature set as Matra's or Tiptap's starter kit.

## Speed, in Node

happy-dom, same document. Useful for the parts that never touch a DOM:

| operation | Matra | Tiptap | |
|---|---|---|---|
| create + parse a document | **0.70** | 21.62 | 31× faster |
| `getJSON()` | **0.16** | 0.11 | 1.5× slower |

## 1.0, in Node

The ratchet's own figures, before and after, in the calibrated units
`bench/bench.mjs` prints — the same machine, the same run, the floor of three
passes each:

| figure | 0.16 | 1.0 | |
|---|---|---|---|
| create editor, 50 ¶ | 17.5 | **3.7** | 4.7× |
| setContent JSON, 2000 ¶ | 127.2 | **80.4** | 1.6× |
| `getHTML()`, 2000 ¶ | 89.6 | **30.3** | 3.0× |
| `getJSON()`, 2000 ¶ | 11.3 | **9.7** | 1.2× |
| `getText()`, 2000 ¶ | 27.6 | **7.5** | 3.7× |
| insert one character | 2.73 | **0.22** | 12× |
| toggle bold over a range | 4.46 | **0.26** | 17× |
| keystroke, mounted, 500 ¶ | 5.04 | **2.17** | 2.3× |

And the ones the ratchet did not measure, in microseconds, because they are
where the time actually went:

| operation, 2000 ¶ | 0.16 | 1.0 | |
|---|---|---|---|
| keystroke at the **end** of the document, mounted | 180 | **34** | 5.3× |
| keystroke at the start, mounted | 77 | **48** | 1.6× |
| toggle bold on a word near the end | 453 | **20** | 23× |
| `isActive('bold')` + `isActive('heading')` | 1.47 | **0.12** | 12× |
| `createEditor`, empty | 85 | **16** | 5.3× |
| parse 2000 ¶ of HTML with marks (ms) | 77 | **55** | 1.4× |

What was costing it:

- **Every position was found by walking.** Resolving a position walked the
  document's children from the first, adding sizes until it passed the point,
  and a keystroke resolves a dozen positions. Typing at the end of a
  two-thousand-block document cost twelve times what typing at the top did,
  and the benchmark only ever typed at the top. A fragment past twenty-four
  children now keeps a prefix index and bisects it.
- **A mark on one word rebuilt the whole document.** Asking "is this bold"
  visited every node in the document to find the ones in the selection, and
  applying the mark rebuilt every level from the first child to the last.
  Both now walk only what the range touches, and only the children that
  changed are swapped into their parent.
- **`toDOM` was handed a full JSON serialisation of the node.** Rendering a
  paragraph serialised its text; rendering the document serialised the
  document, once per level, so that a function returning `['p', 0]` could read
  an attribute. It now gets an object that carries the type and the attributes
  and builds the rest on demand.
- **Every command built a fresh object of twenty closures**, and every
  `isActive` started a transaction to answer a question about the state. The
  context is a class now and the transaction starts on first write, so
  asking is free.
- **The character counter re-serialised the document on every click.**
  Any extension reading `ctx.doc` in its reducer paid for the whole document
  on every transaction, caret moves included. The counter reads the engine's
  text and only when the document changed; `ctx.doc` is cached per document
  within a command.
- **The drag handle asked the browser for every block's rectangle on every
  mouse move.** Blocks stack, so the one under the pointer is found by
  bisection: eleven rectangles on a two-thousand-block page, not two thousand.
- **The undo history copied the whole entry to add each keystroke to it.**
  Inverses are appended and replayed from the end.
- **Every keystroke wrote the browser selection**, even when it was already
  where it was about to be put, and every write came straight back as a
  `selectionchange` event to be read and found identical.
- **A decoration anywhere threw the narrowed redraw away.** Last render's
  decorations are now mapped through the edit before being compared, so a
  search hit that merely moved is the same hit, and only the span where the
  decorations really differ is added to what gets redrawn.

## Where the time went, the first time

Two earlier rounds, kept because both are the kind of thing that grows back:

- **The position map was rebuilt on every keystroke.** Every node was
  re-recorded, so a 4000-paragraph document did eight thousand map writes per
  character — all of them to say the same thing shifted by one. The map now
  absorbs the transaction's mapping and translates positions when asked.
- **The diff visited every block.** It now skips any block the edit's span did
  not touch: 3999 of 4000 children on a keystroke.

And one that was pure waste: `toJSON` called `Object.keys(attrs).length` to ask
whether a node had attributes, allocating an array per node to answer a
question about emptiness.

---



# Security

## The model

A rich text editor is a place untrusted content arrives from at least three
directions, and all three are treated as hostile:

| route | example |
|---|---|
| document JSON | a document loaded from your database, written by a user |
| pasted HTML | anything the clipboard contains |
| collaborative steps | a message from another client |

Validation that lives only in `parseDOM` or only in a command is bypassed by the
other two routes. **The last gate is the rendering path**, in
`engine/model/safe-attrs.ts`, which every route passes through.

## What is enforced

- **Executable attributes are never set.** Anything matching `on*`, plus
  `srcdoc`.
- **URL attributes are scheme-checked** — `href`, `src`, `xlink:href`,
  `action`, `formaction`, `poster`, `data`. `javascript:`, `vbscript:` and
  `data:` are refused, except `data:image/*` on an ``, which is a
  legitimate inline image. The tag matters: the same bytes on an `