Files
tools/vue/editor/README.md
T
robonen 09272dffeb feat(editor): eslint/tsconfig migration + type fixes
@robonen/editor: migrate to eslint flat config + composite tsconfig; fix
convergence test type annotations.
2026-06-07 16:30:05 +07:00

144 lines
6.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# @robonen/editor
A headless, block-based rich-text editor 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
```bash
pnpm add @robonen/editor @robonen/crdt vue
```
## Quick start
```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>
```
`EditorRoot`'s default slot renders `EditorContent` (the single contenteditable). Provide your own slot to add UI around it:
```vue
<EditorRoot :editor="editor" autofocus>
<EditorContent />
<EditorBubbleMenu /> <!-- formatting toolbar on selection -->
<EditorSlashMenu /> <!-- `/` to insert blocks -->
</EditorRoot>
```
The package is **headless**: it ships behavior and DOM structure (`data-block-*`, `data-editor-*` 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:
```ts
import { createRegistry, defineBlock, defineMark, defaultBlocks, defaultMarks } from '@robonen/editor';
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 menu** — `EditorSlashMenu`: type `/` at the start of a line; items come from each block's `meta`.
- **Bubble toolbar** — `EditorBubbleMenu`: 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 `EditorRoot` for per-block drag handles.
- **Hotkeys** — `Mod-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:
```ts
import { toggleMark, setBlockType } from '@robonen/editor';
editor.command(toggleMark('bold'));
editor.command(setBlockType('heading', { level: 2 }));
```
Called without `dispatch` they are a dry run (for disabled/active toolbar state).
## Collaboration (own CRDT)
The editor is CRDT-agnostic behind `CrdtProvider`. The built-in provider maps editor steps to CRDT ops (blocks → fractional-indexed set, text → RGA, formatting → mark store) and syncs op batches over any transport.
```ts
import { bindCrdt, createNativeProvider } from '@robonen/editor';
// First peer seeds the document.
const provider = createNativeProvider({ schema: registry.schema, doc: editor.state.doc, user: { name: 'Alice', color: '#2563eb' } });
const binding = bindCrdt(editor, 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 `EditorRemoteCursors`:
```ts
provider.onLocalAwareness(bytes => channel.postMessage(bytes));
const cursors = ref([]);
provider.onAwareness(next => cursors.value = next);
```
```vue
<EditorRoot :editor="editor">
<EditorContent />
<EditorRemoteCursors :cursors="cursors" />
</EditorRoot>
```
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:
```ts
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
```bash
pnpm --filter @robonen/editor test # logic (jsdom) + CRDT convergence
pnpm --filter @robonen/editor build # tsdown (ESM + CJS + dts)
pnpm --filter @robonen/editor-playground dev
```
See [AGENTS.md](./AGENTS.md) for the architecture and contributor notes.