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:
@@ -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>
|
||||
@@ -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 & divider atoms, plus the full mark set. Everything is registry-driven.</p>
|
||||
|
||||
<div class="toolbar wrap">
|
||||
<button @mousedown.prevent="editor.command(toggleMark('bold'))"><b>B</b></button>
|
||||
<button @mousedown.prevent="editor.command(toggleMark('italic'))"><i>I</i></button>
|
||||
<button @mousedown.prevent="editor.command(toggleMark('underline'))"><u>U</u></button>
|
||||
<button @mousedown.prevent="editor.command(toggleMark('strike'))"><s>S</s></button>
|
||||
<button @mousedown.prevent="editor.command(toggleMark('code'))"></></button>
|
||||
<button @mousedown.prevent="editor.command(toggleMark('highlight'))">HL</button>
|
||||
<button @mousedown.prevent="addLink">Link</button>
|
||||
<span class="sep" />
|
||||
<button @mousedown.prevent="editor.command(setBlockType('paragraph'))">P</button>
|
||||
<button @mousedown.prevent="editor.command(setBlockType('heading', { level: 1 }))">H1</button>
|
||||
<button @mousedown.prevent="editor.command(setBlockType('heading', { level: 2 }))">H2</button>
|
||||
<button @mousedown.prevent="editor.command(setBlockType('blockquote'))">Quote</button>
|
||||
<button @mousedown.prevent="editor.command(setBlockType('code-block'))">Code</button>
|
||||
<button @mousedown.prevent="editor.command(setBlockType('callout', { variant: 'info' }))">Callout</button>
|
||||
<span class="sep" />
|
||||
<button @mousedown.prevent="editor.command(setBlockType('bulleted-list'))">• List</button>
|
||||
<button @mousedown.prevent="editor.command(setBlockType('numbered-list'))">1. List</button>
|
||||
<button @mousedown.prevent="editor.command(setBlockType('todo-list', { checked: false, indent: 0 }))">☐ Todo</button>
|
||||
<button @mousedown.prevent="editor.command(toggleChecked)">Toggle ✓</button>
|
||||
<span class="sep" />
|
||||
<button @mousedown.prevent="insertAfterFocus(image('', ''))">+ Image</button>
|
||||
<button @mousedown.prevent="insertAfterFocus(divider())">+ Divider</button>
|
||||
<span class="sep" />
|
||||
<button @mousedown.prevent="editor.undo()">Undo</button>
|
||||
<button @mousedown.prevent="editor.redo()">Redo</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">Type <kbd>/</kbd> to insert a block; select text for the bubble toolbar; hover a block and drag the <span aria-hidden="true">⠿</span> handle to reorder. Markdown shortcuts work too: <kbd># </kbd>, <kbd>- </kbd>, <kbd>> </kbd>, <kbd>1. </kbd>, <kbd>[] </kbd>.</p>
|
||||
<EditorRoot :editor="editor" autofocus draggable class="editor">
|
||||
<EditorContent />
|
||||
<EditorBubbleMenu />
|
||||
<EditorSlashMenu />
|
||||
</EditorRoot>
|
||||
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
|
||||
</section>
|
||||
</template>
|
||||
@@ -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 & 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>
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
const __DEV__: boolean;
|
||||
}
|
||||
|
||||
declare module 'vue' {
|
||||
interface HTMLAttributes {
|
||||
[key: `data-${string}`]: unknown;
|
||||
}
|
||||
}
|
||||
@@ -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) }) });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
Reference in New Issue
Block a user