@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 · 24

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.

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
focusBlock

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

V
indentListItem

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

V
insertHardBreak

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

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.

V
moveBlockDown

Move the focused block one position later.

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.

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

model · 47

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
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
inlineLength

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

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
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
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
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
sliceInline

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

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
withType

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

registry · 4

schema · 7

state · 7

view · 9