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:
@@ -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><></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>
|
||||
@@ -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>
|
||||
@@ -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 & 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'))"></></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>> </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']);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -1,35 +1,35 @@
|
||||
# AGENTS.md — @robonen/editor
|
||||
# AGENTS.md — @robonen/writekit
|
||||
|
||||
Architecture and conventions for working in this package. Read this before editing.
|
||||
|
||||
## What it is
|
||||
|
||||
A headless block rich-text editor for Vue with a hand-built CRDT. **Do not reach for Yjs/Loro/Automerge** — collaboration is built on `@robonen/crdt` (sibling package in `core/crdt`).
|
||||
A headless block rich-text writekit for Vue with a hand-built CRDT. **Do not reach for Yjs/Loro/Automerge** — collaboration is built on `@robonen/crdt` (sibling package in `core/crdt`).
|
||||
|
||||
## Editing model: single contenteditable
|
||||
|
||||
There is **one** `contenteditable` element — `EditorContent`. Blocks are plain child elements inside it; atom blocks (image, divider) are `contenteditable="false"` islands. We deliberately do **not** use per-block contenteditable: separate editing hosts make native cross-block mouse selection and arrow navigation impossible, which breaks the Word-like behavior we require.
|
||||
There is **one** `contenteditable` element — `WritekitContent`. Blocks are plain child elements inside it; atom blocks (image, divider) are `contenteditable="false"` islands. We deliberately do **not** use per-block contenteditable: separate editing hosts make native cross-block mouse selection and arrow navigation impossible, which breaks the Word-like behavior we require.
|
||||
|
||||
Consequence: cross-block selection and caret navigation are handled natively by the browser; the model only mirrors the DOM selection as `{ blockId, offset }`. The cross-block arrow *commands* were intentionally deleted — don't re-add them.
|
||||
|
||||
## Layers (data flow is one-directional)
|
||||
|
||||
```
|
||||
input/command → Transaction (steps) → dispatch → new EditorState → view reacts (+ CRDT)
|
||||
input/command → Transaction (steps) → dispatch → new WritekitState → view reacts (+ CRDT)
|
||||
```
|
||||
|
||||
All of these are **DOM-free and Vue-free** (typecheck/test under plain Node):
|
||||
|
||||
- `model/` — pure data: `EditorDocument`, `Node`, `Inline` (marked **runs**: `InlineNode[]`), `Mark`, `Position`, `Selection`. Inline formatting is *marked runs*, not flat-text-plus-ranges; every `applyStep` calls `normalizeInline` to merge adjacent equal-marks runs.
|
||||
- `model/` — pure data: `WritekitDocument`, `Node`, `Inline` (marked **runs**: `InlineNode[]`), `Mark`, `Position`, `Selection`. Inline formatting is *marked runs*, not flat-text-plus-ranges; every `applyStep` calls `normalizeInline` to merge adjacent equal-marks runs.
|
||||
- `schema/` — `NodeSpec`/`MarkSpec`/`ContentKind` (3-variant union: text | container | atom), validation, normalization, `toDOM`/`parseDOM` descriptors.
|
||||
- `registry/` — SSOT. `defineBlock`/`defineMark` are identity factories; `createRegistry` builds an immutable registry that **projects** the `Schema`. Adding a block/mark = a module + a line in a barrel — zero core changes.
|
||||
- `state/` — `EditorState`, `Step` (atomic, invertible, serializable — the unit of undo **and** the CRDT contract), `Transaction` (fluent builder), `applyStep`/`applyTransaction`, `history`, `createEditor` (controller + `PubSub`).
|
||||
- `state/` — `WritekitState`, `Step` (atomic, invertible, serializable — the unit of undo **and** the CRDT contract), `Transaction` (fluent builder), `applyStep`/`applyTransaction`, `history`, `createWritekit` (controller + `PubSub`).
|
||||
- `commands/` — `(state, dispatch?, view?) => boolean`. One implementation, three consumers (keymap, UI, programmatic). `dispatch` omitted = dry run.
|
||||
- `keymap/` — combo→command table, `Mod` normalization, one capture-phase keydown dispatcher on the root.
|
||||
|
||||
Vue layer (only this knows about the DOM):
|
||||
|
||||
- `view/` — `EditorRoot` (provider + keydown/selectionchange owner), `EditorContent` (THE contenteditable; owns beforeinput/input/composition), `BlockView` (resolves the block def; text → `TextBlockHost`, atom → the def's component), `TextBlockHost` (renders runs **imperatively** for caret stability), `inline-content/` (render/parse runs ↔ DOM), `selection/` (DOM ↔ model selection bridge), `ui/` (slash menu, bubble menu, remote cursors).
|
||||
- `view/` — `WritekitRoot` (provider + keydown/selectionchange owner), `WritekitContent` (THE contenteditable; owns beforeinput/input/composition), `BlockView` (resolves the block def; text → `TextBlockHost`, atom → the def's component), `TextBlockHost` (renders runs **imperatively** for caret stability), `inline-content/` (render/parse runs ↔ DOM), `selection/` (DOM ↔ model selection bridge), `ui/` (slash menu, bubble menu, remote cursors).
|
||||
- `blocks/` — concrete blocks (+ `.vue` for atoms). `marks/` — concrete marks (data-only `toDOM`/`parseDOM`).
|
||||
- `crdt/` — CRDT-agnostic `CrdtProvider` + `bindCrdt`; `native/` = the adapter over `@robonen/crdt`.
|
||||
- `preset.ts` — `createDefaultRegistry()` / `createBasicRegistry()`.
|
||||
@@ -42,10 +42,10 @@ When touching the view, preserve: `:key="block.id"`, the imperative inner render
|
||||
|
||||
## CRDT mapping (`crdt/native/document-crdt.ts`)
|
||||
|
||||
`DocumentCrdt` maps the editor's **offset-based** steps ↔ **id-based** CRDT ops, and materializes an `EditorDocument`:
|
||||
`DocumentCrdt` maps the writekit's **offset-based** steps ↔ **id-based** CRDT ops, and materializes an `WritekitDocument`:
|
||||
|
||||
- Block list → fractional-indexed set: each block has `LwwRegister`s for `present`/`posKey`/`type`/`attrs` + an `Rga<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).
|
||||
|
||||
Invariants that have already bitten us — keep them:
|
||||
@@ -60,7 +60,7 @@ When changing the adapter, add/extend a two-replica convergence test in `crdt/__
|
||||
## Conventions
|
||||
|
||||
- TS strict, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`. Use `!`/guards on indexed access.
|
||||
- ESLint (`compose(base, typescript, vue, imports, stylistic, …)`). `sort-imports` warnings are tolerated; **errors must be zero**. Run `pnpm --filter @robonen/editor lint:fix`.
|
||||
- ESLint (`compose(base, typescript, vue, imports, stylistic, …)`). `sort-imports` warnings are tolerated; **errors must be zero**. Run `pnpm --filter @robonen/writekit lint:fix`.
|
||||
- Gotcha: the `prefer-includes` autofix once rewrote a private `indexOf` method into `this.includes(...)` (broken). If you have an array-like helper, avoid naming it `indexOf`.
|
||||
- Build: `tsdown` (`tsconfig: ./tsconfig.src.json`), dual ESM/CJS + dts, subpath entries (`./crdt`, `./blocks`, …).
|
||||
- Tests: vitest, two projects — `logic` (jsdom: model/schema/state/commands/crdt) and `view` (Playwright chromium: real contenteditable). **Chromium can't launch in the sandbox** — write a jsdom proof when possible; browser tests run locally.
|
||||
@@ -1,6 +1,6 @@
|
||||
# @robonen/editor
|
||||
# @robonen/writekit
|
||||
|
||||
A headless, block-based rich-text editor for Vue 3 — in the spirit of Tiptap / ProseMirror / Editor.js, but with a **hand-built CRDT** for collaboration (no Yjs/Loro/Automerge).
|
||||
A headless, block-based rich-text writekit for Vue 3 — in the spirit of Tiptap / ProseMirror / Editor.js, but with a **hand-built CRDT** for collaboration (no Yjs/Loro/Automerge).
|
||||
|
||||
- **Block registry + schema** — blocks and inline marks are modular, registered through a registry that projects an immutable schema.
|
||||
- **Single contenteditable** — one editable surface (ProseMirror/Tiptap model), so native cross-block mouse selection and arrow navigation behave like a normal document.
|
||||
@@ -13,35 +13,35 @@ A headless, block-based rich-text editor for Vue 3 — in the spirit of Tiptap /
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm add @robonen/editor @robonen/crdt vue
|
||||
pnpm add @robonen/writekit @robonen/crdt vue
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { createDefaultRegistry, createEditor, createEditorState, EditorRoot } from '@robonen/editor';
|
||||
import { createDefaultRegistry, createWritekit, createWritekitState, WritekitRoot } from '@robonen/writekit';
|
||||
|
||||
const registry = createDefaultRegistry();
|
||||
const editor = createEditor({ state: createEditorState({ registry }) });
|
||||
const writekit = createWritekit({ state: createWritekitState({ registry }) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EditorRoot :editor="editor" autofocus class="editor" />
|
||||
<WritekitRoot :writekit="writekit" autofocus class="writekit" />
|
||||
</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
|
||||
<EditorRoot :editor="editor" autofocus>
|
||||
<EditorContent />
|
||||
<EditorBubbleMenu /> <!-- formatting toolbar on selection -->
|
||||
<EditorSlashMenu /> <!-- `/` to insert blocks -->
|
||||
</EditorRoot>
|
||||
<WritekitRoot :writekit="writekit" autofocus>
|
||||
<WritekitContent />
|
||||
<WritekitBubbleMenu /> <!-- formatting toolbar on selection -->
|
||||
<WritekitSlashMenu /> <!-- `/` to insert blocks -->
|
||||
</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
|
||||
|
||||
@@ -50,7 +50,7 @@ Built-in blocks (via `createDefaultRegistry()`): `paragraph`, `heading` (1–6),
|
||||
Add your own — no core changes needed:
|
||||
|
||||
```ts
|
||||
import { createRegistry, defineBlock, defineMark, defaultBlocks, defaultMarks } from '@robonen/editor';
|
||||
import { createRegistry, defineBlock, defineMark, defaultBlocks, defaultMarks } from '@robonen/writekit';
|
||||
|
||||
const spoiler = defineMark({
|
||||
type: 'spoiler',
|
||||
@@ -62,10 +62,10 @@ const registry = createRegistry({ blocks: defaultBlocks, marks: [...defaultMarks
|
||||
|
||||
## Editing UX
|
||||
|
||||
- **Slash menu** — `EditorSlashMenu`: type `/` at the start of a line; items come from each block's `meta`.
|
||||
- **Bubble toolbar** — `EditorBubbleMenu`: floats over a text selection (positioned with `@floating-ui/vue`); override the buttons via its default slot (`#default="{ active, toggle }"`).
|
||||
- **Slash menu** — `WritekitSlashMenu`: type `/` at the start of a line; items come from each block's `meta`.
|
||||
- **Bubble toolbar** — `WritekitBubbleMenu`: floats over a text selection (positioned with `@floating-ui/vue`); override the buttons via its default slot (`#default="{ active, toggle }"`).
|
||||
- **Markdown input rules** — `# `→heading, `- `/`* `→bulleted list, `1. `→numbered list, `> `→quote, `[] `→to-do.
|
||||
- **Drag to reorder** — pass `draggable` to `EditorRoot` for per-block drag handles.
|
||||
- **Drag to reorder** — pass `draggable` to `WritekitRoot` for per-block drag handles.
|
||||
- **Hotkeys** — `Mod-b/i/u`, `Mod-Shift-s` (strike), `Mod-e` (code), `Mod-z` / `Mod-Shift-z`, `Enter` (split), `Shift-Enter` (hard break), `Backspace`/`Delete` at edges (merge), `Mod-a` (progressive select), `Mod-Alt-1..6` (heading), `Tab`/`Shift-Tab` (list indent).
|
||||
|
||||
## Commands
|
||||
@@ -73,24 +73,24 @@ const registry = createRegistry({ blocks: defaultBlocks, marks: [...defaultMarks
|
||||
Commands are `(state, dispatch?, view?) => boolean` and power the keymap, UI, and programmatic edits:
|
||||
|
||||
```ts
|
||||
import { toggleMark, setBlockType } from '@robonen/editor';
|
||||
import { toggleMark, setBlockType } from '@robonen/writekit';
|
||||
|
||||
editor.command(toggleMark('bold'));
|
||||
editor.command(setBlockType('heading', { level: 2 }));
|
||||
writekit.command(toggleMark('bold'));
|
||||
writekit.command(setBlockType('heading', { level: 2 }));
|
||||
```
|
||||
|
||||
Called without `dispatch` they are a dry run (for disabled/active toolbar state).
|
||||
|
||||
## Collaboration (own CRDT)
|
||||
|
||||
The editor is CRDT-agnostic behind `CrdtProvider`. The built-in provider maps editor steps to CRDT ops (blocks → fractional-indexed set, text → RGA, formatting → mark store) and syncs op batches over any transport.
|
||||
The writekit is CRDT-agnostic behind `CrdtProvider`. The built-in provider maps writekit steps to CRDT ops (blocks → fractional-indexed set, text → RGA, formatting → mark store) and syncs op batches over any transport.
|
||||
|
||||
```ts
|
||||
import { bindCrdt, createNativeProvider } from '@robonen/editor';
|
||||
import { bindCrdt, createNativeProvider } from '@robonen/writekit';
|
||||
|
||||
// First peer seeds the document.
|
||||
const provider = createNativeProvider({ schema: registry.schema, doc: editor.state.doc, user: { name: 'Alice', color: '#2563eb' } });
|
||||
const binding = bindCrdt(editor, provider);
|
||||
const provider = createNativeProvider({ schema: registry.schema, doc: writekit.state.doc, user: { name: 'Alice', color: '#2563eb' } });
|
||||
const binding = bindCrdt(writekit, provider);
|
||||
|
||||
// Wire a transport (BroadcastChannel / WebSocket / …):
|
||||
provider.onLocalOps(bytes => channel.postMessage(bytes));
|
||||
@@ -101,7 +101,7 @@ channel.onmessage = e => provider.applyUpdate(e.data);
|
||||
// provider.applyUpdate(remoteFullState); // = peerA.encodeDelta()
|
||||
```
|
||||
|
||||
Presence/cursors travel on a separate ephemeral channel and render with `EditorRemoteCursors`:
|
||||
Presence/cursors travel on a separate ephemeral channel and render with `WritekitRemoteCursors`:
|
||||
|
||||
```ts
|
||||
provider.onLocalAwareness(bytes => channel.postMessage(bytes));
|
||||
@@ -110,10 +110,10 @@ provider.onAwareness(next => cursors.value = next);
|
||||
```
|
||||
|
||||
```vue
|
||||
<EditorRoot :editor="editor">
|
||||
<EditorContent />
|
||||
<EditorRemoteCursors :cursors="cursors" />
|
||||
</EditorRoot>
|
||||
<WritekitRoot :writekit="writekit">
|
||||
<WritekitContent />
|
||||
<WritekitRemoteCursors :cursors="cursors" />
|
||||
</WritekitRoot>
|
||||
```
|
||||
|
||||
See the **Collaboration** demo in the playground for a full two-replica example.
|
||||
@@ -135,9 +135,9 @@ provider.gc(); // drops deleted characters / removed blocks safe to forget
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm --filter @robonen/editor test # logic (jsdom) + CRDT convergence
|
||||
pnpm --filter @robonen/editor build # tsdown (ESM + CJS + dts)
|
||||
pnpm --filter @robonen/editor-playground dev
|
||||
pnpm --filter @robonen/writekit test # logic (jsdom) + CRDT convergence
|
||||
pnpm --filter @robonen/writekit build # tsdown (ESM + CJS + dts)
|
||||
pnpm --filter @robonen/writekit-playground dev
|
||||
```
|
||||
|
||||
See [AGENTS.md](./AGENTS.md) for the architecture and contributor notes.
|
||||
@@ -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><></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">
|
||||
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 editor = createEditor({ state: createEditorState({ registry }) });
|
||||
const writekit = createWritekit({ state: createWritekitState({ registry }) });
|
||||
<\/script>
|
||||
|
||||
<template>
|
||||
<EditorRoot :editor="editor" autofocus class="editor" />
|
||||
<WritekitRoot :writekit="writekit" autofocus class="writekit" />
|
||||
<\/template>`;
|
||||
|
||||
const composeSlots = `<EditorRoot :editor="editor" autofocus>
|
||||
<EditorContent />
|
||||
<EditorBubbleMenu /> <!-- formatting toolbar on selection -->
|
||||
<EditorSlashMenu /> <!-- type \`/\` to insert blocks -->
|
||||
</EditorRoot>`;
|
||||
const composeSlots = `<WritekitRoot :writekit="writekit" autofocus>
|
||||
<WritekitContent />
|
||||
<WritekitBubbleMenu /> <!-- formatting toolbar on selection -->
|
||||
<WritekitSlashMenu /> <!-- type \`/\` to insert blocks -->
|
||||
</WritekitRoot>`;
|
||||
|
||||
const commands = `import { setBlockType, toggleMark } from '@robonen/editor';
|
||||
const commands = `import { setBlockType, toggleMark } from '@robonen/writekit';
|
||||
|
||||
editor.command(toggleMark('bold'));
|
||||
editor.command(setBlockType('heading', { level: 2 }));
|
||||
writekit.command(toggleMark('bold'));
|
||||
writekit.command(setBlockType('heading', { level: 2 }));
|
||||
|
||||
// Called without a dispatch they run dry — perfect for
|
||||
// computing disabled / active toolbar state.
|
||||
const canBold = editor.command(toggleMark('bold'));`;
|
||||
const canBold = writekit.command(toggleMark('bold'));`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="docs-section">
|
||||
<div class="prose-docs">
|
||||
<h1>@robonen/editor</h1>
|
||||
<h1>@robonen/writekit</h1>
|
||||
<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
|
||||
<strong>hand-built CRDT</strong> for collaboration (no Yjs / Loro / Automerge).
|
||||
</p>
|
||||
@@ -39,9 +39,9 @@ const canBold = editor.command(toggleMark('bold'));`;
|
||||
|
||||
<div class="prose-docs">
|
||||
<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
|
||||
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
|
||||
and inline marks are added without touching the core. The model, schema, state, commands and
|
||||
keymap are entirely DOM-free and Vue-free; the Vue layer only renders and handles input.
|
||||
@@ -51,33 +51,33 @@ const canBold = editor.command(toggleMark('bold'));`;
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<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>
|
||||
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||
Ships behavior and DOM structure (<code class="text-(--accent-text)">data-block-*</code>
|
||||
<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>
|
||||
<p class="text-sm leading-relaxed text-fg-muted">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||
<code class="text-(--accent-text)">defineBlock</code> /
|
||||
<code class="text-(--accent-text)">defineMark</code> register into an immutable schema —
|
||||
<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>
|
||||
<p class="text-sm leading-relaxed text-fg-muted">
|
||||
<code class="text-accent-text">defineBlock</code> /
|
||||
<code class="text-accent-text">defineMark</code> register into an immutable schema —
|
||||
add a custom block or mark with no core changes.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||
<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>
|
||||
<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
|
||||
of truth for both local edits and sync.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||
<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>
|
||||
<p class="text-sm leading-relaxed text-fg-muted">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,18 +85,18 @@ const canBold = editor.command(toggleMark('bold'));`;
|
||||
<div class="prose-docs">
|
||||
<h2>Install</h2>
|
||||
<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.
|
||||
</p>
|
||||
</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">
|
||||
<h2>Quick start</h2>
|
||||
<p>
|
||||
Create a registry, build an editor around its state, and mount <code>EditorRoot</code>. Its
|
||||
default slot renders <code>EditorContent</code> (the single <code>contenteditable</code>), so
|
||||
this is a fully working editor with all built-in blocks and marks.
|
||||
Create a registry, build an writekit around its state, and mount <code>WritekitRoot</code>. Its
|
||||
default slot renders <code>WritekitContent</code> (the single <code>contenteditable</code>), so
|
||||
this is a fully working writekit with all built-in blocks and marks.
|
||||
</p>
|
||||
</div>
|
||||
<DocsCode :code="quickStart" lang="vue" />
|
||||
@@ -113,7 +113,7 @@ const canBold = editor.command(toggleMark('bold'));`;
|
||||
<h2>Commands</h2>
|
||||
<p>
|
||||
Commands are <code>(state, dispatch?, view?) => 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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -135,7 +135,7 @@ const canBold = editor.command(toggleMark('bold'));`;
|
||||
</div>
|
||||
|
||||
<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>
|
||||
Core logic is covered by unit + convergence tests; the contenteditable / Playwright suite
|
||||
runs locally. The collaboration layer has a few documented, deferred limitations.
|
||||
@@ -147,26 +147,26 @@ const canBold = editor.command(toggleMark('bold'));`;
|
||||
<p>Jump into the pieces you'll reach for first:</p>
|
||||
<ul>
|
||||
<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.
|
||||
</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.
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/editor/create-default-registry"><code>createDefaultRegistry</code></NuxtLink>,
|
||||
<NuxtLink to="/editor/define-block"><code>defineBlock</code></NuxtLink> and
|
||||
<NuxtLink to="/editor/define-mark"><code>defineMark</code></NuxtLink> — extend the schema.
|
||||
<NuxtLink to="/writekit/create-default-registry"><code>createDefaultRegistry</code></NuxtLink>,
|
||||
<NuxtLink to="/writekit/define-block"><code>defineBlock</code></NuxtLink> and
|
||||
<NuxtLink to="/writekit/define-mark"><code>defineMark</code></NuxtLink> — extend the schema.
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/editor/toggle-mark"><code>toggleMark</code></NuxtLink> /
|
||||
<NuxtLink to="/editor/set-block-type"><code>setBlockType</code></NuxtLink> — the commands
|
||||
<NuxtLink to="/writekit/toggle-mark"><code>toggleMark</code></NuxtLink> /
|
||||
<NuxtLink to="/writekit/set-block-type"><code>setBlockType</code></NuxtLink> — the commands
|
||||
API for programmatic and toolbar edits.
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/editor/bind-crdt"><code>bindCrdt</code></NuxtLink> and
|
||||
<NuxtLink to="/editor/create-native-provider"><code>createNativeProvider</code></NuxtLink>
|
||||
<NuxtLink to="/writekit/bind-crdt"><code>bindCrdt</code></NuxtLink> and
|
||||
<NuxtLink to="/writekit/create-native-provider"><code>createNativeProvider</code></NuxtLink>
|
||||
— wire up real-time collaboration with the built-in CRDT.
|
||||
</li>
|
||||
</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, {
|
||||
name: 'editor/overrides',
|
||||
name: 'writekit/overrides',
|
||||
files: ['**/*.vue'],
|
||||
rules: {
|
||||
'@stylistic/no-multiple-empty-lines': 'off',
|
||||
},
|
||||
});
|
||||
}, tests);
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@robonen/editor",
|
||||
"name": "@robonen/writekit",
|
||||
"version": "0.0.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Headless block-based rich-text editor for Vue with a registry-driven schema and pluggable CRDT",
|
||||
"description": "Headless block-based rich-text writekit for Vue with a registry-driven schema and pluggable CRDT",
|
||||
"keywords": [
|
||||
"editor",
|
||||
"writekit",
|
||||
"rich-text",
|
||||
"wysiwyg",
|
||||
"block-editor",
|
||||
"block-writekit",
|
||||
"crdt",
|
||||
"collaborative",
|
||||
"vue",
|
||||
@@ -17,7 +17,7 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "vue/editor"
|
||||
"directory": "vue/writekit"
|
||||
},
|
||||
"packageManager": "pnpm@10.34.1",
|
||||
"engines": {
|
||||
@@ -73,10 +73,11 @@
|
||||
"vue-tsc": "^3.3.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/vue": "^1.1.11",
|
||||
"@robonen/crdt": "workspace:*",
|
||||
"@robonen/platform": "workspace:*",
|
||||
"@robonen/primitives": "workspace:*",
|
||||
"@robonen/stdlib": "workspace:*",
|
||||
"@robonen/vue": "workspace:*",
|
||||
"@vue/shared": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@robonen/editor playground</title>
|
||||
<title>@robonen/writekit playground</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@robonen/editor-playground",
|
||||
"name": "@robonen/writekit-playground",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"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",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,7 +11,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@robonen/editor": "workspace:*",
|
||||
"@robonen/writekit": "workspace:*",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+27
-27
@@ -1,19 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
import type { RemoteCursor } from '@editor';
|
||||
import type { RemoteCursor } from '@writekit';
|
||||
import {
|
||||
EditorBubbleMenu,
|
||||
EditorContent,
|
||||
EditorRemoteCursors,
|
||||
EditorRoot,
|
||||
EditorSlashMenu,
|
||||
WritekitBubbleMenu,
|
||||
WritekitContent,
|
||||
WritekitRemoteCursors,
|
||||
WritekitRoot,
|
||||
WritekitSlashMenu,
|
||||
bindCrdt,
|
||||
createDefaultRegistry,
|
||||
createDoc,
|
||||
createEditor,
|
||||
createEditorState,
|
||||
createNativeProvider,
|
||||
} from '@editor';
|
||||
createWritekit,
|
||||
createWritekitState,
|
||||
} from '@writekit';
|
||||
import { h, p } from '../lib';
|
||||
|
||||
const registry = createDefaultRegistry();
|
||||
@@ -24,15 +24,15 @@ const seed = createDoc([
|
||||
]);
|
||||
|
||||
// Peer A owns the initial document.
|
||||
const editorA = createEditor({ state: createEditorState({ registry, doc: seed }) });
|
||||
const providerA = createNativeProvider({ schema: registry.schema, doc: editorA.state.doc, user: { name: 'Alice', color: '#2563eb' } });
|
||||
const writekitA = createWritekit({ state: createWritekitState({ registry, doc: seed }) });
|
||||
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.
|
||||
const editorB = createEditor({ state: createEditorState({ registry }) });
|
||||
const writekitB = createWritekit({ state: createWritekitState({ registry }) });
|
||||
const providerB = createNativeProvider({ schema: registry.schema, user: { name: 'Bob', color: '#db2777' } });
|
||||
|
||||
const bindingA = bindCrdt(editorA, providerA);
|
||||
const bindingB = bindCrdt(editorB, providerB);
|
||||
const bindingA = bindCrdt(writekitA, providerA);
|
||||
const bindingB = bindCrdt(writekitB, providerB);
|
||||
|
||||
providerB.applyUpdate(providerA.encodeDelta());
|
||||
|
||||
@@ -63,25 +63,25 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<section>
|
||||
<h2>Collaboration (own CRDT)</h2>
|
||||
<p class="hint">Two independent editors, each backed by a separate <code>@robonen/crdt</code> replica, synced in memory. Type in either pane — concurrent edits converge live and you'll see the other peer's cursor; no Yjs.</p>
|
||||
<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>
|
||||
<div class="peer-label"><span class="peer-dot" style="background: #2563eb" />Alice</div>
|
||||
<EditorRoot :editor="editorA" autofocus class="editor collab">
|
||||
<EditorContent />
|
||||
<EditorRemoteCursors :cursors="cursorsA" />
|
||||
<EditorBubbleMenu />
|
||||
<EditorSlashMenu />
|
||||
</EditorRoot>
|
||||
<WritekitRoot :writekit="writekitA" autofocus class="writekit collab">
|
||||
<WritekitContent />
|
||||
<WritekitRemoteCursors :cursors="cursorsA" />
|
||||
<WritekitBubbleMenu />
|
||||
<WritekitSlashMenu />
|
||||
</WritekitRoot>
|
||||
</div>
|
||||
<div>
|
||||
<div class="peer-label"><span class="peer-dot" style="background: #db2777" />Bob</div>
|
||||
<EditorRoot :editor="editorB" class="editor collab">
|
||||
<EditorContent />
|
||||
<EditorRemoteCursors :cursors="cursorsB" />
|
||||
<EditorBubbleMenu />
|
||||
<EditorSlashMenu />
|
||||
</EditorRoot>
|
||||
<WritekitRoot :writekit="writekitB" class="writekit collab">
|
||||
<WritekitContent />
|
||||
<WritekitRemoteCursors :cursors="cursorsB" />
|
||||
<WritekitBubbleMenu />
|
||||
<WritekitSlashMenu />
|
||||
</WritekitRoot>
|
||||
</div>
|
||||
</div>
|
||||
</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 & 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'))"></></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>> </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>
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { EditorRoot, createNode, createTransaction } from '@editor';
|
||||
import type { Command, Keymap } from '@editor';
|
||||
import { makeEditor, p } from '../lib';
|
||||
import { WritekitRoot, createNode, createTransaction } from '@writekit';
|
||||
import type { Command, Keymap } from '@writekit';
|
||||
import { makeWritekit, p } from '../lib';
|
||||
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('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>
|
||||
<h2>Custom keymap</h2>
|
||||
<p class="hint">A user keymap merged over the defaults: Cmd/Ctrl+Enter inserts a note.</p>
|
||||
<Toolbar :editor="editor" />
|
||||
<EditorRoot :editor="editor" :keymaps="keymaps" class="editor" />
|
||||
<Toolbar :writekit="writekit" />
|
||||
<WritekitRoot :writekit="writekit" :keymaps="keymaps" class="writekit" />
|
||||
</section>
|
||||
</template>
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Node } from '@editor';
|
||||
import { EditorRoot } from '@editor';
|
||||
import { h, makeEditor, p } from '../lib';
|
||||
import type { Node } from '@writekit';
|
||||
import { WritekitRoot } from '@writekit';
|
||||
import { h, makeWritekit, p } from '../lib';
|
||||
import Toolbar from '../Toolbar.vue';
|
||||
|
||||
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.`));
|
||||
}
|
||||
|
||||
const editor = makeEditor(blocks);
|
||||
const writekit = makeWritekit(blocks);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h2>Many blocks</h2>
|
||||
<p class="hint">60 blocks — test cross-block drag over a long range, ↑/↓ navigation, and Cmd/Ctrl+A (once = current block, twice = whole document).</p>
|
||||
<Toolbar :editor="editor" />
|
||||
<EditorRoot :editor="editor" class="editor scroll" />
|
||||
<Toolbar :writekit="writekit" />
|
||||
<WritekitRoot :writekit="writekit" class="writekit scroll" />
|
||||
</section>
|
||||
</template>
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { EditorRoot } from '@editor';
|
||||
import { makeEditor, p, t } from '../lib';
|
||||
import { WritekitRoot } from '@writekit';
|
||||
import { makeWritekit, p, t } from '../lib';
|
||||
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('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).'),
|
||||
@@ -14,7 +14,7 @@ const editor = makeEditor([
|
||||
<section>
|
||||
<h2>Inline marks</h2>
|
||||
<p class="hint">Adjacent / overlapping marks, marks at run boundaries, partial-word toggling, stored marks.</p>
|
||||
<Toolbar :editor="editor" />
|
||||
<EditorRoot :editor="editor" class="editor" />
|
||||
<Toolbar :writekit="writekit" />
|
||||
<WritekitRoot :writekit="writekit" class="writekit" />
|
||||
</section>
|
||||
</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>
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { EditorRoot } from '@editor';
|
||||
import { h, makeEditor, p, t } from '../lib';
|
||||
import { WritekitRoot } from '@writekit';
|
||||
import { h, makeWritekit, p, t } from '../lib';
|
||||
|
||||
const editor = makeEditor([
|
||||
const writekit = makeWritekit([
|
||||
h(2, 'Read-only'),
|
||||
p([t('You can '), t('select', 'bold'), t(' and copy this text, but typing and shortcuts do nothing.')]),
|
||||
p('Mouse selection and arrow navigation still work across blocks.'),
|
||||
@@ -13,6 +13,6 @@ const editor = makeEditor([
|
||||
<section>
|
||||
<h2>Read-only</h2>
|
||||
<p class="hint">editable=false — selection/navigation work; edits and shortcuts are blocked.</p>
|
||||
<EditorRoot :editor="editor" :editable="false" class="editor" />
|
||||
<WritekitRoot :writekit="writekit" :editable="false" class="writekit" />
|
||||
</section>
|
||||
</template>
|
||||
+12
-12
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { EditorBubbleMenu, EditorContent, EditorRoot, EditorSlashMenu } from '@editor';
|
||||
import { h, makeEditor, p, t } from '../lib';
|
||||
import { WritekitBubbleMenu, WritekitContent, WritekitRoot, WritekitSlashMenu } from '@writekit';
|
||||
import { h, makeWritekit, p, t } from '../lib';
|
||||
import Toolbar from '../Toolbar.vue';
|
||||
|
||||
const editor = makeEditor([
|
||||
h(1, 'Welcome to the editor'),
|
||||
const writekit = makeWritekit([
|
||||
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('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(''),
|
||||
@@ -14,20 +14,20 @@ const editor = makeEditor([
|
||||
]);
|
||||
|
||||
const rev = ref(0);
|
||||
editor.on('transaction', () => (rev.value += 1));
|
||||
const docJson = computed(() => (rev.value, JSON.stringify(editor.state.doc, null, 2)));
|
||||
writekit.on('transaction', () => (rev.value += 1));
|
||||
const docJson = computed(() => (rev.value, JSON.stringify(writekit.state.doc, null, 2)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h2>Rich text</h2>
|
||||
<p class="hint">Mixed marks, headings, an empty block (placeholder), cross-block selection & navigation.</p>
|
||||
<Toolbar :editor="editor" />
|
||||
<EditorRoot :editor="editor" autofocus class="editor">
|
||||
<EditorContent />
|
||||
<EditorBubbleMenu />
|
||||
<EditorSlashMenu />
|
||||
</EditorRoot>
|
||||
<Toolbar :writekit="writekit" />
|
||||
<WritekitRoot :writekit="writekit" autofocus class="writekit">
|
||||
<WritekitContent />
|
||||
<WritekitBubbleMenu />
|
||||
<WritekitSlashMenu />
|
||||
</WritekitRoot>
|
||||
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Editor, Inline, InlineNode, Node } from '@editor';
|
||||
import type { Inline, InlineNode, Node, Writekit } from '@writekit';
|
||||
import {
|
||||
createDefaultRegistry,
|
||||
createDoc,
|
||||
createEditor,
|
||||
createEditorState,
|
||||
createNode,
|
||||
} from '@editor';
|
||||
createWritekit,
|
||||
createWritekitState,
|
||||
} from '@writekit';
|
||||
|
||||
/** A styled inline run: `t('hello', 'bold', 'italic')`. */
|
||||
export function t(text: string, ...markTypes: string[]): InlineNode {
|
||||
@@ -32,8 +32,8 @@ export const todo = (text: string, checked = false): Node => createNode('todo-li
|
||||
export const divider = (): Node => createNode('divider');
|
||||
export const image = (src: string, caption = ''): Node => createNode('image', { attrs: { src, alt: caption, caption } });
|
||||
|
||||
/** Create an editor over the given blocks with the default registry. */
|
||||
export function makeEditor(content: Node[]): Editor {
|
||||
/** Create an writekit over the given blocks with the default registry. */
|
||||
export function makeWritekit(content: Node[]): Writekit {
|
||||
const registry = createDefaultRegistry();
|
||||
return createEditor({ state: createEditorState({ registry, doc: createDoc(content) }) });
|
||||
return createWritekit({ state: createWritekitState({ registry, doc: createDoc(content) }) });
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@editor": ["../src/index.ts"],
|
||||
"@editor/*": ["../src/*"]
|
||||
"@writekit": ["../src/index.ts"],
|
||||
"@writekit/*": ["../src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
@@ -10,11 +10,11 @@ export default defineConfig(({ mode }) => ({
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^@editor\/(.*)$/,
|
||||
find: /^@writekit\/(.*)$/,
|
||||
replacement: fileURLToPath(new URL('../src/$1', import.meta.url)),
|
||||
},
|
||||
{
|
||||
find: /^@editor$/,
|
||||
find: /^@writekit$/,
|
||||
replacement: fileURLToPath(new URL('../src/index.ts', import.meta.url)),
|
||||
},
|
||||
],
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { isInteractiveTarget } from '../view/interactive';
|
||||
|
||||
describe('isInteractiveTarget', () => {
|
||||
it('matches atom controls and contenteditable=false islands, not editor text', () => {
|
||||
it('matches atom controls and contenteditable=false islands, not writekit text', () => {
|
||||
const root = document.createElement('div');
|
||||
root.setAttribute('contenteditable', 'true');
|
||||
root.innerHTML = '<p class="text">hi</p><figure contenteditable="false"><input class="cap"></figure>';
|
||||
+2
-2
@@ -3,13 +3,13 @@ import { createBlockElementRegistry, createSelectionBridge } from '../view/selec
|
||||
|
||||
/**
|
||||
* Builds the single-contenteditable DOM shape the view produces: one
|
||||
* `[data-editor-content]` root containing plain `[data-block-content]` block
|
||||
* `[data-writekit-content]` root containing plain `[data-block-content]` block
|
||||
* elements. Runs in jsdom (logic project) to prove the cross-block selection
|
||||
* mapping without a real browser.
|
||||
*/
|
||||
function buildDoc() {
|
||||
const root = document.createElement('div');
|
||||
root.setAttribute('data-editor-content', '');
|
||||
root.setAttribute('data-writekit-content', '');
|
||||
|
||||
const a = document.createElement('p');
|
||||
a.setAttribute('data-block-content', '');
|
||||
@@ -5,5 +5,5 @@ defineProps<BlockComponentProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<hr data-editor-divider="" />
|
||||
<hr data-writekit-divider="" />
|
||||
</template>
|
||||
@@ -15,7 +15,7 @@ function set(key: string, event: Event): void {
|
||||
</script>
|
||||
|
||||
<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" />
|
||||
<div v-else class="image-placeholder">No image — add a URL below</div>
|
||||
|
||||
+25
-25
@@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest';
|
||||
import { caret, createDoc, createNode, nodeInline, nodeText, textSelection } from '../../model';
|
||||
import { applyInputRule, joinBackward, splitBlock, toggleMark } from '../../commands';
|
||||
import { createDefaultRegistry } from '../../preset';
|
||||
import { createEditor, createEditorState } from '../../state';
|
||||
import { createWritekit, createWritekitState } from '../../state';
|
||||
|
||||
function editorWith(node: ReturnType<typeof createNode>, selection?: ReturnType<typeof caret>) {
|
||||
function writekitWith(node: ReturnType<typeof createNode>, selection?: ReturnType<typeof caret>) {
|
||||
const registry = createDefaultRegistry();
|
||||
return createEditor({ state: createEditorState({ registry, doc: createDoc([node]), selection }) });
|
||||
return createWritekit({ state: createWritekitState({ registry, doc: createDoc([node]), selection }) });
|
||||
}
|
||||
|
||||
describe('default registry', () => {
|
||||
@@ -31,24 +31,24 @@ describe('default registry', () => {
|
||||
|
||||
describe('code block', () => {
|
||||
it('Enter inserts a newline instead of splitting', () => {
|
||||
const editor = editorWith(createNode('code-block', { id: 'c', content: [{ text: 'ab', marks: [] }] }), caret('c', 2));
|
||||
expect(editor.command(splitBlock)).toBe(true);
|
||||
expect(editor.state.doc.content.length).toBe(1);
|
||||
expect(nodeText(editor.state.doc.content[0]!)).toBe('ab\n');
|
||||
const writekit = writekitWith(createNode('code-block', { id: 'c', content: [{ text: 'ab', marks: [] }] }), caret('c', 2));
|
||||
expect(writekit.command(splitBlock)).toBe(true);
|
||||
expect(writekit.state.doc.content.length).toBe(1);
|
||||
expect(nodeText(writekit.state.doc.content[0]!)).toBe('ab\n');
|
||||
});
|
||||
|
||||
it('disallows inline marks', () => {
|
||||
const editor = editorWith(
|
||||
const writekit = writekitWith(
|
||||
createNode('code-block', { id: 'c', content: [{ text: 'abc', marks: [] }] }),
|
||||
textSelection({ blockId: 'c', offset: 0 }, { blockId: 'c', offset: 3 }),
|
||||
);
|
||||
expect(editor.command(toggleMark('bold'))).toBe(false);
|
||||
expect(writekit.command(toggleMark('bold'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not absorb disallowed marks when another block merges into it', () => {
|
||||
const registry = createDefaultRegistry();
|
||||
const editor = createEditor({
|
||||
state: createEditorState({
|
||||
const writekit = createWritekit({
|
||||
state: createWritekitState({
|
||||
registry,
|
||||
doc: createDoc([
|
||||
createNode('code-block', { id: 'c', content: [{ text: 'x', marks: [] }] }),
|
||||
@@ -58,8 +58,8 @@ describe('code block', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(editor.command(joinBackward)).toBe(true);
|
||||
const merged = editor.state.doc.content[0]!;
|
||||
expect(writekit.command(joinBackward)).toBe(true);
|
||||
const merged = writekit.state.doc.content[0]!;
|
||||
expect(merged.type).toBe('code-block');
|
||||
expect(nodeText(merged)).toBe('xB');
|
||||
expect(nodeInline(merged).every(run => run.marks.length === 0)).toBe(true);
|
||||
@@ -68,36 +68,36 @@ describe('code block', () => {
|
||||
|
||||
describe('input rules', () => {
|
||||
it('"# " converts a paragraph to a level-1 heading and strips the marker', () => {
|
||||
const editor = editorWith(createNode('paragraph', { id: 'p', content: [{ text: '# ', marks: [] }] }), caret('p', 2));
|
||||
expect(editor.command(applyInputRule)).toBe(true);
|
||||
const block = editor.state.doc.content[0]!;
|
||||
const writekit = writekitWith(createNode('paragraph', { id: 'p', content: [{ text: '# ', marks: [] }] }), caret('p', 2));
|
||||
expect(writekit.command(applyInputRule)).toBe(true);
|
||||
const block = writekit.state.doc.content[0]!;
|
||||
expect(block.type).toBe('heading');
|
||||
expect(block.attrs['level']).toBe(1);
|
||||
expect(nodeText(block)).toBe('');
|
||||
});
|
||||
|
||||
it('"- " converts a paragraph to a bulleted list', () => {
|
||||
const editor = editorWith(createNode('paragraph', { id: 'p', content: [{ text: '- ', marks: [] }] }), caret('p', 2));
|
||||
expect(editor.command(applyInputRule)).toBe(true);
|
||||
expect(editor.state.doc.content[0]!.type).toBe('bulleted-list');
|
||||
const writekit = writekitWith(createNode('paragraph', { id: 'p', content: [{ text: '- ', marks: [] }] }), caret('p', 2));
|
||||
expect(writekit.command(applyInputRule)).toBe(true);
|
||||
expect(writekit.state.doc.content[0]!.type).toBe('bulleted-list');
|
||||
});
|
||||
|
||||
it('does not re-fire when the block is already the target type', () => {
|
||||
const editor = editorWith(createNode('blockquote', { id: 'q', content: [{ text: '> ', marks: [] }] }), caret('q', 2));
|
||||
expect(editor.command(applyInputRule)).toBe(false);
|
||||
const writekit = writekitWith(createNode('blockquote', { id: 'q', content: [{ text: '> ', marks: [] }] }), caret('q', 2));
|
||||
expect(writekit.command(applyInputRule)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('to-do list', () => {
|
||||
it('starts a new item unchecked when splitting a checked one', () => {
|
||||
const editor = editorWith(
|
||||
const writekit = writekitWith(
|
||||
createNode('todo-list', { id: 't', attrs: { checked: true, indent: 0 }, content: [{ text: 'done', marks: [] }] }),
|
||||
caret('t', 4),
|
||||
);
|
||||
|
||||
expect(editor.command(splitBlock)).toBe(true);
|
||||
expect(editor.state.doc.content.length).toBe(2);
|
||||
const created = editor.state.doc.content[1]!;
|
||||
expect(writekit.command(splitBlock)).toBe(true);
|
||||
expect(writekit.state.doc.content.length).toBe(2);
|
||||
const created = writekit.state.doc.content[1]!;
|
||||
expect(created.type).toBe('todo-list');
|
||||
expect(created.attrs['checked']).toBe(false);
|
||||
});
|
||||
@@ -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 { blockById, isCollapsed, marksAt, nodeInline, normalizeMarks, orderedSelection, rangeHasMarkType } from '../model';
|
||||
import { marksAllowed } from '../schema';
|
||||
import type { Command, EditorState } from '../state';
|
||||
import type { Command, WritekitState } from '../state';
|
||||
import { createTransaction } from '../state';
|
||||
|
||||
/** Whether the focused block permits a mark of `type` (false for code blocks, etc.). */
|
||||
function markAllowedAtFocus(state: EditorState, type: string): boolean {
|
||||
function markAllowedAtFocus(state: WritekitState, type: string): boolean {
|
||||
if (state.selection.kind !== 'text')
|
||||
return false;
|
||||
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
previousBlock,
|
||||
textSelection,
|
||||
} from '../model';
|
||||
import type { Command, EditorState } from '../state';
|
||||
import type { Command, WritekitState } from '../state';
|
||||
import { createTransaction } from '../state';
|
||||
|
||||
function defaultTextType(state: EditorState): string {
|
||||
function defaultTextType(state: WritekitState): string {
|
||||
if (state.registry.hasBlock('paragraph'))
|
||||
return 'paragraph';
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
orderedSelection,
|
||||
previousBlock,
|
||||
} from '../model';
|
||||
import type { Command, EditorState } from '../state';
|
||||
import type { Command, WritekitState } from '../state';
|
||||
import { createTransaction } from '../state';
|
||||
|
||||
/** Type/attrs for the block created when splitting `block` at `offset`. */
|
||||
function continuation(state: EditorState, block: Node, offset: number): { type?: string; attrs?: Attrs } {
|
||||
function continuation(state: WritekitState, block: Node, offset: number): { type?: string; attrs?: Attrs } {
|
||||
const spec = state.schema.nodeSpec(block.type);
|
||||
|
||||
// Defining blocks (e.g. code-block) keep their identity across a split.
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { Attrs, Node } from '../model';
|
||||
import { blockById, isCollapsed, marksAt, nodeInline, orderedSelection, rangeHasMarkType } from '../model';
|
||||
import type { EditorState } from '../state';
|
||||
import type { WritekitState } from '../state';
|
||||
|
||||
/** Block id the selection's focus is in (or the first node-selected block). */
|
||||
export function selectionBlockId(state: EditorState): string | undefined {
|
||||
export function selectionBlockId(state: WritekitState): string | undefined {
|
||||
const sel = state.selection;
|
||||
return sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
|
||||
}
|
||||
|
||||
/** The block the selection currently focuses, or `null`. */
|
||||
export function focusBlock(state: EditorState): Node | null {
|
||||
export function focusBlock(state: WritekitState): Node | null {
|
||||
const id = selectionBlockId(state);
|
||||
return id ? blockById(state.doc, id) : null;
|
||||
}
|
||||
|
||||
/** Whether a block type holds inline (text) content. */
|
||||
export function isTextBlockType(state: EditorState, type: string): boolean {
|
||||
export function isTextBlockType(state: WritekitState, type: string): boolean {
|
||||
return state.schema.nodeSpec(type)?.content.kind === 'text';
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function isTextBlockType(state: EditorState, type: string): boolean {
|
||||
* Whether a mark is active for the current selection — used by `toggleMark` and
|
||||
* by toolbars (call a command without `dispatch` for the same answer).
|
||||
*/
|
||||
export function isMarkActive(state: EditorState, type: string): boolean {
|
||||
export function isMarkActive(state: WritekitState, type: string): boolean {
|
||||
const sel = state.selection;
|
||||
|
||||
if (sel.kind !== 'text')
|
||||
@@ -46,7 +46,7 @@ export function isMarkActive(state: EditorState, type: string): boolean {
|
||||
}
|
||||
|
||||
/** Whether the focused block matches a type (and optionally a subset of attrs). */
|
||||
export function isBlockActive(state: EditorState, type: string, attrs?: Attrs): boolean {
|
||||
export function isBlockActive(state: WritekitState, type: string, attrs?: Attrs): boolean {
|
||||
const block = focusBlock(state);
|
||||
|
||||
if (!block || block.type !== type)
|
||||
+25
-25
@@ -2,17 +2,17 @@ import { describe, expect, it } from 'vitest';
|
||||
import { caret, createDoc, createNode, nodeSelection, nodeText } from '../../model';
|
||||
import { deleteSelection } from '../../commands';
|
||||
import { createDefaultRegistry } from '../../preset';
|
||||
import { createEditor, createEditorState, createTransaction } from '../../state';
|
||||
import { createTransaction, createWritekit, createWritekitState } from '../../state';
|
||||
import { bindCrdt } from '../binding';
|
||||
import type { RemoteCursor } from '../types';
|
||||
import { createNativeProvider } from '../native/provider';
|
||||
|
||||
function makePeer(seedDoc?: ReturnType<typeof createDoc>) {
|
||||
const registry = createDefaultRegistry();
|
||||
const editor = createEditor({ state: createEditorState({ registry, doc: seedDoc }) });
|
||||
const provider = createNativeProvider({ schema: registry.schema, doc: seedDoc ? editor.state.doc : undefined });
|
||||
bindCrdt(editor, provider);
|
||||
return { editor, provider };
|
||||
const writekit = createWritekit({ state: createWritekitState({ registry, doc: seedDoc }) });
|
||||
const provider = createNativeProvider({ schema: registry.schema, doc: seedDoc ? writekit.state.doc : undefined });
|
||||
bindCrdt(writekit, provider);
|
||||
return { writekit, provider };
|
||||
}
|
||||
|
||||
/** Live two-way, in-memory transport between two providers. */
|
||||
@@ -22,10 +22,10 @@ function connect(a: ReturnType<typeof makePeer>, b: ReturnType<typeof makePeer>)
|
||||
}
|
||||
|
||||
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', () => {
|
||||
const doc = createDoc([createNode('paragraph', { id: 'p', content: [{ text: 'Hello', marks: [] }] })]);
|
||||
|
||||
@@ -38,8 +38,8 @@ describe('crdt convergence (two editors)', () => {
|
||||
connect(a, b);
|
||||
|
||||
// Concurrent edits at opposite ends of the same block.
|
||||
a.editor.dispatch(createTransaction(a.editor.state).insertText({ blockId: 'p', offset: 5 }, '!', []).setSelection(caret('p', 6)));
|
||||
b.editor.dispatch(createTransaction(b.editor.state).insertText({ blockId: 'p', offset: 0 }, '>', []).setSelection(caret('p', 1)));
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).insertText({ blockId: 'p', offset: 5 }, '!', []).setSelection(caret('p', 6)));
|
||||
b.writekit.dispatch(createTransaction(b.writekit.state).insertText({ blockId: 'p', offset: 0 }, '>', []).setSelection(caret('p', 1)));
|
||||
|
||||
expect(text(a)).toBe(text(b));
|
||||
expect(text(a)).toBe('>Hello!');
|
||||
@@ -53,7 +53,7 @@ describe('crdt convergence (two editors)', () => {
|
||||
connect(a, b);
|
||||
|
||||
// 'b' sits at UTF-16 offset 3 (the emoji occupies offsets 1..3).
|
||||
a.editor.dispatch(createTransaction(a.editor.state).deleteText('p', 3, 4).setSelection(caret('p', 3)));
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).deleteText('p', 3, 4).setSelection(caret('p', 3)));
|
||||
|
||||
expect(text(a)).toBe('a👍');
|
||||
expect(text(b)).toBe('a👍');
|
||||
@@ -71,12 +71,12 @@ describe('crdt convergence (two editors)', () => {
|
||||
expect(text(b)).toBe('AAA\nBBB');
|
||||
|
||||
// Select every block and delete (inserts one fresh empty paragraph).
|
||||
a.editor.dispatch(createTransaction(a.editor.state).setSelection(nodeSelection(['a', 'b'])));
|
||||
expect(a.editor.command(deleteSelection)).toBe(true);
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).setSelection(nodeSelection(['a', 'b'])));
|
||||
expect(a.writekit.command(deleteSelection)).toBe(true);
|
||||
expect(text(a)).toBe('');
|
||||
|
||||
// Undo must restore the blocks without duplicating them on either replica.
|
||||
expect(a.editor.undo()).toBe(true);
|
||||
expect(a.writekit.undo()).toBe(true);
|
||||
expect(text(a)).toBe('AAA\nBBB');
|
||||
expect(text(b)).toBe('AAA\nBBB');
|
||||
});
|
||||
@@ -94,12 +94,12 @@ describe('crdt convergence (two editors)', () => {
|
||||
b.provider.onLocalAwareness(bytes => a.provider.applyAwareness(bytes));
|
||||
|
||||
// B places its caret after "Hello" (offset 5).
|
||||
b.editor.dispatch(createTransaction(b.editor.state).setSelection(caret('p', 5)));
|
||||
b.writekit.dispatch(createTransaction(b.writekit.state).setSelection(caret('p', 5)));
|
||||
expect(cursors[0]?.selection?.kind).toBe('text');
|
||||
expect(cursors[0]?.selection?.kind === 'text' && cursors[0].selection.focus.offset).toBe(5);
|
||||
|
||||
// A edits locally; A's view of B's cursor (anchored after 'o') re-resolves to offset 7.
|
||||
a.editor.dispatch(createTransaction(a.editor.state).insertText({ blockId: 'p', offset: 0 }, '>>', []).setSelection(caret('p', 2)));
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).insertText({ blockId: 'p', offset: 0 }, '>>', []).setSelection(caret('p', 2)));
|
||||
expect(cursors[0]?.selection?.kind === 'text' && cursors[0].selection.focus.offset).toBe(7);
|
||||
});
|
||||
|
||||
@@ -111,8 +111,8 @@ describe('crdt convergence (two editors)', () => {
|
||||
connect(a, b);
|
||||
|
||||
// A bolds "ab" (stays in the head block); B splits after "abc" — concurrently.
|
||||
a.editor.dispatch(createTransaction(a.editor.state).addMark('p', 0, 2, { type: 'bold' }).setSelection(caret('p', 2)));
|
||||
b.editor.dispatch(createTransaction(b.editor.state).splitBlock({ blockId: 'p', offset: 3 }, undefined, undefined, 'p2').setSelection(caret('p2', 0)));
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).addMark('p', 0, 2, { type: 'bold' }).setSelection(caret('p', 2)));
|
||||
b.writekit.dispatch(createTransaction(b.writekit.state).splitBlock({ blockId: 'p', offset: 3 }, undefined, undefined, 'p2').setSelection(caret('p2', 0)));
|
||||
|
||||
expect(text(a)).toBe(text(b));
|
||||
// Document text is preserved across both edits (split inserts a block boundary).
|
||||
@@ -120,7 +120,7 @@ describe('crdt convergence (two editors)', () => {
|
||||
|
||||
// The bold mark survived on both replicas (somewhere in the doc).
|
||||
const hasBold = (peer: ReturnType<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')));
|
||||
expect(hasBold(a)).toBe(true);
|
||||
expect(hasBold(b)).toBe(true);
|
||||
@@ -136,12 +136,12 @@ describe('crdt convergence (two editors)', () => {
|
||||
b.provider.applyUpdate(a.provider.encodeDelta());
|
||||
connect(a, b);
|
||||
|
||||
const bBefore = b.editor.state.doc.content.find(node => node.id === 'b')!;
|
||||
const bBefore = b.writekit.state.doc.content.find(node => node.id === 'b')!;
|
||||
|
||||
a.editor.dispatch(createTransaction(a.editor.state).insertText({ blockId: 'a', offset: 3 }, '!', []).setSelection(caret('a', 4)));
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).insertText({ blockId: 'a', offset: 3 }, '!', []).setSelection(caret('a', 4)));
|
||||
|
||||
expect(nodeText(b.editor.state.doc.content.find(node => node.id === 'a')!)).toBe('AAA!'); // changed block updated
|
||||
expect(b.editor.state.doc.content.find(node => node.id === 'b')!).toBe(bBefore); // untouched block reused identity
|
||||
expect(nodeText(b.writekit.state.doc.content.find(node => node.id === 'a')!)).toBe('AAA!'); // changed block updated
|
||||
expect(b.writekit.state.doc.content.find(node => node.id === 'b')!).toBe(bBefore); // untouched block reused identity
|
||||
});
|
||||
|
||||
it('tombstone GC compacts deleted content, preserving the document and convergence', () => {
|
||||
@@ -151,8 +151,8 @@ describe('crdt convergence (two editors)', () => {
|
||||
b.provider.applyUpdate(a.provider.encodeDelta());
|
||||
connect(a, b);
|
||||
|
||||
a.editor.dispatch(createTransaction(a.editor.state).deleteText('p', 5, 11).setSelection(caret('p', 5)));
|
||||
a.editor.dispatch(createTransaction(a.editor.state).addMark('p', 0, 5, { type: 'bold' }).setSelection(caret('p', 5)));
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).deleteText('p', 5, 11).setSelection(caret('p', 5)));
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).addMark('p', 0, 5, { type: 'bold' }).setSelection(caret('p', 5)));
|
||||
expect(text(a)).toBe('Hello');
|
||||
expect(text(b)).toBe('Hello');
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('crdt convergence (two editors)', () => {
|
||||
expect(Array.isArray(runs) && runs.length > 0 && 'marks' in runs[0]! && runs[0]!.marks.some((m: { type: string }) => m.type === 'bold')).toBe(true);
|
||||
|
||||
// A further edit still converges across replicas.
|
||||
a.editor.dispatch(createTransaction(a.editor.state).insertText({ blockId: 'p', offset: 5 }, '!', []).setSelection(caret('p', 6)));
|
||||
a.writekit.dispatch(createTransaction(a.writekit.state).insertText({ blockId: 'p', offset: 5 }, '!', []).setSelection(caret('p', 6)));
|
||||
expect(text(a)).toBe('Hello!');
|
||||
expect(text(b)).toBe('Hello!');
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Editor, Transaction } from '../state';
|
||||
import type { Transaction, Writekit } from '../state';
|
||||
import { createTransaction } from '../state';
|
||||
import { reconcileDoc } from './reconcile';
|
||||
import type { CrdtProvider } from './types';
|
||||
@@ -9,29 +9,29 @@ export interface CrdtBinding {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a {@link CrdtProvider} to an {@link Editor}: local transactions flow into
|
||||
* Wire a {@link CrdtProvider} to an {@link Writekit}: local transactions flow into
|
||||
* the CRDT, and remote ops are reflected back as a single history-bypassing
|
||||
* `setDoc` transaction. The provider's `onLocalOps`/`applyUpdate` are connected
|
||||
* to a transport by the caller.
|
||||
*/
|
||||
export function bindCrdt(editor: Editor, provider: CrdtProvider): CrdtBinding {
|
||||
export function bindCrdt(writekit: Writekit, provider: CrdtProvider): CrdtBinding {
|
||||
function onTransaction(tr: Transaction): void {
|
||||
if (tr.getMeta('origin') !== REMOTE_ORIGIN)
|
||||
provider.applyLocal(tr); // never echo a remote-sourced change back into the CRDT
|
||||
provider.setLocalSelection(editor.state.selection); // presence (local edits + remapped remote)
|
||||
provider.setLocalSelection(writekit.state.selection); // presence (local edits + remapped remote)
|
||||
}
|
||||
|
||||
editor.on('transaction', onTransaction);
|
||||
provider.setLocalSelection(editor.state.selection);
|
||||
writekit.on('transaction', onTransaction);
|
||||
provider.setLocalSelection(writekit.state.selection);
|
||||
|
||||
const offRemote = provider.onRemoteApplied(() => {
|
||||
// Reuse unchanged block identities so only the blocks a remote edit touched
|
||||
// repaint (and the local caret in untouched blocks stays put).
|
||||
const next = reconcileDoc(editor.state.doc, provider.load());
|
||||
if (next === editor.state.doc)
|
||||
const next = reconcileDoc(writekit.state.doc, provider.load());
|
||||
if (next === writekit.state.doc)
|
||||
return; // remote ops didn't change the visible document
|
||||
|
||||
editor.dispatch(createTransaction(editor.state)
|
||||
writekit.dispatch(createTransaction(writekit.state)
|
||||
.setDoc(next)
|
||||
.setMeta('origin', REMOTE_ORIGIN)
|
||||
.setMeta('addToHistory', false));
|
||||
@@ -39,7 +39,7 @@ export function bindCrdt(editor: Editor, provider: CrdtProvider): CrdtBinding {
|
||||
|
||||
return {
|
||||
detach: () => {
|
||||
editor.off('transaction', onTransaction);
|
||||
writekit.off('transaction', onTransaction);
|
||||
offRemote();
|
||||
provider.destroy();
|
||||
},
|
||||
@@ -4,4 +4,4 @@ export * from './reconcile';
|
||||
export { createNativeProvider } from './native/provider';
|
||||
export type { NativeProviderOptions } from './native/provider';
|
||||
export { DocumentCrdt } from './native/document-crdt';
|
||||
export type { EditorOp } from './native/document-crdt';
|
||||
export type { WritekitOp } from './native/document-crdt';
|
||||
+25
-25
@@ -1,6 +1,6 @@
|
||||
import type { MarkValue, OpId, VersionVector } from '@robonen/crdt';
|
||||
import { LwwRegister, MarkStore, Rga, keyBetween, opIdEq, opIdToString } from '@robonen/crdt';
|
||||
import type { Attrs, EditorDocument, Inline, InlineNode, Mark, Node, Selection } from '../../model';
|
||||
import type { Attrs, Inline, InlineNode, Mark, Node, Selection, WritekitDocument } from '../../model';
|
||||
import { createDoc, nodeSelection, normalizeInline, normalizeMarks, textSelection } from '../../model';
|
||||
import type { Schema } from '../../schema';
|
||||
import type { Step } from '../../state';
|
||||
@@ -10,7 +10,7 @@ import type { SelectionAnchor } from '../types';
|
||||
* The CRDT operation log entry. Each carries an op id for the oplog; structural
|
||||
* ops address blocks by their stable string id, text ops by character op ids.
|
||||
*/
|
||||
export type EditorOp
|
||||
export type WritekitOp
|
||||
= | { readonly id: OpId; readonly kind: 'block-insert'; readonly blockId: string; readonly blockType: string; readonly attrs: Attrs; readonly posKey: string; readonly isText: boolean }
|
||||
| { readonly id: OpId; readonly kind: 'block-remove'; readonly blockId: string }
|
||||
| { readonly id: OpId; readonly kind: 'block-move'; readonly blockId: string; readonly posKey: string }
|
||||
@@ -39,11 +39,11 @@ function valueToMark(type: string, value: MarkValue): Mark {
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's document CRDT: a fractional-ordered set of blocks, each a text
|
||||
* RGA + a mark store (or an attribute-only atom). It translates the editor's
|
||||
* The writekit's document CRDT: a fractional-ordered set of blocks, each a text
|
||||
* RGA + a mark store (or an attribute-only atom). It translates the writekit's
|
||||
* offset-based {@link Step}s into id-based CRDT ops ({@link translateStep}),
|
||||
* integrates ops from any replica ({@link applyOp}), and materializes an
|
||||
* {@link EditorDocument} ({@link toDocument}).
|
||||
* {@link WritekitDocument} ({@link toDocument}).
|
||||
*/
|
||||
export class DocumentCrdt {
|
||||
private readonly blocks = new Map<string, BlockState>();
|
||||
@@ -59,7 +59,7 @@ export class DocumentCrdt {
|
||||
// ---------------------------------------------------------------- integrate
|
||||
|
||||
/** Apply one op (local or remote). Returns false if a causal dependency is missing. */
|
||||
applyOp(op: EditorOp): boolean {
|
||||
applyOp(op: WritekitOp): boolean {
|
||||
switch (op.kind) {
|
||||
case 'block-insert': {
|
||||
if (!this.blocks.has(op.blockId)) {
|
||||
@@ -139,7 +139,7 @@ export class DocumentCrdt {
|
||||
|
||||
// --------------------------------------------------------------- materialize
|
||||
|
||||
toDocument(): EditorDocument {
|
||||
toDocument(): WritekitDocument {
|
||||
const content: Node[] = [];
|
||||
for (const blockId of this.orderedBlockIds()) {
|
||||
const block = this.blocks.get(blockId)!;
|
||||
@@ -263,7 +263,7 @@ export class DocumentCrdt {
|
||||
// ----------------------------------------------------------------- translate
|
||||
|
||||
/** Generate the ops for a local step, reading current state for ids/positions. */
|
||||
translateStep(step: Step): EditorOp[] {
|
||||
translateStep(step: Step): WritekitOp[] {
|
||||
switch (step.type) {
|
||||
case 'insertInline':
|
||||
return this.insertInlineOps(step.blockId, step.offset, step.content);
|
||||
@@ -295,8 +295,8 @@ export class DocumentCrdt {
|
||||
}
|
||||
|
||||
/** Ops to seed the CRDT from an initial document. */
|
||||
seedFromDocument(doc: EditorDocument): EditorOp[] {
|
||||
const ops: EditorOp[] = [];
|
||||
seedFromDocument(doc: WritekitDocument): WritekitOp[] {
|
||||
const ops: WritekitOp[] = [];
|
||||
let prevKey: string | null = null;
|
||||
|
||||
for (const node of doc.content) {
|
||||
@@ -311,7 +311,7 @@ export class DocumentCrdt {
|
||||
// ------------------------------------------------------------------ op builders
|
||||
|
||||
/** Ops for an `insertBlock` step: reactivate if the block already exists (undo), else create. */
|
||||
private insertBlockOps(node: Node, index: number): EditorOp[] {
|
||||
private insertBlockOps(node: Node, index: number): WritekitOp[] {
|
||||
const posKey = this.posKeyForIndex(index);
|
||||
|
||||
if (this.blocks.has(node.id)) {
|
||||
@@ -322,9 +322,9 @@ export class DocumentCrdt {
|
||||
return this.blockOps(node, posKey);
|
||||
}
|
||||
|
||||
private blockOps(node: Node, posKey: string): EditorOp[] {
|
||||
private blockOps(node: Node, posKey: string): WritekitOp[] {
|
||||
const isText = this.schema.nodeSpec(node.type)?.content.kind === 'text';
|
||||
const ops: EditorOp[] = [{
|
||||
const ops: WritekitOp[] = [{
|
||||
id: this.nextId(),
|
||||
kind: 'block-insert',
|
||||
blockId: node.id,
|
||||
@@ -341,13 +341,13 @@ export class DocumentCrdt {
|
||||
}
|
||||
|
||||
/** Insert inline `content` after the char at `afterId` (null = block start). */
|
||||
private inlineOps(blockId: string, content: Inline, afterId: OpId | null): EditorOp[] {
|
||||
const ops: EditorOp[] = [];
|
||||
private inlineOps(blockId: string, content: Inline, afterId: OpId | null): WritekitOp[] {
|
||||
const ops: WritekitOp[] = [];
|
||||
let after = afterId;
|
||||
|
||||
for (const run of content) {
|
||||
const charIds: OpId[] = [];
|
||||
// Iterate UTF-16 code units (not code points) to match the editor's
|
||||
// Iterate UTF-16 code units (not code points) to match the writekit's
|
||||
// offset space — one RGA node per unit keeps offsets aligned for astral chars.
|
||||
for (let i = 0; i < run.text.length; i++) {
|
||||
const id = this.nextId();
|
||||
@@ -365,14 +365,14 @@ export class DocumentCrdt {
|
||||
return ops;
|
||||
}
|
||||
|
||||
private insertInlineOps(blockId: string, offset: number, content: Inline): EditorOp[] {
|
||||
private insertInlineOps(blockId: string, offset: number, content: Inline): WritekitOp[] {
|
||||
const block = this.blocks.get(blockId);
|
||||
if (!block || !block.isText)
|
||||
return [];
|
||||
return this.inlineOps(blockId, content, block.rga.idAt(offset - 1));
|
||||
}
|
||||
|
||||
private deleteTextOps(blockId: string, from: number, to: number): EditorOp[] {
|
||||
private deleteTextOps(blockId: string, from: number, to: number): WritekitOp[] {
|
||||
const block = this.blocks.get(blockId);
|
||||
if (!block || !block.isText)
|
||||
return [];
|
||||
@@ -380,7 +380,7 @@ export class DocumentCrdt {
|
||||
.map(node => ({ id: this.nextId(), kind: 'text-delete' as const, blockId, charId: node.id }));
|
||||
}
|
||||
|
||||
private markOps(blockId: string, from: number, to: number, markType: string, value: MarkValue): EditorOp[] {
|
||||
private markOps(blockId: string, from: number, to: number, markType: string, value: MarkValue): WritekitOp[] {
|
||||
const block = this.blocks.get(blockId);
|
||||
if (!block || !block.isText || from >= to)
|
||||
return [];
|
||||
@@ -401,7 +401,7 @@ export class DocumentCrdt {
|
||||
return keyBetween(before, after);
|
||||
}
|
||||
|
||||
private moveOps(blockId: string, toIndex: number): EditorOp[] {
|
||||
private moveOps(blockId: string, toIndex: number): WritekitOp[] {
|
||||
if (!this.blocks.has(blockId))
|
||||
return [];
|
||||
|
||||
@@ -411,7 +411,7 @@ export class DocumentCrdt {
|
||||
return [{ id: this.nextId(), kind: 'block-move', blockId, posKey: keyBetween(before, after) }];
|
||||
}
|
||||
|
||||
private splitOps(blockId: string, offset: number, newId: string, newType?: string, newAttrs?: Attrs): EditorOp[] {
|
||||
private splitOps(blockId: string, offset: number, newId: string, newType?: string, newAttrs?: Attrs): WritekitOp[] {
|
||||
const block = this.blocks.get(blockId);
|
||||
if (!block || !block.isText)
|
||||
return [];
|
||||
@@ -426,7 +426,7 @@ export class DocumentCrdt {
|
||||
// the source instead of recreating content (which would duplicate it).
|
||||
const existing = this.blocks.get(newId);
|
||||
if (existing) {
|
||||
const reactivate: EditorOp[] = [{ id: this.nextId(), kind: 'block-insert', blockId: newId, blockType: existing.type.get(), attrs: existing.attrs.get(), posKey, isText: existing.isText }];
|
||||
const reactivate: WritekitOp[] = [{ id: this.nextId(), kind: 'block-insert', blockId: newId, blockType: existing.type.get(), attrs: existing.attrs.get(), posKey, isText: existing.isText }];
|
||||
for (const node of block.rga.visible().slice(offset))
|
||||
reactivate.push({ id: this.nextId(), kind: 'text-delete', blockId, charId: node.id });
|
||||
return reactivate;
|
||||
@@ -436,7 +436,7 @@ export class DocumentCrdt {
|
||||
const attrs = newAttrs ?? (newType ? this.schema.defaultAttrs(newType) : block.attrs.get());
|
||||
const isText = this.schema.nodeSpec(type)?.content.kind === 'text';
|
||||
|
||||
const ops: EditorOp[] = [{ id: this.nextId(), kind: 'block-insert', blockId: newId, blockType: type, attrs, posKey, isText }];
|
||||
const ops: WritekitOp[] = [{ id: this.nextId(), kind: 'block-insert', blockId: newId, blockType: type, attrs, posKey, isText }];
|
||||
|
||||
// Re-create the tail (offset..end) in the new block, then tombstone it in the old.
|
||||
const tail = block.rga.visible().slice(offset);
|
||||
@@ -457,13 +457,13 @@ export class DocumentCrdt {
|
||||
return ops;
|
||||
}
|
||||
|
||||
private mergeOps(blockId: string, intoId: string): EditorOp[] {
|
||||
private mergeOps(blockId: string, intoId: string): WritekitOp[] {
|
||||
const source = this.blocks.get(blockId);
|
||||
const target = this.blocks.get(intoId);
|
||||
if (!source || !target || !source.isText || !target.isText)
|
||||
return [];
|
||||
|
||||
const ops: EditorOp[] = [];
|
||||
const ops: WritekitOp[] = [];
|
||||
const sourceChars = source.rga.visible();
|
||||
const marksPerChar = source.marks.resolve(sourceChars.map(node => node.id));
|
||||
let after = target.rga.idAt(target.rga.length - 1);
|
||||
@@ -1,37 +1,50 @@
|
||||
import { Replica, VersionVector, createSiteId, decodeJson, decodeOps, decodeStateVector, encodeJson, encodeOps, encodeStateVector } from '@robonen/crdt';
|
||||
import type { EditorDocument, Selection } from '../../model';
|
||||
import { PubSub } from '@robonen/stdlib';
|
||||
import type { Selection, WritekitDocument } from '../../model';
|
||||
import type { Schema } from '../../schema';
|
||||
import type { Transaction } from '../../state';
|
||||
import type { AwarenessState, CrdtProvider, CursorUser, RemoteCursor } from '../types';
|
||||
import type { EditorOp } from './document-crdt';
|
||||
import type { WritekitOp } from './document-crdt';
|
||||
import { DocumentCrdt } from './document-crdt';
|
||||
|
||||
export interface NativeProviderOptions {
|
||||
/** Schema (block/mark specs) — needed to know which blocks hold text. */
|
||||
schema: Schema;
|
||||
/** Seed the CRDT from this document (use for the FIRST replica only; joiners sync instead). */
|
||||
doc?: EditorDocument;
|
||||
doc?: WritekitDocument;
|
||||
/** Replica/site id (defaults to a random one). */
|
||||
site?: string;
|
||||
/** Identity broadcast with this replica's cursor. */
|
||||
user?: CursorUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider event map. A mapped type (not the `interface`) satisfies PubSub's
|
||||
* `Record<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
|
||||
* of blocks, each a text RGA + mark store. Editor steps map to CRDT ops via
|
||||
* of blocks, each a text RGA + mark store. Writekit steps map to CRDT ops via
|
||||
* {@link DocumentCrdt}; ops sync as op batches over any transport.
|
||||
*/
|
||||
export function createNativeProvider(options: NativeProviderOptions): CrdtProvider {
|
||||
const document = new DocumentCrdt(options.schema);
|
||||
const site = options.site ?? createSiteId();
|
||||
const replica = new Replica<EditorOp>({ integrate: op => document.applyOp(op) }, site);
|
||||
const replica = new Replica<WritekitOp>({ integrate: op => document.applyOp(op) }, site);
|
||||
document.setIdFactory(() => replica.nextId());
|
||||
|
||||
const localListeners = new Set<(bytes: Uint8Array) => void>();
|
||||
const remoteListeners = new Set<() => void>();
|
||||
const localAwarenessListeners = new Set<(bytes: Uint8Array) => void>();
|
||||
const awarenessListeners = new Set<(cursors: RemoteCursor[]) => void>();
|
||||
const bus = new PubSub<{ [K in keyof ProviderEvents]: ProviderEvents[K] }>();
|
||||
const remoteStates = new Map<string, AwarenessState>();
|
||||
|
||||
if (options.doc) {
|
||||
@@ -55,7 +68,7 @@ export function createNativeProvider(options: NativeProviderOptions): CrdtProvid
|
||||
load: () => document.toDocument(),
|
||||
|
||||
applyLocal: (tr: Transaction) => {
|
||||
const ops: EditorOp[] = [];
|
||||
const ops: WritekitOp[] = [];
|
||||
for (const step of tr.steps) {
|
||||
for (const op of document.translateStep(step)) {
|
||||
replica.commitLocal(op);
|
||||
@@ -63,29 +76,20 @@ export function createNativeProvider(options: NativeProviderOptions): CrdtProvid
|
||||
}
|
||||
}
|
||||
if (ops.length > 0) {
|
||||
const bytes = encodeOps(ops);
|
||||
for (const listener of localListeners)
|
||||
listener(bytes);
|
||||
bus.emit('localOps', encodeOps(ops));
|
||||
// Local edits shifted the document — re-resolve remote cursor positions.
|
||||
if (remoteStates.size > 0) {
|
||||
const cursors = resolveCursors();
|
||||
for (const listener of awarenessListeners)
|
||||
listener(cursors);
|
||||
}
|
||||
if (remoteStates.size > 0)
|
||||
bus.emit('awareness', resolveCursors());
|
||||
}
|
||||
},
|
||||
|
||||
applyUpdate: (bytes) => {
|
||||
const applied = replica.receive(decodeOps<EditorOp>(bytes));
|
||||
const applied = replica.receive(decodeOps<WritekitOp>(bytes));
|
||||
if (applied.length > 0) {
|
||||
for (const listener of remoteListeners)
|
||||
listener();
|
||||
bus.emit('remoteApplied');
|
||||
// Remote ops shifted the document — re-resolve cursors against new positions.
|
||||
if (remoteStates.size > 0) {
|
||||
const cursors = resolveCursors();
|
||||
for (const listener of awarenessListeners)
|
||||
listener(cursors);
|
||||
}
|
||||
if (remoteStates.size > 0)
|
||||
bus.emit('awareness', resolveCursors());
|
||||
}
|
||||
},
|
||||
|
||||
@@ -93,46 +97,42 @@ export function createNativeProvider(options: NativeProviderOptions): CrdtProvid
|
||||
encodeDelta: remote => encodeOps(replica.delta(remote ? decodeStateVector(remote) : new VersionVector())),
|
||||
|
||||
onLocalOps: (listener) => {
|
||||
localListeners.add(listener);
|
||||
return () => localListeners.delete(listener);
|
||||
bus.on('localOps', listener);
|
||||
return () => bus.off('localOps', listener);
|
||||
},
|
||||
onRemoteApplied: (listener) => {
|
||||
remoteListeners.add(listener);
|
||||
return () => remoteListeners.delete(listener);
|
||||
bus.on('remoteApplied', listener);
|
||||
return () => bus.off('remoteApplied', listener);
|
||||
},
|
||||
|
||||
setLocalSelection: (selection: Selection | null) => {
|
||||
const state: AwarenessState = { clientId: site, user: options.user, anchor: selection ? document.toAnchor(selection) : null };
|
||||
const bytes = encodeJson(state);
|
||||
for (const listener of localAwarenessListeners)
|
||||
listener(bytes);
|
||||
bus.emit('localAwareness', encodeJson(state));
|
||||
},
|
||||
|
||||
onLocalAwareness: (listener) => {
|
||||
localAwarenessListeners.add(listener);
|
||||
return () => localAwarenessListeners.delete(listener);
|
||||
bus.on('localAwareness', listener);
|
||||
return () => bus.off('localAwareness', listener);
|
||||
},
|
||||
|
||||
applyAwareness: (bytes) => {
|
||||
const state = decodeJson<AwarenessState>(bytes);
|
||||
remoteStates.set(state.clientId, state);
|
||||
const cursors = resolveCursors();
|
||||
for (const listener of awarenessListeners)
|
||||
listener(cursors);
|
||||
bus.emit('awareness', resolveCursors());
|
||||
},
|
||||
|
||||
onAwareness: (listener) => {
|
||||
awarenessListeners.add(listener);
|
||||
return () => awarenessListeners.delete(listener);
|
||||
bus.on('awareness', listener);
|
||||
return () => bus.off('awareness', listener);
|
||||
},
|
||||
|
||||
gc: stable => document.gc(stable ? decodeStateVector(stable) : replica.version),
|
||||
|
||||
destroy: () => {
|
||||
localListeners.clear();
|
||||
remoteListeners.clear();
|
||||
localAwarenessListeners.clear();
|
||||
awarenessListeners.clear();
|
||||
bus.clear('localOps');
|
||||
bus.clear('remoteApplied');
|
||||
bus.clear('localAwareness');
|
||||
bus.clear('awareness');
|
||||
remoteStates.clear();
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Content, EditorDocument, Node } from '../model';
|
||||
import type { Content, Node, WritekitDocument } from '../model';
|
||||
import { attrsEq, createDoc, isInlineContent, marksEq } from '../model';
|
||||
|
||||
function contentEq(a: Content, b: Content): boolean {
|
||||
@@ -30,7 +30,7 @@ function nodeEq(a: Node, b: Node): boolean {
|
||||
* blocks that actually changed (others keep their reference, and the local caret
|
||||
* in them is undisturbed). Returns `prev` unchanged when nothing differs.
|
||||
*/
|
||||
export function reconcileDoc(prev: EditorDocument, next: EditorDocument): EditorDocument {
|
||||
export function reconcileDoc(prev: WritekitDocument, next: WritekitDocument): WritekitDocument {
|
||||
const prevById = new Map(prev.content.map(node => [node.id, node]));
|
||||
let changed = prev.content.length !== next.content.length;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpId } from '@robonen/crdt';
|
||||
import type { EditorDocument, Selection } from '../model';
|
||||
import type { Selection, WritekitDocument } from '../model';
|
||||
import type { Transaction } from '../state';
|
||||
|
||||
/** Marks transactions that apply remote CRDT changes (so they bypass local history). */
|
||||
@@ -36,14 +36,14 @@ export interface RemoteCursor {
|
||||
}
|
||||
|
||||
/**
|
||||
* A pluggable CRDT backend. The editor core stays CRDT-agnostic behind this
|
||||
* interface; {@link bindCrdt} wires it to an {@link Editor}, and any transport
|
||||
* A pluggable CRDT backend. The writekit core stays CRDT-agnostic behind this
|
||||
* interface; {@link bindCrdt} wires it to an {@link Writekit}, and any transport
|
||||
* (BroadcastChannel, WebSocket, …) is layered on via the op + awareness hooks.
|
||||
*/
|
||||
export interface CrdtProvider {
|
||||
readonly name: string;
|
||||
/** The current document materialized from CRDT state. */
|
||||
load: () => EditorDocument;
|
||||
load: () => WritekitDocument;
|
||||
/** Translate a local transaction's steps into CRDT ops and apply them. */
|
||||
applyLocal: (tr: Transaction) => void;
|
||||
/** Merge a remote update (encoded ops) into the CRDT. */
|
||||
@@ -54,7 +54,7 @@ export interface CrdtProvider {
|
||||
encodeDelta: (remoteStateVector?: Uint8Array) => Uint8Array;
|
||||
/** Subscribe to locally-produced op batches (to broadcast). Returns unsubscribe. */
|
||||
onLocalOps: (listener: (bytes: Uint8Array) => void) => () => void;
|
||||
/** Subscribe to "remote ops were applied" (to reflect into editor state). Returns unsubscribe. */
|
||||
/** Subscribe to "remote ops were applied" (to reflect into writekit state). Returns unsubscribe. */
|
||||
onRemoteApplied: (listener: () => void) => () => void;
|
||||
|
||||
// --- awareness (ephemeral; not part of the persistent document) ---
|
||||
@@ -14,18 +14,18 @@ import {
|
||||
toggleBlockType,
|
||||
toggleMark,
|
||||
} from '../commands';
|
||||
import type { Command, Editor } from '../state';
|
||||
import type { Command, Writekit } from '../state';
|
||||
import type { Keymap } from './types';
|
||||
|
||||
/**
|
||||
* The standard editor keymap. Mark/heading shortcuts are no-ops when the mark or
|
||||
* The standard writekit keymap. Mark/heading shortcuts are no-ops when the mark or
|
||||
* block type isn't registered. Enter/Backspace/Delete are no-ops except at block
|
||||
* boundaries, so ordinary intra-block editing stays native. Arrow navigation and
|
||||
* cross-block selection are fully native (one contenteditable spans the doc).
|
||||
*/
|
||||
export function defaultKeymap(editor: Editor): Keymap {
|
||||
const undo: Command = () => editor.undo();
|
||||
const redo: Command = () => editor.redo();
|
||||
export function defaultKeymap(writekit: Writekit): Keymap {
|
||||
const undo: Command = () => writekit.undo();
|
||||
const redo: Command = () => writekit.redo();
|
||||
|
||||
const keymap: Keymap = {
|
||||
'Mod-b': toggleMark('bold'),
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Command, CommandView, Dispatch, EditorState } from '../state';
|
||||
import type { Command, CommandView, Dispatch, WritekitState } from '../state';
|
||||
import { eventToCombo } from './normalize';
|
||||
|
||||
/**
|
||||
@@ -8,7 +8,7 @@ import { eventToCombo } from './normalize';
|
||||
export function runKeydown(
|
||||
event: KeyboardEvent,
|
||||
compiled: Map<string, Command>,
|
||||
state: EditorState,
|
||||
state: WritekitState,
|
||||
dispatch: Dispatch,
|
||||
view: CommandView,
|
||||
): boolean {
|
||||
@@ -1,59 +1,59 @@
|
||||
import type { Node } from './node';
|
||||
|
||||
/**
|
||||
* The editor document: an ordered list of top-level blocks. Default blocks are
|
||||
* The writekit document: an ordered list of top-level blocks. Default blocks are
|
||||
* flat (lists use indent attributes, not nesting), so document helpers operate
|
||||
* on the top-level array.
|
||||
*/
|
||||
export interface EditorDocument {
|
||||
export interface WritekitDocument {
|
||||
readonly type: 'doc';
|
||||
readonly content: readonly Node[];
|
||||
}
|
||||
|
||||
/** Construct a document from blocks. */
|
||||
export function createDoc(content: readonly Node[] = []): EditorDocument {
|
||||
export function createDoc(content: readonly Node[] = []): WritekitDocument {
|
||||
return { type: 'doc', content };
|
||||
}
|
||||
|
||||
/** Index of a block by id, or `-1` if absent. */
|
||||
export function blockIndex(doc: EditorDocument, id: string): number {
|
||||
export function blockIndex(doc: WritekitDocument, id: string): number {
|
||||
return doc.content.findIndex(block => block.id === id);
|
||||
}
|
||||
|
||||
/** A block and its index, or `null` if absent. */
|
||||
export function findBlock(doc: EditorDocument, id: string): { node: Node; index: number } | null {
|
||||
export function findBlock(doc: WritekitDocument, id: string): { node: Node; index: number } | null {
|
||||
const index = blockIndex(doc, id);
|
||||
return index === -1 ? null : { node: doc.content[index]!, index };
|
||||
}
|
||||
|
||||
/** A block by id, or `null`. */
|
||||
export function blockById(doc: EditorDocument, id: string): Node | null {
|
||||
export function blockById(doc: WritekitDocument, id: string): Node | null {
|
||||
return doc.content.find(block => block.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** The block before `id` in document order, or `null`. */
|
||||
export function previousBlock(doc: EditorDocument, id: string): Node | null {
|
||||
export function previousBlock(doc: WritekitDocument, id: string): Node | null {
|
||||
const index = blockIndex(doc, id);
|
||||
return index > 0 ? doc.content[index - 1]! : null;
|
||||
}
|
||||
|
||||
/** The block after `id` in document order, or `null`. */
|
||||
export function nextBlock(doc: EditorDocument, id: string): Node | null {
|
||||
export function nextBlock(doc: WritekitDocument, id: string): Node | null {
|
||||
const index = blockIndex(doc, id);
|
||||
return index !== -1 && index < doc.content.length - 1 ? doc.content[index + 1]! : null;
|
||||
}
|
||||
|
||||
/** First block, or `null` for an empty document. */
|
||||
export function firstBlock(doc: EditorDocument): Node | null {
|
||||
export function firstBlock(doc: WritekitDocument): Node | null {
|
||||
return doc.content[0] ?? null;
|
||||
}
|
||||
|
||||
/** Last block, or `null` for an empty document. */
|
||||
export function lastBlock(doc: EditorDocument): Node | null {
|
||||
export function lastBlock(doc: WritekitDocument): Node | null {
|
||||
return doc.content[doc.content.length - 1] ?? null;
|
||||
}
|
||||
|
||||
/** Return a copy of `doc` with a different block list. */
|
||||
export function replaceBlocks(doc: EditorDocument, content: readonly Node[]): EditorDocument {
|
||||
export function replaceBlocks(doc: WritekitDocument, content: readonly Node[]): WritekitDocument {
|
||||
return { ...doc, content };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Position } from './position';
|
||||
import { positionEq } from './position';
|
||||
import type { EditorDocument } from './document';
|
||||
import type { WritekitDocument } from './document';
|
||||
import { blockIndex } from './document';
|
||||
|
||||
/** A text selection: caret when `anchor === focus`, range otherwise. May span blocks. */
|
||||
@@ -56,7 +56,7 @@ export function isAcrossBlocks(sel: Selection): boolean {
|
||||
* Endpoints of a text selection in document order (`from` before `to`). Within
|
||||
* one block they are ordered by offset; across blocks by block index.
|
||||
*/
|
||||
export function orderedSelection(sel: TextSelection, doc: EditorDocument): { from: Position; to: Position } {
|
||||
export function orderedSelection(sel: TextSelection, doc: WritekitDocument): { from: Position; to: Position } {
|
||||
const { anchor, focus } = sel;
|
||||
|
||||
if (anchor.blockId === focus.blockId)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user