diff --git a/vue/editor/AGENTS.md b/vue/editor/AGENTS.md new file mode 100644 index 0000000..cf3ebb6 --- /dev/null +++ b/vue/editor/AGENTS.md @@ -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` (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`. diff --git a/vue/editor/README.md b/vue/editor/README.md new file mode 100644 index 0000000..edbb328 --- /dev/null +++ b/vue/editor/README.md @@ -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 + + + +``` + +`EditorRoot`'s default slot renders `EditorContent` (the single contenteditable). Provide your own slot to add UI around it: + +```vue + + + + + +``` + +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` (1–6), `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 + + + + +``` + +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. diff --git a/vue/editor/eslint.config.ts b/vue/editor/eslint.config.ts new file mode 100644 index 0000000..a80a3ee --- /dev/null +++ b/vue/editor/eslint.config.ts @@ -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', + }, +}); diff --git a/vue/editor/package.json b/vue/editor/package.json new file mode 100644 index 0000000..b26908c --- /dev/null +++ b/vue/editor/package.json @@ -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 ", + "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:" + } +} diff --git a/vue/editor/playground/index.html b/vue/editor/playground/index.html new file mode 100644 index 0000000..6593fd6 --- /dev/null +++ b/vue/editor/playground/index.html @@ -0,0 +1,12 @@ + + + + + + @robonen/editor playground + + +
+ + + diff --git a/vue/editor/playground/package.json b/vue/editor/playground/package.json new file mode 100644 index 0000000..f91a63e --- /dev/null +++ b/vue/editor/playground/package.json @@ -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" + } +} diff --git a/vue/editor/playground/src/App.vue b/vue/editor/playground/src/App.vue new file mode 100644 index 0000000..7a92b52 --- /dev/null +++ b/vue/editor/playground/src/App.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/vue/editor/playground/src/Toolbar.vue b/vue/editor/playground/src/Toolbar.vue new file mode 100644 index 0000000..d6fe6df --- /dev/null +++ b/vue/editor/playground/src/Toolbar.vue @@ -0,0 +1,34 @@ + + + diff --git a/vue/editor/playground/src/demos/CollabDemo.vue b/vue/editor/playground/src/demos/CollabDemo.vue new file mode 100644 index 0000000..d8b8a6e --- /dev/null +++ b/vue/editor/playground/src/demos/CollabDemo.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/vue/editor/playground/src/demos/CommandsDemo.vue b/vue/editor/playground/src/demos/CommandsDemo.vue new file mode 100644 index 0000000..6c7cc4a --- /dev/null +++ b/vue/editor/playground/src/demos/CommandsDemo.vue @@ -0,0 +1,62 @@ + + + diff --git a/vue/editor/playground/src/demos/ComplexBlocksDemo.vue b/vue/editor/playground/src/demos/ComplexBlocksDemo.vue new file mode 100644 index 0000000..cb303f0 --- /dev/null +++ b/vue/editor/playground/src/demos/ComplexBlocksDemo.vue @@ -0,0 +1,102 @@ + + + diff --git a/vue/editor/playground/src/demos/CustomKeymapDemo.vue b/vue/editor/playground/src/demos/CustomKeymapDemo.vue new file mode 100644 index 0000000..8bd5bb4 --- /dev/null +++ b/vue/editor/playground/src/demos/CustomKeymapDemo.vue @@ -0,0 +1,36 @@ + + + diff --git a/vue/editor/playground/src/demos/ManyBlocksDemo.vue b/vue/editor/playground/src/demos/ManyBlocksDemo.vue new file mode 100644 index 0000000..9ed2771 --- /dev/null +++ b/vue/editor/playground/src/demos/ManyBlocksDemo.vue @@ -0,0 +1,25 @@ + + + diff --git a/vue/editor/playground/src/demos/MarksDemo.vue b/vue/editor/playground/src/demos/MarksDemo.vue new file mode 100644 index 0000000..bc37bd1 --- /dev/null +++ b/vue/editor/playground/src/demos/MarksDemo.vue @@ -0,0 +1,20 @@ + + + diff --git a/vue/editor/playground/src/demos/MultiEditorDemo.vue b/vue/editor/playground/src/demos/MultiEditorDemo.vue new file mode 100644 index 0000000..6f955af --- /dev/null +++ b/vue/editor/playground/src/demos/MultiEditorDemo.vue @@ -0,0 +1,25 @@ + + + diff --git a/vue/editor/playground/src/demos/ReadOnlyDemo.vue b/vue/editor/playground/src/demos/ReadOnlyDemo.vue new file mode 100644 index 0000000..f690608 --- /dev/null +++ b/vue/editor/playground/src/demos/ReadOnlyDemo.vue @@ -0,0 +1,18 @@ + + + diff --git a/vue/editor/playground/src/demos/RichTextDemo.vue b/vue/editor/playground/src/demos/RichTextDemo.vue new file mode 100644 index 0000000..9f1eb92 --- /dev/null +++ b/vue/editor/playground/src/demos/RichTextDemo.vue @@ -0,0 +1,33 @@ + + + diff --git a/vue/editor/playground/src/env.d.ts b/vue/editor/playground/src/env.d.ts new file mode 100644 index 0000000..f275bb2 --- /dev/null +++ b/vue/editor/playground/src/env.d.ts @@ -0,0 +1,11 @@ +export {}; + +declare global { + const __DEV__: boolean; +} + +declare module 'vue' { + interface HTMLAttributes { + [key: `data-${string}`]: unknown; + } +} diff --git a/vue/editor/playground/src/lib.ts b/vue/editor/playground/src/lib.ts new file mode 100644 index 0000000..dbe8763 --- /dev/null +++ b/vue/editor/playground/src/lib.ts @@ -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) }) }); +} diff --git a/vue/editor/playground/src/main.ts b/vue/editor/playground/src/main.ts new file mode 100644 index 0000000..684d042 --- /dev/null +++ b/vue/editor/playground/src/main.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue'; +import App from './App.vue'; + +createApp(App).mount('#app'); diff --git a/vue/editor/playground/tsconfig.json b/vue/editor/playground/tsconfig.json new file mode 100644 index 0000000..966ac28 --- /dev/null +++ b/vue/editor/playground/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.vue.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@editor": ["../src/index.ts"], + "@editor/*": ["../src/*"] + } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/vue/editor/playground/vite.config.ts b/vue/editor/playground/vite.config.ts new file mode 100644 index 0000000..b3507e9 --- /dev/null +++ b/vue/editor/playground/vite.config.ts @@ -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))], + }, + }, +})); diff --git a/vue/editor/src/__test__/interactive.test.ts b/vue/editor/src/__test__/interactive.test.ts new file mode 100644 index 0000000..83ca4b0 --- /dev/null +++ b/vue/editor/src/__test__/interactive.test.ts @@ -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 = '

hi

'; + 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(); + }); +}); diff --git a/vue/editor/src/__test__/selection-bridge.test.ts b/vue/editor/src/__test__/selection-bridge.test.ts new file mode 100644 index 0000000..41620ca --- /dev/null +++ b/vue/editor/src/__test__/selection-bridge.test.ts @@ -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 }); + } + }); +}); diff --git a/vue/editor/src/__test__/slash-items.test.ts b/vue/editor/src/__test__/slash-items.test.ts new file mode 100644 index 0000000..5970122 --- /dev/null +++ b/vue/editor/src/__test__/slash-items.test.ts @@ -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([]); + }); +}); diff --git a/vue/editor/src/blocks/DividerBlock.vue b/vue/editor/src/blocks/DividerBlock.vue new file mode 100644 index 0000000..c6d5352 --- /dev/null +++ b/vue/editor/src/blocks/DividerBlock.vue @@ -0,0 +1,9 @@ + + + diff --git a/vue/editor/src/blocks/ImageBlock.vue b/vue/editor/src/blocks/ImageBlock.vue new file mode 100644 index 0000000..9c2aad3 --- /dev/null +++ b/vue/editor/src/blocks/ImageBlock.vue @@ -0,0 +1,30 @@ + + + diff --git a/vue/editor/src/blocks/__test__/blocks.test.ts b/vue/editor/src/blocks/__test__/blocks.test.ts new file mode 100644 index 0000000..02cb27a --- /dev/null +++ b/vue/editor/src/blocks/__test__/blocks.test.ts @@ -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, selection?: ReturnType) { + 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); + }); +}); diff --git a/vue/editor/src/blocks/blockquote.ts b/vue/editor/src/blocks/blockquote.ts new file mode 100644 index 0000000..c1e8df5 --- /dev/null +++ b/vue/editor/src/blocks/blockquote.ts @@ -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' }, +}); diff --git a/vue/editor/src/blocks/callout.ts b/vue/editor/src/blocks/callout.ts new file mode 100644 index 0000000..4fcdb59 --- /dev/null +++ b/vue/editor/src/blocks/callout.ts @@ -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' }, +}); diff --git a/vue/editor/src/blocks/code-block.ts b/vue/editor/src/blocks/code-block.ts new file mode 100644 index 0000000..23ef279 --- /dev/null +++ b/vue/editor/src/blocks/code-block.ts @@ -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' }, +}); diff --git a/vue/editor/src/blocks/divider.ts b/vue/editor/src/blocks/divider.ts new file mode 100644 index 0000000..23cfd3c --- /dev/null +++ b/vue/editor/src/blocks/divider.ts @@ -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' }, +}); diff --git a/vue/editor/src/blocks/heading.ts b/vue/editor/src/blocks/heading.ts new file mode 100644 index 0000000..89bdeb1 --- /dev/null +++ b/vue/editor/src/blocks/heading.ts @@ -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' }, +}); diff --git a/vue/editor/src/blocks/image.ts b/vue/editor/src/blocks/image.ts new file mode 100644 index 0000000..5d016d8 --- /dev/null +++ b/vue/editor/src/blocks/image.ts @@ -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' }, +}); diff --git a/vue/editor/src/blocks/index.ts b/vue/editor/src/blocks/index.ts new file mode 100644 index 0000000..6afa4d5 --- /dev/null +++ b/vue/editor/src/blocks/index.ts @@ -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'; diff --git a/vue/editor/src/blocks/list.ts b/vue/editor/src/blocks/list.ts new file mode 100644 index 0000000..6869924 --- /dev/null +++ b/vue/editor/src/blocks/list.ts @@ -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'] }); diff --git a/vue/editor/src/blocks/paragraph.ts b/vue/editor/src/blocks/paragraph.ts new file mode 100644 index 0000000..d8a033b --- /dev/null +++ b/vue/editor/src/blocks/paragraph.ts @@ -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' }, +}); diff --git a/vue/editor/src/commands/__test__/commands.test.ts b/vue/editor/src/commands/__test__/commands.test.ts new file mode 100644 index 0000000..0a2b009 --- /dev/null +++ b/vue/editor/src/commands/__test__/commands.test.ts @@ -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>, selection?: ReturnType) { + 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']); + }); +}); diff --git a/vue/editor/src/commands/blocks.ts b/vue/editor/src/commands/blocks.ts new file mode 100644 index 0000000..8ff5389 --- /dev/null +++ b/vue/editor/src/commands/blocks.ts @@ -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; + }; +} diff --git a/vue/editor/src/commands/chain.ts b/vue/editor/src/commands/chain.ts new file mode 100644 index 0000000..cc28f57 --- /dev/null +++ b/vue/editor/src/commands/chain.ts @@ -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; + }; +} diff --git a/vue/editor/src/commands/index.ts b/vue/editor/src/commands/index.ts new file mode 100644 index 0000000..c77b8ec --- /dev/null +++ b/vue/editor/src/commands/index.ts @@ -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'; diff --git a/vue/editor/src/commands/input-rules.ts b/vue/editor/src/commands/input-rules.ts new file mode 100644 index 0000000..4ce32e9 --- /dev/null +++ b/vue/editor/src/commands/input-rules.ts @@ -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; +}; diff --git a/vue/editor/src/commands/marks.ts b/vue/editor/src/commands/marks.ts new file mode 100644 index 0000000..e399976 --- /dev/null +++ b/vue/editor/src/commands/marks.ts @@ -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[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; + }; +} diff --git a/vue/editor/src/commands/selection.ts b/vue/editor/src/commands/selection.ts new file mode 100644 index 0000000..f5d8279 --- /dev/null +++ b/vue/editor/src/commands/selection.ts @@ -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; +}; diff --git a/vue/editor/src/commands/structure.ts b/vue/editor/src/commands/structure.ts new file mode 100644 index 0000000..ecb6b1e --- /dev/null +++ b/vue/editor/src/commands/structure.ts @@ -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; +}; diff --git a/vue/editor/src/commands/util.ts b/vue/editor/src/commands/util.ts new file mode 100644 index 0000000..fe4c47c --- /dev/null +++ b/vue/editor/src/commands/util.ts @@ -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]); +} diff --git a/vue/editor/src/crdt/__test__/convergence.test.ts b/vue/editor/src/crdt/__test__/convergence.test.ts new file mode 100644 index 0000000..fb9a4f1 --- /dev/null +++ b/vue/editor/src/crdt/__test__/convergence.test.ts @@ -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) { + 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, b: ReturnType) { + a.provider.onLocalOps(bytes => b.provider.applyUpdate(bytes)); + b.provider.onLocalOps(bytes => a.provider.applyUpdate(bytes)); +} + +function text(peer: ReturnType): 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) => + 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!'); + }); +}); diff --git a/vue/editor/src/crdt/__test__/document-crdt.test.ts b/vue/editor/src/crdt/__test__/document-crdt.test.ts new file mode 100644 index 0000000..8641519 --- /dev/null +++ b/vue/editor/src/crdt/__test__/document-crdt.test.ts @@ -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); + }); +}); diff --git a/vue/editor/src/crdt/binding.ts b/vue/editor/src/crdt/binding.ts new file mode 100644 index 0000000..e84796e --- /dev/null +++ b/vue/editor/src/crdt/binding.ts @@ -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(); + }, + }; +} diff --git a/vue/editor/src/crdt/index.ts b/vue/editor/src/crdt/index.ts new file mode 100644 index 0000000..06ff499 --- /dev/null +++ b/vue/editor/src/crdt/index.ts @@ -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'; diff --git a/vue/editor/src/crdt/native/document-crdt.ts b/vue/editor/src/crdt/native/document-crdt.ts new file mode 100644 index 0000000..350f5c5 --- /dev/null +++ b/vue/editor/src/crdt/native/document-crdt.ts @@ -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; + posKey: LwwRegister; + type: LwwRegister; + attrs: LwwRegister; + isText: boolean; + rga: Rga; + 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(); + 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(), + 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(); + 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(value: T, id: OpId): LwwRegister { + const reg = new LwwRegister(value); + reg.set(value, id); + return reg; +} diff --git a/vue/editor/src/crdt/native/provider.ts b/vue/editor/src/crdt/native/provider.ts new file mode 100644 index 0000000..7450a8d --- /dev/null +++ b/vue/editor/src/crdt/native/provider.ts @@ -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({ 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(); + + 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(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(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(); + }, + }; +} diff --git a/vue/editor/src/crdt/reconcile.ts b/vue/editor/src/crdt/reconcile.ts new file mode 100644 index 0000000..9546e08 --- /dev/null +++ b/vue/editor/src/crdt/reconcile.ts @@ -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; +} diff --git a/vue/editor/src/crdt/types.ts b/vue/editor/src/crdt/types.ts new file mode 100644 index 0000000..b35d110 --- /dev/null +++ b/vue/editor/src/crdt/types.ts @@ -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; +} diff --git a/vue/editor/src/env.d.ts b/vue/editor/src/env.d.ts new file mode 100644 index 0000000..b9d14ca --- /dev/null +++ b/vue/editor/src/env.d.ts @@ -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; + } +} diff --git a/vue/editor/src/index.ts b/vue/editor/src/index.ts new file mode 100644 index 0000000..23b974d --- /dev/null +++ b/vue/editor/src/index.ts @@ -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'; diff --git a/vue/editor/src/keymap/compile.ts b/vue/editor/src/keymap/compile.ts new file mode 100644 index 0000000..bdc5a48 --- /dev/null +++ b/vue/editor/src/keymap/compile.ts @@ -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 { + const compiled = new Map(); + + 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; +} diff --git a/vue/editor/src/keymap/defaults.ts b/vue/editor/src/keymap/defaults.ts new file mode 100644 index 0000000..68c8350 --- /dev/null +++ b/vue/editor/src/keymap/defaults.ts @@ -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; +} diff --git a/vue/editor/src/keymap/dispatcher.ts b/vue/editor/src/keymap/dispatcher.ts new file mode 100644 index 0000000..56089cd --- /dev/null +++ b/vue/editor/src/keymap/dispatcher.ts @@ -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, + state: EditorState, + dispatch: Dispatch, + view: CommandView, +): boolean { + const command = compiled.get(eventToCombo(event)); + return command ? command(state, dispatch, view) : false; +} diff --git a/vue/editor/src/keymap/index.ts b/vue/editor/src/keymap/index.ts new file mode 100644 index 0000000..008fb8b --- /dev/null +++ b/vue/editor/src/keymap/index.ts @@ -0,0 +1,5 @@ +export * from './types'; +export * from './normalize'; +export * from './compile'; +export * from './defaults'; +export * from './dispatcher'; diff --git a/vue/editor/src/keymap/normalize.ts b/vue/editor/src/keymap/normalize.ts new file mode 100644 index 0000000..2260668 --- /dev/null +++ b/vue/editor/src/keymap/normalize.ts @@ -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(); + + 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('-'); +} diff --git a/vue/editor/src/keymap/types.ts b/vue/editor/src/keymap/types.ts new file mode 100644 index 0000000..654adbc --- /dev/null +++ b/vue/editor/src/keymap/types.ts @@ -0,0 +1,4 @@ +import type { Command } from '../state'; + +/** A keymap: normalized (or human) key-combos mapped to commands. */ +export type Keymap = Record; diff --git a/vue/editor/src/marks/bold.ts b/vue/editor/src/marks/bold.ts new file mode 100644 index 0000000..e38a4f8 --- /dev/null +++ b/vue/editor/src/marks/bold.ts @@ -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' }, +}); diff --git a/vue/editor/src/marks/code.ts b/vue/editor/src/marks/code.ts new file mode 100644 index 0000000..01b762a --- /dev/null +++ b/vue/editor/src/marks/code.ts @@ -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' }, +}); diff --git a/vue/editor/src/marks/highlight.ts b/vue/editor/src/marks/highlight.ts new file mode 100644 index 0000000..cf3eb6a --- /dev/null +++ b/vue/editor/src/marks/highlight.ts @@ -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' }, +}); diff --git a/vue/editor/src/marks/index.ts b/vue/editor/src/marks/index.ts new file mode 100644 index 0000000..dbd6a9a --- /dev/null +++ b/vue/editor/src/marks/index.ts @@ -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'; diff --git a/vue/editor/src/marks/italic.ts b/vue/editor/src/marks/italic.ts new file mode 100644 index 0000000..bbd10b6 --- /dev/null +++ b/vue/editor/src/marks/italic.ts @@ -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' }, +}); diff --git a/vue/editor/src/marks/link.ts b/vue/editor/src/marks/link.ts new file mode 100644 index 0000000..f8dca72 --- /dev/null +++ b/vue/editor/src/marks/link.ts @@ -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' }, +}); diff --git a/vue/editor/src/marks/strike.ts b/vue/editor/src/marks/strike.ts new file mode 100644 index 0000000..a9aedcc --- /dev/null +++ b/vue/editor/src/marks/strike.ts @@ -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' }, +}); diff --git a/vue/editor/src/marks/underline.ts b/vue/editor/src/marks/underline.ts new file mode 100644 index 0000000..e3a15f4 --- /dev/null +++ b/vue/editor/src/marks/underline.ts @@ -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' }, +}); diff --git a/vue/editor/src/model/__test__/inline.test.ts b/vue/editor/src/model/__test__/inline.test.ts new file mode 100644 index 0000000..60a61a6 --- /dev/null +++ b/vue/editor/src/model/__test__/inline.test.ts @@ -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([]); + }); +}); diff --git a/vue/editor/src/model/attrs.ts b/vue/editor/src/model/attrs.ts new file mode 100644 index 0000000..7eab546 --- /dev/null +++ b/vue/editor/src/model/attrs.ts @@ -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>; + +/** + * 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])); +} diff --git a/vue/editor/src/model/document.ts b/vue/editor/src/model/document.ts new file mode 100644 index 0000000..ab6a223 --- /dev/null +++ b/vue/editor/src/model/document.ts @@ -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 }; +} diff --git a/vue/editor/src/model/id.ts b/vue/editor/src/model/id.ts new file mode 100644 index 0000000..d7cc02b --- /dev/null +++ b/vue/editor/src/model/id.ts @@ -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)}`; +} diff --git a/vue/editor/src/model/index.ts b/vue/editor/src/model/index.ts new file mode 100644 index 0000000..3542a3e --- /dev/null +++ b/vue/editor/src/model/index.ts @@ -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'; diff --git a/vue/editor/src/model/inline.ts b/vue/editor/src/model/inline.ts new file mode 100644 index 0000000..53490d2 --- /dev/null +++ b/vue/editor/src/model/inline.ts @@ -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; +} diff --git a/vue/editor/src/model/marks.ts b/vue/editor/src/model/marks.ts new file mode 100644 index 0000000..281e937 --- /dev/null +++ b/vue/editor/src/model/marks.ts @@ -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(); + + 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); +} diff --git a/vue/editor/src/model/node.ts b/vue/editor/src/model/node.ts new file mode 100644 index 0000000..78eb1cb --- /dev/null +++ b/vue/editor/src/model/node.ts @@ -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 }; +} diff --git a/vue/editor/src/model/position.ts b/vue/editor/src/model/position.ts new file mode 100644 index 0000000..3d94c57 --- /dev/null +++ b/vue/editor/src/model/position.ts @@ -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; +} diff --git a/vue/editor/src/model/selection.ts b/vue/editor/src/model/selection.ts new file mode 100644 index 0000000..5a9f930 --- /dev/null +++ b/vue/editor/src/model/selection.ts @@ -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; +} diff --git a/vue/editor/src/preset.ts b/vue/editor/src/preset.ts new file mode 100644 index 0000000..c703be3 --- /dev/null +++ b/vue/editor/src/preset.ts @@ -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], + }); +} diff --git a/vue/editor/src/registry/__test__/registry.test.ts b/vue/editor/src/registry/__test__/registry.test.ts new file mode 100644 index 0000000..9c835be --- /dev/null +++ b/vue/editor/src/registry/__test__/registry.test.ts @@ -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(); + }); +}); diff --git a/vue/editor/src/registry/define-block.ts b/vue/editor/src/registry/define-block.ts new file mode 100644 index 0000000..f87dd89 --- /dev/null +++ b/vue/editor/src/registry/define-block.ts @@ -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; + /** 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(def: D): D { + return def; +} diff --git a/vue/editor/src/registry/define-mark.ts b/vue/editor/src/registry/define-mark.ts new file mode 100644 index 0000000..cfb9b1e --- /dev/null +++ b/vue/editor/src/registry/define-mark.ts @@ -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(def: D): D { + return def; +} diff --git a/vue/editor/src/registry/index.ts b/vue/editor/src/registry/index.ts new file mode 100644 index 0000000..3f8fa13 --- /dev/null +++ b/vue/editor/src/registry/index.ts @@ -0,0 +1,4 @@ +export * from './input-rule'; +export * from './define-block'; +export * from './define-mark'; +export * from './registry'; diff --git a/vue/editor/src/registry/input-rule.ts b/vue/editor/src/registry/input-rule.ts new file mode 100644 index 0000000..fd68df3 --- /dev/null +++ b/vue/editor/src/registry/input-rule.ts @@ -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; +} diff --git a/vue/editor/src/registry/registry.ts b/vue/editor/src/registry/registry.ts new file mode 100644 index 0000000..0199d49 --- /dev/null +++ b/vue/editor/src/registry/registry.ts @@ -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; + readonly marks: ReadonlyMap; + 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( + items: readonly D[], + onConflict: ConflictPolicy, + kind: string, +): Map { + const map = new Map(); + + 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(); + for (const [type, def] of blocks) + nodeSpecs.set(type, def.spec); + + const markSpecs = new Map(); + 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', + }); +} diff --git a/vue/editor/src/schema/attr-spec.ts b/vue/editor/src/schema/attr-spec.ts new file mode 100644 index 0000000..1976bea --- /dev/null +++ b/vue/editor/src/schema/attr-spec.ts @@ -0,0 +1,11 @@ +import type { AttrValue } from '../model'; + +/** Specification for a single attribute: default, requiredness, validation. */ +export interface AttrSpec { + readonly default?: V; + readonly required?: boolean; + readonly validate?: (value: unknown) => boolean; +} + +/** Map of attribute name → {@link AttrSpec}. */ +export type AttrsSpec = Readonly>; diff --git a/vue/editor/src/schema/content-kind.ts b/vue/editor/src/schema/content-kind.ts new file mode 100644 index 0000000..0e627a5 --- /dev/null +++ b/vue/editor/src/schema/content-kind.ts @@ -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' }; diff --git a/vue/editor/src/schema/dom.ts b/vue/editor/src/schema/dom.ts new file mode 100644 index 0000000..4d02295 --- /dev/null +++ b/vue/editor/src/schema/dom.ts @@ -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]` → `…content…`, + * - 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 | 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; +} diff --git a/vue/editor/src/schema/index.ts b/vue/editor/src/schema/index.ts new file mode 100644 index 0000000..d20fa93 --- /dev/null +++ b/vue/editor/src/schema/index.ts @@ -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'; diff --git a/vue/editor/src/schema/mark-spec.ts b/vue/editor/src/schema/mark-spec.ts new file mode 100644 index 0000000..e000e98 --- /dev/null +++ b/vue/editor/src/schema/mark-spec.ts @@ -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[]; +} diff --git a/vue/editor/src/schema/node-spec.ts b/vue/editor/src/schema/node-spec.ts new file mode 100644 index 0000000..ab721da --- /dev/null +++ b/vue/editor/src/schema/node-spec.ts @@ -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[]; +} diff --git a/vue/editor/src/schema/normalize.ts b/vue/editor/src/schema/normalize.ts new file mode 100644 index 0000000..857763b --- /dev/null +++ b/vue/editor/src/schema/normalize.ts @@ -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); +} diff --git a/vue/editor/src/schema/schema.ts b/vue/editor/src/schema/schema.ts new file mode 100644 index 0000000..3abaade --- /dev/null +++ b/vue/editor/src/schema/schema.ts @@ -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; + readonly marks: ReadonlyMap; + 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 = {}; + + 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; + marks: ReadonlyMap; +}): 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); +} diff --git a/vue/editor/src/schema/validate.ts b/vue/editor/src/schema/validate.ts new file mode 100644 index 0000000..4aad8f0 --- /dev/null +++ b/vue/editor/src/schema/validate.ts @@ -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 }; +} diff --git a/vue/editor/src/state/__test__/step.test.ts b/vue/editor/src/state/__test__/step.test.ts new file mode 100644 index 0000000..5e52d16 --- /dev/null +++ b/vue/editor/src/state/__test__/step.test.ts @@ -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: [] }]); + }); +}); diff --git a/vue/editor/src/state/command.ts b/vue/editor/src/state/command.ts new file mode 100644 index 0000000..f98375c --- /dev/null +++ b/vue/editor/src/state/command.ts @@ -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: Args) => Command; diff --git a/vue/editor/src/state/editor-state.ts b/vue/editor/src/state/editor-state.ts new file mode 100644 index 0000000..a646086 --- /dev/null +++ b/vue/editor/src/state/editor-state.ts @@ -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 }; +} diff --git a/vue/editor/src/state/editor.ts b/vue/editor/src/state/editor.ts new file mode 100644 index 0000000..a39ad31 --- /dev/null +++ b/vue/editor/src/state/editor.ts @@ -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` 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: (event: K, listener: EditorEvents[K]) => void; + off: (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` 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(); + }, + }; +} diff --git a/vue/editor/src/state/history.ts b/vue/editor/src/state/history.ts new file mode 100644 index 0000000..395e11e --- /dev/null +++ b/vue/editor/src/state/history.ts @@ -0,0 +1,69 @@ +import type { Selection } from '../model'; +import type { Step } from './step'; + +/** + * One undoable change: the steps it applied, their inverses, and the selection + * before and after. Undo replays `inverted` (reversed); redo replays `steps`. + */ +export interface HistoryEntry { + readonly steps: readonly Step[]; + readonly inverted: readonly Step[]; + readonly selectionBefore: Selection; + readonly selectionAfter: Selection; +} + +export interface HistoryOptions { + /** Maximum number of undo entries to retain (default 200). */ + readonly maxSize?: number; +} + +/** + * Undo/redo stacks of inverse-step entries. Borrows the ergonomics of stdlib's + * command history (bounded size, redo cleared on a new edit) but stores data + * (inverse steps) rather than closures — which is what makes it serializable and + * collab-friendly. + */ +export interface History { + /** Record a new edit, clearing the redo stack. */ + record: (entry: HistoryEntry) => void; + /** Pop the latest undo entry (and push it onto the redo stack). */ + undo: () => HistoryEntry | undefined; + /** Pop the latest redo entry (and push it back onto the undo stack). */ + redo: () => HistoryEntry | undefined; + canUndo: () => boolean; + canRedo: () => boolean; + clear: () => void; +} + +export function createHistory(options: HistoryOptions = {}): History { + const maxSize = options.maxSize ?? 200; + const undoStack: HistoryEntry[] = []; + const redoStack: HistoryEntry[] = []; + + return { + record(entry) { + undoStack.push(entry); + if (undoStack.length > maxSize) + undoStack.shift(); + redoStack.length = 0; + }, + undo() { + const entry = undoStack.pop(); + if (entry) + redoStack.push(entry); + return entry; + }, + redo() { + const entry = redoStack.pop(); + if (entry) + undoStack.push(entry); + return entry; + }, + canUndo: () => undoStack.length > 0, + canRedo: () => redoStack.length > 0, + clear() { + undoStack.length = 0; + redoStack.length = 0; + }, + }; +} diff --git a/vue/editor/src/state/index.ts b/vue/editor/src/state/index.ts new file mode 100644 index 0000000..e11a72d --- /dev/null +++ b/vue/editor/src/state/index.ts @@ -0,0 +1,6 @@ +export * from './command'; +export * from './editor-state'; +export * from './step'; +export * from './transaction'; +export * from './history'; +export * from './editor'; diff --git a/vue/editor/src/state/step.ts b/vue/editor/src/state/step.ts new file mode 100644 index 0000000..9e65da9 --- /dev/null +++ b/vue/editor/src/state/step.ts @@ -0,0 +1,225 @@ +import type { Attrs, EditorDocument, Inline, Mark, Node } from '../model'; +import { + addMarkInline, + blockById, + blockIndex, + createNode, + deleteTextInline, + findBlock, + inlineLength, + insertInline, + nodeInline, + normalizeInline, + removeMarkInline, + replaceBlocks, + replaceInline, + sliceInline, + withAttrs, + withContent, + withType, +} from '../model'; +import type { Schema } from '../schema'; +import { marksAllowed } from '../schema'; + +/** + * The atomic, invertible, serializable unit of change. Steps are the contract + * shared by the undo history (each carries its exact inverse) and the CRDT + * adapter (each maps to a CRDT operation). Keeping the set small (~12) means a + * new block type never needs a new step. + */ +export type Step + = | { readonly type: 'insertInline'; readonly blockId: string; readonly offset: number; readonly content: Inline } + | { readonly type: 'deleteText'; readonly blockId: string; readonly from: number; readonly to: number } + | { readonly type: 'replaceInline'; readonly blockId: string; readonly from: number; readonly to: number; readonly content: Inline } + | { readonly type: 'addMark'; readonly blockId: string; readonly from: number; readonly to: number; readonly mark: Mark } + | { readonly type: 'removeMark'; readonly blockId: string; readonly from: number; readonly to: number; readonly mark: Mark } + | { readonly type: 'setAttrs'; readonly blockId: string; readonly attrs: Attrs } + | { readonly type: 'setType'; readonly blockId: string; readonly blockType: string; readonly attrs: Attrs } + | { readonly type: 'splitBlock'; readonly blockId: string; readonly offset: number; readonly newId: string; readonly newType?: string; readonly newAttrs?: Attrs } + | { readonly type: 'mergeBlock'; readonly blockId: string; readonly intoId: string } + | { readonly type: 'insertBlock'; readonly node: Node; readonly index: number } + | { readonly type: 'removeBlock'; readonly blockId: string } + | { readonly type: 'moveBlock'; readonly blockId: string; readonly toIndex: number } + | { readonly type: 'setDoc'; readonly doc: EditorDocument }; + +export interface StepResult { + readonly doc: EditorDocument; + readonly inverted: Step; +} + +function mapBlock(doc: EditorDocument, blockId: string, fn: (node: Node) => Node): EditorDocument { + return replaceBlocks(doc, doc.content.map(block => (block.id === blockId ? fn(block) : block))); +} + +/** + * Apply a single step to a document, returning the next document and the exact + * inverse step (so undo is correct by construction). Pure: never mutates input. + * If the addressed block is missing the step is a no-op (defends against remote + * steps referencing concurrently-removed blocks). + */ +export function applyStep(doc: EditorDocument, step: Step, schema: Schema): StepResult { + switch (step.type) { + case 'insertInline': { + const block = blockById(doc, step.blockId); + if (!block) + return { doc, inverted: step }; + + const next = normalizeInline(insertInline(nodeInline(block), step.offset, step.content)); + return { + doc: mapBlock(doc, step.blockId, b => withContent(b, next)), + inverted: { type: 'deleteText', blockId: step.blockId, from: step.offset, to: step.offset + inlineLength(step.content) }, + }; + } + + case 'deleteText': { + const block = blockById(doc, step.blockId); + if (!block) + return { doc, inverted: step }; + + const inline = nodeInline(block); + const removed = sliceInline(inline, step.from, step.to); + return { + doc: mapBlock(doc, step.blockId, b => withContent(b, normalizeInline(deleteTextInline(inline, step.from, step.to)))), + inverted: { type: 'insertInline', blockId: step.blockId, offset: step.from, content: removed }, + }; + } + + case 'replaceInline': { + const block = blockById(doc, step.blockId); + if (!block) + return { doc, inverted: step }; + + const inline = nodeInline(block); + const removed = sliceInline(inline, step.from, step.to); + return { + doc: mapBlock(doc, step.blockId, b => withContent(b, normalizeInline(replaceInline(inline, step.from, step.to, step.content)))), + inverted: { type: 'replaceInline', blockId: step.blockId, from: step.from, to: step.from + inlineLength(step.content), content: removed }, + }; + } + + case 'addMark': + case 'removeMark': { + const block = blockById(doc, step.blockId); + if (!block) + return { doc, inverted: step }; + + const inline = nodeInline(block); + const removed = sliceInline(inline, step.from, step.to); // exact prior state of the range + const next = step.type === 'addMark' + ? addMarkInline(inline, step.from, step.to, step.mark) + : removeMarkInline(inline, step.from, step.to, step.mark.type); + return { + doc: mapBlock(doc, step.blockId, b => withContent(b, normalizeInline(next))), + // Length is unchanged, so restoring the saved slice over [from, to) is an exact inverse. + inverted: { type: 'replaceInline', blockId: step.blockId, from: step.from, to: step.to, content: removed }, + }; + } + + case 'setAttrs': { + const block = blockById(doc, step.blockId); + if (!block) + return { doc, inverted: step }; + + return { + doc: mapBlock(doc, step.blockId, b => withAttrs(b, step.attrs)), + inverted: { type: 'setAttrs', blockId: step.blockId, attrs: block.attrs }, + }; + } + + case 'setType': { + const block = blockById(doc, step.blockId); + if (!block) + return { doc, inverted: step }; + + return { + doc: mapBlock(doc, step.blockId, b => withType(b, step.blockType, step.attrs)), + inverted: { type: 'setType', blockId: step.blockId, blockType: block.type, attrs: block.attrs }, + }; + } + + case 'splitBlock': { + const found = findBlock(doc, step.blockId); + if (!found) + return { doc, inverted: step }; + + const { node, index } = found; + const inline = nodeInline(node); + const head = normalizeInline(sliceInline(inline, 0, step.offset)); + const tail = normalizeInline(sliceInline(inline, step.offset, inlineLength(inline))); + const newAttrs = step.newAttrs ?? (step.newType ? schema.defaultAttrs(step.newType) : node.attrs); + const newNode = createNode(step.newType ?? node.type, { id: step.newId, attrs: newAttrs, content: tail }); + const content = [...doc.content.slice(0, index), withContent(node, head), newNode, ...doc.content.slice(index + 1)]; + return { + doc: replaceBlocks(doc, content), + inverted: { type: 'mergeBlock', blockId: step.newId, intoId: step.blockId }, + }; + } + + case 'mergeBlock': { + const source = findBlock(doc, step.blockId); + const target = findBlock(doc, step.intoId); + if (!source || !target) + return { doc, inverted: step }; + + const targetInline = nodeInline(target.node); + const splitOffset = inlineLength(targetInline); + // Drop source marks the target block disallows (e.g. merging styled text + // into a code-block must not smuggle in marks past `marks: 'none'`). + const targetSpec = schema.nodeSpec(target.node.type); + const sourceInline = targetSpec + ? nodeInline(source.node).map(run => ({ text: run.text, marks: run.marks.filter(m => marksAllowed(targetSpec, m.type)) })) + : nodeInline(source.node); + const mergedInline = normalizeInline([...targetInline, ...sourceInline]); + const content = doc.content + .map(block => (block.id === step.intoId ? withContent(target.node, mergedInline) : block)) + .filter(block => block.id !== step.blockId); + return { + doc: replaceBlocks(doc, content), + inverted: { type: 'splitBlock', blockId: step.intoId, offset: splitOffset, newId: source.node.id, newType: source.node.type, newAttrs: source.node.attrs }, + }; + } + + case 'insertBlock': { + const index = Math.max(0, Math.min(step.index, doc.content.length)); + const content = [...doc.content.slice(0, index), step.node, ...doc.content.slice(index)]; + return { + doc: replaceBlocks(doc, content), + inverted: { type: 'removeBlock', blockId: step.node.id }, + }; + } + + case 'removeBlock': { + const found = findBlock(doc, step.blockId); + if (!found) + return { doc, inverted: step }; + + return { + doc: replaceBlocks(doc, doc.content.filter(block => block.id !== step.blockId)), + inverted: { type: 'insertBlock', node: found.node, index: found.index }, + }; + } + + case 'moveBlock': { + const from = blockIndex(doc, step.blockId); + if (from === -1) + return { doc, inverted: step }; + + const arr = [...doc.content]; + const [moved] = arr.splice(from, 1); + const to = Math.max(0, Math.min(step.toIndex, arr.length)); + arr.splice(to, 0, moved!); + return { + doc: replaceBlocks(doc, arr), + inverted: { type: 'moveBlock', blockId: step.blockId, toIndex: from }, + }; + } + + case 'setDoc': { + // Replace the whole document (used to apply a remote CRDT snapshot). + return { + doc: step.doc, + inverted: { type: 'setDoc', doc }, + }; + } + } +} diff --git a/vue/editor/src/state/transaction.ts b/vue/editor/src/state/transaction.ts new file mode 100644 index 0000000..da5d0f5 --- /dev/null +++ b/vue/editor/src/state/transaction.ts @@ -0,0 +1,180 @@ +import type { Attrs, EditorDocument, Inline, Mark, Marks, Node, Position, Selection } from '../model'; +import { blockById, caret, createId, firstBlock, inlineLength, nodeInline } from '../model'; +import type { Schema } from '../schema'; +import type { EditorState } from './editor-state'; +import type { Step } from './step'; +import { applyStep } from './step'; + +/** + * A mutable builder that accumulates atomic {@link Step}s over a working copy of + * the document. Each builder method applies its step immediately (so later + * builders see prior effects) and records the exact inverse for undo. Dispatch + * turns the finished transaction into a new {@link EditorState}. + */ +export class Transaction { + readonly before: EditorState; + readonly steps: Step[] = []; + /** Inverse of each step, in application order (reversed when undoing). */ + readonly inverted: Step[] = []; + readonly meta = new Map(); + /** Working document after the steps applied so far. */ + doc: EditorDocument; + /** Selection to apply after this transaction. */ + selection: Selection; + /** `undefined` = leave stored marks to default handling; otherwise set them. */ + storedMarks: Marks | null | undefined = undefined; + /** Id of the block created by the most recent {@link splitBlock}. */ + lastSplitId: string | undefined; + + private readonly schema: Schema; + + constructor(state: EditorState) { + this.before = state; + this.doc = state.doc; + this.selection = state.selection; + this.schema = state.schema; + } + + /** Apply a raw step (also used by undo/redo to replay stored steps). */ + step(step: Step): this { + const result = applyStep(this.doc, step, this.schema); + this.doc = result.doc; + this.steps.push(step); + this.inverted.push(result.inverted); + return this; + } + + insertText(pos: Position, text: string, marks: Marks = []): this { + return this.step({ type: 'insertInline', blockId: pos.blockId, offset: pos.offset, content: text ? [{ text, marks }] : [] }); + } + + insertInline(pos: Position, content: Inline): this { + return this.step({ type: 'insertInline', blockId: pos.blockId, offset: pos.offset, content }); + } + + deleteText(blockId: string, from: number, to: number): this { + return this.step({ type: 'deleteText', blockId, from, to }); + } + + replaceInline(blockId: string, from: number, to: number, content: Inline): this { + return this.step({ type: 'replaceInline', blockId, from, to, content }); + } + + /** Replace a block's entire inline content (used by the input flush path). */ + setBlockContent(blockId: string, content: Inline): this { + const block = blockById(this.doc, blockId); + const length = block ? inlineLength(nodeInline(block)) : 0; + return this.step({ type: 'replaceInline', blockId, from: 0, to: length, content }); + } + + addMark(blockId: string, from: number, to: number, mark: Mark): this { + return this.step({ type: 'addMark', blockId, from, to, mark }); + } + + removeMark(blockId: string, from: number, to: number, mark: Mark): this { + return this.step({ type: 'removeMark', blockId, from, to, mark }); + } + + /** Merge `attrs` into the block's existing attrs. */ + setAttrs(blockId: string, attrs: Attrs): this { + const block = blockById(this.doc, blockId); + return this.step({ type: 'setAttrs', blockId, attrs: { ...(block?.attrs ?? {}), ...attrs } }); + } + + /** Convert a block to another type, preserving its inline content. */ + setBlockType(blockId: string, type: string, attrs?: Attrs): this { + return this.step({ type: 'setType', blockId, blockType: type, attrs: attrs ?? this.schema.defaultAttrs(type) }); + } + + splitBlock(pos: Position, newType?: string, newAttrs?: Attrs, newId: string = createId()): this { + this.lastSplitId = newId; + return this.step({ type: 'splitBlock', blockId: pos.blockId, offset: pos.offset, newId, newType, newAttrs }); + } + + mergeBlock(blockId: string, intoId: string): this { + return this.step({ type: 'mergeBlock', blockId, intoId }); + } + + insertBlock(node: Node, index: number): this { + return this.step({ type: 'insertBlock', node, index }); + } + + removeBlock(blockId: string): this { + return this.step({ type: 'removeBlock', blockId }); + } + + moveBlock(blockId: string, toIndex: number): this { + return this.step({ type: 'moveBlock', blockId, toIndex }); + } + + /** Replace the whole document (used to apply a remote CRDT snapshot). */ + setDoc(doc: EditorDocument): this { + return this.step({ type: 'setDoc', doc }); + } + + setSelection(selection: Selection): this { + this.selection = selection; + return this; + } + + setStoredMarks(marks: Marks | null): this { + this.storedMarks = marks; + return this; + } + + setMeta(key: string, value: unknown): this { + this.meta.set(key, value); + return this; + } + + getMeta(key: string): unknown { + return this.meta.get(key); + } +} + +/** Start a transaction from the current editor state. */ +export function createTransaction(state: EditorState): Transaction { + return new Transaction(state); +} + +function clampPoint(point: Position, doc: EditorDocument): Position | null { + const block = blockById(doc, point.blockId); + + if (!block) + return null; + + const length = inlineLength(nodeInline(block)); + return { blockId: point.blockId, offset: Math.max(0, Math.min(point.offset, length)) }; +} + +function clampSelection(selection: Selection, doc: EditorDocument): Selection { + if (selection.kind === 'node') + return selection; + + const anchor = clampPoint(selection.anchor, doc); + const focus = clampPoint(selection.focus, doc); + + if (!anchor || !focus) { + const first = firstBlock(doc); + return first ? caret(first.id, 0) : selection; + } + + return { kind: 'text', anchor, focus }; +} + +/** + * Produce the next editor state from a transaction. Stored marks are kept when + * explicitly set, cleared on any content change, and otherwise preserved. + */ +export function applyTransaction(state: EditorState, tr: Transaction): EditorState { + const storedMarks = tr.storedMarks !== undefined + ? tr.storedMarks + : (tr.steps.length > 0 ? null : state.storedMarks); + + return { + ...state, + doc: tr.doc, + selection: clampSelection(tr.selection, tr.doc), + storedMarks, + }; +} diff --git a/vue/editor/src/view/BlockView.vue b/vue/editor/src/view/BlockView.vue new file mode 100644 index 0000000..edc1f4c --- /dev/null +++ b/vue/editor/src/view/BlockView.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/vue/editor/src/view/EditorContent.vue b/vue/editor/src/view/EditorContent.vue new file mode 100644 index 0000000..2733f2b --- /dev/null +++ b/vue/editor/src/view/EditorContent.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/vue/editor/src/view/EditorRoot.vue b/vue/editor/src/view/EditorRoot.vue new file mode 100644 index 0000000..915de3a --- /dev/null +++ b/vue/editor/src/view/EditorRoot.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/vue/editor/src/view/TextBlockHost.vue b/vue/editor/src/view/TextBlockHost.vue new file mode 100644 index 0000000..84f114f --- /dev/null +++ b/vue/editor/src/view/TextBlockHost.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/vue/editor/src/view/__test__/editor.browser.test.ts b/vue/editor/src/view/__test__/editor.browser.test.ts new file mode 100644 index 0000000..1ac96d9 --- /dev/null +++ b/vue/editor/src/view/__test__/editor.browser.test.ts @@ -0,0 +1,133 @@ +import { render } from 'vitest-browser-vue'; +import { describe, expect, it } from 'vitest'; +import { nextTick } from 'vue'; +import { createDoc, createNode, textSelection } from '../../model'; +import { createDefaultRegistry } from '../../preset'; +import { createEditor, createEditorState, createTransaction } from '../../state'; +import EditorRoot from '../EditorRoot.vue'; + +function para(id: string, text: string) { + return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] }); +} + +function mount(blocks: Array>) { + const registry = createDefaultRegistry(); + const editor = createEditor({ state: createEditorState({ registry, doc: createDoc(blocks) }) }); + render(EditorRoot, { props: { editor, platform: 'mac' } }); + return editor; +} + +function selectNative(anchor: { node: Node; offset: number }, focus: { node: Node; offset: number }) { + const sel = getSelection()!; + sel.removeAllRanges(); + const range = document.createRange(); + range.setStart(anchor.node, anchor.offset); + range.setEnd(focus.node, focus.offset); + sel.addRange(range); +} + +describe('EditorRoot (single contenteditable)', () => { + it('renders ONE editable root containing non-editable block elements', async () => { + mount([para('a', 'hello')]); + await nextTick(); + + const ce = document.querySelector('[data-editor-content]')!; + expect(ce.getAttribute('contenteditable')).toBe('true'); + + const host = document.querySelector('[data-block-content]') as HTMLElement; + expect(host.textContent).toBe('hello'); + // The block element itself is NOT a separate editing host. + expect(host.getAttribute('contenteditable')).toBeNull(); + }); + + it('maps a cross-block native selection to a cross-block model range', async () => { + const editor = mount([para('a', 'hello'), para('b', 'world')]); + await nextTick(); + + const hosts = document.querySelectorAll('[data-block-content]'); + const aText = hosts[0]!.firstChild!; // text node "hello" + const bText = hosts[1]!.firstChild!; // text node "world" + + selectNative({ node: aText, offset: 1 }, { node: bText, offset: 3 }); + await nextTick(); + await nextTick(); + + const sel = editor.state.selection; + expect(sel.kind).toBe('text'); + if (sel.kind === 'text') { + expect(sel.anchor.blockId).toBe('a'); + expect(sel.anchor.offset).toBe(1); + expect(sel.focus.blockId).toBe('b'); + expect(sel.focus.offset).toBe(3); + } + }); + + it('writes a cross-block model selection back to a native range spanning blocks', async () => { + const editor = mount([para('a', 'hello'), para('b', 'world')]); + await nextTick(); + + editor.dispatch(createTransaction(editor.state).setSelection( + textSelection({ blockId: 'a', offset: 2 }, { blockId: 'b', offset: 4 }), + )); + await nextTick(); + await nextTick(); + + const sel = getSelection()!; + const hosts = document.querySelectorAll('[data-block-content]'); + expect(hosts[0]!.contains(sel.anchorNode)).toBe(true); + expect(hosts[1]!.contains(sel.focusNode)).toBe(true); + expect(sel.isCollapsed).toBe(false); + }); + + it('applies bold via Mod-b to a selected range', async () => { + const editor = mount([para('a', 'hello')]); + await nextTick(); + + editor.dispatch(createTransaction(editor.state).setSelection( + textSelection({ blockId: 'a', offset: 0 }, { blockId: 'a', offset: 5 }), + )); + await nextTick(); + + const root = document.querySelector('[data-editor-root]')!; + root.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true })); + await nextTick(); + await nextTick(); + + expect(document.querySelector('[data-block-content] strong')?.textContent).toBe('hello'); + }); + + it('splits a block on Enter', async () => { + const editor = mount([para('a', 'hello')]); + await nextTick(); + + editor.dispatch(createTransaction(editor.state).setSelection(textSelection({ blockId: 'a', offset: 2 }))); + await nextTick(); + + const root = document.querySelector('[data-editor-root]')!; + root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + await nextTick(); + await nextTick(); + + const hosts = document.querySelectorAll('[data-block-content]'); + expect(hosts.length).toBe(2); + expect(hosts[0]!.textContent).toBe('he'); + expect(hosts[1]!.textContent).toBe('llo'); + }); + + it('merges into the previous block on Backspace at block start', async () => { + const editor = mount([para('a', 'foo'), para('b', 'bar')]); + await nextTick(); + + editor.dispatch(createTransaction(editor.state).setSelection(textSelection({ blockId: 'b', offset: 0 }))); + await nextTick(); + + const root = document.querySelector('[data-editor-root]')!; + root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true })); + await nextTick(); + await nextTick(); + + const hosts = document.querySelectorAll('[data-block-content]'); + expect(hosts.length).toBe(1); + expect(hosts[0]!.textContent).toBe('foobar'); + }); +}); diff --git a/vue/editor/src/view/composables/index.ts b/vue/editor/src/view/composables/index.ts new file mode 100644 index 0000000..32077b1 --- /dev/null +++ b/vue/editor/src/view/composables/index.ts @@ -0,0 +1,2 @@ +export { useContextFactory } from './useContextFactory'; +export { useEventListener } from './useEventListener'; diff --git a/vue/editor/src/view/composables/useContextFactory.ts b/vue/editor/src/view/composables/useContextFactory.ts new file mode 100644 index 0000000..637cd4e --- /dev/null +++ b/vue/editor/src/view/composables/useContextFactory.ts @@ -0,0 +1,31 @@ +import type { App, InjectionKey } from 'vue'; +import { inject as vueInject, provide as vueProvide } from 'vue'; + +/** + * Factory for a strongly-typed provide/inject pair keyed by a unique Symbol. + * Local copy of the `@robonen/vue` helper so the editor stays self-contained. + */ +export function useContextFactory(name: string) { + const injectionKey: InjectionKey = Symbol(name); + + const inject = (fallback?: Fallback) => { + const context = vueInject(injectionKey, fallback); + + if (context !== undefined) + return context; + + throw new Error(`useContextFactory: '${name}' context is not provided`); + }; + + const provide = (context: ContextValue) => { + vueProvide(injectionKey, context); + return context; + }; + + const appProvide = (app: App) => (context: ContextValue) => { + app.provide(injectionKey, context); + return context; + }; + + return { inject, provide, appProvide, key: injectionKey }; +} diff --git a/vue/editor/src/view/composables/useEventListener.ts b/vue/editor/src/view/composables/useEventListener.ts new file mode 100644 index 0000000..8efcd7d --- /dev/null +++ b/vue/editor/src/view/composables/useEventListener.ts @@ -0,0 +1,38 @@ +import type { MaybeRefOrGetter } from 'vue'; +import { onScopeDispose, toValue, watch } from 'vue'; + +type ListenerTarget = Window | Document | HTMLElement | null | undefined; + +/** + * Attach an event listener to a (possibly reactive) target, re-binding when the + * target changes and cleaning up on scope dispose. Minimal local replacement for + * the `@robonen/vue` composable. + */ +export function useEventListener( + target: MaybeRefOrGetter, + event: string, + handler: (event: Event) => void, + options?: boolean | AddEventListenerOptions, +): () => void { + let detach = (): void => {}; + + const stopWatch = watch( + () => toValue(target), + (el) => { + detach(); + if (!el) + return; + el.addEventListener(event, handler, options); + detach = () => el.removeEventListener(event, handler, options); + }, + { immediate: true, flush: 'post' }, + ); + + const stop = (): void => { + detach(); + stopWatch(); + }; + + onScopeDispose(stop); + return stop; +} diff --git a/vue/editor/src/view/config.ts b/vue/editor/src/view/config.ts new file mode 100644 index 0000000..7385e0a --- /dev/null +++ b/vue/editor/src/view/config.ts @@ -0,0 +1,35 @@ +export type Platform = 'mac' | 'other'; + +/** Editor-wide configuration provided through the editor context. */ +export interface EditorConfig { + /** Whether content is editable (false renders read-only). */ + editable: boolean; + /** Platform for keybinding normalization (`Mod` → Cmd/Ctrl). */ + platform: Platform; + /** Text direction. */ + dir: 'ltr' | 'rtl'; + /** Native spellcheck on the contenteditable hosts. */ + spellcheck: boolean; + /** Show per-block drag handles for reordering. */ + draggable: boolean; +} + +/** Detect the platform from the user agent (defaults to `'other'` off-browser). */ +export function detectPlatform(): Platform { + if (typeof navigator === 'undefined') + return 'other'; + + const probe = navigator.userAgent || ''; + return /Mac|iPhone|iPad|iPod/.test(probe) ? 'mac' : 'other'; +} + +/** Build a config with sensible defaults. */ +export function resolveConfig(partial?: Partial): EditorConfig { + return { + editable: partial?.editable ?? true, + platform: partial?.platform ?? detectPlatform(), + dir: partial?.dir ?? 'ltr', + spellcheck: partial?.spellcheck ?? true, + draggable: partial?.draggable ?? false, + }; +} diff --git a/vue/editor/src/view/context.ts b/vue/editor/src/view/context.ts new file mode 100644 index 0000000..5a8d87c --- /dev/null +++ b/vue/editor/src/view/context.ts @@ -0,0 +1,36 @@ +import type { Ref, ShallowRef } from 'vue'; +import type { Registry } from '../registry'; +import { useContextFactory } from './composables'; +import type { Command, Dispatch, Editor, EditorState } from '../state'; +import type { EditorConfig } from './config'; +import type { BlockElementRegistry, SelectionBridge } from './selection'; + +/** Everything child components and the input/selection plumbing need. */ +export interface EditorContextValue { + /** The headless controller. */ + editor: Editor; + /** Reactive mirror of `editor.state`, replaced wholesale per transaction. */ + state: ShallowRef; + registry: Registry; + config: EditorConfig; + /** The single contenteditable root element (set by EditorContent). */ + contentRoot: ShallowRef; + /** Block id → its (non-editable) block-content element. */ + blockElements: BlockElementRegistry; + /** DOM ↔ model selection mapping. */ + selection: SelectionBridge; + /** True while an IME composition is in flight (suppresses model sync). */ + composing: Ref; + /** Origin (`meta('origin')`) of the most recent transaction, if any. */ + lastOrigin: Ref; + dispatch: Dispatch; + /** Run a command against the current state. */ + exec: (command: Command) => boolean; + /** Move real DOM focus + caret into a block. */ + focusBlock: (blockId: string, offset: number | 'start' | 'end') => void; +} + +export const { + inject: useEditorContext, + provide: provideEditorContext, +} = useContextFactory('EditorContext'); diff --git a/vue/editor/src/view/index.ts b/vue/editor/src/view/index.ts new file mode 100644 index 0000000..e4409b9 --- /dev/null +++ b/vue/editor/src/view/index.ts @@ -0,0 +1,15 @@ +export * from './primitive'; +export * from './config'; +export * from './context'; +export * from './inline-content'; +export * from './selection'; +export * from './ui'; + +export { default as EditorRoot } from './EditorRoot.vue'; +export type { EditorRootProps } from './EditorRoot.vue'; +export { default as EditorContent } from './EditorContent.vue'; +export type { EditorContentProps } from './EditorContent.vue'; +export { default as BlockView } from './BlockView.vue'; +export type { BlockViewProps } from './BlockView.vue'; +export { default as TextBlockHost } from './TextBlockHost.vue'; +export type { TextBlockHostProps } from './TextBlockHost.vue'; diff --git a/vue/editor/src/view/inline-content/index.ts b/vue/editor/src/view/inline-content/index.ts new file mode 100644 index 0000000..6ca0506 --- /dev/null +++ b/vue/editor/src/view/inline-content/index.ts @@ -0,0 +1,2 @@ +export { renderRuns, FILLER_ATTR } from './render'; +export { parseRuns } from './parse'; diff --git a/vue/editor/src/view/inline-content/parse.ts b/vue/editor/src/view/inline-content/parse.ts new file mode 100644 index 0000000..541cebd --- /dev/null +++ b/vue/editor/src/view/inline-content/parse.ts @@ -0,0 +1,67 @@ +import type { Inline, InlineNode, Mark } from '../../model'; +import { normalizeInline, normalizeMarks } from '../../model'; +import type { Registry } from '../../registry'; +import { FILLER_ATTR } from './render'; + +// Zero-width space, built without embedding the literal character in source. +const ZWSP = new RegExp(String.fromCharCode(0x200B), 'g'); + +/** Marks contributed by a single element, via each mark's `parseDOM` rules. */ +function marksForElement(el: HTMLElement, registry: Registry): Mark[] { + const marks: Mark[] = []; + + for (const def of registry.allMarks()) { + for (const rule of def.spec.parseDOM) { + if (!rule.tag || !el.matches(rule.tag)) + continue; + + let attrs = rule.attrs; + + if (rule.getAttrs) { + const got = rule.getAttrs(el); + if (got === false || got === null) + continue; + attrs = { ...(rule.attrs ?? {}), ...got }; + } + + marks.push(attrs && Object.keys(attrs).length > 0 ? { type: def.type, attrs } : { type: def.type }); + break; // first matching rule wins for this mark + } + } + + return marks; +} + +function walk(node: Node, marks: readonly Mark[], out: InlineNode[], registry: Registry): void { + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const text = (child.nodeValue ?? '').replace(ZWSP, ''); + if (text) + out.push({ text, marks }); + continue; + } + + if (child.nodeType !== Node.ELEMENT_NODE) + continue; + + const el = child as HTMLElement; + + if (el.tagName === 'BR') { + if (!el.hasAttribute(FILLER_ATTR)) + out.push({ text: '\n', marks }); // hard break + continue; + } + + walk(el, normalizeMarks([...marks, ...marksForElement(el, registry)]), out, registry); + } +} + +/** + * Parse a contenteditable host (or any DOM subtree, e.g. pasted HTML) back into + * normalized inline runs, resolving marks from the registry's `parseDOM` rules. + */ +export function parseRuns(host: HTMLElement, registry: Registry): Inline { + const out: InlineNode[] = []; + walk(host, [], out, registry); + return normalizeInline(out); +} diff --git a/vue/editor/src/view/inline-content/render.ts b/vue/editor/src/view/inline-content/render.ts new file mode 100644 index 0000000..5ab99e7 --- /dev/null +++ b/vue/editor/src/view/inline-content/render.ts @@ -0,0 +1,90 @@ +import type { Inline, Mark } from '../../model'; +import type { Registry } from '../../registry'; +import type { DOMOutputSpec } from '../../schema'; + +/** Attribute marking the filler `
` of an empty block (not a real newline). */ +export const FILLER_ATTR = 'data-editor-br-filler'; + +function markRank(registry: Registry, mark: Mark): number { + return registry.getMark(mark.type)?.spec.rank ?? 0; +} + +function isAttrsObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Realize a mark's `toDOM` spec into a wrapper element (content appended later). */ +function createWrapper(spec: DOMOutputSpec, markType: string): HTMLElement { + if (typeof spec === 'string') { + const el = document.createElement(spec); + el.setAttribute('data-mark', markType); + return el; + } + + const [tag, ...rest] = spec as readonly unknown[]; + const el = document.createElement(typeof tag === 'string' ? tag : 'span'); + + if (rest.length > 0 && isAttrsObject(rest[0])) { + for (const [key, value] of Object.entries(rest[0])) + el.setAttribute(key, String(value)); + } + + el.setAttribute('data-mark', markType); + return el; +} + +/** Build the innermost content for a run, turning `\n` into hard-break `
`. */ +function buildInner(text: string): DocumentFragment { + const frag = document.createDocumentFragment(); + const segments = text.split('\n'); + + segments.forEach((segment, index) => { + if (index > 0) + frag.appendChild(document.createElement('br')); + if (segment.length > 0) + frag.appendChild(document.createTextNode(segment)); + }); + + return frag; +} + +function wrapWithMarks(inner: Node, marks: readonly Mark[], registry: Registry): Node { + let node = inner; + + for (let i = marks.length - 1; i >= 0; i--) { + const def = registry.getMark(marks[i]!.type); + if (!def) + continue; + + const wrapper = createWrapper(def.spec.toDOM(marks[i]!), marks[i]!.type); + wrapper.appendChild(node); + node = wrapper; + } + + return node; +} + +/** + * Render inline content into a contenteditable host imperatively (never via + * Vue's template diff, which would fight the caret). Marks nest by `rank` + * (lower = outer) for stable, deterministic output. An empty block gets a single + * filler `
` so it has height and a caret target. + */ +export function renderRuns(host: HTMLElement, inline: Inline, registry: Registry): void { + const frag = document.createDocumentFragment(); + let total = 0; + + for (const run of inline) { + total += run.text.length; + const marks = [...run.marks].sort((a, b) => markRank(registry, a) - markRank(registry, b)); + frag.appendChild(wrapWithMarks(buildInner(run.text), marks, registry)); + } + + if (total === 0) { + const filler = document.createElement('br'); + filler.setAttribute(FILLER_ATTR, ''); + frag.appendChild(filler); + } + + host.replaceChildren(frag); +} diff --git a/vue/editor/src/view/interactive.ts b/vue/editor/src/view/interactive.ts new file mode 100644 index 0000000..94b486b --- /dev/null +++ b/vue/editor/src/view/interactive.ts @@ -0,0 +1,11 @@ +/** + * Whether a node is (inside) an atom's interactive control — a form field or a + * `contenteditable="false"` island. Events from these must NOT be treated as + * editor input: e.g. typing in an image's caption `` bubbles up to the + * single contenteditable, and without this guard the editor would re-sync a text + * block and yank the caret to the start of the document. + */ +export function isInteractiveTarget(node: EventTarget | null): boolean { + return node instanceof Element + && node.closest('input, textarea, select, button, [contenteditable="false"]') !== null; +} diff --git a/vue/editor/src/view/primitive/Primitive.ts b/vue/editor/src/view/primitive/Primitive.ts new file mode 100644 index 0000000..1d2726e --- /dev/null +++ b/vue/editor/src/view/primitive/Primitive.ts @@ -0,0 +1,30 @@ +import type { AllowedComponentProps, Component, IntrinsicElementAttributes, SetupContext, VNodeProps } from 'vue'; +import { h } from 'vue'; +import { renderSlotChild } from './Slot'; + +type FunctionalComponentContext = Omit; + +export interface PrimitiveProps { + as?: keyof IntrinsicElementAttributes | Component; +} + +/** + * Polymorphic element renderer: renders `as` (a tag or component), or the single + * slotted child when `as === 'template'`. Local copy of the primitives helper. + */ +export function Primitive(props: PrimitiveProps & VNodeProps & AllowedComponentProps & Record, ctx: FunctionalComponentContext) { + const as = props.as; + + return as === 'template' + ? renderSlotChild(ctx.slots, ctx.attrs) + : h(as!, ctx.attrs, ctx.slots); +} + +Primitive.inheritAttrs = false; + +Primitive.props = { + as: { + type: [String, Object], + default: 'div' as const, + }, +}; diff --git a/vue/editor/src/view/primitive/Slot.ts b/vue/editor/src/view/primitive/Slot.ts new file mode 100644 index 0000000..04ab41e --- /dev/null +++ b/vue/editor/src/view/primitive/Slot.ts @@ -0,0 +1,50 @@ +import type { SetupContext, Slots, VNode } from 'vue'; +import { Comment, Fragment, cloneVNode, warn } from 'vue'; +import { getRawChildren } from './getRawChildren'; + +type FunctionalComponentContext = Omit; + +/** + * Renders a single child from the provided default slot, applying attrs to it. + * Shared between `` and ``. + * + * @param slots - Component slots + * @param attrs - Attrs to apply to the slotted child + * @returns Cloned VNode with merged attrs or null + */ +export function renderSlotChild(slots: Slots, attrs: Record): VNode | null { + if (!slots.default) return null; + + const raw = slots.default(); + + if (raw.length === 1) { + const only = raw[0] as VNode; + const t = only.type; + if (t !== Fragment && t !== Comment) + return cloneVNode(only, attrs, true); + } + + const children = getRawChildren(raw); + + if (!children.length) return null; + + if (__DEV__ && children.length > 1) { + warn(' can only be used on a single element or component.'); + } + + return cloneVNode(children[0]!, attrs, true); +} + +/** + * A component that renders a single child from its default slot, applying the + * provided attributes to it. + * + * @param _ - Props (unused) + * @param context - Setup context containing slots and attrs + * @returns Cloned VNode with merged attrs or null + */ +export function Slot(_: Record, { slots, attrs }: FunctionalComponentContext) { + return renderSlotChild(slots, attrs); +} + +Slot.inheritAttrs = false; diff --git a/vue/editor/src/view/primitive/getRawChildren.ts b/vue/editor/src/view/primitive/getRawChildren.ts new file mode 100644 index 0000000..836ee67 --- /dev/null +++ b/vue/editor/src/view/primitive/getRawChildren.ts @@ -0,0 +1,43 @@ +import type { VNode } from 'vue'; +import { Comment, Fragment } from 'vue'; +import { PatchFlags } from '@vue/shared'; + +/** + * Recursively extracts and flattens VNodes from potentially nested Fragments + * while filtering out Comment nodes. Local copy of the primitives helper to keep + * `@robonen/editor` self-contained. + * + * @param children - Array of VNodes to process + * @returns Flattened array of non-Comment VNodes + */ +export function getRawChildren(children: VNode[]): VNode[] { + const result: VNode[] = []; + flatten(children, result); + return result; +} + +function flatten(children: VNode[], result: VNode[]): void { + let keyedFragmentCount = 0; + const startIdx = result.length; + + for (let i = 0, len = children.length; i < len; i++) { + const child = children[i]!; + + if (child.type === Fragment) { + if (child.patchFlag & PatchFlags.KEYED_FRAGMENT) { + keyedFragmentCount++; + } + + flatten(child.children as VNode[], result); + } + else if (child.type !== Comment) { + result.push(child); + } + } + + if (keyedFragmentCount > 1) { + for (let i = startIdx; i < result.length; i++) { + result[i]!.patchFlag = PatchFlags.BAIL; + } + } +} diff --git a/vue/editor/src/view/primitive/index.ts b/vue/editor/src/view/primitive/index.ts new file mode 100644 index 0000000..c88baf4 --- /dev/null +++ b/vue/editor/src/view/primitive/index.ts @@ -0,0 +1,4 @@ +export { Primitive } from './Primitive'; +export type { PrimitiveProps } from './Primitive'; +export { Slot, renderSlotChild } from './Slot'; +export { getRawChildren } from './getRawChildren'; diff --git a/vue/editor/src/view/selection/block-host.ts b/vue/editor/src/view/selection/block-host.ts new file mode 100644 index 0000000..6bbc1e7 --- /dev/null +++ b/vue/editor/src/view/selection/block-host.ts @@ -0,0 +1,25 @@ +/** Maps block ids to their contenteditable host elements for selection/focus. */ +export interface BlockElementRegistry { + set: (blockId: string, el: HTMLElement) => void; + delete: (blockId: string) => void; + get: (blockId: string) => HTMLElement | undefined; +} + +export function createBlockElementRegistry(): BlockElementRegistry { + const map = new Map(); + + return { + set: (blockId, el) => void map.set(blockId, el), + delete: blockId => void map.delete(blockId), + get: blockId => map.get(blockId), + }; +} + +/** The nearest contenteditable block host containing `node`, or `null`. */ +export function closestBlockHost(node: Node | null): HTMLElement | null { + if (!node) + return null; + + const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement; + return el?.closest('[data-block-content]') ?? null; +} diff --git a/vue/editor/src/view/selection/index.ts b/vue/editor/src/view/selection/index.ts new file mode 100644 index 0000000..9c621bc --- /dev/null +++ b/vue/editor/src/view/selection/index.ts @@ -0,0 +1,2 @@ +export * from './block-host'; +export * from './selection-bridge'; diff --git a/vue/editor/src/view/selection/selection-bridge.ts b/vue/editor/src/view/selection/selection-bridge.ts new file mode 100644 index 0000000..44247ca --- /dev/null +++ b/vue/editor/src/view/selection/selection-bridge.ts @@ -0,0 +1,195 @@ +import type { Selection } from '../../model'; +import { textSelection } from '../../model'; +import { FILLER_ATTR } from '../inline-content'; +import type { BlockElementRegistry } from './block-host'; +import { closestBlockHost } from './block-host'; + +/** Maps the native `Selection`/`Range` (over the single editable root) to model coordinates and back. */ +export interface SelectionBridge { + /** Read the native selection as a model selection (null if outside editor). */ + read: () => Selection | null; + /** Apply a model selection to the native selection (focusing the root). */ + write: (selection: Selection) => void; + /** Snapshot the current model selection. */ + save: () => Selection | null; + /** Restore a previously saved selection. */ + restore: (selection: Selection | null) => void; + domPointToOffset: (host: HTMLElement, node: Node, offset: number) => number; + offsetToDomPoint: (host: HTMLElement, offset: number) => { node: Node; offset: number }; +} + +function isFillerBr(node: Node): boolean { + return node.nodeType === Node.ELEMENT_NODE + && (node as HTMLElement).tagName === 'BR' + && (node as HTMLElement).hasAttribute(FILLER_ATTR); +} + +/** Count model characters in a DOM subtree: text length + 1 per hard-break. */ +function measureLength(node: Node): number { + let length = 0; + + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + length += (child.nodeValue ?? '').length; + } + else if (child.nodeType === Node.ELEMENT_NODE) { + if ((child as HTMLElement).tagName === 'BR') + length += isFillerBr(child) ? 0 : 1; + else + length += measureLength(child); + } + } + + return length; +} + +function indexInParent(el: Node): number { + return el.parentNode ? Array.from(el.parentNode.childNodes).indexOf(el as ChildNode) : 0; +} + +function getWindow(): Window | null { + return typeof globalThis.window === 'undefined' ? null : globalThis.window; +} + +export function createSelectionBridge( + getRoot: () => HTMLElement | null, + blockElements: BlockElementRegistry, +): SelectionBridge { + /** DOM point → model character offset within one block-content element. */ + function domPointToOffset(host: HTMLElement, node: Node, offset: number): number { + const range = host.ownerDocument.createRange(); + range.selectNodeContents(host); + + try { + range.setEnd(node, offset); + } + catch { + return measureLength(host); + } + + return measureLength(range.cloneContents()); + } + + /** Model character offset → DOM point within one block-content element. */ + function offsetToDomPoint(host: HTMLElement, offset: number): { node: Node; offset: number } { + let remaining = offset; + + function search(node: Node): { node: Node; offset: number } | null { + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const length = (child.nodeValue ?? '').length; + if (remaining <= length) + return { node: child, offset: remaining }; + remaining -= length; + } + else if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as HTMLElement; + + if (el.tagName === 'BR') { + if (isFillerBr(el)) + continue; + if (remaining === 0) + return { node: el.parentNode!, offset: indexInParent(el) }; + remaining -= 1; + if (remaining === 0) + return { node: el.parentNode!, offset: indexInParent(el) + 1 }; + } + else { + const found = search(el); + if (found) + return found; + } + } + } + + return null; + } + + return search(host) ?? { node: host, offset: 0 }; + } + + function hostFor(blockId: string): HTMLElement | null { + return blockElements.get(blockId) ?? null; + } + + function read(): Selection | null { + const root = getRoot(); + const domSel = getWindow()?.getSelection(); + + if (!root || !domSel || domSel.rangeCount === 0 || !domSel.anchorNode) + return null; + + // Both endpoints must live inside our single editable root. + if (!root.contains(domSel.anchorNode)) + return null; + + const anchorHost = closestBlockHost(domSel.anchorNode); + const focusHost = closestBlockHost(domSel.focusNode) ?? anchorHost; + + if (!anchorHost || !focusHost) + return null; + + const anchorId = anchorHost.dataset['blockId']; + const focusId = focusHost.dataset['blockId']; + + if (!anchorId || !focusId) + return null; + + const anchorOffset = domPointToOffset(anchorHost, domSel.anchorNode, domSel.anchorOffset); + const focusOffset = domSel.focusNode + ? domPointToOffset(focusHost, domSel.focusNode, domSel.focusOffset) + : anchorOffset; + + return textSelection({ blockId: anchorId, offset: anchorOffset }, { blockId: focusId, offset: focusOffset }); + } + + function write(selection: Selection): void { + const root = getRoot(); + const domSel = getWindow()?.getSelection(); + + if (!root || !domSel) + return; + + if (selection.kind === 'node') { + // Block-level selection has no native text range; the visual highlight + // comes from [data-selected] on the block wrapper. Keep the editable root + // focused so keyboard commands (Backspace/Delete on the node) still reach it. + domSel.removeAllRanges(); + if (root.isContentEditable && root.ownerDocument.activeElement !== root) + root.focus({ preventScroll: true }); + return; + } + + const anchorHost = hostFor(selection.anchor.blockId); + const focusHost = hostFor(selection.focus.blockId); + + if (!anchorHost || !focusHost) + return; + + // Focus the ONE editable root (not the block) so the caret renders. + if (root.isContentEditable && root.ownerDocument.activeElement !== root) + root.focus({ preventScroll: true }); + + const anchorPoint = offsetToDomPoint(anchorHost, selection.anchor.offset); + const focusPoint = offsetToDomPoint(focusHost, selection.focus.offset); + + try { + domSel.setBaseAndExtent(anchorPoint.node, anchorPoint.offset, focusPoint.node, focusPoint.offset); + } + catch { + // Invalid DOM points (e.g. mid-reconcile) — ignore; the next reconcile fixes it. + } + } + + return { + read, + write, + save: read, + restore: (selection) => { + if (selection) + write(selection); + }, + domPointToOffset, + offsetToDomPoint, + }; +} diff --git a/vue/editor/src/view/ui/EditorBubbleMenu.vue b/vue/editor/src/view/ui/EditorBubbleMenu.vue new file mode 100644 index 0000000..57d777b --- /dev/null +++ b/vue/editor/src/view/ui/EditorBubbleMenu.vue @@ -0,0 +1,86 @@ + + + diff --git a/vue/editor/src/view/ui/EditorRemoteCursors.vue b/vue/editor/src/view/ui/EditorRemoteCursors.vue new file mode 100644 index 0000000..9389f49 --- /dev/null +++ b/vue/editor/src/view/ui/EditorRemoteCursors.vue @@ -0,0 +1,125 @@ + + + diff --git a/vue/editor/src/view/ui/EditorSlashMenu.vue b/vue/editor/src/view/ui/EditorSlashMenu.vue new file mode 100644 index 0000000..c6d4ebd --- /dev/null +++ b/vue/editor/src/view/ui/EditorSlashMenu.vue @@ -0,0 +1,187 @@ + + + diff --git a/vue/editor/src/view/ui/index.ts b/vue/editor/src/view/ui/index.ts new file mode 100644 index 0000000..ff705e4 --- /dev/null +++ b/vue/editor/src/view/ui/index.ts @@ -0,0 +1,8 @@ +export * from './slash-items'; + +export { default as EditorBubbleMenu } from './EditorBubbleMenu.vue'; +export type { EditorBubbleMenuProps } from './EditorBubbleMenu.vue'; +export { default as EditorSlashMenu } from './EditorSlashMenu.vue'; +export type { EditorSlashMenuProps } from './EditorSlashMenu.vue'; +export { default as EditorRemoteCursors } from './EditorRemoteCursors.vue'; +export type { EditorRemoteCursorsProps } from './EditorRemoteCursors.vue'; diff --git a/vue/editor/src/view/ui/slash-items.ts b/vue/editor/src/view/ui/slash-items.ts new file mode 100644 index 0000000..912b2fd --- /dev/null +++ b/vue/editor/src/view/ui/slash-items.ts @@ -0,0 +1,33 @@ +import type { Registry } from '../../registry'; + +/** A slash-menu entry derived from a block definition's metadata. */ +export interface SlashItem { + type: string; + title: string; + group: string; + keywords: readonly string[]; +} + +/** + * Build slash-menu items from the registry, filtered by `query` against each + * block's title and keywords. Data-driven: any newly registered block with + * `meta` shows up automatically. + */ +export function getSlashItems(registry: Registry, query = ''): SlashItem[] { + const items: SlashItem[] = registry.listBlocks() + .filter(def => def.meta !== undefined) + .map(def => ({ + type: def.type, + title: def.meta!.title, + group: def.meta!.group ?? 'blocks', + keywords: def.meta!.keywords ?? [], + })); + + const q = query.trim().toLowerCase(); + if (!q) + return items; + + return items.filter(item => + item.title.toLowerCase().includes(q) || item.keywords.some(keyword => keyword.toLowerCase().includes(q)), + ); +} diff --git a/vue/editor/tsconfig.json b/vue/editor/tsconfig.json new file mode 100644 index 0000000..2781e66 --- /dev/null +++ b/vue/editor/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.src.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/vue/editor/tsconfig.node.json b/vue/editor/tsconfig.node.json new file mode 100644 index 0000000..edc474f --- /dev/null +++ b/vue/editor/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.node.json", + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + }, + "include": ["*.config.ts"] +} diff --git a/vue/editor/tsconfig.src.json b/vue/editor/tsconfig.src.json new file mode 100644 index 0000000..522e7c7 --- /dev/null +++ b/vue/editor/tsconfig.src.json @@ -0,0 +1,12 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.vue.json", + "compilerOptions": { + "composite": true, + "types": [], + "paths": { + "@/*": ["./src/*"] + }, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.src.tsbuildinfo" + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/vue/editor/tsdown.config.ts b/vue/editor/tsdown.config.ts new file mode 100644 index 0000000..c315bda --- /dev/null +++ b/vue/editor/tsdown.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'tsdown'; +import { sharedConfig } from '@robonen/tsdown'; +import Vue from 'unplugin-vue/rolldown'; + +export default defineConfig({ + ...sharedConfig, + tsconfig: './tsconfig.src.json', + entry: ['src/index.ts', 'src/*/index.ts'], + plugins: [Vue({ isProduction: true })], + dts: { vue: true }, + deps: { + neverBundle: ['vue'], + alwaysBundle: [/^@robonen\//, '@vue/shared'], + }, + inputOptions: { + resolve: { + alias: { + '@vue/shared': '@vue/shared/dist/shared.esm-bundler.js', + }, + }, + }, + outputOptions: { + ...sharedConfig.outputOptions, + chunkFileNames: 'shared/[name]-[hash].js', + }, + define: { + __DEV__: 'false', + }, +}); diff --git a/vue/editor/vitest.config.ts b/vue/editor/vitest.config.ts new file mode 100644 index 0000000..0589dcc --- /dev/null +++ b/vue/editor/vitest.config.ts @@ -0,0 +1,42 @@ +import { resolve } from 'node:path'; +import { playwright } from '@vitest/browser-playwright'; +import Vue from 'unplugin-vue/vite'; +import { defineConfig } from 'vitest/config'; + +const alias = { '@': resolve(__dirname, './src') }; + +export default defineConfig({ + plugins: [Vue()], + define: { + __DEV__: 'true', + }, + resolve: { alias }, + test: { + projects: [ + { + // Pure logic: model, schema, registry, state, commands. + extends: true, + test: { + name: 'logic', + environment: 'jsdom', + include: ['src/**/*.test.ts'], + exclude: ['src/view/**', 'src/keymap/**'], + }, + }, + { + // DOM-heavy: view rendering, selection, hotkeys. + extends: true, + test: { + name: 'view', + include: ['src/view/**/*.test.ts', 'src/keymap/**/*.test.ts'], + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [{ browser: 'chromium' }], + }, + }, + }, + ], + }, +});