Files
tools/vue/writekit
robonen cc93715c03 fix(writekit): attr coercion stops erasing data, validate runs, undo coalesces
Three fixes driven by building a real consumer (cyrille studio) on 0.0.1, each
pinned by tests:

- `coerceAttrs` treated the spec as a whitelist: any attribute a block
  definition did not declare was silently deleted by the first
  `normalizeDocument` pass — and since normalization runs on load and consumers
  autosave, the erasure wrote itself back to storage. Coercion now fills
  defaults and keeps unknown keys verbatim. Parse rules build attrs explicitly,
  so pasted markup cannot smuggle keys through this path; the CRDT never calls
  coercion, so replica semantics are unchanged.

- `AttrSpec.validate` was consulted only by `validateDocument`, which nothing
  in the library calls — it looked like enforcement and was inert. A provided
  value failing `validate` now falls back to the declared default,
  deterministically (CRDT-safe given one spec) and loudly in dev.

- Undo recorded one entry per transaction — one keystroke per Ctrl+Z, and 200
  keystrokes evicted the entire earlier history. Plain typing in one block now
  coalesces within a 500ms window by concatenation, which preserves the replay
  invariant (`inverted` stays in application order, replayed reversed), counts
  as ONE entry against maxSize, and never merges across blocks, structural
  changes, or a foreign transaction (remote setDoc, undo/redo, selection-only
  moves interrupt the chain). `coalesceMs: 0` opts out.

Also: `component` in a block definition may now be a lazy loader
(`() => import('./Card.vue')`) — a registry imported for its schema (codecs,
tests, server-side normalizers) then carries no view graph; the view wraps the
loader in a cached async component on first render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 03:04:12 +07:00
..

@robonen/writekit

A headless, block-based rich-text writekit for Vue 3 — in the spirit of Tiptap / ProseMirror / Editor.js, but with a hand-built CRDT for collaboration (no Yjs/Loro/Automerge).

  • Block registry + schema — blocks and inline marks are modular, registered through a registry that projects an immutable schema.
  • Single contenteditable — one editable surface (ProseMirror/Tiptap model), so native cross-block mouse selection and arrow navigation behave like a normal document.
  • Framework-agnostic core — the model, schema, registry, state, commands and keymap are DOM-free and Vue-free; the Vue layer is only rendering + input.
  • Step-based transactions with exact inverses → real undo/redo, and the same steps drive the CRDT.
  • Own CRDT (@robonen/crdt) behind a pluggable CrdtProvider — RGA text, fractional-indexed blocks, Peritext-style marks, version-vector sync, presence/cursors.

Status: v0 / work in progress. Logic is covered by unit + convergence tests; the contenteditable/Playwright tests run locally (not in CI sandboxes).

Install

pnpm add @robonen/writekit @robonen/crdt vue

Quick start

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

WritekitRoot's default slot renders WritekitContent (the single contenteditable). Provide your own slot to add UI around it:

<WritekitRoot :writekit="writekit" autofocus>
  <WritekitContent />
  <WritekitBubbleMenu />   <!-- formatting toolbar on selection -->
  <WritekitSlashMenu />    <!-- `/` to insert blocks -->
</WritekitRoot>

The package is headless: it ships behavior and DOM structure (data-block-*, data-writekit-* hooks), not styling. See playground/src/App.vue for a complete stylesheet.

Blocks & marks

Built-in blocks (via createDefaultRegistry()): paragraph, heading (16), bulleted-list / numbered-list / todo-list (flat-with-indent), blockquote, code-block, callout, divider, image. Built-in marks: bold, italic, underline, strike, highlight, code, link.

Add your own — no core changes needed:

import { createRegistry, defineBlock, defineMark, defaultBlocks, defaultMarks } from '@robonen/writekit';

const spoiler = defineMark({
  type: 'spoiler',
  spec: { toDOM: () => ['span', { 'data-spoiler': '' }, 0], parseDOM: [{ tag: 'span[data-spoiler]' }] },
});

const registry = createRegistry({ blocks: defaultBlocks, marks: [...defaultMarks, spoiler] });

Editing UX

  • Slash menuWritekitSlashMenu: type / at the start of a line; items come from each block's meta.
  • Bubble toolbarWritekitBubbleMenu: floats over a text selection (positioned with @floating-ui/vue); override the buttons via its default slot (#default="{ active, toggle }").
  • Markdown input rules# →heading, - /* →bulleted list, 1. →numbered list, > →quote, [] →to-do.
  • Drag to reorder — pass draggable to WritekitRoot for per-block drag handles.
  • HotkeysMod-b/i/u, Mod-Shift-s (strike), Mod-e (code), Mod-z / Mod-Shift-z, Enter (split), Shift-Enter (hard break), Backspace/Delete at edges (merge), Mod-a (progressive select), Mod-Alt-1..6 (heading), Tab/Shift-Tab (list indent).

Commands

Commands are (state, dispatch?, view?) => boolean and power the keymap, UI, and programmatic edits:

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

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

Called without dispatch they are a dry run (for disabled/active toolbar state).

Collaboration (own CRDT)

The writekit is CRDT-agnostic behind CrdtProvider. The built-in provider maps writekit steps to CRDT ops (blocks → fractional-indexed set, text → RGA, formatting → mark store) and syncs op batches over any transport.

import { bindCrdt, createNativeProvider } from '@robonen/writekit';

// First peer seeds the document.
const provider = createNativeProvider({ schema: registry.schema, doc: writekit.state.doc, user: { name: 'Alice', color: '#2563eb' } });
const binding = bindCrdt(writekit, provider);

// Wire a transport (BroadcastChannel / WebSocket / …):
provider.onLocalOps(bytes => channel.postMessage(bytes));
channel.onmessage = e => provider.applyUpdate(e.data);

// A joining peer starts empty and syncs:
//   const provider = createNativeProvider({ schema });
//   provider.applyUpdate(remoteFullState);   // = peerA.encodeDelta()

Presence/cursors travel on a separate ephemeral channel and render with WritekitRemoteCursors:

provider.onLocalAwareness(bytes => channel.postMessage(bytes));
const cursors = ref([]);
provider.onAwareness(next => cursors.value = next);
<WritekitRoot :writekit="writekit">
  <WritekitContent />
  <WritekitRemoteCursors :cursors="cursors" />
</WritekitRoot>

See the Collaboration demo in the playground for a full two-replica example.

Applying a remote change is per-block: bindCrdt reuses the node identity of every block a remote edit didn't touch, so only changed blocks repaint and the local caret in untouched blocks is undisturbed.

For long-lived sessions, compact tombstones once the replicas are quiesced and fully synced:

provider.gc(); // drops deleted characters / removed blocks safe to forget

Known limitations (documented, deferred)

  • A local caret does not auto-shift when a remote peer inserts text before it (the caret keeps its offset).
  • Concurrent split/merge of the exact same range can drop a mark recreated on the moved tail.
  • gc() is only safe at quiescence (no in-flight ops) — it has no built-in stability protocol; drive it from your sync layer.

Development

pnpm --filter @robonen/writekit test          # logic (jsdom) + CRDT convergence
pnpm --filter @robonen/writekit build         # tsdown (ESM + CJS + dts)
pnpm --filter @robonen/writekit-playground dev

See AGENTS.md for the architecture and contributor notes.