@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 · 24
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.
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.
The block the selection currently focuses, or `null`.
Indent a list item by raising its `indent` attr (lists only).
Insert a hard line break (Shift+Enter) inside the current block.
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.
Move the focused block one position later.
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.
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`).
model · 47
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`.
Total length of inline content in UTF-16 code units (DOM-offset compatible).
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.
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 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.
Inline slice between two character offsets `[from, to)`.
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.
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 · 7
Build a {@link Schema} from node and mark spec maps.
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 · 9
The nearest contenteditable block host containing `node`, or `null`.
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.
Attribute marking the filler `<br>` of an empty block (not a real newline).
Build slash-menu items from the registry, filtered by `query` against each block's title and keywords. Data-driven: any newly registered block with `meta` shows up automatically.
Parse a contenteditable host (or any DOM subtree, e.g. pasted HTML) back into normalized inline runs, resolving marks from the registry's `parseDOM` rules.
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.
Build a config with sensible defaults.