@robonen/writekit
A headless, block-based rich-text writekit for Vue 3 — in the spirit of Tiptap / ProseMirror / Editor.js, but with a registry-driven schema and a hand-built CRDT for collaboration (no Yjs / Loro / Automerge).
Most writekits force a trade: the structured, block-first authoring of Editor.js, or the document fidelity of ProseMirror where native cross-block selection and arrow navigation just work. @robonen/writekit takes the ProseMirror route — a single contenteditable surface — and layers a modular block registry on top, so blocks and inline marks are added without touching the core. The model, schema, state, commands and keymap are entirely DOM-free and Vue-free; the Vue layer only renders and handles input. Every edit is a step-based transaction with an exact inverse, which gives you real undo/redo and — because the same steps drive the CRDT — conflict-free collaboration for free.
Headless by design
Ships behavior and DOM structure (data-block-* hooks), never styling. Bring your own CSS and own the look completely.
Registry-driven schema
defineBlock / defineMark register into an immutable schema — add a custom block or mark with no core changes.
Step-based transactions
Every edit is a step with an exact inverse, powering reliable undo/redo and a single source of truth for both local edits and sync.
Own CRDT, pluggable
RGA text, fractional-indexed blocks, Peritext-style marks and presence behind a CrdtProvider — over any transport.
Install
The writekit depends on @robonen/crdt for the built-in collaboration provider, and on vue as a peer.
pnpm add @robonen/writekit @robonen/crdt vueQuick start
Create a registry, build an writekit around its state, and mount WritekitRoot. Its default slot renders WritekitContent (the single contenteditable), so this is a fully working writekit with all built-in blocks and marks.
<script setup lang="ts">
import { createDefaultRegistry, createWritekit, createWritekitState, WritekitRoot } from '@robonen/writekit';
const registry = createDefaultRegistry();
const writekit = createWritekit({ state: createWritekitState({ registry }) });
</script>
<template>
<WritekitRoot :writekit="writekit" autofocus class="writekit" />
</template> Provide your own slot to add UI around the editable surface — the bubble toolbar floats over a selection, and the slash menu opens when you type / at the start of a line.
<WritekitRoot :writekit="writekit" autofocus>
<WritekitContent />
<WritekitBubbleMenu /> <!-- formatting toolbar on selection -->
<WritekitSlashMenu /> <!-- type `/` to insert blocks -->
</WritekitRoot>Commands
Commands are (state, dispatch?, view?) => boolean functions that power the keymap, the UI, and programmatic edits. Run one with writekit.command(...); omit the dispatch to dry-run it for active/disabled state.
import { setBlockType, toggleMark } from '@robonen/writekit';
writekit.command(toggleMark('bold'));
writekit.command(setBlockType('heading', { level: 2 }));
// Called without a dispatch they run dry — perfect for
// computing disabled / active toolbar state.
const canBold = writekit.command(toggleMark('bold'));Built-in blocks & marks
createDefaultRegistry() wires up a full set out of the box — blocks: paragraph, heading (1–6), bulleted-list / numbered-list / todo-list, blockquote, code-block, callout, divider, image; marks: bold, italic, underline, strike, highlight, code, link. Markdown input rules (# , - , 1. , > , [] ) and hotkeys (Mod-b/i/u, Mod-z, …) are included.
Status: v0, work in progress. Core logic is covered by unit + convergence tests; the contenteditable / Playwright suite runs locally. The collaboration layer has a few documented, deferred limitations.
Where to next
Jump into the pieces you'll reach for first:
- Playground — a live writekit you can type in, right here in the docs.
WritekitRootandWritekitContent— the mount surface and the single contenteditable.createDefaultRegistry,defineBlockanddefineMark— extend the schema.toggleMark/setBlockType— the commands API for programmatic and toolbar edits.bindCrdtandcreateNativeProvider— wire up real-time collaboration with the built-in CRDT.
The full API reference for every export is listed right below.
commands · 35
Add a mark across the current (same-block) range.
Apply the first matching block input-rule at the caret. Rules live on block definitions (`inputRules`) and match the text from the block start to the caret — e.g. `'# '` → heading, `'- '` → bulleted list, `'> '` → quote. Run from the input flow after each text change.
Combine commands into one that runs them in order and stops at the first that applies (returns `true`). The standard way to bind several fallbacks to a key.
Convert one block by id (the gutter menu's "turn into"), keeping its inline content.
The block type a fresh line gets: `paragraph` when registered, else the first text block.
Delete the current selection. Handles a node (block-level) selection, a same-block range, and a cross-block range (delete the partial ends, drop the blocks in between, merge the last block into the first). Never leaves an empty document — a fresh paragraph is inserted if everything was removed.
Remove whatever the selection covers, recording the steps on `tr`, and say where an insertion should now go. A collapsed caret removes nothing and anchors at itself. Returns `null` when the selection cannot be removed (a range whose end blocks are not text).
Insert a copy of a block right after it, under a fresh id.
A document never ends up empty: seed a default text block if everything was removed.
Enter with an atom selected: start a paragraph right below it. An atom (image, divider, an app's card) has no text position inside it, so without this the only way OUT of a selected atom — and the only way to write between two atoms, or after one that ends the document — was to abandon the keyboard. Mirrors `createParagraphNear` in the ProseMirror tradition.
The block the selection currently focuses, or `null`.
Indent a list item by raising its `indent` attr (lists only).
Insert `node` next to a block — below by default, above with `above`. The caret lands at the end of a text node (so a node seeded with `/` opens the slash menu), a void node is selected.
Insert a hard line break (Shift+Enter) inside the current block.
Splice a fragment into the working document at `anchor`, recording the steps on `tr`, and return the selection that lands after it. Inline fragments insert in place. Block fragments split the caret's block: an open first block merges into the head, an open last block into the tail, whole blocks go between. An empty line the fragment lands in is replaced, not kept, and an empty head/tail adopts the type of the block it merges with — pasting a heading into a blank paragraph yields a heading.
Whether the focused block matches a type (and optionally a subset of attrs).
Whether a mark is active for the current selection — used by `toggleMark` and by toolbars (call a command without `dispatch` for the same answer).
Whether a block type holds inline (text) content.
Backspace at the start of a block: merge it into the previous text block, or select a preceding atom block (image/divider) so a second Backspace deletes it.
Delete at the end of a block: merge the next text block into it.
After removing blocks at `index`: the end of what precedes, else the start of what follows.
Move the focused block one position later.
Move a set of blocks so they sit, in document order, before the block that currently occupies `toIndex` (`content.length` means "at the end"). One transaction, one undo entry; a drop that changes nothing is refused.
Move the focused block one position earlier.
Outdent a list item by lowering its `indent` attr (lists only).
Delete a specific block by id (used by atom-block UIs).
Remove a mark across the current (same-block) range.
Replace the selection with a fragment — one transaction, one undo entry.
Progressive select-all (Mod+A): first press selects the current block's text, a second press selects every block.
Block id the selection's focus is in (or the first node-selected block).
Convert the focused block to `type` (preserving inline content).
Split the current text block at the caret (Enter). A non-collapsed same-block selection is deleted first. Caret lands at the start of the new block.
Toggle the focused block between `type` (with `attrs`) and a fallback type (default `paragraph`). Powers heading shortcuts and conversion toggles.
Toggle the `checked` attribute of the focused to-do item.
Toggle a mark. On a collapsed caret it flips the stored marks (applied to the next typed character); on a range it adds/removes the mark across it, honoring the mark's `excludes`. Cross-block ranges are deferred to M2 (returns false).
crdt · 5
Wire a {@link CrdtProvider} to an {@link Writekit}: local transactions flow into the CRDT, and remote ops are reflected back as a single history-bypassing `setDoc` transaction. The provider's `onLocalOps`/`applyUpdate` are connected to a transport by the caller.
The built-in CRDT provider backed by `@robonen/crdt`: a fractional-ordered set of blocks, each a text RGA + mark store. Writekit steps map to CRDT ops via {@link DocumentCrdt}; ops sync as op batches over any transport.
The writekit's document CRDT: a fractional-ordered set of blocks, each a text RGA + a mark store (or an attribute-only atom). It translates the writekit's offset-based {@link Step}s into id-based CRDT ops ({@link translateStep}), integrates ops from any replica ({@link applyOp}), and materializes an {@link WritekitDocument} ({@link toDocument}).
Build a document equal to `next` but reusing block-node identities from `prev` wherever a block is deep-equal — so applying a remote change repaints only the blocks that actually changed (others keep their reference, and the local caret in them is undisturbed). Returns `prev` unchanged when nothing differs.
Marks transactions that apply remote CRDT changes (so they bypass local history).
general · 4
Lightweight registry — basic text blocks and the common marks only.
Batteries-included registry with the default blocks and marks.
The block definitions bundled in the default preset (registration order = menu order).
The mark definitions bundled in the default preset.
keymap · 5
Merge ordered keymaps into a single normalized lookup. Earlier keymaps win, so pass user overrides before the defaults: `compileKeymaps([user, defaults], …)`.
The standard writekit keymap. Mark/heading shortcuts are no-ops when the mark or block type isn't registered. Enter/Backspace/Delete are no-ops except at block boundaries, so ordinary intra-block editing stays native. Arrow navigation and cross-block selection are fully native (one contenteditable spans the doc).
Canonical combo string for a keydown event (matches {@link normalizeCombo}).
Normalize a human combo (`'Mod-Shift-z'`) to a canonical, platform-resolved form (`'Shift-Meta-z'` on mac). Modifiers are ordered deterministically so a keydown event maps to the same string via {@link eventToCombo}.
Look up and run the command bound to a keydown event. Returns `true` when a command handled it (the caller should then `preventDefault`).
marks · 1
model · 56
Add `mark` across `[from, to)`, replacing any existing mark of the same type.
Structural equality for attribute bags. `undefined` and `{}` are equivalent so `{ type: 'bold' }` equals `{ type: 'bold', attrs: {} }`.
Structural equality for two attribute values. Order-insensitive for object keys, deep for arrays/objects. Used by mark/attr deduplication and tests.
A block by id, or `null`.
Index of a block by id, or `-1` if absent.
Construct a collapsed caret selection.
Construct a document from blocks.
Stable, collision-resistant identifier for blocks. Block ids survive split/merge/move and are how positions, selections, and the CRDT address a block — so they must be unique and never reused.
Construct a {@link Node}, generating an id when not supplied.
Delete the character range `[from, to)`.
A block and its index, or `null` if absent.
First block, or `null` for an empty document.
Whether `marks` contains a mark structurally equal to `mark`.
Whether `marks` contains any mark of the given `type`.
Structural equality of two inline contents (same runs, same marks).
Total length of inline content in UTF-16 code units (DOM-offset compatible).
A single-block, open-both-ends fragment: plain inline content with a carrier type.
Concatenated plain text of inline content.
Insert inline `content` (preserving its marks) at character `offset`.
Insert `text` (carrying `marks`) at character `offset`.
Whether the selection spans more than one block.
Whether the selection is a collapsed caret.
Best-effort runtime check for inline (text-block) content. The authoritative answer comes from the schema; this is a convenience for model-level helpers.
Whether a fragment is one open text block — i.e. it inserts inline, with no block boundary.
Last block, or `null` for an empty document.
Structural equality for two marks (type + attrs).
Marks active at a collapsed caret `offset` — used to seed stored marks and to decide toggle state. Defaults to the marks of the character before the caret.
Ordered structural equality for two normalized mark sets.
The block order after moving `ids` (in document order) to sit before the block currently at `toIndex`; `content.length` means the end. What a drop and `moveBlocks` agree on, so the indicator never promises a move the command would refuse.
The block after `id` in document order, or `null`.
Inline content of a node, or `[]` when the node is not a text block.
Construct a block-level selection.
Plain text of a node, or `''` when the node has no inline content.
Canonical form: drop empty runs, merge adjacent runs with equal mark sets, normalize each run's marks. Must be applied after every inline mutation so the model stays diff-stable and equality stays cheap.
Canonicalize a mark set: keep the last occurrence per `type` (so a re-applied mark with new attrs wins) and sort by `type`. The deterministic order is what makes {@link marksEq} an O(n) comparison and keeps the model diff-stable.
Endpoints of a text selection in document order (`from` before `to`). Within one block they are ordered by offset; across blocks by block index.
Construct a {@link Position}.
Whether two positions address the same block and offset.
The block before `id` in document order, or `null`.
Whether the whole range `[from, to)` carries a mark of `markType`.
Remove every mark of `markType` across `[from, to)`.
Return a copy of `doc` with a different block list.
Replace the character range `[from, to)` with inline `content`.
Structural equality for two selections.
The fragment covered by a selection, or `null` when there is nothing to copy. A text range is open at both ends; a node selection is whole blocks.
Inline slice between two character offsets `[from, to)`.
Plain text of a fragment: one line per text block; atoms contribute nothing.
Construct a text selection (focus defaults to anchor → collapsed caret).
Return a copy of `node` with new attrs.
Return a copy of `node` with new content.
Copies of `blocks` under new ids. Pasted blocks must never reuse the ids they were copied with: the same id twice in a document breaks selection and, in the CRDT, re-inserting a known id *reactivates* the tombstoned original instead of adding a block.
Return a copy of `node` with a new type (and optionally new attrs).
registry · 4
Build an immutable {@link Registry} from block and mark definitions.
Identity factory that narrows a block definition's literal type (cf. `definePlugin`).
Identity factory that narrows a mark definition's literal type (cf. `definePlugin`).
Return a new registry extending `base` with extra blocks/marks (override wins).
schema · 8
Build a {@link Schema} from node and mark spec maps.
Drop the marks a block spec disallows (and any the schema does not know).
Whether a block spec is an atom/void block.
Whether a block spec is a container of child blocks.
Whether a block spec holds inline (text) content.
Whether a mark of `markType` is allowed inside a block with this spec.
Bring a document to canonical form against a schema: coerce attrs, normalize inline content, drop marks that are unknown or disallowed in their block, and drop blocks of unknown type. This is the single funnel every document passes through before it becomes writekit state.
Structural validation of a document against a schema. Reports unknown block types, missing required attrs, and failed attr validators. Used in tests and as a guard around untrusted input; runtime mutation paths rely on {@link normalizeDocument} instead.
state · 7
Apply a single step to a document, returning the next document and the exact inverse step (so undo is correct by construction). Pure: never mutates input. If the addressed block is missing the step is a no-op (defends against remote steps referencing concurrently-removed blocks).
Produce the next writekit state from a transaction. Stored marks are kept when explicitly set, cleared on any content change, and otherwise preserved.
Start a transaction from the current writekit state.
Create an {@link Writekit} around an initial state.
Build the initial writekit state: normalize the document against the schema and ensure it has at least one editable block to place the caret in.
A mutable builder that accumulates atomic {@link Step}s over a working copy of the document. Each builder method applies its step immediately (so later builders see prior effects) and records the exact inverse for undo. Dispatch turns the finished transaction into a new {@link WritekitState}.
view · 35
How fast to scroll when the pointer is within `edge` of a container edge: 0 outside the zone, ramping to `max` px per frame at the edge itself. Negative means up.
What the gutter's children (handle, inserter, anything custom) work against.
The vertical position of a boundary: the top of the row after it, or the bottom of the last row.
The nearest contenteditable block host containing `node`, or `null`.
The compiled rules of a registry — built on first use, one set per registry.
Which way a native deletion eats. At a block boundary the browser would cross into the neighbouring block's DOM, so those cases go to the join commands regardless of granularity (character, word, line).
Detect the platform for keybinding normalization (defaults to `'other'` off-browser). Delegates UA sniffing to `@robonen/platform`, which also handles iPadOS masquerading as a Mac; `isMac`/`isIOS` return `undefined` off-browser.
Pixel distance between two points.
The boundary a vertical position points at: `k` means "before row k", and `rows.length` is the end. Rows are in document order, so the answer is a binary search over their midpoints — no hit-testing, no DOM.
Attribute marking the filler `<br>` of an empty block (not a real newline).
Slash-menu items filtered by `query` against each item's title and keywords.
What a text block can be turned into: the text-kind entries, the current one flagged.
Whether a fragment carries no structure the HTML added: only default-type blocks, no marks, no attrs. Such HTML (a code editor's coloured spans, a terminal, a textarea) says less than the plain-text payload beside it.
Edits that insert the event's `data` — the ones a ranged selection turns into replace-with-text.
Every pickable block, one entry per definition — or one per `meta.variants` entry when the definition declares them (Heading 1/2/3). Also what the gutter's "turn into" menu lists, minus the atoms. Data-driven: any newly registered block with `meta` shows up automatically.
The first block rule an element satisfies, in priority order.
The marks an element contributes: for each mark type, its first satisfied rule.
Edits writekit performs as commands instead of the browser. `historyUndo`/ `historyRedo` arrive from the context menu and from platforms whose undo shortcut never reaches the keymap; the `format*` family comes from native shortcuts and menus on some platforms.
Read a DOM subtree into a fragment. Only the model comes out: no element of the source, no style, no attribute the rules did not ask for. Works on a detached (inert) document or a live one.
Parse clipboard HTML. `DOMParser` builds an inert document — scripts never run, images never load — and the walk copies text and marks out of it, so pasted markup cannot reach the live page at all.
Read a fragment written by {@link serializeSlice}. Anything that is not the expected shape yields `null` so the caller falls back to HTML; whatever passes goes through the schema funnel like every other document.
Parse a contenteditable host back into normalized inline runs, resolving marks from the registry's `parseDOM` rules. This is the typing path — one block, the DOM writekit painted itself; foreign HTML goes through `view/clipboard`, which also knows about blocks.
Parse plain text: one block per line, blank lines dropped, ``` fences folded into a code block, and each line's leading marker resolved through the registry's input rules — so a markdown file pastes as headings, lists and quotes. A single line with no marker is inline: it joins the caret's line rather than splitting it.
The model position under a viewport point, or `null` when the point is not over text inside `root`. Chromium and WebKit still ship the old `caretRangeFromPoint`; Firefox has only the standard one.
The fragment a clipboard (or drop) payload holds, by fidelity: writekit's own JSON, then HTML, then text. HTML that adds no structure of its own — a code editor's coloured spans, a terminal — defers to the text beside it, which also gets the markdown-style line rules. `null` when there is nothing to paste as content (an image file, say).
Render inline content into a contenteditable host imperatively (never via Vue's template diff, which would fight the caret). Marks nest by `rank` (lower = outer) for stable, deterministic output. An empty block gets a single filler `<br>` so it has height and a caret target.
Realize a `DOMOutputSpec`: a bare string is a tag, an array is `[tag, attrs?, ...children]` where a string child is text and `0` is the content hole. Pure structure — the caller fills the hole.
Build a config with sensible defaults.
Pointer-driven block reordering. No native drag and drop: it cannot be styled, has no drop indicator, ignores touch on iOS, and a `draggable` child inside a contenteditable fights text selection. Block rects are measured once when the drag begins, relative to the content root; each frame maps the pointer to a boundary by binary search and adds the root's current offset, so scrolling needs no re-measure. The dragged blocks become the node selection for the duration, which is what the consumer's `[data-selected]` styling and Escape/Backspace already handle.
The clipboard type writekit reads back losslessly: a fragment as JSON. Other apps ignore it; writekit prefers it to `text/html`, so marks with attrs a foreign parser would flatten survive a copy/paste round trip.
Put a fragment on the clipboard in every format writekit can produce.