- Live @robonen/editor instances built with the default registry — the real
- headless controller, single-contenteditable view, and CRDT-backed model from the API
- reference. Switch tabs to explore the capabilities.
-
- Alice
- Bob
-
-
- {{ inSync ? 'in sync' : 'diverged' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Each pane is a separate CRDT replica synced over an in-memory channel. Toggle
- Offline, edit both sides so they diverge, then reconnect — the replicas
- converge automatically (no Yjs).
-
-
-
-
-
-
- Loading editor…
-
-
-
-
-
-
How it's wired
-
- The editor is created from a registry and a document, then rendered with a single
- EditorRoot. Multiplayer is just two editors, each bound to its own CRDT
- replica with bindCrdt, exchanging ops over any transport.
-
-
-
-
-
-
- See createEditor,
- bindCrdt and
- toggleMark in the API reference for the full surface.
-
Type / to insert a block; select text for the bubble toolbar; hover a block and drag the ⠿ handle to reorder. Markdown shortcuts work too: # , - , > , 1. , [] .
Two independent editors on one page — selection and editing in one must never affect the other.
-
-
-
-
-
-
-
-
-
-
-
-
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 @@
+
+
+
+
+
+
+
+
Playground
+
+ Live @robonen/writekit instances built with the default registry — the real
+ headless controller, single-contenteditable view, and CRDT-backed model from the API
+ reference. Switch tabs to explore the capabilities.
+
+ Alice
+ Bob
+
+
+ {{ inSync ? 'in sync' : 'diverged' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Each pane is a separate CRDT replica synced over an in-memory channel. Toggle
+ Offline, edit both sides so they diverge, then reconnect — the replicas
+ converge automatically (no Yjs).
+
+
+
+
+
+
+ Loading writekit…
+
+
+
+
+
+
How it's wired
+
+ The writekit is created from a registry and a document, then rendered with a single
+ WritekitRoot. Multiplayer is just two writekits, each bound to its own CRDT
+ replica with bindCrdt, exchanging ops over any transport.
+
+
+
+
+
+
+ See createWritekit,
+ bindCrdt and
+ toggleMark in the API reference for the full surface.
+
+
+
+
+
+
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 @@
-
@robonen/editor
+
@robonen/writekit
- A headless, block-based rich-text editor for Vue 3 — in the spirit of
+ A headless, block-based rich-text writekit for Vue 3 — in the spirit of
Tiptap / ProseMirror / Editor.js, but with a registry-driven schema and a
hand-built CRDT for collaboration (no Yjs / Loro / Automerge).
- Most editors force a trade: the structured, block-first authoring of Editor.js, or the
+ Most writekits force a trade: the structured, block-first authoring of Editor.js, or the
document fidelity of ProseMirror where native cross-block selection and arrow navigation
- just work. @robonen/editor takes the ProseMirror route — a single
+ just work. @robonen/writekit takes the ProseMirror route — a single
contenteditable surface — and layers a modular block registry on top, so blocks
and inline marks are added without touching the core. The model, schema, state, commands and
keymap are entirely DOM-free and Vue-free; the Vue layer only renders and handles input.
@@ -51,33 +51,33 @@ const canBold = editor.command(toggleMark('bold'));`;
-
-
Headless by design
-
- Ships behavior and DOM structure (data-block-*
+
+
Headless by design
+
+ Ships behavior and DOM structure (data-block-*
hooks), never styling. Bring your own CSS and own the look completely.
-
-
Registry-driven schema
-
- defineBlock /
- defineMark register into an immutable schema —
+
+
Registry-driven schema
+
+ defineBlock /
+ defineMark register into an immutable schema —
add a custom block or mark with no core changes.
-
-
Step-based transactions
-
+
+
Step-based transactions
+
Every edit is a step with an exact inverse, powering reliable undo/redo and a single source
of truth for both local edits and sync.
-
-
Own CRDT, pluggable
-
+
+
Own CRDT, pluggable
+
RGA text, fractional-indexed blocks, Peritext-style marks and presence behind a
- CrdtProvider — over any transport.
+ CrdtProvider — over any transport.
- The editor depends on @robonen/crdt for the built-in collaboration provider, and
+ The writekit depends on @robonen/crdt for the built-in collaboration provider, and
on vue as a peer.
-
+
Quick start
- Create a registry, build an editor around its state, and mount EditorRoot. Its
- default slot renders EditorContent (the single contenteditable), so
- this is a fully working editor with all built-in blocks and marks.
+ Create a registry, build an writekit around its state, and mount WritekitRoot. Its
+ default slot renders WritekitContent (the single contenteditable), so
+ this is a fully working writekit with all built-in blocks and marks.
Commands are (state, dispatch?, view?) => boolean functions that power the
- keymap, the UI, and programmatic edits. Run one with editor.command(...); omit
+ keymap, the UI, and programmatic edits. Run one with writekit.command(...); omit
the dispatch to dry-run it for active/disabled state.
Status: v0, work in progress.
Core logic is covered by unit + convergence tests; the contenteditable / Playwright suite
runs locally. The collaboration layer has a few documented, deferred limitations.
@@ -147,26 +147,26 @@ const canBold = editor.command(toggleMark('bold'));`;
Jump into the pieces you'll reach for first:
- Playground — a live editor
+ Playground — a live writekit
you can type in, right here in the docs.
- EditorRoot and EditorContent — the mount
+ WritekitRoot and WritekitContent — the mount
surface and the single contenteditable.
- createDefaultRegistry,
- defineBlock and
- defineMark — extend the schema.
+ createDefaultRegistry,
+ defineBlock and
+ defineMark — extend the schema.
- toggleMark /
- setBlockType — the commands
+ toggleMark /
+ setBlockType — the commands
API for programmatic and toolbar edits.
- bindCrdt and
- createNativeProvider
+ bindCrdt and
+ createNativeProvider
— wire up real-time collaboration with the built-in CRDT.
Type / to insert a block; select text for the bubble toolbar; hover a block and drag the ⠿ handle to reorder. Markdown shortcuts work too: # , - , > , 1. , [] .
+
+
+
+
+
+ document JSON
{{ docJson }}
+
+
diff --git a/vue/editor/playground/src/demos/CustomKeymapDemo.vue b/vue/writekit/playground/src/demos/CustomKeymapDemo.vue
similarity index 74%
rename from vue/editor/playground/src/demos/CustomKeymapDemo.vue
rename to vue/writekit/playground/src/demos/CustomKeymapDemo.vue
index 8bd5bb4..97621d0 100644
--- a/vue/editor/playground/src/demos/CustomKeymapDemo.vue
+++ b/vue/writekit/playground/src/demos/CustomKeymapDemo.vue
@@ -1,10 +1,10 @@
Many blocks
60 blocks — test cross-block drag over a long range, ↑/↓ navigation, and Cmd/Ctrl+A (once = current block, twice = whole document).
-
-
+
+
diff --git a/vue/editor/playground/src/demos/MarksDemo.vue b/vue/writekit/playground/src/demos/MarksDemo.vue
similarity index 76%
rename from vue/editor/playground/src/demos/MarksDemo.vue
rename to vue/writekit/playground/src/demos/MarksDemo.vue
index bc37bd1..017741d 100644
--- a/vue/editor/playground/src/demos/MarksDemo.vue
+++ b/vue/writekit/playground/src/demos/MarksDemo.vue
@@ -1,9 +1,9 @@
+
+
+
+
Multiple writekits
+
Two independent writekits on one page — selection and editing in one must never affect the other.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/editor/playground/src/demos/ReadOnlyDemo.vue b/vue/writekit/playground/src/demos/ReadOnlyDemo.vue
similarity index 67%
rename from vue/editor/playground/src/demos/ReadOnlyDemo.vue
rename to vue/writekit/playground/src/demos/ReadOnlyDemo.vue
index f690608..713ec87 100644
--- a/vue/editor/playground/src/demos/ReadOnlyDemo.vue
+++ b/vue/writekit/playground/src/demos/ReadOnlyDemo.vue
@@ -1,8 +1,8 @@
diff --git a/vue/editor/playground/src/env.d.ts b/vue/writekit/playground/src/env.d.ts
similarity index 100%
rename from vue/editor/playground/src/env.d.ts
rename to vue/writekit/playground/src/env.d.ts
diff --git a/vue/editor/playground/src/lib.ts b/vue/writekit/playground/src/lib.ts
similarity index 82%
rename from vue/editor/playground/src/lib.ts
rename to vue/writekit/playground/src/lib.ts
index dbe8763..d1348ee 100644
--- a/vue/editor/playground/src/lib.ts
+++ b/vue/writekit/playground/src/lib.ts
@@ -1,11 +1,11 @@
-import type { Editor, Inline, InlineNode, Node } from '@editor';
+import type { Inline, InlineNode, Node, Writekit } from '@writekit';
import {
createDefaultRegistry,
createDoc,
- createEditor,
- createEditorState,
createNode,
-} from '@editor';
+ createWritekit,
+ createWritekitState,
+} from '@writekit';
/** A styled inline run: `t('hello', 'bold', 'italic')`. */
export function t(text: string, ...markTypes: string[]): InlineNode {
@@ -32,8 +32,8 @@ export const todo = (text: string, checked = false): Node => createNode('todo-li
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 {
+/** Create an writekit over the given blocks with the default registry. */
+export function makeWritekit(content: Node[]): Writekit {
const registry = createDefaultRegistry();
- return createEditor({ state: createEditorState({ registry, doc: createDoc(content) }) });
+ return createWritekit({ state: createWritekitState({ registry, doc: createDoc(content) }) });
}
diff --git a/vue/editor/playground/src/main.ts b/vue/writekit/playground/src/main.ts
similarity index 100%
rename from vue/editor/playground/src/main.ts
rename to vue/writekit/playground/src/main.ts
diff --git a/vue/editor/playground/tsconfig.json b/vue/writekit/playground/tsconfig.json
similarity index 68%
rename from vue/editor/playground/tsconfig.json
rename to vue/writekit/playground/tsconfig.json
index 966ac28..be72833 100644
--- a/vue/editor/playground/tsconfig.json
+++ b/vue/writekit/playground/tsconfig.json
@@ -3,8 +3,8 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
- "@editor": ["../src/index.ts"],
- "@editor/*": ["../src/*"]
+ "@writekit": ["../src/index.ts"],
+ "@writekit/*": ["../src/*"]
}
},
"include": ["src", "vite.config.ts"]
diff --git a/vue/editor/playground/vite.config.ts b/vue/writekit/playground/vite.config.ts
similarity index 90%
rename from vue/editor/playground/vite.config.ts
rename to vue/writekit/playground/vite.config.ts
index b3507e9..54df9cb 100644
--- a/vue/editor/playground/vite.config.ts
+++ b/vue/writekit/playground/vite.config.ts
@@ -10,11 +10,11 @@ export default defineConfig(({ mode }) => ({
resolve: {
alias: [
{
- find: /^@editor\/(.*)$/,
+ find: /^@writekit\/(.*)$/,
replacement: fileURLToPath(new URL('../src/$1', import.meta.url)),
},
{
- find: /^@editor$/,
+ find: /^@writekit$/,
replacement: fileURLToPath(new URL('../src/index.ts', import.meta.url)),
},
],
diff --git a/vue/editor/src/__test__/interactive.test.ts b/vue/writekit/src/__test__/interactive.test.ts
similarity index 96%
rename from vue/editor/src/__test__/interactive.test.ts
rename to vue/writekit/src/__test__/interactive.test.ts
index 83ca4b0..edfd435 100644
--- a/vue/editor/src/__test__/interactive.test.ts
+++ b/vue/writekit/src/__test__/interactive.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { isInteractiveTarget } from '../view/interactive';
describe('isInteractiveTarget', () => {
- it('matches atom controls and contenteditable=false islands, not editor text', () => {
+ it('matches atom controls and contenteditable=false islands, not writekit text', () => {
const root = document.createElement('div');
root.setAttribute('contenteditable', 'true');
root.innerHTML = '
hi
';
diff --git a/vue/editor/src/__test__/selection-bridge.test.ts b/vue/writekit/src/__test__/selection-bridge.test.ts
similarity index 93%
rename from vue/editor/src/__test__/selection-bridge.test.ts
rename to vue/writekit/src/__test__/selection-bridge.test.ts
index 41620ca..a0c2eeb 100644
--- a/vue/editor/src/__test__/selection-bridge.test.ts
+++ b/vue/writekit/src/__test__/selection-bridge.test.ts
@@ -3,13 +3,13 @@ import { createBlockElementRegistry, createSelectionBridge } from '../view/selec
/**
* Builds the single-contenteditable DOM shape the view produces: one
- * `[data-editor-content]` root containing plain `[data-block-content]` block
+ * `[data-writekit-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', '');
+ root.setAttribute('data-writekit-content', '');
const a = document.createElement('p');
a.setAttribute('data-block-content', '');
diff --git a/vue/editor/src/__test__/slash-items.test.ts b/vue/writekit/src/__test__/slash-items.test.ts
similarity index 100%
rename from vue/editor/src/__test__/slash-items.test.ts
rename to vue/writekit/src/__test__/slash-items.test.ts
diff --git a/vue/editor/src/blocks/DividerBlock.vue b/vue/writekit/src/blocks/DividerBlock.vue
similarity index 81%
rename from vue/editor/src/blocks/DividerBlock.vue
rename to vue/writekit/src/blocks/DividerBlock.vue
index c6d5352..c119170 100644
--- a/vue/editor/src/blocks/DividerBlock.vue
+++ b/vue/writekit/src/blocks/DividerBlock.vue
@@ -5,5 +5,5 @@ defineProps();
-
+
diff --git a/vue/editor/src/blocks/ImageBlock.vue b/vue/writekit/src/blocks/ImageBlock.vue
similarity index 93%
rename from vue/editor/src/blocks/ImageBlock.vue
rename to vue/writekit/src/blocks/ImageBlock.vue
index 9c2aad3..4deb26d 100644
--- a/vue/editor/src/blocks/ImageBlock.vue
+++ b/vue/writekit/src/blocks/ImageBlock.vue
@@ -15,7 +15,7 @@ function set(key: string, event: Event): void {
-
+
No image — add a URL below
diff --git a/vue/editor/src/blocks/__test__/blocks.test.ts b/vue/writekit/src/blocks/__test__/blocks.test.ts
similarity index 63%
rename from vue/editor/src/blocks/__test__/blocks.test.ts
rename to vue/writekit/src/blocks/__test__/blocks.test.ts
index 02cb27a..3777a89 100644
--- a/vue/editor/src/blocks/__test__/blocks.test.ts
+++ b/vue/writekit/src/blocks/__test__/blocks.test.ts
@@ -2,11 +2,11 @@ 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';
+import { createWritekit, createWritekitState } from '../../state';
-function editorWith(node: ReturnType, selection?: ReturnType) {
+function writekitWith(node: ReturnType, selection?: ReturnType) {
const registry = createDefaultRegistry();
- return createEditor({ state: createEditorState({ registry, doc: createDoc([node]), selection }) });
+ return createWritekit({ state: createWritekitState({ registry, doc: createDoc([node]), selection }) });
}
describe('default registry', () => {
@@ -31,24 +31,24 @@ describe('default registry', () => {
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');
+ const writekit = writekitWith(createNode('code-block', { id: 'c', content: [{ text: 'ab', marks: [] }] }), caret('c', 2));
+ expect(writekit.command(splitBlock)).toBe(true);
+ expect(writekit.state.doc.content.length).toBe(1);
+ expect(nodeText(writekit.state.doc.content[0]!)).toBe('ab\n');
});
it('disallows inline marks', () => {
- const editor = editorWith(
+ const writekit = writekitWith(
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);
+ expect(writekit.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({
+ const writekit = createWritekit({
+ state: createWritekitState({
registry,
doc: createDoc([
createNode('code-block', { id: 'c', content: [{ text: 'x', marks: [] }] }),
@@ -58,8 +58,8 @@ describe('code block', () => {
}),
});
- expect(editor.command(joinBackward)).toBe(true);
- const merged = editor.state.doc.content[0]!;
+ expect(writekit.command(joinBackward)).toBe(true);
+ const merged = writekit.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);
@@ -68,36 +68,36 @@ describe('code block', () => {
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]!;
+ const writekit = writekitWith(createNode('paragraph', { id: 'p', content: [{ text: '# ', marks: [] }] }), caret('p', 2));
+ expect(writekit.command(applyInputRule)).toBe(true);
+ const block = writekit.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');
+ const writekit = writekitWith(createNode('paragraph', { id: 'p', content: [{ text: '- ', marks: [] }] }), caret('p', 2));
+ expect(writekit.command(applyInputRule)).toBe(true);
+ expect(writekit.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);
+ const writekit = writekitWith(createNode('blockquote', { id: 'q', content: [{ text: '> ', marks: [] }] }), caret('q', 2));
+ expect(writekit.command(applyInputRule)).toBe(false);
});
});
describe('to-do list', () => {
it('starts a new item unchecked when splitting a checked one', () => {
- const editor = editorWith(
+ const writekit = writekitWith(
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(writekit.command(splitBlock)).toBe(true);
+ expect(writekit.state.doc.content.length).toBe(2);
+ const created = writekit.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/writekit/src/blocks/blockquote.ts
similarity index 100%
rename from vue/editor/src/blocks/blockquote.ts
rename to vue/writekit/src/blocks/blockquote.ts
diff --git a/vue/editor/src/blocks/callout.ts b/vue/writekit/src/blocks/callout.ts
similarity index 100%
rename from vue/editor/src/blocks/callout.ts
rename to vue/writekit/src/blocks/callout.ts
diff --git a/vue/editor/src/blocks/code-block.ts b/vue/writekit/src/blocks/code-block.ts
similarity index 100%
rename from vue/editor/src/blocks/code-block.ts
rename to vue/writekit/src/blocks/code-block.ts
diff --git a/vue/editor/src/blocks/divider.ts b/vue/writekit/src/blocks/divider.ts
similarity index 100%
rename from vue/editor/src/blocks/divider.ts
rename to vue/writekit/src/blocks/divider.ts
diff --git a/vue/editor/src/blocks/heading.ts b/vue/writekit/src/blocks/heading.ts
similarity index 100%
rename from vue/editor/src/blocks/heading.ts
rename to vue/writekit/src/blocks/heading.ts
diff --git a/vue/editor/src/blocks/image.ts b/vue/writekit/src/blocks/image.ts
similarity index 100%
rename from vue/editor/src/blocks/image.ts
rename to vue/writekit/src/blocks/image.ts
diff --git a/vue/editor/src/blocks/index.ts b/vue/writekit/src/blocks/index.ts
similarity index 100%
rename from vue/editor/src/blocks/index.ts
rename to vue/writekit/src/blocks/index.ts
diff --git a/vue/editor/src/blocks/list.ts b/vue/writekit/src/blocks/list.ts
similarity index 100%
rename from vue/editor/src/blocks/list.ts
rename to vue/writekit/src/blocks/list.ts
diff --git a/vue/editor/src/blocks/paragraph.ts b/vue/writekit/src/blocks/paragraph.ts
similarity index 100%
rename from vue/editor/src/blocks/paragraph.ts
rename to vue/writekit/src/blocks/paragraph.ts
diff --git a/vue/writekit/src/commands/__test__/commands.test.ts b/vue/writekit/src/commands/__test__/commands.test.ts
new file mode 100644
index 0000000..bf6788d
--- /dev/null
+++ b/vue/writekit/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 { createWritekit, createWritekitState } from '../../state';
+import { joinBackward, splitBlock, toggleMark } from '..';
+
+function para(id: string, text: string) {
+ return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
+}
+
+function writekitWith(blocks: Array>, selection?: ReturnType) {
+ const registry = createDefaultRegistry();
+ return createWritekit({ state: createWritekitState({ registry, doc: createDoc(blocks), selection }) });
+}
+
+describe('commands', () => {
+ it('toggleMark applies then removes bold on a range', () => {
+ const registry = createDefaultRegistry();
+ const writekit = createWritekit({
+ state: createWritekitState({
+ registry,
+ doc: createDoc([para('a', 'abc')]),
+ selection: textSelection({ blockId: 'a', offset: 0 }, { blockId: 'a', offset: 3 }),
+ }),
+ });
+
+ expect(writekit.command(toggleMark('bold'))).toBe(true);
+ expect(nodeInline(writekit.state.doc.content[0]!)).toEqual([{ text: 'abc', marks: [{ type: 'bold' }] }]);
+
+ writekit.command(toggleMark('bold'));
+ expect(nodeInline(writekit.state.doc.content[0]!)).toEqual([{ text: 'abc', marks: [] }]);
+ });
+
+ it('splitBlock splits at the caret', () => {
+ const writekit = writekitWith([para('a', 'hello')], caret('a', 2));
+ expect(writekit.command(splitBlock)).toBe(true);
+ expect(writekit.state.doc.content.map(block => nodeText(block))).toEqual(['he', 'llo']);
+ expect(writekit.state.selection.kind).toBe('text');
+ });
+
+ it('joinBackward merges into the previous block', () => {
+ const writekit = writekitWith([para('a', 'foo'), para('b', 'bar')], caret('b', 0));
+ expect(writekit.command(joinBackward)).toBe(true);
+ expect(writekit.state.doc.content.map(block => nodeText(block))).toEqual(['foobar']);
+ });
+
+ it('undo restores the document after a split', () => {
+ const writekit = writekitWith([para('a', 'hello')], caret('a', 2));
+ writekit.command(splitBlock);
+ expect(writekit.state.doc.content.length).toBe(2);
+ expect(writekit.undo()).toBe(true);
+ expect(writekit.state.doc.content.map(block => nodeText(block))).toEqual(['hello']);
+ });
+});
diff --git a/vue/editor/src/commands/blocks.ts b/vue/writekit/src/commands/blocks.ts
similarity index 100%
rename from vue/editor/src/commands/blocks.ts
rename to vue/writekit/src/commands/blocks.ts
diff --git a/vue/editor/src/commands/chain.ts b/vue/writekit/src/commands/chain.ts
similarity index 100%
rename from vue/editor/src/commands/chain.ts
rename to vue/writekit/src/commands/chain.ts
diff --git a/vue/editor/src/commands/index.ts b/vue/writekit/src/commands/index.ts
similarity index 100%
rename from vue/editor/src/commands/index.ts
rename to vue/writekit/src/commands/index.ts
diff --git a/vue/editor/src/commands/input-rules.ts b/vue/writekit/src/commands/input-rules.ts
similarity index 100%
rename from vue/editor/src/commands/input-rules.ts
rename to vue/writekit/src/commands/input-rules.ts
diff --git a/vue/editor/src/commands/marks.ts b/vue/writekit/src/commands/marks.ts
similarity index 96%
rename from vue/editor/src/commands/marks.ts
rename to vue/writekit/src/commands/marks.ts
index e399976..87ff698 100644
--- a/vue/editor/src/commands/marks.ts
+++ b/vue/writekit/src/commands/marks.ts
@@ -1,11 +1,11 @@
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 type { Command, WritekitState } 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 {
+function markAllowedAtFocus(state: WritekitState, type: string): boolean {
if (state.selection.kind !== 'text')
return false;
diff --git a/vue/editor/src/commands/selection.ts b/vue/writekit/src/commands/selection.ts
similarity index 97%
rename from vue/editor/src/commands/selection.ts
rename to vue/writekit/src/commands/selection.ts
index f5d8279..37aa96d 100644
--- a/vue/editor/src/commands/selection.ts
+++ b/vue/writekit/src/commands/selection.ts
@@ -12,10 +12,10 @@ import {
previousBlock,
textSelection,
} from '../model';
-import type { Command, EditorState } from '../state';
+import type { Command, WritekitState } from '../state';
import { createTransaction } from '../state';
-function defaultTextType(state: EditorState): string {
+function defaultTextType(state: WritekitState): string {
if (state.registry.hasBlock('paragraph'))
return 'paragraph';
diff --git a/vue/editor/src/commands/structure.ts b/vue/writekit/src/commands/structure.ts
similarity index 96%
rename from vue/editor/src/commands/structure.ts
rename to vue/writekit/src/commands/structure.ts
index ecb6b1e..8179c8e 100644
--- a/vue/editor/src/commands/structure.ts
+++ b/vue/writekit/src/commands/structure.ts
@@ -11,11 +11,11 @@ import {
orderedSelection,
previousBlock,
} from '../model';
-import type { Command, EditorState } from '../state';
+import type { Command, WritekitState } 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 } {
+function continuation(state: WritekitState, 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.
diff --git a/vue/editor/src/commands/util.ts b/vue/writekit/src/commands/util.ts
similarity index 79%
rename from vue/editor/src/commands/util.ts
rename to vue/writekit/src/commands/util.ts
index fe4c47c..735b7c6 100644
--- a/vue/editor/src/commands/util.ts
+++ b/vue/writekit/src/commands/util.ts
@@ -1,21 +1,21 @@
import type { Attrs, Node } from '../model';
import { blockById, isCollapsed, marksAt, nodeInline, orderedSelection, rangeHasMarkType } from '../model';
-import type { EditorState } from '../state';
+import type { WritekitState } from '../state';
/** Block id the selection's focus is in (or the first node-selected block). */
-export function selectionBlockId(state: EditorState): string | undefined {
+export function selectionBlockId(state: WritekitState): 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 {
+export function focusBlock(state: WritekitState): 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 {
+export function isTextBlockType(state: WritekitState, type: string): boolean {
return state.schema.nodeSpec(type)?.content.kind === 'text';
}
@@ -23,7 +23,7 @@ export function isTextBlockType(state: EditorState, type: string): boolean {
* 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 {
+export function isMarkActive(state: WritekitState, type: string): boolean {
const sel = state.selection;
if (sel.kind !== 'text')
@@ -46,7 +46,7 @@ export function isMarkActive(state: EditorState, type: string): boolean {
}
/** Whether the focused block matches a type (and optionally a subset of attrs). */
-export function isBlockActive(state: EditorState, type: string, attrs?: Attrs): boolean {
+export function isBlockActive(state: WritekitState, type: string, attrs?: Attrs): boolean {
const block = focusBlock(state);
if (!block || block.type !== type)
diff --git a/vue/editor/src/crdt/__test__/convergence.test.ts b/vue/writekit/src/crdt/__test__/convergence.test.ts
similarity index 71%
rename from vue/editor/src/crdt/__test__/convergence.test.ts
rename to vue/writekit/src/crdt/__test__/convergence.test.ts
index fb9a4f1..30c1d16 100644
--- a/vue/editor/src/crdt/__test__/convergence.test.ts
+++ b/vue/writekit/src/crdt/__test__/convergence.test.ts
@@ -2,17 +2,17 @@ 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 { createTransaction, createWritekit, createWritekitState } 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 };
+ const writekit = createWritekit({ state: createWritekitState({ registry, doc: seedDoc }) });
+ const provider = createNativeProvider({ schema: registry.schema, doc: seedDoc ? writekit.state.doc : undefined });
+ bindCrdt(writekit, provider);
+ return { writekit, provider };
}
/** Live two-way, in-memory transport between two providers. */
@@ -22,10 +22,10 @@ function connect(a: ReturnType, b: ReturnType)
}
function text(peer: ReturnType): string {
- return peer.editor.state.doc.content.map(block => nodeText(block)).join('\n');
+ return peer.writekit.state.doc.content.map(block => nodeText(block)).join('\n');
}
-describe('crdt convergence (two editors)', () => {
+describe('crdt convergence (two writekits)', () => {
it('a joining peer syncs the initial document, then concurrent edits converge', () => {
const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'Hello', marks: [] }] })]);
@@ -38,8 +38,8 @@ describe('crdt convergence (two editors)', () => {
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)));
+ a.writekit.dispatch(createTransaction(a.writekit.state).insertText({ blockId: 'p', offset: 5 }, '!', []).setSelection(caret('p', 6)));
+ b.writekit.dispatch(createTransaction(b.writekit.state).insertText({ blockId: 'p', offset: 0 }, '>', []).setSelection(caret('p', 1)));
expect(text(a)).toBe(text(b));
expect(text(a)).toBe('>Hello!');
@@ -53,7 +53,7 @@ describe('crdt convergence (two editors)', () => {
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)));
+ a.writekit.dispatch(createTransaction(a.writekit.state).deleteText('p', 3, 4).setSelection(caret('p', 3)));
expect(text(a)).toBe('a👍');
expect(text(b)).toBe('a👍');
@@ -71,12 +71,12 @@ describe('crdt convergence (two editors)', () => {
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);
+ a.writekit.dispatch(createTransaction(a.writekit.state).setSelection(nodeSelection(['a', 'b'])));
+ expect(a.writekit.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(a.writekit.undo()).toBe(true);
expect(text(a)).toBe('AAA\nBBB');
expect(text(b)).toBe('AAA\nBBB');
});
@@ -94,12 +94,12 @@ describe('crdt convergence (two editors)', () => {
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)));
+ b.writekit.dispatch(createTransaction(b.writekit.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)));
+ a.writekit.dispatch(createTransaction(a.writekit.state).insertText({ blockId: 'p', offset: 0 }, '>>', []).setSelection(caret('p', 2)));
expect(cursors[0]?.selection?.kind === 'text' && cursors[0].selection.focus.offset).toBe(7);
});
@@ -111,8 +111,8 @@ describe('crdt convergence (two editors)', () => {
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)));
+ a.writekit.dispatch(createTransaction(a.writekit.state).addMark('p', 0, 2, { type: 'bold' }).setSelection(caret('p', 2)));
+ b.writekit.dispatch(createTransaction(b.writekit.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).
@@ -120,7 +120,7 @@ describe('crdt convergence (two editors)', () => {
// The bold mark survived on both replicas (somewhere in the doc).
const hasBold = (peer: ReturnType) =>
- peer.editor.state.doc.content.some(block =>
+ peer.writekit.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);
@@ -136,12 +136,12 @@ describe('crdt convergence (two editors)', () => {
b.provider.applyUpdate(a.provider.encodeDelta());
connect(a, b);
- const bBefore = b.editor.state.doc.content.find(node => node.id === 'b')!;
+ const bBefore = b.writekit.state.doc.content.find(node => node.id === 'b')!;
- a.editor.dispatch(createTransaction(a.editor.state).insertText({ blockId: 'a', offset: 3 }, '!', []).setSelection(caret('a', 4)));
+ a.writekit.dispatch(createTransaction(a.writekit.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
+ expect(nodeText(b.writekit.state.doc.content.find(node => node.id === 'a')!)).toBe('AAA!'); // changed block updated
+ expect(b.writekit.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', () => {
@@ -151,8 +151,8 @@ describe('crdt convergence (two editors)', () => {
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)));
+ a.writekit.dispatch(createTransaction(a.writekit.state).deleteText('p', 5, 11).setSelection(caret('p', 5)));
+ a.writekit.dispatch(createTransaction(a.writekit.state).addMark('p', 0, 5, { type: 'bold' }).setSelection(caret('p', 5)));
expect(text(a)).toBe('Hello');
expect(text(b)).toBe('Hello');
@@ -168,7 +168,7 @@ describe('crdt convergence (two editors)', () => {
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)));
+ a.writekit.dispatch(createTransaction(a.writekit.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/writekit/src/crdt/__test__/document-crdt.test.ts
similarity index 100%
rename from vue/editor/src/crdt/__test__/document-crdt.test.ts
rename to vue/writekit/src/crdt/__test__/document-crdt.test.ts
diff --git a/vue/editor/src/crdt/binding.ts b/vue/writekit/src/crdt/binding.ts
similarity index 63%
rename from vue/editor/src/crdt/binding.ts
rename to vue/writekit/src/crdt/binding.ts
index e84796e..cc25d32 100644
--- a/vue/editor/src/crdt/binding.ts
+++ b/vue/writekit/src/crdt/binding.ts
@@ -1,4 +1,4 @@
-import type { Editor, Transaction } from '../state';
+import type { Transaction, Writekit } from '../state';
import { createTransaction } from '../state';
import { reconcileDoc } from './reconcile';
import type { CrdtProvider } from './types';
@@ -9,29 +9,29 @@ export interface CrdtBinding {
}
/**
- * Wire a {@link CrdtProvider} to an {@link Editor}: local transactions flow into
+ * Wire a {@link CrdtProvider} to an {@link Writekit}: 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 {
+export function bindCrdt(writekit: Writekit, 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)
+ provider.setLocalSelection(writekit.state.selection); // presence (local edits + remapped remote)
}
- editor.on('transaction', onTransaction);
- provider.setLocalSelection(editor.state.selection);
+ writekit.on('transaction', onTransaction);
+ provider.setLocalSelection(writekit.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)
+ const next = reconcileDoc(writekit.state.doc, provider.load());
+ if (next === writekit.state.doc)
return; // remote ops didn't change the visible document
- editor.dispatch(createTransaction(editor.state)
+ writekit.dispatch(createTransaction(writekit.state)
.setDoc(next)
.setMeta('origin', REMOTE_ORIGIN)
.setMeta('addToHistory', false));
@@ -39,7 +39,7 @@ export function bindCrdt(editor: Editor, provider: CrdtProvider): CrdtBinding {
return {
detach: () => {
- editor.off('transaction', onTransaction);
+ writekit.off('transaction', onTransaction);
offRemote();
provider.destroy();
},
diff --git a/vue/editor/src/crdt/index.ts b/vue/writekit/src/crdt/index.ts
similarity index 81%
rename from vue/editor/src/crdt/index.ts
rename to vue/writekit/src/crdt/index.ts
index 06ff499..1240acb 100644
--- a/vue/editor/src/crdt/index.ts
+++ b/vue/writekit/src/crdt/index.ts
@@ -4,4 +4,4 @@ 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';
+export type { WritekitOp } from './native/document-crdt';
diff --git a/vue/editor/src/crdt/native/document-crdt.ts b/vue/writekit/src/crdt/native/document-crdt.ts
similarity index 93%
rename from vue/editor/src/crdt/native/document-crdt.ts
rename to vue/writekit/src/crdt/native/document-crdt.ts
index 350f5c5..fc895f5 100644
--- a/vue/editor/src/crdt/native/document-crdt.ts
+++ b/vue/writekit/src/crdt/native/document-crdt.ts
@@ -1,6 +1,6 @@
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 type { Attrs, Inline, InlineNode, Mark, Node, Selection, WritekitDocument } from '../../model';
import { createDoc, nodeSelection, normalizeInline, normalizeMarks, textSelection } from '../../model';
import type { Schema } from '../../schema';
import type { Step } from '../../state';
@@ -10,7 +10,7 @@ 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
+export type WritekitOp
= | { 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 }
@@ -39,11 +39,11 @@ function valueToMark(type: string, value: MarkValue): Mark {
}
/**
- * 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
+ * The writekit's document CRDT: a fractional-ordered set of blocks, each a text
+ * RGA + a mark store (or an attribute-only atom). It translates the writekit'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}).
+ * {@link WritekitDocument} ({@link toDocument}).
*/
export class DocumentCrdt {
private readonly blocks = new Map();
@@ -59,7 +59,7 @@ export class DocumentCrdt {
// ---------------------------------------------------------------- integrate
/** Apply one op (local or remote). Returns false if a causal dependency is missing. */
- applyOp(op: EditorOp): boolean {
+ applyOp(op: WritekitOp): boolean {
switch (op.kind) {
case 'block-insert': {
if (!this.blocks.has(op.blockId)) {
@@ -139,7 +139,7 @@ export class DocumentCrdt {
// --------------------------------------------------------------- materialize
- toDocument(): EditorDocument {
+ toDocument(): WritekitDocument {
const content: Node[] = [];
for (const blockId of this.orderedBlockIds()) {
const block = this.blocks.get(blockId)!;
@@ -263,7 +263,7 @@ export class DocumentCrdt {
// ----------------------------------------------------------------- translate
/** Generate the ops for a local step, reading current state for ids/positions. */
- translateStep(step: Step): EditorOp[] {
+ translateStep(step: Step): WritekitOp[] {
switch (step.type) {
case 'insertInline':
return this.insertInlineOps(step.blockId, step.offset, step.content);
@@ -295,8 +295,8 @@ export class DocumentCrdt {
}
/** Ops to seed the CRDT from an initial document. */
- seedFromDocument(doc: EditorDocument): EditorOp[] {
- const ops: EditorOp[] = [];
+ seedFromDocument(doc: WritekitDocument): WritekitOp[] {
+ const ops: WritekitOp[] = [];
let prevKey: string | null = null;
for (const node of doc.content) {
@@ -311,7 +311,7 @@ export class DocumentCrdt {
// ------------------------------------------------------------------ op builders
/** Ops for an `insertBlock` step: reactivate if the block already exists (undo), else create. */
- private insertBlockOps(node: Node, index: number): EditorOp[] {
+ private insertBlockOps(node: Node, index: number): WritekitOp[] {
const posKey = this.posKeyForIndex(index);
if (this.blocks.has(node.id)) {
@@ -322,9 +322,9 @@ export class DocumentCrdt {
return this.blockOps(node, posKey);
}
- private blockOps(node: Node, posKey: string): EditorOp[] {
+ private blockOps(node: Node, posKey: string): WritekitOp[] {
const isText = this.schema.nodeSpec(node.type)?.content.kind === 'text';
- const ops: EditorOp[] = [{
+ const ops: WritekitOp[] = [{
id: this.nextId(),
kind: 'block-insert',
blockId: node.id,
@@ -341,13 +341,13 @@ export class DocumentCrdt {
}
/** Insert inline `content` after the char at `afterId` (null = block start). */
- private inlineOps(blockId: string, content: Inline, afterId: OpId | null): EditorOp[] {
- const ops: EditorOp[] = [];
+ private inlineOps(blockId: string, content: Inline, afterId: OpId | null): WritekitOp[] {
+ const ops: WritekitOp[] = [];
let after = afterId;
for (const run of content) {
const charIds: OpId[] = [];
- // Iterate UTF-16 code units (not code points) to match the editor's
+ // Iterate UTF-16 code units (not code points) to match the writekit'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();
@@ -365,14 +365,14 @@ export class DocumentCrdt {
return ops;
}
- private insertInlineOps(blockId: string, offset: number, content: Inline): EditorOp[] {
+ private insertInlineOps(blockId: string, offset: number, content: Inline): WritekitOp[] {
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[] {
+ private deleteTextOps(blockId: string, from: number, to: number): WritekitOp[] {
const block = this.blocks.get(blockId);
if (!block || !block.isText)
return [];
@@ -380,7 +380,7 @@ export class DocumentCrdt {
.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[] {
+ private markOps(blockId: string, from: number, to: number, markType: string, value: MarkValue): WritekitOp[] {
const block = this.blocks.get(blockId);
if (!block || !block.isText || from >= to)
return [];
@@ -401,7 +401,7 @@ export class DocumentCrdt {
return keyBetween(before, after);
}
- private moveOps(blockId: string, toIndex: number): EditorOp[] {
+ private moveOps(blockId: string, toIndex: number): WritekitOp[] {
if (!this.blocks.has(blockId))
return [];
@@ -411,7 +411,7 @@ export class DocumentCrdt {
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[] {
+ private splitOps(blockId: string, offset: number, newId: string, newType?: string, newAttrs?: Attrs): WritekitOp[] {
const block = this.blocks.get(blockId);
if (!block || !block.isText)
return [];
@@ -426,7 +426,7 @@ export class DocumentCrdt {
// 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 }];
+ const reactivate: WritekitOp[] = [{ 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;
@@ -436,7 +436,7 @@ export class DocumentCrdt {
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 }];
+ const ops: WritekitOp[] = [{ 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);
@@ -457,13 +457,13 @@ export class DocumentCrdt {
return ops;
}
- private mergeOps(blockId: string, intoId: string): EditorOp[] {
+ private mergeOps(blockId: string, intoId: string): WritekitOp[] {
const source = this.blocks.get(blockId);
const target = this.blocks.get(intoId);
if (!source || !target || !source.isText || !target.isText)
return [];
- const ops: EditorOp[] = [];
+ const ops: WritekitOp[] = [];
const sourceChars = source.rga.visible();
const marksPerChar = source.marks.resolve(sourceChars.map(node => node.id));
let after = target.rga.idAt(target.rga.length - 1);
diff --git a/vue/editor/src/crdt/native/provider.ts b/vue/writekit/src/crdt/native/provider.ts
similarity index 60%
rename from vue/editor/src/crdt/native/provider.ts
rename to vue/writekit/src/crdt/native/provider.ts
index 7450a8d..05a65a2 100644
--- a/vue/editor/src/crdt/native/provider.ts
+++ b/vue/writekit/src/crdt/native/provider.ts
@@ -1,37 +1,50 @@
import { Replica, VersionVector, createSiteId, decodeJson, decodeOps, decodeStateVector, encodeJson, encodeOps, encodeStateVector } from '@robonen/crdt';
-import type { EditorDocument, Selection } from '../../model';
+import { PubSub } from '@robonen/stdlib';
+import type { Selection, WritekitDocument } 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 type { WritekitOp } 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;
+ doc?: WritekitDocument;
/** Replica/site id (defaults to a random one). */
site?: string;
/** Identity broadcast with this replica's cursor. */
user?: CursorUser;
}
+/**
+ * Provider event map. A mapped type (not the `interface`) satisfies PubSub's
+ * `Record` constraint — same trick as the writekit's event bus.
+ */
+interface ProviderEvents {
+ /** A batch of locally-produced ops, encoded for broadcast. */
+ localOps: (bytes: Uint8Array) => void;
+ /** Remote ops were applied to the document. */
+ remoteApplied: () => void;
+ /** This replica's presence/awareness state, encoded for broadcast. */
+ localAwareness: (bytes: Uint8Array) => void;
+ /** Resolved remote cursors changed. */
+ awareness: (cursors: RemoteCursor[]) => void;
+}
+
/**
* 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
+ * of blocks, each a text RGA + mark store. Writekit 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);
+ 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 bus = new PubSub<{ [K in keyof ProviderEvents]: ProviderEvents[K] }>();
const remoteStates = new Map();
if (options.doc) {
@@ -55,7 +68,7 @@ export function createNativeProvider(options: NativeProviderOptions): CrdtProvid
load: () => document.toDocument(),
applyLocal: (tr: Transaction) => {
- const ops: EditorOp[] = [];
+ const ops: WritekitOp[] = [];
for (const step of tr.steps) {
for (const op of document.translateStep(step)) {
replica.commitLocal(op);
@@ -63,29 +76,20 @@ export function createNativeProvider(options: NativeProviderOptions): CrdtProvid
}
}
if (ops.length > 0) {
- const bytes = encodeOps(ops);
- for (const listener of localListeners)
- listener(bytes);
+ bus.emit('localOps', encodeOps(ops));
// Local edits shifted the document — re-resolve remote cursor positions.
- if (remoteStates.size > 0) {
- const cursors = resolveCursors();
- for (const listener of awarenessListeners)
- listener(cursors);
- }
+ if (remoteStates.size > 0)
+ bus.emit('awareness', resolveCursors());
}
},
applyUpdate: (bytes) => {
- const applied = replica.receive(decodeOps(bytes));
+ const applied = replica.receive(decodeOps(bytes));
if (applied.length > 0) {
- for (const listener of remoteListeners)
- listener();
+ bus.emit('remoteApplied');
// 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);
- }
+ if (remoteStates.size > 0)
+ bus.emit('awareness', resolveCursors());
}
},
@@ -93,46 +97,42 @@ export function createNativeProvider(options: NativeProviderOptions): CrdtProvid
encodeDelta: remote => encodeOps(replica.delta(remote ? decodeStateVector(remote) : new VersionVector())),
onLocalOps: (listener) => {
- localListeners.add(listener);
- return () => localListeners.delete(listener);
+ bus.on('localOps', listener);
+ return () => bus.off('localOps', listener);
},
onRemoteApplied: (listener) => {
- remoteListeners.add(listener);
- return () => remoteListeners.delete(listener);
+ bus.on('remoteApplied', listener);
+ return () => bus.off('remoteApplied', 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);
+ bus.emit('localAwareness', encodeJson(state));
},
onLocalAwareness: (listener) => {
- localAwarenessListeners.add(listener);
- return () => localAwarenessListeners.delete(listener);
+ bus.on('localAwareness', listener);
+ return () => bus.off('localAwareness', listener);
},
applyAwareness: (bytes) => {
const state = decodeJson(bytes);
remoteStates.set(state.clientId, state);
- const cursors = resolveCursors();
- for (const listener of awarenessListeners)
- listener(cursors);
+ bus.emit('awareness', resolveCursors());
},
onAwareness: (listener) => {
- awarenessListeners.add(listener);
- return () => awarenessListeners.delete(listener);
+ bus.on('awareness', listener);
+ return () => bus.off('awareness', listener);
},
gc: stable => document.gc(stable ? decodeStateVector(stable) : replica.version),
destroy: () => {
- localListeners.clear();
- remoteListeners.clear();
- localAwarenessListeners.clear();
- awarenessListeners.clear();
+ bus.clear('localOps');
+ bus.clear('remoteApplied');
+ bus.clear('localAwareness');
+ bus.clear('awareness');
remoteStates.clear();
},
};
diff --git a/vue/editor/src/crdt/reconcile.ts b/vue/writekit/src/crdt/reconcile.ts
similarity index 90%
rename from vue/editor/src/crdt/reconcile.ts
rename to vue/writekit/src/crdt/reconcile.ts
index 9546e08..c199fe2 100644
--- a/vue/editor/src/crdt/reconcile.ts
+++ b/vue/writekit/src/crdt/reconcile.ts
@@ -1,4 +1,4 @@
-import type { Content, EditorDocument, Node } from '../model';
+import type { Content, Node, WritekitDocument } from '../model';
import { attrsEq, createDoc, isInlineContent, marksEq } from '../model';
function contentEq(a: Content, b: Content): boolean {
@@ -30,7 +30,7 @@ function nodeEq(a: Node, b: Node): boolean {
* 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 {
+export function reconcileDoc(prev: WritekitDocument, next: WritekitDocument): WritekitDocument {
const prevById = new Map(prev.content.map(node => [node.id, node]));
let changed = prev.content.length !== next.content.length;
diff --git a/vue/editor/src/crdt/types.ts b/vue/writekit/src/crdt/types.ts
similarity index 89%
rename from vue/editor/src/crdt/types.ts
rename to vue/writekit/src/crdt/types.ts
index b35d110..24a46d8 100644
--- a/vue/editor/src/crdt/types.ts
+++ b/vue/writekit/src/crdt/types.ts
@@ -1,5 +1,5 @@
import type { OpId } from '@robonen/crdt';
-import type { EditorDocument, Selection } from '../model';
+import type { Selection, WritekitDocument } from '../model';
import type { Transaction } from '../state';
/** Marks transactions that apply remote CRDT changes (so they bypass local history). */
@@ -36,14 +36,14 @@ export interface RemoteCursor {
}
/**
- * A pluggable CRDT backend. The editor core stays CRDT-agnostic behind this
- * interface; {@link bindCrdt} wires it to an {@link Editor}, and any transport
+ * A pluggable CRDT backend. The writekit core stays CRDT-agnostic behind this
+ * interface; {@link bindCrdt} wires it to an {@link Writekit}, 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;
+ load: () => WritekitDocument;
/** 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. */
@@ -54,7 +54,7 @@ export interface CrdtProvider {
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. */
+ /** Subscribe to "remote ops were applied" (to reflect into writekit state). Returns unsubscribe. */
onRemoteApplied: (listener: () => void) => () => void;
// --- awareness (ephemeral; not part of the persistent document) ---
diff --git a/vue/editor/src/env.d.ts b/vue/writekit/src/env.d.ts
similarity index 100%
rename from vue/editor/src/env.d.ts
rename to vue/writekit/src/env.d.ts
diff --git a/vue/editor/src/index.ts b/vue/writekit/src/index.ts
similarity index 100%
rename from vue/editor/src/index.ts
rename to vue/writekit/src/index.ts
diff --git a/vue/editor/src/keymap/compile.ts b/vue/writekit/src/keymap/compile.ts
similarity index 100%
rename from vue/editor/src/keymap/compile.ts
rename to vue/writekit/src/keymap/compile.ts
diff --git a/vue/editor/src/keymap/defaults.ts b/vue/writekit/src/keymap/defaults.ts
similarity index 82%
rename from vue/editor/src/keymap/defaults.ts
rename to vue/writekit/src/keymap/defaults.ts
index 68c8350..26ae57f 100644
--- a/vue/editor/src/keymap/defaults.ts
+++ b/vue/writekit/src/keymap/defaults.ts
@@ -14,18 +14,18 @@ import {
toggleBlockType,
toggleMark,
} from '../commands';
-import type { Command, Editor } from '../state';
+import type { Command, Writekit } from '../state';
import type { Keymap } from './types';
/**
- * The standard editor keymap. Mark/heading shortcuts are no-ops when the mark or
+ * The standard writekit 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();
+export function defaultKeymap(writekit: Writekit): Keymap {
+ const undo: Command = () => writekit.undo();
+ const redo: Command = () => writekit.redo();
const keymap: Keymap = {
'Mod-b': toggleMark('bold'),
diff --git a/vue/editor/src/keymap/dispatcher.ts b/vue/writekit/src/keymap/dispatcher.ts
similarity index 81%
rename from vue/editor/src/keymap/dispatcher.ts
rename to vue/writekit/src/keymap/dispatcher.ts
index 56089cd..0ed9e65 100644
--- a/vue/editor/src/keymap/dispatcher.ts
+++ b/vue/writekit/src/keymap/dispatcher.ts
@@ -1,4 +1,4 @@
-import type { Command, CommandView, Dispatch, EditorState } from '../state';
+import type { Command, CommandView, Dispatch, WritekitState } from '../state';
import { eventToCombo } from './normalize';
/**
@@ -8,7 +8,7 @@ import { eventToCombo } from './normalize';
export function runKeydown(
event: KeyboardEvent,
compiled: Map,
- state: EditorState,
+ state: WritekitState,
dispatch: Dispatch,
view: CommandView,
): boolean {
diff --git a/vue/editor/src/keymap/index.ts b/vue/writekit/src/keymap/index.ts
similarity index 100%
rename from vue/editor/src/keymap/index.ts
rename to vue/writekit/src/keymap/index.ts
diff --git a/vue/editor/src/keymap/normalize.ts b/vue/writekit/src/keymap/normalize.ts
similarity index 100%
rename from vue/editor/src/keymap/normalize.ts
rename to vue/writekit/src/keymap/normalize.ts
diff --git a/vue/editor/src/keymap/types.ts b/vue/writekit/src/keymap/types.ts
similarity index 100%
rename from vue/editor/src/keymap/types.ts
rename to vue/writekit/src/keymap/types.ts
diff --git a/vue/editor/src/marks/bold.ts b/vue/writekit/src/marks/bold.ts
similarity index 100%
rename from vue/editor/src/marks/bold.ts
rename to vue/writekit/src/marks/bold.ts
diff --git a/vue/editor/src/marks/code.ts b/vue/writekit/src/marks/code.ts
similarity index 100%
rename from vue/editor/src/marks/code.ts
rename to vue/writekit/src/marks/code.ts
diff --git a/vue/editor/src/marks/highlight.ts b/vue/writekit/src/marks/highlight.ts
similarity index 100%
rename from vue/editor/src/marks/highlight.ts
rename to vue/writekit/src/marks/highlight.ts
diff --git a/vue/editor/src/marks/index.ts b/vue/writekit/src/marks/index.ts
similarity index 100%
rename from vue/editor/src/marks/index.ts
rename to vue/writekit/src/marks/index.ts
diff --git a/vue/editor/src/marks/italic.ts b/vue/writekit/src/marks/italic.ts
similarity index 100%
rename from vue/editor/src/marks/italic.ts
rename to vue/writekit/src/marks/italic.ts
diff --git a/vue/editor/src/marks/link.ts b/vue/writekit/src/marks/link.ts
similarity index 100%
rename from vue/editor/src/marks/link.ts
rename to vue/writekit/src/marks/link.ts
diff --git a/vue/editor/src/marks/strike.ts b/vue/writekit/src/marks/strike.ts
similarity index 100%
rename from vue/editor/src/marks/strike.ts
rename to vue/writekit/src/marks/strike.ts
diff --git a/vue/editor/src/marks/underline.ts b/vue/writekit/src/marks/underline.ts
similarity index 100%
rename from vue/editor/src/marks/underline.ts
rename to vue/writekit/src/marks/underline.ts
diff --git a/vue/editor/src/model/__test__/inline.test.ts b/vue/writekit/src/model/__test__/inline.test.ts
similarity index 100%
rename from vue/editor/src/model/__test__/inline.test.ts
rename to vue/writekit/src/model/__test__/inline.test.ts
diff --git a/vue/editor/src/model/attrs.ts b/vue/writekit/src/model/attrs.ts
similarity index 100%
rename from vue/editor/src/model/attrs.ts
rename to vue/writekit/src/model/attrs.ts
diff --git a/vue/editor/src/model/document.ts b/vue/writekit/src/model/document.ts
similarity index 60%
rename from vue/editor/src/model/document.ts
rename to vue/writekit/src/model/document.ts
index ab6a223..3807518 100644
--- a/vue/editor/src/model/document.ts
+++ b/vue/writekit/src/model/document.ts
@@ -1,59 +1,59 @@
import type { Node } from './node';
/**
- * The editor document: an ordered list of top-level blocks. Default blocks are
+ * The writekit 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 {
+export interface WritekitDocument {
readonly type: 'doc';
readonly content: readonly Node[];
}
/** Construct a document from blocks. */
-export function createDoc(content: readonly Node[] = []): EditorDocument {
+export function createDoc(content: readonly Node[] = []): WritekitDocument {
return { type: 'doc', content };
}
/** Index of a block by id, or `-1` if absent. */
-export function blockIndex(doc: EditorDocument, id: string): number {
+export function blockIndex(doc: WritekitDocument, 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 {
+export function findBlock(doc: WritekitDocument, 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 {
+export function blockById(doc: WritekitDocument, 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 {
+export function previousBlock(doc: WritekitDocument, 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 {
+export function nextBlock(doc: WritekitDocument, 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 {
+export function firstBlock(doc: WritekitDocument): Node | null {
return doc.content[0] ?? null;
}
/** Last block, or `null` for an empty document. */
-export function lastBlock(doc: EditorDocument): Node | null {
+export function lastBlock(doc: WritekitDocument): 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 {
+export function replaceBlocks(doc: WritekitDocument, content: readonly Node[]): WritekitDocument {
return { ...doc, content };
}
diff --git a/vue/editor/src/model/id.ts b/vue/writekit/src/model/id.ts
similarity index 100%
rename from vue/editor/src/model/id.ts
rename to vue/writekit/src/model/id.ts
diff --git a/vue/editor/src/model/index.ts b/vue/writekit/src/model/index.ts
similarity index 100%
rename from vue/editor/src/model/index.ts
rename to vue/writekit/src/model/index.ts
diff --git a/vue/editor/src/model/inline.ts b/vue/writekit/src/model/inline.ts
similarity index 100%
rename from vue/editor/src/model/inline.ts
rename to vue/writekit/src/model/inline.ts
diff --git a/vue/editor/src/model/marks.ts b/vue/writekit/src/model/marks.ts
similarity index 100%
rename from vue/editor/src/model/marks.ts
rename to vue/writekit/src/model/marks.ts
diff --git a/vue/editor/src/model/node.ts b/vue/writekit/src/model/node.ts
similarity index 100%
rename from vue/editor/src/model/node.ts
rename to vue/writekit/src/model/node.ts
diff --git a/vue/editor/src/model/position.ts b/vue/writekit/src/model/position.ts
similarity index 100%
rename from vue/editor/src/model/position.ts
rename to vue/writekit/src/model/position.ts
diff --git a/vue/editor/src/model/selection.ts b/vue/writekit/src/model/selection.ts
similarity index 94%
rename from vue/editor/src/model/selection.ts
rename to vue/writekit/src/model/selection.ts
index 5a9f930..845d433 100644
--- a/vue/editor/src/model/selection.ts
+++ b/vue/writekit/src/model/selection.ts
@@ -1,6 +1,6 @@
import type { Position } from './position';
import { positionEq } from './position';
-import type { EditorDocument } from './document';
+import type { WritekitDocument } from './document';
import { blockIndex } from './document';
/** A text selection: caret when `anchor === focus`, range otherwise. May span blocks. */
@@ -56,7 +56,7 @@ export function isAcrossBlocks(sel: Selection): boolean {
* 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 } {
+export function orderedSelection(sel: TextSelection, doc: WritekitDocument): { from: Position; to: Position } {
const { anchor, focus } = sel;
if (anchor.blockId === focus.blockId)
diff --git a/vue/editor/src/preset.ts b/vue/writekit/src/preset.ts
similarity index 100%
rename from vue/editor/src/preset.ts
rename to vue/writekit/src/preset.ts
diff --git a/vue/editor/src/registry/__test__/registry.test.ts b/vue/writekit/src/registry/__test__/registry.test.ts
similarity index 100%
rename from vue/editor/src/registry/__test__/registry.test.ts
rename to vue/writekit/src/registry/__test__/registry.test.ts
diff --git a/vue/editor/src/registry/define-block.ts b/vue/writekit/src/registry/define-block.ts
similarity index 98%
rename from vue/editor/src/registry/define-block.ts
rename to vue/writekit/src/registry/define-block.ts
index f87dd89..d395745 100644
--- a/vue/editor/src/registry/define-block.ts
+++ b/vue/writekit/src/registry/define-block.ts
@@ -10,7 +10,7 @@ export interface BlockComponentProps {
node: Node;
/** Whether the block is currently node-selected. */
selected: boolean;
- /** Editor-level editable flag. */
+ /** Writekit-level editable flag. */
editable: boolean;
/** Merge new attrs into the block (e.g. image src/caption). */
update: (attrs: Attrs) => void;
diff --git a/vue/editor/src/registry/define-mark.ts b/vue/writekit/src/registry/define-mark.ts
similarity index 100%
rename from vue/editor/src/registry/define-mark.ts
rename to vue/writekit/src/registry/define-mark.ts
diff --git a/vue/editor/src/registry/index.ts b/vue/writekit/src/registry/index.ts
similarity index 100%
rename from vue/editor/src/registry/index.ts
rename to vue/writekit/src/registry/index.ts
diff --git a/vue/editor/src/registry/input-rule.ts b/vue/writekit/src/registry/input-rule.ts
similarity index 100%
rename from vue/editor/src/registry/input-rule.ts
rename to vue/writekit/src/registry/input-rule.ts
diff --git a/vue/editor/src/registry/registry.ts b/vue/writekit/src/registry/registry.ts
similarity index 97%
rename from vue/editor/src/registry/registry.ts
rename to vue/writekit/src/registry/registry.ts
index 0199d49..c5863bc 100644
--- a/vue/editor/src/registry/registry.ts
+++ b/vue/writekit/src/registry/registry.ts
@@ -42,7 +42,7 @@ function buildMap(
for (const item of items) {
if (map.has(item.type)) {
if (onConflict === 'throw')
- throw new Error(`Editor registry: duplicate ${kind} type '${item.type}'`);
+ throw new Error(`Writekit registry: duplicate ${kind} type '${item.type}'`);
if (onConflict === 'first-wins')
continue;
diff --git a/vue/editor/src/schema/attr-spec.ts b/vue/writekit/src/schema/attr-spec.ts
similarity index 100%
rename from vue/editor/src/schema/attr-spec.ts
rename to vue/writekit/src/schema/attr-spec.ts
diff --git a/vue/editor/src/schema/content-kind.ts b/vue/writekit/src/schema/content-kind.ts
similarity index 100%
rename from vue/editor/src/schema/content-kind.ts
rename to vue/writekit/src/schema/content-kind.ts
diff --git a/vue/editor/src/schema/dom.ts b/vue/writekit/src/schema/dom.ts
similarity index 100%
rename from vue/editor/src/schema/dom.ts
rename to vue/writekit/src/schema/dom.ts
diff --git a/vue/editor/src/schema/index.ts b/vue/writekit/src/schema/index.ts
similarity index 100%
rename from vue/editor/src/schema/index.ts
rename to vue/writekit/src/schema/index.ts
diff --git a/vue/editor/src/schema/mark-spec.ts b/vue/writekit/src/schema/mark-spec.ts
similarity index 100%
rename from vue/editor/src/schema/mark-spec.ts
rename to vue/writekit/src/schema/mark-spec.ts
diff --git a/vue/editor/src/schema/node-spec.ts b/vue/writekit/src/schema/node-spec.ts
similarity index 100%
rename from vue/editor/src/schema/node-spec.ts
rename to vue/writekit/src/schema/node-spec.ts
diff --git a/vue/editor/src/schema/normalize.ts b/vue/writekit/src/schema/normalize.ts
similarity index 87%
rename from vue/editor/src/schema/normalize.ts
rename to vue/writekit/src/schema/normalize.ts
index 857763b..73b8f26 100644
--- a/vue/editor/src/schema/normalize.ts
+++ b/vue/writekit/src/schema/normalize.ts
@@ -1,4 +1,4 @@
-import type { EditorDocument, Inline, Node } from '../model';
+import type { Inline, Node, WritekitDocument } from '../model';
import { isInlineContent, normalizeInline, replaceBlocks } from '../model';
import type { NodeSpec } from './node-spec';
import type { Schema } from './schema';
@@ -15,9 +15,9 @@ function filterRunMarks(inline: Inline, spec: NodeSpec, schema: Schema): Inline
* 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.
+ * through before it becomes writekit state.
*/
-export function normalizeDocument(doc: EditorDocument, schema: Schema): EditorDocument {
+export function normalizeDocument(doc: WritekitDocument, schema: Schema): WritekitDocument {
const content: Node[] = [];
for (const block of doc.content) {
diff --git a/vue/editor/src/schema/schema.ts b/vue/writekit/src/schema/schema.ts
similarity index 100%
rename from vue/editor/src/schema/schema.ts
rename to vue/writekit/src/schema/schema.ts
diff --git a/vue/editor/src/schema/validate.ts b/vue/writekit/src/schema/validate.ts
similarity index 89%
rename from vue/editor/src/schema/validate.ts
rename to vue/writekit/src/schema/validate.ts
index 4aad8f0..cd54f81 100644
--- a/vue/editor/src/schema/validate.ts
+++ b/vue/writekit/src/schema/validate.ts
@@ -1,4 +1,4 @@
-import type { EditorDocument } from '../model';
+import type { WritekitDocument } from '../model';
import type { Schema } from './schema';
export interface ValidationResult {
@@ -12,7 +12,7 @@ export interface ValidationResult {
* as a guard around untrusted input; runtime mutation paths rely on
* {@link normalizeDocument} instead.
*/
-export function validateDocument(doc: EditorDocument, schema: Schema): ValidationResult {
+export function validateDocument(doc: WritekitDocument, schema: Schema): ValidationResult {
const errors: string[] = [];
for (const block of doc.content) {
diff --git a/vue/editor/src/state/__test__/step.test.ts b/vue/writekit/src/state/__test__/step.test.ts
similarity index 100%
rename from vue/editor/src/state/__test__/step.test.ts
rename to vue/writekit/src/state/__test__/step.test.ts
diff --git a/vue/editor/src/state/command.ts b/vue/writekit/src/state/command.ts
similarity index 71%
rename from vue/editor/src/state/command.ts
rename to vue/writekit/src/state/command.ts
index f98375c..ec936f0 100644
--- a/vue/editor/src/state/command.ts
+++ b/vue/writekit/src/state/command.ts
@@ -1,12 +1,12 @@
-import type { EditorState } from './editor-state';
+import type { WritekitState } from './writekit-state';
import type { Transaction } from './transaction';
-/** Applies a transaction, updating editor state and notifying subscribers. */
+/** Applies a transaction, updating writekit 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
+ * The Vue `WritekitContext` is structurally compatible; pure logic/tests can pass
* a stub. Keeps the command layer free of any Vue/DOM dependency.
*/
export interface CommandView {
@@ -19,7 +19,7 @@ export interface CommandView {
* 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;
+export type Command = (state: WritekitState, dispatch?: Dispatch, view?: CommandView) => boolean;
/** A parameterized command constructor. */
export type CommandFactory = (...args: Args) => Command;
diff --git a/vue/editor/src/state/history.ts b/vue/writekit/src/state/history.ts
similarity index 100%
rename from vue/editor/src/state/history.ts
rename to vue/writekit/src/state/history.ts
diff --git a/vue/editor/src/state/index.ts b/vue/writekit/src/state/index.ts
similarity index 63%
rename from vue/editor/src/state/index.ts
rename to vue/writekit/src/state/index.ts
index e11a72d..b046d2d 100644
--- a/vue/editor/src/state/index.ts
+++ b/vue/writekit/src/state/index.ts
@@ -1,6 +1,6 @@
export * from './command';
-export * from './editor-state';
+export * from './writekit-state';
export * from './step';
export * from './transaction';
export * from './history';
-export * from './editor';
+export * from './writekit';
diff --git a/vue/editor/src/state/step.ts b/vue/writekit/src/state/step.ts
similarity index 93%
rename from vue/editor/src/state/step.ts
rename to vue/writekit/src/state/step.ts
index 9e65da9..abbc1bb 100644
--- a/vue/editor/src/state/step.ts
+++ b/vue/writekit/src/state/step.ts
@@ -1,4 +1,5 @@
-import type { Attrs, EditorDocument, Inline, Mark, Node } from '../model';
+import { clamp, move } from '@robonen/stdlib';
+import type { Attrs, Inline, Mark, Node, WritekitDocument } from '../model';
import {
addMarkInline,
blockById,
@@ -40,14 +41,14 @@ export type Step
| { 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 };
+ | { readonly type: 'setDoc'; readonly doc: WritekitDocument };
export interface StepResult {
- readonly doc: EditorDocument;
+ readonly doc: WritekitDocument;
readonly inverted: Step;
}
-function mapBlock(doc: EditorDocument, blockId: string, fn: (node: Node) => Node): EditorDocument {
+function mapBlock(doc: WritekitDocument, blockId: string, fn: (node: Node) => Node): WritekitDocument {
return replaceBlocks(doc, doc.content.map(block => (block.id === blockId ? fn(block) : block)));
}
@@ -57,7 +58,7 @@ function mapBlock(doc: EditorDocument, blockId: string, fn: (node: Node) => Node
* 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 {
+export function applyStep(doc: WritekitDocument, step: Step, schema: Schema): StepResult {
switch (step.type) {
case 'insertInline': {
const block = blockById(doc, step.blockId);
@@ -180,7 +181,7 @@ export function applyStep(doc: EditorDocument, step: Step, schema: Schema): Step
}
case 'insertBlock': {
- const index = Math.max(0, Math.min(step.index, doc.content.length));
+ const index = clamp(step.index, 0, doc.content.length);
const content = [...doc.content.slice(0, index), step.node, ...doc.content.slice(index)];
return {
doc: replaceBlocks(doc, content),
@@ -204,10 +205,7 @@ export function applyStep(doc: EditorDocument, step: Step, schema: Schema): Step
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!);
+ const arr = move(doc.content, from, step.toIndex);
return {
doc: replaceBlocks(doc, arr),
inverted: { type: 'moveBlock', blockId: step.blockId, toIndex: from },
diff --git a/vue/editor/src/state/transaction.ts b/vue/writekit/src/state/transaction.ts
similarity index 84%
rename from vue/editor/src/state/transaction.ts
rename to vue/writekit/src/state/transaction.ts
index da5d0f5..cc5ab8a 100644
--- a/vue/editor/src/state/transaction.ts
+++ b/vue/writekit/src/state/transaction.ts
@@ -1,7 +1,8 @@
-import type { Attrs, EditorDocument, Inline, Mark, Marks, Node, Position, Selection } from '../model';
+import { clamp } from '@robonen/stdlib';
+import type { Attrs, Inline, Mark, Marks, Node, Position, Selection, WritekitDocument } from '../model';
import { blockById, caret, createId, firstBlock, inlineLength, nodeInline } from '../model';
import type { Schema } from '../schema';
-import type { EditorState } from './editor-state';
+import type { WritekitState } from './writekit-state';
import type { Step } from './step';
import { applyStep } from './step';
@@ -9,16 +10,16 @@ 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}.
+ * turns the finished transaction into a new {@link WritekitState}.
*/
export class Transaction {
- readonly before: EditorState;
+ readonly before: WritekitState;
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;
+ doc: WritekitDocument;
/** Selection to apply after this transaction. */
selection: Selection;
/** `undefined` = leave stored marks to default handling; otherwise set them. */
@@ -28,7 +29,7 @@ export class Transaction {
private readonly schema: Schema;
- constructor(state: EditorState) {
+ constructor(state: WritekitState) {
this.before = state;
this.doc = state.doc;
this.selection = state.selection;
@@ -108,7 +109,7 @@ export class Transaction {
}
/** Replace the whole document (used to apply a remote CRDT snapshot). */
- setDoc(doc: EditorDocument): this {
+ setDoc(doc: WritekitDocument): this {
return this.step({ type: 'setDoc', doc });
}
@@ -132,22 +133,22 @@ export class Transaction {
}
}
-/** Start a transaction from the current editor state. */
-export function createTransaction(state: EditorState): Transaction {
+/** Start a transaction from the current writekit state. */
+export function createTransaction(state: WritekitState): Transaction {
return new Transaction(state);
}
-function clampPoint(point: Position, doc: EditorDocument): Position | null {
+function clampPoint(point: Position, doc: WritekitDocument): 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)) };
+ return { blockId: point.blockId, offset: clamp(point.offset, 0, length) };
}
-function clampSelection(selection: Selection, doc: EditorDocument): Selection {
+function clampSelection(selection: Selection, doc: WritekitDocument): Selection {
if (selection.kind === 'node')
return selection;
@@ -163,10 +164,10 @@ function clampSelection(selection: Selection, doc: EditorDocument): Selection {
}
/**
- * Produce the next editor state from a transaction. Stored marks are kept when
+ * Produce the next writekit 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 {
+export function applyTransaction(state: WritekitState, tr: Transaction): WritekitState {
const storedMarks = tr.storedMarks !== undefined
? tr.storedMarks
: (tr.steps.length > 0 ? null : state.storedMarks);
diff --git a/vue/editor/src/state/editor-state.ts b/vue/writekit/src/state/writekit-state.ts
similarity index 74%
rename from vue/editor/src/state/editor-state.ts
rename to vue/writekit/src/state/writekit-state.ts
index a646086..4384767 100644
--- a/vue/editor/src/state/editor-state.ts
+++ b/vue/writekit/src/state/writekit-state.ts
@@ -1,12 +1,12 @@
-import type { EditorDocument, Marks, Selection } from '../model';
+import type { Marks, Selection, WritekitDocument } 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;
+/** Immutable snapshot of everything the writekit renders and commands read. */
+export interface WritekitState {
+ readonly doc: WritekitDocument;
readonly selection: Selection;
readonly schema: Schema;
readonly registry: Registry;
@@ -14,9 +14,9 @@ export interface EditorState {
readonly storedMarks: Marks | null;
}
-export interface CreateEditorStateOptions {
+export interface CreateWritekitStateOptions {
readonly registry: Registry;
- readonly doc?: EditorDocument;
+ readonly doc?: WritekitDocument;
readonly selection?: Selection;
}
@@ -33,10 +33,10 @@ function defaultBlockType(registry: Registry): string | undefined {
}
/**
- * Build the initial editor state: normalize the document against the schema and
+ * Build the initial writekit 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 {
+export function createWritekitState(options: CreateWritekitStateOptions): WritekitState {
const { registry } = options;
const schema = registry.schema;
diff --git a/vue/editor/src/state/editor.ts b/vue/writekit/src/state/writekit.ts
similarity index 74%
rename from vue/editor/src/state/editor.ts
rename to vue/writekit/src/state/writekit.ts
index a39ad31..44ab7f0 100644
--- a/vue/editor/src/state/editor.ts
+++ b/vue/writekit/src/state/writekit.ts
@@ -1,31 +1,31 @@
import { PubSub } from '@robonen/stdlib';
import { selectionEq } from '../model';
import type { Command, Dispatch } from './command';
-import type { EditorState } from './editor-state';
+import type { WritekitState } from './writekit-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
+ * Writekit event map. A `type` (not `interface`) so it satisfies the
* `Record` constraint of {@link PubSub}.
*/
-export interface EditorEvents {
+export interface WritekitEvents {
/** Fired for every applied transaction (local, undo/redo, or remote). */
- transaction: (tr: Transaction, next: EditorState, prev: EditorState) => void;
+ transaction: (tr: Transaction, next: WritekitState, prev: WritekitState) => void;
/** Fired when the document changed. */
- docChange: (next: EditorState, prev: EditorState) => void;
+ docChange: (next: WritekitState, prev: WritekitState) => void;
/** Fired when the selection changed. */
- selectionChange: (next: EditorState, prev: EditorState) => void;
+ selectionChange: (next: WritekitState, prev: WritekitState) => void;
}
/**
- * The headless editor controller: owns live state, the undo history, and a
+ * The headless writekit 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;
+export interface Writekit {
+ readonly state: WritekitState;
dispatch: Dispatch;
/** Run a command against the current state, dispatching if it applies. */
command: (cmd: Command) => boolean;
@@ -33,21 +33,21 @@ export interface Editor {
redo: () => boolean;
canUndo: () => boolean;
canRedo: () => boolean;
- on: (event: K, listener: EditorEvents[K]) => void;
- off: (event: K, listener: EditorEvents[K]) => void;
+ on: (event: K, listener: WritekitEvents[K]) => void;
+ off: (event: K, listener: WritekitEvents[K]) => void;
destroy: () => void;
}
-export interface CreateEditorOptions {
- readonly state: EditorState;
+export interface CreateWritekitOptions {
+ readonly state: WritekitState;
readonly history?: HistoryOptions;
}
-/** Create an {@link Editor} around an initial state. */
-export function createEditor(options: CreateEditorOptions): Editor {
+/** Create an {@link Writekit} around an initial state. */
+export function createWritekit(options: CreateWritekitOptions): Writekit {
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 bus = new PubSub<{ [K in keyof WritekitEvents]: WritekitEvents[K] }>();
const history = createHistory(options.history);
const dispatch: Dispatch = (tr) => {
diff --git a/vue/editor/src/view/BlockView.vue b/vue/writekit/src/view/BlockView.vue
similarity index 81%
rename from vue/editor/src/view/BlockView.vue
rename to vue/writekit/src/view/BlockView.vue
index edc1f4c..c63d5ab 100644
--- a/vue/editor/src/view/BlockView.vue
+++ b/vue/writekit/src/view/BlockView.vue
@@ -8,7 +8,7 @@ import { computed } from 'vue';
import { nodeSelection } from '../model';
import { createTransaction } from '../state';
import { Primitive } from './primitive';
-import { useEditorContext } from './context';
+import { useWritekitContext } from './context';
import TextBlockHost from './TextBlockHost.vue';
export interface BlockViewProps {
@@ -16,7 +16,7 @@ export interface BlockViewProps {
}
const { block } = defineProps();
-const ctx = useEditorContext();
+const ctx = useWritekitContext();
const def = computed(() => ctx.registry.getBlock(block.type));
const wrapperTag = computed(() => (def.value?.as ?? 'div') as keyof IntrinsicElementAttributes);
@@ -28,7 +28,7 @@ const isSelected = computed(() => {
});
function updateAttrs(attrs: Attrs): void {
- ctx.dispatch(createTransaction(ctx.editor.state).setAttrs(block.id, attrs).setSelection(ctx.editor.state.selection));
+ ctx.dispatch(createTransaction(ctx.writekit.state).setAttrs(block.id, attrs).setSelection(ctx.writekit.state.selection));
}
/** Clicking an atom block selects it as a node (so Backspace/Delete remove it). */
@@ -41,11 +41,11 @@ function onMousedown(event: MouseEvent): void {
return;
event.preventDefault();
- ctx.dispatch(createTransaction(ctx.editor.state).setSelection(nodeSelection([block.id])));
+ ctx.dispatch(createTransaction(ctx.writekit.state).setSelection(nodeSelection([block.id])));
ctx.contentRoot.value?.focus({ preventScroll: true });
}
-const DND_TYPE = 'application/x-robonen-editor-block';
+const DND_TYPE = 'application/x-robonen-writekit-block';
function onDragStart(event: DragEvent): void {
event.dataTransfer?.setData(DND_TYPE, block.id);
@@ -64,9 +64,9 @@ function onDrop(event: DragEvent): void {
return;
event.preventDefault();
- const toIndex = ctx.editor.state.doc.content.findIndex(candidate => candidate.id === block.id);
+ const toIndex = ctx.writekit.state.doc.content.findIndex(candidate => candidate.id === block.id);
if (toIndex !== -1)
- ctx.dispatch(createTransaction(ctx.editor.state).moveBlock(draggedId, toIndex).setSelection(ctx.editor.state.selection));
+ ctx.dispatch(createTransaction(ctx.writekit.state).moveBlock(draggedId, toIndex).setSelection(ctx.writekit.state.selection));
}
@@ -84,8 +84,8 @@ function onDrop(event: DragEvent): void {
>
();
-const ctx = useEditorContext();
+const ctx = useWritekitContext();
let hostEl: HTMLElement | null = null;
@@ -48,7 +48,7 @@ const hostAttrs = computed>(() => {
* Function ref: fires when the element is created or replaced (e.g. a heading
* level change swaps the tag). Registers the element and paints its inline
* content imperatively. The element is NOT itself contenteditable — the single
- * editable root is the ancestor EditorContent — but Vue must never diff this
+ * editable root is the ancestor WritekitContent — but Vue must never diff this
* inner DOM, which is what keeps the caret stable.
*/
function setHost(el: unknown): void {
diff --git a/vue/editor/src/view/EditorContent.vue b/vue/writekit/src/view/WritekitContent.vue
similarity index 79%
rename from vue/editor/src/view/EditorContent.vue
rename to vue/writekit/src/view/WritekitContent.vue
index 2733f2b..da919e3 100644
--- a/vue/editor/src/view/EditorContent.vue
+++ b/vue/writekit/src/view/WritekitContent.vue
@@ -7,15 +7,15 @@ import { blockById, caret, inlineLength, isCollapsed, nodeInline } from '../mode
import { applyInputRule, deleteSelection, insertHardBreak, joinBackward, joinForward, splitBlock } from '../commands';
import { createTransaction } from '../state';
import { Primitive } from './primitive';
-import { useEditorContext } from './context';
+import { useWritekitContext } from './context';
import { isInteractiveTarget } from './interactive';
import { parseRuns } from './inline-content';
import BlockView from './BlockView.vue';
-export interface EditorContentProps extends PrimitiveProps {}
+export interface WritekitContentProps extends PrimitiveProps {}
-const { as = 'div' } = defineProps();
-const ctx = useEditorContext();
+const { as = 'div' } = defineProps();
+const ctx = useWritekitContext();
function setContentRoot(el: unknown): void {
ctx.contentRoot.value = (el as HTMLElement | null) ?? null;
@@ -42,50 +42,50 @@ function onBeforeInput(event: InputEvent): void {
// Ranged selection — we own it so cross-block deletes/inserts stay consistent.
if (!isCollapsed(sel)) {
event.preventDefault();
- ctx.editor.command(deleteSelection);
+ ctx.writekit.command(deleteSelection);
- const after = ctx.editor.state.selection;
+ const after = ctx.writekit.state.selection;
if (after.kind !== 'text')
return;
if ((type === 'insertText' || type === 'insertReplacementText' || type === 'insertCompositionText') && event.data) {
- ctx.editor.dispatch(createTransaction(ctx.editor.state)
- .insertText(after.focus, event.data, ctx.editor.state.storedMarks ?? [])
+ ctx.writekit.dispatch(createTransaction(ctx.writekit.state)
+ .insertText(after.focus, event.data, ctx.writekit.state.storedMarks ?? [])
.setSelection(caret(after.focus.blockId, after.focus.offset + event.data.length)));
}
else if (type === 'insertParagraph') {
- ctx.editor.command(splitBlock);
+ ctx.writekit.command(splitBlock);
}
else if (type === 'insertLineBreak') {
- ctx.editor.command(insertHardBreak);
+ ctx.writekit.command(insertHardBreak);
}
return;
}
// Collapsed — take over only structural edits and block-boundary deletions.
- const block = blockById(ctx.editor.state.doc, sel.focus.blockId);
+ const block = blockById(ctx.writekit.state.doc, sel.focus.blockId);
const atStart = sel.focus.offset === 0;
const atEnd = block ? sel.focus.offset === inlineLength(nodeInline(block)) : false;
switch (type) {
case 'insertParagraph':
event.preventDefault();
- ctx.editor.command(splitBlock);
+ ctx.writekit.command(splitBlock);
break;
case 'insertLineBreak':
event.preventDefault();
- ctx.editor.command(insertHardBreak);
+ ctx.writekit.command(insertHardBreak);
break;
case 'deleteContentBackward':
if (atStart) {
event.preventDefault();
- ctx.editor.command(joinBackward);
+ ctx.writekit.command(joinBackward);
}
break; // else: native deletes within the block, synced on `input`
case 'deleteContentForward':
if (atEnd) {
event.preventDefault();
- ctx.editor.command(joinForward);
+ ctx.writekit.command(joinForward);
}
break;
default:
@@ -103,18 +103,18 @@ function onInput(event?: Event): void {
return;
const host = ctx.blockElements.get(sel.focus.blockId);
- const block = blockById(ctx.editor.state.doc, sel.focus.blockId);
- if (!host || !block || ctx.editor.state.schema.nodeSpec(block.type)?.content.kind !== 'text')
+ const block = blockById(ctx.writekit.state.doc, sel.focus.blockId);
+ if (!host || !block || ctx.writekit.state.schema.nodeSpec(block.type)?.content.kind !== 'text')
return;
const runs = parseRuns(host, ctx.registry);
- ctx.editor.dispatch(createTransaction(ctx.editor.state)
+ ctx.writekit.dispatch(createTransaction(ctx.writekit.state)
.setBlockContent(sel.focus.blockId, runs)
.setSelection(sel)
.setMeta('origin', sel.focus.blockId)); // suppress repaint of the block we just typed in
// Markdown-style shortcuts: '# ' → heading, '- ' → list, '> ' → quote, …
- ctx.editor.command(applyInputRule);
+ ctx.writekit.command(applyInputRule);
}
function onCompositionStart(event: CompositionEvent): void {
@@ -138,7 +138,7 @@ function onCompositionEnd(event: CompositionEvent): void {
role="textbox"
aria-multiline="true"
:aria-readonly="!ctx.config.editable || undefined"
- data-editor-content=""
+ data-writekit-content=""
:contenteditable="ctx.config.editable ? 'true' : 'false'"
:spellcheck="ctx.config.spellcheck"
@beforeinput="onBeforeInput"
diff --git a/vue/editor/src/view/EditorRoot.vue b/vue/writekit/src/view/WritekitRoot.vue
similarity index 73%
rename from vue/editor/src/view/EditorRoot.vue
rename to vue/writekit/src/view/WritekitRoot.vue
index 915de3a..0371631 100644
--- a/vue/editor/src/view/EditorRoot.vue
+++ b/vue/writekit/src/view/WritekitRoot.vue
@@ -1,7 +1,7 @@
@@ -11,16 +11,16 @@ import { blockById, caret, inlineLength, nodeInline, selectionEq } from '../mode
import { compileKeymaps, defaultKeymap, runKeydown } from '../keymap';
import { createTransaction } from '../state';
import { Primitive } from './primitive';
-import { provideEditorContext } from './context';
+import { provideWritekitContext } from './context';
import { resolveConfig } from './config';
import { createBlockElementRegistry, createSelectionBridge } from './selection';
import { useEventListener } from './composables';
import { isInteractiveTarget } from './interactive';
-import EditorContent from './EditorContent.vue';
+import WritekitContent from './WritekitContent.vue';
-export interface EditorRootProps extends PrimitiveProps {
- /** The headless controller (create with `createEditor(createEditorState(...))`). */
- editor: Editor;
+export interface WritekitRootProps extends PrimitiveProps {
+ /** The headless controller (create with `createWritekit(createWritekitState(...))`). */
+ writekit: Writekit;
/** User keymaps, merged over the defaults (earlier wins). */
keymaps?: Keymap[];
editable?: boolean;
@@ -34,7 +34,7 @@ export interface EditorRootProps extends PrimitiveProps {
}
const {
- editor,
+ writekit,
keymaps,
as = 'div',
editable = true,
@@ -43,22 +43,22 @@ const {
platform,
draggable = false,
autofocus = false,
-} = defineProps();
+} = defineProps();
const root = ref(null);
const contentRoot = shallowRef(null);
-const state = shallowRef(editor.state);
+const state = shallowRef(writekit.state);
const composing = ref(false);
const lastOrigin = ref(undefined);
const blockElements = createBlockElementRegistry();
const selection = createSelectionBridge(() => contentRoot.value, blockElements);
const config = resolveConfig({ editable, dir, spellcheck, platform, draggable });
-const compiled = compileKeymaps([...(keymaps ?? []), defaultKeymap(editor)], config.platform);
+const compiled = compileKeymaps([...(keymaps ?? []), defaultKeymap(writekit)], config.platform);
let suppressSelectionSync = false;
function focusBlock(blockId: string, offset: number | 'start' | 'end'): void {
- const block = blockById(editor.state.doc, blockId);
+ const block = blockById(writekit.state.doc, blockId);
const length = block ? inlineLength(nodeInline(block)) : 0;
const resolved = offset === 'start' ? 0 : offset === 'end' ? length : offset;
selection.write(caret(blockId, resolved));
@@ -66,18 +66,18 @@ function focusBlock(blockId: string, offset: number | 'start' | 'end'): void {
const view: CommandView = { focusBlock };
-provideEditorContext({
- editor,
+provideWritekitContext({
+ writekit,
state,
- registry: editor.state.registry,
+ registry: writekit.state.registry,
config,
contentRoot,
blockElements,
selection,
composing,
lastOrigin,
- dispatch: editor.dispatch,
- exec: (command: Command) => editor.command(command),
+ dispatch: writekit.dispatch,
+ exec: (command: Command) => writekit.command(command),
focusBlock,
});
@@ -87,36 +87,36 @@ function reconcileSelection(): void {
return;
// The user is editing an atom's control (e.g. an image caption input); writing
- // the model selection here would focus the editor and yank focus out of it.
+ // the model selection here would focus the writekit and yank focus out of it.
if (typeof document !== 'undefined' && isInteractiveTarget(document.activeElement))
return;
const current = selection.read();
- if (current && selectionEq(current, editor.state.selection))
+ if (current && selectionEq(current, writekit.state.selection))
return;
suppressSelectionSync = true;
- selection.write(editor.state.selection);
+ selection.write(writekit.state.selection);
nextTick(() => {
suppressSelectionSync = false;
});
}
-function onTransaction(tr: Transaction, next: EditorState): void {
+function onTransaction(tr: Transaction, next: WritekitState): void {
state.value = next;
lastOrigin.value = tr.getMeta('origin') as string | undefined;
// Defer to nextTick so block content re-renders (TextBlockHost) run first.
nextTick(reconcileSelection);
}
-editor.on('transaction', onTransaction);
-onBeforeUnmount(() => editor.off('transaction', onTransaction));
+writekit.on('transaction', onTransaction);
+onBeforeUnmount(() => writekit.off('transaction', onTransaction));
function onKeydown(event: KeyboardEvent): void {
if (composing.value || event.isComposing || !editable || isInteractiveTarget(event.target))
return; // let atom controls (e.g. image caption inputs) handle their own keys
- if (runKeydown(event, compiled, editor.state, editor.dispatch, view)) {
+ if (runKeydown(event, compiled, writekit.state, writekit.dispatch, view)) {
event.preventDefault();
event.stopPropagation();
}
@@ -127,15 +127,15 @@ function onSelectionChange(): void {
return;
// Ignore selection changes that belong to an atom's own control (e.g. an image
- // caption input) — reading/writing them would steal focus back into the editor.
+ // caption input) — reading/writing them would steal focus back into the writekit.
if (typeof document !== 'undefined' && isInteractiveTarget(document.activeElement))
return;
const sel = selection.read();
- if (!sel || selectionEq(sel, editor.state.selection))
+ if (!sel || selectionEq(sel, writekit.state.selection))
return;
- editor.dispatch(createTransaction(editor.state).setSelection(sel).setMeta('selectionOnly', true));
+ writekit.dispatch(createTransaction(writekit.state).setSelection(sel).setMeta('selectionOnly', true));
}
useEventListener(root, 'keydown', onKeydown as (event: Event) => void, { capture: true });
@@ -143,7 +143,7 @@ useEventListener(() => (typeof document === 'undefined' ? undefined : document),
if (autofocus) {
nextTick(() => {
- const first = editor.state.doc.content[0];
+ const first = writekit.state.doc.content[0];
if (first)
focusBlock(first.id, 'start');
});
@@ -159,10 +159,10 @@ function setRoot(el: unknown): void {
:ref="setRoot"
:as="as"
role="group"
- data-editor-root=""
+ data-writekit-root=""
:data-editable="editable ? '' : undefined"
:dir="dir"
>
-
+
diff --git a/vue/editor/src/view/__test__/editor.browser.test.ts b/vue/writekit/src/view/__test__/writekit.browser.test.ts
similarity index 72%
rename from vue/editor/src/view/__test__/editor.browser.test.ts
rename to vue/writekit/src/view/__test__/writekit.browser.test.ts
index d01e865..cf6672e 100644
--- a/vue/editor/src/view/__test__/editor.browser.test.ts
+++ b/vue/writekit/src/view/__test__/writekit.browser.test.ts
@@ -3,8 +3,8 @@ import { describe, expect, it, vi } 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';
+import { createTransaction, createWritekit, createWritekitState } from '../../state';
+import WritekitRoot from '../WritekitRoot.vue';
function para(id: string, text: string) {
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
@@ -12,9 +12,9 @@ function para(id: string, text: string) {
function mount(blocks: Array>) {
const registry = createDefaultRegistry();
- const editor = createEditor({ state: createEditorState({ registry, doc: createDoc(blocks) }) });
- render(EditorRoot, { props: { editor, platform: 'mac' } });
- return editor;
+ const writekit = createWritekit({ state: createWritekitState({ registry, doc: createDoc(blocks) }) });
+ render(WritekitRoot, { props: { writekit, platform: 'mac' } });
+ return writekit;
}
function selectNative(anchor: { node: Node; offset: number }, focus: { node: Node; offset: number }) {
@@ -26,12 +26,12 @@ function selectNative(anchor: { node: Node; offset: number }, focus: { node: Nod
sel.addRange(range);
}
-describe('EditorRoot (single contenteditable)', () => {
+describe('WritekitRoot (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]')!;
+ const ce = document.querySelector('[data-writekit-content]')!;
expect(ce.getAttribute('contenteditable')).toBe('true');
const host = document.querySelector('[data-block-content]') as HTMLElement;
@@ -41,7 +41,7 @@ describe('EditorRoot (single contenteditable)', () => {
});
it('maps a cross-block native selection to a cross-block model range', async () => {
- const editor = mount([para('a', 'hello'), para('b', 'world')]);
+ const writekit = mount([para('a', 'hello'), para('b', 'world')]);
await nextTick();
const hosts = document.querySelectorAll('[data-block-content]');
@@ -51,9 +51,9 @@ describe('EditorRoot (single contenteditable)', () => {
selectNative({ node: aText, offset: 1 }, { node: bText, offset: 3 });
// `selectionchange` is dispatched on a macrotask, so awaiting microtasks
- // (nextTick) isn't enough — poll until the editor has synced the model.
+ // (nextTick) isn't enough — poll until the writekit has synced the model.
const sel = await vi.waitFor(() => {
- const s = editor.state.selection;
+ const s = writekit.state.selection;
if (s.kind !== 'text' || s.anchor.offset !== 1)
throw new Error('selection not synced yet');
return s;
@@ -66,10 +66,10 @@ describe('EditorRoot (single contenteditable)', () => {
});
it('writes a cross-block model selection back to a native range spanning blocks', async () => {
- const editor = mount([para('a', 'hello'), para('b', 'world')]);
+ const writekit = mount([para('a', 'hello'), para('b', 'world')]);
await nextTick();
- editor.dispatch(createTransaction(editor.state).setSelection(
+ writekit.dispatch(createTransaction(writekit.state).setSelection(
textSelection({ blockId: 'a', offset: 2 }, { blockId: 'b', offset: 4 }),
));
await nextTick();
@@ -83,15 +83,15 @@ describe('EditorRoot (single contenteditable)', () => {
});
it('applies bold via Mod-b to a selected range', async () => {
- const editor = mount([para('a', 'hello')]);
+ const writekit = mount([para('a', 'hello')]);
await nextTick();
- editor.dispatch(createTransaction(editor.state).setSelection(
+ writekit.dispatch(createTransaction(writekit.state).setSelection(
textSelection({ blockId: 'a', offset: 0 }, { blockId: 'a', offset: 5 }),
));
await nextTick();
- const root = document.querySelector('[data-editor-root]')!;
+ const root = document.querySelector('[data-writekit-root]')!;
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true }));
await nextTick();
await nextTick();
@@ -100,13 +100,13 @@ describe('EditorRoot (single contenteditable)', () => {
});
it('splits a block on Enter', async () => {
- const editor = mount([para('a', 'hello')]);
+ const writekit = mount([para('a', 'hello')]);
await nextTick();
- editor.dispatch(createTransaction(editor.state).setSelection(textSelection({ blockId: 'a', offset: 2 })));
+ writekit.dispatch(createTransaction(writekit.state).setSelection(textSelection({ blockId: 'a', offset: 2 })));
await nextTick();
- const root = document.querySelector('[data-editor-root]')!;
+ const root = document.querySelector('[data-writekit-root]')!;
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await nextTick();
await nextTick();
@@ -118,13 +118,13 @@ describe('EditorRoot (single contenteditable)', () => {
});
it('merges into the previous block on Backspace at block start', async () => {
- const editor = mount([para('a', 'foo'), para('b', 'bar')]);
+ const writekit = mount([para('a', 'foo'), para('b', 'bar')]);
await nextTick();
- editor.dispatch(createTransaction(editor.state).setSelection(textSelection({ blockId: 'b', offset: 0 })));
+ writekit.dispatch(createTransaction(writekit.state).setSelection(textSelection({ blockId: 'b', offset: 0 })));
await nextTick();
- const root = document.querySelector('[data-editor-root]')!;
+ const root = document.querySelector('[data-writekit-root]')!;
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true }));
await nextTick();
await nextTick();
diff --git a/vue/writekit/src/view/composables/index.ts b/vue/writekit/src/view/composables/index.ts
new file mode 100644
index 0000000..64aecd9
--- /dev/null
+++ b/vue/writekit/src/view/composables/index.ts
@@ -0,0 +1 @@
+export { useContextFactory, useEventListener } from '@robonen/vue';
diff --git a/vue/editor/src/view/config.ts b/vue/writekit/src/view/config.ts
similarity index 57%
rename from vue/editor/src/view/config.ts
rename to vue/writekit/src/view/config.ts
index 7385e0a..a5e0bc1 100644
--- a/vue/editor/src/view/config.ts
+++ b/vue/writekit/src/view/config.ts
@@ -1,7 +1,9 @@
+import { isIOS, isMac } from '@robonen/platform/browsers';
+
export type Platform = 'mac' | 'other';
-/** Editor-wide configuration provided through the editor context. */
-export interface EditorConfig {
+/** Writekit-wide configuration provided through the writekit context. */
+export interface WritekitConfig {
/** Whether content is editable (false renders read-only). */
editable: boolean;
/** Platform for keybinding normalization (`Mod` → Cmd/Ctrl). */
@@ -14,17 +16,17 @@ export interface EditorConfig {
draggable: boolean;
}
-/** Detect the platform from the user agent (defaults to `'other'` off-browser). */
+/**
+ * Detect the platform for keybinding normalization (defaults to `'other'`
+ * off-browser). Delegates UA sniffing to `@robonen/platform`, which also handles
+ * iPadOS masquerading as a Mac; `isMac`/`isIOS` return `undefined` 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';
+ return (isMac() || isIOS()) ? 'mac' : 'other';
}
/** Build a config with sensible defaults. */
-export function resolveConfig(partial?: Partial): EditorConfig {
+export function resolveConfig(partial?: Partial): WritekitConfig {
return {
editable: partial?.editable ?? true,
platform: partial?.platform ?? detectPlatform(),
diff --git a/vue/editor/src/view/context.ts b/vue/writekit/src/view/context.ts
similarity index 66%
rename from vue/editor/src/view/context.ts
rename to vue/writekit/src/view/context.ts
index 5a8d87c..e8c315f 100644
--- a/vue/editor/src/view/context.ts
+++ b/vue/writekit/src/view/context.ts
@@ -1,19 +1,19 @@
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 { Command, Dispatch, Writekit, WritekitState } from '../state';
+import type { WritekitConfig } from './config';
import type { BlockElementRegistry, SelectionBridge } from './selection';
/** Everything child components and the input/selection plumbing need. */
-export interface EditorContextValue {
+export interface WritekitContextValue {
/** The headless controller. */
- editor: Editor;
- /** Reactive mirror of `editor.state`, replaced wholesale per transaction. */
- state: ShallowRef;
+ writekit: Writekit;
+ /** Reactive mirror of `writekit.state`, replaced wholesale per transaction. */
+ state: ShallowRef;
registry: Registry;
- config: EditorConfig;
- /** The single contenteditable root element (set by EditorContent). */
+ config: WritekitConfig;
+ /** The single contenteditable root element (set by WritekitContent). */
contentRoot: ShallowRef;
/** Block id → its (non-editable) block-content element. */
blockElements: BlockElementRegistry;
@@ -31,6 +31,6 @@ export interface EditorContextValue {
}
export const {
- inject: useEditorContext,
- provide: provideEditorContext,
-} = useContextFactory('EditorContext');
+ inject: useWritekitContext,
+ provide: provideWritekitContext,
+} = useContextFactory('WritekitContext');
diff --git a/vue/editor/src/view/index.ts b/vue/writekit/src/view/index.ts
similarity index 61%
rename from vue/editor/src/view/index.ts
rename to vue/writekit/src/view/index.ts
index e4409b9..84629e5 100644
--- a/vue/editor/src/view/index.ts
+++ b/vue/writekit/src/view/index.ts
@@ -5,10 +5,10 @@ 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 WritekitRoot } from './WritekitRoot.vue';
+export type { WritekitRootProps } from './WritekitRoot.vue';
+export { default as WritekitContent } from './WritekitContent.vue';
+export type { WritekitContentProps } from './WritekitContent.vue';
export { default as BlockView } from './BlockView.vue';
export type { BlockViewProps } from './BlockView.vue';
export { default as TextBlockHost } from './TextBlockHost.vue';
diff --git a/vue/editor/src/view/inline-content/index.ts b/vue/writekit/src/view/inline-content/index.ts
similarity index 100%
rename from vue/editor/src/view/inline-content/index.ts
rename to vue/writekit/src/view/inline-content/index.ts
diff --git a/vue/editor/src/view/inline-content/parse.ts b/vue/writekit/src/view/inline-content/parse.ts
similarity index 100%
rename from vue/editor/src/view/inline-content/parse.ts
rename to vue/writekit/src/view/inline-content/parse.ts
diff --git a/vue/editor/src/view/inline-content/render.ts b/vue/writekit/src/view/inline-content/render.ts
similarity index 98%
rename from vue/editor/src/view/inline-content/render.ts
rename to vue/writekit/src/view/inline-content/render.ts
index 5ab99e7..eaf7d27 100644
--- a/vue/editor/src/view/inline-content/render.ts
+++ b/vue/writekit/src/view/inline-content/render.ts
@@ -3,7 +3,7 @@ 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';
+export const FILLER_ATTR = 'data-writekit-br-filler';
function markRank(registry: Registry, mark: Mark): number {
return registry.getMark(mark.type)?.spec.rank ?? 0;
diff --git a/vue/editor/src/view/interactive.ts b/vue/writekit/src/view/interactive.ts
similarity index 72%
rename from vue/editor/src/view/interactive.ts
rename to vue/writekit/src/view/interactive.ts
index 94b486b..ad605ae 100644
--- a/vue/editor/src/view/interactive.ts
+++ b/vue/writekit/src/view/interactive.ts
@@ -1,8 +1,8 @@
/**
* 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
+ * writekit input: e.g. typing in an image's caption `` bubbles up to the
+ * single contenteditable, and without this guard the writekit would re-sync a text
* block and yank the caret to the start of the document.
*/
export function isInteractiveTarget(node: EventTarget | null): boolean {
diff --git a/vue/writekit/src/view/primitive/index.ts b/vue/writekit/src/view/primitive/index.ts
new file mode 100644
index 0000000..6edd7ce
--- /dev/null
+++ b/vue/writekit/src/view/primitive/index.ts
@@ -0,0 +1,2 @@
+export { Primitive, Slot } from '@robonen/primitives';
+export type { PrimitiveProps } from '@robonen/primitives';
diff --git a/vue/editor/src/view/selection/block-host.ts b/vue/writekit/src/view/selection/block-host.ts
similarity index 100%
rename from vue/editor/src/view/selection/block-host.ts
rename to vue/writekit/src/view/selection/block-host.ts
diff --git a/vue/editor/src/view/selection/index.ts b/vue/writekit/src/view/selection/index.ts
similarity index 100%
rename from vue/editor/src/view/selection/index.ts
rename to vue/writekit/src/view/selection/index.ts
diff --git a/vue/editor/src/view/selection/selection-bridge.ts b/vue/writekit/src/view/selection/selection-bridge.ts
similarity index 99%
rename from vue/editor/src/view/selection/selection-bridge.ts
rename to vue/writekit/src/view/selection/selection-bridge.ts
index 5349543..1abddc8 100644
--- a/vue/editor/src/view/selection/selection-bridge.ts
+++ b/vue/writekit/src/view/selection/selection-bridge.ts
@@ -6,7 +6,7 @@ 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 the native selection as a model selection (null if outside writekit). */
read: () => Selection | null;
/** Apply a model selection to the native selection (focusing the root). */
write: (selection: Selection) => void;
diff --git a/vue/writekit/src/view/ui/WritekitBubbleMenu.vue b/vue/writekit/src/view/ui/WritekitBubbleMenu.vue
new file mode 100644
index 0000000..ba5c4d4
--- /dev/null
+++ b/vue/writekit/src/view/ui/WritekitBubbleMenu.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/editor/src/view/ui/EditorRemoteCursors.vue b/vue/writekit/src/view/ui/WritekitRemoteCursors.vue
similarity index 87%
rename from vue/editor/src/view/ui/EditorRemoteCursors.vue
rename to vue/writekit/src/view/ui/WritekitRemoteCursors.vue
index 9389f49..68dca47 100644
--- a/vue/editor/src/view/ui/EditorRemoteCursors.vue
+++ b/vue/writekit/src/view/ui/WritekitRemoteCursors.vue
@@ -1,14 +1,14 @@
-
+
- {{ cursor.name }}
+ {{ cursor.name }}
diff --git a/vue/editor/src/view/ui/EditorSlashMenu.vue b/vue/writekit/src/view/ui/WritekitSlashMenu.vue
similarity index 58%
rename from vue/editor/src/view/ui/EditorSlashMenu.vue
rename to vue/writekit/src/view/ui/WritekitSlashMenu.vue
index 65e2153..1fd12c4 100644
--- a/vue/editor/src/view/ui/EditorSlashMenu.vue
+++ b/vue/writekit/src/view/ui/WritekitSlashMenu.vue
@@ -1,37 +1,34 @@
-
-
-
-
-
+
+
+
+
+
+
diff --git a/vue/writekit/src/view/ui/index.ts b/vue/writekit/src/view/ui/index.ts
new file mode 100644
index 0000000..27aeb96
--- /dev/null
+++ b/vue/writekit/src/view/ui/index.ts
@@ -0,0 +1,8 @@
+export * from './slash-items';
+
+export { default as WritekitBubbleMenu } from './WritekitBubbleMenu.vue';
+export type { WritekitBubbleMenuProps } from './WritekitBubbleMenu.vue';
+export { default as WritekitSlashMenu } from './WritekitSlashMenu.vue';
+export type { WritekitSlashMenuProps } from './WritekitSlashMenu.vue';
+export { default as WritekitRemoteCursors } from './WritekitRemoteCursors.vue';
+export type { WritekitRemoteCursorsProps } from './WritekitRemoteCursors.vue';
diff --git a/vue/editor/src/view/ui/slash-items.ts b/vue/writekit/src/view/ui/slash-items.ts
similarity index 100%
rename from vue/editor/src/view/ui/slash-items.ts
rename to vue/writekit/src/view/ui/slash-items.ts
diff --git a/vue/editor/tsconfig.json b/vue/writekit/tsconfig.json
similarity index 100%
rename from vue/editor/tsconfig.json
rename to vue/writekit/tsconfig.json
diff --git a/vue/editor/tsconfig.node.json b/vue/writekit/tsconfig.node.json
similarity index 100%
rename from vue/editor/tsconfig.node.json
rename to vue/writekit/tsconfig.node.json
diff --git a/vue/editor/tsconfig.src.json b/vue/writekit/tsconfig.src.json
similarity index 100%
rename from vue/editor/tsconfig.src.json
rename to vue/writekit/tsconfig.src.json
diff --git a/vue/editor/tsdown.config.ts b/vue/writekit/tsdown.config.ts
similarity index 100%
rename from vue/editor/tsdown.config.ts
rename to vue/writekit/tsdown.config.ts
diff --git a/vue/editor/vitest.config.ts b/vue/writekit/vitest.config.ts
similarity index 100%
rename from vue/editor/vitest.config.ts
rename to vue/writekit/vitest.config.ts