R

@robonen/editor

A headless, block-based rich-text editor 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 editors 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/editor 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 editor depends on @robonen/crdt for the built-in collaboration provider, and on vue as a peer.

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

Quick start

Create a registry, build an editor around its state, and mount EditorRoot. Its default slot renders EditorContent (the single contenteditable), so this is a fully working editor with all built-in blocks and marks.

vue
<script setup lang="ts">
import { createDefaultRegistry, createEditor, createEditorState, EditorRoot } from '@robonen/editor';

const registry = createDefaultRegistry();
const editor = createEditor({ state: createEditorState({ registry }) });
</script>

<template>
  <EditorRoot :editor="editor" autofocus class="editor" />
</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
<EditorRoot :editor="editor" autofocus>
  <EditorContent />
  <EditorBubbleMenu />  <!-- formatting toolbar on selection -->
  <EditorSlashMenu />   <!-- type `/` to insert blocks -->
</EditorRoot>

Commands

Commands are (state, dispatch?, view?) => boolean functions that power the keymap, the UI, and programmatic edits. Run one with editor.command(...); omit the dispatch to dry-run it for active/disabled state.

ts
import { setBlockType, toggleMark } from '@robonen/editor';

editor.command(toggleMark('bold'));
editor.command(setBlockType('heading', { level: 2 }));

// Called without a dispatch they run dry — perfect for
// computing disabled / active toolbar state.
const canBold = editor.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.

API Reference

Commands · 12

Crdt · 5

General · 2

Keymap · 5

Model · 58

fn
addMarkInlinetested

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

fn
attrsEqtested

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

T
AttrValuetested

Attribute values are JSON-serializable so documents round-trip losslessly and a CRDT adapter can map them onto its own primitives without special-casing.

fn
attrValueEqtested

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

fn
blockByIdtested

A block by id, or `null`.

fn
blockIndextested

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

fn
carettested

Construct a collapsed caret selection.

T
Contenttested

A block's content. Three shapes, chosen by the block's schema: - `Inline` for text blocks (paragraph, heading, list item), - `readonly Node[]` for container blocks (reserved; no default block uses it), - `null` for atom/void blocks (image, divider).

fn
createDoctested

Construct a document from blocks.

fn
createIdtested

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
createNodetested

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

fn
deleteTextInlinetested

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

I
EditorDocumenttested

The editor document: an ordered list of top-level blocks. Default blocks are flat (lists use indent attributes, not nesting), so document helpers operate on the top-level array.

fn
findBlocktested

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

fn
firstBlocktested

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

fn
hasMarktested

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

fn
hasMarkTypetested

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

T
Inlinetested

A block's inline content: an ordered, normalized list of runs.

fn
inlineLengthtested

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

I
InlineNodetested

A run of text sharing the same marks. The chosen inline representation ("marked runs") renders to per-block contenteditable as a span list and maps isomorphically onto a character-sequence CRDT with formatting.

fn
inlineTexttested

Concatenated plain text of inline content.

fn
insertInlinetested

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

fn
insertTextInlinetested

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

fn
isAcrossBlockstested

Whether the selection spans more than one block.

fn
isCollapsedtested

Whether the selection is a collapsed caret.

fn
isInlineContenttested

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
isNodeSelectiontested
fn
isTextSelectiontested
fn
lastBlocktested

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

I
Marktested

An inline formatting mark applied to a run of text (bold, italic, link, ...). `type` is the registry key; `attrs` holds mark-specific data (e.g. link href).

fn
markEqtested

Structural equality for two marks (type + attrs).

T
Markstested

A normalized set of marks: at most one per type, sorted by `type`.

fn
marksAttested

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
marksEqtested

Ordered structural equality for two normalized mark sets.

fn
nextBlocktested

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

I
Nodetested

A document block. `id` is stable across split/merge/move.

fn
nodeInlinetested

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

fn
nodeSelectiontested

Construct a block-level selection.

I
NodeSelectiontested

A block-level selection of one or more whole blocks (atoms, Mod+A stage 2).

fn
nodeTexttested

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

fn
normalizeInlinetested

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
normalizeMarkstested

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
orderedSelectiontested

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
positiontested

Construct a {@link Position}.

I
Positiontested

A position inside the document, addressed by block id + a UTF-16 character offset into that block's inline content. Offsets are UTF-16 code units to line up with the DOM `Selection`/`Range` API, so the view bridge maps 1:1.

fn
positionEqtested

Whether two positions address the same block and offset.

fn
previousBlocktested

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

fn
rangeHasMarkTypetested

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

fn
removeMarkInlinetested

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

fn
replaceBlockstested

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

fn
replaceInlinetested

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

fn
selectionEqtested

Structural equality for two selections.

fn
sliceInlinetested

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

fn
textSelectiontested

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

I
TextSelectiontested

A text selection: caret when `anchor === focus`, range otherwise. May span blocks.

fn
withAttrstested

Return a copy of `node` with new attrs.

fn
withContenttested

Return a copy of `node` with new content.

fn
withTypetested

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

Registry · 13

I
BlockBehaviortested

Optional block-specific behaviors used by core commands.

I
BlockComponentPropstested

Props passed to an atom/void block's Vue `component`.

I
BlockDefinitiontested

A block definition: schema contribution + behavior + an opaque Vue component. Non-view layers treat `component` as an opaque value; only the view resolves it. The type is `Component` purely for authoring ergonomics (type-only import).

I
BlockMetatested

Presentational/discovery metadata: powers slash menu, conversion, toolbars.

T
ConflictPolicytested

How to resolve two definitions registered under the same type.

fn
createRegistrytested

Build an immutable {@link Registry} from block and mark definitions.

fn
defineBlocktested

Identity factory that narrows a block definition's literal type (cf. `definePlugin`).

fn
defineMarktested

Identity factory that narrows a mark definition's literal type (cf. `definePlugin`).

fn
extendRegistrytested

Return a new registry extending `base` with extra blocks/marks (override wins).

I
InputRuleSpectested

A pattern that transforms the current block or marks when typed (e.g. `'# '` → heading, `'**x**'` → bold). The matching engine lands in M2; the type is declared now so block/mark definitions can carry their rules as data.

I
MarkDefinitiontested

A mark definition: schema contribution (attrs, exclusivity, rank, toDOM, parseDOM) + metadata. Marks are data-only — the view renders/parses them via the spec, which is what makes them fully modular through the registry.

I
MarkMetatested

Presentational/discovery metadata for a mark (toolbar label, shortcut hint).

I
Registrytested

The single source of truth for which block and mark types exist and how they behave. Immutable: built once via {@link createRegistry}; {@link extendRegistry} returns a new registry. The {@link Schema} is projected from the definitions.

Schema · 16

I
AttrSpec

Specification for a single attribute: default, requiredness, validation.

T
AttrsSpec

Map of attribute name → {@link AttrSpec}.

T
ContentKind

The content model of a block — a deliberately small, closed union instead of ProseMirror's content-expression grammar (KISS). - `text`: holds inline content; `marks` whitelists which marks may apply, - `container`: holds child blocks (reserved; no default block uses it yet), - `atom`: holds no editable content (image, divider).

fn
createSchema

Build a {@link Schema} from node and mark spec maps.

T
DOMOutputHole

Placeholder marking where a node/mark's content should be spliced in.

T
DOMOutputSpec

A serializable description of DOM output (ProseMirror-style), kept free of real DOM so the schema layer stays pure. The view realizes it into elements. - `'text'` → a text node, - `['tag', { attr: 'v' }, 0]` → `<tag attr="v">…content…</tag>`, - the attrs object is optional; `0` is the content hole. The array part is an interface so the recursion (an element may contain nested elements) is well-founded for the type checker.

fn
isAtomBlock

Whether a block spec is an atom/void block.

fn
isContainerBlock

Whether a block spec is a container of child blocks.

fn
isTextBlock

Whether a block spec holds inline (text) content.

fn
marksAllowed

Whether a mark of `markType` is allowed inside a block with this spec.

I
MarkSpec

Schema contribution of a mark type.

I
NodeSpec

Schema contribution of a block type.

fn
normalizeDocument

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 editor state.

I
ParseRule

A rule for parsing DOM (paste / HTML import) into a block or mark. `getAttrs` receives a real `HTMLElement` (only ever called by the view); the type reference is compile-time only and introduces no runtime DOM dependency.

I
Schema

The compiled schema: the set of known node/mark specs plus attribute coercion helpers. Projected from the registry (the registry is the SSOT).

fn
validateDocument

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 · 17

fn
applySteptested

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

fn
applyTransactiontested

Produce the next editor state from a transaction. Stored marks are kept when explicitly set, cleared on any content change, and otherwise preserved.

T
Commandtested

A command in the ProseMirror style: returns `true` when applicable (and dispatches when `dispatch` is provided), `false` otherwise so the keymap can fall through to native behavior. Called without `dispatch` it is a dry run for computing UI enabled/active state.

T
CommandFactorytested

A parameterized command constructor.

I
CommandViewtested

Minimal view surface a command may use to move real DOM focus across blocks. The Vue `EditorContext` is structurally compatible; pure logic/tests can pass a stub. Keeps the command layer free of any Vue/DOM dependency.

fn
createEditortested

Create an {@link Editor} around an initial state.

fn
createEditorStatetested

Build the initial editor state: normalize the document against the schema and ensure it has at least one editable block to place the caret in.

fn
createHistorytested
fn
createTransactiontested

Start a transaction from the current editor state.

T
Dispatchtested

Applies a transaction, updating editor state and notifying subscribers.

I
Editortested

The headless editor controller: owns live state, the undo history, and a typed event bus. The Vue layer wraps it; the CRDT adapter subscribes to it.

I
EditorEventstested

Editor event map. A `type` (not `interface`) so it satisfies the `Record<string, ...>` constraint of {@link PubSub}.

I
EditorStatetested

Immutable snapshot of everything the editor renders and commands read.

I
Historytested

Undo/redo stacks of inverse-step entries. Borrows the ergonomics of stdlib's command history (bounded size, redo cleared on a new edit) but stores data (inverse steps) rather than closures — which is what makes it serializable and collab-friendly.

I
HistoryEntrytested

One undoable change: the steps it applied, their inverses, and the selection before and after. Undo replays `inverted` (reversed); redo replays `steps`.

T
Steptested

The atomic, invertible, serializable unit of change. Steps are the contract shared by the undo history (each carries its exact inverse) and the CRDT adapter (each maps to a CRDT operation). Keeping the set small (~12) means a new block type never needs a new step.

C
Transactiontested

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 EditorState}.

View · 17

I
BlockElementRegistry

Maps block ids to their contenteditable host elements for selection/focus.

fn
closestBlockHost

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

fn
createBlockElementRegistry
fn
createSelectionBridge
fn
detectPlatformtested

Detect the platform from the user agent (defaults to `'other'` off-browser).

I
EditorConfigtested

Editor-wide configuration provided through the editor context.

I
EditorContextValuetested

Everything child components and the input/selection plumbing need.

fn
getRawChildren

Recursively extracts and flattens VNodes from potentially nested Fragments while filtering out Comment nodes. Local copy of the primitives helper to keep `@robonen/editor` self-contained.

fn
getSlashItems

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.

fn
parseRuns

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.

fn
Primitive

Polymorphic element renderer: renders `as` (a tag or component), or the single slotted child when `as === 'template'`. Local copy of the primitives helper.

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
renderSlotChild

Renders a single child from the provided default slot, applying attrs to it. Shared between `<Slot>` and `<Primitive as="template">`.

fn
resolveConfigtested

Build a config with sensible defaults.

I
SelectionBridge

Maps the native `Selection`/`Range` (over the single editable root) to model coordinates and back.

I
SlashItem

A slash-menu entry derived from a block definition's metadata.

fn
Slot

A component that renders a single child from its default slot, applying the provided attributes to it.