feat(editor): eslint/tsconfig migration + type fixes

@robonen/editor: migrate to eslint flat config + composite tsconfig; fix
convergence test type annotations.
This commit is contained in:
2026-06-07 16:30:05 +07:00
parent 626fbc70d8
commit 09272dffeb
136 changed files with 7248 additions and 0 deletions
+12
View File
@@ -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/editor playground</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@robonen/editor-playground",
"version": "0.0.0",
"private": true,
"license": "Apache-2.0",
"description": "Minimal playground for @robonen/editor — eyeball, debug, hack on hypotheses",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@robonen/editor": "workspace:*",
"vue": "catalog:"
},
"devDependencies": {
"@robonen/tsconfig": "workspace:*",
"@vitejs/plugin-vue": "^6.0.6",
"vite": "^7.1.9",
"vue-tsc": "^3.2.5"
}
}
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { shallowRef } from 'vue';
import CollabDemo from './demos/CollabDemo.vue';
import CommandsDemo from './demos/CommandsDemo.vue';
import ComplexBlocksDemo from './demos/ComplexBlocksDemo.vue';
import CustomKeymapDemo from './demos/CustomKeymapDemo.vue';
import ManyBlocksDemo from './demos/ManyBlocksDemo.vue';
import MarksDemo from './demos/MarksDemo.vue';
import MultiEditorDemo from './demos/MultiEditorDemo.vue';
import ReadOnlyDemo from './demos/ReadOnlyDemo.vue';
import RichTextDemo from './demos/RichTextDemo.vue';
const demos = [
{ id: 'rich', title: 'Rich text', component: RichTextDemo },
{ id: 'complex', title: 'Complex blocks', component: ComplexBlocksDemo },
{ id: 'collab', title: 'Collaboration', component: CollabDemo },
{ id: 'marks', title: 'Inline marks', component: MarksDemo },
{ id: 'many', title: 'Many blocks', component: ManyBlocksDemo },
{ id: 'multi', title: 'Multiple editors', component: MultiEditorDemo },
{ id: 'readonly', title: 'Read-only', component: ReadOnlyDemo },
{ id: 'commands', title: 'Commands API', component: CommandsDemo },
{ id: 'keymap', title: 'Custom keymap', component: CustomKeymapDemo },
];
const current = shallowRef(demos[0]!);
</script>
<template>
<div class="layout">
<nav class="sidebar">
<h1>@robonen/editor</h1>
<button
v-for="demo in demos"
:key="demo.id"
:class="{ active: demo.id === current.id }"
@click="current = demo"
>
{{ demo.title }}
</button>
</nav>
<main class="content">
<component :is="current.component" :key="current.id" />
</main>
</div>
</template>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; color: #1a1a1a; background: #fafafa; }
.layout { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
.sidebar { border-right: 1px solid #e5e5e5; padding: 1rem; background: #fff; position: sticky; top: 0; height: 100vh; }
.sidebar h1 { font-size: 14px; margin: 0 0 1rem; color: #666; }
.sidebar button { display: block; width: 100%; text-align: left; padding: 8px 10px; margin-bottom: 2px; border: 0; background: transparent; border-radius: 6px; cursor: pointer; font-size: 14px; color: #333; }
.sidebar button:hover { background: #f0f0f0; }
.sidebar button.active { background: #1a1a1a; color: #fff; }
.content { padding: 2rem; max-width: 880px; }
.content section > h2 { margin: 0 0 0.25rem; }
.hint { color: #888; font-size: 13px; margin: 0 0 1rem; }
.toolbar { display: flex; gap: 4px; align-items: center; margin-bottom: 0.75rem; }
.toolbar.wrap { flex-wrap: wrap; }
.toolbar button { min-width: 32px; height: 32px; padding: 0 8px; border: 1px solid #ddd; background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; }
.toolbar button:hover { border-color: #bbb; }
.toolbar button:disabled { opacity: 0.4; cursor: default; }
.toolbar button[data-active] { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.toolbar .sep { width: 1px; height: 20px; background: #ddd; margin: 0 4px; }
.editor { border: 1px solid #e5e5e5; border-radius: 8px; padding: 1rem 1.25rem; min-height: 120px; background: #fff; }
.editor:focus-within { border-color: #999; }
.editor.scroll { max-height: 420px; overflow: auto; }
.editor [data-block-content] { outline: none; margin: 0.4em 0; line-height: 1.6; }
.editor [data-block-content]:is(h1, h2, h3, h4, h5, h6) { margin: 0.6em 0 0.3em; line-height: 1.3; }
.editor [data-block-type='heading'] [data-block-content] { font-weight: 700; }
.editor [data-block-content][data-empty]::before { content: attr(data-placeholder); color: #bbb; pointer-events: none; }
.editor [data-block-content] strong { font-weight: 700; }
.editor [data-block-content] em { font-style: italic; }
.editor ::selection { background: #b3d4fc; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
details { margin-top: 1rem; }
summary { cursor: pointer; color: #666; font-size: 13px; }
details pre { background: #f6f6f6; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 12px; max-height: 300px; }
/* inline marks */
.editor [data-block-content] mark { background: #fde68a; border-radius: 2px; }
.editor [data-block-content] code { background: #eef0f2; padding: 0.1em 0.35em; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
.editor [data-block-content] a { color: #2563eb; text-decoration: underline; cursor: pointer; }
/* blockquote */
.editor blockquote[data-block-content] { border-left: 3px solid #ddd; padding-left: 1rem; color: #555; font-style: italic; }
/* code block */
.editor pre[data-block-content] { background: #f6f8fa; border: 1px solid #eaecef; border-radius: 6px; padding: 0.75rem 1rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; white-space: pre-wrap; }
/* callout */
.editor [data-callout] { position: relative; border-radius: 8px; margin: 0.5em 0; padding: 0.6rem 0.8rem 0.6rem 2.4rem; }
.editor [data-callout]::before { position: absolute; left: 0.8rem; }
.editor [data-callout='info'] { background: #eef4ff; } .editor [data-callout='info']::before { content: '️'; }
.editor [data-callout='warn'] { background: #fff6e6; } .editor [data-callout='warn']::before { content: '⚠️'; }
.editor [data-callout='success'] { background: #ecfdf3; } .editor [data-callout='success']::before { content: '✅'; }
/* lists (flat-with-indent; marker in the gutter, indent via inline margin-left) */
.editor { counter-reset: editor-ol; }
.editor [data-list] { position: relative; }
.editor [data-list]::before { position: absolute; left: 0.1em; color: #555; }
.editor [data-list='bullet']::before { content: '•'; }
.editor [data-list='ordered'] { counter-increment: editor-ol; }
.editor [data-list='ordered']::before { content: counter(editor-ol) '.'; }
.editor [data-list='todo']::before { content: '☐'; }
.editor [data-list='todo'][data-checked='true']::before { content: '☑'; }
.editor [data-list='todo'][data-checked='true'] { color: #999; text-decoration: line-through; }
/* atoms: image + divider */
.editor [data-editor-image] { margin: 0.8em 0; text-align: center; }
.editor [data-editor-image] img { max-width: 100%; border-radius: 8px; }
.editor [data-editor-image] figcaption { color: #888; font-size: 13px; margin-top: 4px; }
.editor [data-editor-image] .image-placeholder { background: #f3f3f3; border: 1px dashed #ccc; border-radius: 8px; padding: 1.5rem; color: #999; }
.editor [data-editor-image] .image-fields { display: flex; flex-direction: column; gap: 4px; margin: 6px auto 0; max-width: 360px; }
.editor [data-editor-image] .image-fields input { padding: 4px 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
.editor [data-editor-divider] { border: 0; border-top: 2px solid #e5e5e5; margin: 1em 0; }
/* node selection highlight */
.editor [data-block-type='image'][data-selected], .editor [data-block-type='divider'][data-selected] { outline: 2px solid #2563eb; outline-offset: 2px; border-radius: 6px; }
.editor [data-block-content][data-selected], .editor [data-block-id][data-selected]:not([data-block-type='image']):not([data-block-type='divider']) { background: rgba(37, 99, 235, 0.08); border-radius: 4px; }
/* floating menus (teleported to body) */
.editor-bubble-menu { display: flex; gap: 2px; background: #1a1a1a; border-radius: 8px; padding: 4px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); z-index: 50; }
.editor-bubble-menu button { min-width: 30px; height: 28px; padding: 0 8px; border: 0; background: transparent; color: #fff; border-radius: 5px; cursor: pointer; font-size: 13px; text-transform: capitalize; }
.editor-bubble-menu button:hover { background: rgba(255, 255, 255, 0.15); }
.editor-bubble-menu button[data-active] { background: #fff; color: #1a1a1a; }
.editor-slash-menu { background: #fff; border: 1px solid #e5e5e5; border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); width: 230px; max-height: 280px; overflow: auto; z-index: 50; }
.editor-slash-menu button { display: flex; justify-content: space-between; align-items: baseline; width: 100%; text-align: left; border: 0; background: transparent; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 14px; color: #333; }
.editor-slash-menu button[data-highlighted] { background: #f0f0f0; }
.editor-slash-menu .slash-group { font-size: 11px; color: #aaa; text-transform: capitalize; }
kbd { background: #eee; border: 1px solid #ddd; border-radius: 4px; padding: 1px 5px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* drag-to-reorder handle */
.editor [data-block-id] { position: relative; }
.editor-drag-handle { position: absolute; left: -1.2em; top: 0.25em; cursor: grab; color: #ccc; user-select: none; opacity: 0; transition: opacity 0.1s; line-height: 1.4; }
.editor [data-block-id]:hover > .editor-drag-handle { opacity: 1; }
.editor-drag-handle:hover { color: #888; }
.editor-drag-handle:active { cursor: grabbing; }
/* remote collaboration cursors */
.editor.collab { position: relative; }
.editor-remote-cursors { position: absolute; inset: 0; pointer-events: none; overflow: visible; z-index: 4; }
.editor-remote-selection { position: absolute; background: var(--cursor-color); opacity: 0.22; border-radius: 2px; }
.editor-remote-caret { position: absolute; width: 2px; background: var(--cursor-color); }
.editor-remote-caret-label { position: absolute; top: -1.05em; left: -1px; font-size: 10px; line-height: 1; white-space: nowrap; color: #fff; background: var(--cursor-color); padding: 1px 4px; border-radius: 3px 3px 3px 0; }
</style>
+34
View File
@@ -0,0 +1,34 @@
<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>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import type { RemoteCursor } from '@editor';
import {
EditorBubbleMenu,
EditorContent,
EditorRemoteCursors,
EditorRoot,
EditorSlashMenu,
bindCrdt,
createDefaultRegistry,
createDoc,
createEditor,
createEditorState,
createNativeProvider,
} from '@editor';
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 editorA = createEditor({ state: createEditorState({ registry, doc: seed }) });
const providerA = createNativeProvider({ schema: registry.schema, doc: editorA.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 providerB = createNativeProvider({ schema: registry.schema, user: { name: 'Bob', color: '#db2777' } });
const bindingA = bindCrdt(editorA, providerA);
const bindingB = bindCrdt(editorB, 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 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>
<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>
</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>
</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 {
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>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { Node } from '@editor';
import {
EditorBubbleMenu,
EditorContent,
EditorRoot,
EditorSlashMenu,
addMark,
createTransaction,
nodeSelection,
setBlockType,
toggleChecked,
toggleMark,
} from '@editor';
import { bullet, callout, code, divider, h, image, makeEditor, numbered, p, quote, t, todo } from '../lib';
const editor = makeEditor([
h(1, 'Complex blocks'),
p([t('A document with '), t('many', 'bold'), t(' block types. Put the caret in a block and use the controls to convert it, or insert media.')]),
quote('“The block is the unit of composition.” — a registry-driven editor.'),
callout('info', 'Callouts carry a variant attribute. This one is "info".'),
callout('warn', 'And this is a "warn" callout.'),
code('function hello() {\n // Enter inserts a newline here, not a block split\n return \'code block\';\n}'),
h(2, 'Lists'),
bullet('Bulleted item one'),
bullet('Nested bullet (indent = 1) — Tab / Shift+Tab changes indent', 1),
bullet('Bulleted item two'),
numbered('Numbered item (counter via CSS)'),
numbered('Numbered item'),
todo('A finished task', true),
todo('A pending task', false),
h(2, 'Media (atoms)'),
image('https://picsum.photos/seed/robonen/520/240', 'A random sample image'),
divider(),
p('Click an image or divider to select it, then Backspace/Delete removes it. Selecting an image reveals its URL / alt / caption fields.'),
]);
const rev = ref(0);
editor.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(editor.state.doc, null, 2)));
function focusIndex(): number {
const sel = editor.state.selection;
const id = sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
const index = id ? editor.state.doc.content.findIndex(block => block.id === id) : -1;
return index === -1 ? editor.state.doc.content.length - 1 : index;
}
function insertAfterFocus(node: Node): void {
editor.dispatch(createTransaction(editor.state).insertBlock(node, focusIndex() + 1).setSelection(nodeSelection([node.id])));
}
function addLink(): void {
const href = globalThis.prompt('Link URL', 'https://');
if (href)
editor.command(addMark('link', { href }));
}
</script>
<template>
<section>
<h2>Complex blocks</h2>
<p class="hint">Quote, callout, code block, lists (bulleted / numbered / to-do), image &amp; divider atoms, plus the full mark set. Everything is registry-driven.</p>
<div class="toolbar wrap">
<button @mousedown.prevent="editor.command(toggleMark('bold'))"><b>B</b></button>
<button @mousedown.prevent="editor.command(toggleMark('italic'))"><i>I</i></button>
<button @mousedown.prevent="editor.command(toggleMark('underline'))"><u>U</u></button>
<button @mousedown.prevent="editor.command(toggleMark('strike'))"><s>S</s></button>
<button @mousedown.prevent="editor.command(toggleMark('code'))">&lt;/&gt;</button>
<button @mousedown.prevent="editor.command(toggleMark('highlight'))">HL</button>
<button @mousedown.prevent="addLink">Link</button>
<span class="sep" />
<button @mousedown.prevent="editor.command(setBlockType('paragraph'))">P</button>
<button @mousedown.prevent="editor.command(setBlockType('heading', { level: 1 }))">H1</button>
<button @mousedown.prevent="editor.command(setBlockType('heading', { level: 2 }))">H2</button>
<button @mousedown.prevent="editor.command(setBlockType('blockquote'))">Quote</button>
<button @mousedown.prevent="editor.command(setBlockType('code-block'))">Code</button>
<button @mousedown.prevent="editor.command(setBlockType('callout', { variant: 'info' }))">Callout</button>
<span class="sep" />
<button @mousedown.prevent="editor.command(setBlockType('bulleted-list'))"> List</button>
<button @mousedown.prevent="editor.command(setBlockType('numbered-list'))">1. List</button>
<button @mousedown.prevent="editor.command(setBlockType('todo-list', { checked: false, indent: 0 }))"> Todo</button>
<button @mousedown.prevent="editor.command(toggleChecked)">Toggle </button>
<span class="sep" />
<button @mousedown.prevent="insertAfterFocus(image('', ''))">+ Image</button>
<button @mousedown.prevent="insertAfterFocus(divider())">+ Divider</button>
<span class="sep" />
<button @mousedown.prevent="editor.undo()">Undo</button>
<button @mousedown.prevent="editor.redo()">Redo</button>
</div>
<p class="hint">Type <kbd>/</kbd> to insert a block; select text for the bubble toolbar; hover a block and drag the <span aria-hidden="true"></span> handle to reorder. Markdown shortcuts work too: <kbd># </kbd>, <kbd>- </kbd>, <kbd>&gt; </kbd>, <kbd>1. </kbd>, <kbd>[] </kbd>.</p>
<EditorRoot :editor="editor" autofocus draggable class="editor">
<EditorContent />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { EditorRoot, createNode, createTransaction } from '@editor';
import type { Command, Keymap } from '@editor';
import { makeEditor, p } from '../lib';
import Toolbar from '../Toolbar.vue';
const editor = makeEditor([
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 :editor="editor" />
<EditorRoot :editor="editor" :keymaps="keymaps" class="editor" />
</section>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Node } from '@editor';
import { EditorRoot } from '@editor';
import { h, makeEditor, 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 editor = makeEditor(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" />
</section>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { EditorRoot } from '@editor';
import { makeEditor, p, t } from '../lib';
import Toolbar from '../Toolbar.vue';
const editor = makeEditor([
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 :editor="editor" />
<EditorRoot :editor="editor" class="editor" />
</section>
</template>
@@ -0,0 +1,25 @@
<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>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { EditorRoot } from '@editor';
import { h, makeEditor, p, t } from '../lib';
const editor = makeEditor([
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>
<EditorRoot :editor="editor" :editable="false" class="editor" />
</section>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { EditorBubbleMenu, EditorContent, EditorRoot, EditorSlashMenu } from '@editor';
import { h, makeEditor, p, t } from '../lib';
import Toolbar from '../Toolbar.vue';
const editor = makeEditor([
h(1, 'Welcome to the editor'),
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);
editor.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(editor.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 :editor="editor" />
<EditorRoot :editor="editor" autofocus class="editor">
<EditorContent />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>
+11
View File
@@ -0,0 +1,11 @@
export {};
declare global {
const __DEV__: boolean;
}
declare module 'vue' {
interface HTMLAttributes {
[key: `data-${string}`]: unknown;
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { Editor, Inline, InlineNode, Node } from '@editor';
import {
createDefaultRegistry,
createDoc,
createEditor,
createEditorState,
createNode,
} from '@editor';
/** 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 editor over the given blocks with the default registry. */
export function makeEditor(content: Node[]): Editor {
const registry = createDefaultRegistry();
return createEditor({ state: createEditorState({ registry, doc: createDoc(content) }) });
}
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "@robonen/tsconfig/tsconfig.vue.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@editor": ["../src/index.ts"],
"@editor/*": ["../src/*"]
}
},
"include": ["src", "vite.config.ts"]
}
+28
View File
@@ -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: /^@editor\/(.*)$/,
replacement: fileURLToPath(new URL('../src/$1', import.meta.url)),
},
{
find: /^@editor$/,
replacement: fileURLToPath(new URL('../src/index.ts', import.meta.url)),
},
],
},
server: {
port: 5181,
fs: {
allow: [fileURLToPath(new URL('../', import.meta.url))],
},
},
}));