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,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@robonen/writekit playground</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@robonen/writekit-playground",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"description": "Minimal playground for @robonen/writekit — eyeball, debug, hack on hypotheses",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@robonen/writekit": "workspace:*",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"vite": "^8.0.16",
|
||||
"vue-tsc": "^3.3.4"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
const __DEV__: boolean;
|
||||
}
|
||||
|
||||
declare module 'vue' {
|
||||
type HTMLAttributes = Record<`data-${string}`, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Inline, InlineNode, Node, Writekit } from '@writekit';
|
||||
import {
|
||||
createDefaultRegistry,
|
||||
createDoc,
|
||||
createNode,
|
||||
createWritekit,
|
||||
createWritekitState,
|
||||
} from '@writekit';
|
||||
|
||||
/** A styled inline run: `t('hello', 'bold', 'italic')`. */
|
||||
export function t(text: string, ...markTypes: string[]): InlineNode {
|
||||
return { text, marks: markTypes.map(type => ({ type })) };
|
||||
}
|
||||
|
||||
/** A paragraph block from a string or inline runs. */
|
||||
export function p(content: string | Inline = ''): Node {
|
||||
const inline = typeof content === 'string' ? (content ? [t(content)] : []) : content;
|
||||
return createNode('paragraph', { content: inline });
|
||||
}
|
||||
|
||||
/** A heading block. */
|
||||
export function h(level: number, text: string): Node {
|
||||
return createNode('heading', { attrs: { level }, content: text ? [t(text)] : [] });
|
||||
}
|
||||
|
||||
export const quote = (text: string): Node => createNode('blockquote', { content: text ? [t(text)] : [] });
|
||||
export const code = (text: string): Node => createNode('code-block', { content: text ? [t(text)] : [] });
|
||||
export const callout = (variant: string, text: string): Node => createNode('callout', { attrs: { variant }, content: [t(text)] });
|
||||
export const bullet = (text: string, indent = 0): Node => createNode('bulleted-list', { attrs: { indent }, content: [t(text)] });
|
||||
export const numbered = (text: string, indent = 0): Node => createNode('numbered-list', { attrs: { indent }, content: [t(text)] });
|
||||
export const todo = (text: string, checked = false): Node => createNode('todo-list', { attrs: { checked, indent: 0 }, content: [t(text)] });
|
||||
export const divider = (): Node => createNode('divider');
|
||||
export const image = (src: string, caption = ''): Node => createNode('image', { attrs: { src, alt: caption, caption } });
|
||||
|
||||
/** Create an writekit over the given blocks with the default registry. */
|
||||
export function makeWritekit(content: Node[]): Writekit {
|
||||
const registry = createDefaultRegistry();
|
||||
return createWritekit({ state: createWritekitState({ registry, doc: createDoc(content) }) });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.vue.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@writekit": ["../src/index.ts"],
|
||||
"@writekit/*": ["../src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { URL, fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
__DEV__: JSON.stringify(mode !== 'production'),
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^@writekit\/(.*)$/,
|
||||
replacement: fileURLToPath(new URL('../src/$1', import.meta.url)),
|
||||
},
|
||||
{
|
||||
find: /^@writekit$/,
|
||||
replacement: fileURLToPath(new URL('../src/index.ts', import.meta.url)),
|
||||
},
|
||||
],
|
||||
},
|
||||
server: {
|
||||
port: 5181,
|
||||
fs: {
|
||||
allow: [fileURLToPath(new URL('../', import.meta.url))],
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user