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:
@@ -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 & 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>
|
||||
@@ -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 & 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>
|
||||
Reference in New Issue
Block a user