@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.

sh
pnpm add @robonen/writekit @robonen/crdt vue

Quick 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.

vue
<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.

vue
<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.

ts
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:

The full API reference for every export is listed right below.

❯

commands · 35

fn
addMark

Add a mark across the current (same-block) range.

V
applyInputRule

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.

fn
chainCommands

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.

fn
convertBlock

Convert one block by id (the gutter menu's "turn into"), keeping its inline content.

fn
defaultTextType

The block type a fresh line gets: `paragraph` when registered, else the first text block.

V
deleteSelection

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.

fn
deleteSelectionInto

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).

fn
duplicateBlock

Insert a copy of a block right after it, under a fresh id.

fn
ensureNotEmpty

A document never ends up empty: seed a default text block if everything was removed.

V
exitAtom

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.

fn
focusBlock

The block the selection currently focuses, or `null`.

V
indentListItem

Indent a list item by raising its `indent` attr (lists only).

fn
insertBlockBeside

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.

V
insertHardBreak

Insert a hard line break (Shift+Enter) inside the current block.

fn
insertSliceAt

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.

fn
isBlockActive

Whether the focused block matches a type (and optionally a subset of attrs).

fn
isMarkActive

Whether a mark is active for the current selection — used by `toggleMark` and by toolbars (call a command without `dispatch` for the same answer).

fn
isTextBlockType

Whether a block type holds inline (text) content.

V
joinBackward

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.

V
joinForward

Delete at the end of a block: merge the next text block into it.

fn
landingAfter

After removing blocks at `index`: the end of what precedes, else the start of what follows.

V
moveBlockDown

Move the focused block one position later.

fn
moveBlocks

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.

V
moveBlockUp

Move the focused block one position earlier.

V
outdentListItem

Outdent a list item by lowering its `indent` attr (lists only).

fn
removeBlock

Delete a specific block by id (used by atom-block UIs).

fn
removeMark

Remove a mark across the current (same-block) range.

fn
replaceSelection

Replace the selection with a fragment — one transaction, one undo entry.

V
selectAll

Progressive select-all (Mod+A): first press selects the current block's text, a second press selects every block.

fn
selectionBlockId

Block id the selection's focus is in (or the first node-selected block).

fn
setBlockType

Convert the focused block to `type` (preserving inline content).

V
splitBlock

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.

fn
toggleBlockType

Toggle the focused block between `type` (with `attrs`) and a fallback type (default `paragraph`). Powers heading shortcuts and conversion toggles.

V
toggleChecked

Toggle the `checked` attribute of the focused to-do item.

fn
toggleMark

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

general · 4

keymap · 5

marks · 1

model · 56

fn
addMarkInline

Add `mark` across `[from, to)`, replacing any existing mark of the same type.

fn
attrsEq

Structural equality for attribute bags. `undefined` and `{}` are equivalent so `{ type: 'bold' }` equals `{ type: 'bold', attrs: {} }`.

fn
attrValueEq

Structural equality for two attribute values. Order-insensitive for object keys, deep for arrays/objects. Used by mark/attr deduplication and tests.

fn
blockById

A block by id, or `null`.

fn
blockIndex

Index of a block by id, or `-1` if absent.

fn
caret

Construct a collapsed caret selection.

fn
createDoc

Construct a document from blocks.

fn
createId

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.

fn
createNode

Construct a {@link Node}, generating an id when not supplied.

fn
createSlice
fn
deleteTextInline

Delete the character range `[from, to)`.

fn
findBlock

A block and its index, or `null` if absent.

fn
firstBlock

First block, or `null` for an empty document.

fn
hasMark

Whether `marks` contains a mark structurally equal to `mark`.

fn
hasMarkType

Whether `marks` contains any mark of the given `type`.

fn
inlineEq

Structural equality of two inline contents (same runs, same marks).

fn
inlineLength

Total length of inline content in UTF-16 code units (DOM-offset compatible).

fn
inlineSlice

A single-block, open-both-ends fragment: plain inline content with a carrier type.

fn
inlineText

Concatenated plain text of inline content.

fn
insertInline

Insert inline `content` (preserving its marks) at character `offset`.

fn
insertTextInline

Insert `text` (carrying `marks`) at character `offset`.

fn
isAcrossBlocks

Whether the selection spans more than one block.

fn
isCollapsed

Whether the selection is a collapsed caret.

fn
isEmptySlice
fn
isInlineContent

Best-effort runtime check for inline (text-block) content. The authoritative answer comes from the schema; this is a convenience for model-level helpers.

fn
isInlineSlice

Whether a fragment is one open text block — i.e. it inserts inline, with no block boundary.

fn
isNodeSelection
fn
isTextSelection
fn
lastBlock

Last block, or `null` for an empty document.

fn
markEq

Structural equality for two marks (type + attrs).

fn
marksAt

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.

fn
marksEq

Ordered structural equality for two normalized mark sets.

fn
moveBefore

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.

fn
nextBlock

The block after `id` in document order, or `null`.

fn
nodeInline

Inline content of a node, or `[]` when the node is not a text block.

fn
nodeSelection

Construct a block-level selection.

fn
nodeText

Plain text of a node, or `''` when the node has no inline content.

fn
normalizeInline

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.

fn
normalizeMarks

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.

fn
orderedSelection

Endpoints of a text selection in document order (`from` before `to`). Within one block they are ordered by offset; across blocks by block index.

fn
position

Construct a {@link Position}.

fn
positionEq

Whether two positions address the same block and offset.

fn
previousBlock

The block before `id` in document order, or `null`.

fn
rangeHasMarkType

Whether the whole range `[from, to)` carries a mark of `markType`.

fn
removeMarkInline

Remove every mark of `markType` across `[from, to)`.

fn
replaceBlocks

Return a copy of `doc` with a different block list.

fn
replaceInline

Replace the character range `[from, to)` with inline `content`.

fn
selectionEq

Structural equality for two selections.

fn
sliceFromSelection

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.

fn
sliceInline

Inline slice between two character offsets `[from, to)`.

fn
sliceText

Plain text of a fragment: one line per text block; atoms contribute nothing.

fn
textSelection

Construct a text selection (focus defaults to anchor → collapsed caret).

fn
withAttrs

Return a copy of `node` with new attrs.

fn
withContent

Return a copy of `node` with new content.

fn
withFreshIds

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.

fn
withType

Return a copy of `node` with a new type (and optionally new attrs).

registry · 4

schema · 8

state · 7

view · 35

fn
autoscrollSpeed

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.

I
BlockGutterContextValue

What the gutter's children (handle, inserter, anything custom) work against.

fn
boundaryY

The vertical position of a boundary: the top of the row after it, or the bottom of the last row.

fn
closestBlockHost

The nearest contenteditable block host containing `node`, or `null`.

fn
compiledRules

The compiled rules of a registry — built on first use, one set per registry.

fn
createBlockElementRegistry
fn
createSelectionBridge
fn
deletionDirection

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).

fn
detectPlatform

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.

fn
distance

Pixel distance between two points.

fn
dropIndexAt

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.

V
FILLER_ATTR

Attribute marking the filler `<br>` of an empty block (not a real newline).

fn
getSlashItems

Slash-menu items filtered by `query` against each item's title and keywords.

fn
getTurnIntoItems

What a text block can be turned into: the text-kind entries, the current one flagged.

fn
isNativeInlineEdit
fn
isStructureless

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.

fn
isTextInsertion

Edits that insert the event's `data` — the ones a ranged selection turns into replace-with-text.

fn
listBlockItems

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.

fn
matchBlock

The first block rule an element satisfies, in priority order.

fn
matchMarks

The marks an element contributes: for each mark type, its first satisfied rule.

fn
ownedEdit

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.

fn
parseDOMSlice

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.

fn
parseHtmlSlice

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.

fn
parseJsonSlice

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.

fn
parseRuns

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.

fn
parseTextSlice

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.

fn
positionFromPoint

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.

fn
readSlice

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).

fn
renderRuns

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.

fn
renderSpec

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.

fn
resolveConfig

Build a config with sensible defaults.

fn
serializeSlice
fn
useBlockDrag

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.

V
WRITEKIT_MIME

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.

fn
writeSlice

Put a fragment on the clipboard in every format writekit can produce.