Rename the rich-text editor package and all Editor* exports to Writekit*; remove the old vue/editor tree.
7.1 KiB
AGENTS.md — @robonen/writekit
Architecture and conventions for working in this package. Read this before editing.
What it is
A headless block rich-text writekit for Vue with a hand-built CRDT. Do not reach for Yjs/Loro/Automerge — collaboration is built on @robonen/crdt (sibling package in core/crdt).
Editing model: single contenteditable
There is one contenteditable element — WritekitContent. Blocks are plain child elements inside it; atom blocks (image, divider) are contenteditable="false" islands. We deliberately do not use per-block contenteditable: separate editing hosts make native cross-block mouse selection and arrow navigation impossible, which breaks the Word-like behavior we require.
Consequence: cross-block selection and caret navigation are handled natively by the browser; the model only mirrors the DOM selection as { blockId, offset }. The cross-block arrow commands were intentionally deleted — don't re-add them.
Layers (data flow is one-directional)
input/command → Transaction (steps) → dispatch → new WritekitState → view reacts (+ CRDT)
All of these are DOM-free and Vue-free (typecheck/test under plain Node):
model/— pure data:WritekitDocument,Node,Inline(marked runs:InlineNode[]),Mark,Position,Selection. Inline formatting is marked runs, not flat-text-plus-ranges; everyapplyStepcallsnormalizeInlineto merge adjacent equal-marks runs.schema/—NodeSpec/MarkSpec/ContentKind(3-variant union: text | container | atom), validation, normalization,toDOM/parseDOMdescriptors.registry/— SSOT.defineBlock/defineMarkare identity factories;createRegistrybuilds an immutable registry that projects theSchema. Adding a block/mark = a module + a line in a barrel — zero core changes.state/—WritekitState,Step(atomic, invertible, serializable — the unit of undo and the CRDT contract),Transaction(fluent builder),applyStep/applyTransaction,history,createWritekit(controller +PubSub).commands/—(state, dispatch?, view?) => boolean. One implementation, three consumers (keymap, UI, programmatic).dispatchomitted = dry run.keymap/— combo→command table,Modnormalization, one capture-phase keydown dispatcher on the root.
Vue layer (only this knows about the DOM):
view/—WritekitRoot(provider + keydown/selectionchange owner),WritekitContent(THE contenteditable; owns beforeinput/input/composition),BlockView(resolves the block def; text →TextBlockHost, atom → the def's component),TextBlockHost(renders runs imperatively for caret stability),inline-content/(render/parse runs ↔ DOM),selection/(DOM ↔ model selection bridge),ui/(slash menu, bubble menu, remote cursors).blocks/— concrete blocks (+.vuefor atoms).marks/— concrete marks (data-onlytoDOM/parseDOM).crdt/— CRDT-agnosticCrdtProvider+bindCrdt;native/= the adapter over@robonen/crdt.preset.ts—createDefaultRegistry()/createBasicRegistry().
Caret stability (the #1 contenteditable risk)
TextBlockHost is not Vue-managed inside: children are written imperatively. While the user types, the DOM is the source of truth — on input we parse the DOM → a transaction tagged meta('origin', blockId). We repaint a block only for foreign changes (undo/redo, command, remote CRDT), never for the block that originated the edit. Guards: skip while composing, skip on self-origin, save/restore the model selection across a repaint in nextTick.
When touching the view, preserve: :key="block.id", the imperative inner render, and the origin/composing guards.
CRDT mapping (crdt/native/document-crdt.ts)
DocumentCrdt maps the writekit's offset-based steps ↔ id-based CRDT ops, and materializes an WritekitDocument:
- Block list → fractional-indexed set: each block has
LwwRegisters forpresent/posKey/type/attrs+ anRga<string>(text) + aMarkStore.moveBlock= changeposKey(cheap). - Text →
Rga(one node per UTF-16 code unit — must match the writekit's offset space; do not iterate code points). - Marks →
MarkStore(Peritext-ish spans anchored to char ids, LWW per char/type).
Invariants that have already bitten us — keep them:
- Block removal only sets
present=false; the RGA chars stay. So re-inserting an existing (tombstoned) block must reactivate it, not re-add content (else it duplicates).insertBlock/splitBlockof an existing id take the reactivate path. applyOpreturnsfalsewhen a causal dependency is missing (block absent, RGA origin/char absent) so theReplicabuffers and retries.text-deletemust propagateintegrateDelete's result — don't hard-returntrue.- Remote application flows through a single
setDocstep (REMOTE_ORIGIN,addToHistory:false).bindCrdtnever echoes a remote-origin transaction back into the provider. It runsreconcileDocfirst (crdt/reconcile.ts) so unchanged blocks keep their node identity — only touched blocks repaint, and untouched carets are undisturbed. Preserve that deep-equal reuse. - Tombstone GC (
Rga.gc/DocumentCrdt.gc/provider.gc()) is safe only at quiescence (all peers synced, nothing in flight) — there's no stability protocol. It must keep mark-span endpoint chars (pass them via thekeeppredicate) or formatting on live chars between them is lost.
When changing the adapter, add/extend a two-replica convergence test in crdt/__test__/convergence.test.ts (dispatch → sync → assert documents equal, no duplication).
Conventions
- TS strict,
noUncheckedIndexedAccess,verbatimModuleSyntax. Use!/guards on indexed access. - ESLint (
compose(base, typescript, vue, imports, stylistic, …)).sort-importswarnings are tolerated; errors must be zero. Runpnpm --filter @robonen/writekit lint:fix.- Gotcha: the
prefer-includesautofix once rewrote a privateindexOfmethod intothis.includes(...)(broken). If you have an array-like helper, avoid naming itindexOf.
- Gotcha: the
- Build:
tsdown(tsconfig: ./tsconfig.src.json), dual ESM/CJS + dts, subpath entries (./crdt,./blocks, …). - Tests: vitest, two projects —
logic(jsdom: model/schema/state/commands/crdt) andview(Playwright chromium: real contenteditable). Chromium can't launch in the sandbox — write a jsdom proof when possible; browser tests run locally. Primitive/Slot/getRawChildrenanduseContextFactory/useEventListenerare copied locally underview/(we don't depend on@robonen/vue, whose dts build is currently broken).
Milestones
M1 core + single-CE pivot · M2 rich blocks/marks · M2-UI slash/bubble/input-rules/drag · M3 own CRDT (core/crdt + native provider + awareness + collab demo) · M4 a11y + docs + CRDT optimizations (per-block patching, tombstone GC) — all done. Remaining: the Playwright view tests run locally only (chromium can't launch in the sandbox); run-length compression is still deferred.
The full plan lives at ~/.claude/plans/vue-memoized-torvalds.md.