diff --git a/vue/editor/docs/01-playground.vue b/vue/editor/docs/01-playground.vue deleted file mode 100644 index 6943eea..0000000 --- a/vue/editor/docs/01-playground.vue +++ /dev/null @@ -1,443 +0,0 @@ - - - - - - - diff --git a/vue/editor/playground/src/App.vue b/vue/editor/playground/src/App.vue deleted file mode 100644 index 7a92b52..0000000 --- a/vue/editor/playground/src/App.vue +++ /dev/null @@ -1,156 +0,0 @@ - - - - - diff --git a/vue/editor/playground/src/Toolbar.vue b/vue/editor/playground/src/Toolbar.vue deleted file mode 100644 index d6fe6df..0000000 --- a/vue/editor/playground/src/Toolbar.vue +++ /dev/null @@ -1,34 +0,0 @@ - - - diff --git a/vue/editor/playground/src/demos/CommandsDemo.vue b/vue/editor/playground/src/demos/CommandsDemo.vue deleted file mode 100644 index 6c7cc4a..0000000 --- a/vue/editor/playground/src/demos/CommandsDemo.vue +++ /dev/null @@ -1,62 +0,0 @@ - - - diff --git a/vue/editor/playground/src/demos/ComplexBlocksDemo.vue b/vue/editor/playground/src/demos/ComplexBlocksDemo.vue deleted file mode 100644 index cb303f0..0000000 --- a/vue/editor/playground/src/demos/ComplexBlocksDemo.vue +++ /dev/null @@ -1,102 +0,0 @@ - - - diff --git a/vue/editor/playground/src/demos/MultiEditorDemo.vue b/vue/editor/playground/src/demos/MultiEditorDemo.vue deleted file mode 100644 index 6f955af..0000000 --- a/vue/editor/playground/src/demos/MultiEditorDemo.vue +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/vue/editor/src/commands/__test__/commands.test.ts b/vue/editor/src/commands/__test__/commands.test.ts deleted file mode 100644 index 0a2b009..0000000 --- a/vue/editor/src/commands/__test__/commands.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -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/view/composables/index.ts b/vue/editor/src/view/composables/index.ts deleted file mode 100644 index 32077b1..0000000 --- a/vue/editor/src/view/composables/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 637cd4e..0000000 --- a/vue/editor/src/view/composables/useContextFactory.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 8efcd7d..0000000 --- a/vue/editor/src/view/composables/useEventListener.ts +++ /dev/null @@ -1,38 +0,0 @@ -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/primitive/Primitive.ts b/vue/editor/src/view/primitive/Primitive.ts deleted file mode 100644 index 1d2726e..0000000 --- a/vue/editor/src/view/primitive/Primitive.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 04ab41e..0000000 --- a/vue/editor/src/view/primitive/Slot.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 836ee67..0000000 --- a/vue/editor/src/view/primitive/getRawChildren.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index c88baf4..0000000 --- a/vue/editor/src/view/primitive/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -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/ui/EditorBubbleMenu.vue b/vue/editor/src/view/ui/EditorBubbleMenu.vue deleted file mode 100644 index 3fc8eb7..0000000 --- a/vue/editor/src/view/ui/EditorBubbleMenu.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - diff --git a/vue/editor/src/view/ui/index.ts b/vue/editor/src/view/ui/index.ts deleted file mode 100644 index ff705e4..0000000 --- a/vue/editor/src/view/ui/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -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/AGENTS.md b/vue/writekit/AGENTS.md similarity index 73% rename from vue/editor/AGENTS.md rename to vue/writekit/AGENTS.md index cf3ebb6..16ae2db 100644 --- a/vue/editor/AGENTS.md +++ b/vue/writekit/AGENTS.md @@ -1,35 +1,35 @@ -# AGENTS.md — @robonen/editor +# AGENTS.md — @robonen/writekit 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`). +A headless block rich-text writekit for Vue with a hand-built CRDT. **Do not reach for Yjs/Loro/Automerge** — collaboration is built on `@robonen/crdt` (sibling package in `core/crdt`). ## Editing model: single contenteditable -There is **one** `contenteditable` element — `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. +There is **one** `contenteditable` element — `WritekitContent`. Blocks are plain child elements inside it; atom blocks (image, divider) are `contenteditable="false"` islands. We deliberately do **not** use per-block contenteditable: separate editing hosts make native cross-block mouse selection and arrow navigation impossible, which breaks the Word-like behavior we require. Consequence: cross-block selection and caret navigation are handled natively by the browser; the model only mirrors the DOM selection as `{ blockId, offset }`. The cross-block arrow *commands* were intentionally deleted — don't re-add them. ## Layers (data flow is one-directional) ``` -input/command → Transaction (steps) → dispatch → new EditorState → view reacts (+ CRDT) +input/command → Transaction (steps) → dispatch → new WritekitState → view reacts (+ CRDT) ``` All of these are **DOM-free and Vue-free** (typecheck/test under plain Node): -- `model/` — pure data: `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. +- `model/` — pure data: `WritekitDocument`, `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`). +- `state/` — `WritekitState`, `Step` (atomic, invertible, serializable — the unit of undo **and** the CRDT contract), `Transaction` (fluent builder), `applyStep`/`applyTransaction`, `history`, `createWritekit` (controller + `PubSub`). - `commands/` — `(state, dispatch?, view?) => boolean`. One implementation, three consumers (keymap, UI, programmatic). `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). +- `view/` — `WritekitRoot` (provider + keydown/selectionchange owner), `WritekitContent` (THE contenteditable; owns beforeinput/input/composition), `BlockView` (resolves the block def; text → `TextBlockHost`, atom → the def's component), `TextBlockHost` (renders runs **imperatively** for caret stability), `inline-content/` (render/parse runs ↔ DOM), `selection/` (DOM ↔ model selection bridge), `ui/` (slash menu, bubble menu, remote cursors). - `blocks/` — concrete blocks (+ `.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()`. @@ -42,10 +42,10 @@ When touching the view, preserve: `:key="block.id"`, the imperative inner render ## CRDT mapping (`crdt/native/document-crdt.ts`) -`DocumentCrdt` maps the editor's **offset-based** steps ↔ **id-based** CRDT ops, and materializes an `EditorDocument`: +`DocumentCrdt` maps the writekit's **offset-based** steps ↔ **id-based** CRDT ops, and materializes an `WritekitDocument`: - 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). +- Text → `Rga` (one node per **UTF-16 code unit** — must match the writekit's offset space; do not iterate code points). - Marks → `MarkStore` (Peritext-ish spans anchored to char ids, LWW per char/type). Invariants that have already bitten us — keep them: @@ -60,7 +60,7 @@ When changing the adapter, add/extend a two-replica convergence test in `crdt/__ ## 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`. +- ESLint (`compose(base, typescript, vue, imports, stylistic, …)`). `sort-imports` warnings are tolerated; **errors must be zero**. Run `pnpm --filter @robonen/writekit 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. diff --git a/vue/editor/README.md b/vue/writekit/README.md similarity index 63% rename from vue/editor/README.md rename to vue/writekit/README.md index edbb328..ab42eef 100644 --- a/vue/editor/README.md +++ b/vue/writekit/README.md @@ -1,6 +1,6 @@ -# @robonen/editor +# @robonen/writekit -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). +A headless, block-based rich-text writekit for Vue 3 — in the spirit of Tiptap / ProseMirror / Editor.js, but with a **hand-built CRDT** for collaboration (no Yjs/Loro/Automerge). - **Block registry + schema** — blocks and inline marks are modular, registered through a registry that projects an immutable schema. - **Single contenteditable** — one editable surface (ProseMirror/Tiptap model), so native cross-block mouse selection and arrow navigation behave like a normal document. @@ -13,35 +13,35 @@ A headless, block-based rich-text editor for Vue 3 — in the spirit of Tiptap / ## Install ```bash -pnpm add @robonen/editor @robonen/crdt vue +pnpm add @robonen/writekit @robonen/crdt vue ``` ## Quick start ```vue ``` -`EditorRoot`'s default slot renders `EditorContent` (the single contenteditable). Provide your own slot to add UI around it: +`WritekitRoot`'s default slot renders `WritekitContent` (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. +The package is **headless**: it ships behavior and DOM structure (`data-block-*`, `data-writekit-*` hooks), not styling. See `playground/src/App.vue` for a complete stylesheet. ## Blocks & marks @@ -50,7 +50,7 @@ Built-in blocks (via `createDefaultRegistry()`): `paragraph`, `heading` (1–6), Add your own — no core changes needed: ```ts -import { createRegistry, defineBlock, defineMark, defaultBlocks, defaultMarks } from '@robonen/editor'; +import { createRegistry, defineBlock, defineMark, defaultBlocks, defaultMarks } from '@robonen/writekit'; const spoiler = defineMark({ type: 'spoiler', @@ -62,10 +62,10 @@ const registry = createRegistry({ blocks: defaultBlocks, marks: [...defaultMarks ## 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 }"`). +- **Slash menu** — `WritekitSlashMenu`: type `/` at the start of a line; items come from each block's `meta`. +- **Bubble toolbar** — `WritekitBubbleMenu`: 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. +- **Drag to reorder** — pass `draggable` to `WritekitRoot` 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 @@ -73,24 +73,24 @@ const registry = createRegistry({ blocks: defaultBlocks, marks: [...defaultMarks Commands are `(state, dispatch?, view?) => boolean` and power the keymap, UI, and programmatic edits: ```ts -import { toggleMark, setBlockType } from '@robonen/editor'; +import { toggleMark, setBlockType } from '@robonen/writekit'; -editor.command(toggleMark('bold')); -editor.command(setBlockType('heading', { level: 2 })); +writekit.command(toggleMark('bold')); +writekit.command(setBlockType('heading', { level: 2 })); ``` Called without `dispatch` they are a dry run (for disabled/active toolbar state). ## Collaboration (own CRDT) -The 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. +The writekit is CRDT-agnostic behind `CrdtProvider`. The built-in provider maps writekit steps to CRDT ops (blocks → fractional-indexed set, text → RGA, formatting → mark store) and syncs op batches over any transport. ```ts -import { bindCrdt, createNativeProvider } from '@robonen/editor'; +import { bindCrdt, createNativeProvider } from '@robonen/writekit'; // 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); +const provider = createNativeProvider({ schema: registry.schema, doc: writekit.state.doc, user: { name: 'Alice', color: '#2563eb' } }); +const binding = bindCrdt(writekit, provider); // Wire a transport (BroadcastChannel / WebSocket / …): provider.onLocalOps(bytes => channel.postMessage(bytes)); @@ -101,7 +101,7 @@ channel.onmessage = e => provider.applyUpdate(e.data); // provider.applyUpdate(remoteFullState); // = peerA.encodeDelta() ``` -Presence/cursors travel on a separate ephemeral channel and render with `EditorRemoteCursors`: +Presence/cursors travel on a separate ephemeral channel and render with `WritekitRemoteCursors`: ```ts provider.onLocalAwareness(bytes => channel.postMessage(bytes)); @@ -110,10 +110,10 @@ provider.onAwareness(next => cursors.value = next); ``` ```vue - - - - + + + + ``` See the **Collaboration** demo in the playground for a full two-replica example. @@ -135,9 +135,9 @@ provider.gc(); // drops deleted characters / removed blocks safe to forget ## 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 +pnpm --filter @robonen/writekit test # logic (jsdom) + CRDT convergence +pnpm --filter @robonen/writekit build # tsdown (ESM + CJS + dts) +pnpm --filter @robonen/writekit-playground dev ``` See [AGENTS.md](./AGENTS.md) for the architecture and contributor notes. diff --git a/vue/writekit/docs/01-playground.vue b/vue/writekit/docs/01-playground.vue new file mode 100644 index 0000000..e5d26c8 --- /dev/null +++ b/vue/writekit/docs/01-playground.vue @@ -0,0 +1,443 @@ + + + + + + + diff --git a/vue/editor/docs/intro.vue b/vue/writekit/docs/intro.vue similarity index 56% rename from vue/editor/docs/intro.vue rename to vue/writekit/docs/intro.vue index fd9ff98..52fe1b7 100644 --- a/vue/editor/docs/intro.vue +++ b/vue/writekit/docs/intro.vue @@ -1,37 +1,37 @@