feat(writekit): rename @robonen/editor to @robonen/writekit

Rename the rich-text editor package and all Editor* exports to Writekit*;
remove the old vue/editor tree.
This commit is contained in:
2026-06-15 16:54:06 +07:00
parent 55e78786d6
commit 263c32002f
149 changed files with 1563 additions and 1748 deletions
-443
View File
@@ -1,443 +0,0 @@
<!-- title: Playground -->
<!-- order: 1 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { EditorDocument, Inline, InlineNode, Node, RemoteCursor } from '../src';
import {
bindCrdt,
createDefaultRegistry,
createDoc,
createEditor,
createEditorState,
createNativeProvider,
createNode,
EditorBubbleMenu,
EditorContent,
EditorRemoteCursors,
EditorRoot,
EditorSlashMenu,
isBlockActive,
isMarkActive,
setBlockType,
toggleBlockType,
toggleMark,
} from '../src';
// ── Content helpers ──────────────────────────────────────────────────────────
function t(text: string, ...markTypes: string[]): InlineNode {
return { text, marks: markTypes.map(type => ({ type })) };
}
function p(content: string | Inline = ''): Node {
const inline = typeof content === 'string' ? (content ? [t(content)] : []) : content;
return createNode('paragraph', { content: inline });
}
const heading = (level: number, text: string): Node => createNode('heading', { attrs: { level }, content: text ? [t(text)] : [] });
const quote = (text: string): Node => createNode('blockquote', { content: [t(text)] });
const codeBlock = (text: string): Node => createNode('code-block', { content: [t(text)] });
const callout = (variant: string, text: string): Node => createNode('callout', { attrs: { variant }, content: [t(text)] });
const bullet = (text: string): Node => createNode('bulleted-list', { attrs: { indent: 0 }, content: [t(text)] });
const numbered = (text: string): Node => createNode('numbered-list', { attrs: { indent: 0 }, content: [t(text)] });
const todo = (text: string, checked = false): Node => createNode('todo-list', { attrs: { checked, indent: 0 }, content: [t(text)] });
const divider = (): Node => createNode('divider');
/** Visible text of a document (for word count / convergence check). */
function docText(doc: EditorDocument): string {
return doc.content
.map((block) => {
const c = block.content as unknown;
return Array.isArray(c) ? c.map(run => (run && typeof run === 'object' && 'text' in run ? String((run as InlineNode).text) : '')).join('') : '';
})
.join('\n');
}
// ── Tabs ───────────────────────────────────────────────────────────────────
const tabs = [
{ id: 'editor', label: 'Rich text & blocks' },
{ id: 'collab', label: 'Multiplayer' },
] as const;
const tab = ref<'editor' | 'collab'>('editor');
const registry = createDefaultRegistry();
// ── Tab 1: the rich editor (drag-to-reorder + live output) ───────────────────
const editor = createEditor({
state: createEditorState({
registry,
doc: createDoc([
heading(1, 'Try the editor'),
p([
t('A headless, block-based rich-text editor for Vue. This line mixes '),
t('bold', 'bold'), t(', '), t('italic', 'italic'), t(', '), t('code', 'code'),
t(' and '), t('highlight', 'highlight'), t('.'),
]),
p('Hover a block and drag the ⠿ handle on its left to reorder. Select text for the bubble menu, or type “/” on an empty line for the block menu.'),
heading(2, 'Blocks'),
bullet('Bulleted lists'),
numbered('Numbered lists'),
todo('A checkable to-do item', false),
quote('Block quotes for asides and citations.'),
callout('info', 'Callouts highlight tips and notes.'),
codeBlock('const editor = createEditor(createEditorState({ registry, doc }))'),
divider(),
p(''),
]),
}),
});
const rev = ref(0);
const bump = (): void => void (rev.value += 1);
editor.on('transaction', bump);
onBeforeUnmount(() => editor.off('transaction', bump));
const boldActive = computed(() => (rev.value, isMarkActive(editor.state, 'bold')));
const italicActive = computed(() => (rev.value, isMarkActive(editor.state, 'italic')));
const codeActive = computed(() => (rev.value, isMarkActive(editor.state, 'code')));
const highlightActive = computed(() => (rev.value, isMarkActive(editor.state, 'highlight')));
const h1Active = computed(() => (rev.value, isBlockActive(editor.state, 'heading', { level: 1 })));
const h2Active = computed(() => (rev.value, isBlockActive(editor.state, 'heading', { level: 2 })));
const quoteActive = computed(() => (rev.value, isBlockActive(editor.state, 'blockquote')));
const canUndo = computed(() => (rev.value, editor.canUndo()));
const canRedo = computed(() => (rev.value, editor.canRedo()));
// Live output
const showJson = ref(false);
const blockCount = computed(() => (rev.value, editor.state.doc.content.length));
const wordCount = computed(() => (rev.value, docText(editor.state.doc).trim().split(/\s+/).filter(Boolean).length));
const sid = (id: string): string => id.slice(0, 4);
const selectionSummary = computed(() => {
void rev.value;
const s = editor.state.selection;
if (s.kind === 'text')
return `text · ${sid(s.anchor.blockId)}:${s.anchor.offset}${sid(s.focus.blockId)}:${s.focus.offset}`;
return `node · ${s.ids.length} block${s.ids.length === 1 ? '' : 's'}`;
});
const docJson = computed(() => (rev.value, JSON.stringify(editor.state.doc, null, 2)));
// ── Tab 2: two CRDT replicas, synced in memory (multiplayer) ─────────────────
const seed = createDoc([
heading(1, 'Shared document'),
p('Edit in either pane — each is its own @robonen/crdt replica. Concurrent edits converge and you see the other cursor.'),
p(''),
]);
const editorA = createEditor({ state: createEditorState({ registry, doc: seed }) });
const providerA = createNativeProvider({ schema: registry.schema, doc: editorA.state.doc, user: { name: 'Alice', color: '#2563eb' } });
const editorB = createEditor({ state: createEditorState({ registry }) });
const providerB = createNativeProvider({ schema: registry.schema, user: { name: 'Bob', color: '#db2777' } });
const bindingA = bindCrdt(editorA, providerA);
const bindingB = bindCrdt(editorB, providerB);
providerB.applyUpdate(providerA.encodeDelta());
// In-memory transport with a "Connected" switch: while offline, ops queue and
// the docs diverge; reconnecting flushes them and they converge.
const connected = ref(true);
let queueAB: Uint8Array[] = [];
let queueBA: Uint8Array[] = [];
const offOpsA = providerA.onLocalOps((bytes) => {
if (connected.value) providerB.applyUpdate(bytes);
else queueAB.push(bytes);
});
const offOpsB = providerB.onLocalOps((bytes) => {
if (connected.value) providerA.applyUpdate(bytes);
else queueBA.push(bytes);
});
watch(connected, (on) => {
if (!on) return;
for (const b of queueAB) providerB.applyUpdate(b);
for (const b of queueBA) providerA.applyUpdate(b);
queueAB = [];
queueBA = [];
});
const cursorsA = ref<RemoteCursor[]>([]);
const cursorsB = ref<RemoteCursor[]>([]);
const offCurA = providerA.onAwareness(c => (cursorsA.value = c));
const offCurB = providerB.onAwareness(c => (cursorsB.value = c));
const offAwA = providerA.onLocalAwareness(bytes => connected.value && providerB.applyAwareness(bytes));
const offAwB = providerB.onLocalAwareness(bytes => connected.value && providerA.applyAwareness(bytes));
const collabRev = ref(0);
const bumpCollab = (): void => void (collabRev.value += 1);
editorA.on('transaction', bumpCollab);
editorB.on('transaction', bumpCollab);
const inSync = computed(() => (collabRev.value, docText(editorA.state.doc) === docText(editorB.state.doc)));
onBeforeUnmount(() => {
for (const off of [offOpsA, offOpsB, offCurA, offCurB, offAwA, offAwB]) off();
editorA.off('transaction', bumpCollab);
editorB.off('transaction', bumpCollab);
bindingA.detach();
bindingB.detach();
});
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>Playground</h1>
<p>
Live <code>@robonen/editor</code> 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.
</p>
</div>
<!-- Tabs -->
<div class="ed-tabs" role="tablist">
<button
v-for="tb in tabs"
:key="tb.id"
type="button"
role="tab"
:aria-selected="tab === tb.id"
:class="['ed-tab', { 'ed-tab-active': tab === tb.id }]"
@click="tab = tb.id"
>
{{ tb.label }}
</button>
</div>
<ClientOnly>
<!-- Rich text & blocks -->
<div v-show="tab === 'editor'" class="editor-demo">
<div class="editor-demo-toolbar">
<button type="button" title="Bold" :data-active="boldActive || undefined" @mousedown.prevent="editor.command(toggleMark('bold'))"><b>B</b></button>
<button type="button" title="Italic" :data-active="italicActive || undefined" @mousedown.prevent="editor.command(toggleMark('italic'))"><i>I</i></button>
<button type="button" title="Inline code" :data-active="codeActive || undefined" @mousedown.prevent="editor.command(toggleMark('code'))"><code>&lt;&gt;</code></button>
<button type="button" title="Highlight" :data-active="highlightActive || undefined" @mousedown.prevent="editor.command(toggleMark('highlight'))">H</button>
<span class="sep" />
<button type="button" title="Heading 1" :data-active="h1Active || undefined" @mousedown.prevent="editor.command(toggleBlockType('heading', { level: 1 }))">H1</button>
<button type="button" title="Heading 2" :data-active="h2Active || undefined" @mousedown.prevent="editor.command(toggleBlockType('heading', { level: 2 }))">H2</button>
<button type="button" title="Quote" :data-active="quoteActive || undefined" @mousedown.prevent="editor.command(toggleBlockType('blockquote'))"></button>
<button type="button" title="Paragraph" @mousedown.prevent="editor.command(setBlockType('paragraph'))"></button>
<span class="sep" />
<button type="button" title="Undo" :disabled="!canUndo" @mousedown.prevent="editor.undo()"></button>
<button type="button" title="Redo" :disabled="!canRedo" @mousedown.prevent="editor.redo()"></button>
</div>
<EditorRoot :editor="editor" draggable class="editor-demo-root">
<EditorContent />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
<!-- Live output -->
<div class="ed-output">
<div class="ed-stats">
<span><b>{{ blockCount }}</b> blocks</span>
<span><b>{{ wordCount }}</b> words</span>
<span class="ed-sel">selection: <code>{{ selectionSummary }}</code></span>
<button type="button" class="ed-json-toggle" @click="showJson = !showJson">
{{ showJson ? 'Hide' : 'Show' }} document JSON
</button>
</div>
<pre v-if="showJson" class="ed-json">{{ docJson }}</pre>
</div>
</div>
<!-- Multiplayer -->
<div v-show="tab === 'collab'" class="editor-demo">
<div class="ed-collab-bar">
<span class="ed-peer"><span class="ed-dot" style="background:#2563eb" />Alice</span>
<span class="ed-peer"><span class="ed-dot" style="background:#db2777" />Bob</span>
<span class="ed-spacer" />
<span :class="['ed-sync', inSync ? 'ed-sync-ok' : 'ed-sync-pending']">
{{ inSync ? 'in sync' : 'diverged' }}
</span>
<button type="button" :class="['ed-conn', connected ? 'ed-conn-on' : 'ed-conn-off']" @click="connected = !connected">
{{ connected ? 'Connected' : 'Offline' }}
</button>
</div>
<div class="ed-collab-grid">
<EditorRoot :editor="editorA" draggable class="editor-demo-root collab">
<EditorContent />
<EditorRemoteCursors :cursors="cursorsA" />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
<EditorRoot :editor="editorB" draggable class="editor-demo-root collab">
<EditorContent />
<EditorRemoteCursors :cursors="cursorsB" />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
</div>
<p class="ed-hint">
Each pane is a separate CRDT replica synced over an in-memory channel. Toggle
<b>Offline</b>, edit both sides so they diverge, then reconnect the replicas
converge automatically (no Yjs).
</p>
</div>
<template #fallback>
<div class="flex min-h-72 items-center justify-center gap-2 rounded-xl border border-(--border) bg-(--bg-subtle) text-sm text-(--fg-subtle)">
<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Loading editor
</div>
</template>
</ClientOnly>
<div class="prose-docs">
<h2>How it's wired</h2>
<p>
The editor is created from a registry and a document, then rendered with a single
<code>EditorRoot</code>. Multiplayer is just two editors, each bound to its own CRDT
replica with <code>bindCrdt</code>, exchanging ops over any transport.
</p>
</div>
<DocsCode
lang="ts"
:code="`import {
EditorRoot, EditorContent, EditorRemoteCursors,
createDefaultRegistry, createDoc, createEditor, createEditorState,
createNativeProvider, bindCrdt,
} from '@robonen/editor';
const registry = createDefaultRegistry();
const editor = createEditor({ state: createEditorState({ registry, doc: createDoc(blocks) }) });
// Collaboration: bind the editor to a CRDT replica and pipe ops to peers.
const provider = createNativeProvider({ schema: registry.schema, user: { name: 'Alice' } });
bindCrdt(editor, provider);
provider.onLocalOps(bytes => socket.send(bytes)); // any transport
socket.onmessage = bytes => provider.applyUpdate(bytes);`"
/>
<div class="prose-docs">
<p>
See <NuxtLink to="/editor/create-editor">createEditor</NuxtLink>,
<NuxtLink to="/editor/bind-crdt">bindCrdt</NuxtLink> and
<NuxtLink to="/editor/toggle-mark">toggleMark</NuxtLink> in the API reference for the full surface.
</p>
</div>
</div>
</template>
<style>
/* Unscoped on purpose: the editor renders its own DOM (and teleports menus to
<body>), so scoped styles can't reach them. Selectors are namespaced under
`.editor-demo*`, `.ed-*` and the editor's own classes to avoid leaking. */
.editor-demo { counter-reset: editor-demo-ol; }
/* tabs */
.ed-tabs { display: flex; gap: 4px; margin-bottom: 0.75rem; }
.ed-tab { padding: 6px 12px; border: 1px solid var(--border); background: var(--bg-elevated); color: var(--fg-muted); border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500; transition: background 0.12s, color 0.12s, border-color 0.12s; }
.ed-tab:hover { background: var(--bg-inset); color: var(--fg); }
.ed-tab-active { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
/* toolbar */
.editor-demo-toolbar { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; padding: 6px; border: 1px solid var(--border); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--bg-subtle); }
.editor-demo-toolbar button { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 30px; padding: 0 9px; border: 1px solid var(--border); background: var(--bg-elevated); color: var(--fg); border-radius: 7px; cursor: pointer; font-size: 13px; line-height: 1; transition: background 0.12s, border-color 0.12s; }
.editor-demo-toolbar button code { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; }
.editor-demo-toolbar button:hover { border-color: var(--border-strong); background: var(--bg-inset); }
.editor-demo-toolbar button:disabled { opacity: 0.4; cursor: not-allowed; }
.editor-demo-toolbar button[data-active] { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
.editor-demo-toolbar .sep { width: 1px; height: 18px; background: var(--border); margin: 0 4px; }
/* editable surface */
.editor-demo-root { border: 1px solid var(--border); border-radius: 0 0 12px 12px; padding: 1rem 1.25rem 1rem 2rem; min-height: 280px; background: var(--bg); color: var(--fg); }
.editor-demo-root, .editor-demo-root [data-editor-content] { outline: none; }
.editor-demo-root:focus-within { border-color: var(--accent); }
.editor-demo-root [data-block-id] { position: relative; }
.editor-demo-root [data-block-content] { outline: none; margin: 0.45em 0; line-height: 1.7; }
.editor-demo-root h1[data-block-content], .editor-demo-root h2[data-block-content], .editor-demo-root h3[data-block-content] { margin: 0.7em 0 0.3em; line-height: 1.3; font-weight: 700; letter-spacing: -0.01em; }
.editor-demo-root h1[data-block-content] { font-size: 1.6rem; }
.editor-demo-root h2[data-block-content] { font-size: 1.3rem; }
.editor-demo-root h3[data-block-content] { font-size: 1.1rem; }
.editor-demo-root [data-block-content][data-empty]::before { content: attr(data-placeholder); color: var(--fg-subtle); pointer-events: none; }
/* inline marks */
.editor-demo-root [data-block-content] strong { font-weight: 700; }
.editor-demo-root [data-block-content] em { font-style: italic; }
.editor-demo-root [data-block-content] u { text-decoration: underline; }
.editor-demo-root [data-block-content] s, .editor-demo-root [data-block-content] del { text-decoration: line-through; }
.editor-demo-root [data-block-content] mark { background: rgba(245, 200, 66, 0.4); color: inherit; border-radius: 2px; padding: 0 0.1em; }
.editor-demo-root [data-block-content] code { background: var(--bg-inset); border: 1px solid var(--border); padding: 0.05em 0.35em; border-radius: 4px; font-family: var(--font-mono, ui-monospace, monospace); font-size: 0.9em; }
.editor-demo-root [data-block-content] a { color: var(--accent-text); text-decoration: underline; cursor: pointer; }
.editor-demo-root blockquote[data-block-content] { border-left: 3px solid var(--border-strong); padding-left: 1rem; color: var(--fg-muted); font-style: italic; }
.editor-demo-root pre[data-block-content] { background: var(--bg-inset); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; font-family: var(--font-mono, ui-monospace, monospace); font-size: 0.85rem; white-space: pre-wrap; }
/* callouts */
.editor-demo-root [data-callout] { position: relative; border-radius: 8px; margin: 0.5em 0; padding: 0.6rem 0.8rem 0.6rem 2.4rem; border: 1px solid var(--border); background: var(--bg-subtle); }
.editor-demo-root [data-callout]::before { position: absolute; left: 0.8rem; }
.editor-demo-root [data-callout='info']::before { content: ''; }
.editor-demo-root [data-callout='warn']::before { content: ''; }
.editor-demo-root [data-callout='success']::before { content: ''; }
/* lists */
.editor-demo-root [data-list] { position: relative; padding-left: 1.6em; }
.editor-demo-root [data-list]::before { position: absolute; left: 0.35em; color: var(--fg-muted); }
.editor-demo-root [data-list='bullet']::before { content: ''; }
.editor-demo-root [data-list='ordered'] { counter-increment: editor-demo-ol; }
.editor-demo-root [data-list='ordered']::before { content: counter(editor-demo-ol) '.'; }
.editor-demo-root [data-list='todo']::before { content: ''; }
.editor-demo-root [data-list='todo'][data-checked='true']::before { content: ''; }
.editor-demo-root [data-list='todo'][data-checked='true'] { color: var(--fg-subtle); text-decoration: line-through; }
.editor-demo-root [data-editor-divider] { border: 0; border-top: 2px solid var(--border); margin: 1em 0; }
/* selection */
.editor-demo-root ::selection { background: var(--accent-subtle); }
.editor-demo-root [data-block-content][data-selected], .editor-demo-root [data-block-id][data-selected] { background: var(--accent-subtle); border-radius: 4px; }
/* drag-to-reorder handle */
.editor-demo-root .editor-drag-handle { position: absolute; left: -1.4em; top: 0.2em; cursor: grab; color: var(--fg-subtle); user-select: none; opacity: 0; transition: opacity 0.1s; line-height: 1.4; }
.editor-demo-root [data-block-id]:hover > .editor-drag-handle { opacity: 1; }
.editor-demo-root .editor-drag-handle:hover { color: var(--fg-muted); }
.editor-demo-root .editor-drag-handle:active { cursor: grabbing; }
/* output panel */
.ed-output { margin-top: 0.75rem; }
.ed-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1rem; font-size: 13px; color: var(--fg-muted); }
.ed-stats b { color: var(--fg); font-variant-numeric: tabular-nums; }
.ed-stats code { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; color: var(--accent-text); }
.ed-sel { min-width: 0; }
.ed-json-toggle { margin-left: auto; border: 1px solid var(--border); background: var(--bg-elevated); color: var(--fg-muted); border-radius: 7px; padding: 4px 10px; font-size: 12px; cursor: pointer; transition: background 0.12s, color 0.12s; }
.ed-json-toggle:hover { background: var(--bg-inset); color: var(--fg); }
.ed-json { margin-top: 0.6rem; max-height: 320px; overflow: auto; background: var(--bg-inset); border: 1px solid var(--border); border-radius: 10px; padding: 0.9rem 1rem; font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; line-height: 1.6; color: var(--fg-muted); white-space: pre; }
/* multiplayer */
.ed-collab-bar { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.6rem; font-size: 13px; }
.ed-peer { display: inline-flex; align-items: center; gap: 6px; color: var(--fg-muted); font-weight: 500; }
.ed-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.ed-spacer { flex: 1; }
.ed-sync { font-size: 12px; font-weight: 600; border-radius: 999px; padding: 2px 10px; }
.ed-sync-ok { color: var(--accent-text); background: var(--accent-subtle); }
.ed-sync-pending { color: #b45309; background: rgba(245, 158, 11, 0.15); }
.ed-conn { border: 1px solid var(--border); border-radius: 8px; padding: 4px 12px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background 0.12s, color 0.12s, border-color 0.12s; }
.ed-conn-on { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
.ed-conn-off { background: var(--bg-elevated); color: var(--fg-muted); }
.ed-collab-grid { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
@media (min-width: 720px) { .ed-collab-grid { grid-template-columns: 1fr 1fr; } }
.ed-collab-grid .editor-demo-root { border-radius: 12px; min-height: 200px; }
.editor-demo-root.collab { position: relative; }
.ed-hint { margin-top: 0.6rem; font-size: 13px; color: var(--fg-subtle); }
/* remote cursors (component sets --cursor-color per peer) */
.editor-remote-cursors { position: absolute; inset: 0; pointer-events: none; overflow: visible; z-index: 4; }
.editor-remote-selection { position: absolute; background: var(--cursor-color); opacity: 0.22; border-radius: 2px; }
.editor-remote-caret { position: absolute; width: 2px; background: var(--cursor-color); }
.editor-remote-caret-label { position: absolute; top: -1.05em; left: -1px; font-size: 10px; line-height: 1; white-space: nowrap; color: #fff; background: var(--cursor-color); padding: 1px 4px; border-radius: 3px 3px 3px 0; }
/* floating menus (teleported to <body>) */
.editor-bubble-menu { display: flex; gap: 2px; background: var(--bg-elevated); border: 1px solid var(--border-strong); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); z-index: 60; }
.editor-bubble-menu button { min-width: 30px; height: 28px; padding: 0 8px; border: 0; background: transparent; color: var(--fg-muted); border-radius: 5px; cursor: pointer; font-size: 13px; text-transform: capitalize; }
.editor-bubble-menu button:hover { background: var(--bg-inset); color: var(--fg); }
.editor-bubble-menu button[data-active] { background: var(--accent); color: var(--accent-fg); }
.editor-slash-menu { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 10px; padding: 4px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16); width: 240px; max-height: 300px; overflow: auto; z-index: 60; }
.editor-slash-menu button { display: flex; justify-content: space-between; align-items: baseline; width: 100%; text-align: left; border: 0; background: transparent; padding: 7px 10px; border-radius: 7px; cursor: pointer; font-size: 14px; color: var(--fg); }
.editor-slash-menu button[data-highlighted] { background: var(--bg-inset); }
.editor-slash-menu .slash-group { font-size: 11px; color: var(--fg-subtle); text-transform: capitalize; }
</style>
-156
View File
@@ -1,156 +0,0 @@
<script setup lang="ts">
import { shallowRef } from 'vue';
import CollabDemo from './demos/CollabDemo.vue';
import CommandsDemo from './demos/CommandsDemo.vue';
import ComplexBlocksDemo from './demos/ComplexBlocksDemo.vue';
import CustomKeymapDemo from './demos/CustomKeymapDemo.vue';
import ManyBlocksDemo from './demos/ManyBlocksDemo.vue';
import MarksDemo from './demos/MarksDemo.vue';
import MultiEditorDemo from './demos/MultiEditorDemo.vue';
import ReadOnlyDemo from './demos/ReadOnlyDemo.vue';
import RichTextDemo from './demos/RichTextDemo.vue';
const demos = [
{ id: 'rich', title: 'Rich text', component: RichTextDemo },
{ id: 'complex', title: 'Complex blocks', component: ComplexBlocksDemo },
{ id: 'collab', title: 'Collaboration', component: CollabDemo },
{ id: 'marks', title: 'Inline marks', component: MarksDemo },
{ id: 'many', title: 'Many blocks', component: ManyBlocksDemo },
{ id: 'multi', title: 'Multiple editors', component: MultiEditorDemo },
{ id: 'readonly', title: 'Read-only', component: ReadOnlyDemo },
{ id: 'commands', title: 'Commands API', component: CommandsDemo },
{ id: 'keymap', title: 'Custom keymap', component: CustomKeymapDemo },
];
const current = shallowRef(demos[0]!);
</script>
<template>
<div class="layout">
<nav class="sidebar">
<h1>@robonen/editor</h1>
<button
v-for="demo in demos"
:key="demo.id"
:class="{ active: demo.id === current.id }"
@click="current = demo"
>
{{ demo.title }}
</button>
</nav>
<main class="content">
<component :is="current.component" :key="current.id" />
</main>
</div>
</template>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; color: #1a1a1a; background: #fafafa; }
.layout { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
.sidebar { border-right: 1px solid #e5e5e5; padding: 1rem; background: #fff; position: sticky; top: 0; height: 100vh; }
.sidebar h1 { font-size: 14px; margin: 0 0 1rem; color: #666; }
.sidebar button { display: block; width: 100%; text-align: left; padding: 8px 10px; margin-bottom: 2px; border: 0; background: transparent; border-radius: 6px; cursor: pointer; font-size: 14px; color: #333; }
.sidebar button:hover { background: #f0f0f0; }
.sidebar button.active { background: #1a1a1a; color: #fff; }
.content { padding: 2rem; max-width: 880px; }
.content section > h2 { margin: 0 0 0.25rem; }
.hint { color: #888; font-size: 13px; margin: 0 0 1rem; }
.toolbar { display: flex; gap: 4px; align-items: center; margin-bottom: 0.75rem; }
.toolbar.wrap { flex-wrap: wrap; }
.toolbar button { min-width: 32px; height: 32px; padding: 0 8px; border: 1px solid #ddd; background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; }
.toolbar button:hover { border-color: #bbb; }
.toolbar button:disabled { opacity: 0.4; cursor: default; }
.toolbar button[data-active] { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.toolbar .sep { width: 1px; height: 20px; background: #ddd; margin: 0 4px; }
.editor { border: 1px solid #e5e5e5; border-radius: 8px; padding: 1rem 1.25rem; min-height: 120px; background: #fff; }
.editor:focus-within { border-color: #999; }
.editor.scroll { max-height: 420px; overflow: auto; }
.editor [data-block-content] { outline: none; margin: 0.4em 0; line-height: 1.6; }
.editor [data-block-content]:is(h1, h2, h3, h4, h5, h6) { margin: 0.6em 0 0.3em; line-height: 1.3; }
.editor [data-block-type='heading'] [data-block-content] { font-weight: 700; }
.editor [data-block-content][data-empty]::before { content: attr(data-placeholder); color: #bbb; pointer-events: none; }
.editor [data-block-content] strong { font-weight: 700; }
.editor [data-block-content] em { font-style: italic; }
.editor ::selection { background: #b3d4fc; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
details { margin-top: 1rem; }
summary { cursor: pointer; color: #666; font-size: 13px; }
details pre { background: #f6f6f6; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 12px; max-height: 300px; }
/* inline marks */
.editor [data-block-content] mark { background: #fde68a; border-radius: 2px; }
.editor [data-block-content] code { background: #eef0f2; padding: 0.1em 0.35em; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
.editor [data-block-content] a { color: #2563eb; text-decoration: underline; cursor: pointer; }
/* blockquote */
.editor blockquote[data-block-content] { border-left: 3px solid #ddd; padding-left: 1rem; color: #555; font-style: italic; }
/* code block */
.editor pre[data-block-content] { background: #f6f8fa; border: 1px solid #eaecef; border-radius: 6px; padding: 0.75rem 1rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; white-space: pre-wrap; }
/* callout */
.editor [data-callout] { position: relative; border-radius: 8px; margin: 0.5em 0; padding: 0.6rem 0.8rem 0.6rem 2.4rem; }
.editor [data-callout]::before { position: absolute; left: 0.8rem; }
.editor [data-callout='info'] { background: #eef4ff; } .editor [data-callout='info']::before { content: '️'; }
.editor [data-callout='warn'] { background: #fff6e6; } .editor [data-callout='warn']::before { content: '⚠️'; }
.editor [data-callout='success'] { background: #ecfdf3; } .editor [data-callout='success']::before { content: '✅'; }
/* lists (flat-with-indent; marker in the gutter, indent via inline margin-left) */
.editor { counter-reset: editor-ol; }
.editor [data-list] { position: relative; }
.editor [data-list]::before { position: absolute; left: 0.1em; color: #555; }
.editor [data-list='bullet']::before { content: '•'; }
.editor [data-list='ordered'] { counter-increment: editor-ol; }
.editor [data-list='ordered']::before { content: counter(editor-ol) '.'; }
.editor [data-list='todo']::before { content: '☐'; }
.editor [data-list='todo'][data-checked='true']::before { content: '☑'; }
.editor [data-list='todo'][data-checked='true'] { color: #999; text-decoration: line-through; }
/* atoms: image + divider */
.editor [data-editor-image] { margin: 0.8em 0; text-align: center; }
.editor [data-editor-image] img { max-width: 100%; border-radius: 8px; }
.editor [data-editor-image] figcaption { color: #888; font-size: 13px; margin-top: 4px; }
.editor [data-editor-image] .image-placeholder { background: #f3f3f3; border: 1px dashed #ccc; border-radius: 8px; padding: 1.5rem; color: #999; }
.editor [data-editor-image] .image-fields { display: flex; flex-direction: column; gap: 4px; margin: 6px auto 0; max-width: 360px; }
.editor [data-editor-image] .image-fields input { padding: 4px 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
.editor [data-editor-divider] { border: 0; border-top: 2px solid #e5e5e5; margin: 1em 0; }
/* node selection highlight */
.editor [data-block-type='image'][data-selected], .editor [data-block-type='divider'][data-selected] { outline: 2px solid #2563eb; outline-offset: 2px; border-radius: 6px; }
.editor [data-block-content][data-selected], .editor [data-block-id][data-selected]:not([data-block-type='image']):not([data-block-type='divider']) { background: rgba(37, 99, 235, 0.08); border-radius: 4px; }
/* floating menus (teleported to body) */
.editor-bubble-menu { display: flex; gap: 2px; background: #1a1a1a; border-radius: 8px; padding: 4px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); z-index: 50; }
.editor-bubble-menu button { min-width: 30px; height: 28px; padding: 0 8px; border: 0; background: transparent; color: #fff; border-radius: 5px; cursor: pointer; font-size: 13px; text-transform: capitalize; }
.editor-bubble-menu button:hover { background: rgba(255, 255, 255, 0.15); }
.editor-bubble-menu button[data-active] { background: #fff; color: #1a1a1a; }
.editor-slash-menu { background: #fff; border: 1px solid #e5e5e5; border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); width: 230px; max-height: 280px; overflow: auto; z-index: 50; }
.editor-slash-menu button { display: flex; justify-content: space-between; align-items: baseline; width: 100%; text-align: left; border: 0; background: transparent; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 14px; color: #333; }
.editor-slash-menu button[data-highlighted] { background: #f0f0f0; }
.editor-slash-menu .slash-group { font-size: 11px; color: #aaa; text-transform: capitalize; }
kbd { background: #eee; border: 1px solid #ddd; border-radius: 4px; padding: 1px 5px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* drag-to-reorder handle */
.editor [data-block-id] { position: relative; }
.editor-drag-handle { position: absolute; left: -1.2em; top: 0.25em; cursor: grab; color: #ccc; user-select: none; opacity: 0; transition: opacity 0.1s; line-height: 1.4; }
.editor [data-block-id]:hover > .editor-drag-handle { opacity: 1; }
.editor-drag-handle:hover { color: #888; }
.editor-drag-handle:active { cursor: grabbing; }
/* remote collaboration cursors */
.editor.collab { position: relative; }
.editor-remote-cursors { position: absolute; inset: 0; pointer-events: none; overflow: visible; z-index: 4; }
.editor-remote-selection { position: absolute; background: var(--cursor-color); opacity: 0.22; border-radius: 2px; }
.editor-remote-caret { position: absolute; width: 2px; background: var(--cursor-color); }
.editor-remote-caret-label { position: absolute; top: -1.05em; left: -1px; font-size: 10px; line-height: 1; white-space: nowrap; color: #fff; background: var(--cursor-color); padding: 1px 4px; border-radius: 3px 3px 3px 0; }
</style>
-34
View File
@@ -1,34 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import type { Editor } from '@editor';
import { isBlockActive, isMarkActive, setBlockType, toggleBlockType, toggleMark } from '@editor';
const { editor } = defineProps<{ editor: Editor }>();
// Re-evaluate active-states on every transaction.
const rev = ref(0);
const bump = (): void => void (rev.value += 1);
editor.on('transaction', bump);
onBeforeUnmount(() => editor.off('transaction', bump));
const boldActive = computed(() => (rev.value, isMarkActive(editor.state, 'bold')));
const italicActive = computed(() => (rev.value, isMarkActive(editor.state, 'italic')));
const h1Active = computed(() => (rev.value, isBlockActive(editor.state, 'heading', { level: 1 })));
const h2Active = computed(() => (rev.value, isBlockActive(editor.state, 'heading', { level: 2 })));
const canUndo = computed(() => (rev.value, editor.canUndo()));
const canRedo = computed(() => (rev.value, editor.canRedo()));
</script>
<template>
<div class="toolbar">
<button :data-active="boldActive || undefined" @mousedown.prevent="editor.command(toggleMark('bold'))"><b>B</b></button>
<button :data-active="italicActive || undefined" @mousedown.prevent="editor.command(toggleMark('italic'))"><i>I</i></button>
<span class="sep" />
<button :data-active="h1Active || undefined" @mousedown.prevent="editor.command(toggleBlockType('heading', { level: 1 }))">H1</button>
<button :data-active="h2Active || undefined" @mousedown.prevent="editor.command(toggleBlockType('heading', { level: 2 }))">H2</button>
<button @mousedown.prevent="editor.command(setBlockType('paragraph'))">P</button>
<span class="sep" />
<button :disabled="!canUndo" @mousedown.prevent="editor.undo()">Undo</button>
<button :disabled="!canRedo" @mousedown.prevent="editor.redo()">Redo</button>
</div>
</template>
@@ -1,62 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
EditorRoot,
createNode,
createTransaction,
moveBlockDown,
moveBlockUp,
removeBlock,
setBlockType,
toggleMark,
} from '@editor';
import { h, makeEditor, p } from '../lib';
const editor = makeEditor([
h(1, 'Commands API'),
p('Drive the editor programmatically with the buttons below. Put the caret in a block first.'),
p('Second block.'),
p('Third block.'),
]);
const rev = ref(0);
editor.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(editor.state.doc, null, 2)));
const canDelete = computed(() => (rev.value, editor.state.doc.content.length > 1));
function focusId(): string | undefined {
const sel = editor.state.selection;
return sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
}
function appendParagraph(): void {
const node = createNode('paragraph', { content: [{ text: 'Appended block', marks: [] }] });
editor.dispatch(createTransaction(editor.state).insertBlock(node, editor.state.doc.content.length));
}
function deleteFocused(): void {
const id = focusId();
if (id && editor.state.doc.content.length > 1)
editor.command(removeBlock(id));
}
</script>
<template>
<section>
<h2>Commands API</h2>
<p class="hint">Programmatic control every button is a command or transaction on the editor.</p>
<div class="toolbar wrap">
<button @mousedown.prevent="appendParagraph">Append paragraph</button>
<button @mousedown.prevent="editor.command(moveBlockUp)">Move block </button>
<button @mousedown.prevent="editor.command(moveBlockDown)">Move block </button>
<button @mousedown.prevent="editor.command(setBlockType('heading', { level: 1 }))"> H1</button>
<button @mousedown.prevent="editor.command(setBlockType('paragraph'))"> Paragraph</button>
<button @mousedown.prevent="editor.command(toggleMark('bold'))">Toggle bold</button>
<button :disabled="!canDelete" @mousedown.prevent="deleteFocused">Delete block</button>
</div>
<EditorRoot :editor="editor" class="editor" />
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>
@@ -1,102 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { Node } from '@editor';
import {
EditorBubbleMenu,
EditorContent,
EditorRoot,
EditorSlashMenu,
addMark,
createTransaction,
nodeSelection,
setBlockType,
toggleChecked,
toggleMark,
} from '@editor';
import { bullet, callout, code, divider, h, image, makeEditor, numbered, p, quote, t, todo } from '../lib';
const editor = makeEditor([
h(1, 'Complex blocks'),
p([t('A document with '), t('many', 'bold'), t(' block types. Put the caret in a block and use the controls to convert it, or insert media.')]),
quote('“The block is the unit of composition.” — a registry-driven editor.'),
callout('info', 'Callouts carry a variant attribute. This one is "info".'),
callout('warn', 'And this is a "warn" callout.'),
code('function hello() {\n // Enter inserts a newline here, not a block split\n return \'code block\';\n}'),
h(2, 'Lists'),
bullet('Bulleted item one'),
bullet('Nested bullet (indent = 1) — Tab / Shift+Tab changes indent', 1),
bullet('Bulleted item two'),
numbered('Numbered item (counter via CSS)'),
numbered('Numbered item'),
todo('A finished task', true),
todo('A pending task', false),
h(2, 'Media (atoms)'),
image('https://picsum.photos/seed/robonen/520/240', 'A random sample image'),
divider(),
p('Click an image or divider to select it, then Backspace/Delete removes it. Selecting an image reveals its URL / alt / caption fields.'),
]);
const rev = ref(0);
editor.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(editor.state.doc, null, 2)));
function focusIndex(): number {
const sel = editor.state.selection;
const id = sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
const index = id ? editor.state.doc.content.findIndex(block => block.id === id) : -1;
return index === -1 ? editor.state.doc.content.length - 1 : index;
}
function insertAfterFocus(node: Node): void {
editor.dispatch(createTransaction(editor.state).insertBlock(node, focusIndex() + 1).setSelection(nodeSelection([node.id])));
}
function addLink(): void {
const href = globalThis.prompt('Link URL', 'https://');
if (href)
editor.command(addMark('link', { href }));
}
</script>
<template>
<section>
<h2>Complex blocks</h2>
<p class="hint">Quote, callout, code block, lists (bulleted / numbered / to-do), image &amp; divider atoms, plus the full mark set. Everything is registry-driven.</p>
<div class="toolbar wrap">
<button @mousedown.prevent="editor.command(toggleMark('bold'))"><b>B</b></button>
<button @mousedown.prevent="editor.command(toggleMark('italic'))"><i>I</i></button>
<button @mousedown.prevent="editor.command(toggleMark('underline'))"><u>U</u></button>
<button @mousedown.prevent="editor.command(toggleMark('strike'))"><s>S</s></button>
<button @mousedown.prevent="editor.command(toggleMark('code'))">&lt;/&gt;</button>
<button @mousedown.prevent="editor.command(toggleMark('highlight'))">HL</button>
<button @mousedown.prevent="addLink">Link</button>
<span class="sep" />
<button @mousedown.prevent="editor.command(setBlockType('paragraph'))">P</button>
<button @mousedown.prevent="editor.command(setBlockType('heading', { level: 1 }))">H1</button>
<button @mousedown.prevent="editor.command(setBlockType('heading', { level: 2 }))">H2</button>
<button @mousedown.prevent="editor.command(setBlockType('blockquote'))">Quote</button>
<button @mousedown.prevent="editor.command(setBlockType('code-block'))">Code</button>
<button @mousedown.prevent="editor.command(setBlockType('callout', { variant: 'info' }))">Callout</button>
<span class="sep" />
<button @mousedown.prevent="editor.command(setBlockType('bulleted-list'))"> List</button>
<button @mousedown.prevent="editor.command(setBlockType('numbered-list'))">1. List</button>
<button @mousedown.prevent="editor.command(setBlockType('todo-list', { checked: false, indent: 0 }))"> Todo</button>
<button @mousedown.prevent="editor.command(toggleChecked)">Toggle </button>
<span class="sep" />
<button @mousedown.prevent="insertAfterFocus(image('', ''))">+ Image</button>
<button @mousedown.prevent="insertAfterFocus(divider())">+ Divider</button>
<span class="sep" />
<button @mousedown.prevent="editor.undo()">Undo</button>
<button @mousedown.prevent="editor.redo()">Redo</button>
</div>
<p class="hint">Type <kbd>/</kbd> to insert a block; select text for the bubble toolbar; hover a block and drag the <span aria-hidden="true"></span> handle to reorder. Markdown shortcuts work too: <kbd># </kbd>, <kbd>- </kbd>, <kbd>&gt; </kbd>, <kbd>1. </kbd>, <kbd>[] </kbd>.</p>
<EditorRoot :editor="editor" autofocus draggable class="editor">
<EditorContent />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>
@@ -1,25 +0,0 @@
<script setup lang="ts">
import { EditorRoot } from '@editor';
import { h, makeEditor, p } from '../lib';
import Toolbar from '../Toolbar.vue';
const left = makeEditor([h(2, 'Editor A'), p('Type and select here.')]);
const right = makeEditor([h(2, 'Editor B'), p('This editor is fully independent.')]);
</script>
<template>
<section>
<h2>Multiple editors</h2>
<p class="hint">Two independent editors on one page selection and editing in one must never affect the other.</p>
<div class="cols">
<div>
<Toolbar :editor="left" />
<EditorRoot :editor="left" class="editor" />
</div>
<div>
<Toolbar :editor="right" />
<EditorRoot :editor="right" class="editor" />
</div>
</div>
</section>
</template>
@@ -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<ReturnType<typeof para>>, selection?: ReturnType<typeof caret>) {
const registry = createDefaultRegistry();
return createEditor({ state: createEditorState({ registry, doc: createDoc(blocks), selection }) });
}
describe('commands', () => {
it('toggleMark applies then removes bold on a range', () => {
const registry = createDefaultRegistry();
const editor = createEditor({
state: createEditorState({
registry,
doc: createDoc([para('a', 'abc')]),
selection: textSelection({ blockId: 'a', offset: 0 }, { blockId: 'a', offset: 3 }),
}),
});
expect(editor.command(toggleMark('bold'))).toBe(true);
expect(nodeInline(editor.state.doc.content[0]!)).toEqual([{ text: 'abc', marks: [{ type: 'bold' }] }]);
editor.command(toggleMark('bold'));
expect(nodeInline(editor.state.doc.content[0]!)).toEqual([{ text: 'abc', marks: [] }]);
});
it('splitBlock splits at the caret', () => {
const editor = editorWith([para('a', 'hello')], caret('a', 2));
expect(editor.command(splitBlock)).toBe(true);
expect(editor.state.doc.content.map(block => nodeText(block))).toEqual(['he', 'llo']);
expect(editor.state.selection.kind).toBe('text');
});
it('joinBackward merges into the previous block', () => {
const editor = editorWith([para('a', 'foo'), para('b', 'bar')], caret('b', 0));
expect(editor.command(joinBackward)).toBe(true);
expect(editor.state.doc.content.map(block => nodeText(block))).toEqual(['foobar']);
});
it('undo restores the document after a split', () => {
const editor = editorWith([para('a', 'hello')], caret('a', 2));
editor.command(splitBlock);
expect(editor.state.doc.content.length).toBe(2);
expect(editor.undo()).toBe(true);
expect(editor.state.doc.content.map(block => nodeText(block))).toEqual(['hello']);
});
});
-2
View File
@@ -1,2 +0,0 @@
export { useContextFactory } from './useContextFactory';
export { useEventListener } from './useEventListener';
@@ -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<ContextValue>(name: string) {
const injectionKey: InjectionKey<ContextValue> = Symbol(name);
const inject = <Fallback extends ContextValue = ContextValue>(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 };
}
@@ -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<ListenerTarget>,
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;
}
@@ -1,30 +0,0 @@
import type { AllowedComponentProps, Component, IntrinsicElementAttributes, SetupContext, VNodeProps } from 'vue';
import { h } from 'vue';
import { renderSlotChild } from './Slot';
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
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<string, unknown>, 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,
},
};
-50
View File
@@ -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<SetupContext, 'expose'>;
/**
* Renders a single child from the provided default slot, applying attrs to it.
* Shared between `<Slot>` and `<Primitive as="template">`.
*
* @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<string, unknown>): 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('<Slot> 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<string, unknown>, { slots, attrs }: FunctionalComponentContext) {
return renderSlotChild(slots, attrs);
}
Slot.inheritAttrs = false;
@@ -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;
}
}
}
-4
View File
@@ -1,4 +0,0 @@
export { Primitive } from './Primitive';
export type { PrimitiveProps } from './Primitive';
export { Slot, renderSlotChild } from './Slot';
export { getRawChildren } from './getRawChildren';
@@ -1,86 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue';
import { isCollapsed } from '../../model';
import { isMarkActive, toggleMark } from '../../commands';
import { useEditorContext } from '../context';
import { useEventListener } from '../composables';
export interface EditorBubbleMenuProps {
/** Marks shown in the default toolbar (ignored when the default slot is used). */
marks?: string[];
}
const { marks = ['bold', 'italic', 'underline', 'strike', 'code'] } = defineProps<EditorBubbleMenuProps>();
const ctx = useEditorContext();
const reference = ref<{ getBoundingClientRect: () => DOMRect } | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const open = ref(false);
const rev = ref(0);
const { floatingStyles, update } = useFloating(reference, floatingEl, {
placement: 'top',
middleware: [offset(8), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
});
function selectionRect(): DOMRect | null {
const selection = globalThis.window === undefined ? null : globalThis.getSelection();
if (!selection || selection.rangeCount === 0)
return null;
const rect = selection.getRangeAt(0).getBoundingClientRect();
return rect.width || rect.height ? rect : null;
}
function refresh(): void {
rev.value += 1;
const sel = ctx.editor.state.selection;
const rect = selectionRect();
open.value = sel.kind === 'text' && !isCollapsed(sel) && !ctx.composing.value && rect !== null;
if (open.value) {
reference.value = { getBoundingClientRect: () => selectionRect() ?? new DOMRect() };
void update();
}
}
ctx.editor.on('transaction', refresh);
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', refresh);
onBeforeUnmount(() => ctx.editor.off('transaction', refresh));
function active(type: string): boolean {
return Boolean(rev.value >= 0 && isMarkActive(ctx.editor.state, type));
}
function toggle(type: string): void {
ctx.editor.command(toggleMark(type));
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
ref="floatingEl"
:style="floatingStyles"
class="editor-bubble-menu"
role="toolbar"
data-editor-bubble-menu=""
>
<slot :active="active" :toggle="toggle" :editor="ctx.editor">
<button
v-for="mark in marks"
:key="mark"
type="button"
:data-mark="mark"
:data-active="active(mark) || undefined"
@mousedown.prevent="toggle(mark)"
>
{{ mark }}
</button>
</slot>
</div>
</Teleport>
</template>
-8
View File
@@ -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';
+10 -10
View File
@@ -1,35 +1,35 @@
# AGENTS.md — @robonen/editor # AGENTS.md — @robonen/writekit
Architecture and conventions for working in this package. Read this before editing. Architecture and conventions for working in this package. Read this before editing.
## What it is ## 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 ## 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. 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) ## 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): 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. - `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. - `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. - `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. - `keymap/` — combo→command table, `Mod` normalization, one capture-phase keydown dispatcher on the root.
Vue layer (only this knows about the DOM): 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`). - `blocks/` — concrete blocks (+ `.vue` for atoms). `marks/` — concrete marks (data-only `toDOM`/`parseDOM`).
- `crdt/` — CRDT-agnostic `CrdtProvider` + `bindCrdt`; `native/` = the adapter over `@robonen/crdt`. - `crdt/` — CRDT-agnostic `CrdtProvider` + `bindCrdt`; `native/` = the adapter over `@robonen/crdt`.
- `preset.ts``createDefaultRegistry()` / `createBasicRegistry()`. - `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`) ## 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<string>` (text) + a `MarkStore`. `moveBlock` = change `posKey` (cheap). - Block list → fractional-indexed set: each block has `LwwRegister`s for `present`/`posKey`/`type`/`attrs` + an `Rga<string>` (text) + a `MarkStore`. `moveBlock` = change `posKey` (cheap).
- Text → `Rga` (one node per **UTF-16 code unit** — must match the editor's offset space; do not iterate code points). - 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). - Marks → `MarkStore` (Peritext-ish spans anchored to char ids, LWW per char/type).
Invariants that have already bitten us — keep them: 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 ## Conventions
- TS strict, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`. Use `!`/guards on indexed access. - 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`. - 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`, …). - 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. - 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.
+32 -32
View File
@@ -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. - **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. - **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 ## Install
```bash ```bash
pnpm add @robonen/editor @robonen/crdt vue pnpm add @robonen/writekit @robonen/crdt vue
``` ```
## Quick start ## Quick start
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
import { createDefaultRegistry, createEditor, createEditorState, EditorRoot } from '@robonen/editor'; import { createDefaultRegistry, createWritekit, createWritekitState, WritekitRoot } from '@robonen/writekit';
const registry = createDefaultRegistry(); const registry = createDefaultRegistry();
const editor = createEditor({ state: createEditorState({ registry }) }); const writekit = createWritekit({ state: createWritekitState({ registry }) });
</script> </script>
<template> <template>
<EditorRoot :editor="editor" autofocus class="editor" /> <WritekitRoot :writekit="writekit" autofocus class="writekit" />
</template> </template>
``` ```
`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 ```vue
<EditorRoot :editor="editor" autofocus> <WritekitRoot :writekit="writekit" autofocus>
<EditorContent /> <WritekitContent />
<EditorBubbleMenu /> <!-- formatting toolbar on selection --> <WritekitBubbleMenu /> <!-- formatting toolbar on selection -->
<EditorSlashMenu /> <!-- `/` to insert blocks --> <WritekitSlashMenu /> <!-- `/` to insert blocks -->
</EditorRoot> </WritekitRoot>
``` ```
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 ## Blocks & marks
@@ -50,7 +50,7 @@ Built-in blocks (via `createDefaultRegistry()`): `paragraph`, `heading` (16),
Add your own — no core changes needed: Add your own — no core changes needed:
```ts ```ts
import { createRegistry, defineBlock, defineMark, defaultBlocks, defaultMarks } from '@robonen/editor'; import { createRegistry, defineBlock, defineMark, defaultBlocks, defaultMarks } from '@robonen/writekit';
const spoiler = defineMark({ const spoiler = defineMark({
type: 'spoiler', type: 'spoiler',
@@ -62,10 +62,10 @@ const registry = createRegistry({ blocks: defaultBlocks, marks: [...defaultMarks
## Editing UX ## Editing UX
- **Slash menu**`EditorSlashMenu`: type `/` at the start of a line; items come from each block's `meta`. - **Slash menu**`WritekitSlashMenu`: 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 }"`). - **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. - **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). - **Hotkeys**`Mod-b/i/u`, `Mod-Shift-s` (strike), `Mod-e` (code), `Mod-z` / `Mod-Shift-z`, `Enter` (split), `Shift-Enter` (hard break), `Backspace`/`Delete` at edges (merge), `Mod-a` (progressive select), `Mod-Alt-1..6` (heading), `Tab`/`Shift-Tab` (list indent).
## Commands ## Commands
@@ -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: Commands are `(state, dispatch?, view?) => boolean` and power the keymap, UI, and programmatic edits:
```ts ```ts
import { toggleMark, setBlockType } from '@robonen/editor'; import { toggleMark, setBlockType } from '@robonen/writekit';
editor.command(toggleMark('bold')); writekit.command(toggleMark('bold'));
editor.command(setBlockType('heading', { level: 2 })); writekit.command(setBlockType('heading', { level: 2 }));
``` ```
Called without `dispatch` they are a dry run (for disabled/active toolbar state). Called without `dispatch` they are a dry run (for disabled/active toolbar state).
## Collaboration (own CRDT) ## 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 ```ts
import { bindCrdt, createNativeProvider } from '@robonen/editor'; import { bindCrdt, createNativeProvider } from '@robonen/writekit';
// First peer seeds the document. // First peer seeds the document.
const provider = createNativeProvider({ schema: registry.schema, doc: editor.state.doc, user: { name: 'Alice', color: '#2563eb' } }); const provider = createNativeProvider({ schema: registry.schema, doc: writekit.state.doc, user: { name: 'Alice', color: '#2563eb' } });
const binding = bindCrdt(editor, provider); const binding = bindCrdt(writekit, provider);
// Wire a transport (BroadcastChannel / WebSocket / …): // Wire a transport (BroadcastChannel / WebSocket / …):
provider.onLocalOps(bytes => channel.postMessage(bytes)); provider.onLocalOps(bytes => channel.postMessage(bytes));
@@ -101,7 +101,7 @@ channel.onmessage = e => provider.applyUpdate(e.data);
// provider.applyUpdate(remoteFullState); // = peerA.encodeDelta() // 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 ```ts
provider.onLocalAwareness(bytes => channel.postMessage(bytes)); provider.onLocalAwareness(bytes => channel.postMessage(bytes));
@@ -110,10 +110,10 @@ provider.onAwareness(next => cursors.value = next);
``` ```
```vue ```vue
<EditorRoot :editor="editor"> <WritekitRoot :writekit="writekit">
<EditorContent /> <WritekitContent />
<EditorRemoteCursors :cursors="cursors" /> <WritekitRemoteCursors :cursors="cursors" />
</EditorRoot> </WritekitRoot>
``` ```
See the **Collaboration** demo in the playground for a full two-replica example. 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 ## Development
```bash ```bash
pnpm --filter @robonen/editor test # logic (jsdom) + CRDT convergence pnpm --filter @robonen/writekit test # logic (jsdom) + CRDT convergence
pnpm --filter @robonen/editor build # tsdown (ESM + CJS + dts) pnpm --filter @robonen/writekit build # tsdown (ESM + CJS + dts)
pnpm --filter @robonen/editor-playground dev pnpm --filter @robonen/writekit-playground dev
``` ```
See [AGENTS.md](./AGENTS.md) for the architecture and contributor notes. See [AGENTS.md](./AGENTS.md) for the architecture and contributor notes.
+443
View File
@@ -0,0 +1,443 @@
<!-- title: Playground -->
<!-- order: 1 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { WritekitDocument, Inline, InlineNode, Node, RemoteCursor } from '../src';
import {
bindCrdt,
createDefaultRegistry,
createDoc,
createWritekit,
createWritekitState,
createNativeProvider,
createNode,
WritekitBubbleMenu,
WritekitContent,
WritekitRemoteCursors,
WritekitRoot,
WritekitSlashMenu,
isBlockActive,
isMarkActive,
setBlockType,
toggleBlockType,
toggleMark,
} from '../src';
// ── Content helpers ──────────────────────────────────────────────────────────
function t(text: string, ...markTypes: string[]): InlineNode {
return { text, marks: markTypes.map(type => ({ type })) };
}
function p(content: string | Inline = ''): Node {
const inline = typeof content === 'string' ? (content ? [t(content)] : []) : content;
return createNode('paragraph', { content: inline });
}
const heading = (level: number, text: string): Node => createNode('heading', { attrs: { level }, content: text ? [t(text)] : [] });
const quote = (text: string): Node => createNode('blockquote', { content: [t(text)] });
const codeBlock = (text: string): Node => createNode('code-block', { content: [t(text)] });
const callout = (variant: string, text: string): Node => createNode('callout', { attrs: { variant }, content: [t(text)] });
const bullet = (text: string): Node => createNode('bulleted-list', { attrs: { indent: 0 }, content: [t(text)] });
const numbered = (text: string): Node => createNode('numbered-list', { attrs: { indent: 0 }, content: [t(text)] });
const todo = (text: string, checked = false): Node => createNode('todo-list', { attrs: { checked, indent: 0 }, content: [t(text)] });
const divider = (): Node => createNode('divider');
/** Visible text of a document (for word count / convergence check). */
function docText(doc: WritekitDocument): string {
return doc.content
.map((block) => {
const c = block.content as unknown;
return Array.isArray(c) ? c.map(run => (run && typeof run === 'object' && 'text' in run ? String((run as InlineNode).text) : '')).join('') : '';
})
.join('\n');
}
// ── Tabs ───────────────────────────────────────────────────────────────────
const tabs = [
{ id: 'writekit', label: 'Rich text & blocks' },
{ id: 'collab', label: 'Multiplayer' },
] as const;
const tab = ref<'writekit' | 'collab'>('writekit');
const registry = createDefaultRegistry();
// ── Tab 1: the rich writekit (drag-to-reorder + live output) ───────────────────
const writekit = createWritekit({
state: createWritekitState({
registry,
doc: createDoc([
heading(1, 'Try the writekit'),
p([
t('A headless, block-based rich-text writekit for Vue. This line mixes '),
t('bold', 'bold'), t(', '), t('italic', 'italic'), t(', '), t('code', 'code'),
t(' and '), t('highlight', 'highlight'), t('.'),
]),
p('Hover a block and drag the ⠿ handle on its left to reorder. Select text for the bubble menu, or type “/” on an empty line for the block menu.'),
heading(2, 'Blocks'),
bullet('Bulleted lists'),
numbered('Numbered lists'),
todo('A checkable to-do item', false),
quote('Block quotes for asides and citations.'),
callout('info', 'Callouts highlight tips and notes.'),
codeBlock('const writekit = createWritekit(createWritekitState({ registry, doc }))'),
divider(),
p(''),
]),
}),
});
const rev = ref(0);
const bump = (): void => void (rev.value += 1);
writekit.on('transaction', bump);
onBeforeUnmount(() => writekit.off('transaction', bump));
const boldActive = computed(() => (rev.value, isMarkActive(writekit.state, 'bold')));
const italicActive = computed(() => (rev.value, isMarkActive(writekit.state, 'italic')));
const codeActive = computed(() => (rev.value, isMarkActive(writekit.state, 'code')));
const highlightActive = computed(() => (rev.value, isMarkActive(writekit.state, 'highlight')));
const h1Active = computed(() => (rev.value, isBlockActive(writekit.state, 'heading', { level: 1 })));
const h2Active = computed(() => (rev.value, isBlockActive(writekit.state, 'heading', { level: 2 })));
const quoteActive = computed(() => (rev.value, isBlockActive(writekit.state, 'blockquote')));
const canUndo = computed(() => (rev.value, writekit.canUndo()));
const canRedo = computed(() => (rev.value, writekit.canRedo()));
// Live output
const showJson = ref(false);
const blockCount = computed(() => (rev.value, writekit.state.doc.content.length));
const wordCount = computed(() => (rev.value, docText(writekit.state.doc).trim().split(/\s+/).filter(Boolean).length));
const sid = (id: string): string => id.slice(0, 4);
const selectionSummary = computed(() => {
void rev.value;
const s = writekit.state.selection;
if (s.kind === 'text')
return `text · ${sid(s.anchor.blockId)}:${s.anchor.offset}${sid(s.focus.blockId)}:${s.focus.offset}`;
return `node · ${s.ids.length} block${s.ids.length === 1 ? '' : 's'}`;
});
const docJson = computed(() => (rev.value, JSON.stringify(writekit.state.doc, null, 2)));
// ── Tab 2: two CRDT replicas, synced in memory (multiplayer) ─────────────────
const seed = createDoc([
heading(1, 'Shared document'),
p('Edit in either pane — each is its own @robonen/crdt replica. Concurrent edits converge and you see the other cursor.'),
p(''),
]);
const writekitA = createWritekit({ state: createWritekitState({ registry, doc: seed }) });
const providerA = createNativeProvider({ schema: registry.schema, doc: writekitA.state.doc, user: { name: 'Alice', color: '#2563eb' } });
const writekitB = createWritekit({ state: createWritekitState({ registry }) });
const providerB = createNativeProvider({ schema: registry.schema, user: { name: 'Bob', color: '#db2777' } });
const bindingA = bindCrdt(writekitA, providerA);
const bindingB = bindCrdt(writekitB, providerB);
providerB.applyUpdate(providerA.encodeDelta());
// In-memory transport with a "Connected" switch: while offline, ops queue and
// the docs diverge; reconnecting flushes them and they converge.
const connected = ref(true);
let queueAB: Uint8Array[] = [];
let queueBA: Uint8Array[] = [];
const offOpsA = providerA.onLocalOps((bytes) => {
if (connected.value) providerB.applyUpdate(bytes);
else queueAB.push(bytes);
});
const offOpsB = providerB.onLocalOps((bytes) => {
if (connected.value) providerA.applyUpdate(bytes);
else queueBA.push(bytes);
});
watch(connected, (on) => {
if (!on) return;
for (const b of queueAB) providerB.applyUpdate(b);
for (const b of queueBA) providerA.applyUpdate(b);
queueAB = [];
queueBA = [];
});
const cursorsA = ref<RemoteCursor[]>([]);
const cursorsB = ref<RemoteCursor[]>([]);
const offCurA = providerA.onAwareness(c => (cursorsA.value = c));
const offCurB = providerB.onAwareness(c => (cursorsB.value = c));
const offAwA = providerA.onLocalAwareness(bytes => connected.value && providerB.applyAwareness(bytes));
const offAwB = providerB.onLocalAwareness(bytes => connected.value && providerA.applyAwareness(bytes));
const collabRev = ref(0);
const bumpCollab = (): void => void (collabRev.value += 1);
writekitA.on('transaction', bumpCollab);
writekitB.on('transaction', bumpCollab);
const inSync = computed(() => (collabRev.value, docText(writekitA.state.doc) === docText(writekitB.state.doc)));
onBeforeUnmount(() => {
for (const off of [offOpsA, offOpsB, offCurA, offCurB, offAwA, offAwB]) off();
writekitA.off('transaction', bumpCollab);
writekitB.off('transaction', bumpCollab);
bindingA.detach();
bindingB.detach();
});
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>Playground</h1>
<p>
Live <code>@robonen/writekit</code> 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.
</p>
</div>
<!-- Tabs -->
<div class="ed-tabs" role="tablist">
<button
v-for="tb in tabs"
:key="tb.id"
type="button"
role="tab"
:aria-selected="tab === tb.id"
:class="['ed-tab', { 'ed-tab-active': tab === tb.id }]"
@click="tab = tb.id"
>
{{ tb.label }}
</button>
</div>
<ClientOnly>
<!-- Rich text & blocks -->
<div v-show="tab === 'writekit'" class="writekit-demo">
<div class="writekit-demo-toolbar">
<button type="button" title="Bold" :data-active="boldActive || undefined" @mousedown.prevent="writekit.command(toggleMark('bold'))"><b>B</b></button>
<button type="button" title="Italic" :data-active="italicActive || undefined" @mousedown.prevent="writekit.command(toggleMark('italic'))"><i>I</i></button>
<button type="button" title="Inline code" :data-active="codeActive || undefined" @mousedown.prevent="writekit.command(toggleMark('code'))"><code>&lt;&gt;</code></button>
<button type="button" title="Highlight" :data-active="highlightActive || undefined" @mousedown.prevent="writekit.command(toggleMark('highlight'))">H</button>
<span class="sep" />
<button type="button" title="Heading 1" :data-active="h1Active || undefined" @mousedown.prevent="writekit.command(toggleBlockType('heading', { level: 1 }))">H1</button>
<button type="button" title="Heading 2" :data-active="h2Active || undefined" @mousedown.prevent="writekit.command(toggleBlockType('heading', { level: 2 }))">H2</button>
<button type="button" title="Quote" :data-active="quoteActive || undefined" @mousedown.prevent="writekit.command(toggleBlockType('blockquote'))"></button>
<button type="button" title="Paragraph" @mousedown.prevent="writekit.command(setBlockType('paragraph'))"></button>
<span class="sep" />
<button type="button" title="Undo" :disabled="!canUndo" @mousedown.prevent="writekit.undo()"></button>
<button type="button" title="Redo" :disabled="!canRedo" @mousedown.prevent="writekit.redo()"></button>
</div>
<WritekitRoot :writekit="writekit" draggable class="writekit-demo-root">
<WritekitContent />
<WritekitBubbleMenu />
<WritekitSlashMenu />
</WritekitRoot>
<!-- Live output -->
<div class="ed-output">
<div class="ed-stats">
<span><b>{{ blockCount }}</b> blocks</span>
<span><b>{{ wordCount }}</b> words</span>
<span class="ed-sel">selection: <code>{{ selectionSummary }}</code></span>
<button type="button" class="ed-json-toggle" @click="showJson = !showJson">
{{ showJson ? 'Hide' : 'Show' }} document JSON
</button>
</div>
<pre v-if="showJson" class="ed-json">{{ docJson }}</pre>
</div>
</div>
<!-- Multiplayer -->
<div v-show="tab === 'collab'" class="writekit-demo">
<div class="ed-collab-bar">
<span class="ed-peer"><span class="ed-dot" style="background:#2563eb" />Alice</span>
<span class="ed-peer"><span class="ed-dot" style="background:#db2777" />Bob</span>
<span class="ed-spacer" />
<span :class="['ed-sync', inSync ? 'ed-sync-ok' : 'ed-sync-pending']">
{{ inSync ? 'in sync' : 'diverged' }}
</span>
<button type="button" :class="['ed-conn', connected ? 'ed-conn-on' : 'ed-conn-off']" @click="connected = !connected">
{{ connected ? 'Connected' : 'Offline' }}
</button>
</div>
<div class="ed-collab-grid">
<WritekitRoot :writekit="writekitA" draggable class="writekit-demo-root collab">
<WritekitContent />
<WritekitRemoteCursors :cursors="cursorsA" />
<WritekitBubbleMenu />
<WritekitSlashMenu />
</WritekitRoot>
<WritekitRoot :writekit="writekitB" draggable class="writekit-demo-root collab">
<WritekitContent />
<WritekitRemoteCursors :cursors="cursorsB" />
<WritekitBubbleMenu />
<WritekitSlashMenu />
</WritekitRoot>
</div>
<p class="ed-hint">
Each pane is a separate CRDT replica synced over an in-memory channel. Toggle
<b>Offline</b>, edit both sides so they diverge, then reconnect the replicas
converge automatically (no Yjs).
</p>
</div>
<template #fallback>
<div class="flex min-h-72 items-center justify-center gap-2 rounded-xl border border-border bg-bg-subtle text-sm text-fg-subtle">
<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Loading writekit
</div>
</template>
</ClientOnly>
<div class="prose-docs">
<h2>How it's wired</h2>
<p>
The writekit is created from a registry and a document, then rendered with a single
<code>WritekitRoot</code>. Multiplayer is just two writekits, each bound to its own CRDT
replica with <code>bindCrdt</code>, exchanging ops over any transport.
</p>
</div>
<DocsCode
lang="ts"
:code="`import {
WritekitRoot, WritekitContent, WritekitRemoteCursors,
createDefaultRegistry, createDoc, createWritekit, createWritekitState,
createNativeProvider, bindCrdt,
} from '@robonen/writekit';
const registry = createDefaultRegistry();
const writekit = createWritekit({ state: createWritekitState({ registry, doc: createDoc(blocks) }) });
// Collaboration: bind the writekit to a CRDT replica and pipe ops to peers.
const provider = createNativeProvider({ schema: registry.schema, user: { name: 'Alice' } });
bindCrdt(writekit, provider);
provider.onLocalOps(bytes => socket.send(bytes)); // any transport
socket.onmessage = bytes => provider.applyUpdate(bytes);`"
/>
<div class="prose-docs">
<p>
See <NuxtLink to="/writekit/create-writekit">createWritekit</NuxtLink>,
<NuxtLink to="/writekit/bind-crdt">bindCrdt</NuxtLink> and
<NuxtLink to="/writekit/toggle-mark">toggleMark</NuxtLink> in the API reference for the full surface.
</p>
</div>
</div>
</template>
<style>
/* Unscoped on purpose: the writekit renders its own DOM (and teleports menus to
<body>), so scoped styles can't reach them. Selectors are namespaced under
`.writekit-demo*`, `.ed-*` and the writekit's own classes to avoid leaking. */
.writekit-demo { counter-reset: writekit-demo-ol; }
/* tabs */
.ed-tabs { display: flex; gap: 4px; margin-bottom: 0.75rem; }
.ed-tab { padding: 6px 12px; border: 1px solid var(--border); background: var(--bg-elevated); color: var(--fg-muted); border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500; transition: background 0.12s, color 0.12s, border-color 0.12s; }
.ed-tab:hover { background: var(--bg-inset); color: var(--fg); }
.ed-tab-active { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
/* toolbar */
.writekit-demo-toolbar { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; padding: 6px; border: 1px solid var(--border); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--bg-subtle); }
.writekit-demo-toolbar button { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 30px; padding: 0 9px; border: 1px solid var(--border); background: var(--bg-elevated); color: var(--fg); border-radius: 7px; cursor: pointer; font-size: 13px; line-height: 1; transition: background 0.12s, border-color 0.12s; }
.writekit-demo-toolbar button code { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; }
.writekit-demo-toolbar button:hover { border-color: var(--border-strong); background: var(--bg-inset); }
.writekit-demo-toolbar button:disabled { opacity: 0.4; cursor: not-allowed; }
.writekit-demo-toolbar button[data-active] { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
.writekit-demo-toolbar .sep { width: 1px; height: 18px; background: var(--border); margin: 0 4px; }
/* editable surface */
.writekit-demo-root { border: 1px solid var(--border); border-radius: 0 0 12px 12px; padding: 1rem 1.25rem 1rem 2rem; min-height: 280px; background: var(--bg); color: var(--fg); }
.writekit-demo-root, .writekit-demo-root [data-writekit-content] { outline: none; }
.writekit-demo-root:focus-within { border-color: var(--accent); }
.writekit-demo-root [data-block-id] { position: relative; }
.writekit-demo-root [data-block-content] { outline: none; margin: 0.45em 0; line-height: 1.7; }
.writekit-demo-root h1[data-block-content], .writekit-demo-root h2[data-block-content], .writekit-demo-root h3[data-block-content] { margin: 0.7em 0 0.3em; line-height: 1.3; font-weight: 700; letter-spacing: -0.01em; }
.writekit-demo-root h1[data-block-content] { font-size: 1.6rem; }
.writekit-demo-root h2[data-block-content] { font-size: 1.3rem; }
.writekit-demo-root h3[data-block-content] { font-size: 1.1rem; }
.writekit-demo-root [data-block-content][data-empty]::before { content: attr(data-placeholder); color: var(--fg-subtle); pointer-events: none; }
/* inline marks */
.writekit-demo-root [data-block-content] strong { font-weight: 700; }
.writekit-demo-root [data-block-content] em { font-style: italic; }
.writekit-demo-root [data-block-content] u { text-decoration: underline; }
.writekit-demo-root [data-block-content] s, .writekit-demo-root [data-block-content] del { text-decoration: line-through; }
.writekit-demo-root [data-block-content] mark { background: rgba(245, 200, 66, 0.4); color: inherit; border-radius: 2px; padding: 0 0.1em; }
.writekit-demo-root [data-block-content] code { background: var(--bg-inset); border: 1px solid var(--border); padding: 0.05em 0.35em; border-radius: 4px; font-family: var(--font-mono, ui-monospace, monospace); font-size: 0.9em; }
.writekit-demo-root [data-block-content] a { color: var(--accent-text); text-decoration: underline; cursor: pointer; }
.writekit-demo-root blockquote[data-block-content] { border-left: 3px solid var(--border-strong); padding-left: 1rem; color: var(--fg-muted); font-style: italic; }
.writekit-demo-root pre[data-block-content] { background: var(--bg-inset); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; font-family: var(--font-mono, ui-monospace, monospace); font-size: 0.85rem; white-space: pre-wrap; }
/* callouts */
.writekit-demo-root [data-callout] { position: relative; border-radius: 8px; margin: 0.5em 0; padding: 0.6rem 0.8rem 0.6rem 2.4rem; border: 1px solid var(--border); background: var(--bg-subtle); }
.writekit-demo-root [data-callout]::before { position: absolute; left: 0.8rem; }
.writekit-demo-root [data-callout='info']::before { content: ''; }
.writekit-demo-root [data-callout='warn']::before { content: ''; }
.writekit-demo-root [data-callout='success']::before { content: ''; }
/* lists */
.writekit-demo-root [data-list] { position: relative; padding-left: 1.6em; }
.writekit-demo-root [data-list]::before { position: absolute; left: 0.35em; color: var(--fg-muted); }
.writekit-demo-root [data-list='bullet']::before { content: ''; }
.writekit-demo-root [data-list='ordered'] { counter-increment: writekit-demo-ol; }
.writekit-demo-root [data-list='ordered']::before { content: counter(writekit-demo-ol) '.'; }
.writekit-demo-root [data-list='todo']::before { content: ''; }
.writekit-demo-root [data-list='todo'][data-checked='true']::before { content: ''; }
.writekit-demo-root [data-list='todo'][data-checked='true'] { color: var(--fg-subtle); text-decoration: line-through; }
.writekit-demo-root [data-writekit-divider] { border: 0; border-top: 2px solid var(--border); margin: 1em 0; }
/* selection */
.writekit-demo-root ::selection { background: var(--accent-subtle); }
.writekit-demo-root [data-block-content][data-selected], .writekit-demo-root [data-block-id][data-selected] { background: var(--accent-subtle); border-radius: 4px; }
/* drag-to-reorder handle */
.writekit-demo-root .writekit-drag-handle { position: absolute; left: -1.4em; top: 0.2em; cursor: grab; color: var(--fg-subtle); user-select: none; opacity: 0; transition: opacity 0.1s; line-height: 1.4; }
.writekit-demo-root [data-block-id]:hover > .writekit-drag-handle { opacity: 1; }
.writekit-demo-root .writekit-drag-handle:hover { color: var(--fg-muted); }
.writekit-demo-root .writekit-drag-handle:active { cursor: grabbing; }
/* output panel */
.ed-output { margin-top: 0.75rem; }
.ed-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1rem; font-size: 13px; color: var(--fg-muted); }
.ed-stats b { color: var(--fg); font-variant-numeric: tabular-nums; }
.ed-stats code { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; color: var(--accent-text); }
.ed-sel { min-width: 0; }
.ed-json-toggle { margin-left: auto; border: 1px solid var(--border); background: var(--bg-elevated); color: var(--fg-muted); border-radius: 7px; padding: 4px 10px; font-size: 12px; cursor: pointer; transition: background 0.12s, color 0.12s; }
.ed-json-toggle:hover { background: var(--bg-inset); color: var(--fg); }
.ed-json { margin-top: 0.6rem; max-height: 320px; overflow: auto; background: var(--bg-inset); border: 1px solid var(--border); border-radius: 10px; padding: 0.9rem 1rem; font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; line-height: 1.6; color: var(--fg-muted); white-space: pre; }
/* multiplayer */
.ed-collab-bar { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.6rem; font-size: 13px; }
.ed-peer { display: inline-flex; align-items: center; gap: 6px; color: var(--fg-muted); font-weight: 500; }
.ed-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.ed-spacer { flex: 1; }
.ed-sync { font-size: 12px; font-weight: 600; border-radius: 999px; padding: 2px 10px; }
.ed-sync-ok { color: var(--accent-text); background: var(--accent-subtle); }
.ed-sync-pending { color: #b45309; background: rgba(245, 158, 11, 0.15); }
.ed-conn { border: 1px solid var(--border); border-radius: 8px; padding: 4px 12px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background 0.12s, color 0.12s, border-color 0.12s; }
.ed-conn-on { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
.ed-conn-off { background: var(--bg-elevated); color: var(--fg-muted); }
.ed-collab-grid { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
@media (min-width: 720px) { .ed-collab-grid { grid-template-columns: 1fr 1fr; } }
.ed-collab-grid .writekit-demo-root { border-radius: 12px; min-height: 200px; }
.writekit-demo-root.collab { position: relative; }
.ed-hint { margin-top: 0.6rem; font-size: 13px; color: var(--fg-subtle); }
/* remote cursors (component sets --cursor-color per peer) */
.writekit-remote-cursors { position: absolute; inset: 0; pointer-events: none; overflow: visible; z-index: 4; }
.writekit-remote-selection { position: absolute; background: var(--cursor-color); opacity: 0.22; border-radius: 2px; }
.writekit-remote-caret { position: absolute; width: 2px; background: var(--cursor-color); }
.writekit-remote-caret-label { position: absolute; top: -1.05em; left: -1px; font-size: 10px; line-height: 1; white-space: nowrap; color: #fff; background: var(--cursor-color); padding: 1px 4px; border-radius: 3px 3px 3px 0; }
/* floating menus (teleported to <body>) */
.writekit-bubble-menu { display: flex; gap: 2px; background: var(--bg-elevated); border: 1px solid var(--border-strong); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); z-index: 60; }
.writekit-bubble-menu button { min-width: 30px; height: 28px; padding: 0 8px; border: 0; background: transparent; color: var(--fg-muted); border-radius: 5px; cursor: pointer; font-size: 13px; text-transform: capitalize; }
.writekit-bubble-menu button:hover { background: var(--bg-inset); color: var(--fg); }
.writekit-bubble-menu button[data-active] { background: var(--accent); color: var(--accent-fg); }
.writekit-slash-menu { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 10px; padding: 4px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16); width: 240px; max-height: 300px; overflow: auto; z-index: 60; }
.writekit-slash-menu button { display: flex; justify-content: space-between; align-items: baseline; width: 100%; text-align: left; border: 0; background: transparent; padding: 7px 10px; border-radius: 7px; cursor: pointer; font-size: 14px; color: var(--fg); }
.writekit-slash-menu button[data-highlighted] { background: var(--bg-inset); }
.writekit-slash-menu .slash-group { font-size: 11px; color: var(--fg-subtle); text-transform: capitalize; }
</style>
@@ -1,37 +1,37 @@
<script setup lang="ts"> <script setup lang="ts">
const quickStart = `<script setup lang="ts"> const quickStart = `<script setup lang="ts">
import { createDefaultRegistry, createEditor, createEditorState, EditorRoot } from '@robonen/editor'; import { createDefaultRegistry, createWritekit, createWritekitState, WritekitRoot } from '@robonen/writekit';
const registry = createDefaultRegistry(); const registry = createDefaultRegistry();
const editor = createEditor({ state: createEditorState({ registry }) }); const writekit = createWritekit({ state: createWritekitState({ registry }) });
<\/script> <\/script>
<template> <template>
<EditorRoot :editor="editor" autofocus class="editor" /> <WritekitRoot :writekit="writekit" autofocus class="writekit" />
<\/template>`; <\/template>`;
const composeSlots = `<EditorRoot :editor="editor" autofocus> const composeSlots = `<WritekitRoot :writekit="writekit" autofocus>
<EditorContent /> <WritekitContent />
<EditorBubbleMenu /> <!-- formatting toolbar on selection --> <WritekitBubbleMenu /> <!-- formatting toolbar on selection -->
<EditorSlashMenu /> <!-- type \`/\` to insert blocks --> <WritekitSlashMenu /> <!-- type \`/\` to insert blocks -->
</EditorRoot>`; </WritekitRoot>`;
const commands = `import { setBlockType, toggleMark } from '@robonen/editor'; const commands = `import { setBlockType, toggleMark } from '@robonen/writekit';
editor.command(toggleMark('bold')); writekit.command(toggleMark('bold'));
editor.command(setBlockType('heading', { level: 2 })); writekit.command(setBlockType('heading', { level: 2 }));
// Called without a dispatch they run dry perfect for // Called without a dispatch they run dry perfect for
// computing disabled / active toolbar state. // computing disabled / active toolbar state.
const canBold = editor.command(toggleMark('bold'));`; const canBold = writekit.command(toggleMark('bold'));`;
</script> </script>
<template> <template>
<div class="docs-section"> <div class="docs-section">
<div class="prose-docs"> <div class="prose-docs">
<h1>@robonen/editor</h1> <h1>@robonen/writekit</h1>
<p> <p>
A <strong>headless, block-based rich-text editor for Vue 3</strong> in the spirit of A <strong>headless, block-based rich-text writekit for Vue 3</strong> in the spirit of
Tiptap / ProseMirror / Editor.js, but with a registry-driven schema and a Tiptap / ProseMirror / Editor.js, but with a registry-driven schema and a
<strong>hand-built CRDT</strong> for collaboration (no Yjs / Loro / Automerge). <strong>hand-built CRDT</strong> for collaboration (no Yjs / Loro / Automerge).
</p> </p>
@@ -39,9 +39,9 @@ const canBold = editor.command(toggleMark('bold'));`;
<div class="prose-docs"> <div class="prose-docs">
<p> <p>
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 document fidelity of ProseMirror where native cross-block selection and arrow navigation
just work. <code>@robonen/editor</code> takes the ProseMirror route a single just work. <code>@robonen/writekit</code> takes the ProseMirror route a single
<code>contenteditable</code> surface and layers a modular block registry on top, so blocks <code>contenteditable</code> 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 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. 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'));`;
</div> </div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Headless by design</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Headless by design</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
Ships behavior and DOM structure (<code class="text-(--accent-text)">data-block-*</code> Ships behavior and DOM structure (<code class="text-accent-text">data-block-*</code>
hooks), never styling. Bring your own CSS and own the look completely. hooks), never styling. Bring your own CSS and own the look completely.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Registry-driven schema</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Registry-driven schema</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<code class="text-(--accent-text)">defineBlock</code> / <code class="text-accent-text">defineBlock</code> /
<code class="text-(--accent-text)">defineMark</code> register into an immutable schema <code class="text-accent-text">defineMark</code> register into an immutable schema
add a custom block or mark with no core changes. add a custom block or mark with no core changes.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Step-based transactions</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Step-based transactions</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
Every edit is a step with an exact inverse, powering reliable undo/redo and a single source 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. of truth for both local edits and sync.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Own CRDT, pluggable</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Own CRDT, pluggable</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
RGA text, fractional-indexed blocks, Peritext-style marks and presence behind a RGA text, fractional-indexed blocks, Peritext-style marks and presence behind a
<code class="text-(--accent-text)">CrdtProvider</code> over any transport. <code class="text-accent-text">CrdtProvider</code> over any transport.
</p> </p>
</div> </div>
</div> </div>
@@ -85,18 +85,18 @@ const canBold = editor.command(toggleMark('bold'));`;
<div class="prose-docs"> <div class="prose-docs">
<h2>Install</h2> <h2>Install</h2>
<p> <p>
The editor depends on <code>@robonen/crdt</code> for the built-in collaboration provider, and The writekit depends on <code>@robonen/crdt</code> for the built-in collaboration provider, and
on <code>vue</code> as a peer. on <code>vue</code> as a peer.
</p> </p>
</div> </div>
<DocsCode :code="`pnpm add @robonen/editor @robonen/crdt vue`" lang="bash" /> <DocsCode :code="`pnpm add @robonen/writekit @robonen/crdt vue`" lang="bash" />
<div class="prose-docs"> <div class="prose-docs">
<h2>Quick start</h2> <h2>Quick start</h2>
<p> <p>
Create a registry, build an editor around its state, and mount <code>EditorRoot</code>. Its Create a registry, build an writekit around its state, and mount <code>WritekitRoot</code>. Its
default slot renders <code>EditorContent</code> (the single <code>contenteditable</code>), so default slot renders <code>WritekitContent</code> (the single <code>contenteditable</code>), so
this is a fully working editor with all built-in blocks and marks. this is a fully working writekit with all built-in blocks and marks.
</p> </p>
</div> </div>
<DocsCode :code="quickStart" lang="vue" /> <DocsCode :code="quickStart" lang="vue" />
@@ -113,7 +113,7 @@ const canBold = editor.command(toggleMark('bold'));`;
<h2>Commands</h2> <h2>Commands</h2>
<p> <p>
Commands are <code>(state, dispatch?, view?) =&gt; boolean</code> functions that power the Commands are <code>(state, dispatch?, view?) =&gt; boolean</code> functions that power the
keymap, the UI, and programmatic edits. Run one with <code>editor.command(...)</code>; omit keymap, the UI, and programmatic edits. Run one with <code>writekit.command(...)</code>; omit
the dispatch to dry-run it for active/disabled state. the dispatch to dry-run it for active/disabled state.
</p> </p>
</div> </div>
@@ -135,7 +135,7 @@ const canBold = editor.command(toggleMark('bold'));`;
</div> </div>
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4"> <div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<strong class="text-amber-700 dark:text-amber-400">Status: v0, work in progress.</strong> <strong class="text-amber-700 dark:text-amber-400">Status: v0, work in progress.</strong>
Core logic is covered by unit + convergence tests; the contenteditable / Playwright suite Core logic is covered by unit + convergence tests; the contenteditable / Playwright suite
runs locally. The collaboration layer has a few documented, deferred limitations. runs locally. The collaboration layer has a few documented, deferred limitations.
@@ -147,26 +147,26 @@ const canBold = editor.command(toggleMark('bold'));`;
<p>Jump into the pieces you'll reach for first:</p> <p>Jump into the pieces you'll reach for first:</p>
<ul> <ul>
<li> <li>
<NuxtLink to="/editor/playground"><strong>Playground</strong></NuxtLink> a live editor <NuxtLink to="/writekit/playground"><strong>Playground</strong></NuxtLink> a live writekit
you can type in, right here in the docs. you can type in, right here in the docs.
</li> </li>
<li> <li>
<code>EditorRoot</code> and <code>EditorContent</code> the mount <code>WritekitRoot</code> and <code>WritekitContent</code> the mount
surface and the single contenteditable. surface and the single contenteditable.
</li> </li>
<li> <li>
<NuxtLink to="/editor/create-default-registry"><code>createDefaultRegistry</code></NuxtLink>, <NuxtLink to="/writekit/create-default-registry"><code>createDefaultRegistry</code></NuxtLink>,
<NuxtLink to="/editor/define-block"><code>defineBlock</code></NuxtLink> and <NuxtLink to="/writekit/define-block"><code>defineBlock</code></NuxtLink> and
<NuxtLink to="/editor/define-mark"><code>defineMark</code></NuxtLink> extend the schema. <NuxtLink to="/writekit/define-mark"><code>defineMark</code></NuxtLink> extend the schema.
</li> </li>
<li> <li>
<NuxtLink to="/editor/toggle-mark"><code>toggleMark</code></NuxtLink> / <NuxtLink to="/writekit/toggle-mark"><code>toggleMark</code></NuxtLink> /
<NuxtLink to="/editor/set-block-type"><code>setBlockType</code></NuxtLink> the commands <NuxtLink to="/writekit/set-block-type"><code>setBlockType</code></NuxtLink> the commands
API for programmatic and toolbar edits. API for programmatic and toolbar edits.
</li> </li>
<li> <li>
<NuxtLink to="/editor/bind-crdt"><code>bindCrdt</code></NuxtLink> and <NuxtLink to="/writekit/bind-crdt"><code>bindCrdt</code></NuxtLink> and
<NuxtLink to="/editor/create-native-provider"><code>createNativeProvider</code></NuxtLink> <NuxtLink to="/writekit/create-native-provider"><code>createNativeProvider</code></NuxtLink>
wire up real-time collaboration with the built-in CRDT. wire up real-time collaboration with the built-in CRDT.
</li> </li>
</ul> </ul>
@@ -1,9 +1,9 @@
import { base, compose, imports, stylistic, typescript, vue } from '@robonen/eslint'; import { base, compose, imports, stylistic, tests, typescript, vue } from '@robonen/eslint';
export default compose(base, typescript, vue, imports, stylistic, { export default compose(base, typescript, vue, imports, stylistic, {
name: 'editor/overrides', name: 'writekit/overrides',
files: ['**/*.vue'], files: ['**/*.vue'],
rules: { rules: {
'@stylistic/no-multiple-empty-lines': 'off', '@stylistic/no-multiple-empty-lines': 'off',
}, },
}); }, tests);
@@ -1,13 +1,13 @@
{ {
"name": "@robonen/editor", "name": "@robonen/writekit",
"version": "0.0.1", "version": "0.0.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Headless block-based rich-text editor for Vue with a registry-driven schema and pluggable CRDT", "description": "Headless block-based rich-text writekit for Vue with a registry-driven schema and pluggable CRDT",
"keywords": [ "keywords": [
"editor", "writekit",
"rich-text", "rich-text",
"wysiwyg", "wysiwyg",
"block-editor", "block-writekit",
"crdt", "crdt",
"collaborative", "collaborative",
"vue", "vue",
@@ -17,7 +17,7 @@
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "vue/editor" "directory": "vue/writekit"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@10.34.1",
"engines": { "engines": {
@@ -73,10 +73,11 @@
"vue-tsc": "^3.3.4" "vue-tsc": "^3.3.4"
}, },
"dependencies": { "dependencies": {
"@floating-ui/vue": "^1.1.11",
"@robonen/crdt": "workspace:*", "@robonen/crdt": "workspace:*",
"@robonen/platform": "workspace:*", "@robonen/platform": "workspace:*",
"@robonen/primitives": "workspace:*",
"@robonen/stdlib": "workspace:*", "@robonen/stdlib": "workspace:*",
"@robonen/vue": "workspace:*",
"@vue/shared": "catalog:", "@vue/shared": "catalog:",
"vue": "catalog:" "vue": "catalog:"
} }
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@robonen/editor playground</title> <title>@robonen/writekit playground</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
@@ -1,9 +1,9 @@
{ {
"name": "@robonen/editor-playground", "name": "@robonen/writekit-playground",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Minimal playground for @robonen/editor — eyeball, debug, hack on hypotheses", "description": "Minimal playground for @robonen/writekit — eyeball, debug, hack on hypotheses",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -11,7 +11,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@robonen/editor": "workspace:*", "@robonen/writekit": "workspace:*",
"vue": "catalog:" "vue": "catalog:"
}, },
"devDependencies": { "devDependencies": {
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { shallowRef } from 'vue';
import CollabDemo from './demos/CollabDemo.vue';
import CommandsDemo from './demos/CommandsDemo.vue';
import ComplexBlocksDemo from './demos/ComplexBlocksDemo.vue';
import CustomKeymapDemo from './demos/CustomKeymapDemo.vue';
import ManyBlocksDemo from './demos/ManyBlocksDemo.vue';
import MarksDemo from './demos/MarksDemo.vue';
import MultiWritekitDemo from './demos/MultiWritekitDemo.vue';
import ReadOnlyDemo from './demos/ReadOnlyDemo.vue';
import RichTextDemo from './demos/RichTextDemo.vue';
const demos = [
{ id: 'rich', title: 'Rich text', component: RichTextDemo },
{ id: 'complex', title: 'Complex blocks', component: ComplexBlocksDemo },
{ id: 'collab', title: 'Collaboration', component: CollabDemo },
{ id: 'marks', title: 'Inline marks', component: MarksDemo },
{ id: 'many', title: 'Many blocks', component: ManyBlocksDemo },
{ id: 'multi', title: 'Multiple writekits', component: MultiWritekitDemo },
{ id: 'readonly', title: 'Read-only', component: ReadOnlyDemo },
{ id: 'commands', title: 'Commands API', component: CommandsDemo },
{ id: 'keymap', title: 'Custom keymap', component: CustomKeymapDemo },
];
const current = shallowRef(demos[0]!);
</script>
<template>
<div class="layout">
<nav class="sidebar">
<h1>@robonen/writekit</h1>
<button
v-for="demo in demos"
:key="demo.id"
:class="{ active: demo.id === current.id }"
@click="current = demo"
>
{{ demo.title }}
</button>
</nav>
<main class="content">
<component :is="current.component" :key="current.id" />
</main>
</div>
</template>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; color: #1a1a1a; background: #fafafa; }
.layout { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
.sidebar { border-right: 1px solid #e5e5e5; padding: 1rem; background: #fff; position: sticky; top: 0; height: 100vh; }
.sidebar h1 { font-size: 14px; margin: 0 0 1rem; color: #666; }
.sidebar button { display: block; width: 100%; text-align: left; padding: 8px 10px; margin-bottom: 2px; border: 0; background: transparent; border-radius: 6px; cursor: pointer; font-size: 14px; color: #333; }
.sidebar button:hover { background: #f0f0f0; }
.sidebar button.active { background: #1a1a1a; color: #fff; }
.content { padding: 2rem; max-width: 880px; }
.content section > h2 { margin: 0 0 0.25rem; }
.hint { color: #888; font-size: 13px; margin: 0 0 1rem; }
.toolbar { display: flex; gap: 4px; align-items: center; margin-bottom: 0.75rem; }
.toolbar.wrap { flex-wrap: wrap; }
.toolbar button { min-width: 32px; height: 32px; padding: 0 8px; border: 1px solid #ddd; background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; }
.toolbar button:hover { border-color: #bbb; }
.toolbar button:disabled { opacity: 0.4; cursor: default; }
.toolbar button[data-active] { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.toolbar .sep { width: 1px; height: 20px; background: #ddd; margin: 0 4px; }
.writekit { border: 1px solid #e5e5e5; border-radius: 8px; padding: 1rem 1.25rem; min-height: 120px; background: #fff; }
.writekit:focus-within { border-color: #999; }
.writekit.scroll { max-height: 420px; overflow: auto; }
.writekit [data-block-content] { outline: none; margin: 0.4em 0; line-height: 1.6; }
.writekit [data-block-content]:is(h1, h2, h3, h4, h5, h6) { margin: 0.6em 0 0.3em; line-height: 1.3; }
.writekit [data-block-type='heading'] [data-block-content] { font-weight: 700; }
.writekit [data-block-content][data-empty]::before { content: attr(data-placeholder); color: #bbb; pointer-events: none; }
.writekit [data-block-content] strong { font-weight: 700; }
.writekit [data-block-content] em { font-style: italic; }
.writekit ::selection { background: #b3d4fc; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
details { margin-top: 1rem; }
summary { cursor: pointer; color: #666; font-size: 13px; }
details pre { background: #f6f6f6; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 12px; max-height: 300px; }
/* inline marks */
.writekit [data-block-content] mark { background: #fde68a; border-radius: 2px; }
.writekit [data-block-content] code { background: #eef0f2; padding: 0.1em 0.35em; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
.writekit [data-block-content] a { color: #2563eb; text-decoration: underline; cursor: pointer; }
/* blockquote */
.writekit blockquote[data-block-content] { border-left: 3px solid #ddd; padding-left: 1rem; color: #555; font-style: italic; }
/* code block */
.writekit pre[data-block-content] { background: #f6f8fa; border: 1px solid #eaecef; border-radius: 6px; padding: 0.75rem 1rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; white-space: pre-wrap; }
/* callout */
.writekit [data-callout] { position: relative; border-radius: 8px; margin: 0.5em 0; padding: 0.6rem 0.8rem 0.6rem 2.4rem; }
.writekit [data-callout]::before { position: absolute; left: 0.8rem; }
.writekit [data-callout='info'] { background: #eef4ff; } .writekit [data-callout='info']::before { content: '️'; }
.writekit [data-callout='warn'] { background: #fff6e6; } .writekit [data-callout='warn']::before { content: '⚠️'; }
.writekit [data-callout='success'] { background: #ecfdf3; } .writekit [data-callout='success']::before { content: '✅'; }
/* lists (flat-with-indent; marker in the gutter, indent via inline margin-left) */
.writekit { counter-reset: writekit-ol; }
.writekit [data-list] { position: relative; }
.writekit [data-list]::before { position: absolute; left: 0.1em; color: #555; }
.writekit [data-list='bullet']::before { content: '•'; }
.writekit [data-list='ordered'] { counter-increment: writekit-ol; }
.writekit [data-list='ordered']::before { content: counter(writekit-ol) '.'; }
.writekit [data-list='todo']::before { content: '☐'; }
.writekit [data-list='todo'][data-checked='true']::before { content: '☑'; }
.writekit [data-list='todo'][data-checked='true'] { color: #999; text-decoration: line-through; }
/* atoms: image + divider */
.writekit [data-writekit-image] { margin: 0.8em 0; text-align: center; }
.writekit [data-writekit-image] img { max-width: 100%; border-radius: 8px; }
.writekit [data-writekit-image] figcaption { color: #888; font-size: 13px; margin-top: 4px; }
.writekit [data-writekit-image] .image-placeholder { background: #f3f3f3; border: 1px dashed #ccc; border-radius: 8px; padding: 1.5rem; color: #999; }
.writekit [data-writekit-image] .image-fields { display: flex; flex-direction: column; gap: 4px; margin: 6px auto 0; max-width: 360px; }
.writekit [data-writekit-image] .image-fields input { padding: 4px 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
.writekit [data-writekit-divider] { border: 0; border-top: 2px solid #e5e5e5; margin: 1em 0; }
/* node selection highlight */
.writekit [data-block-type='image'][data-selected], .writekit [data-block-type='divider'][data-selected] { outline: 2px solid #2563eb; outline-offset: 2px; border-radius: 6px; }
.writekit [data-block-content][data-selected], .writekit [data-block-id][data-selected]:not([data-block-type='image']):not([data-block-type='divider']) { background: rgba(37, 99, 235, 0.08); border-radius: 4px; }
/* floating menus (teleported to body) */
.writekit-bubble-menu { display: flex; gap: 2px; background: #1a1a1a; border-radius: 8px; padding: 4px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); z-index: 50; }
.writekit-bubble-menu button { min-width: 30px; height: 28px; padding: 0 8px; border: 0; background: transparent; color: #fff; border-radius: 5px; cursor: pointer; font-size: 13px; text-transform: capitalize; }
.writekit-bubble-menu button:hover { background: rgba(255, 255, 255, 0.15); }
.writekit-bubble-menu button[data-active] { background: #fff; color: #1a1a1a; }
.writekit-slash-menu { background: #fff; border: 1px solid #e5e5e5; border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); width: 230px; max-height: 280px; overflow: auto; z-index: 50; }
.writekit-slash-menu button { display: flex; justify-content: space-between; align-items: baseline; width: 100%; text-align: left; border: 0; background: transparent; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 14px; color: #333; }
.writekit-slash-menu button[data-highlighted] { background: #f0f0f0; }
.writekit-slash-menu .slash-group { font-size: 11px; color: #aaa; text-transform: capitalize; }
kbd { background: #eee; border: 1px solid #ddd; border-radius: 4px; padding: 1px 5px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* drag-to-reorder handle */
.writekit [data-block-id] { position: relative; }
.writekit-drag-handle { position: absolute; left: -1.2em; top: 0.25em; cursor: grab; color: #ccc; user-select: none; opacity: 0; transition: opacity 0.1s; line-height: 1.4; }
.writekit [data-block-id]:hover > .writekit-drag-handle { opacity: 1; }
.writekit-drag-handle:hover { color: #888; }
.writekit-drag-handle:active { cursor: grabbing; }
/* remote collaboration cursors */
.writekit.collab { position: relative; }
.writekit-remote-cursors { position: absolute; inset: 0; pointer-events: none; overflow: visible; z-index: 4; }
.writekit-remote-selection { position: absolute; background: var(--cursor-color); opacity: 0.22; border-radius: 2px; }
.writekit-remote-caret { position: absolute; width: 2px; background: var(--cursor-color); }
.writekit-remote-caret-label { position: absolute; top: -1.05em; left: -1px; font-size: 10px; line-height: 1; white-space: nowrap; color: #fff; background: var(--cursor-color); padding: 1px 4px; border-radius: 3px 3px 3px 0; }
</style>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import type { Writekit } from '@writekit';
import { isBlockActive, isMarkActive, setBlockType, toggleBlockType, toggleMark } from '@writekit';
const { writekit } = defineProps<{ writekit: Writekit }>();
// Re-evaluate active-states on every transaction.
const rev = ref(0);
const bump = (): void => void (rev.value += 1);
writekit.on('transaction', bump);
onBeforeUnmount(() => writekit.off('transaction', bump));
const boldActive = computed(() => (rev.value, isMarkActive(writekit.state, 'bold')));
const italicActive = computed(() => (rev.value, isMarkActive(writekit.state, 'italic')));
const h1Active = computed(() => (rev.value, isBlockActive(writekit.state, 'heading', { level: 1 })));
const h2Active = computed(() => (rev.value, isBlockActive(writekit.state, 'heading', { level: 2 })));
const canUndo = computed(() => (rev.value, writekit.canUndo()));
const canRedo = computed(() => (rev.value, writekit.canRedo()));
</script>
<template>
<div class="toolbar">
<button :data-active="boldActive || undefined" @mousedown.prevent="writekit.command(toggleMark('bold'))"><b>B</b></button>
<button :data-active="italicActive || undefined" @mousedown.prevent="writekit.command(toggleMark('italic'))"><i>I</i></button>
<span class="sep" />
<button :data-active="h1Active || undefined" @mousedown.prevent="writekit.command(toggleBlockType('heading', { level: 1 }))">H1</button>
<button :data-active="h2Active || undefined" @mousedown.prevent="writekit.command(toggleBlockType('heading', { level: 2 }))">H2</button>
<button @mousedown.prevent="writekit.command(setBlockType('paragraph'))">P</button>
<span class="sep" />
<button :disabled="!canUndo" @mousedown.prevent="writekit.undo()">Undo</button>
<button :disabled="!canRedo" @mousedown.prevent="writekit.redo()">Redo</button>
</div>
</template>
@@ -1,19 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'; import { onBeforeUnmount, ref } from 'vue';
import type { RemoteCursor } from '@editor'; import type { RemoteCursor } from '@writekit';
import { import {
EditorBubbleMenu, WritekitBubbleMenu,
EditorContent, WritekitContent,
EditorRemoteCursors, WritekitRemoteCursors,
EditorRoot, WritekitRoot,
EditorSlashMenu, WritekitSlashMenu,
bindCrdt, bindCrdt,
createDefaultRegistry, createDefaultRegistry,
createDoc, createDoc,
createEditor,
createEditorState,
createNativeProvider, createNativeProvider,
} from '@editor'; createWritekit,
createWritekitState,
} from '@writekit';
import { h, p } from '../lib'; import { h, p } from '../lib';
const registry = createDefaultRegistry(); const registry = createDefaultRegistry();
@@ -24,15 +24,15 @@ const seed = createDoc([
]); ]);
// Peer A owns the initial document. // Peer A owns the initial document.
const editorA = createEditor({ state: createEditorState({ registry, doc: seed }) }); const writekitA = createWritekit({ state: createWritekitState({ registry, doc: seed }) });
const providerA = createNativeProvider({ schema: registry.schema, doc: editorA.state.doc, user: { name: 'Alice', color: '#2563eb' } }); const providerA = createNativeProvider({ schema: registry.schema, doc: writekitA.state.doc, user: { name: 'Alice', color: '#2563eb' } });
// Peer B starts empty and joins by syncing A's full state. // Peer B starts empty and joins by syncing A's full state.
const editorB = createEditor({ state: createEditorState({ registry }) }); const writekitB = createWritekit({ state: createWritekitState({ registry }) });
const providerB = createNativeProvider({ schema: registry.schema, user: { name: 'Bob', color: '#db2777' } }); const providerB = createNativeProvider({ schema: registry.schema, user: { name: 'Bob', color: '#db2777' } });
const bindingA = bindCrdt(editorA, providerA); const bindingA = bindCrdt(writekitA, providerA);
const bindingB = bindCrdt(editorB, providerB); const bindingB = bindCrdt(writekitB, providerB);
providerB.applyUpdate(providerA.encodeDelta()); providerB.applyUpdate(providerA.encodeDelta());
@@ -63,25 +63,25 @@ onBeforeUnmount(() => {
<template> <template>
<section> <section>
<h2>Collaboration (own CRDT)</h2> <h2>Collaboration (own CRDT)</h2>
<p class="hint">Two independent editors, each backed by a separate <code>@robonen/crdt</code> replica, synced in memory. Type in either pane concurrent edits converge live and you'll see the other peer's cursor; no Yjs.</p> <p class="hint">Two independent writekits, each backed by a separate <code>@robonen/crdt</code> replica, synced in memory. Type in either pane concurrent edits converge live and you'll see the other peer's cursor; no Yjs.</p>
<div class="cols"> <div class="cols">
<div> <div>
<div class="peer-label"><span class="peer-dot" style="background: #2563eb" />Alice</div> <div class="peer-label"><span class="peer-dot" style="background: #2563eb" />Alice</div>
<EditorRoot :editor="editorA" autofocus class="editor collab"> <WritekitRoot :writekit="writekitA" autofocus class="writekit collab">
<EditorContent /> <WritekitContent />
<EditorRemoteCursors :cursors="cursorsA" /> <WritekitRemoteCursors :cursors="cursorsA" />
<EditorBubbleMenu /> <WritekitBubbleMenu />
<EditorSlashMenu /> <WritekitSlashMenu />
</EditorRoot> </WritekitRoot>
</div> </div>
<div> <div>
<div class="peer-label"><span class="peer-dot" style="background: #db2777" />Bob</div> <div class="peer-label"><span class="peer-dot" style="background: #db2777" />Bob</div>
<EditorRoot :editor="editorB" class="editor collab"> <WritekitRoot :writekit="writekitB" class="writekit collab">
<EditorContent /> <WritekitContent />
<EditorRemoteCursors :cursors="cursorsB" /> <WritekitRemoteCursors :cursors="cursorsB" />
<EditorBubbleMenu /> <WritekitBubbleMenu />
<EditorSlashMenu /> <WritekitSlashMenu />
</EditorRoot> </WritekitRoot>
</div> </div>
</div> </div>
</section> </section>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
WritekitRoot,
createNode,
createTransaction,
moveBlockDown,
moveBlockUp,
removeBlock,
setBlockType,
toggleMark,
} from '@writekit';
import { h, makeWritekit, p } from '../lib';
const writekit = makeWritekit([
h(1, 'Commands API'),
p('Drive the writekit programmatically with the buttons below. Put the caret in a block first.'),
p('Second block.'),
p('Third block.'),
]);
const rev = ref(0);
writekit.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(writekit.state.doc, null, 2)));
const canDelete = computed(() => (rev.value, writekit.state.doc.content.length > 1));
function focusId(): string | undefined {
const sel = writekit.state.selection;
return sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
}
function appendParagraph(): void {
const node = createNode('paragraph', { content: [{ text: 'Appended block', marks: [] }] });
writekit.dispatch(createTransaction(writekit.state).insertBlock(node, writekit.state.doc.content.length));
}
function deleteFocused(): void {
const id = focusId();
if (id && writekit.state.doc.content.length > 1)
writekit.command(removeBlock(id));
}
</script>
<template>
<section>
<h2>Commands API</h2>
<p class="hint">Programmatic control every button is a command or transaction on the writekit.</p>
<div class="toolbar wrap">
<button @mousedown.prevent="appendParagraph">Append paragraph</button>
<button @mousedown.prevent="writekit.command(moveBlockUp)">Move block </button>
<button @mousedown.prevent="writekit.command(moveBlockDown)">Move block </button>
<button @mousedown.prevent="writekit.command(setBlockType('heading', { level: 1 }))"> H1</button>
<button @mousedown.prevent="writekit.command(setBlockType('paragraph'))"> Paragraph</button>
<button @mousedown.prevent="writekit.command(toggleMark('bold'))">Toggle bold</button>
<button :disabled="!canDelete" @mousedown.prevent="deleteFocused">Delete block</button>
</div>
<WritekitRoot :writekit="writekit" class="writekit" />
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { Node } from '@writekit';
import {
WritekitBubbleMenu,
WritekitContent,
WritekitRoot,
WritekitSlashMenu,
addMark,
createTransaction,
nodeSelection,
setBlockType,
toggleChecked,
toggleMark,
} from '@writekit';
import { bullet, callout, code, divider, h, image, makeWritekit, numbered, p, quote, t, todo } from '../lib';
const writekit = makeWritekit([
h(1, 'Complex blocks'),
p([t('A document with '), t('many', 'bold'), t(' block types. Put the caret in a block and use the controls to convert it, or insert media.')]),
quote('“The block is the unit of composition.” — a registry-driven writekit.'),
callout('info', 'Callouts carry a variant attribute. This one is "info".'),
callout('warn', 'And this is a "warn" callout.'),
code('function hello() {\n // Enter inserts a newline here, not a block split\n return \'code block\';\n}'),
h(2, 'Lists'),
bullet('Bulleted item one'),
bullet('Nested bullet (indent = 1) — Tab / Shift+Tab changes indent', 1),
bullet('Bulleted item two'),
numbered('Numbered item (counter via CSS)'),
numbered('Numbered item'),
todo('A finished task', true),
todo('A pending task', false),
h(2, 'Media (atoms)'),
image('https://picsum.photos/seed/robonen/520/240', 'A random sample image'),
divider(),
p('Click an image or divider to select it, then Backspace/Delete removes it. Selecting an image reveals its URL / alt / caption fields.'),
]);
const rev = ref(0);
writekit.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(writekit.state.doc, null, 2)));
function focusIndex(): number {
const sel = writekit.state.selection;
const id = sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
const index = id ? writekit.state.doc.content.findIndex(block => block.id === id) : -1;
return index === -1 ? writekit.state.doc.content.length - 1 : index;
}
function insertAfterFocus(node: Node): void {
writekit.dispatch(createTransaction(writekit.state).insertBlock(node, focusIndex() + 1).setSelection(nodeSelection([node.id])));
}
function addLink(): void {
const href = globalThis.prompt('Link URL', 'https://');
if (href)
writekit.command(addMark('link', { href }));
}
</script>
<template>
<section>
<h2>Complex blocks</h2>
<p class="hint">Quote, callout, code block, lists (bulleted / numbered / to-do), image &amp; divider atoms, plus the full mark set. Everything is registry-driven.</p>
<div class="toolbar wrap">
<button @mousedown.prevent="writekit.command(toggleMark('bold'))"><b>B</b></button>
<button @mousedown.prevent="writekit.command(toggleMark('italic'))"><i>I</i></button>
<button @mousedown.prevent="writekit.command(toggleMark('underline'))"><u>U</u></button>
<button @mousedown.prevent="writekit.command(toggleMark('strike'))"><s>S</s></button>
<button @mousedown.prevent="writekit.command(toggleMark('code'))">&lt;/&gt;</button>
<button @mousedown.prevent="writekit.command(toggleMark('highlight'))">HL</button>
<button @mousedown.prevent="addLink">Link</button>
<span class="sep" />
<button @mousedown.prevent="writekit.command(setBlockType('paragraph'))">P</button>
<button @mousedown.prevent="writekit.command(setBlockType('heading', { level: 1 }))">H1</button>
<button @mousedown.prevent="writekit.command(setBlockType('heading', { level: 2 }))">H2</button>
<button @mousedown.prevent="writekit.command(setBlockType('blockquote'))">Quote</button>
<button @mousedown.prevent="writekit.command(setBlockType('code-block'))">Code</button>
<button @mousedown.prevent="writekit.command(setBlockType('callout', { variant: 'info' }))">Callout</button>
<span class="sep" />
<button @mousedown.prevent="writekit.command(setBlockType('bulleted-list'))"> List</button>
<button @mousedown.prevent="writekit.command(setBlockType('numbered-list'))">1. List</button>
<button @mousedown.prevent="writekit.command(setBlockType('todo-list', { checked: false, indent: 0 }))"> Todo</button>
<button @mousedown.prevent="writekit.command(toggleChecked)">Toggle </button>
<span class="sep" />
<button @mousedown.prevent="insertAfterFocus(image('', ''))">+ Image</button>
<button @mousedown.prevent="insertAfterFocus(divider())">+ Divider</button>
<span class="sep" />
<button @mousedown.prevent="writekit.undo()">Undo</button>
<button @mousedown.prevent="writekit.redo()">Redo</button>
</div>
<p class="hint">Type <kbd>/</kbd> to insert a block; select text for the bubble toolbar; hover a block and drag the <span aria-hidden="true"></span> handle to reorder. Markdown shortcuts work too: <kbd># </kbd>, <kbd>- </kbd>, <kbd>&gt; </kbd>, <kbd>1. </kbd>, <kbd>[] </kbd>.</p>
<WritekitRoot :writekit="writekit" autofocus draggable class="writekit">
<WritekitContent />
<WritekitBubbleMenu />
<WritekitSlashMenu />
</WritekitRoot>
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { EditorRoot, createNode, createTransaction } from '@editor'; import { WritekitRoot, createNode, createTransaction } from '@writekit';
import type { Command, Keymap } from '@editor'; import type { Command, Keymap } from '@writekit';
import { makeEditor, p } from '../lib'; import { makeWritekit, p } from '../lib';
import Toolbar from '../Toolbar.vue'; import Toolbar from '../Toolbar.vue';
const editor = makeEditor([ const writekit = makeWritekit([
p('Press Cmd/Ctrl+Enter to insert an italic note below the current block.'), p('Press Cmd/Ctrl+Enter to insert an italic note below the current block.'),
p('The default keymap (Enter, Backspace, Cmd/Ctrl+B, …) still works — user keymaps merge over it.'), p('The default keymap (Enter, Backspace, Cmd/Ctrl+B, …) still works — user keymaps merge over it.'),
]); ]);
@@ -30,7 +30,7 @@ const keymaps: Keymap[] = [{ 'Mod-Enter': insertNote }];
<section> <section>
<h2>Custom keymap</h2> <h2>Custom keymap</h2>
<p class="hint">A user keymap merged over the defaults: Cmd/Ctrl+Enter inserts a note.</p> <p class="hint">A user keymap merged over the defaults: Cmd/Ctrl+Enter inserts a note.</p>
<Toolbar :editor="editor" /> <Toolbar :writekit="writekit" />
<EditorRoot :editor="editor" :keymaps="keymaps" class="editor" /> <WritekitRoot :writekit="writekit" :keymaps="keymaps" class="writekit" />
</section> </section>
</template> </template>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Node } from '@editor'; import type { Node } from '@writekit';
import { EditorRoot } from '@editor'; import { WritekitRoot } from '@writekit';
import { h, makeEditor, p } from '../lib'; import { h, makeWritekit, p } from '../lib';
import Toolbar from '../Toolbar.vue'; import Toolbar from '../Toolbar.vue';
const blocks: Node[] = []; const blocks: Node[] = [];
@@ -12,14 +12,14 @@ for (let i = 1; i <= 60; i++) {
blocks.push(p(`Block ${i}: the quick brown fox jumps over the lazy dog.`)); blocks.push(p(`Block ${i}: the quick brown fox jumps over the lazy dog.`));
} }
const editor = makeEditor(blocks); const writekit = makeWritekit(blocks);
</script> </script>
<template> <template>
<section> <section>
<h2>Many blocks</h2> <h2>Many blocks</h2>
<p class="hint">60 blocks test cross-block drag over a long range, / navigation, and Cmd/Ctrl+A (once = current block, twice = whole document).</p> <p class="hint">60 blocks test cross-block drag over a long range, / navigation, and Cmd/Ctrl+A (once = current block, twice = whole document).</p>
<Toolbar :editor="editor" /> <Toolbar :writekit="writekit" />
<EditorRoot :editor="editor" class="editor scroll" /> <WritekitRoot :writekit="writekit" class="writekit scroll" />
</section> </section>
</template> </template>
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { EditorRoot } from '@editor'; import { WritekitRoot } from '@writekit';
import { makeEditor, p, t } from '../lib'; import { makeWritekit, p, t } from '../lib';
import Toolbar from '../Toolbar.vue'; import Toolbar from '../Toolbar.vue';
const editor = makeEditor([ const writekit = makeWritekit([
p([t('Adjacent runs: '), t('bold', 'bold'), t(' '), t('italic', 'italic'), t(' '), t('bold+italic', 'bold', 'italic'), t(' plain.')]), p([t('Adjacent runs: '), t('bold', 'bold'), t(' '), t('italic', 'italic'), t(' '), t('bold+italic', 'bold', 'italic'), t(' plain.')]),
p([t('boldAtStart', 'bold'), t(' then plain then '), t('boldAtEnd', 'bold')]), p([t('boldAtStart', 'bold'), t(' then plain then '), t('boldAtEnd', 'bold')]),
p('Select part of a word and toggle Cmd/Ctrl+B — the mark splits the run mid-word; toggle again to remove. With a collapsed caret, toggling sets the mark for the next typed character (stored marks).'), p('Select part of a word and toggle Cmd/Ctrl+B — the mark splits the run mid-word; toggle again to remove. With a collapsed caret, toggling sets the mark for the next typed character (stored marks).'),
@@ -14,7 +14,7 @@ const editor = makeEditor([
<section> <section>
<h2>Inline marks</h2> <h2>Inline marks</h2>
<p class="hint">Adjacent / overlapping marks, marks at run boundaries, partial-word toggling, stored marks.</p> <p class="hint">Adjacent / overlapping marks, marks at run boundaries, partial-word toggling, stored marks.</p>
<Toolbar :editor="editor" /> <Toolbar :writekit="writekit" />
<EditorRoot :editor="editor" class="editor" /> <WritekitRoot :writekit="writekit" class="writekit" />
</section> </section>
</template> </template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { WritekitRoot } from '@writekit';
import { h, makeWritekit, p } from '../lib';
import Toolbar from '../Toolbar.vue';
const left = makeWritekit([h(2, 'Writekit A'), p('Type and select here.')]);
const right = makeWritekit([h(2, 'Writekit B'), p('This writekit is fully independent.')]);
</script>
<template>
<section>
<h2>Multiple writekits</h2>
<p class="hint">Two independent writekits on one page selection and editing in one must never affect the other.</p>
<div class="cols">
<div>
<Toolbar :writekit="left" />
<WritekitRoot :writekit="left" class="writekit" />
</div>
<div>
<Toolbar :writekit="right" />
<WritekitRoot :writekit="right" class="writekit" />
</div>
</div>
</section>
</template>
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { EditorRoot } from '@editor'; import { WritekitRoot } from '@writekit';
import { h, makeEditor, p, t } from '../lib'; import { h, makeWritekit, p, t } from '../lib';
const editor = makeEditor([ const writekit = makeWritekit([
h(2, 'Read-only'), h(2, 'Read-only'),
p([t('You can '), t('select', 'bold'), t(' and copy this text, but typing and shortcuts do nothing.')]), p([t('You can '), t('select', 'bold'), t(' and copy this text, but typing and shortcuts do nothing.')]),
p('Mouse selection and arrow navigation still work across blocks.'), p('Mouse selection and arrow navigation still work across blocks.'),
@@ -13,6 +13,6 @@ const editor = makeEditor([
<section> <section>
<h2>Read-only</h2> <h2>Read-only</h2>
<p class="hint">editable=false selection/navigation work; edits and shortcuts are blocked.</p> <p class="hint">editable=false selection/navigation work; edits and shortcuts are blocked.</p>
<EditorRoot :editor="editor" :editable="false" class="editor" /> <WritekitRoot :writekit="writekit" :editable="false" class="writekit" />
</section> </section>
</template> </template>
@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { EditorBubbleMenu, EditorContent, EditorRoot, EditorSlashMenu } from '@editor'; import { WritekitBubbleMenu, WritekitContent, WritekitRoot, WritekitSlashMenu } from '@writekit';
import { h, makeEditor, p, t } from '../lib'; import { h, makeWritekit, p, t } from '../lib';
import Toolbar from '../Toolbar.vue'; import Toolbar from '../Toolbar.vue';
const editor = makeEditor([ const writekit = makeWritekit([
h(1, 'Welcome to the editor'), h(1, 'Welcome to the writekit'),
p([t('This paragraph mixes '), t('bold', 'bold'), t(', '), t('italic', 'italic'), t(', and '), t('both at once', 'bold', 'italic'), t('.')]), p([t('This paragraph mixes '), t('bold', 'bold'), t(', '), t('italic', 'italic'), t(', and '), t('both at once', 'bold', 'italic'), t('.')]),
p('Drag with the mouse across these two paragraphs — the selection spans both, just like Word. Use ↑/↓ to move between blocks and Shift+↑/↓ to extend across them.'), p('Drag with the mouse across these two paragraphs — the selection spans both, just like Word. Use ↑/↓ to move between blocks and Shift+↑/↓ to extend across them.'),
p(''), p(''),
@@ -14,20 +14,20 @@ const editor = makeEditor([
]); ]);
const rev = ref(0); const rev = ref(0);
editor.on('transaction', () => (rev.value += 1)); writekit.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(editor.state.doc, null, 2))); const docJson = computed(() => (rev.value, JSON.stringify(writekit.state.doc, null, 2)));
</script> </script>
<template> <template>
<section> <section>
<h2>Rich text</h2> <h2>Rich text</h2>
<p class="hint">Mixed marks, headings, an empty block (placeholder), cross-block selection &amp; navigation.</p> <p class="hint">Mixed marks, headings, an empty block (placeholder), cross-block selection &amp; navigation.</p>
<Toolbar :editor="editor" /> <Toolbar :writekit="writekit" />
<EditorRoot :editor="editor" autofocus class="editor"> <WritekitRoot :writekit="writekit" autofocus class="writekit">
<EditorContent /> <WritekitContent />
<EditorBubbleMenu /> <WritekitBubbleMenu />
<EditorSlashMenu /> <WritekitSlashMenu />
</EditorRoot> </WritekitRoot>
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details> <details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section> </section>
</template> </template>
@@ -1,11 +1,11 @@
import type { Editor, Inline, InlineNode, Node } from '@editor'; import type { Inline, InlineNode, Node, Writekit } from '@writekit';
import { import {
createDefaultRegistry, createDefaultRegistry,
createDoc, createDoc,
createEditor,
createEditorState,
createNode, createNode,
} from '@editor'; createWritekit,
createWritekitState,
} from '@writekit';
/** A styled inline run: `t('hello', 'bold', 'italic')`. */ /** A styled inline run: `t('hello', 'bold', 'italic')`. */
export function t(text: string, ...markTypes: string[]): InlineNode { 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 divider = (): Node => createNode('divider');
export const image = (src: string, caption = ''): Node => createNode('image', { attrs: { src, alt: caption, caption } }); 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. */ /** Create an writekit over the given blocks with the default registry. */
export function makeEditor(content: Node[]): Editor { export function makeWritekit(content: Node[]): Writekit {
const registry = createDefaultRegistry(); const registry = createDefaultRegistry();
return createEditor({ state: createEditorState({ registry, doc: createDoc(content) }) }); return createWritekit({ state: createWritekitState({ registry, doc: createDoc(content) }) });
} }
@@ -3,8 +3,8 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@editor": ["../src/index.ts"], "@writekit": ["../src/index.ts"],
"@editor/*": ["../src/*"] "@writekit/*": ["../src/*"]
} }
}, },
"include": ["src", "vite.config.ts"] "include": ["src", "vite.config.ts"]
@@ -10,11 +10,11 @@ export default defineConfig(({ mode }) => ({
resolve: { resolve: {
alias: [ alias: [
{ {
find: /^@editor\/(.*)$/, find: /^@writekit\/(.*)$/,
replacement: fileURLToPath(new URL('../src/$1', import.meta.url)), replacement: fileURLToPath(new URL('../src/$1', import.meta.url)),
}, },
{ {
find: /^@editor$/, find: /^@writekit$/,
replacement: fileURLToPath(new URL('../src/index.ts', import.meta.url)), replacement: fileURLToPath(new URL('../src/index.ts', import.meta.url)),
}, },
], ],
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { isInteractiveTarget } from '../view/interactive'; import { isInteractiveTarget } from '../view/interactive';
describe('isInteractiveTarget', () => { 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'); const root = document.createElement('div');
root.setAttribute('contenteditable', 'true'); root.setAttribute('contenteditable', 'true');
root.innerHTML = '<p class="text">hi</p><figure contenteditable="false"><input class="cap"></figure>'; root.innerHTML = '<p class="text">hi</p><figure contenteditable="false"><input class="cap"></figure>';
@@ -3,13 +3,13 @@ import { createBlockElementRegistry, createSelectionBridge } from '../view/selec
/** /**
* Builds the single-contenteditable DOM shape the view produces: one * 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 * elements. Runs in jsdom (logic project) to prove the cross-block selection
* mapping without a real browser. * mapping without a real browser.
*/ */
function buildDoc() { function buildDoc() {
const root = document.createElement('div'); const root = document.createElement('div');
root.setAttribute('data-editor-content', ''); root.setAttribute('data-writekit-content', '');
const a = document.createElement('p'); const a = document.createElement('p');
a.setAttribute('data-block-content', ''); a.setAttribute('data-block-content', '');
@@ -5,5 +5,5 @@ defineProps<BlockComponentProps>();
</script> </script>
<template> <template>
<hr data-editor-divider="" /> <hr data-writekit-divider="" />
</template> </template>
@@ -15,7 +15,7 @@ function set(key: string, event: Event): void {
</script> </script>
<template> <template>
<figure data-editor-image="" :data-selected="selected ? '' : undefined"> <figure data-writekit-image="" :data-selected="selected ? '' : undefined">
<img v-if="src" :src="src" :alt="alt" draggable="false" /> <img v-if="src" :src="src" :alt="alt" draggable="false" />
<div v-else class="image-placeholder">No image add a URL below</div> <div v-else class="image-placeholder">No image add a URL below</div>
@@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest';
import { caret, createDoc, createNode, nodeInline, nodeText, textSelection } from '../../model'; import { caret, createDoc, createNode, nodeInline, nodeText, textSelection } from '../../model';
import { applyInputRule, joinBackward, splitBlock, toggleMark } from '../../commands'; import { applyInputRule, joinBackward, splitBlock, toggleMark } from '../../commands';
import { createDefaultRegistry } from '../../preset'; import { createDefaultRegistry } from '../../preset';
import { createEditor, createEditorState } from '../../state'; import { createWritekit, createWritekitState } from '../../state';
function editorWith(node: ReturnType<typeof createNode>, selection?: ReturnType<typeof caret>) { function writekitWith(node: ReturnType<typeof createNode>, selection?: ReturnType<typeof caret>) {
const registry = createDefaultRegistry(); const registry = createDefaultRegistry();
return createEditor({ state: createEditorState({ registry, doc: createDoc([node]), selection }) }); return createWritekit({ state: createWritekitState({ registry, doc: createDoc([node]), selection }) });
} }
describe('default registry', () => { describe('default registry', () => {
@@ -31,24 +31,24 @@ describe('default registry', () => {
describe('code block', () => { describe('code block', () => {
it('Enter inserts a newline instead of splitting', () => { it('Enter inserts a newline instead of splitting', () => {
const editor = editorWith(createNode('code-block', { id: 'c', content: [{ text: 'ab', marks: [] }] }), caret('c', 2)); const writekit = writekitWith(createNode('code-block', { id: 'c', content: [{ text: 'ab', marks: [] }] }), caret('c', 2));
expect(editor.command(splitBlock)).toBe(true); expect(writekit.command(splitBlock)).toBe(true);
expect(editor.state.doc.content.length).toBe(1); expect(writekit.state.doc.content.length).toBe(1);
expect(nodeText(editor.state.doc.content[0]!)).toBe('ab\n'); expect(nodeText(writekit.state.doc.content[0]!)).toBe('ab\n');
}); });
it('disallows inline marks', () => { it('disallows inline marks', () => {
const editor = editorWith( const writekit = writekitWith(
createNode('code-block', { id: 'c', content: [{ text: 'abc', marks: [] }] }), createNode('code-block', { id: 'c', content: [{ text: 'abc', marks: [] }] }),
textSelection({ blockId: 'c', offset: 0 }, { blockId: 'c', offset: 3 }), 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', () => { it('does not absorb disallowed marks when another block merges into it', () => {
const registry = createDefaultRegistry(); const registry = createDefaultRegistry();
const editor = createEditor({ const writekit = createWritekit({
state: createEditorState({ state: createWritekitState({
registry, registry,
doc: createDoc([ doc: createDoc([
createNode('code-block', { id: 'c', content: [{ text: 'x', marks: [] }] }), createNode('code-block', { id: 'c', content: [{ text: 'x', marks: [] }] }),
@@ -58,8 +58,8 @@ describe('code block', () => {
}), }),
}); });
expect(editor.command(joinBackward)).toBe(true); expect(writekit.command(joinBackward)).toBe(true);
const merged = editor.state.doc.content[0]!; const merged = writekit.state.doc.content[0]!;
expect(merged.type).toBe('code-block'); expect(merged.type).toBe('code-block');
expect(nodeText(merged)).toBe('xB'); expect(nodeText(merged)).toBe('xB');
expect(nodeInline(merged).every(run => run.marks.length === 0)).toBe(true); expect(nodeInline(merged).every(run => run.marks.length === 0)).toBe(true);
@@ -68,36 +68,36 @@ describe('code block', () => {
describe('input rules', () => { describe('input rules', () => {
it('"# " converts a paragraph to a level-1 heading and strips the marker', () => { 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)); const writekit = writekitWith(createNode('paragraph', { id: 'p', content: [{ text: '# ', marks: [] }] }), caret('p', 2));
expect(editor.command(applyInputRule)).toBe(true); expect(writekit.command(applyInputRule)).toBe(true);
const block = editor.state.doc.content[0]!; const block = writekit.state.doc.content[0]!;
expect(block.type).toBe('heading'); expect(block.type).toBe('heading');
expect(block.attrs['level']).toBe(1); expect(block.attrs['level']).toBe(1);
expect(nodeText(block)).toBe(''); expect(nodeText(block)).toBe('');
}); });
it('"- " converts a paragraph to a bulleted list', () => { it('"- " converts a paragraph to a bulleted list', () => {
const editor = editorWith(createNode('paragraph', { id: 'p', content: [{ text: '- ', marks: [] }] }), caret('p', 2)); const writekit = writekitWith(createNode('paragraph', { id: 'p', content: [{ text: '- ', marks: [] }] }), caret('p', 2));
expect(editor.command(applyInputRule)).toBe(true); expect(writekit.command(applyInputRule)).toBe(true);
expect(editor.state.doc.content[0]!.type).toBe('bulleted-list'); expect(writekit.state.doc.content[0]!.type).toBe('bulleted-list');
}); });
it('does not re-fire when the block is already the target type', () => { 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)); const writekit = writekitWith(createNode('blockquote', { id: 'q', content: [{ text: '> ', marks: [] }] }), caret('q', 2));
expect(editor.command(applyInputRule)).toBe(false); expect(writekit.command(applyInputRule)).toBe(false);
}); });
}); });
describe('to-do list', () => { describe('to-do list', () => {
it('starts a new item unchecked when splitting a checked one', () => { 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: [] }] }), createNode('todo-list', { id: 't', attrs: { checked: true, indent: 0 }, content: [{ text: 'done', marks: [] }] }),
caret('t', 4), caret('t', 4),
); );
expect(editor.command(splitBlock)).toBe(true); expect(writekit.command(splitBlock)).toBe(true);
expect(editor.state.doc.content.length).toBe(2); expect(writekit.state.doc.content.length).toBe(2);
const created = editor.state.doc.content[1]!; const created = writekit.state.doc.content[1]!;
expect(created.type).toBe('todo-list'); expect(created.type).toBe('todo-list');
expect(created.attrs['checked']).toBe(false); expect(created.attrs['checked']).toBe(false);
}); });
@@ -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<ReturnType<typeof para>>, selection?: ReturnType<typeof caret>) {
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']);
});
});
@@ -1,11 +1,11 @@
import type { Attrs, Mark } from '../model'; import type { Attrs, Mark } from '../model';
import { blockById, isCollapsed, marksAt, nodeInline, normalizeMarks, orderedSelection, rangeHasMarkType } from '../model'; import { blockById, isCollapsed, marksAt, nodeInline, normalizeMarks, orderedSelection, rangeHasMarkType } from '../model';
import { marksAllowed } from '../schema'; import { marksAllowed } from '../schema';
import type { Command, EditorState } from '../state'; import type { Command, WritekitState } from '../state';
import { createTransaction } from '../state'; import { createTransaction } from '../state';
/** Whether the focused block permits a mark of `type` (false for code blocks, etc.). */ /** 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') if (state.selection.kind !== 'text')
return false; return false;
@@ -12,10 +12,10 @@ import {
previousBlock, previousBlock,
textSelection, textSelection,
} from '../model'; } from '../model';
import type { Command, EditorState } from '../state'; import type { Command, WritekitState } from '../state';
import { createTransaction } from '../state'; import { createTransaction } from '../state';
function defaultTextType(state: EditorState): string { function defaultTextType(state: WritekitState): string {
if (state.registry.hasBlock('paragraph')) if (state.registry.hasBlock('paragraph'))
return 'paragraph'; return 'paragraph';
@@ -11,11 +11,11 @@ import {
orderedSelection, orderedSelection,
previousBlock, previousBlock,
} from '../model'; } from '../model';
import type { Command, EditorState } from '../state'; import type { Command, WritekitState } from '../state';
import { createTransaction } from '../state'; import { createTransaction } from '../state';
/** Type/attrs for the block created when splitting `block` at `offset`. */ /** 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); const spec = state.schema.nodeSpec(block.type);
// Defining blocks (e.g. code-block) keep their identity across a split. // Defining blocks (e.g. code-block) keep their identity across a split.
@@ -1,21 +1,21 @@
import type { Attrs, Node } from '../model'; import type { Attrs, Node } from '../model';
import { blockById, isCollapsed, marksAt, nodeInline, orderedSelection, rangeHasMarkType } 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). */ /** 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; const sel = state.selection;
return sel.kind === 'text' ? sel.focus.blockId : sel.ids[0]; return sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
} }
/** The block the selection currently focuses, or `null`. */ /** 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); const id = selectionBlockId(state);
return id ? blockById(state.doc, id) : null; return id ? blockById(state.doc, id) : null;
} }
/** Whether a block type holds inline (text) content. */ /** 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'; 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 * Whether a mark is active for the current selection used by `toggleMark` and
* by toolbars (call a command without `dispatch` for the same answer). * 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; const sel = state.selection;
if (sel.kind !== 'text') 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). */ /** 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); const block = focusBlock(state);
if (!block || block.type !== type) if (!block || block.type !== type)
@@ -2,17 +2,17 @@ import { describe, expect, it } from 'vitest';
import { caret, createDoc, createNode, nodeSelection, nodeText } from '../../model'; import { caret, createDoc, createNode, nodeSelection, nodeText } from '../../model';
import { deleteSelection } from '../../commands'; import { deleteSelection } from '../../commands';
import { createDefaultRegistry } from '../../preset'; import { createDefaultRegistry } from '../../preset';
import { createEditor, createEditorState, createTransaction } from '../../state'; import { createTransaction, createWritekit, createWritekitState } from '../../state';
import { bindCrdt } from '../binding'; import { bindCrdt } from '../binding';
import type { RemoteCursor } from '../types'; import type { RemoteCursor } from '../types';
import { createNativeProvider } from '../native/provider'; import { createNativeProvider } from '../native/provider';
function makePeer(seedDoc?: ReturnType<typeof createDoc>) { function makePeer(seedDoc?: ReturnType<typeof createDoc>) {
const registry = createDefaultRegistry(); const registry = createDefaultRegistry();
const editor = createEditor({ state: createEditorState({ registry, doc: seedDoc }) }); const writekit = createWritekit({ state: createWritekitState({ registry, doc: seedDoc }) });
const provider = createNativeProvider({ schema: registry.schema, doc: seedDoc ? editor.state.doc : undefined }); const provider = createNativeProvider({ schema: registry.schema, doc: seedDoc ? writekit.state.doc : undefined });
bindCrdt(editor, provider); bindCrdt(writekit, provider);
return { editor, provider }; return { writekit, provider };
} }
/** Live two-way, in-memory transport between two providers. */ /** Live two-way, in-memory transport between two providers. */
@@ -22,10 +22,10 @@ function connect(a: ReturnType<typeof makePeer>, b: ReturnType<typeof makePeer>)
} }
function text(peer: ReturnType<typeof makePeer>): string { function text(peer: ReturnType<typeof makePeer>): 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', () => { it('a joining peer syncs the initial document, then concurrent edits converge', () => {
const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'Hello', marks: [] }] })]); const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'Hello', marks: [] }] })]);
@@ -38,8 +38,8 @@ describe('crdt convergence (two editors)', () => {
connect(a, b); connect(a, b);
// Concurrent edits at opposite ends of the same block. // 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))); a.writekit.dispatch(createTransaction(a.writekit.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))); 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(text(b));
expect(text(a)).toBe('>Hello!'); expect(text(a)).toBe('>Hello!');
@@ -53,7 +53,7 @@ describe('crdt convergence (two editors)', () => {
connect(a, b); connect(a, b);
// 'b' sits at UTF-16 offset 3 (the emoji occupies offsets 1..3). // '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(a)).toBe('a👍');
expect(text(b)).toBe('a👍'); expect(text(b)).toBe('a👍');
@@ -71,12 +71,12 @@ describe('crdt convergence (two editors)', () => {
expect(text(b)).toBe('AAA\nBBB'); expect(text(b)).toBe('AAA\nBBB');
// Select every block and delete (inserts one fresh empty paragraph). // Select every block and delete (inserts one fresh empty paragraph).
a.editor.dispatch(createTransaction(a.editor.state).setSelection(nodeSelection(['a', 'b']))); a.writekit.dispatch(createTransaction(a.writekit.state).setSelection(nodeSelection(['a', 'b'])));
expect(a.editor.command(deleteSelection)).toBe(true); expect(a.writekit.command(deleteSelection)).toBe(true);
expect(text(a)).toBe(''); expect(text(a)).toBe('');
// Undo must restore the blocks without duplicating them on either replica. // 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(a)).toBe('AAA\nBBB');
expect(text(b)).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.provider.onLocalAwareness(bytes => a.provider.applyAwareness(bytes));
// B places its caret after "Hello" (offset 5). // 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).toBe('text');
expect(cursors[0]?.selection?.kind === 'text' && cursors[0].selection.focus.offset).toBe(5); 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 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); 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); connect(a, b);
// A bolds "ab" (stays in the head block); B splits after "abc" — concurrently. // 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))); a.writekit.dispatch(createTransaction(a.writekit.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))); 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)); expect(text(a)).toBe(text(b));
// Document text is preserved across both edits (split inserts a block boundary). // 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). // The bold mark survived on both replicas (somewhere in the doc).
const hasBold = (peer: ReturnType<typeof makePeer>) => const hasBold = (peer: ReturnType<typeof makePeer>) =>
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'))); 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(a)).toBe(true);
expect(hasBold(b)).toBe(true); expect(hasBold(b)).toBe(true);
@@ -136,12 +136,12 @@ describe('crdt convergence (two editors)', () => {
b.provider.applyUpdate(a.provider.encodeDelta()); b.provider.applyUpdate(a.provider.encodeDelta());
connect(a, b); 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(nodeText(b.writekit.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(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', () => { 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()); b.provider.applyUpdate(a.provider.encodeDelta());
connect(a, b); connect(a, b);
a.editor.dispatch(createTransaction(a.editor.state).deleteText('p', 5, 11).setSelection(caret('p', 5))); a.writekit.dispatch(createTransaction(a.writekit.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).addMark('p', 0, 5, { type: 'bold' }).setSelection(caret('p', 5)));
expect(text(a)).toBe('Hello'); expect(text(a)).toBe('Hello');
expect(text(b)).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); 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 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(a)).toBe('Hello!');
expect(text(b)).toBe('Hello!'); expect(text(b)).toBe('Hello!');
}); });
@@ -1,4 +1,4 @@
import type { Editor, Transaction } from '../state'; import type { Transaction, Writekit } from '../state';
import { createTransaction } from '../state'; import { createTransaction } from '../state';
import { reconcileDoc } from './reconcile'; import { reconcileDoc } from './reconcile';
import type { CrdtProvider } from './types'; 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 * the CRDT, and remote ops are reflected back as a single history-bypassing
* `setDoc` transaction. The provider's `onLocalOps`/`applyUpdate` are connected * `setDoc` transaction. The provider's `onLocalOps`/`applyUpdate` are connected
* to a transport by the caller. * 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 { function onTransaction(tr: Transaction): void {
if (tr.getMeta('origin') !== REMOTE_ORIGIN) if (tr.getMeta('origin') !== REMOTE_ORIGIN)
provider.applyLocal(tr); // never echo a remote-sourced change back into the CRDT 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); writekit.on('transaction', onTransaction);
provider.setLocalSelection(editor.state.selection); provider.setLocalSelection(writekit.state.selection);
const offRemote = provider.onRemoteApplied(() => { const offRemote = provider.onRemoteApplied(() => {
// Reuse unchanged block identities so only the blocks a remote edit touched // Reuse unchanged block identities so only the blocks a remote edit touched
// repaint (and the local caret in untouched blocks stays put). // repaint (and the local caret in untouched blocks stays put).
const next = reconcileDoc(editor.state.doc, provider.load()); const next = reconcileDoc(writekit.state.doc, provider.load());
if (next === editor.state.doc) if (next === writekit.state.doc)
return; // remote ops didn't change the visible document return; // remote ops didn't change the visible document
editor.dispatch(createTransaction(editor.state) writekit.dispatch(createTransaction(writekit.state)
.setDoc(next) .setDoc(next)
.setMeta('origin', REMOTE_ORIGIN) .setMeta('origin', REMOTE_ORIGIN)
.setMeta('addToHistory', false)); .setMeta('addToHistory', false));
@@ -39,7 +39,7 @@ export function bindCrdt(editor: Editor, provider: CrdtProvider): CrdtBinding {
return { return {
detach: () => { detach: () => {
editor.off('transaction', onTransaction); writekit.off('transaction', onTransaction);
offRemote(); offRemote();
provider.destroy(); provider.destroy();
}, },
@@ -4,4 +4,4 @@ export * from './reconcile';
export { createNativeProvider } from './native/provider'; export { createNativeProvider } from './native/provider';
export type { NativeProviderOptions } from './native/provider'; export type { NativeProviderOptions } from './native/provider';
export { DocumentCrdt } from './native/document-crdt'; export { DocumentCrdt } from './native/document-crdt';
export type { EditorOp } from './native/document-crdt'; export type { WritekitOp } from './native/document-crdt';
@@ -1,6 +1,6 @@
import type { MarkValue, OpId, VersionVector } from '@robonen/crdt'; import type { MarkValue, OpId, VersionVector } from '@robonen/crdt';
import { LwwRegister, MarkStore, Rga, keyBetween, opIdEq, opIdToString } 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 { createDoc, nodeSelection, normalizeInline, normalizeMarks, textSelection } from '../../model';
import type { Schema } from '../../schema'; import type { Schema } from '../../schema';
import type { Step } from '../../state'; 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 * 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. * 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-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-remove'; readonly blockId: string }
| { readonly id: OpId; readonly kind: 'block-move'; readonly blockId: string; readonly posKey: 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 * 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 editor's * 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}), * offset-based {@link Step}s into id-based CRDT ops ({@link translateStep}),
* integrates ops from any replica ({@link applyOp}), and materializes an * integrates ops from any replica ({@link applyOp}), and materializes an
* {@link EditorDocument} ({@link toDocument}). * {@link WritekitDocument} ({@link toDocument}).
*/ */
export class DocumentCrdt { export class DocumentCrdt {
private readonly blocks = new Map<string, BlockState>(); private readonly blocks = new Map<string, BlockState>();
@@ -59,7 +59,7 @@ export class DocumentCrdt {
// ---------------------------------------------------------------- integrate // ---------------------------------------------------------------- integrate
/** Apply one op (local or remote). Returns false if a causal dependency is missing. */ /** 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) { switch (op.kind) {
case 'block-insert': { case 'block-insert': {
if (!this.blocks.has(op.blockId)) { if (!this.blocks.has(op.blockId)) {
@@ -139,7 +139,7 @@ export class DocumentCrdt {
// --------------------------------------------------------------- materialize // --------------------------------------------------------------- materialize
toDocument(): EditorDocument { toDocument(): WritekitDocument {
const content: Node[] = []; const content: Node[] = [];
for (const blockId of this.orderedBlockIds()) { for (const blockId of this.orderedBlockIds()) {
const block = this.blocks.get(blockId)!; const block = this.blocks.get(blockId)!;
@@ -263,7 +263,7 @@ export class DocumentCrdt {
// ----------------------------------------------------------------- translate // ----------------------------------------------------------------- translate
/** Generate the ops for a local step, reading current state for ids/positions. */ /** Generate the ops for a local step, reading current state for ids/positions. */
translateStep(step: Step): EditorOp[] { translateStep(step: Step): WritekitOp[] {
switch (step.type) { switch (step.type) {
case 'insertInline': case 'insertInline':
return this.insertInlineOps(step.blockId, step.offset, step.content); 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. */ /** Ops to seed the CRDT from an initial document. */
seedFromDocument(doc: EditorDocument): EditorOp[] { seedFromDocument(doc: WritekitDocument): WritekitOp[] {
const ops: EditorOp[] = []; const ops: WritekitOp[] = [];
let prevKey: string | null = null; let prevKey: string | null = null;
for (const node of doc.content) { for (const node of doc.content) {
@@ -311,7 +311,7 @@ export class DocumentCrdt {
// ------------------------------------------------------------------ op builders // ------------------------------------------------------------------ op builders
/** Ops for an `insertBlock` step: reactivate if the block already exists (undo), else create. */ /** 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); const posKey = this.posKeyForIndex(index);
if (this.blocks.has(node.id)) { if (this.blocks.has(node.id)) {
@@ -322,9 +322,9 @@ export class DocumentCrdt {
return this.blockOps(node, posKey); 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 isText = this.schema.nodeSpec(node.type)?.content.kind === 'text';
const ops: EditorOp[] = [{ const ops: WritekitOp[] = [{
id: this.nextId(), id: this.nextId(),
kind: 'block-insert', kind: 'block-insert',
blockId: node.id, blockId: node.id,
@@ -341,13 +341,13 @@ export class DocumentCrdt {
} }
/** Insert inline `content` after the char at `afterId` (null = block start). */ /** Insert inline `content` after the char at `afterId` (null = block start). */
private inlineOps(blockId: string, content: Inline, afterId: OpId | null): EditorOp[] { private inlineOps(blockId: string, content: Inline, afterId: OpId | null): WritekitOp[] {
const ops: EditorOp[] = []; const ops: WritekitOp[] = [];
let after = afterId; let after = afterId;
for (const run of content) { for (const run of content) {
const charIds: OpId[] = []; 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. // offset space — one RGA node per unit keeps offsets aligned for astral chars.
for (let i = 0; i < run.text.length; i++) { for (let i = 0; i < run.text.length; i++) {
const id = this.nextId(); const id = this.nextId();
@@ -365,14 +365,14 @@ export class DocumentCrdt {
return ops; 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); const block = this.blocks.get(blockId);
if (!block || !block.isText) if (!block || !block.isText)
return []; return [];
return this.inlineOps(blockId, content, block.rga.idAt(offset - 1)); 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); const block = this.blocks.get(blockId);
if (!block || !block.isText) if (!block || !block.isText)
return []; return [];
@@ -380,7 +380,7 @@ export class DocumentCrdt {
.map(node => ({ id: this.nextId(), kind: 'text-delete' as const, blockId, charId: node.id })); .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); const block = this.blocks.get(blockId);
if (!block || !block.isText || from >= to) if (!block || !block.isText || from >= to)
return []; return [];
@@ -401,7 +401,7 @@ export class DocumentCrdt {
return keyBetween(before, after); return keyBetween(before, after);
} }
private moveOps(blockId: string, toIndex: number): EditorOp[] { private moveOps(blockId: string, toIndex: number): WritekitOp[] {
if (!this.blocks.has(blockId)) if (!this.blocks.has(blockId))
return []; return [];
@@ -411,7 +411,7 @@ export class DocumentCrdt {
return [{ id: this.nextId(), kind: 'block-move', blockId, posKey: keyBetween(before, after) }]; 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); const block = this.blocks.get(blockId);
if (!block || !block.isText) if (!block || !block.isText)
return []; return [];
@@ -426,7 +426,7 @@ export class DocumentCrdt {
// the source instead of recreating content (which would duplicate it). // the source instead of recreating content (which would duplicate it).
const existing = this.blocks.get(newId); const existing = this.blocks.get(newId);
if (existing) { 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)) for (const node of block.rga.visible().slice(offset))
reactivate.push({ id: this.nextId(), kind: 'text-delete', blockId, charId: node.id }); reactivate.push({ id: this.nextId(), kind: 'text-delete', blockId, charId: node.id });
return reactivate; return reactivate;
@@ -436,7 +436,7 @@ export class DocumentCrdt {
const attrs = newAttrs ?? (newType ? this.schema.defaultAttrs(newType) : block.attrs.get()); const attrs = newAttrs ?? (newType ? this.schema.defaultAttrs(newType) : block.attrs.get());
const isText = this.schema.nodeSpec(type)?.content.kind === 'text'; 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. // Re-create the tail (offset..end) in the new block, then tombstone it in the old.
const tail = block.rga.visible().slice(offset); const tail = block.rga.visible().slice(offset);
@@ -457,13 +457,13 @@ export class DocumentCrdt {
return ops; return ops;
} }
private mergeOps(blockId: string, intoId: string): EditorOp[] { private mergeOps(blockId: string, intoId: string): WritekitOp[] {
const source = this.blocks.get(blockId); const source = this.blocks.get(blockId);
const target = this.blocks.get(intoId); const target = this.blocks.get(intoId);
if (!source || !target || !source.isText || !target.isText) if (!source || !target || !source.isText || !target.isText)
return []; return [];
const ops: EditorOp[] = []; const ops: WritekitOp[] = [];
const sourceChars = source.rga.visible(); const sourceChars = source.rga.visible();
const marksPerChar = source.marks.resolve(sourceChars.map(node => node.id)); const marksPerChar = source.marks.resolve(sourceChars.map(node => node.id));
let after = target.rga.idAt(target.rga.length - 1); let after = target.rga.idAt(target.rga.length - 1);
@@ -1,37 +1,50 @@
import { Replica, VersionVector, createSiteId, decodeJson, decodeOps, decodeStateVector, encodeJson, encodeOps, encodeStateVector } from '@robonen/crdt'; 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 { Schema } from '../../schema';
import type { Transaction } from '../../state'; import type { Transaction } from '../../state';
import type { AwarenessState, CrdtProvider, CursorUser, RemoteCursor } from '../types'; 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'; import { DocumentCrdt } from './document-crdt';
export interface NativeProviderOptions { export interface NativeProviderOptions {
/** Schema (block/mark specs) — needed to know which blocks hold text. */ /** Schema (block/mark specs) — needed to know which blocks hold text. */
schema: Schema; schema: Schema;
/** Seed the CRDT from this document (use for the FIRST replica only; joiners sync instead). */ /** 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). */ /** Replica/site id (defaults to a random one). */
site?: string; site?: string;
/** Identity broadcast with this replica's cursor. */ /** Identity broadcast with this replica's cursor. */
user?: CursorUser; user?: CursorUser;
} }
/**
* Provider event map. A mapped type (not the `interface`) satisfies PubSub's
* `Record<string, …>` 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 * 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. * {@link DocumentCrdt}; ops sync as op batches over any transport.
*/ */
export function createNativeProvider(options: NativeProviderOptions): CrdtProvider { export function createNativeProvider(options: NativeProviderOptions): CrdtProvider {
const document = new DocumentCrdt(options.schema); const document = new DocumentCrdt(options.schema);
const site = options.site ?? createSiteId(); const site = options.site ?? createSiteId();
const replica = new Replica<EditorOp>({ integrate: op => document.applyOp(op) }, site); const replica = new Replica<WritekitOp>({ integrate: op => document.applyOp(op) }, site);
document.setIdFactory(() => replica.nextId()); document.setIdFactory(() => replica.nextId());
const localListeners = new Set<(bytes: Uint8Array) => void>(); const bus = new PubSub<{ [K in keyof ProviderEvents]: ProviderEvents[K] }>();
const remoteListeners = new Set<() => void>();
const localAwarenessListeners = new Set<(bytes: Uint8Array) => void>();
const awarenessListeners = new Set<(cursors: RemoteCursor[]) => void>();
const remoteStates = new Map<string, AwarenessState>(); const remoteStates = new Map<string, AwarenessState>();
if (options.doc) { if (options.doc) {
@@ -55,7 +68,7 @@ export function createNativeProvider(options: NativeProviderOptions): CrdtProvid
load: () => document.toDocument(), load: () => document.toDocument(),
applyLocal: (tr: Transaction) => { applyLocal: (tr: Transaction) => {
const ops: EditorOp[] = []; const ops: WritekitOp[] = [];
for (const step of tr.steps) { for (const step of tr.steps) {
for (const op of document.translateStep(step)) { for (const op of document.translateStep(step)) {
replica.commitLocal(op); replica.commitLocal(op);
@@ -63,29 +76,20 @@ export function createNativeProvider(options: NativeProviderOptions): CrdtProvid
} }
} }
if (ops.length > 0) { if (ops.length > 0) {
const bytes = encodeOps(ops); bus.emit('localOps', encodeOps(ops));
for (const listener of localListeners)
listener(bytes);
// Local edits shifted the document — re-resolve remote cursor positions. // Local edits shifted the document — re-resolve remote cursor positions.
if (remoteStates.size > 0) { if (remoteStates.size > 0)
const cursors = resolveCursors(); bus.emit('awareness', resolveCursors());
for (const listener of awarenessListeners)
listener(cursors);
}
} }
}, },
applyUpdate: (bytes) => { applyUpdate: (bytes) => {
const applied = replica.receive(decodeOps<EditorOp>(bytes)); const applied = replica.receive(decodeOps<WritekitOp>(bytes));
if (applied.length > 0) { if (applied.length > 0) {
for (const listener of remoteListeners) bus.emit('remoteApplied');
listener();
// Remote ops shifted the document — re-resolve cursors against new positions. // Remote ops shifted the document — re-resolve cursors against new positions.
if (remoteStates.size > 0) { if (remoteStates.size > 0)
const cursors = resolveCursors(); bus.emit('awareness', resolveCursors());
for (const listener of awarenessListeners)
listener(cursors);
}
} }
}, },
@@ -93,46 +97,42 @@ export function createNativeProvider(options: NativeProviderOptions): CrdtProvid
encodeDelta: remote => encodeOps(replica.delta(remote ? decodeStateVector(remote) : new VersionVector())), encodeDelta: remote => encodeOps(replica.delta(remote ? decodeStateVector(remote) : new VersionVector())),
onLocalOps: (listener) => { onLocalOps: (listener) => {
localListeners.add(listener); bus.on('localOps', listener);
return () => localListeners.delete(listener); return () => bus.off('localOps', listener);
}, },
onRemoteApplied: (listener) => { onRemoteApplied: (listener) => {
remoteListeners.add(listener); bus.on('remoteApplied', listener);
return () => remoteListeners.delete(listener); return () => bus.off('remoteApplied', listener);
}, },
setLocalSelection: (selection: Selection | null) => { setLocalSelection: (selection: Selection | null) => {
const state: AwarenessState = { clientId: site, user: options.user, anchor: selection ? document.toAnchor(selection) : null }; const state: AwarenessState = { clientId: site, user: options.user, anchor: selection ? document.toAnchor(selection) : null };
const bytes = encodeJson(state); bus.emit('localAwareness', encodeJson(state));
for (const listener of localAwarenessListeners)
listener(bytes);
}, },
onLocalAwareness: (listener) => { onLocalAwareness: (listener) => {
localAwarenessListeners.add(listener); bus.on('localAwareness', listener);
return () => localAwarenessListeners.delete(listener); return () => bus.off('localAwareness', listener);
}, },
applyAwareness: (bytes) => { applyAwareness: (bytes) => {
const state = decodeJson<AwarenessState>(bytes); const state = decodeJson<AwarenessState>(bytes);
remoteStates.set(state.clientId, state); remoteStates.set(state.clientId, state);
const cursors = resolveCursors(); bus.emit('awareness', resolveCursors());
for (const listener of awarenessListeners)
listener(cursors);
}, },
onAwareness: (listener) => { onAwareness: (listener) => {
awarenessListeners.add(listener); bus.on('awareness', listener);
return () => awarenessListeners.delete(listener); return () => bus.off('awareness', listener);
}, },
gc: stable => document.gc(stable ? decodeStateVector(stable) : replica.version), gc: stable => document.gc(stable ? decodeStateVector(stable) : replica.version),
destroy: () => { destroy: () => {
localListeners.clear(); bus.clear('localOps');
remoteListeners.clear(); bus.clear('remoteApplied');
localAwarenessListeners.clear(); bus.clear('localAwareness');
awarenessListeners.clear(); bus.clear('awareness');
remoteStates.clear(); remoteStates.clear();
}, },
}; };
@@ -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'; import { attrsEq, createDoc, isInlineContent, marksEq } from '../model';
function contentEq(a: Content, b: Content): boolean { 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 * blocks that actually changed (others keep their reference, and the local caret
* in them is undisturbed). Returns `prev` unchanged when nothing differs. * 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])); const prevById = new Map(prev.content.map(node => [node.id, node]));
let changed = prev.content.length !== next.content.length; let changed = prev.content.length !== next.content.length;
@@ -1,5 +1,5 @@
import type { OpId } from '@robonen/crdt'; import type { OpId } from '@robonen/crdt';
import type { EditorDocument, Selection } from '../model'; import type { Selection, WritekitDocument } from '../model';
import type { Transaction } from '../state'; import type { Transaction } from '../state';
/** Marks transactions that apply remote CRDT changes (so they bypass local history). */ /** 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 * A pluggable CRDT backend. The writekit core stays CRDT-agnostic behind this
* interface; {@link bindCrdt} wires it to an {@link Editor}, and any transport * interface; {@link bindCrdt} wires it to an {@link Writekit}, and any transport
* (BroadcastChannel, WebSocket, ) is layered on via the op + awareness hooks. * (BroadcastChannel, WebSocket, ) is layered on via the op + awareness hooks.
*/ */
export interface CrdtProvider { export interface CrdtProvider {
readonly name: string; readonly name: string;
/** The current document materialized from CRDT state. */ /** The current document materialized from CRDT state. */
load: () => EditorDocument; load: () => WritekitDocument;
/** Translate a local transaction's steps into CRDT ops and apply them. */ /** Translate a local transaction's steps into CRDT ops and apply them. */
applyLocal: (tr: Transaction) => void; applyLocal: (tr: Transaction) => void;
/** Merge a remote update (encoded ops) into the CRDT. */ /** Merge a remote update (encoded ops) into the CRDT. */
@@ -54,7 +54,7 @@ export interface CrdtProvider {
encodeDelta: (remoteStateVector?: Uint8Array) => Uint8Array; encodeDelta: (remoteStateVector?: Uint8Array) => Uint8Array;
/** Subscribe to locally-produced op batches (to broadcast). Returns unsubscribe. */ /** Subscribe to locally-produced op batches (to broadcast). Returns unsubscribe. */
onLocalOps: (listener: (bytes: Uint8Array) => void) => () => void; 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; onRemoteApplied: (listener: () => void) => () => void;
// --- awareness (ephemeral; not part of the persistent document) --- // --- awareness (ephemeral; not part of the persistent document) ---
@@ -14,18 +14,18 @@ import {
toggleBlockType, toggleBlockType,
toggleMark, toggleMark,
} from '../commands'; } from '../commands';
import type { Command, Editor } from '../state'; import type { Command, Writekit } from '../state';
import type { Keymap } from './types'; 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 * 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 * boundaries, so ordinary intra-block editing stays native. Arrow navigation and
* cross-block selection are fully native (one contenteditable spans the doc). * cross-block selection are fully native (one contenteditable spans the doc).
*/ */
export function defaultKeymap(editor: Editor): Keymap { export function defaultKeymap(writekit: Writekit): Keymap {
const undo: Command = () => editor.undo(); const undo: Command = () => writekit.undo();
const redo: Command = () => editor.redo(); const redo: Command = () => writekit.redo();
const keymap: Keymap = { const keymap: Keymap = {
'Mod-b': toggleMark('bold'), 'Mod-b': toggleMark('bold'),
@@ -1,4 +1,4 @@
import type { Command, CommandView, Dispatch, EditorState } from '../state'; import type { Command, CommandView, Dispatch, WritekitState } from '../state';
import { eventToCombo } from './normalize'; import { eventToCombo } from './normalize';
/** /**
@@ -8,7 +8,7 @@ import { eventToCombo } from './normalize';
export function runKeydown( export function runKeydown(
event: KeyboardEvent, event: KeyboardEvent,
compiled: Map<string, Command>, compiled: Map<string, Command>,
state: EditorState, state: WritekitState,
dispatch: Dispatch, dispatch: Dispatch,
view: CommandView, view: CommandView,
): boolean { ): boolean {
@@ -1,59 +1,59 @@
import type { Node } from './node'; 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 * flat (lists use indent attributes, not nesting), so document helpers operate
* on the top-level array. * on the top-level array.
*/ */
export interface EditorDocument { export interface WritekitDocument {
readonly type: 'doc'; readonly type: 'doc';
readonly content: readonly Node[]; readonly content: readonly Node[];
} }
/** Construct a document from blocks. */ /** Construct a document from blocks. */
export function createDoc(content: readonly Node[] = []): EditorDocument { export function createDoc(content: readonly Node[] = []): WritekitDocument {
return { type: 'doc', content }; return { type: 'doc', content };
} }
/** Index of a block by id, or `-1` if absent. */ /** 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); return doc.content.findIndex(block => block.id === id);
} }
/** A block and its index, or `null` if absent. */ /** 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); const index = blockIndex(doc, id);
return index === -1 ? null : { node: doc.content[index]!, index }; return index === -1 ? null : { node: doc.content[index]!, index };
} }
/** A block by id, or `null`. */ /** 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; return doc.content.find(block => block.id === id) ?? null;
} }
/** The block before `id` in document order, or `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); const index = blockIndex(doc, id);
return index > 0 ? doc.content[index - 1]! : null; return index > 0 ? doc.content[index - 1]! : null;
} }
/** The block after `id` in document order, or `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); const index = blockIndex(doc, id);
return index !== -1 && index < doc.content.length - 1 ? doc.content[index + 1]! : null; return index !== -1 && index < doc.content.length - 1 ? doc.content[index + 1]! : null;
} }
/** First block, or `null` for an empty document. */ /** 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; return doc.content[0] ?? null;
} }
/** Last block, or `null` for an empty document. */ /** 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 doc.content[doc.content.length - 1] ?? null;
} }
/** Return a copy of `doc` with a different block list. */ /** 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 }; return { ...doc, content };
} }
@@ -1,6 +1,6 @@
import type { Position } from './position'; import type { Position } from './position';
import { positionEq } from './position'; import { positionEq } from './position';
import type { EditorDocument } from './document'; import type { WritekitDocument } from './document';
import { blockIndex } from './document'; import { blockIndex } from './document';
/** A text selection: caret when `anchor === focus`, range otherwise. May span blocks. */ /** 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 * Endpoints of a text selection in document order (`from` before `to`). Within
* one block they are ordered by offset; across blocks by block index. * 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; const { anchor, focus } = sel;
if (anchor.blockId === focus.blockId) if (anchor.blockId === focus.blockId)

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