feat(editor): eslint/tsconfig migration + type fixes

@robonen/editor: migrate to eslint flat config + composite tsconfig; fix
convergence test type annotations.
This commit is contained in:
2026-06-07 16:30:05 +07:00
parent 626fbc70d8
commit 09272dffeb
136 changed files with 7248 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
# AGENTS.md — @robonen/editor
Architecture and conventions for working in this package. Read this before editing.
## What it is
A headless block rich-text editor 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 — `EditorContent`. 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 EditorState → view reacts (+ CRDT)
```
All of these are **DOM-free and Vue-free** (typecheck/test under plain Node):
- `model/` — pure data: `EditorDocument`, `Node`, `Inline` (marked **runs**: `InlineNode[]`), `Mark`, `Position`, `Selection`. Inline formatting is *marked runs*, not flat-text-plus-ranges; every `applyStep` calls `normalizeInline` to merge adjacent equal-marks runs.
- `schema/``NodeSpec`/`MarkSpec`/`ContentKind` (3-variant union: text | container | atom), validation, normalization, `toDOM`/`parseDOM` descriptors.
- `registry/` — SSOT. `defineBlock`/`defineMark` are identity factories; `createRegistry` builds an immutable registry that **projects** the `Schema`. Adding a block/mark = a module + a line in a barrel — zero core changes.
- `state/``EditorState`, `Step` (atomic, invertible, serializable — the unit of undo **and** the CRDT contract), `Transaction` (fluent builder), `applyStep`/`applyTransaction`, `history`, `createEditor` (controller + `PubSub`).
- `commands/``(state, dispatch?, view?) => boolean`. One implementation, three consumers (keymap, UI, programmatic). `dispatch` omitted = dry run.
- `keymap/` — combo→command table, `Mod` normalization, one capture-phase keydown dispatcher on the root.
Vue layer (only this knows about the DOM):
- `view/``EditorRoot` (provider + keydown/selectionchange owner), `EditorContent` (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 (+ `.vue` for atoms). `marks/` — concrete marks (data-only `toDOM`/`parseDOM`).
- `crdt/` — CRDT-agnostic `CrdtProvider` + `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 editor's **offset-based** steps ↔ **id-based** CRDT ops, and materializes an `EditorDocument`:
- Block list → fractional-indexed set: each block has `LwwRegister`s for `present`/`posKey`/`type`/`attrs` + an `Rga<string>` (text) + a `MarkStore`. `moveBlock` = change `posKey` (cheap).
- Text → `Rga` (one node per **UTF-16 code unit** — must match the editor'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`/`splitBlock` of an existing id take the reactivate path.
- `applyOp` returns `false` when a causal dependency is missing (block absent, RGA origin/char absent) so the `Replica` buffers and retries. `text-delete` must propagate `integrateDelete`'s result — don't hard-return `true`.
- Remote application flows through a single `setDoc` step (`REMOTE_ORIGIN`, `addToHistory:false`). `bindCrdt` never echoes a remote-origin transaction back into the provider. It runs `reconcileDoc` first (`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 the `keep` predicate) 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-imports` warnings are tolerated; **errors must be zero**. Run `pnpm --filter @robonen/editor lint:fix`.
- Gotcha: the `prefer-includes` autofix once rewrote a private `indexOf` method into `this.includes(...)` (broken). If you have an array-like helper, avoid naming it `indexOf`.
- 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) and `view` (Playwright chromium: real contenteditable). **Chromium can't launch in the sandbox** — write a jsdom proof when possible; browser tests run locally.
- `Primitive`/`Slot`/`getRawChildren` and `useContextFactory`/`useEventListener` are **copied locally** under `view/` (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`.
+143
View File
@@ -0,0 +1,143 @@
# @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.
+9
View File
@@ -0,0 +1,9 @@
import { base, compose, imports, stylistic, typescript, vue } from '@robonen/eslint';
export default compose(base, typescript, vue, imports, stylistic, {
name: 'editor/overrides',
files: ['**/*.vue'],
rules: {
'@stylistic/no-multiple-empty-lines': 'off',
},
});
+83
View File
@@ -0,0 +1,83 @@
{
"name": "@robonen/editor",
"version": "0.0.1",
"license": "Apache-2.0",
"description": "Headless block-based rich-text editor for Vue with a registry-driven schema and pluggable CRDT",
"keywords": [
"editor",
"rich-text",
"wysiwyg",
"block-editor",
"crdt",
"collaborative",
"vue",
"tools"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "vue/editor"
},
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./*": {
"import": {
"types": "./dist/*/index.d.mts",
"default": "./dist/*/index.mjs"
},
"require": {
"types": "./dist/*/index.d.cts",
"default": "./dist/*/index.cjs"
}
}
},
"scripts": {
"lint:check": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"bench": "vitest bench",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/eslint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@vitest/browser": "catalog:",
"@vitest/browser-playwright": "^4.0.18",
"@vue/test-utils": "catalog:",
"eslint": "catalog:",
"jsdom": "catalog:",
"playwright": "^1.48.0",
"tsdown": "catalog:",
"unplugin-vue": "^7.1.1",
"vitest-browser-vue": "^1.0.0",
"vue-tsc": "^3.2.5"
},
"dependencies": {
"@floating-ui/vue": "^1.1.11",
"@robonen/crdt": "workspace:*",
"@robonen/platform": "workspace:*",
"@robonen/stdlib": "workspace:*",
"@vue/shared": "catalog:",
"vue": "catalog:"
}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@robonen/editor playground</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@robonen/editor-playground",
"version": "0.0.0",
"private": true,
"license": "Apache-2.0",
"description": "Minimal playground for @robonen/editor — eyeball, debug, hack on hypotheses",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@robonen/editor": "workspace:*",
"vue": "catalog:"
},
"devDependencies": {
"@robonen/tsconfig": "workspace:*",
"@vitejs/plugin-vue": "^6.0.6",
"vite": "^7.1.9",
"vue-tsc": "^3.2.5"
}
}
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { shallowRef } from 'vue';
import CollabDemo from './demos/CollabDemo.vue';
import CommandsDemo from './demos/CommandsDemo.vue';
import ComplexBlocksDemo from './demos/ComplexBlocksDemo.vue';
import CustomKeymapDemo from './demos/CustomKeymapDemo.vue';
import ManyBlocksDemo from './demos/ManyBlocksDemo.vue';
import MarksDemo from './demos/MarksDemo.vue';
import MultiEditorDemo from './demos/MultiEditorDemo.vue';
import ReadOnlyDemo from './demos/ReadOnlyDemo.vue';
import RichTextDemo from './demos/RichTextDemo.vue';
const demos = [
{ id: 'rich', title: 'Rich text', component: RichTextDemo },
{ id: 'complex', title: 'Complex blocks', component: ComplexBlocksDemo },
{ id: 'collab', title: 'Collaboration', component: CollabDemo },
{ id: 'marks', title: 'Inline marks', component: MarksDemo },
{ id: 'many', title: 'Many blocks', component: ManyBlocksDemo },
{ id: 'multi', title: 'Multiple editors', component: MultiEditorDemo },
{ id: 'readonly', title: 'Read-only', component: ReadOnlyDemo },
{ id: 'commands', title: 'Commands API', component: CommandsDemo },
{ id: 'keymap', title: 'Custom keymap', component: CustomKeymapDemo },
];
const current = shallowRef(demos[0]!);
</script>
<template>
<div class="layout">
<nav class="sidebar">
<h1>@robonen/editor</h1>
<button
v-for="demo in demos"
:key="demo.id"
:class="{ active: demo.id === current.id }"
@click="current = demo"
>
{{ demo.title }}
</button>
</nav>
<main class="content">
<component :is="current.component" :key="current.id" />
</main>
</div>
</template>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; color: #1a1a1a; background: #fafafa; }
.layout { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
.sidebar { border-right: 1px solid #e5e5e5; padding: 1rem; background: #fff; position: sticky; top: 0; height: 100vh; }
.sidebar h1 { font-size: 14px; margin: 0 0 1rem; color: #666; }
.sidebar button { display: block; width: 100%; text-align: left; padding: 8px 10px; margin-bottom: 2px; border: 0; background: transparent; border-radius: 6px; cursor: pointer; font-size: 14px; color: #333; }
.sidebar button:hover { background: #f0f0f0; }
.sidebar button.active { background: #1a1a1a; color: #fff; }
.content { padding: 2rem; max-width: 880px; }
.content section > h2 { margin: 0 0 0.25rem; }
.hint { color: #888; font-size: 13px; margin: 0 0 1rem; }
.toolbar { display: flex; gap: 4px; align-items: center; margin-bottom: 0.75rem; }
.toolbar.wrap { flex-wrap: wrap; }
.toolbar button { min-width: 32px; height: 32px; padding: 0 8px; border: 1px solid #ddd; background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; }
.toolbar button:hover { border-color: #bbb; }
.toolbar button:disabled { opacity: 0.4; cursor: default; }
.toolbar button[data-active] { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.toolbar .sep { width: 1px; height: 20px; background: #ddd; margin: 0 4px; }
.editor { border: 1px solid #e5e5e5; border-radius: 8px; padding: 1rem 1.25rem; min-height: 120px; background: #fff; }
.editor:focus-within { border-color: #999; }
.editor.scroll { max-height: 420px; overflow: auto; }
.editor [data-block-content] { outline: none; margin: 0.4em 0; line-height: 1.6; }
.editor [data-block-content]:is(h1, h2, h3, h4, h5, h6) { margin: 0.6em 0 0.3em; line-height: 1.3; }
.editor [data-block-type='heading'] [data-block-content] { font-weight: 700; }
.editor [data-block-content][data-empty]::before { content: attr(data-placeholder); color: #bbb; pointer-events: none; }
.editor [data-block-content] strong { font-weight: 700; }
.editor [data-block-content] em { font-style: italic; }
.editor ::selection { background: #b3d4fc; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
details { margin-top: 1rem; }
summary { cursor: pointer; color: #666; font-size: 13px; }
details pre { background: #f6f6f6; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 12px; max-height: 300px; }
/* inline marks */
.editor [data-block-content] mark { background: #fde68a; border-radius: 2px; }
.editor [data-block-content] code { background: #eef0f2; padding: 0.1em 0.35em; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
.editor [data-block-content] a { color: #2563eb; text-decoration: underline; cursor: pointer; }
/* blockquote */
.editor blockquote[data-block-content] { border-left: 3px solid #ddd; padding-left: 1rem; color: #555; font-style: italic; }
/* code block */
.editor pre[data-block-content] { background: #f6f8fa; border: 1px solid #eaecef; border-radius: 6px; padding: 0.75rem 1rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; white-space: pre-wrap; }
/* callout */
.editor [data-callout] { position: relative; border-radius: 8px; margin: 0.5em 0; padding: 0.6rem 0.8rem 0.6rem 2.4rem; }
.editor [data-callout]::before { position: absolute; left: 0.8rem; }
.editor [data-callout='info'] { background: #eef4ff; } .editor [data-callout='info']::before { content: '️'; }
.editor [data-callout='warn'] { background: #fff6e6; } .editor [data-callout='warn']::before { content: '⚠️'; }
.editor [data-callout='success'] { background: #ecfdf3; } .editor [data-callout='success']::before { content: '✅'; }
/* lists (flat-with-indent; marker in the gutter, indent via inline margin-left) */
.editor { counter-reset: editor-ol; }
.editor [data-list] { position: relative; }
.editor [data-list]::before { position: absolute; left: 0.1em; color: #555; }
.editor [data-list='bullet']::before { content: '•'; }
.editor [data-list='ordered'] { counter-increment: editor-ol; }
.editor [data-list='ordered']::before { content: counter(editor-ol) '.'; }
.editor [data-list='todo']::before { content: '☐'; }
.editor [data-list='todo'][data-checked='true']::before { content: '☑'; }
.editor [data-list='todo'][data-checked='true'] { color: #999; text-decoration: line-through; }
/* atoms: image + divider */
.editor [data-editor-image] { margin: 0.8em 0; text-align: center; }
.editor [data-editor-image] img { max-width: 100%; border-radius: 8px; }
.editor [data-editor-image] figcaption { color: #888; font-size: 13px; margin-top: 4px; }
.editor [data-editor-image] .image-placeholder { background: #f3f3f3; border: 1px dashed #ccc; border-radius: 8px; padding: 1.5rem; color: #999; }
.editor [data-editor-image] .image-fields { display: flex; flex-direction: column; gap: 4px; margin: 6px auto 0; max-width: 360px; }
.editor [data-editor-image] .image-fields input { padding: 4px 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
.editor [data-editor-divider] { border: 0; border-top: 2px solid #e5e5e5; margin: 1em 0; }
/* node selection highlight */
.editor [data-block-type='image'][data-selected], .editor [data-block-type='divider'][data-selected] { outline: 2px solid #2563eb; outline-offset: 2px; border-radius: 6px; }
.editor [data-block-content][data-selected], .editor [data-block-id][data-selected]:not([data-block-type='image']):not([data-block-type='divider']) { background: rgba(37, 99, 235, 0.08); border-radius: 4px; }
/* floating menus (teleported to body) */
.editor-bubble-menu { display: flex; gap: 2px; background: #1a1a1a; border-radius: 8px; padding: 4px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); z-index: 50; }
.editor-bubble-menu button { min-width: 30px; height: 28px; padding: 0 8px; border: 0; background: transparent; color: #fff; border-radius: 5px; cursor: pointer; font-size: 13px; text-transform: capitalize; }
.editor-bubble-menu button:hover { background: rgba(255, 255, 255, 0.15); }
.editor-bubble-menu button[data-active] { background: #fff; color: #1a1a1a; }
.editor-slash-menu { background: #fff; border: 1px solid #e5e5e5; border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); width: 230px; max-height: 280px; overflow: auto; z-index: 50; }
.editor-slash-menu button { display: flex; justify-content: space-between; align-items: baseline; width: 100%; text-align: left; border: 0; background: transparent; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 14px; color: #333; }
.editor-slash-menu button[data-highlighted] { background: #f0f0f0; }
.editor-slash-menu .slash-group { font-size: 11px; color: #aaa; text-transform: capitalize; }
kbd { background: #eee; border: 1px solid #ddd; border-radius: 4px; padding: 1px 5px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* drag-to-reorder handle */
.editor [data-block-id] { position: relative; }
.editor-drag-handle { position: absolute; left: -1.2em; top: 0.25em; cursor: grab; color: #ccc; user-select: none; opacity: 0; transition: opacity 0.1s; line-height: 1.4; }
.editor [data-block-id]:hover > .editor-drag-handle { opacity: 1; }
.editor-drag-handle:hover { color: #888; }
.editor-drag-handle:active { cursor: grabbing; }
/* remote collaboration cursors */
.editor.collab { position: relative; }
.editor-remote-cursors { position: absolute; inset: 0; pointer-events: none; overflow: visible; z-index: 4; }
.editor-remote-selection { position: absolute; background: var(--cursor-color); opacity: 0.22; border-radius: 2px; }
.editor-remote-caret { position: absolute; width: 2px; background: var(--cursor-color); }
.editor-remote-caret-label { position: absolute; top: -1.05em; left: -1px; font-size: 10px; line-height: 1; white-space: nowrap; color: #fff; background: var(--cursor-color); padding: 1px 4px; border-radius: 3px 3px 3px 0; }
</style>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import type { Editor } from '@editor';
import { isBlockActive, isMarkActive, setBlockType, toggleBlockType, toggleMark } from '@editor';
const { editor } = defineProps<{ editor: Editor }>();
// Re-evaluate active-states on every transaction.
const rev = ref(0);
const bump = (): void => void (rev.value += 1);
editor.on('transaction', bump);
onBeforeUnmount(() => editor.off('transaction', bump));
const boldActive = computed(() => (rev.value, isMarkActive(editor.state, 'bold')));
const italicActive = computed(() => (rev.value, isMarkActive(editor.state, 'italic')));
const h1Active = computed(() => (rev.value, isBlockActive(editor.state, 'heading', { level: 1 })));
const h2Active = computed(() => (rev.value, isBlockActive(editor.state, 'heading', { level: 2 })));
const canUndo = computed(() => (rev.value, editor.canUndo()));
const canRedo = computed(() => (rev.value, editor.canRedo()));
</script>
<template>
<div class="toolbar">
<button :data-active="boldActive || undefined" @mousedown.prevent="editor.command(toggleMark('bold'))"><b>B</b></button>
<button :data-active="italicActive || undefined" @mousedown.prevent="editor.command(toggleMark('italic'))"><i>I</i></button>
<span class="sep" />
<button :data-active="h1Active || undefined" @mousedown.prevent="editor.command(toggleBlockType('heading', { level: 1 }))">H1</button>
<button :data-active="h2Active || undefined" @mousedown.prevent="editor.command(toggleBlockType('heading', { level: 2 }))">H2</button>
<button @mousedown.prevent="editor.command(setBlockType('paragraph'))">P</button>
<span class="sep" />
<button :disabled="!canUndo" @mousedown.prevent="editor.undo()">Undo</button>
<button :disabled="!canRedo" @mousedown.prevent="editor.redo()">Redo</button>
</div>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import type { RemoteCursor } from '@editor';
import {
EditorBubbleMenu,
EditorContent,
EditorRemoteCursors,
EditorRoot,
EditorSlashMenu,
bindCrdt,
createDefaultRegistry,
createDoc,
createEditor,
createEditorState,
createNativeProvider,
} from '@editor';
import { h, p } from '../lib';
const registry = createDefaultRegistry();
const seed = createDoc([
h(1, 'Shared document'),
p('Edit in either pane — changes sync to the other through its own CRDT replica.'),
p(''),
]);
// Peer A owns the initial document.
const editorA = createEditor({ state: createEditorState({ registry, doc: seed }) });
const providerA = createNativeProvider({ schema: registry.schema, doc: editorA.state.doc, user: { name: 'Alice', color: '#2563eb' } });
// Peer B starts empty and joins by syncing A's full state.
const editorB = createEditor({ state: createEditorState({ registry }) });
const providerB = createNativeProvider({ schema: registry.schema, user: { name: 'Bob', color: '#db2777' } });
const bindingA = bindCrdt(editorA, providerA);
const bindingB = bindCrdt(editorB, providerB);
providerB.applyUpdate(providerA.encodeDelta());
// Live two-way transport, in memory (stand-in for a BroadcastChannel/WebSocket).
const offOpsA = providerA.onLocalOps(bytes => providerB.applyUpdate(bytes));
const offOpsB = providerB.onLocalOps(bytes => providerA.applyUpdate(bytes));
// Awareness (cursors) over the same in-memory channel.
const cursorsA = ref<RemoteCursor[]>([]);
const cursorsB = ref<RemoteCursor[]>([]);
const offCurA = providerA.onAwareness((cursors) => {
cursorsA.value = cursors;
});
const offCurB = providerB.onAwareness((cursors) => {
cursorsB.value = cursors;
});
const offAwA = providerA.onLocalAwareness(bytes => providerB.applyAwareness(bytes));
const offAwB = providerB.onLocalAwareness(bytes => providerA.applyAwareness(bytes));
onBeforeUnmount(() => {
for (const off of [offOpsA, offOpsB, offCurA, offCurB, offAwA, offAwB])
off();
bindingA.detach();
bindingB.detach();
});
</script>
<template>
<section>
<h2>Collaboration (own CRDT)</h2>
<p class="hint">Two independent editors, each backed by a separate <code>@robonen/crdt</code> replica, synced in memory. Type in either pane concurrent edits converge live and you'll see the other peer's cursor; no Yjs.</p>
<div class="cols">
<div>
<div class="peer-label"><span class="peer-dot" style="background: #2563eb" />Alice</div>
<EditorRoot :editor="editorA" autofocus class="editor collab">
<EditorContent />
<EditorRemoteCursors :cursors="cursorsA" />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
</div>
<div>
<div class="peer-label"><span class="peer-dot" style="background: #db2777" />Bob</div>
<EditorRoot :editor="editorB" class="editor collab">
<EditorContent />
<EditorRemoteCursors :cursors="cursorsB" />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
</div>
</div>
</section>
</template>
<style scoped>
.peer-label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #888; margin-bottom: 4px; }
.peer-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
EditorRoot,
createNode,
createTransaction,
moveBlockDown,
moveBlockUp,
removeBlock,
setBlockType,
toggleMark,
} from '@editor';
import { h, makeEditor, p } from '../lib';
const editor = makeEditor([
h(1, 'Commands API'),
p('Drive the editor programmatically with the buttons below. Put the caret in a block first.'),
p('Second block.'),
p('Third block.'),
]);
const rev = ref(0);
editor.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(editor.state.doc, null, 2)));
const canDelete = computed(() => (rev.value, editor.state.doc.content.length > 1));
function focusId(): string | undefined {
const sel = editor.state.selection;
return sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
}
function appendParagraph(): void {
const node = createNode('paragraph', { content: [{ text: 'Appended block', marks: [] }] });
editor.dispatch(createTransaction(editor.state).insertBlock(node, editor.state.doc.content.length));
}
function deleteFocused(): void {
const id = focusId();
if (id && editor.state.doc.content.length > 1)
editor.command(removeBlock(id));
}
</script>
<template>
<section>
<h2>Commands API</h2>
<p class="hint">Programmatic control every button is a command or transaction on the editor.</p>
<div class="toolbar wrap">
<button @mousedown.prevent="appendParagraph">Append paragraph</button>
<button @mousedown.prevent="editor.command(moveBlockUp)">Move block </button>
<button @mousedown.prevent="editor.command(moveBlockDown)">Move block </button>
<button @mousedown.prevent="editor.command(setBlockType('heading', { level: 1 }))"> H1</button>
<button @mousedown.prevent="editor.command(setBlockType('paragraph'))"> Paragraph</button>
<button @mousedown.prevent="editor.command(toggleMark('bold'))">Toggle bold</button>
<button :disabled="!canDelete" @mousedown.prevent="deleteFocused">Delete block</button>
</div>
<EditorRoot :editor="editor" class="editor" />
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { Node } from '@editor';
import {
EditorBubbleMenu,
EditorContent,
EditorRoot,
EditorSlashMenu,
addMark,
createTransaction,
nodeSelection,
setBlockType,
toggleChecked,
toggleMark,
} from '@editor';
import { bullet, callout, code, divider, h, image, makeEditor, numbered, p, quote, t, todo } from '../lib';
const editor = makeEditor([
h(1, 'Complex blocks'),
p([t('A document with '), t('many', 'bold'), t(' block types. Put the caret in a block and use the controls to convert it, or insert media.')]),
quote('“The block is the unit of composition.” — a registry-driven editor.'),
callout('info', 'Callouts carry a variant attribute. This one is "info".'),
callout('warn', 'And this is a "warn" callout.'),
code('function hello() {\n // Enter inserts a newline here, not a block split\n return \'code block\';\n}'),
h(2, 'Lists'),
bullet('Bulleted item one'),
bullet('Nested bullet (indent = 1) — Tab / Shift+Tab changes indent', 1),
bullet('Bulleted item two'),
numbered('Numbered item (counter via CSS)'),
numbered('Numbered item'),
todo('A finished task', true),
todo('A pending task', false),
h(2, 'Media (atoms)'),
image('https://picsum.photos/seed/robonen/520/240', 'A random sample image'),
divider(),
p('Click an image or divider to select it, then Backspace/Delete removes it. Selecting an image reveals its URL / alt / caption fields.'),
]);
const rev = ref(0);
editor.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(editor.state.doc, null, 2)));
function focusIndex(): number {
const sel = editor.state.selection;
const id = sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
const index = id ? editor.state.doc.content.findIndex(block => block.id === id) : -1;
return index === -1 ? editor.state.doc.content.length - 1 : index;
}
function insertAfterFocus(node: Node): void {
editor.dispatch(createTransaction(editor.state).insertBlock(node, focusIndex() + 1).setSelection(nodeSelection([node.id])));
}
function addLink(): void {
const href = globalThis.prompt('Link URL', 'https://');
if (href)
editor.command(addMark('link', { href }));
}
</script>
<template>
<section>
<h2>Complex blocks</h2>
<p class="hint">Quote, callout, code block, lists (bulleted / numbered / to-do), image &amp; divider atoms, plus the full mark set. Everything is registry-driven.</p>
<div class="toolbar wrap">
<button @mousedown.prevent="editor.command(toggleMark('bold'))"><b>B</b></button>
<button @mousedown.prevent="editor.command(toggleMark('italic'))"><i>I</i></button>
<button @mousedown.prevent="editor.command(toggleMark('underline'))"><u>U</u></button>
<button @mousedown.prevent="editor.command(toggleMark('strike'))"><s>S</s></button>
<button @mousedown.prevent="editor.command(toggleMark('code'))">&lt;/&gt;</button>
<button @mousedown.prevent="editor.command(toggleMark('highlight'))">HL</button>
<button @mousedown.prevent="addLink">Link</button>
<span class="sep" />
<button @mousedown.prevent="editor.command(setBlockType('paragraph'))">P</button>
<button @mousedown.prevent="editor.command(setBlockType('heading', { level: 1 }))">H1</button>
<button @mousedown.prevent="editor.command(setBlockType('heading', { level: 2 }))">H2</button>
<button @mousedown.prevent="editor.command(setBlockType('blockquote'))">Quote</button>
<button @mousedown.prevent="editor.command(setBlockType('code-block'))">Code</button>
<button @mousedown.prevent="editor.command(setBlockType('callout', { variant: 'info' }))">Callout</button>
<span class="sep" />
<button @mousedown.prevent="editor.command(setBlockType('bulleted-list'))"> List</button>
<button @mousedown.prevent="editor.command(setBlockType('numbered-list'))">1. List</button>
<button @mousedown.prevent="editor.command(setBlockType('todo-list', { checked: false, indent: 0 }))"> Todo</button>
<button @mousedown.prevent="editor.command(toggleChecked)">Toggle </button>
<span class="sep" />
<button @mousedown.prevent="insertAfterFocus(image('', ''))">+ Image</button>
<button @mousedown.prevent="insertAfterFocus(divider())">+ Divider</button>
<span class="sep" />
<button @mousedown.prevent="editor.undo()">Undo</button>
<button @mousedown.prevent="editor.redo()">Redo</button>
</div>
<p class="hint">Type <kbd>/</kbd> to insert a block; select text for the bubble toolbar; hover a block and drag the <span aria-hidden="true"></span> handle to reorder. Markdown shortcuts work too: <kbd># </kbd>, <kbd>- </kbd>, <kbd>&gt; </kbd>, <kbd>1. </kbd>, <kbd>[] </kbd>.</p>
<EditorRoot :editor="editor" autofocus draggable class="editor">
<EditorContent />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { EditorRoot, createNode, createTransaction } from '@editor';
import type { Command, Keymap } from '@editor';
import { makeEditor, p } from '../lib';
import Toolbar from '../Toolbar.vue';
const editor = makeEditor([
p('Press Cmd/Ctrl+Enter to insert an italic note below the current block.'),
p('The default keymap (Enter, Backspace, Cmd/Ctrl+B, …) still works — user keymaps merge over it.'),
]);
const insertNote: Command = (state, dispatch) => {
const sel = state.selection;
if (sel.kind !== 'text')
return false;
if (dispatch) {
const index = state.doc.content.findIndex(block => block.id === sel.focus.blockId);
const node = createNode('paragraph', { content: [{ text: '— note —', marks: [{ type: 'italic' }] }] });
dispatch(createTransaction(state).insertBlock(node, index + 1));
}
return true;
};
const keymaps: Keymap[] = [{ 'Mod-Enter': insertNote }];
</script>
<template>
<section>
<h2>Custom keymap</h2>
<p class="hint">A user keymap merged over the defaults: Cmd/Ctrl+Enter inserts a note.</p>
<Toolbar :editor="editor" />
<EditorRoot :editor="editor" :keymaps="keymaps" class="editor" />
</section>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Node } from '@editor';
import { EditorRoot } from '@editor';
import { h, makeEditor, p } from '../lib';
import Toolbar from '../Toolbar.vue';
const blocks: Node[] = [];
for (let i = 1; i <= 60; i++) {
if (i % 10 === 1)
blocks.push(h(2, `Section ${Math.ceil(i / 10)}`));
else
blocks.push(p(`Block ${i}: the quick brown fox jumps over the lazy dog.`));
}
const editor = makeEditor(blocks);
</script>
<template>
<section>
<h2>Many blocks</h2>
<p class="hint">60 blocks test cross-block drag over a long range, / navigation, and Cmd/Ctrl+A (once = current block, twice = whole document).</p>
<Toolbar :editor="editor" />
<EditorRoot :editor="editor" class="editor scroll" />
</section>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { EditorRoot } from '@editor';
import { makeEditor, p, t } from '../lib';
import Toolbar from '../Toolbar.vue';
const editor = makeEditor([
p([t('Adjacent runs: '), t('bold', 'bold'), t(' '), t('italic', 'italic'), t(' '), t('bold+italic', 'bold', 'italic'), t(' plain.')]),
p([t('boldAtStart', 'bold'), t(' then plain then '), t('boldAtEnd', 'bold')]),
p('Select part of a word and toggle Cmd/Ctrl+B — the mark splits the run mid-word; toggle again to remove. With a collapsed caret, toggling sets the mark for the next typed character (stored marks).'),
]);
</script>
<template>
<section>
<h2>Inline marks</h2>
<p class="hint">Adjacent / overlapping marks, marks at run boundaries, partial-word toggling, stored marks.</p>
<Toolbar :editor="editor" />
<EditorRoot :editor="editor" class="editor" />
</section>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { EditorRoot } from '@editor';
import { h, makeEditor, p } from '../lib';
import Toolbar from '../Toolbar.vue';
const left = makeEditor([h(2, 'Editor A'), p('Type and select here.')]);
const right = makeEditor([h(2, 'Editor B'), p('This editor is fully independent.')]);
</script>
<template>
<section>
<h2>Multiple editors</h2>
<p class="hint">Two independent editors on one page selection and editing in one must never affect the other.</p>
<div class="cols">
<div>
<Toolbar :editor="left" />
<EditorRoot :editor="left" class="editor" />
</div>
<div>
<Toolbar :editor="right" />
<EditorRoot :editor="right" class="editor" />
</div>
</div>
</section>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { EditorRoot } from '@editor';
import { h, makeEditor, p, t } from '../lib';
const editor = makeEditor([
h(2, 'Read-only'),
p([t('You can '), t('select', 'bold'), t(' and copy this text, but typing and shortcuts do nothing.')]),
p('Mouse selection and arrow navigation still work across blocks.'),
]);
</script>
<template>
<section>
<h2>Read-only</h2>
<p class="hint">editable=false selection/navigation work; edits and shortcuts are blocked.</p>
<EditorRoot :editor="editor" :editable="false" class="editor" />
</section>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { EditorBubbleMenu, EditorContent, EditorRoot, EditorSlashMenu } from '@editor';
import { h, makeEditor, p, t } from '../lib';
import Toolbar from '../Toolbar.vue';
const editor = makeEditor([
h(1, 'Welcome to the editor'),
p([t('This paragraph mixes '), t('bold', 'bold'), t(', '), t('italic', 'italic'), t(', and '), t('both at once', 'bold', 'italic'), t('.')]),
p('Drag with the mouse across these two paragraphs — the selection spans both, just like Word. Use ↑/↓ to move between blocks and Shift+↑/↓ to extend across them.'),
p(''),
h(2, 'Editing'),
p('Press Enter to split a block. Press Backspace at the very start of a block to merge it into the previous one. Cmd/Ctrl+Z undoes, Cmd/Ctrl+Shift+Z redoes.'),
]);
const rev = ref(0);
editor.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(editor.state.doc, null, 2)));
</script>
<template>
<section>
<h2>Rich text</h2>
<p class="hint">Mixed marks, headings, an empty block (placeholder), cross-block selection &amp; navigation.</p>
<Toolbar :editor="editor" />
<EditorRoot :editor="editor" autofocus class="editor">
<EditorContent />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>
+11
View File
@@ -0,0 +1,11 @@
export {};
declare global {
const __DEV__: boolean;
}
declare module 'vue' {
interface HTMLAttributes {
[key: `data-${string}`]: unknown;
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { Editor, Inline, InlineNode, Node } from '@editor';
import {
createDefaultRegistry,
createDoc,
createEditor,
createEditorState,
createNode,
} from '@editor';
/** A styled inline run: `t('hello', 'bold', 'italic')`. */
export function t(text: string, ...markTypes: string[]): InlineNode {
return { text, marks: markTypes.map(type => ({ type })) };
}
/** A paragraph block from a string or inline runs. */
export function p(content: string | Inline = ''): Node {
const inline = typeof content === 'string' ? (content ? [t(content)] : []) : content;
return createNode('paragraph', { content: inline });
}
/** A heading block. */
export function h(level: number, text: string): Node {
return createNode('heading', { attrs: { level }, content: text ? [t(text)] : [] });
}
export const quote = (text: string): Node => createNode('blockquote', { content: text ? [t(text)] : [] });
export const code = (text: string): Node => createNode('code-block', { content: text ? [t(text)] : [] });
export const callout = (variant: string, text: string): Node => createNode('callout', { attrs: { variant }, content: [t(text)] });
export const bullet = (text: string, indent = 0): Node => createNode('bulleted-list', { attrs: { indent }, content: [t(text)] });
export const numbered = (text: string, indent = 0): Node => createNode('numbered-list', { attrs: { indent }, content: [t(text)] });
export const todo = (text: string, checked = false): Node => createNode('todo-list', { attrs: { checked, indent: 0 }, content: [t(text)] });
export const divider = (): Node => createNode('divider');
export const image = (src: string, caption = ''): Node => createNode('image', { attrs: { src, alt: caption, caption } });
/** Create an editor over the given blocks with the default registry. */
export function makeEditor(content: Node[]): Editor {
const registry = createDefaultRegistry();
return createEditor({ state: createEditorState({ registry, doc: createDoc(content) }) });
}
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "@robonen/tsconfig/tsconfig.vue.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@editor": ["../src/index.ts"],
"@editor/*": ["../src/*"]
}
},
"include": ["src", "vite.config.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
import { URL, fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => ({
plugins: [vue()],
define: {
__DEV__: JSON.stringify(mode !== 'production'),
},
resolve: {
alias: [
{
find: /^@editor\/(.*)$/,
replacement: fileURLToPath(new URL('../src/$1', import.meta.url)),
},
{
find: /^@editor$/,
replacement: fileURLToPath(new URL('../src/index.ts', import.meta.url)),
},
],
},
server: {
port: 5181,
fs: {
allow: [fileURLToPath(new URL('../', import.meta.url))],
},
},
}));
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { isInteractiveTarget } from '../view/interactive';
describe('isInteractiveTarget', () => {
it('matches atom controls and contenteditable=false islands, not editor text', () => {
const root = document.createElement('div');
root.setAttribute('contenteditable', 'true');
root.innerHTML = '<p class="text">hi</p><figure contenteditable="false"><input class="cap"></figure>';
document.body.append(root);
expect(isInteractiveTarget(root.querySelector('input.cap'))).toBe(true);
expect(isInteractiveTarget(root.querySelector('figure'))).toBe(true);
expect(isInteractiveTarget(root.querySelector('p.text'))).toBe(false);
expect(isInteractiveTarget(root)).toBe(false);
expect(isInteractiveTarget(null)).toBe(false);
root.remove();
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { createBlockElementRegistry, createSelectionBridge } from '../view/selection';
/**
* Builds the single-contenteditable DOM shape the view produces: one
* `[data-editor-content]` root containing plain `[data-block-content]` block
* elements. Runs in jsdom (logic project) to prove the cross-block selection
* mapping without a real browser.
*/
function buildDoc() {
const root = document.createElement('div');
root.setAttribute('data-editor-content', '');
const a = document.createElement('p');
a.setAttribute('data-block-content', '');
a.setAttribute('data-block-id', 'a');
a.textContent = 'hello';
const b = document.createElement('p');
b.setAttribute('data-block-content', '');
b.setAttribute('data-block-id', 'b');
b.textContent = 'world';
root.append(a, b);
document.body.replaceChildren(root);
const registry = createBlockElementRegistry();
registry.set('a', a);
registry.set('b', b);
return { root, a, b, registry };
}
describe('selection bridge (jsdom)', () => {
it('round-trips offset ↔ DOM point within a block', () => {
const { root, a, registry } = buildDoc();
const bridge = createSelectionBridge(() => root, registry);
const point = bridge.offsetToDomPoint(a, 3);
expect(bridge.domPointToOffset(a, point.node, point.offset)).toBe(3);
});
it('reads a cross-block native selection as a cross-block model range', () => {
const { root, a, b, registry } = buildDoc();
const bridge = createSelectionBridge(() => root, registry);
const sel = globalThis.getSelection!()!;
sel.removeAllRanges();
const range = document.createRange();
range.setStart(a.firstChild!, 1);
range.setEnd(b.firstChild!, 3);
sel.addRange(range);
const model = bridge.read();
expect(model?.kind).toBe('text');
if (model?.kind === 'text') {
expect(model.anchor).toEqual({ blockId: 'a', offset: 1 });
expect(model.focus).toEqual({ blockId: 'b', offset: 3 });
}
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { createDefaultRegistry } from '../preset';
import { getSlashItems } from '../view/ui';
describe('getSlashItems', () => {
it('returns every block with meta when the query is empty', () => {
const items = getSlashItems(createDefaultRegistry());
const types = items.map(item => item.type);
expect(types).toContain('heading');
expect(types).toContain('image');
expect(items.length).toBeGreaterThan(5);
});
it('filters by title and keywords', () => {
const registry = createDefaultRegistry();
expect(getSlashItems(registry, 'quote').some(item => item.type === 'blockquote')).toBe(true);
expect(getSlashItems(registry, 'h1').some(item => item.type === 'heading')).toBe(true);
expect(getSlashItems(registry, 'zzzz')).toEqual([]);
});
});
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import type { BlockComponentProps } from '../registry';
defineProps<BlockComponentProps>();
</script>
<template>
<hr data-editor-divider="" />
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { BlockComponentProps } from '../registry';
const props = defineProps<BlockComponentProps>();
const src = computed(() => String(props.node.attrs['src'] ?? ''));
const alt = computed(() => String(props.node.attrs['alt'] ?? ''));
const caption = computed(() => String(props.node.attrs['caption'] ?? ''));
const editing = computed(() => props.editable && props.selected);
function set(key: string, event: Event): void {
props.update({ [key]: (event.target as HTMLInputElement).value });
}
</script>
<template>
<figure data-editor-image="" :data-selected="selected ? '' : undefined">
<img v-if="src" :src="src" :alt="alt" draggable="false" />
<div v-else class="image-placeholder">No image add a URL below</div>
<figcaption v-if="caption && !editing">{{ caption }}</figcaption>
<div v-if="editing" class="image-fields" contenteditable="false">
<input :value="src" placeholder="Image URL" @input="set('src', $event)" >
<input :value="alt" placeholder="Alt text" @input="set('alt', $event)" >
<input :value="caption" placeholder="Caption" @input="set('caption', $event)" >
</div>
</figure>
</template>
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import { caret, createDoc, createNode, nodeInline, nodeText, textSelection } from '../../model';
import { applyInputRule, joinBackward, splitBlock, toggleMark } from '../../commands';
import { createDefaultRegistry } from '../../preset';
import { createEditor, createEditorState } from '../../state';
function editorWith(node: ReturnType<typeof createNode>, selection?: ReturnType<typeof caret>) {
const registry = createDefaultRegistry();
return createEditor({ state: createEditorState({ registry, doc: createDoc([node]), selection }) });
}
describe('default registry', () => {
it('registers the full block and mark set', () => {
const registry = createDefaultRegistry();
for (const type of ['paragraph', 'heading', 'blockquote', 'code-block', 'callout', 'bulleted-list', 'numbered-list', 'todo-list', 'divider', 'image'])
expect(registry.hasBlock(type)).toBe(true);
for (const mark of ['bold', 'italic', 'underline', 'strike', 'highlight', 'code', 'link'])
expect(registry.hasMark(mark)).toBe(true);
});
it('marks the list blocks as group "list" and gives to-do a checked attr', () => {
const registry = createDefaultRegistry();
expect(registry.getBlock('bulleted-list')?.spec.group).toBe('list');
expect(registry.getBlock('todo-list')?.spec.attrs?.['checked']).toBeDefined();
});
it('inline code mark excludes all others', () => {
expect(createDefaultRegistry().getMark('code')?.spec.excludes).toBe('_all');
});
});
describe('code block', () => {
it('Enter inserts a newline instead of splitting', () => {
const editor = editorWith(createNode('code-block', { id: 'c', content: [{ text: 'ab', marks: [] }] }), caret('c', 2));
expect(editor.command(splitBlock)).toBe(true);
expect(editor.state.doc.content.length).toBe(1);
expect(nodeText(editor.state.doc.content[0]!)).toBe('ab\n');
});
it('disallows inline marks', () => {
const editor = editorWith(
createNode('code-block', { id: 'c', content: [{ text: 'abc', marks: [] }] }),
textSelection({ blockId: 'c', offset: 0 }, { blockId: 'c', offset: 3 }),
);
expect(editor.command(toggleMark('bold'))).toBe(false);
});
it('does not absorb disallowed marks when another block merges into it', () => {
const registry = createDefaultRegistry();
const editor = createEditor({
state: createEditorState({
registry,
doc: createDoc([
createNode('code-block', { id: 'c', content: [{ text: 'x', marks: [] }] }),
createNode('paragraph', { id: 'p', content: [{ text: 'B', marks: [{ type: 'bold' }] }] }),
]),
selection: caret('p', 0),
}),
});
expect(editor.command(joinBackward)).toBe(true);
const merged = editor.state.doc.content[0]!;
expect(merged.type).toBe('code-block');
expect(nodeText(merged)).toBe('xB');
expect(nodeInline(merged).every(run => run.marks.length === 0)).toBe(true);
});
});
describe('input rules', () => {
it('"# " converts a paragraph to a level-1 heading and strips the marker', () => {
const editor = editorWith(createNode('paragraph', { id: 'p', content: [{ text: '# ', marks: [] }] }), caret('p', 2));
expect(editor.command(applyInputRule)).toBe(true);
const block = editor.state.doc.content[0]!;
expect(block.type).toBe('heading');
expect(block.attrs['level']).toBe(1);
expect(nodeText(block)).toBe('');
});
it('"- " converts a paragraph to a bulleted list', () => {
const editor = editorWith(createNode('paragraph', { id: 'p', content: [{ text: '- ', marks: [] }] }), caret('p', 2));
expect(editor.command(applyInputRule)).toBe(true);
expect(editor.state.doc.content[0]!.type).toBe('bulleted-list');
});
it('does not re-fire when the block is already the target type', () => {
const editor = editorWith(createNode('blockquote', { id: 'q', content: [{ text: '> ', marks: [] }] }), caret('q', 2));
expect(editor.command(applyInputRule)).toBe(false);
});
});
describe('to-do list', () => {
it('starts a new item unchecked when splitting a checked one', () => {
const editor = editorWith(
createNode('todo-list', { id: 't', attrs: { checked: true, indent: 0 }, content: [{ text: 'done', marks: [] }] }),
caret('t', 4),
);
expect(editor.command(splitBlock)).toBe(true);
expect(editor.state.doc.content.length).toBe(2);
const created = editor.state.doc.content[1]!;
expect(created.type).toBe('todo-list');
expect(created.attrs['checked']).toBe(false);
});
});
+13
View File
@@ -0,0 +1,13 @@
import { defineBlock } from '../registry';
export const blockquote = defineBlock({
type: 'blockquote',
spec: {
content: { kind: 'text' },
group: 'block',
toDOM: () => ['blockquote', 0],
parseDOM: [{ tag: 'blockquote' }],
},
inputRules: [{ match: /^>\s$/ }],
meta: { title: 'Quote', icon: 'quote', keywords: ['quote', 'blockquote', 'citation'], group: 'basic' },
});
+17
View File
@@ -0,0 +1,17 @@
import type { Node } from '../model';
import { defineBlock } from '../registry';
export const callout = defineBlock({
type: 'callout',
spec: {
content: { kind: 'text' },
group: 'block',
attrs: { variant: { default: 'info' } },
toDOM: (node: Node) => ['div', { 'data-callout': String(node.attrs['variant'] ?? 'info') }, 0],
parseDOM: [{
tag: 'div[data-callout]',
getAttrs: (el: HTMLElement) => ({ variant: el.getAttribute('data-callout') ?? 'info' }),
}],
},
meta: { title: 'Callout', icon: 'info', keywords: ['callout', 'note', 'info', 'warning'], group: 'basic' },
});
+16
View File
@@ -0,0 +1,16 @@
import type { Node } from '../model';
import { defineBlock } from '../registry';
export const codeBlock = defineBlock({
type: 'code-block',
spec: {
content: { kind: 'text', marks: 'none' }, // raw text, no inline formatting
group: 'block',
code: true, // Enter inserts a newline instead of splitting
defining: true,
attrs: { language: { default: 'plain' } },
toDOM: (node: Node) => ['pre', { 'data-language': String(node.attrs['language'] ?? 'plain') }, 0],
parseDOM: [{ tag: 'pre' }],
},
meta: { title: 'Code block', icon: 'code', keywords: ['code', 'pre', 'snippet'], group: 'basic' },
});
+14
View File
@@ -0,0 +1,14 @@
import { defineBlock } from '../registry';
import DividerBlock from './DividerBlock.vue';
export const divider = defineBlock({
type: 'divider',
spec: {
content: { kind: 'atom' },
group: 'block',
toDOM: () => ['hr'],
parseDOM: [{ tag: 'hr' }],
},
component: DividerBlock,
meta: { title: 'Divider', icon: 'minus', keywords: ['divider', 'hr', 'rule', 'separator'], group: 'media' },
});
+18
View File
@@ -0,0 +1,18 @@
import { defineBlock } from '../registry';
const LEVELS = [1, 2, 3, 4, 5, 6] as const;
export const heading = defineBlock({
type: 'heading',
spec: {
content: { kind: 'text' },
group: 'block',
attrs: {
level: { default: 1, validate: value => typeof value === 'number' && value >= 1 && value <= 6 },
},
toDOM: node => [`h${node.attrs['level'] ?? 1}`, 0],
parseDOM: LEVELS.map(level => ({ tag: `h${level}`, attrs: { level } })),
},
inputRules: LEVELS.map(level => ({ match: new RegExp(`^#{${level}}\\s$`), attrs: { level } })),
meta: { title: 'Heading', icon: 'heading', keywords: ['heading', 'title', 'h1', 'h2', 'h3'], group: 'basic' },
});
+27
View File
@@ -0,0 +1,27 @@
import type { Node } from '../model';
import { defineBlock } from '../registry';
import ImageBlock from './ImageBlock.vue';
export const image = defineBlock({
type: 'image',
spec: {
content: { kind: 'atom' },
group: 'block',
attrs: {
src: { default: '' },
alt: { default: '' },
caption: { default: '' },
},
toDOM: (node: Node) => [
'figure',
['img', { src: String(node.attrs['src'] ?? ''), alt: String(node.attrs['alt'] ?? '') }],
['figcaption', String(node.attrs['caption'] ?? '')],
],
parseDOM: [{
tag: 'img',
getAttrs: (el: HTMLElement) => ({ src: el.getAttribute('src') ?? '', alt: el.getAttribute('alt') ?? '' }),
}],
},
component: ImageBlock,
meta: { title: 'Image', icon: 'image', keywords: ['image', 'img', 'picture', 'photo'], group: 'media' },
});
+8
View File
@@ -0,0 +1,8 @@
export { paragraph } from './paragraph';
export { heading } from './heading';
export { blockquote } from './blockquote';
export { codeBlock } from './code-block';
export { callout } from './callout';
export { bulletedList, numberedList, todoList } from './list';
export { divider } from './divider';
export { image } from './image';
+52
View File
@@ -0,0 +1,52 @@
import type { Node } from '../model';
import type { AttrsSpec } from '../schema';
import { defineBlock } from '../registry';
type ListType = 'bullet' | 'ordered' | 'todo';
function indentOf(node: Node): number {
return typeof node.attrs['indent'] === 'number' ? node.attrs['indent'] : 0;
}
/**
* DRY factory for the three list variants. Lists are **flat-with-indent**: each
* item is its own top-level text block carrying an `indent` attribute (and
* `checked` for to-dos). Markers/numbering and indentation are presentation
* (CSS), so the model stays a simple flat block list that maps cleanly to a CRDT.
*/
function defineListBlock(options: { type: string; listType: ListType; title: string; keywords: readonly string[] }) {
const todo = options.listType === 'todo';
const attrs: AttrsSpec = {
indent: { default: 0 },
...(todo ? { checked: { default: false } } : {}),
};
const inputRules = options.listType === 'bullet'
? [{ match: /^[-*]\s$/ }]
: options.listType === 'ordered'
? [{ match: /^\d+\.\s$/ }]
: [{ match: /^\[\s?\]\s$/ }];
return defineBlock({
type: options.type,
spec: {
content: { kind: 'text' },
group: 'list',
attrs,
toDOM: (node: Node) => ['div', {
'data-list': options.listType,
// margin shifts the item per indent level; padding leaves a gutter for the marker.
style: `margin-left:${indentOf(node) * 1.5}em;padding-left:1.5em`,
...(todo ? { 'data-checked': node.attrs['checked'] ? 'true' : 'false' } : {}),
}, 0],
parseDOM: [{ tag: `[data-list='${options.listType}']` }],
},
inputRules,
meta: { title: options.title, icon: 'list', keywords: options.keywords, group: 'lists' },
});
}
export const bulletedList = defineListBlock({ type: 'bulleted-list', listType: 'bullet', title: 'Bulleted list', keywords: ['ul', 'bullet', 'unordered', 'list'] });
export const numberedList = defineListBlock({ type: 'numbered-list', listType: 'ordered', title: 'Numbered list', keywords: ['ol', 'number', 'ordered', 'list'] });
export const todoList = defineListBlock({ type: 'todo-list', listType: 'todo', title: 'To-do list', keywords: ['todo', 'task', 'checkbox', 'check'] });
+13
View File
@@ -0,0 +1,13 @@
import { defineBlock } from '../registry';
export const paragraph = defineBlock({
type: 'paragraph',
spec: {
content: { kind: 'text' },
group: 'block',
toDOM: () => ['p', 0],
parseDOM: [{ tag: 'p' }],
},
placeholder: 'Write something…',
meta: { title: 'Paragraph', icon: 'text', keywords: ['paragraph', 'text', 'p'], group: 'basic' },
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { caret, createDoc, createNode, nodeInline, nodeText, textSelection } from '../../model';
import { createDefaultRegistry } from '../../preset';
import { createEditor, createEditorState } from '../../state';
import { joinBackward, splitBlock, toggleMark } from '..';
function para(id: string, text: string) {
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
}
function editorWith(blocks: Array<ReturnType<typeof para>>, selection?: ReturnType<typeof caret>) {
const registry = createDefaultRegistry();
return createEditor({ state: createEditorState({ registry, doc: createDoc(blocks), selection }) });
}
describe('commands', () => {
it('toggleMark applies then removes bold on a range', () => {
const registry = createDefaultRegistry();
const editor = createEditor({
state: createEditorState({
registry,
doc: createDoc([para('a', 'abc')]),
selection: textSelection({ blockId: 'a', offset: 0 }, { blockId: 'a', offset: 3 }),
}),
});
expect(editor.command(toggleMark('bold'))).toBe(true);
expect(nodeInline(editor.state.doc.content[0]!)).toEqual([{ text: 'abc', marks: [{ type: 'bold' }] }]);
editor.command(toggleMark('bold'));
expect(nodeInline(editor.state.doc.content[0]!)).toEqual([{ text: 'abc', marks: [] }]);
});
it('splitBlock splits at the caret', () => {
const editor = editorWith([para('a', 'hello')], caret('a', 2));
expect(editor.command(splitBlock)).toBe(true);
expect(editor.state.doc.content.map(block => nodeText(block))).toEqual(['he', 'llo']);
expect(editor.state.selection.kind).toBe('text');
});
it('joinBackward merges into the previous block', () => {
const editor = editorWith([para('a', 'foo'), para('b', 'bar')], caret('b', 0));
expect(editor.command(joinBackward)).toBe(true);
expect(editor.state.doc.content.map(block => nodeText(block))).toEqual(['foobar']);
});
it('undo restores the document after a split', () => {
const editor = editorWith([para('a', 'hello')], caret('a', 2));
editor.command(splitBlock);
expect(editor.state.doc.content.length).toBe(2);
expect(editor.undo()).toBe(true);
expect(editor.state.doc.content.map(block => nodeText(block))).toEqual(['hello']);
});
});
+133
View File
@@ -0,0 +1,133 @@
import type { Attrs } from '../model';
import { blockById, blockIndex } from '../model';
import type { Command } from '../state';
import { createTransaction } from '../state';
import { focusBlock, isBlockActive, selectionBlockId } from './util';
/** Convert the focused block to `type` (preserving inline content). */
export function setBlockType(type: string, attrs?: Attrs): Command {
return (state, dispatch) => {
const blockId = selectionBlockId(state);
if (!blockId || !state.registry.hasBlock(type))
return false;
if (dispatch)
dispatch(createTransaction(state).setBlockType(blockId, type, attrs).setSelection(state.selection));
return true;
};
}
/**
* Toggle the focused block between `type` (with `attrs`) and a fallback type
* (default `paragraph`). Powers heading shortcuts and conversion toggles.
*/
export function toggleBlockType(type: string, attrs?: Attrs, fallback = 'paragraph'): Command {
return (state, dispatch) => {
const blockId = selectionBlockId(state);
if (!blockId)
return false;
const active = isBlockActive(state, type, attrs);
const target = active ? fallback : type;
const targetAttrs = active ? undefined : attrs;
if (!state.registry.hasBlock(target))
return false;
if (dispatch)
dispatch(createTransaction(state).setBlockType(blockId, target, targetAttrs).setSelection(state.selection));
return true;
};
}
function moveFocusedBlock(delta: number): Command {
return (state, dispatch) => {
const block = focusBlock(state);
if (!block)
return false;
const index = blockIndex(state.doc, block.id);
const target = index + delta;
if (index === -1 || target < 0 || target >= state.doc.content.length)
return false;
if (dispatch)
dispatch(createTransaction(state).moveBlock(block.id, target).setSelection(state.selection));
return true;
};
}
/** Move the focused block one position earlier. */
export const moveBlockUp: Command = moveFocusedBlock(-1);
/** Move the focused block one position later. */
export const moveBlockDown: Command = moveFocusedBlock(1);
/** Indent a list item by raising its `indent` attr (lists only). */
export const indentListItem: Command = (state, dispatch) => {
const block = focusBlock(state);
if (!block || state.schema.nodeSpec(block.type)?.group !== 'list')
return false;
const indent = typeof block.attrs.indent === 'number' ? block.attrs.indent : 0;
if (indent >= 8)
return false;
if (dispatch)
dispatch(createTransaction(state).setAttrs(block.id, { indent: indent + 1 }).setSelection(state.selection));
return true;
};
/** Outdent a list item by lowering its `indent` attr (lists only). */
export const outdentListItem: Command = (state, dispatch) => {
const block = focusBlock(state);
if (!block || state.schema.nodeSpec(block.type)?.group !== 'list')
return false;
const indent = typeof block.attrs.indent === 'number' ? block.attrs.indent : 0;
if (indent <= 0)
return false;
if (dispatch)
dispatch(createTransaction(state).setAttrs(block.id, { indent: indent - 1 }).setSelection(state.selection));
return true;
};
/** Toggle the `checked` attribute of the focused to-do item. */
export const toggleChecked: Command = (state, dispatch) => {
const block = focusBlock(state);
if (!block || !('checked' in block.attrs))
return false;
if (dispatch)
dispatch(createTransaction(state).setAttrs(block.id, { checked: !block.attrs['checked'] }).setSelection(state.selection));
return true;
};
/** Delete a specific block by id (used by atom-block UIs). */
export function removeBlock(blockId: string): Command {
return (state, dispatch) => {
if (!blockById(state.doc, blockId))
return false;
if (dispatch)
dispatch(createTransaction(state).removeBlock(blockId));
return true;
};
}
+16
View File
@@ -0,0 +1,16 @@
import type { Command } from '../state';
/**
* 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.
*/
export function chainCommands(...commands: readonly Command[]): Command {
return (state, dispatch, view) => {
for (const command of commands) {
if (command(state, dispatch, view))
return true;
}
return false;
};
}
+7
View File
@@ -0,0 +1,7 @@
export * from './util';
export * from './chain';
export * from './marks';
export * from './structure';
export * from './blocks';
export * from './selection';
export * from './input-rules';
+48
View File
@@ -0,0 +1,48 @@
import { blockById, caret, inlineText, isCollapsed, nodeInline } from '../model';
import type { Command } from '../state';
import { createTransaction } from '../state';
/**
* Apply the first matching block input-rule at the caret. Rules live on block
* definitions (`inputRules`) and match the text from the block start to the
* caret — e.g. `'# '` → heading, `'- '` → bulleted list, `'> '` → quote. Run
* from the input flow after each text change.
*/
export const applyInputRule: Command = (state, dispatch) => {
const sel = state.selection;
if (sel.kind !== 'text' || !isCollapsed(sel))
return false;
const block = blockById(state.doc, sel.focus.blockId);
const spec = block && state.schema.nodeSpec(block.type);
if (!block || spec?.content.kind !== 'text' || spec.code)
return false;
const before = inlineText(nodeInline(block)).slice(0, sel.focus.offset);
for (const def of state.registry.listBlocks()) {
for (const rule of def.inputRules ?? []) {
const match = rule.match.exec(before);
if (!match)
continue;
const targetType = rule.type ?? def.type;
if (block.type === targetType)
return false; // already that type — leave the typed text alone
if (dispatch) {
dispatch(createTransaction(state)
.deleteText(block.id, 0, match[0].length)
.setBlockType(block.id, targetType, rule.attrs ?? state.schema.defaultAttrs(targetType))
.setSelection(caret(block.id, 0)));
}
return true;
}
}
return false;
};
+122
View File
@@ -0,0 +1,122 @@
import type { Attrs, Mark } from '../model';
import { blockById, isCollapsed, marksAt, nodeInline, normalizeMarks, orderedSelection, rangeHasMarkType } from '../model';
import { marksAllowed } from '../schema';
import type { Command, EditorState } from '../state';
import { createTransaction } from '../state';
/** Whether the focused block permits a mark of `type` (false for code blocks, etc.). */
function markAllowedAtFocus(state: EditorState, type: string): boolean {
if (state.selection.kind !== 'text')
return false;
const block = blockById(state.doc, state.selection.focus.blockId);
const spec = block && state.schema.nodeSpec(block.type);
return spec ? marksAllowed(spec, type) : false;
}
function excludedTypes(state: Parameters<Command>[0], type: string): readonly string[] {
const excludes = state.schema.markSpec(type)?.excludes;
if (excludes === '_all')
return state.registry.listMarks().map(def => def.type).filter(other => other !== type);
return excludes ?? [];
}
/**
* 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).
*/
export function toggleMark(type: string, attrs?: Attrs): Command {
return (state, dispatch) => {
if (!state.registry.hasMark(type) || !markAllowedAtFocus(state, type))
return false;
const sel = state.selection;
if (sel.kind !== 'text')
return false;
const mark: Mark = attrs ? { type, attrs } : { type };
if (isCollapsed(sel)) {
if (dispatch) {
const block = blockById(state.doc, sel.focus.blockId);
const current = state.storedMarks ?? (block ? marksAt(nodeInline(block), sel.focus.offset) : []);
const has = current.some(m => m.type === type);
const next = has
? current.filter(m => m.type !== type)
: normalizeMarks([...current.filter(m => !excludedTypes(state, type).includes(m.type)), mark]);
dispatch(createTransaction(state).setStoredMarks(next));
}
return true;
}
if (sel.anchor.blockId !== sel.focus.blockId)
return false;
const block = blockById(state.doc, sel.focus.blockId);
if (!block)
return false;
const { from, to } = orderedSelection(sel, state.doc);
const active = rangeHasMarkType(nodeInline(block), from.offset, to.offset, type);
if (dispatch) {
const tr = createTransaction(state);
if (active) {
tr.removeMark(block.id, from.offset, to.offset, mark);
}
else {
for (const ex of excludedTypes(state, type))
tr.removeMark(block.id, from.offset, to.offset, { type: ex });
tr.addMark(block.id, from.offset, to.offset, mark);
}
tr.setSelection(sel);
dispatch(tr);
}
return true;
};
}
/** Add a mark across the current (same-block) range. */
export function addMark(type: string, attrs?: Attrs): Command {
return (state, dispatch) => {
const sel = state.selection;
if (!state.registry.hasMark(type) || !markAllowedAtFocus(state, type) || sel.kind !== 'text' || isCollapsed(sel) || sel.anchor.blockId !== sel.focus.blockId)
return false;
if (dispatch) {
const { from, to } = orderedSelection(sel, state.doc);
dispatch(createTransaction(state)
.addMark(from.blockId, from.offset, to.offset, attrs ? { type, attrs } : { type })
.setSelection(sel));
}
return true;
};
}
/** Remove a mark across the current (same-block) range. */
export function removeMark(type: string): Command {
return (state, dispatch) => {
const sel = state.selection;
if (sel.kind !== 'text' || isCollapsed(sel) || sel.anchor.blockId !== sel.focus.blockId)
return false;
if (dispatch) {
const { from, to } = orderedSelection(sel, state.doc);
dispatch(createTransaction(state)
.removeMark(from.blockId, from.offset, to.offset, { type })
.setSelection(sel));
}
return true;
};
}
+135
View File
@@ -0,0 +1,135 @@
import {
blockById,
blockIndex,
caret,
createNode,
inlineLength,
isAcrossBlocks,
isCollapsed,
nodeInline,
nodeSelection,
orderedSelection,
previousBlock,
textSelection,
} from '../model';
import type { Command, EditorState } from '../state';
import { createTransaction } from '../state';
function defaultTextType(state: EditorState): string {
if (state.registry.hasBlock('paragraph'))
return 'paragraph';
for (const def of state.registry.listBlocks()) {
if (def.spec.content.kind === 'text')
return def.type;
}
return state.registry.listBlocks()[0]?.type ?? 'paragraph';
}
/**
* Delete the current selection. Handles a node (block-level) selection, a
* same-block range, and a cross-block range (delete the partial ends, drop the
* blocks in between, merge the last block into the first). Never leaves an empty
* document — a fresh paragraph is inserted if everything was removed.
*/
export const deleteSelection: Command = (state, dispatch) => {
const sel = state.selection;
if (sel.kind === 'node') {
if (sel.ids.length === 0)
return false;
if (dispatch) {
const before = previousBlock(state.doc, sel.ids[0]!);
const tr = createTransaction(state);
for (const id of sel.ids)
tr.removeBlock(id);
if (tr.doc.content.length === 0) {
const type = defaultTextType(state);
const node = createNode(type, { attrs: state.schema.defaultAttrs(type) });
tr.insertBlock(node, 0).setSelection(caret(node.id, 0));
}
else if (before) {
tr.setSelection(caret(before.id, inlineLength(nodeInline(before))));
}
else {
tr.setSelection(caret(tr.doc.content[0]!.id, 0));
}
dispatch(tr);
}
return true;
}
if (isCollapsed(sel))
return false;
const { from, to } = orderedSelection(sel, state.doc);
if (!isAcrossBlocks(sel)) {
if (dispatch)
dispatch(createTransaction(state).deleteText(from.blockId, from.offset, to.offset).setSelection(caret(from.blockId, from.offset)));
return true;
}
const a = blockById(state.doc, from.blockId);
const b = blockById(state.doc, to.blockId);
if (!a || !b || state.schema.nodeSpec(a.type)?.content.kind !== 'text' || state.schema.nodeSpec(b.type)?.content.kind !== 'text')
return false;
if (dispatch) {
const tr = createTransaction(state);
tr.deleteText(a.id, from.offset, inlineLength(nodeInline(a)));
tr.deleteText(b.id, 0, to.offset);
const ai = blockIndex(state.doc, a.id);
const bi = blockIndex(state.doc, b.id);
for (const mid of state.doc.content.slice(ai + 1, bi))
tr.removeBlock(mid.id);
tr.mergeBlock(b.id, a.id).setSelection(caret(a.id, from.offset));
dispatch(tr);
}
return true;
};
/**
* Progressive select-all (Mod+A): first press selects the current block's text,
* a second press selects every block.
*/
export const selectAll: Command = (state, dispatch) => {
const sel = state.selection;
if (sel.kind === 'text' && !isAcrossBlocks(sel)) {
const block = blockById(state.doc, sel.focus.blockId);
if (block) {
const length = inlineLength(nodeInline(block));
const { from, to } = orderedSelection(sel, state.doc);
const wholeBlock = from.offset === 0 && to.offset === length;
if (!wholeBlock && length > 0) {
if (dispatch) {
dispatch(createTransaction(state).setSelection(
textSelection({ blockId: block.id, offset: 0 }, { blockId: block.id, offset: length }),
));
}
return true;
}
}
}
if (state.doc.content.length === 0)
return false;
if (dispatch)
dispatch(createTransaction(state).setSelection(nodeSelection(state.doc.content.map(block => block.id))));
return true;
};
+180
View File
@@ -0,0 +1,180 @@
import type { Attrs, Node } from '../model';
import {
blockById,
caret,
inlineLength,
isAcrossBlocks,
isCollapsed,
nextBlock,
nodeInline,
nodeSelection,
orderedSelection,
previousBlock,
} from '../model';
import type { Command, EditorState } from '../state';
import { createTransaction } from '../state';
/** Type/attrs for the block created when splitting `block` at `offset`. */
function continuation(state: EditorState, block: Node, offset: number): { type?: string; attrs?: Attrs } {
const spec = state.schema.nodeSpec(block.type);
// Defining blocks (e.g. code-block) keep their identity across a split.
if (spec?.defining)
return { type: block.type, attrs: block.attrs };
// Pressing Enter at the end of a heading starts a fresh paragraph.
if (block.type === 'heading' && offset >= inlineLength(nodeInline(block)) && state.registry.hasBlock('paragraph'))
return { type: 'paragraph' };
// A new to-do item always starts unchecked (don't inherit the checked state).
if (block.type === 'todo-list')
return { type: block.type, attrs: { ...block.attrs, checked: false } };
// Otherwise continue the same type (lists keep their indent/listType attrs).
return { type: block.type, attrs: block.attrs };
}
/**
* Split the current text block at the caret (Enter). A non-collapsed same-block
* selection is deleted first. Caret lands at the start of the new block.
*/
export const splitBlock: Command = (state, dispatch) => {
const sel = state.selection;
if (sel.kind !== 'text' || isAcrossBlocks(sel))
return false;
const block = blockById(state.doc, sel.focus.blockId);
const spec = block && state.schema.nodeSpec(block.type);
if (!block || spec?.content.kind !== 'text')
return false;
// Code blocks never split — Enter inserts a literal newline.
if (spec.code) {
if (dispatch) {
const tr = createTransaction(state);
let pos = sel.focus;
if (!isCollapsed(sel)) {
const { from, to } = orderedSelection(sel, state.doc);
tr.deleteText(block.id, from.offset, to.offset);
pos = from;
}
tr.insertText(pos, '\n', []).setSelection(caret(block.id, pos.offset + 1));
dispatch(tr);
}
return true;
}
if (dispatch) {
const tr = createTransaction(state);
let pos = sel.focus;
if (!isCollapsed(sel)) {
const { from, to } = orderedSelection(sel, state.doc);
tr.deleteText(block.id, from.offset, to.offset);
pos = from;
}
const cont = continuation(state, block, pos.offset);
tr.splitBlock(pos, cont.type, cont.attrs);
tr.setSelection(caret(tr.lastSplitId!, 0));
dispatch(tr);
}
return true;
};
/** Insert a hard line break (Shift+Enter) inside the current block. */
export const insertHardBreak: Command = (state, dispatch) => {
const sel = state.selection;
if (sel.kind !== 'text' || !isCollapsed(sel))
return false;
const block = blockById(state.doc, sel.focus.blockId);
const spec = block && state.schema.nodeSpec(block.type);
if (!block || spec?.content.kind !== 'text')
return false;
if (dispatch) {
dispatch(createTransaction(state)
.insertText(sel.focus, '\n', state.storedMarks ?? [])
.setSelection(caret(block.id, sel.focus.offset + 1)));
}
return true;
};
/**
* Backspace at the start of a block: merge it into the previous text block, or
* select a preceding atom block (image/divider) so a second Backspace deletes it.
*/
export const joinBackward: Command = (state, dispatch) => {
const sel = state.selection;
if (sel.kind !== 'text' || !isCollapsed(sel) || sel.focus.offset !== 0)
return false;
const current = blockById(state.doc, sel.focus.blockId);
const prev = previousBlock(state.doc, sel.focus.blockId);
if (!current || !prev)
return false;
const currentSpec = state.schema.nodeSpec(current.type);
const prevSpec = state.schema.nodeSpec(prev.type);
if (currentSpec?.isolating || prevSpec?.isolating)
return false;
if (prevSpec?.content.kind !== 'text') {
if (dispatch)
dispatch(createTransaction(state).setSelection(nodeSelection([prev.id])));
return true;
}
if (currentSpec?.content.kind !== 'text')
return false;
if (dispatch) {
const caretOffset = inlineLength(nodeInline(prev));
dispatch(createTransaction(state).mergeBlock(current.id, prev.id).setSelection(caret(prev.id, caretOffset)));
}
return true;
};
/** Delete at the end of a block: merge the next text block into it. */
export const joinForward: Command = (state, dispatch) => {
const sel = state.selection;
if (sel.kind !== 'text' || !isCollapsed(sel))
return false;
const current = blockById(state.doc, sel.focus.blockId);
if (!current || sel.focus.offset !== inlineLength(nodeInline(current)))
return false;
const next = nextBlock(state.doc, current.id);
if (!next)
return false;
const currentSpec = state.schema.nodeSpec(current.type);
const nextSpec = state.schema.nodeSpec(next.type);
if (currentSpec?.isolating || nextSpec?.isolating || currentSpec?.content.kind !== 'text' || nextSpec?.content.kind !== 'text')
return false;
if (dispatch) {
const caretOffset = inlineLength(nodeInline(current));
dispatch(createTransaction(state).mergeBlock(next.id, current.id).setSelection(caret(current.id, caretOffset)));
}
return true;
};
+59
View File
@@ -0,0 +1,59 @@
import type { Attrs, Node } from '../model';
import { blockById, isCollapsed, marksAt, nodeInline, orderedSelection, rangeHasMarkType } from '../model';
import type { EditorState } from '../state';
/** Block id the selection's focus is in (or the first node-selected block). */
export function selectionBlockId(state: EditorState): string | undefined {
const sel = state.selection;
return sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
}
/** The block the selection currently focuses, or `null`. */
export function focusBlock(state: EditorState): Node | null {
const id = selectionBlockId(state);
return id ? blockById(state.doc, id) : null;
}
/** Whether a block type holds inline (text) content. */
export function isTextBlockType(state: EditorState, type: string): boolean {
return state.schema.nodeSpec(type)?.content.kind === 'text';
}
/**
* Whether a mark is active for the current selection — used by `toggleMark` and
* by toolbars (call a command without `dispatch` for the same answer).
*/
export function isMarkActive(state: EditorState, type: string): boolean {
const sel = state.selection;
if (sel.kind !== 'text')
return false;
if (isCollapsed(sel)) {
if (state.storedMarks)
return state.storedMarks.some(mark => mark.type === type);
const block = blockById(state.doc, sel.focus.blockId);
return block ? marksAt(nodeInline(block), sel.focus.offset).some(mark => mark.type === type) : false;
}
if (sel.anchor.blockId !== sel.focus.blockId)
return false;
const { from, to } = orderedSelection(sel, state.doc);
const block = blockById(state.doc, sel.focus.blockId);
return block ? rangeHasMarkType(nodeInline(block), from.offset, to.offset, type) : false;
}
/** Whether the focused block matches a type (and optionally a subset of attrs). */
export function isBlockActive(state: EditorState, type: string, attrs?: Attrs): boolean {
const block = focusBlock(state);
if (!block || block.type !== type)
return false;
if (!attrs)
return true;
return Object.keys(attrs).every(key => block.attrs[key] === attrs[key]);
}
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest';
import { caret, createDoc, createNode, nodeSelection, nodeText } from '../../model';
import { deleteSelection } from '../../commands';
import { createDefaultRegistry } from '../../preset';
import { createEditor, createEditorState, createTransaction } from '../../state';
import { bindCrdt } from '../binding';
import type { RemoteCursor } from '../types';
import { createNativeProvider } from '../native/provider';
function makePeer(seedDoc?: ReturnType<typeof createDoc>) {
const registry = createDefaultRegistry();
const editor = createEditor({ state: createEditorState({ registry, doc: seedDoc }) });
const provider = createNativeProvider({ schema: registry.schema, doc: seedDoc ? editor.state.doc : undefined });
bindCrdt(editor, provider);
return { editor, provider };
}
/** Live two-way, in-memory transport between two providers. */
function connect(a: ReturnType<typeof makePeer>, b: ReturnType<typeof makePeer>) {
a.provider.onLocalOps(bytes => b.provider.applyUpdate(bytes));
b.provider.onLocalOps(bytes => a.provider.applyUpdate(bytes));
}
function text(peer: ReturnType<typeof makePeer>): string {
return peer.editor.state.doc.content.map(block => nodeText(block)).join('\n');
}
describe('crdt convergence (two editors)', () => {
it('a joining peer syncs the initial document, then concurrent edits converge', () => {
const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'Hello', marks: [] }] })]);
const a = makePeer(doc);
const b = makePeer(); // empty; joins by syncing A's full state
b.provider.applyUpdate(a.provider.encodeDelta());
expect(text(b)).toBe('Hello');
connect(a, b);
// Concurrent edits at opposite ends of the same block.
a.editor.dispatch(createTransaction(a.editor.state).insertText({ blockId: 'p', offset: 5 }, '!', []).setSelection(caret('p', 6)));
b.editor.dispatch(createTransaction(b.editor.state).insertText({ blockId: 'p', offset: 0 }, '>', []).setSelection(caret('p', 1)));
expect(text(a)).toBe(text(b));
expect(text(a)).toBe('>Hello!');
});
it('keeps offsets aligned for astral characters (emoji) across replicas', () => {
const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'a👍b', marks: [] }] })]);
const a = makePeer(doc);
const b = makePeer();
b.provider.applyUpdate(a.provider.encodeDelta());
connect(a, b);
// 'b' sits at UTF-16 offset 3 (the emoji occupies offsets 1..3).
a.editor.dispatch(createTransaction(a.editor.state).deleteText('p', 3, 4).setSelection(caret('p', 3)));
expect(text(a)).toBe('a👍');
expect(text(b)).toBe('a👍');
});
it('undo of a select-all delete does not duplicate content on the other replica', () => {
const doc = createDoc([
createNode('paragraph', { id: 'a', content: [{ text: 'AAA', marks: [] }] }),
createNode('paragraph', { id: 'b', content: [{ text: 'BBB', marks: [] }] }),
]);
const a = makePeer(doc);
const b = makePeer();
b.provider.applyUpdate(a.provider.encodeDelta());
connect(a, b);
expect(text(b)).toBe('AAA\nBBB');
// Select every block and delete (inserts one fresh empty paragraph).
a.editor.dispatch(createTransaction(a.editor.state).setSelection(nodeSelection(['a', 'b'])));
expect(a.editor.command(deleteSelection)).toBe(true);
expect(text(a)).toBe('');
// Undo must restore the blocks without duplicating them on either replica.
expect(a.editor.undo()).toBe(true);
expect(text(a)).toBe('AAA\nBBB');
expect(text(b)).toBe('AAA\nBBB');
});
it('awareness: a remote cursor anchor stays on its character when text is inserted before it', () => {
const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'Hello', marks: [] }] })]);
const a = makePeer(doc);
const b = makePeer();
b.provider.applyUpdate(a.provider.encodeDelta());
let cursors: RemoteCursor[] = [];
a.provider.onAwareness((next) => {
cursors = next;
});
b.provider.onLocalAwareness(bytes => a.provider.applyAwareness(bytes));
// B places its caret after "Hello" (offset 5).
b.editor.dispatch(createTransaction(b.editor.state).setSelection(caret('p', 5)));
expect(cursors[0]?.selection?.kind).toBe('text');
expect(cursors[0]?.selection?.kind === 'text' && cursors[0].selection.focus.offset).toBe(5);
// A edits locally; A's view of B's cursor (anchored after 'o') re-resolves to offset 7.
a.editor.dispatch(createTransaction(a.editor.state).insertText({ blockId: 'p', offset: 0 }, '>>', []).setSelection(caret('p', 2)));
expect(cursors[0]?.selection?.kind === 'text' && cursors[0].selection.focus.offset).toBe(7);
});
it('converges across splits, bold, and a second block', () => {
const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'abcdef', marks: [] }] })]);
const a = makePeer(doc);
const b = makePeer();
b.provider.applyUpdate(a.provider.encodeDelta());
connect(a, b);
// A bolds "ab" (stays in the head block); B splits after "abc" — concurrently.
a.editor.dispatch(createTransaction(a.editor.state).addMark('p', 0, 2, { type: 'bold' }).setSelection(caret('p', 2)));
b.editor.dispatch(createTransaction(b.editor.state).splitBlock({ blockId: 'p', offset: 3 }, undefined, undefined, 'p2').setSelection(caret('p2', 0)));
expect(text(a)).toBe(text(b));
// Document text is preserved across both edits (split inserts a block boundary).
expect(text(a).replace('\n', '')).toBe('abcdef');
// The bold mark survived on both replicas (somewhere in the doc).
const hasBold = (peer: ReturnType<typeof makePeer>) =>
peer.editor.state.doc.content.some(block =>
Array.isArray(block.content) && block.content.some(run => 'marks' in run && run.marks.some((m: { type: string }) => m.type === 'bold')));
expect(hasBold(a)).toBe(true);
expect(hasBold(b)).toBe(true);
});
it('per-block patching: a remote edit keeps untouched block node identities', () => {
const doc = createDoc([
createNode('paragraph', { id: 'a', content: [{ text: 'AAA', marks: [] }] }),
createNode('paragraph', { id: 'b', content: [{ text: 'BBB', marks: [] }] }),
]);
const a = makePeer(doc);
const b = makePeer();
b.provider.applyUpdate(a.provider.encodeDelta());
connect(a, b);
const bBefore = b.editor.state.doc.content.find(node => node.id === 'b')!;
a.editor.dispatch(createTransaction(a.editor.state).insertText({ blockId: 'a', offset: 3 }, '!', []).setSelection(caret('a', 4)));
expect(nodeText(b.editor.state.doc.content.find(node => node.id === 'a')!)).toBe('AAA!'); // changed block updated
expect(b.editor.state.doc.content.find(node => node.id === 'b')!).toBe(bBefore); // untouched block reused identity
});
it('tombstone GC compacts deleted content, preserving the document and convergence', () => {
const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'Hello World', marks: [] }] })]);
const a = makePeer(doc);
const b = makePeer();
b.provider.applyUpdate(a.provider.encodeDelta());
connect(a, b);
a.editor.dispatch(createTransaction(a.editor.state).deleteText('p', 5, 11).setSelection(caret('p', 5)));
a.editor.dispatch(createTransaction(a.editor.state).addMark('p', 0, 5, { type: 'bold' }).setSelection(caret('p', 5)));
expect(text(a)).toBe('Hello');
expect(text(b)).toBe('Hello');
// Quiesced + fully synced → GC is safe on both replicas.
const removed = a.provider.gc();
b.provider.gc();
expect(removed.chars).toBeGreaterThanOrEqual(6); // the deleted " World"
// The compacted CRDT still materializes the right content and formatting.
const reloaded = a.provider.load();
expect(nodeText(reloaded.content[0]!)).toBe('Hello');
const runs = reloaded.content[0]!.content;
expect(Array.isArray(runs) && runs.length > 0 && 'marks' in runs[0]! && runs[0]!.marks.some((m: { type: string }) => m.type === 'bold')).toBe(true);
// A further edit still converges across replicas.
a.editor.dispatch(createTransaction(a.editor.state).insertText({ blockId: 'p', offset: 5 }, '!', []).setSelection(caret('p', 6)));
expect(text(a)).toBe('Hello!');
expect(text(b)).toBe('Hello!');
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { opId } from '@robonen/crdt';
import { createDoc, createNode } from '../../model';
import { createDefaultRegistry } from '../../preset';
import { DocumentCrdt } from '../native/document-crdt';
describe('documentCrdt.applyOp', () => {
it('buffers (returns false for) a text-delete whose dependency is missing', () => {
const registry = createDefaultRegistry();
const doc = new DocumentCrdt(registry.schema);
let counter = 0;
doc.setIdFactory(() => opId('x', ++counter));
for (const op of doc.seedFromDocument(createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'ab', marks: [] }] })])))
doc.applyOp(op);
// Delete referencing a char id we've never seen → not ready (must buffer).
expect(doc.applyOp({ id: opId('r', 1), kind: 'text-delete', blockId: 'p', charId: opId('r', 99) })).toBe(false);
// Delete referencing a missing block → also not ready.
expect(doc.applyOp({ id: opId('r', 2), kind: 'text-delete', blockId: 'gone', charId: opId('x', 1) })).toBe(false);
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { Editor, Transaction } from '../state';
import { createTransaction } from '../state';
import { reconcileDoc } from './reconcile';
import type { CrdtProvider } from './types';
import { REMOTE_ORIGIN } from './types';
export interface CrdtBinding {
detach: () => void;
}
/**
* 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.
*/
export function bindCrdt(editor: Editor, provider: CrdtProvider): CrdtBinding {
function onTransaction(tr: Transaction): void {
if (tr.getMeta('origin') !== REMOTE_ORIGIN)
provider.applyLocal(tr); // never echo a remote-sourced change back into the CRDT
provider.setLocalSelection(editor.state.selection); // presence (local edits + remapped remote)
}
editor.on('transaction', onTransaction);
provider.setLocalSelection(editor.state.selection);
const offRemote = provider.onRemoteApplied(() => {
// Reuse unchanged block identities so only the blocks a remote edit touched
// repaint (and the local caret in untouched blocks stays put).
const next = reconcileDoc(editor.state.doc, provider.load());
if (next === editor.state.doc)
return; // remote ops didn't change the visible document
editor.dispatch(createTransaction(editor.state)
.setDoc(next)
.setMeta('origin', REMOTE_ORIGIN)
.setMeta('addToHistory', false));
});
return {
detach: () => {
editor.off('transaction', onTransaction);
offRemote();
provider.destroy();
},
};
}
+7
View File
@@ -0,0 +1,7 @@
export * from './types';
export * from './binding';
export * from './reconcile';
export { createNativeProvider } from './native/provider';
export type { NativeProviderOptions } from './native/provider';
export { DocumentCrdt } from './native/document-crdt';
export type { EditorOp } from './native/document-crdt';
+488
View File
@@ -0,0 +1,488 @@
import type { MarkValue, OpId, VersionVector } from '@robonen/crdt';
import { LwwRegister, MarkStore, Rga, keyBetween, opIdEq, opIdToString } from '@robonen/crdt';
import type { Attrs, EditorDocument, Inline, InlineNode, Mark, Node, Selection } from '../../model';
import { createDoc, nodeSelection, normalizeInline, normalizeMarks, textSelection } from '../../model';
import type { Schema } from '../../schema';
import type { Step } from '../../state';
import type { SelectionAnchor } from '../types';
/**
* 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.
*/
export type EditorOp
= | { readonly id: OpId; readonly kind: 'block-insert'; readonly blockId: string; readonly blockType: string; readonly attrs: Attrs; readonly posKey: string; readonly isText: boolean }
| { readonly id: OpId; readonly kind: 'block-remove'; readonly blockId: string }
| { readonly id: OpId; readonly kind: 'block-move'; readonly blockId: string; readonly posKey: string }
| { readonly id: OpId; readonly kind: 'block-attrs'; readonly blockId: string; readonly attrs: Attrs }
| { readonly id: OpId; readonly kind: 'block-type'; readonly blockId: string; readonly blockType: string; readonly attrs: Attrs }
| { readonly id: OpId; readonly kind: 'text-insert'; readonly blockId: string; readonly afterId: OpId | null; readonly ch: string }
| { readonly id: OpId; readonly kind: 'text-delete'; readonly blockId: string; readonly charId: OpId }
| { readonly id: OpId; readonly kind: 'mark-add'; readonly blockId: string; readonly markType: string; readonly value: MarkValue; readonly startId: OpId; readonly endId: OpId };
interface BlockState {
present: LwwRegister<boolean>;
posKey: LwwRegister<string>;
type: LwwRegister<string>;
attrs: LwwRegister<Attrs>;
isText: boolean;
rga: Rga<string>;
marks: MarkStore;
}
function markToValue(mark: Mark): MarkValue {
return mark.attrs && Object.keys(mark.attrs).length > 0 ? (mark.attrs as MarkValue) : true;
}
function valueToMark(type: string, value: MarkValue): Mark {
return value && typeof value === 'object' ? { type, attrs: value as Attrs } : { type };
}
/**
* 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}).
*/
export class DocumentCrdt {
private readonly blocks = new Map<string, BlockState>();
private nextId: () => OpId = () => { throw new Error('DocumentCrdt: id factory not set'); };
constructor(private readonly schema: Schema) {}
/** Wire the replica's id generator (called once by the provider). */
setIdFactory(factory: () => OpId): void {
this.nextId = factory;
}
// ---------------------------------------------------------------- integrate
/** Apply one op (local or remote). Returns false if a causal dependency is missing. */
applyOp(op: EditorOp): boolean {
switch (op.kind) {
case 'block-insert': {
if (!this.blocks.has(op.blockId)) {
this.blocks.set(op.blockId, {
present: register(true, op.id),
posKey: register(op.posKey, op.id),
type: register(op.blockType, op.id),
attrs: register(op.attrs, op.id),
isText: op.isText,
rga: new Rga<string>(),
marks: new MarkStore(),
});
}
else {
// Re-activating a tombstoned block (e.g. undo of a removal): its RGA
// and marks are intact, so we only restore presence/position/attrs.
const block = this.blocks.get(op.blockId)!;
block.present.set(true, op.id);
block.posKey.set(op.posKey, op.id);
block.type.set(op.blockType, op.id);
block.attrs.set(op.attrs, op.id);
}
return true;
}
case 'block-remove': {
const block = this.blocks.get(op.blockId);
if (!block)
return false;
block.present.set(false, op.id);
return true;
}
case 'block-move': {
const block = this.blocks.get(op.blockId);
if (!block)
return false;
block.posKey.set(op.posKey, op.id);
return true;
}
case 'block-attrs': {
const block = this.blocks.get(op.blockId);
if (!block)
return false;
block.attrs.set(op.attrs, op.id);
return true;
}
case 'block-type': {
const block = this.blocks.get(op.blockId);
if (!block)
return false;
block.type.set(op.blockType, op.id);
block.attrs.set(op.attrs, op.id);
return true;
}
case 'text-insert': {
const block = this.blocks.get(op.blockId);
if (!block)
return false;
return block.rga.integrateInsert(op.id, op.ch, op.afterId);
}
case 'text-delete': {
const block = this.blocks.get(op.blockId);
if (!block)
return false;
// Propagate false so the Replica buffers a delete that arrives before its
// target insert (deleting an already-tombstoned id still returns true).
return block.rga.integrateDelete(op.charId);
}
case 'mark-add': {
const block = this.blocks.get(op.blockId);
if (!block)
return false;
block.marks.add({ id: op.id, type: op.markType, value: op.value, start: op.startId, end: op.endId });
return true;
}
}
}
// --------------------------------------------------------------- materialize
toDocument(): EditorDocument {
const content: Node[] = [];
for (const blockId of this.orderedBlockIds()) {
const block = this.blocks.get(blockId)!;
content.push({
id: blockId,
type: block.type.get(),
attrs: block.attrs.get(),
content: block.isText ? this.materialize(block) : null,
});
}
return createDoc(content);
}
private orderedBlockIds(): string[] {
return [...this.blocks.entries()]
.filter(([, block]) => block.present.get())
.sort(([idA, a], [idB, b]) => {
const ka = a.posKey.get();
const kb = b.posKey.get();
if (ka !== kb)
return ka < kb ? -1 : 1;
return idA < idB ? -1 : idA > idB ? 1 : 0;
})
.map(([id]) => id);
}
private materialize(block: BlockState): Inline {
const nodes = block.rga.visible();
const marksPerChar = block.marks.resolve(nodes.map(node => node.id));
const runs: InlineNode[] = [];
for (let i = 0; i < nodes.length; i++) {
const marks = normalizeMarks([...marksPerChar[i]!].map(([type, value]) => valueToMark(type, value)));
runs.push({ text: nodes[i]!.value, marks });
}
return normalizeInline(runs);
}
// ----------------------------------------------------------------- maintenance
/**
* Compact the CRDT: drop tombstoned characters and fully-removed blocks that
* are covered by `stable`. Mark-span endpoints are preserved so formatting
* survives. Call ONLY at quiescence — every replica fully synced, nothing in
* flight — or a late op referencing dropped content can no longer integrate.
*/
gc(stable: VersionVector): { blocks: number; chars: number } {
let blocks = 0;
let chars = 0;
for (const [id, block] of this.blocks) {
const removedAt = block.present.timestamp;
if (!block.present.get() && removedAt && stable.has(removedAt)) {
this.blocks.delete(id);
blocks += 1;
continue;
}
const keep = new Set<string>();
for (const span of block.marks.all()) {
keep.add(opIdToString(span.start));
keep.add(opIdToString(span.end));
}
chars += block.rga.gc(stable, charId => keep.has(opIdToString(charId)));
}
return { blocks, chars };
}
// ------------------------------------------------------------------ awareness
/** Whether a block currently exists and is visible. */
hasBlock(blockId: string): boolean {
return this.blocks.get(blockId)?.present.get() ?? false;
}
/** The char id a caret at `offset` sits after (null at block start) — a stable cursor anchor. */
private anchorAt(blockId: string, offset: number): OpId | null {
const block = this.blocks.get(blockId);
return block?.isText ? block.rga.idAt(offset - 1) : null;
}
/** Resolve a char-id anchor back to an offset in the current state. */
private offsetOf(blockId: string, afterCharId: OpId | null): number {
const block = this.blocks.get(blockId);
if (!block?.isText)
return 0;
if (afterCharId === null)
return 0;
const visible = block.rga.visible();
const index = visible.findIndex(node => opIdEq(node.id, afterCharId));
return index === -1 ? visible.length : index + 1;
}
/** Convert a model selection into a char-id anchor (for presence broadcast). */
toAnchor(selection: Selection): SelectionAnchor {
if (selection.kind === 'node')
return { kind: 'node', ids: selection.ids };
return {
kind: 'text',
anchor: { blockId: selection.anchor.blockId, afterCharId: this.anchorAt(selection.anchor.blockId, selection.anchor.offset) },
focus: { blockId: selection.focus.blockId, afterCharId: this.anchorAt(selection.focus.blockId, selection.focus.offset) },
};
}
/** Resolve an anchor back into a model selection against the current document. */
resolveAnchor(anchor: SelectionAnchor | null): Selection | null {
if (!anchor)
return null;
if (anchor.kind === 'node')
return anchor.ids.length > 0 ? nodeSelection(anchor.ids) : null;
if (!this.hasBlock(anchor.anchor.blockId) || !this.hasBlock(anchor.focus.blockId))
return null;
return textSelection(
{ blockId: anchor.anchor.blockId, offset: this.offsetOf(anchor.anchor.blockId, anchor.anchor.afterCharId) },
{ blockId: anchor.focus.blockId, offset: this.offsetOf(anchor.focus.blockId, anchor.focus.afterCharId) },
);
}
// ----------------------------------------------------------------- translate
/** Generate the ops for a local step, reading current state for ids/positions. */
translateStep(step: Step): EditorOp[] {
switch (step.type) {
case 'insertInline':
return this.insertInlineOps(step.blockId, step.offset, step.content);
case 'deleteText':
return this.deleteTextOps(step.blockId, step.from, step.to);
case 'replaceInline':
return [...this.deleteTextOps(step.blockId, step.from, step.to), ...this.insertInlineOps(step.blockId, step.from, step.content)];
case 'addMark':
return this.markOps(step.blockId, step.from, step.to, step.mark.type, markToValue(step.mark));
case 'removeMark':
return this.markOps(step.blockId, step.from, step.to, step.mark.type, null);
case 'setAttrs':
return this.blocks.has(step.blockId) ? [{ id: this.nextId(), kind: 'block-attrs', blockId: step.blockId, attrs: step.attrs }] : [];
case 'setType':
return this.blocks.has(step.blockId) ? [{ id: this.nextId(), kind: 'block-type', blockId: step.blockId, blockType: step.blockType, attrs: step.attrs }] : [];
case 'insertBlock':
return this.insertBlockOps(step.node, step.index);
case 'removeBlock':
return this.blocks.has(step.blockId) ? [{ id: this.nextId(), kind: 'block-remove', blockId: step.blockId }] : [];
case 'moveBlock':
return this.moveOps(step.blockId, step.toIndex);
case 'splitBlock':
return this.splitOps(step.blockId, step.offset, step.newId, step.newType, step.newAttrs);
case 'mergeBlock':
return this.mergeOps(step.blockId, step.intoId);
case 'setDoc':
return []; // whole-doc replace is local-only (e.g. applying a remote snapshot); not re-emitted
}
}
/** Ops to seed the CRDT from an initial document. */
seedFromDocument(doc: EditorDocument): EditorOp[] {
const ops: EditorOp[] = [];
let prevKey: string | null = null;
for (const node of doc.content) {
const posKey = keyBetween(prevKey, null);
prevKey = posKey;
ops.push(...this.blockOps(node, posKey));
}
return ops;
}
// ------------------------------------------------------------------ op builders
/** Ops for an `insertBlock` step: reactivate if the block already exists (undo), else create. */
private insertBlockOps(node: Node, index: number): EditorOp[] {
const posKey = this.posKeyForIndex(index);
if (this.blocks.has(node.id)) {
const isText = this.schema.nodeSpec(node.type)?.content.kind === 'text';
return [{ id: this.nextId(), kind: 'block-insert', blockId: node.id, blockType: node.type, attrs: node.attrs, posKey, isText }];
}
return this.blockOps(node, posKey);
}
private blockOps(node: Node, posKey: string): EditorOp[] {
const isText = this.schema.nodeSpec(node.type)?.content.kind === 'text';
const ops: EditorOp[] = [{
id: this.nextId(),
kind: 'block-insert',
blockId: node.id,
blockType: node.type,
attrs: node.attrs,
posKey,
isText,
}];
if (isText && Array.isArray(node.content))
ops.push(...this.inlineOps(node.id, node.content as Inline, null));
return ops;
}
/** Insert inline `content` after the char at `afterId` (null = block start). */
private inlineOps(blockId: string, content: Inline, afterId: OpId | null): EditorOp[] {
const ops: EditorOp[] = [];
let after = afterId;
for (const run of content) {
const charIds: OpId[] = [];
// Iterate UTF-16 code units (not code points) to match the editor's
// offset space — one RGA node per unit keeps offsets aligned for astral chars.
for (let i = 0; i < run.text.length; i++) {
const id = this.nextId();
ops.push({ id, kind: 'text-insert', blockId, afterId: after, ch: run.text[i]! });
after = id;
charIds.push(id);
}
if (charIds.length > 0) {
for (const mark of run.marks)
ops.push({ id: this.nextId(), kind: 'mark-add', blockId, markType: mark.type, value: markToValue(mark), startId: charIds[0]!, endId: charIds[charIds.length - 1]! });
}
}
return ops;
}
private insertInlineOps(blockId: string, offset: number, content: Inline): EditorOp[] {
const block = this.blocks.get(blockId);
if (!block || !block.isText)
return [];
return this.inlineOps(blockId, content, block.rga.idAt(offset - 1));
}
private deleteTextOps(blockId: string, from: number, to: number): EditorOp[] {
const block = this.blocks.get(blockId);
if (!block || !block.isText)
return [];
return block.rga.visible().slice(from, to)
.map(node => ({ id: this.nextId(), kind: 'text-delete' as const, blockId, charId: node.id }));
}
private markOps(blockId: string, from: number, to: number, markType: string, value: MarkValue): EditorOp[] {
const block = this.blocks.get(blockId);
if (!block || !block.isText || from >= to)
return [];
const visible = block.rga.visible();
const start = visible[from]?.id;
const end = visible[to - 1]?.id;
if (!start || !end)
return [];
return [{ id: this.nextId(), kind: 'mark-add', blockId, markType, value, startId: start, endId: end }];
}
private posKeyForIndex(index: number): string {
const order = this.orderedBlockIds();
const before = index > 0 ? this.blocks.get(order[index - 1]!)?.posKey.get() ?? null : null;
const after = index < order.length ? this.blocks.get(order[index]!)?.posKey.get() ?? null : null;
return keyBetween(before, after);
}
private moveOps(blockId: string, toIndex: number): EditorOp[] {
if (!this.blocks.has(blockId))
return [];
const order = this.orderedBlockIds().filter(id => id !== blockId);
const before = toIndex > 0 ? this.blocks.get(order[toIndex - 1]!)?.posKey.get() ?? null : null;
const after = toIndex < order.length ? this.blocks.get(order[toIndex]!)?.posKey.get() ?? null : null;
return [{ id: this.nextId(), kind: 'block-move', blockId, posKey: keyBetween(before, after) }];
}
private splitOps(blockId: string, offset: number, newId: string, newType?: string, newAttrs?: Attrs): EditorOp[] {
const block = this.blocks.get(blockId);
if (!block || !block.isText)
return [];
const order = this.orderedBlockIds();
const index = order.indexOf(blockId);
const nextKey = index >= 0 && index + 1 < order.length ? this.blocks.get(order[index + 1]!)!.posKey.get() : null;
const posKey = keyBetween(block.posKey.get(), nextKey);
// Undo of a merge: the split's target block already exists (tombstoned) with
// its original content intact. Reactivate it and drop the merged-in tail from
// the source instead of recreating content (which would duplicate it).
const existing = this.blocks.get(newId);
if (existing) {
const reactivate: EditorOp[] = [{ id: this.nextId(), kind: 'block-insert', blockId: newId, blockType: existing.type.get(), attrs: existing.attrs.get(), posKey, isText: existing.isText }];
for (const node of block.rga.visible().slice(offset))
reactivate.push({ id: this.nextId(), kind: 'text-delete', blockId, charId: node.id });
return reactivate;
}
const type = newType ?? block.type.get();
const attrs = newAttrs ?? (newType ? this.schema.defaultAttrs(newType) : block.attrs.get());
const isText = this.schema.nodeSpec(type)?.content.kind === 'text';
const ops: EditorOp[] = [{ id: this.nextId(), kind: 'block-insert', blockId: newId, blockType: type, attrs, posKey, isText }];
// Re-create the tail (offset..end) in the new block, then tombstone it in the old.
const tail = block.rga.visible().slice(offset);
const marksPerChar = block.marks.resolve(block.rga.visible().map(node => node.id));
let after: OpId | null = null;
for (let k = 0; k < tail.length; k++) {
const id = this.nextId();
ops.push({ id, kind: 'text-insert', blockId: newId, afterId: after, ch: tail[k]!.value });
after = id;
for (const [markType, value] of marksPerChar[offset + k]!)
ops.push({ id: this.nextId(), kind: 'mark-add', blockId: newId, markType, value, startId: id, endId: id });
}
for (const node of tail)
ops.push({ id: this.nextId(), kind: 'text-delete', blockId, charId: node.id });
return ops;
}
private mergeOps(blockId: string, intoId: string): EditorOp[] {
const source = this.blocks.get(blockId);
const target = this.blocks.get(intoId);
if (!source || !target || !source.isText || !target.isText)
return [];
const ops: EditorOp[] = [];
const sourceChars = source.rga.visible();
const marksPerChar = source.marks.resolve(sourceChars.map(node => node.id));
let after = target.rga.idAt(target.rga.length - 1);
for (let k = 0; k < sourceChars.length; k++) {
const id = this.nextId();
ops.push({ id, kind: 'text-insert', blockId: intoId, afterId: after, ch: sourceChars[k]!.value });
after = id;
for (const [markType, value] of marksPerChar[k]!)
ops.push({ id: this.nextId(), kind: 'mark-add', blockId: intoId, markType, value, startId: id, endId: id });
}
ops.push({ id: this.nextId(), kind: 'block-remove', blockId });
return ops;
}
}
function register<T>(value: T, id: OpId): LwwRegister<T> {
const reg = new LwwRegister<T>(value);
reg.set(value, id);
return reg;
}
+139
View File
@@ -0,0 +1,139 @@
import { Replica, VersionVector, createSiteId, decodeJson, decodeOps, decodeStateVector, encodeJson, encodeOps, encodeStateVector } from '@robonen/crdt';
import type { EditorDocument, Selection } from '../../model';
import type { Schema } from '../../schema';
import type { Transaction } from '../../state';
import type { AwarenessState, CrdtProvider, CursorUser, RemoteCursor } from '../types';
import type { EditorOp } from './document-crdt';
import { DocumentCrdt } from './document-crdt';
export interface NativeProviderOptions {
/** Schema (block/mark specs) — needed to know which blocks hold text. */
schema: Schema;
/** Seed the CRDT from this document (use for the FIRST replica only; joiners sync instead). */
doc?: EditorDocument;
/** Replica/site id (defaults to a random one). */
site?: string;
/** Identity broadcast with this replica's cursor. */
user?: CursorUser;
}
/**
* 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.
*/
export function createNativeProvider(options: NativeProviderOptions): CrdtProvider {
const document = new DocumentCrdt(options.schema);
const site = options.site ?? createSiteId();
const replica = new Replica<EditorOp>({ integrate: op => document.applyOp(op) }, site);
document.setIdFactory(() => replica.nextId());
const localListeners = new Set<(bytes: Uint8Array) => void>();
const remoteListeners = new Set<() => void>();
const localAwarenessListeners = new Set<(bytes: Uint8Array) => void>();
const awarenessListeners = new Set<(cursors: RemoteCursor[]) => void>();
const remoteStates = new Map<string, AwarenessState>();
if (options.doc) {
for (const op of document.seedFromDocument(options.doc))
replica.commitLocal(op);
}
function resolveCursors(): RemoteCursor[] {
const cursors: RemoteCursor[] = [];
for (const state of remoteStates.values()) {
if (state.clientId === site)
continue;
cursors.push({ clientId: state.clientId, user: state.user, selection: document.resolveAnchor(state.anchor) });
}
return cursors;
}
return {
name: 'native',
load: () => document.toDocument(),
applyLocal: (tr: Transaction) => {
const ops: EditorOp[] = [];
for (const step of tr.steps) {
for (const op of document.translateStep(step)) {
replica.commitLocal(op);
ops.push(op);
}
}
if (ops.length > 0) {
const bytes = encodeOps(ops);
for (const listener of localListeners)
listener(bytes);
// Local edits shifted the document — re-resolve remote cursor positions.
if (remoteStates.size > 0) {
const cursors = resolveCursors();
for (const listener of awarenessListeners)
listener(cursors);
}
}
},
applyUpdate: (bytes) => {
const applied = replica.receive(decodeOps<EditorOp>(bytes));
if (applied.length > 0) {
for (const listener of remoteListeners)
listener();
// Remote ops shifted the document — re-resolve cursors against new positions.
if (remoteStates.size > 0) {
const cursors = resolveCursors();
for (const listener of awarenessListeners)
listener(cursors);
}
}
},
encodeStateVector: () => encodeStateVector(replica.version),
encodeDelta: remote => encodeOps(replica.delta(remote ? decodeStateVector(remote) : new VersionVector())),
onLocalOps: (listener) => {
localListeners.add(listener);
return () => localListeners.delete(listener);
},
onRemoteApplied: (listener) => {
remoteListeners.add(listener);
return () => remoteListeners.delete(listener);
},
setLocalSelection: (selection: Selection | null) => {
const state: AwarenessState = { clientId: site, user: options.user, anchor: selection ? document.toAnchor(selection) : null };
const bytes = encodeJson(state);
for (const listener of localAwarenessListeners)
listener(bytes);
},
onLocalAwareness: (listener) => {
localAwarenessListeners.add(listener);
return () => localAwarenessListeners.delete(listener);
},
applyAwareness: (bytes) => {
const state = decodeJson<AwarenessState>(bytes);
remoteStates.set(state.clientId, state);
const cursors = resolveCursors();
for (const listener of awarenessListeners)
listener(cursors);
},
onAwareness: (listener) => {
awarenessListeners.add(listener);
return () => awarenessListeners.delete(listener);
},
gc: stable => document.gc(stable ? decodeStateVector(stable) : replica.version),
destroy: () => {
localListeners.clear();
remoteListeners.clear();
localAwarenessListeners.clear();
awarenessListeners.clear();
remoteStates.clear();
},
};
}
+49
View File
@@ -0,0 +1,49 @@
import type { Content, EditorDocument, Node } from '../model';
import { attrsEq, createDoc, isInlineContent, marksEq } from '../model';
function contentEq(a: Content, b: Content): boolean {
if (a === b)
return true;
if (a === null || b === null)
return false;
if (isInlineContent(a) && isInlineContent(b)) {
if (a.length !== b.length)
return false;
for (let i = 0; i < a.length; i++) {
if (a[i]!.text !== b[i]!.text || !marksEq(a[i]!.marks, b[i]!.marks))
return false;
}
return true;
}
return false; // container Node[] — unused in the flat model; treat as changed
}
function nodeEq(a: Node, b: Node): boolean {
return a.id === b.id && a.type === b.type && attrsEq(a.attrs, b.attrs) && contentEq(a.content, b.content);
}
/**
* 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.
*/
export function reconcileDoc(prev: EditorDocument, next: EditorDocument): EditorDocument {
const prevById = new Map(prev.content.map(node => [node.id, node]));
let changed = prev.content.length !== next.content.length;
const content = next.content.map((node, index) => {
const before = prevById.get(node.id);
if (before && nodeEq(before, node)) {
if (prev.content[index]?.id !== node.id)
changed = true; // same block, new position
return before; // reuse identity → no repaint
}
changed = true;
return node;
});
return changed ? createDoc(content) : prev;
}
+78
View File
@@ -0,0 +1,78 @@
import type { OpId } from '@robonen/crdt';
import type { EditorDocument, Selection } from '../model';
import type { Transaction } from '../state';
/** Marks transactions that apply remote CRDT changes (so they bypass local history). */
export const REMOTE_ORIGIN = 'crdt-remote';
export interface CursorUser {
readonly name?: string;
readonly color?: string;
}
/** A caret point anchored to the character op id it sits after (stable under remote edits). */
export interface PointAnchor {
readonly blockId: string;
readonly afterCharId: OpId | null;
}
/** A selection anchored to char ids rather than offsets, for awareness. */
export type SelectionAnchor
= | { readonly kind: 'text'; readonly anchor: PointAnchor; readonly focus: PointAnchor }
| { readonly kind: 'node'; readonly ids: readonly string[] };
/** Ephemeral per-client presence (cursor + identity), sent over the awareness channel. */
export interface AwarenessState {
readonly clientId: string;
readonly user?: CursorUser;
readonly anchor: SelectionAnchor | null;
}
/** A remote participant's cursor, resolved back into local model coordinates. */
export interface RemoteCursor {
readonly clientId: string;
readonly user?: CursorUser;
readonly selection: Selection | null;
}
/**
* A pluggable CRDT backend. The editor core stays CRDT-agnostic behind this
* interface; {@link bindCrdt} wires it to an {@link Editor}, and any transport
* (BroadcastChannel, WebSocket, ) is layered on via the op + awareness hooks.
*/
export interface CrdtProvider {
readonly name: string;
/** The current document materialized from CRDT state. */
load: () => EditorDocument;
/** Translate a local transaction's steps into CRDT ops and apply them. */
applyLocal: (tr: Transaction) => void;
/** Merge a remote update (encoded ops) into the CRDT. */
applyUpdate: (bytes: Uint8Array, origin?: unknown) => void;
/** Encode this replica's version vector for a sync handshake. */
encodeStateVector: () => Uint8Array;
/** Encode ops a remote is missing (by its state vector); omit for a full snapshot. */
encodeDelta: (remoteStateVector?: Uint8Array) => Uint8Array;
/** Subscribe to locally-produced op batches (to broadcast). Returns unsubscribe. */
onLocalOps: (listener: (bytes: Uint8Array) => void) => () => void;
/** Subscribe to "remote ops were applied" (to reflect into editor state). Returns unsubscribe. */
onRemoteApplied: (listener: () => void) => () => void;
// --- awareness (ephemeral; not part of the persistent document) ---
/** Publish the local selection as presence (anchored to char ids). */
setLocalSelection: (selection: Selection | null) => void;
/** Subscribe to locally-produced awareness frames (to broadcast). Returns unsubscribe. */
onLocalAwareness: (listener: (bytes: Uint8Array) => void) => () => void;
/** Merge a remote awareness frame. */
applyAwareness: (bytes: Uint8Array) => void;
/** Subscribe to the resolved set of remote cursors. Returns unsubscribe. */
onAwareness: (listener: (cursors: RemoteCursor[]) => void) => () => void;
/**
* Compact tombstones and removed blocks covered by `stableStateVector` (or this
* replica's version when omitted). Safe only at quiescence all peers fully
* synced, nothing in flight. Returns how much was dropped.
*/
gc: (stableStateVector?: Uint8Array) => { blocks: number; chars: number };
destroy: () => void;
}
+17
View File
@@ -0,0 +1,17 @@
export {};
declare global {
const __DEV__: boolean;
}
declare module 'vue' {
interface ComponentCustomProps {
[key: `data${string}`]: unknown;
}
}
declare module 'vue' {
interface HTMLAttributes {
[key: `data-${string}`]: unknown;
}
}
+11
View File
@@ -0,0 +1,11 @@
export * from './model';
export * from './schema';
export * from './registry';
export * from './state';
export * from './commands';
export * from './keymap';
export * from './view';
export * from './blocks';
export * from './marks';
export * from './preset';
export * from './crdt';
+22
View File
@@ -0,0 +1,22 @@
import type { Command } from '../state';
import type { Platform } from '../view/config';
import type { Keymap } from './types';
import { normalizeCombo } from './normalize';
/**
* Merge ordered keymaps into a single normalized lookup. Earlier keymaps win, so
* pass user overrides before the defaults: `compileKeymaps([user, defaults], …)`.
*/
export function compileKeymaps(keymaps: readonly Keymap[], platform: Platform): Map<string, Command> {
const compiled = new Map<string, Command>();
for (const keymap of keymaps) {
for (const combo in keymap) {
const normalized = normalizeCombo(combo, platform);
if (!compiled.has(normalized))
compiled.set(normalized, keymap[combo]!);
}
}
return compiled;
}
+55
View File
@@ -0,0 +1,55 @@
import {
chainCommands,
deleteSelection,
indentListItem,
insertHardBreak,
joinBackward,
joinForward,
moveBlockDown,
moveBlockUp,
outdentListItem,
selectAll,
setBlockType,
splitBlock,
toggleBlockType,
toggleMark,
} from '../commands';
import type { Command, Editor } from '../state';
import type { Keymap } from './types';
/**
* 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).
*/
export function defaultKeymap(editor: Editor): Keymap {
const undo: Command = () => editor.undo();
const redo: Command = () => editor.redo();
const keymap: Keymap = {
'Mod-b': toggleMark('bold'),
'Mod-i': toggleMark('italic'),
'Mod-u': toggleMark('underline'),
'Mod-Shift-s': toggleMark('strike'),
'Mod-e': toggleMark('code'),
'Mod-z': undo,
'Mod-Shift-z': redo,
'Mod-y': redo,
Enter: splitBlock,
'Shift-Enter': insertHardBreak,
Backspace: chainCommands(deleteSelection, joinBackward),
Delete: chainCommands(deleteSelection, joinForward),
'Mod-a': selectAll,
Tab: indentListItem,
'Shift-Tab': outdentListItem,
'Mod-Shift-ArrowUp': moveBlockUp,
'Mod-Shift-ArrowDown': moveBlockDown,
'Mod-Alt-0': setBlockType('paragraph'),
};
for (let level = 1; level <= 6; level++)
keymap[`Mod-Alt-${level}`] = toggleBlockType('heading', { level });
return keymap;
}
+17
View File
@@ -0,0 +1,17 @@
import type { Command, CommandView, Dispatch, EditorState } from '../state';
import { eventToCombo } from './normalize';
/**
* Look up and run the command bound to a keydown event. Returns `true` when a
* command handled it (the caller should then `preventDefault`).
*/
export function runKeydown(
event: KeyboardEvent,
compiled: Map<string, Command>,
state: EditorState,
dispatch: Dispatch,
view: CommandView,
): boolean {
const command = compiled.get(eventToCombo(event));
return command ? command(state, dispatch, view) : false;
}
+5
View File
@@ -0,0 +1,5 @@
export * from './types';
export * from './normalize';
export * from './compile';
export * from './defaults';
export * from './dispatcher';
+52
View File
@@ -0,0 +1,52 @@
import type { Platform } from '../view/config';
const MOD_ORDER = ['Ctrl', 'Alt', 'Shift', 'Meta'] as const;
function modAlias(token: string, platform: Platform): string | null {
switch (token.toLowerCase()) {
case 'mod': return platform === 'mac' ? 'Meta' : 'Ctrl';
case 'cmd':
case 'command':
case 'meta': return 'Meta';
case 'ctrl':
case 'control': return 'Ctrl';
case 'alt':
case 'option': return 'Alt';
case 'shift': return 'Shift';
default: return null;
}
}
/**
* 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}.
*/
export function normalizeCombo(combo: string, platform: Platform): string {
const parts = combo.split(/[-+]/).map(part => part.trim()).filter(Boolean);
const key = parts.pop() ?? '';
const mods = new Set<string>();
for (const part of parts) {
const mod = modAlias(part, platform);
if (mod)
mods.add(mod);
}
const order = MOD_ORDER.filter(mod => mods.has(mod));
const normalizedKey = key.length === 1 ? key.toLowerCase() : key;
return [...order, normalizedKey].join('-');
}
/** Canonical combo string for a keydown event (matches {@link normalizeCombo}). */
export function eventToCombo(event: KeyboardEvent): string {
const mods: string[] = [];
if (event.ctrlKey) mods.push('Ctrl');
if (event.altKey) mods.push('Alt');
if (event.shiftKey) mods.push('Shift');
if (event.metaKey) mods.push('Meta');
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
return [...mods, key].join('-');
}
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from '../state';
/** A keymap: normalized (or human) key-combos mapped to commands. */
export type Keymap = Record<string, Command>;
+12
View File
@@ -0,0 +1,12 @@
import { defineMark } from '../registry';
export const bold = defineMark({
type: 'bold',
spec: {
inclusive: true,
rank: 1,
toDOM: () => ['strong', 0],
parseDOM: [{ tag: 'strong' }, { tag: 'b' }],
},
meta: { title: 'Bold', icon: 'bold', hotkey: 'Mod-b' },
});
+13
View File
@@ -0,0 +1,13 @@
import { defineMark } from '../registry';
export const code = defineMark({
type: 'code',
spec: {
inclusive: false,
rank: 9,
excludes: '_all', // inline code wins: it strips every other mark on the range
toDOM: () => ['code', 0],
parseDOM: [{ tag: 'code' }],
},
meta: { title: 'Inline code', icon: 'code', hotkey: 'Mod-e' },
});
+12
View File
@@ -0,0 +1,12 @@
import { defineMark } from '../registry';
export const highlight = defineMark({
type: 'highlight',
spec: {
inclusive: true,
rank: 5,
toDOM: () => ['mark', 0],
parseDOM: [{ tag: 'mark' }],
},
meta: { title: 'Highlight', icon: 'highlighter' },
});
+7
View File
@@ -0,0 +1,7 @@
export { bold } from './bold';
export { italic } from './italic';
export { underline } from './underline';
export { strike } from './strike';
export { highlight } from './highlight';
export { code } from './code';
export { link } from './link';
+12
View File
@@ -0,0 +1,12 @@
import { defineMark } from '../registry';
export const italic = defineMark({
type: 'italic',
spec: {
inclusive: true,
rank: 2,
toDOM: () => ['em', 0],
parseDOM: [{ tag: 'em' }, { tag: 'i' }],
},
meta: { title: 'Italic', icon: 'italic', hotkey: 'Mod-i' },
});
+31
View File
@@ -0,0 +1,31 @@
import type { Mark } from '../model';
import { defineMark } from '../registry';
export const link = defineMark({
type: 'link',
spec: {
inclusive: false, // typing past a link's end does not extend it
rank: 10,
attrs: {
href: { default: '' },
target: { default: '_blank' },
},
toDOM: (mark: Mark) => [
'a',
{
href: String(mark.attrs?.['href'] ?? ''),
target: String(mark.attrs?.['target'] ?? '_blank'),
rel: 'noopener noreferrer',
},
0,
],
parseDOM: [{
tag: 'a[href]',
getAttrs: (el: HTMLElement) => ({
href: el.getAttribute('href') ?? '',
target: el.getAttribute('target') ?? '_blank',
}),
}],
},
meta: { title: 'Link', icon: 'link', hotkey: 'Mod-k' },
});
+12
View File
@@ -0,0 +1,12 @@
import { defineMark } from '../registry';
export const strike = defineMark({
type: 'strike',
spec: {
inclusive: true,
rank: 4,
toDOM: () => ['s', 0],
parseDOM: [{ tag: 's' }, { tag: 'del' }],
},
meta: { title: 'Strikethrough', icon: 'strikethrough', hotkey: 'Mod-Shift-s' },
});
+12
View File
@@ -0,0 +1,12 @@
import { defineMark } from '../registry';
export const underline = defineMark({
type: 'underline',
spec: {
inclusive: true,
rank: 3,
toDOM: () => ['u', 0],
parseDOM: [{ tag: 'u' }],
},
meta: { title: 'Underline', icon: 'underline', hotkey: 'Mod-u' },
});
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import type { Inline } from '../inline';
import {
addMarkInline,
deleteTextInline,
inlineText,
insertTextInline,
marksAt,
normalizeInline,
removeMarkInline,
} from '../inline';
const bold = { type: 'bold' };
describe('inline', () => {
it('merges adjacent equal-mark runs and drops empties', () => {
const input: Inline = [{ text: 'a', marks: [] }, { text: '', marks: [] }, { text: 'b', marks: [] }];
expect(normalizeInline(input)).toEqual([{ text: 'ab', marks: [] }]);
});
it('inserts text at an offset', () => {
expect(inlineText(insertTextInline([{ text: 'ac', marks: [] }], 1, 'b', []))).toBe('abc');
});
it('adds a mark over a range, splitting runs', () => {
expect(addMarkInline([{ text: 'abc', marks: [] }], 1, 2, bold)).toEqual([
{ text: 'a', marks: [] },
{ text: 'b', marks: [bold] },
{ text: 'c', marks: [] },
]);
});
it('removes a mark over a range', () => {
expect(removeMarkInline([{ text: 'abc', marks: [bold] }], 0, 3, 'bold')).toEqual([{ text: 'abc', marks: [] }]);
});
it('deletes a range', () => {
expect(inlineText(deleteTextInline([{ text: 'abcd', marks: [] }], 1, 3))).toBe('ad');
});
it('reads marks at a caret as the preceding character marks', () => {
const marked: Inline = [{ text: 'ab', marks: [bold] }, { text: 'c', marks: [] }];
expect(marksAt(marked, 2)).toEqual([bold]);
expect(marksAt(marked, 3)).toEqual([]);
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* Attribute values are JSON-serializable so documents round-trip losslessly and
* a CRDT adapter can map them onto its own primitives without special-casing.
*/
export type AttrValue
= | string
| number
| boolean
| null
| readonly AttrValue[]
| { readonly [key: string]: AttrValue };
export type Attrs = Readonly<Record<string, AttrValue>>;
/**
* Structural equality for two attribute values. Order-insensitive for object
* keys, deep for arrays/objects. Used by mark/attr deduplication and tests.
*/
export function attrValueEq(a: AttrValue | undefined, b: AttrValue | undefined): boolean {
if (a === b)
return true;
if (a === null || b === null || a === undefined || b === undefined)
return a === b;
const aArr = Array.isArray(a);
const bArr = Array.isArray(b);
if (aArr || bArr) {
if (!aArr || !bArr || a.length !== b.length)
return false;
return a.every((v, i) => attrValueEq(v, b[i]));
}
if (typeof a === 'object' && typeof b === 'object')
return attrsEq(a as Attrs, b as Attrs);
return false;
}
/**
* Structural equality for attribute bags. `undefined` and `{}` are equivalent
* so `{ type: 'bold' }` equals `{ type: 'bold', attrs: {} }`.
*/
export function attrsEq(a?: Attrs, b?: Attrs): boolean {
if (a === b)
return true;
const aKeys = a ? Object.keys(a) : [];
const bKeys = b ? Object.keys(b) : [];
if (aKeys.length !== bKeys.length)
return false;
return aKeys.every(key => attrValueEq(a![key], b?.[key]));
}
+59
View File
@@ -0,0 +1,59 @@
import type { Node } from './node';
/**
* 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.
*/
export interface EditorDocument {
readonly type: 'doc';
readonly content: readonly Node[];
}
/** Construct a document from blocks. */
export function createDoc(content: readonly Node[] = []): EditorDocument {
return { type: 'doc', content };
}
/** Index of a block by id, or `-1` if absent. */
export function blockIndex(doc: EditorDocument, id: string): number {
return doc.content.findIndex(block => block.id === id);
}
/** A block and its index, or `null` if absent. */
export function findBlock(doc: EditorDocument, id: string): { node: Node; index: number } | null {
const index = blockIndex(doc, id);
return index === -1 ? null : { node: doc.content[index]!, index };
}
/** A block by id, or `null`. */
export function blockById(doc: EditorDocument, id: string): Node | null {
return doc.content.find(block => block.id === id) ?? null;
}
/** The block before `id` in document order, or `null`. */
export function previousBlock(doc: EditorDocument, id: string): Node | null {
const index = blockIndex(doc, id);
return index > 0 ? doc.content[index - 1]! : null;
}
/** The block after `id` in document order, or `null`. */
export function nextBlock(doc: EditorDocument, id: string): Node | null {
const index = blockIndex(doc, id);
return index !== -1 && index < doc.content.length - 1 ? doc.content[index + 1]! : null;
}
/** First block, or `null` for an empty document. */
export function firstBlock(doc: EditorDocument): Node | null {
return doc.content[0] ?? null;
}
/** Last block, or `null` for an empty document. */
export function lastBlock(doc: EditorDocument): Node | null {
return doc.content[doc.content.length - 1] ?? null;
}
/** Return a copy of `doc` with a different block list. */
export function replaceBlocks(doc: EditorDocument, content: readonly Node[]): EditorDocument {
return { ...doc, content };
}
+13
View File
@@ -0,0 +1,13 @@
/**
* 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.
*/
export function createId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
return crypto.randomUUID();
// Fallback for exotic runtimes without WebCrypto (Node >= 19 and all target
// browsers provide `crypto.randomUUID`, so this is effectively dead code).
return `b-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
}
+8
View File
@@ -0,0 +1,8 @@
export * from './attrs';
export * from './marks';
export * from './inline';
export * from './id';
export * from './node';
export * from './document';
export * from './position';
export * from './selection';
+177
View File
@@ -0,0 +1,177 @@
import type { Mark, Marks } from './marks';
import { marksEq, normalizeMarks } from './marks';
/**
* 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.
*/
export interface InlineNode {
readonly text: string;
readonly marks: Marks;
}
/** A block's inline content: an ordered, normalized list of runs. */
export type Inline = readonly InlineNode[];
/** Total length of inline content in UTF-16 code units (DOM-offset compatible). */
export function inlineLength(inline: Inline): number {
let length = 0;
for (const run of inline)
length += run.text.length;
return length;
}
/** Concatenated plain text of inline content. */
export function inlineText(inline: Inline): string {
let text = '';
for (const run of inline)
text += run.text;
return text;
}
/**
* One UTF-16 code unit carrying its marks. Operations explode inline content to
* chars, splice, then regroup obviously correct and cheap for small blocks.
* UTF-16 units (not code points) keep offsets aligned with the DOM.
*/
interface Char {
readonly ch: string;
readonly marks: Marks;
}
function toChars(inline: Inline): Char[] {
const chars: Char[] = [];
for (const run of inline) {
const marks = normalizeMarks(run.marks);
for (let i = 0; i < run.text.length; i++)
chars.push({ ch: run.text[i]!, marks });
}
return chars;
}
function fromChars(chars: readonly Char[]): Inline {
const runs: InlineNode[] = [];
for (const { ch, marks } of chars) {
const last = runs[runs.length - 1];
if (last && marksEq(last.marks, marks))
runs[runs.length - 1] = { text: last.text + ch, marks: last.marks };
else
runs.push({ text: ch, marks });
}
return runs;
}
/**
* 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.
*/
export function normalizeInline(inline: Inline): Inline {
return fromChars(toChars(inline));
}
/** Inline slice between two character offsets `[from, to)`. */
export function sliceInline(inline: Inline, from: number, to: number): Inline {
return fromChars(toChars(inline).slice(from, to));
}
/** Insert `text` (carrying `marks`) at character `offset`. */
export function insertTextInline(inline: Inline, offset: number, text: string, marks: Marks): Inline {
if (text.length === 0)
return normalizeInline(inline);
const chars = toChars(inline);
const normalized = normalizeMarks(marks);
const inserted: Char[] = [];
for (let i = 0; i < text.length; i++)
inserted.push({ ch: text[i]!, marks: normalized });
chars.splice(offset, 0, ...inserted);
return fromChars(chars);
}
/** Insert inline `content` (preserving its marks) at character `offset`. */
export function insertInline(inline: Inline, offset: number, content: Inline): Inline {
const chars = toChars(inline);
chars.splice(offset, 0, ...toChars(content));
return fromChars(chars);
}
/** Replace the character range `[from, to)` with inline `content`. */
export function replaceInline(inline: Inline, from: number, to: number, content: Inline): Inline {
const chars = toChars(inline);
chars.splice(from, to - from, ...toChars(content));
return fromChars(chars);
}
/** Delete the character range `[from, to)`. */
export function deleteTextInline(inline: Inline, from: number, to: number): Inline {
const chars = toChars(inline);
chars.splice(from, to - from);
return fromChars(chars);
}
/** Add `mark` across `[from, to)`, replacing any existing mark of the same type. */
export function addMarkInline(inline: Inline, from: number, to: number, mark: Mark): Inline {
const chars = toChars(inline);
for (let i = from; i < to && i < chars.length; i++) {
const current = chars[i]!;
chars[i] = { ch: current.ch, marks: normalizeMarks([...current.marks.filter(m => m.type !== mark.type), mark]) };
}
return fromChars(chars);
}
/** Remove every mark of `markType` across `[from, to)`. */
export function removeMarkInline(inline: Inline, from: number, to: number, markType: string): Inline {
const chars = toChars(inline);
for (let i = from; i < to && i < chars.length; i++) {
const current = chars[i]!;
chars[i] = { ch: current.ch, marks: current.marks.filter(m => m.type !== markType) };
}
return fromChars(chars);
}
/**
* 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.
*/
export function marksAt(inline: Inline, offset: number): Marks {
const chars = toChars(inline);
if (chars.length === 0)
return [];
const index = offset > 0 ? offset - 1 : 0;
return chars[Math.min(index, chars.length - 1)]?.marks ?? [];
}
/** Whether the whole range `[from, to)` carries a mark of `markType`. */
export function rangeHasMarkType(inline: Inline, from: number, to: number, markType: string): boolean {
const chars = toChars(inline);
if (from >= to)
return false;
for (let i = from; i < to && i < chars.length; i++) {
if (!chars[i]!.marks.some(m => m.type === markType))
return false;
}
return true;
}
+57
View File
@@ -0,0 +1,57 @@
import type { Attrs } from './attrs';
import { attrsEq } from './attrs';
/**
* 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).
*/
export interface Mark {
readonly type: string;
readonly attrs?: Attrs;
}
/** A normalized set of marks: at most one per type, sorted by `type`. */
export type Marks = readonly Mark[];
/** Structural equality for two marks (type + attrs). */
export function markEq(a: Mark, b: Mark): boolean {
return a.type === b.type && attrsEq(a.attrs, b.attrs);
}
/** Ordered structural equality for two normalized mark sets. */
export function marksEq(a: Marks, b: Marks): boolean {
if (a === b)
return true;
if (a.length !== b.length)
return false;
return a.every((mark, i) => markEq(mark, b[i]!));
}
/**
* 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.
*/
export function normalizeMarks(marks: Marks): Marks {
if (marks.length <= 1)
return marks;
const byType = new Map<string, Mark>();
for (const mark of marks)
byType.set(mark.type, mark);
return [...byType.values()].sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0));
}
/** Whether `marks` contains a mark structurally equal to `mark`. */
export function hasMark(marks: Marks, mark: Mark): boolean {
return marks.some(m => markEq(m, mark));
}
/** Whether `marks` contains any mark of the given `type`. */
export function hasMarkType(marks: Marks, type: string): boolean {
return marks.some(m => m.type === type);
}
+69
View File
@@ -0,0 +1,69 @@
import type { Attrs } from './attrs';
import type { Inline } from './inline';
import { inlineText } from './inline';
import { createId } from './id';
/**
* 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).
*/
export type Content = Inline | readonly Node[] | null;
/** A document block. `id` is stable across split/merge/move. */
export interface Node {
readonly id: string;
readonly type: string;
readonly attrs: Attrs;
readonly content: Content;
}
export interface CreateNodeOptions {
readonly id?: string;
readonly attrs?: Attrs;
readonly content?: Content;
}
/** Construct a {@link Node}, generating an id when not supplied. */
export function createNode(type: string, options: CreateNodeOptions = {}): Node {
return {
id: options.id ?? createId(),
type,
attrs: options.attrs ?? {},
content: options.content ?? null,
};
}
/**
* Best-effort runtime check for inline (text-block) content. The authoritative
* answer comes from the schema; this is a convenience for model-level helpers.
*/
export function isInlineContent(content: Content): content is Inline {
return Array.isArray(content) && (content.length === 0 || 'text' in (content[0] as object));
}
/** Inline content of a node, or `[]` when the node is not a text block. */
export function nodeInline(node: Node): Inline {
return isInlineContent(node.content) ? node.content : [];
}
/** Plain text of a node, or `''` when the node has no inline content. */
export function nodeText(node: Node): string {
return isInlineContent(node.content) ? inlineText(node.content) : '';
}
/** Return a copy of `node` with new content. */
export function withContent(node: Node, content: Content): Node {
return { ...node, content };
}
/** Return a copy of `node` with new attrs. */
export function withAttrs(node: Node, attrs: Attrs): Node {
return { ...node, attrs };
}
/** Return a copy of `node` with a new type (and optionally new attrs). */
export function withType(node: Node, type: string, attrs?: Attrs): Node {
return { ...node, type, attrs: attrs ?? node.attrs };
}
+19
View File
@@ -0,0 +1,19 @@
/**
* 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.
*/
export interface Position {
readonly blockId: string;
readonly offset: number;
}
/** Construct a {@link Position}. */
export function position(blockId: string, offset: number): Position {
return { blockId, offset };
}
/** Whether two positions address the same block and offset. */
export function positionEq(a: Position, b: Position): boolean {
return a.blockId === b.blockId && a.offset === b.offset;
}
+82
View File
@@ -0,0 +1,82 @@
import type { Position } from './position';
import { positionEq } from './position';
import type { EditorDocument } from './document';
import { blockIndex } from './document';
/** A text selection: caret when `anchor === focus`, range otherwise. May span blocks. */
export interface TextSelection {
readonly kind: 'text';
readonly anchor: Position;
readonly focus: Position;
}
/** A block-level selection of one or more whole blocks (atoms, Mod+A stage 2). */
export interface NodeSelection {
readonly kind: 'node';
readonly ids: readonly string[];
}
export type Selection = TextSelection | NodeSelection;
/** Construct a text selection (focus defaults to anchor → collapsed caret). */
export function textSelection(anchor: Position, focus: Position = anchor): TextSelection {
return { kind: 'text', anchor, focus };
}
/** Construct a collapsed caret selection. */
export function caret(blockId: string, offset: number): TextSelection {
const point: Position = { blockId, offset };
return { kind: 'text', anchor: point, focus: point };
}
/** Construct a block-level selection. */
export function nodeSelection(ids: readonly string[]): NodeSelection {
return { kind: 'node', ids };
}
export function isTextSelection(sel: Selection): sel is TextSelection {
return sel.kind === 'text';
}
export function isNodeSelection(sel: Selection): sel is NodeSelection {
return sel.kind === 'node';
}
/** Whether the selection is a collapsed caret. */
export function isCollapsed(sel: Selection): boolean {
return sel.kind === 'text' && positionEq(sel.anchor, sel.focus);
}
/** Whether the selection spans more than one block. */
export function isAcrossBlocks(sel: Selection): boolean {
return sel.kind === 'text' && sel.anchor.blockId !== sel.focus.blockId;
}
/**
* Endpoints of a text selection in document order (`from` before `to`). Within
* one block they are ordered by offset; across blocks by block index.
*/
export function orderedSelection(sel: TextSelection, doc: EditorDocument): { from: Position; to: Position } {
const { anchor, focus } = sel;
if (anchor.blockId === focus.blockId)
return anchor.offset <= focus.offset ? { from: anchor, to: focus } : { from: focus, to: anchor };
return blockIndex(doc, anchor.blockId) <= blockIndex(doc, focus.blockId)
? { from: anchor, to: focus }
: { from: focus, to: anchor };
}
/** Structural equality for two selections. */
export function selectionEq(a: Selection, b: Selection): boolean {
if (a.kind !== b.kind)
return false;
if (a.kind === 'text' && b.kind === 'text')
return positionEq(a.anchor, b.anchor) && positionEq(a.focus, b.focus);
if (a.kind === 'node' && b.kind === 'node')
return a.ids.length === b.ids.length && a.ids.every((id, i) => id === b.ids[i]);
return false;
}
+44
View File
@@ -0,0 +1,44 @@
import { createRegistry } from './registry';
import {
blockquote,
bulletedList,
callout,
codeBlock,
divider,
heading,
image,
numberedList,
paragraph,
todoList,
} from './blocks';
import { bold, code, highlight, italic, link, strike, underline } from './marks';
/** The block definitions bundled in the default preset (registration order = menu order). */
export const defaultBlocks = [
paragraph,
heading,
blockquote,
codeBlock,
callout,
bulletedList,
numberedList,
todoList,
divider,
image,
];
/** The mark definitions bundled in the default preset. */
export const defaultMarks = [bold, italic, underline, strike, highlight, code, link];
/** Batteries-included registry with the default blocks and marks. */
export function createDefaultRegistry() {
return createRegistry({ blocks: defaultBlocks, marks: defaultMarks });
}
/** Lightweight registry — basic text blocks and the common marks only. */
export function createBasicRegistry() {
return createRegistry({
blocks: [paragraph, heading, blockquote, bulletedList, numberedList],
marks: [bold, italic, link],
});
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { heading, paragraph } from '../../blocks';
import { bold } from '../../marks';
import { createRegistry } from '../registry';
describe('registry', () => {
it('projects a schema from definitions', () => {
const registry = createRegistry({ blocks: [paragraph, heading], marks: [bold] });
expect(registry.schema.nodeSpec('paragraph')).toBeDefined();
expect(registry.schema.markSpec('bold')).toBeDefined();
expect(registry.getBlock('heading')?.meta?.title).toBe('Heading');
expect(registry.listBlocks().map(block => block.type)).toEqual(['paragraph', 'heading']);
});
it('throws on a duplicate type by default', () => {
expect(() => createRegistry({ blocks: [paragraph, paragraph] })).toThrow();
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { Component } from 'vue';
import type { Attrs, Content, Node } from '../model';
import type { NodeSpec } from '../schema';
import type { CommandFactory } from '../state/command';
import type { InputRuleSpec } from './input-rule';
/** Props passed to an atom/void block's Vue `component`. */
export interface BlockComponentProps {
/** The block's model node (read its `attrs`). */
node: Node;
/** Whether the block is currently node-selected. */
selected: boolean;
/** Editor-level editable flag. */
editable: boolean;
/** Merge new attrs into the block (e.g. image src/caption). */
update: (attrs: Attrs) => void;
}
/** Presentational/discovery metadata: powers slash menu, conversion, toolbars. */
export interface BlockMeta {
readonly title: string;
readonly icon?: string;
readonly keywords?: readonly string[];
readonly group?: string;
}
/** Optional block-specific behaviors used by core commands. */
export interface BlockBehavior {
/** Content for a fresh empty block of this type (defaults to empty inline). */
readonly empty?: () => Content;
/** Plain-text extraction (defaults to inline text). */
readonly toText?: (node: Node) => string;
}
/**
* 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).
*/
export interface BlockDefinition {
readonly type: string;
readonly spec: NodeSpec;
readonly component?: Component;
readonly meta?: BlockMeta;
readonly behavior?: BlockBehavior;
readonly commands?: Record<string, CommandFactory>;
/** Wrapper element tag for the block (default `'div'`). */
readonly as?: string;
/** Placeholder text shown when an empty text block has focus. */
readonly placeholder?: string;
readonly inputRules?: readonly InputRuleSpec[];
}
/** Identity factory that narrows a block definition's literal type (cf. `definePlugin`). */
export function defineBlock<const D extends BlockDefinition>(def: D): D {
return def;
}
+26
View File
@@ -0,0 +1,26 @@
import type { MarkSpec } from '../schema';
import type { InputRuleSpec } from './input-rule';
/** Presentational/discovery metadata for a mark (toolbar label, shortcut hint). */
export interface MarkMeta {
readonly title: string;
readonly icon?: string;
readonly hotkey?: string;
}
/**
* 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.
*/
export interface MarkDefinition {
readonly type: string;
readonly spec: MarkSpec;
readonly meta?: MarkMeta;
readonly inputRules?: readonly InputRuleSpec[];
}
/** Identity factory that narrows a mark definition's literal type (cf. `definePlugin`). */
export function defineMark<const D extends MarkDefinition>(def: D): D {
return def;
}
+4
View File
@@ -0,0 +1,4 @@
export * from './input-rule';
export * from './define-block';
export * from './define-mark';
export * from './registry';
+15
View File
@@ -0,0 +1,15 @@
import type { Attrs } from '../model';
/**
* 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.
*/
export interface InputRuleSpec {
/** Pattern tested against the text ending at the caret. */
readonly match: RegExp;
/** Target block/mark type to apply on match. */
readonly type?: string;
/** Attrs to apply with the transformation. */
readonly attrs?: Attrs;
}
+99
View File
@@ -0,0 +1,99 @@
import type { MarkSpec, NodeSpec, Schema } from '../schema';
import { createSchema } from '../schema';
import type { BlockDefinition } from './define-block';
import type { MarkDefinition } from './define-mark';
/** How to resolve two definitions registered under the same type. */
export type ConflictPolicy = 'throw' | 'last-wins' | 'first-wins';
/**
* 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.
*/
export interface Registry {
readonly blocks: ReadonlyMap<string, BlockDefinition>;
readonly marks: ReadonlyMap<string, MarkDefinition>;
readonly schema: Schema;
getBlock: (type: string) => BlockDefinition | undefined;
getMark: (type: string) => MarkDefinition | undefined;
/** Definitions in registration order (drives slash menu / toolbars). */
listBlocks: () => readonly BlockDefinition[];
listMarks: () => readonly MarkDefinition[];
/** Alias of {@link listMarks}, for the inline renderer/parser. */
allMarks: () => readonly MarkDefinition[];
hasBlock: (type: string) => boolean;
hasMark: (type: string) => boolean;
}
export interface CreateRegistryOptions {
readonly blocks?: readonly BlockDefinition[];
readonly marks?: readonly MarkDefinition[];
readonly onConflict?: ConflictPolicy;
}
function buildMap<D extends { readonly type: string }>(
items: readonly D[],
onConflict: ConflictPolicy,
kind: string,
): Map<string, D> {
const map = new Map<string, D>();
for (const item of items) {
if (map.has(item.type)) {
if (onConflict === 'throw')
throw new Error(`Editor registry: duplicate ${kind} type '${item.type}'`);
if (onConflict === 'first-wins')
continue;
}
map.set(item.type, item);
}
return map;
}
/** Build an immutable {@link Registry} from block and mark definitions. */
export function createRegistry(options: CreateRegistryOptions = {}): Registry {
const onConflict = options.onConflict ?? 'throw';
const blocks = buildMap(options.blocks ?? [], onConflict, 'block');
const marks = buildMap(options.marks ?? [], onConflict, 'mark');
const nodeSpecs = new Map<string, NodeSpec>();
for (const [type, def] of blocks)
nodeSpecs.set(type, def.spec);
const markSpecs = new Map<string, MarkSpec>();
for (const [type, def] of marks)
markSpecs.set(type, def.spec);
const schema = createSchema({ nodes: nodeSpecs, marks: markSpecs });
const blockList = [...blocks.values()];
const markList = [...marks.values()];
return {
blocks,
marks,
schema,
getBlock: type => blocks.get(type),
getMark: type => marks.get(type),
listBlocks: () => blockList,
listMarks: () => markList,
allMarks: () => markList,
hasBlock: type => blocks.has(type),
hasMark: type => marks.has(type),
};
}
/** Return a new registry extending `base` with extra blocks/marks (override wins). */
export function extendRegistry(
base: Registry,
add: { blocks?: readonly BlockDefinition[]; marks?: readonly MarkDefinition[]; onConflict?: ConflictPolicy },
): Registry {
return createRegistry({
blocks: [...base.listBlocks(), ...(add.blocks ?? [])],
marks: [...base.listMarks(), ...(add.marks ?? [])],
onConflict: add.onConflict ?? 'last-wins',
});
}
+11
View File
@@ -0,0 +1,11 @@
import type { AttrValue } from '../model';
/** Specification for a single attribute: default, requiredness, validation. */
export interface AttrSpec<V extends AttrValue = AttrValue> {
readonly default?: V;
readonly required?: boolean;
readonly validate?: (value: unknown) => boolean;
}
/** Map of attribute name → {@link AttrSpec}. */
export type AttrsSpec = Readonly<Record<string, AttrSpec>>;
+12
View File
@@ -0,0 +1,12 @@
/**
* 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).
*/
export type ContentKind
= | { readonly kind: 'text'; readonly marks?: 'all' | 'none' | readonly string[] }
| { readonly kind: 'container'; readonly allow?: readonly string[]; readonly group?: string }
| { readonly kind: 'atom' };
+39
View File
@@ -0,0 +1,39 @@
import type { Attrs } from '../model';
/** Placeholder marking where a node/mark's content should be spliced in. */
export type DOMOutputHole = 0;
export type DOMOutputChild = DOMOutputSpec | DOMOutputHole;
/**
* 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.
*/
export type DOMOutputSpec = string | DOMOutputArray;
export interface DOMOutputArray extends ReadonlyArray<string | Record<string, string> | DOMOutputChild> {}
/**
* 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.
*/
export interface ParseRule {
/** CSS selector to match, e.g. `'a[href]'`, `'strong'`, `'h1'`. */
readonly tag?: string;
/** Inline-style match, e.g. `'font-weight=700'` (reserved for M2). */
readonly style?: string;
/** Static attrs applied when the rule matches. */
readonly attrs?: Attrs;
/** Derive attrs from the matched element; `false`/`null` rejects the match. */
readonly getAttrs?: (el: HTMLElement) => Attrs | false | null;
/** Higher priority rules are tried first. */
readonly priority?: number;
}
+8
View File
@@ -0,0 +1,8 @@
export * from './attr-spec';
export * from './content-kind';
export * from './dom';
export * from './node-spec';
export * from './mark-spec';
export * from './schema';
export * from './validate';
export * from './normalize';
+19
View File
@@ -0,0 +1,19 @@
import type { Mark } from '../model';
import type { AttrsSpec } from './attr-spec';
import type { DOMOutputSpec, ParseRule } from './dom';
/** Schema contribution of a mark type. */
export interface MarkSpec {
/** Attribute specs (defaults + validation), e.g. link `href`. */
readonly attrs?: AttrsSpec;
/** Whether typing at the mark's boundary extends it (bold yes, link no). */
readonly inclusive?: boolean;
/** Marks that cannot coexist with this one; `'_all'` excludes every other. */
readonly excludes?: readonly string[] | '_all';
/** Nesting order in {@link DOMOutputSpec}: lower = outer wrapper. */
readonly rank?: number;
/** Serialize the mark to a DOM description wrapping its content. */
readonly toDOM: (mark: Mark) => DOMOutputSpec;
/** Rules for parsing DOM into this mark (paste / import). */
readonly parseDOM: readonly ParseRule[];
}
+24
View File
@@ -0,0 +1,24 @@
import type { Node } from '../model';
import type { AttrsSpec } from './attr-spec';
import type { ContentKind } from './content-kind';
import type { DOMOutputSpec, ParseRule } from './dom';
/** Schema contribution of a block type. */
export interface NodeSpec {
/** Content model (text / container / atom). */
readonly content: ContentKind;
/** Attribute specs (defaults + validation). */
readonly attrs?: AttrsSpec;
/** Group name for membership tests (e.g. `'block'`, `'list'`). */
readonly group?: string;
/** Keep this block's type/identity when merged into (e.g. code-block). */
readonly defining?: boolean;
/** Raw multiline text: Enter inserts a newline instead of splitting (code-block). */
readonly code?: boolean;
/** Selection and merge cannot cross this block's boundary. */
readonly isolating?: boolean;
/** Serialize a node of this type to a DOM description (HTML export). */
readonly toDOM?: (node: Node) => DOMOutputSpec;
/** Rules for parsing DOM into a node of this type (paste / import). */
readonly parseDOM?: readonly ParseRule[];
}
+44
View File
@@ -0,0 +1,44 @@
import type { EditorDocument, Inline, Node } from '../model';
import { isInlineContent, normalizeInline, replaceBlocks } from '../model';
import type { NodeSpec } from './node-spec';
import type { Schema } from './schema';
import { marksAllowed } from './schema';
function filterRunMarks(inline: Inline, spec: NodeSpec, schema: Schema): Inline {
return inline.map(run => ({
text: run.text,
marks: run.marks.filter(mark => schema.markSpec(mark.type) !== undefined && marksAllowed(spec, mark.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.
*/
export function normalizeDocument(doc: EditorDocument, schema: Schema): EditorDocument {
const content: Node[] = [];
for (const block of doc.content) {
const spec = schema.nodeSpec(block.type);
if (!spec)
continue; // drop unknown block types
const attrs = schema.coerceAttrs(block.type, block.attrs);
if (spec.content.kind === 'text') {
const inline = isInlineContent(block.content) ? block.content : [];
content.push({ ...block, attrs, content: normalizeInline(filterRunMarks(inline, spec, schema)) });
}
else if (spec.content.kind === 'atom') {
content.push({ ...block, attrs, content: block.content ?? null });
}
else {
content.push({ ...block, attrs });
}
}
return replaceBlocks(doc, content);
}
+91
View File
@@ -0,0 +1,91 @@
import type { AttrValue, Attrs } from '../model';
import type { AttrsSpec } from './attr-spec';
import type { NodeSpec } from './node-spec';
import type { MarkSpec } from './mark-spec';
/**
* The compiled schema: the set of known node/mark specs plus attribute
* coercion helpers. Projected from the registry (the registry is the SSOT).
*/
export interface Schema {
readonly nodes: ReadonlyMap<string, NodeSpec>;
readonly marks: ReadonlyMap<string, MarkSpec>;
nodeSpec: (type: string) => NodeSpec | undefined;
markSpec: (type: string) => MarkSpec | undefined;
/** Default attrs for a block type (all defaults applied). */
defaultAttrs: (type: string) => Attrs;
/** Fill defaults and drop unknown keys for a block type. */
coerceAttrs: (type: string, attrs?: Attrs) => Attrs;
/** Default attrs for a mark type. */
defaultMarkAttrs: (type: string) => Attrs;
/** Fill defaults and drop unknown keys for a mark type. */
coerceMarkAttrs: (type: string, attrs?: Attrs) => Attrs;
}
function coerceWithSpec(spec: AttrsSpec | undefined, attrs?: Attrs): Attrs {
if (!spec)
return {};
const result: Record<string, AttrValue> = {};
for (const key in spec) {
const provided = attrs?.[key];
if (provided !== undefined)
result[key] = provided;
else if (spec[key]!.default !== undefined)
result[key] = spec[key]!.default!;
}
return result;
}
/** Build a {@link Schema} from node and mark spec maps. */
export function createSchema(input: {
nodes: ReadonlyMap<string, NodeSpec>;
marks: ReadonlyMap<string, MarkSpec>;
}): Schema {
const { nodes, marks } = input;
return {
nodes,
marks,
nodeSpec: type => nodes.get(type),
markSpec: type => marks.get(type),
defaultAttrs: type => coerceWithSpec(nodes.get(type)?.attrs),
coerceAttrs: (type, attrs) => coerceWithSpec(nodes.get(type)?.attrs, attrs),
defaultMarkAttrs: type => coerceWithSpec(marks.get(type)?.attrs),
coerceMarkAttrs: (type, attrs) => coerceWithSpec(marks.get(type)?.attrs, attrs),
};
}
/** Whether a block spec holds inline (text) content. */
export function isTextBlock(spec: NodeSpec): boolean {
return spec.content.kind === 'text';
}
/** Whether a block spec is an atom/void block. */
export function isAtomBlock(spec: NodeSpec): boolean {
return spec.content.kind === 'atom';
}
/** Whether a block spec is a container of child blocks. */
export function isContainerBlock(spec: NodeSpec): boolean {
return spec.content.kind === 'container';
}
/** Whether a mark of `markType` is allowed inside a block with this spec. */
export function marksAllowed(spec: NodeSpec, markType: string): boolean {
if (spec.content.kind !== 'text')
return false;
const allowed = spec.content.marks;
if (allowed === undefined || allowed === 'all')
return true;
if (allowed === 'none')
return false;
return allowed.includes(markType);
}
+42
View File
@@ -0,0 +1,42 @@
import type { EditorDocument } from '../model';
import type { Schema } from './schema';
export interface ValidationResult {
readonly valid: boolean;
readonly errors: readonly string[];
}
/**
* 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.
*/
export function validateDocument(doc: EditorDocument, schema: Schema): ValidationResult {
const errors: string[] = [];
for (const block of doc.content) {
const spec = schema.nodeSpec(block.type);
if (!spec) {
errors.push(`unknown block type: '${block.type}'`);
continue;
}
if (!spec.attrs)
continue;
for (const key in spec.attrs) {
const attr = spec.attrs[key]!;
const value = block.attrs[key];
if (attr.required && value === undefined && attr.default === undefined)
errors.push(`block '${block.type}' is missing required attr '${key}'`);
if (attr.validate && value !== undefined && !attr.validate(value))
errors.push(`block '${block.type}' has invalid attr '${key}'`);
}
}
return { valid: errors.length === 0, errors };
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { createDoc, createNode, nodeText } from '../../model';
import { createDefaultRegistry } from '../../preset';
import { applyStep } from '../step';
const schema = createDefaultRegistry().schema;
function para(id: string, text: string) {
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
}
describe('applyStep', () => {
it('inserts and inverts to the original', () => {
const doc = createDoc([para('a', 'hi')]);
const inserted = applyStep(doc, { type: 'insertInline', blockId: 'a', offset: 2, content: [{ text: '!', marks: [] }] }, schema);
expect(nodeText(inserted.doc.content[0]!)).toBe('hi!');
const back = applyStep(inserted.doc, inserted.inverted, schema);
expect(nodeText(back.doc.content[0]!)).toBe('hi');
});
it('splits a block, and its inverse merges back', () => {
const doc = createDoc([para('a', 'hello')]);
const split = applyStep(doc, { type: 'splitBlock', blockId: 'a', offset: 2, newId: 'b' }, schema);
expect(split.doc.content.map(block => nodeText(block))).toEqual(['he', 'llo']);
const merged = applyStep(split.doc, split.inverted, schema);
expect(merged.doc.content.map(block => nodeText(block))).toEqual(['hello']);
});
it('moves a block, and its inverse restores order', () => {
const doc = createDoc([para('a', '1'), para('b', '2'), para('c', '3')]);
const moved = applyStep(doc, { type: 'moveBlock', blockId: 'a', toIndex: 2 }, schema);
expect(moved.doc.content.map(block => block.id)).toEqual(['b', 'c', 'a']);
const back = applyStep(moved.doc, moved.inverted, schema);
expect(back.doc.content.map(block => block.id)).toEqual(['a', 'b', 'c']);
});
it('adds a mark and inverts to the prior inline state', () => {
const doc = createDoc([para('a', 'abc')]);
const marked = applyStep(doc, { type: 'addMark', blockId: 'a', from: 0, to: 3, mark: { type: 'bold' } }, schema);
expect(marked.doc.content[0]!.content).toEqual([{ text: 'abc', marks: [{ type: 'bold' }] }]);
const back = applyStep(marked.doc, marked.inverted, schema);
expect(back.doc.content[0]!.content).toEqual([{ text: 'abc', marks: [] }]);
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { EditorState } from './editor-state';
import type { Transaction } from './transaction';
/** Applies a transaction, updating editor state and notifying subscribers. */
export type Dispatch = (tr: Transaction) => void;
/**
* 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.
*/
export interface CommandView {
focusBlock: (blockId: string, offset: number | 'start' | 'end') => void;
}
/**
* 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.
*/
export type Command = (state: EditorState, dispatch?: Dispatch, view?: CommandView) => boolean;
/** A parameterized command constructor. */
export type CommandFactory<Args extends readonly unknown[] = readonly unknown[]> = (...args: Args) => Command;
+55
View File
@@ -0,0 +1,55 @@
import type { EditorDocument, Marks, Selection } from '../model';
import { caret, createDoc, createNode, firstBlock, nodeSelection } from '../model';
import type { Registry } from '../registry';
import type { Schema } from '../schema';
import { normalizeDocument } from '../schema';
/** Immutable snapshot of everything the editor renders and commands read. */
export interface EditorState {
readonly doc: EditorDocument;
readonly selection: Selection;
readonly schema: Schema;
readonly registry: Registry;
/** Marks to apply to the next typed character (toggle-before-type). */
readonly storedMarks: Marks | null;
}
export interface CreateEditorStateOptions {
readonly registry: Registry;
readonly doc?: EditorDocument;
readonly selection?: Selection;
}
function defaultBlockType(registry: Registry): string | undefined {
if (registry.hasBlock('paragraph'))
return 'paragraph';
for (const def of registry.listBlocks()) {
if (def.spec.content.kind === 'text')
return def.type;
}
return registry.listBlocks()[0]?.type;
}
/**
* 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.
*/
export function createEditorState(options: CreateEditorStateOptions): EditorState {
const { registry } = options;
const schema = registry.schema;
let doc = normalizeDocument(options.doc ?? createDoc(), schema);
if (doc.content.length === 0) {
const type = defaultBlockType(registry);
if (type)
doc = createDoc([createNode(type, { attrs: schema.defaultAttrs(type) })]);
}
const first = firstBlock(doc);
const selection = options.selection ?? (first ? caret(first.id, 0) : nodeSelection([]));
return { doc, selection, schema, registry, storedMarks: null };
}
+119
View File
@@ -0,0 +1,119 @@
import { PubSub } from '@robonen/stdlib';
import { selectionEq } from '../model';
import type { Command, Dispatch } from './command';
import type { EditorState } from './editor-state';
import type { HistoryOptions } from './history';
import { createHistory } from './history';
import type { Transaction } from './transaction';
import { applyTransaction, createTransaction } from './transaction';
/**
* Editor event map. A `type` (not `interface`) so it satisfies the
* `Record<string, ...>` constraint of {@link PubSub}.
*/
export interface EditorEvents {
/** Fired for every applied transaction (local, undo/redo, or remote). */
transaction: (tr: Transaction, next: EditorState, prev: EditorState) => void;
/** Fired when the document changed. */
docChange: (next: EditorState, prev: EditorState) => void;
/** Fired when the selection changed. */
selectionChange: (next: EditorState, prev: EditorState) => void;
}
/**
* 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.
*/
export interface Editor {
readonly state: EditorState;
dispatch: Dispatch;
/** Run a command against the current state, dispatching if it applies. */
command: (cmd: Command) => boolean;
undo: () => boolean;
redo: () => boolean;
canUndo: () => boolean;
canRedo: () => boolean;
on: <K extends keyof EditorEvents>(event: K, listener: EditorEvents[K]) => void;
off: <K extends keyof EditorEvents>(event: K, listener: EditorEvents[K]) => void;
destroy: () => void;
}
export interface CreateEditorOptions {
readonly state: EditorState;
readonly history?: HistoryOptions;
}
/** Create an {@link Editor} around an initial state. */
export function createEditor(options: CreateEditorOptions): Editor {
let state = options.state;
// A mapped type (not the `interface`) satisfies PubSub's `Record<string, …>` constraint.
const bus = new PubSub<{ [K in keyof EditorEvents]: EditorEvents[K] }>();
const history = createHistory(options.history);
const dispatch: Dispatch = (tr) => {
const prev = state;
const next = applyTransaction(prev, tr);
state = next;
if (tr.meta.get('addToHistory') !== false && tr.steps.length > 0) {
history.record({
steps: tr.steps,
inverted: tr.inverted,
selectionBefore: prev.selection,
selectionAfter: next.selection,
});
}
bus.emit('transaction', tr, next, prev);
if (next.doc !== prev.doc)
bus.emit('docChange', next, prev);
if (!selectionEq(next.selection, prev.selection))
bus.emit('selectionChange', next, prev);
};
return {
get state() {
return state;
},
dispatch,
command: cmd => cmd(state, dispatch),
undo() {
const entry = history.undo();
if (!entry)
return false;
const tr = createTransaction(state);
for (let i = entry.inverted.length - 1; i >= 0; i--)
tr.step(entry.inverted[i]!);
tr.setSelection(entry.selectionBefore).setMeta('addToHistory', false).setMeta('history', 'undo');
dispatch(tr);
return true;
},
redo() {
const entry = history.redo();
if (!entry)
return false;
const tr = createTransaction(state);
for (const step of entry.steps)
tr.step(step);
tr.setSelection(entry.selectionAfter).setMeta('addToHistory', false).setMeta('history', 'redo');
dispatch(tr);
return true;
},
canUndo: history.canUndo,
canRedo: history.canRedo,
on(event, listener) {
bus.on(event, listener);
},
off(event, listener) {
bus.off(event, listener);
},
destroy() {
bus.clear('transaction').clear('docChange').clear('selectionChange');
history.clear();
},
};
}

Some files were not shown because too many files have changed in this diff Show More