@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.
pnpm add @robonen/editor @robonen/crdt vueQuick 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.
<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.
<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.
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:
EditorRootandEditorContent— 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.
API Reference
Commands · 12
Add a mark across the current (same-block) range.
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.
The block the selection currently focuses, or `null`.
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.
Delete a specific block by id (used by atom-block UIs).
Remove a mark across the current (same-block) range.
Block id the selection's focus is in (or the first node-selected block).
Convert the focused block to `type` (preserving inline content).
Toggle the focused block between `type` (with `attrs`) and a fallback type (default `paragraph`). Powers heading shortcuts and conversion toggles.
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 Editor}: 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. Editor steps map to CRDT ops via {@link DocumentCrdt}; ops sync as op batches over any transport.
The editor's document CRDT: a fractional-ordered set of blocks, each a text RGA + a mark store (or an attribute-only atom). It translates the editor's offset-based {@link Step}s into id-based CRDT ops ({@link translateStep}), integrates ops from any replica ({@link applyOp}), and materializes an {@link EditorDocument} ({@link toDocument}).
The CRDT operation log entry. Each carries an op id for the oplog; structural ops address blocks by their stable string id, text ops by character op ids.
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.
General · 2
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 editor 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 · 58
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: {} }`.
Attribute values are JSON-serializable so documents round-trip losslessly and a CRDT adapter can map them onto its own primitives without special-casing.
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.
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).
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)`.
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.
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`.
A block's inline content: an ordered, normalized list of runs.
Total length of inline content in UTF-16 code units (DOM-offset compatible).
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.
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.
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).
Structural equality for two marks (type + attrs).
A normalized set of marks: at most one per type, sorted by `type`.
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`.
A document block. `id` is stable across split/merge/move.
Inline content of a node, or `[]` when the node is not a text block.
Construct a block-level selection.
A block-level selection of one or more whole blocks (atoms, Mod+A stage 2).
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}.
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.
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).
A text selection: caret when `anchor === focus`, range otherwise. May span blocks.
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 · 13
Optional block-specific behaviors used by core commands.
Props passed to an atom/void block's Vue `component`.
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).
Presentational/discovery metadata: powers slash menu, conversion, toolbars.
How to resolve two definitions registered under the same type.
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).
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.
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.
Presentational/discovery metadata for a mark (toolbar label, shortcut hint).
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
Specification for a single attribute: default, requiredness, validation.
Map of attribute name → {@link AttrSpec}.
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).
Build a {@link Schema} from node and mark spec maps.
Placeholder marking where a node/mark's content should be spliced in.
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.
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.
Schema contribution of a mark type.
Schema contribution of a block type.
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.
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.
The compiled schema: the set of known node/mark specs plus attribute coercion helpers. Projected from the registry (the registry is the SSOT).
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
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 editor state from a transaction. Stored marks are kept when explicitly set, cleared on any content change, and otherwise preserved.
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.
A parameterized command constructor.
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.
Create an {@link Editor} around an initial state.
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.
Start a transaction from the current editor state.
Applies a transaction, updating editor state and notifying subscribers.
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.
Editor event map. A `type` (not `interface`) so it satisfies the `Record<string, ...>` constraint of {@link PubSub}.
Immutable snapshot of everything the editor renders and commands read.
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.
One undoable change: the steps it applied, their inverses, and the selection before and after. Undo replays `inverted` (reversed); redo replays `steps`.
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.
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
Maps block ids to their contenteditable host elements for selection/focus.
The nearest contenteditable block host containing `node`, or `null`.
Detect the platform from the user agent (defaults to `'other'` off-browser).
Editor-wide configuration provided through the editor context.
Everything child components and the input/selection plumbing need.
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.
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.
Polymorphic element renderer: renders `as` (a tag or component), or the single slotted child when `as === 'template'`. Local copy of the primitives helper.
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.
Renders a single child from the provided default slot, applying attrs to it. Shared between `<Slot>` and `<Primitive as="template">`.
Build a config with sensible defaults.
Maps the native `Selection`/`Range` (over the single editable root) to model coordinates and back.
A slash-menu entry derived from a block definition's metadata.
A component that renders a single child from its default slot, applying the provided attributes to it.