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

Rename the rich-text editor package and all Editor* exports to Writekit*;
remove the old vue/editor tree.
This commit is contained in:
2026-06-15 16:54:06 +07:00
parent 55e78786d6
commit 263c32002f
149 changed files with 1563 additions and 1748 deletions
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import type { RemoteCursor } from '@writekit';
import {
WritekitBubbleMenu,
WritekitContent,
WritekitRemoteCursors,
WritekitRoot,
WritekitSlashMenu,
bindCrdt,
createDefaultRegistry,
createDoc,
createNativeProvider,
createWritekit,
createWritekitState,
} from '@writekit';
import { h, p } from '../lib';
const registry = createDefaultRegistry();
const seed = createDoc([
h(1, 'Shared document'),
p('Edit in either pane — changes sync to the other through its own CRDT replica.'),
p(''),
]);
// Peer A owns the initial document.
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 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());
// Live two-way transport, in memory (stand-in for a BroadcastChannel/WebSocket).
const offOpsA = providerA.onLocalOps(bytes => providerB.applyUpdate(bytes));
const offOpsB = providerB.onLocalOps(bytes => providerA.applyUpdate(bytes));
// Awareness (cursors) over the same in-memory channel.
const cursorsA = ref<RemoteCursor[]>([]);
const cursorsB = ref<RemoteCursor[]>([]);
const offCurA = providerA.onAwareness((cursors) => {
cursorsA.value = cursors;
});
const offCurB = providerB.onAwareness((cursors) => {
cursorsB.value = cursors;
});
const offAwA = providerA.onLocalAwareness(bytes => providerB.applyAwareness(bytes));
const offAwB = providerB.onLocalAwareness(bytes => providerA.applyAwareness(bytes));
onBeforeUnmount(() => {
for (const off of [offOpsA, offOpsB, offCurA, offCurB, offAwA, offAwB])
off();
bindingA.detach();
bindingB.detach();
});
</script>
<template>
<section>
<h2>Collaboration (own CRDT)</h2>
<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>
<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>
<WritekitRoot :writekit="writekitB" class="writekit collab">
<WritekitContent />
<WritekitRemoteCursors :cursors="cursorsB" />
<WritekitBubbleMenu />
<WritekitSlashMenu />
</WritekitRoot>
</div>
</div>
</section>
</template>
<style scoped>
.peer-label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #888; margin-bottom: 4px; }
.peer-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
WritekitRoot,
createNode,
createTransaction,
moveBlockDown,
moveBlockUp,
removeBlock,
setBlockType,
toggleMark,
} from '@writekit';
import { h, makeWritekit, p } from '../lib';
const writekit = makeWritekit([
h(1, 'Commands API'),
p('Drive the writekit programmatically with the buttons below. Put the caret in a block first.'),
p('Second block.'),
p('Third block.'),
]);
const rev = ref(0);
writekit.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(writekit.state.doc, null, 2)));
const canDelete = computed(() => (rev.value, writekit.state.doc.content.length > 1));
function focusId(): string | undefined {
const sel = writekit.state.selection;
return sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
}
function appendParagraph(): void {
const node = createNode('paragraph', { content: [{ text: 'Appended block', marks: [] }] });
writekit.dispatch(createTransaction(writekit.state).insertBlock(node, writekit.state.doc.content.length));
}
function deleteFocused(): void {
const id = focusId();
if (id && writekit.state.doc.content.length > 1)
writekit.command(removeBlock(id));
}
</script>
<template>
<section>
<h2>Commands API</h2>
<p class="hint">Programmatic control every button is a command or transaction on the writekit.</p>
<div class="toolbar wrap">
<button @mousedown.prevent="appendParagraph">Append paragraph</button>
<button @mousedown.prevent="writekit.command(moveBlockUp)">Move block </button>
<button @mousedown.prevent="writekit.command(moveBlockDown)">Move block </button>
<button @mousedown.prevent="writekit.command(setBlockType('heading', { level: 1 }))"> H1</button>
<button @mousedown.prevent="writekit.command(setBlockType('paragraph'))"> Paragraph</button>
<button @mousedown.prevent="writekit.command(toggleMark('bold'))">Toggle bold</button>
<button :disabled="!canDelete" @mousedown.prevent="deleteFocused">Delete block</button>
</div>
<WritekitRoot :writekit="writekit" class="writekit" />
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { Node } from '@writekit';
import {
WritekitBubbleMenu,
WritekitContent,
WritekitRoot,
WritekitSlashMenu,
addMark,
createTransaction,
nodeSelection,
setBlockType,
toggleChecked,
toggleMark,
} from '@writekit';
import { bullet, callout, code, divider, h, image, makeWritekit, numbered, p, quote, t, todo } from '../lib';
const writekit = makeWritekit([
h(1, 'Complex blocks'),
p([t('A document with '), t('many', 'bold'), t(' block types. Put the caret in a block and use the controls to convert it, or insert media.')]),
quote('“The block is the unit of composition.” — a registry-driven writekit.'),
callout('info', 'Callouts carry a variant attribute. This one is "info".'),
callout('warn', 'And this is a "warn" callout.'),
code('function hello() {\n // Enter inserts a newline here, not a block split\n return \'code block\';\n}'),
h(2, 'Lists'),
bullet('Bulleted item one'),
bullet('Nested bullet (indent = 1) — Tab / Shift+Tab changes indent', 1),
bullet('Bulleted item two'),
numbered('Numbered item (counter via CSS)'),
numbered('Numbered item'),
todo('A finished task', true),
todo('A pending task', false),
h(2, 'Media (atoms)'),
image('https://picsum.photos/seed/robonen/520/240', 'A random sample image'),
divider(),
p('Click an image or divider to select it, then Backspace/Delete removes it. Selecting an image reveals its URL / alt / caption fields.'),
]);
const rev = ref(0);
writekit.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(writekit.state.doc, null, 2)));
function focusIndex(): number {
const sel = writekit.state.selection;
const id = sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
const index = id ? writekit.state.doc.content.findIndex(block => block.id === id) : -1;
return index === -1 ? writekit.state.doc.content.length - 1 : index;
}
function insertAfterFocus(node: Node): void {
writekit.dispatch(createTransaction(writekit.state).insertBlock(node, focusIndex() + 1).setSelection(nodeSelection([node.id])));
}
function addLink(): void {
const href = globalThis.prompt('Link URL', 'https://');
if (href)
writekit.command(addMark('link', { href }));
}
</script>
<template>
<section>
<h2>Complex blocks</h2>
<p class="hint">Quote, callout, code block, lists (bulleted / numbered / to-do), image &amp; divider atoms, plus the full mark set. Everything is registry-driven.</p>
<div class="toolbar wrap">
<button @mousedown.prevent="writekit.command(toggleMark('bold'))"><b>B</b></button>
<button @mousedown.prevent="writekit.command(toggleMark('italic'))"><i>I</i></button>
<button @mousedown.prevent="writekit.command(toggleMark('underline'))"><u>U</u></button>
<button @mousedown.prevent="writekit.command(toggleMark('strike'))"><s>S</s></button>
<button @mousedown.prevent="writekit.command(toggleMark('code'))">&lt;/&gt;</button>
<button @mousedown.prevent="writekit.command(toggleMark('highlight'))">HL</button>
<button @mousedown.prevent="addLink">Link</button>
<span class="sep" />
<button @mousedown.prevent="writekit.command(setBlockType('paragraph'))">P</button>
<button @mousedown.prevent="writekit.command(setBlockType('heading', { level: 1 }))">H1</button>
<button @mousedown.prevent="writekit.command(setBlockType('heading', { level: 2 }))">H2</button>
<button @mousedown.prevent="writekit.command(setBlockType('blockquote'))">Quote</button>
<button @mousedown.prevent="writekit.command(setBlockType('code-block'))">Code</button>
<button @mousedown.prevent="writekit.command(setBlockType('callout', { variant: 'info' }))">Callout</button>
<span class="sep" />
<button @mousedown.prevent="writekit.command(setBlockType('bulleted-list'))"> List</button>
<button @mousedown.prevent="writekit.command(setBlockType('numbered-list'))">1. List</button>
<button @mousedown.prevent="writekit.command(setBlockType('todo-list', { checked: false, indent: 0 }))"> Todo</button>
<button @mousedown.prevent="writekit.command(toggleChecked)">Toggle </button>
<span class="sep" />
<button @mousedown.prevent="insertAfterFocus(image('', ''))">+ Image</button>
<button @mousedown.prevent="insertAfterFocus(divider())">+ Divider</button>
<span class="sep" />
<button @mousedown.prevent="writekit.undo()">Undo</button>
<button @mousedown.prevent="writekit.redo()">Redo</button>
</div>
<p class="hint">Type <kbd>/</kbd> to insert a block; select text for the bubble toolbar; hover a block and drag the <span aria-hidden="true"></span> handle to reorder. Markdown shortcuts work too: <kbd># </kbd>, <kbd>- </kbd>, <kbd>&gt; </kbd>, <kbd>1. </kbd>, <kbd>[] </kbd>.</p>
<WritekitRoot :writekit="writekit" autofocus draggable class="writekit">
<WritekitContent />
<WritekitBubbleMenu />
<WritekitSlashMenu />
</WritekitRoot>
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { WritekitRoot, createNode, createTransaction } from '@writekit';
import type { Command, Keymap } from '@writekit';
import { makeWritekit, p } from '../lib';
import Toolbar from '../Toolbar.vue';
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.'),
]);
const insertNote: Command = (state, dispatch) => {
const sel = state.selection;
if (sel.kind !== 'text')
return false;
if (dispatch) {
const index = state.doc.content.findIndex(block => block.id === sel.focus.blockId);
const node = createNode('paragraph', { content: [{ text: '— note —', marks: [{ type: 'italic' }] }] });
dispatch(createTransaction(state).insertBlock(node, index + 1));
}
return true;
};
const keymaps: Keymap[] = [{ 'Mod-Enter': insertNote }];
</script>
<template>
<section>
<h2>Custom keymap</h2>
<p class="hint">A user keymap merged over the defaults: Cmd/Ctrl+Enter inserts a note.</p>
<Toolbar :writekit="writekit" />
<WritekitRoot :writekit="writekit" :keymaps="keymaps" class="writekit" />
</section>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Node } from '@writekit';
import { WritekitRoot } from '@writekit';
import { h, makeWritekit, p } from '../lib';
import Toolbar from '../Toolbar.vue';
const blocks: Node[] = [];
for (let i = 1; i <= 60; i++) {
if (i % 10 === 1)
blocks.push(h(2, `Section ${Math.ceil(i / 10)}`));
else
blocks.push(p(`Block ${i}: the quick brown fox jumps over the lazy dog.`));
}
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 :writekit="writekit" />
<WritekitRoot :writekit="writekit" class="writekit scroll" />
</section>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { WritekitRoot } from '@writekit';
import { makeWritekit, p, t } from '../lib';
import Toolbar from '../Toolbar.vue';
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).'),
]);
</script>
<template>
<section>
<h2>Inline marks</h2>
<p class="hint">Adjacent / overlapping marks, marks at run boundaries, partial-word toggling, stored marks.</p>
<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>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { WritekitRoot } from '@writekit';
import { h, makeWritekit, p, t } from '../lib';
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.'),
]);
</script>
<template>
<section>
<h2>Read-only</h2>
<p class="hint">editable=false selection/navigation work; edits and shortcuts are blocked.</p>
<WritekitRoot :writekit="writekit" :editable="false" class="writekit" />
</section>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { WritekitBubbleMenu, WritekitContent, WritekitRoot, WritekitSlashMenu } from '@writekit';
import { h, makeWritekit, p, t } from '../lib';
import Toolbar from '../Toolbar.vue';
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(''),
h(2, 'Editing'),
p('Press Enter to split a block. Press Backspace at the very start of a block to merge it into the previous one. Cmd/Ctrl+Z undoes, Cmd/Ctrl+Shift+Z redoes.'),
]);
const rev = ref(0);
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 &amp; navigation.</p>
<Toolbar :writekit="writekit" />
<WritekitRoot :writekit="writekit" autofocus class="writekit">
<WritekitContent />
<WritekitBubbleMenu />
<WritekitSlashMenu />
</WritekitRoot>
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>