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
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import type { Attrs, Node } from '../model';
</script>
<script setup lang="ts">
import type { IntrinsicElementAttributes } from 'vue';
import { computed } from 'vue';
import { nodeSelection } from '../model';
import { createTransaction } from '../state';
import { Primitive } from './primitive';
import { useEditorContext } from './context';
import TextBlockHost from './TextBlockHost.vue';
export interface BlockViewProps {
block: Node;
}
const { block } = defineProps<BlockViewProps>();
const ctx = useEditorContext();
const def = computed(() => ctx.registry.getBlock(block.type));
const wrapperTag = computed<keyof IntrinsicElementAttributes>(() => (def.value?.as ?? 'div') as keyof IntrinsicElementAttributes);
const isText = computed(() => def.value?.spec.content.kind === 'text');
const atomComponent = computed(() => def.value?.component);
const isSelected = computed(() => {
const sel = ctx.state.value.selection;
return sel.kind === 'node' && sel.ids.includes(block.id);
});
function updateAttrs(attrs: Attrs): void {
ctx.dispatch(createTransaction(ctx.editor.state).setAttrs(block.id, attrs).setSelection(ctx.editor.state.selection));
}
/** Clicking an atom block selects it as a node (so Backspace/Delete remove it). */
function onMousedown(event: MouseEvent): void {
if (isText.value)
return;
// Don't hijack interactive controls inside the atom (e.g. image fields).
if ((event.target as HTMLElement).closest('input, textarea, button, a, select'))
return;
event.preventDefault();
ctx.dispatch(createTransaction(ctx.editor.state).setSelection(nodeSelection([block.id])));
ctx.contentRoot.value?.focus({ preventScroll: true });
}
const DND_TYPE = 'application/x-robonen-editor-block';
function onDragStart(event: DragEvent): void {
event.dataTransfer?.setData(DND_TYPE, block.id);
if (event.dataTransfer)
event.dataTransfer.effectAllowed = 'move';
}
function onDragOver(event: DragEvent): void {
if (event.dataTransfer?.types.includes(DND_TYPE))
event.preventDefault(); // allow drop
}
function onDrop(event: DragEvent): void {
const draggedId = event.dataTransfer?.getData(DND_TYPE);
if (!draggedId || draggedId === block.id)
return;
event.preventDefault();
const toIndex = ctx.editor.state.doc.content.findIndex(candidate => candidate.id === block.id);
if (toIndex !== -1)
ctx.dispatch(createTransaction(ctx.editor.state).moveBlock(draggedId, toIndex).setSelection(ctx.editor.state.selection));
}
</script>
<template>
<Primitive
:as="wrapperTag"
:data-block-id="block.id"
:data-block-type="block.type"
:data-selected="isSelected ? '' : undefined"
:data-draggable="ctx.config.draggable ? '' : undefined"
:contenteditable="isText ? undefined : 'false'"
@mousedown="onMousedown"
@dragover="onDragOver"
@drop="onDrop"
>
<span
v-if="ctx.config.draggable"
class="editor-drag-handle"
data-editor-drag-handle=""
contenteditable="false"
draggable="true"
aria-label="Drag to reorder"
@mousedown.stop
@dragstart="onDragStart"
></span>
<TextBlockHost
v-if="isText && def"
:block="block"
:definition="def"
/>
<component
:is="atomComponent || 'div'"
v-else-if="atomComponent"
:node="block"
:selected="isSelected"
:editable="ctx.config.editable"
:update="updateAttrs"
/>
</Primitive>
</template>
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import type { PrimitiveProps } from './primitive';
</script>
<script setup lang="ts">
import { blockById, caret, inlineLength, isCollapsed, nodeInline } from '../model';
import { applyInputRule, deleteSelection, insertHardBreak, joinBackward, joinForward, splitBlock } from '../commands';
import { createTransaction } from '../state';
import { Primitive } from './primitive';
import { useEditorContext } from './context';
import { isInteractiveTarget } from './interactive';
import { parseRuns } from './inline-content';
import BlockView from './BlockView.vue';
export interface EditorContentProps extends PrimitiveProps {}
const { as = 'div' } = defineProps<EditorContentProps>();
const ctx = useEditorContext();
function setContentRoot(el: unknown): void {
ctx.contentRoot.value = (el as HTMLElement | null) ?? null;
}
/**
* Intercept content mutations that the browser would handle incorrectly across
* the single editable root: ranged edits (which could corrupt cross-block DOM)
* and structural edits at block boundaries. Plain intra-block typing/deletion is
* left to the browser and synced from the DOM on `input`.
*/
function onBeforeInput(event: InputEvent): void {
if (ctx.composing.value || isInteractiveTarget(event.target))
return;
const type = event.inputType;
if (!type.startsWith('insert') && !type.startsWith('delete'))
return;
const sel = ctx.selection.read();
if (!sel || sel.kind !== 'text')
return;
// Ranged selection — we own it so cross-block deletes/inserts stay consistent.
if (!isCollapsed(sel)) {
event.preventDefault();
ctx.editor.command(deleteSelection);
const after = ctx.editor.state.selection;
if (after.kind !== 'text')
return;
if ((type === 'insertText' || type === 'insertReplacementText' || type === 'insertCompositionText') && event.data) {
ctx.editor.dispatch(createTransaction(ctx.editor.state)
.insertText(after.focus, event.data, ctx.editor.state.storedMarks ?? [])
.setSelection(caret(after.focus.blockId, after.focus.offset + event.data.length)));
}
else if (type === 'insertParagraph') {
ctx.editor.command(splitBlock);
}
else if (type === 'insertLineBreak') {
ctx.editor.command(insertHardBreak);
}
return;
}
// Collapsed — take over only structural edits and block-boundary deletions.
const block = blockById(ctx.editor.state.doc, sel.focus.blockId);
const atStart = sel.focus.offset === 0;
const atEnd = block ? sel.focus.offset === inlineLength(nodeInline(block)) : false;
switch (type) {
case 'insertParagraph':
event.preventDefault();
ctx.editor.command(splitBlock);
break;
case 'insertLineBreak':
event.preventDefault();
ctx.editor.command(insertHardBreak);
break;
case 'deleteContentBackward':
if (atStart) {
event.preventDefault();
ctx.editor.command(joinBackward);
}
break; // else: native deletes within the block, synced on `input`
case 'deleteContentForward':
if (atEnd) {
event.preventDefault();
ctx.editor.command(joinForward);
}
break;
default:
break; // insertText etc. → native, synced on `input`
}
}
/** Sync the model from the DOM after a native intra-block edit (one block only). */
function onInput(event?: Event): void {
if (ctx.composing.value || (event && isInteractiveTarget(event.target)))
return;
const sel = ctx.selection.read();
if (!sel || sel.kind !== 'text')
return;
const host = ctx.blockElements.get(sel.focus.blockId);
const block = blockById(ctx.editor.state.doc, sel.focus.blockId);
if (!host || !block || ctx.editor.state.schema.nodeSpec(block.type)?.content.kind !== 'text')
return;
const runs = parseRuns(host, ctx.registry);
ctx.editor.dispatch(createTransaction(ctx.editor.state)
.setBlockContent(sel.focus.blockId, runs)
.setSelection(sel)
.setMeta('origin', sel.focus.blockId)); // suppress repaint of the block we just typed in
// Markdown-style shortcuts: '# ' → heading, '- ' → list, '> ' → quote, …
ctx.editor.command(applyInputRule);
}
function onCompositionStart(event: CompositionEvent): void {
if (isInteractiveTarget(event.target))
return;
ctx.composing.value = true;
}
function onCompositionEnd(event: CompositionEvent): void {
if (isInteractiveTarget(event.target))
return;
ctx.composing.value = false;
onInput();
}
</script>
<template>
<Primitive
:ref="setContentRoot"
:as="as"
role="textbox"
aria-multiline="true"
:aria-readonly="!ctx.config.editable || undefined"
data-editor-content=""
:contenteditable="ctx.config.editable ? 'true' : 'false'"
:spellcheck="ctx.config.spellcheck"
@beforeinput="onBeforeInput"
@input="onInput"
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
>
<BlockView
v-for="block in ctx.state.value.doc.content"
:key="block.id"
:block="block"
/>
</Primitive>
</template>
+168
View File
@@ -0,0 +1,168 @@
<script lang="ts">
import type { PrimitiveProps } from './primitive';
import type { Keymap } from '../keymap';
import type { Command, CommandView, Editor, EditorState, Transaction } from '../state';
import type { Platform } from './config';
</script>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, shallowRef } from 'vue';
import { blockById, caret, inlineLength, nodeInline, selectionEq } from '../model';
import { compileKeymaps, defaultKeymap, runKeydown } from '../keymap';
import { createTransaction } from '../state';
import { Primitive } from './primitive';
import { provideEditorContext } from './context';
import { resolveConfig } from './config';
import { createBlockElementRegistry, createSelectionBridge } from './selection';
import { useEventListener } from './composables';
import { isInteractiveTarget } from './interactive';
import EditorContent from './EditorContent.vue';
export interface EditorRootProps extends PrimitiveProps {
/** The headless controller (create with `createEditor(createEditorState(...))`). */
editor: Editor;
/** User keymaps, merged over the defaults (earlier wins). */
keymaps?: Keymap[];
editable?: boolean;
dir?: 'ltr' | 'rtl';
spellcheck?: boolean;
platform?: Platform;
/** Show per-block drag handles for reordering. */
draggable?: boolean;
/** Focus the start of the document on mount. */
autofocus?: boolean;
}
const {
editor,
keymaps,
as = 'div',
editable = true,
dir = 'ltr',
spellcheck = true,
platform,
draggable = false,
autofocus = false,
} = defineProps<EditorRootProps>();
const root = ref<HTMLElement | null>(null);
const contentRoot = shallowRef<HTMLElement | null>(null);
const state = shallowRef<EditorState>(editor.state);
const composing = ref(false);
const lastOrigin = ref<string | undefined>(undefined);
const blockElements = createBlockElementRegistry();
const selection = createSelectionBridge(() => contentRoot.value, blockElements);
const config = resolveConfig({ editable, dir, spellcheck, platform, draggable });
const compiled = compileKeymaps([...(keymaps ?? []), defaultKeymap(editor)], config.platform);
let suppressSelectionSync = false;
function focusBlock(blockId: string, offset: number | 'start' | 'end'): void {
const block = blockById(editor.state.doc, blockId);
const length = block ? inlineLength(nodeInline(block)) : 0;
const resolved = offset === 'start' ? 0 : offset === 'end' ? length : offset;
selection.write(caret(blockId, resolved));
}
const view: CommandView = { focusBlock };
provideEditorContext({
editor,
state,
registry: editor.state.registry,
config,
contentRoot,
blockElements,
selection,
composing,
lastOrigin,
dispatch: editor.dispatch,
exec: (command: Command) => editor.command(command),
focusBlock,
});
/** After a transaction, reflect the model selection back into the DOM caret. */
function reconcileSelection(): void {
if (composing.value)
return;
// The user is editing an atom's control (e.g. an image caption input); writing
// the model selection here would focus the editor and yank focus out of it.
if (typeof document !== 'undefined' && isInteractiveTarget(document.activeElement))
return;
const current = selection.read();
if (current && selectionEq(current, editor.state.selection))
return;
suppressSelectionSync = true;
selection.write(editor.state.selection);
nextTick(() => {
suppressSelectionSync = false;
});
}
function onTransaction(tr: Transaction, next: EditorState): void {
state.value = next;
lastOrigin.value = tr.getMeta('origin') as string | undefined;
// Defer to nextTick so block content re-renders (TextBlockHost) run first.
nextTick(reconcileSelection);
}
editor.on('transaction', onTransaction);
onBeforeUnmount(() => editor.off('transaction', onTransaction));
function onKeydown(event: KeyboardEvent): void {
if (composing.value || event.isComposing || !editable || isInteractiveTarget(event.target))
return; // let atom controls (e.g. image caption inputs) handle their own keys
if (runKeydown(event, compiled, editor.state, editor.dispatch, view)) {
event.preventDefault();
event.stopPropagation();
}
}
function onSelectionChange(): void {
if (composing.value || suppressSelectionSync)
return;
// Ignore selection changes that belong to an atom's own control (e.g. an image
// caption input) — reading/writing them would steal focus back into the editor.
if (typeof document !== 'undefined' && isInteractiveTarget(document.activeElement))
return;
const sel = selection.read();
if (!sel || selectionEq(sel, editor.state.selection))
return;
editor.dispatch(createTransaction(editor.state).setSelection(sel).setMeta('selectionOnly', true));
}
useEventListener(root, 'keydown', onKeydown as (event: Event) => void, { capture: true });
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', onSelectionChange);
if (autofocus) {
nextTick(() => {
const first = editor.state.doc.content[0];
if (first)
focusBlock(first.id, 'start');
});
}
function setRoot(el: unknown): void {
root.value = (el as HTMLElement | null) ?? null;
}
</script>
<template>
<Primitive
:ref="setRoot"
:as="as"
role="group"
data-editor-root=""
:data-editable="editable ? '' : undefined"
:dir="dir"
>
<slot><EditorContent /></slot>
</Primitive>
</template>
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
import type { Node } from '../model';
import type { BlockDefinition } from '../registry';
</script>
<script setup lang="ts">
import type { IntrinsicElementAttributes } from 'vue';
import { computed, onBeforeUnmount, watch } from 'vue';
import { inlineLength, nodeInline } from '../model';
import { Primitive } from './primitive';
import { useEditorContext } from './context';
import { renderRuns } from './inline-content';
export interface TextBlockHostProps {
block: Node;
definition: BlockDefinition;
}
const { block, definition } = defineProps<TextBlockHostProps>();
const ctx = useEditorContext();
let hostEl: HTMLElement | null = null;
/** The element's tag — derived from the block's `toDOM` (h1, p, …). */
const tag = computed<keyof IntrinsicElementAttributes>(() => {
const out = definition.spec.toDOM?.(block);
if (typeof out === 'string')
return out as keyof IntrinsicElementAttributes;
if (Array.isArray(out) && typeof out[0] === 'string')
return out[0] as keyof IntrinsicElementAttributes;
return (definition.as ?? 'div') as keyof IntrinsicElementAttributes;
});
const isEmpty = computed(() => inlineLength(nodeInline(block)) === 0);
/** Element attributes contributed by the block's `toDOM` (e.g. callout/list styling). */
const hostAttrs = computed<Record<string, string>>(() => {
const out = definition.spec.toDOM?.(block);
if (Array.isArray(out) && out.length > 1) {
const second = out[1];
if (second && typeof second === 'object' && !Array.isArray(second))
return second as Record<string, string>;
}
return {};
});
/**
* Function ref: fires when the element is created or replaced (e.g. a heading
* level change swaps the tag). Registers the element and paints its inline
* content imperatively. The element is NOT itself contenteditable — the single
* editable root is the ancestor EditorContent — but Vue must never diff this
* inner DOM, which is what keeps the caret stable.
*/
function setHost(el: unknown): void {
const node = (el as HTMLElement | null) ?? null;
hostEl = node;
if (node) {
ctx.blockElements.set(block.id, node);
renderRuns(node, nodeInline(block), ctx.registry);
}
}
onBeforeUnmount(() => ctx.blockElements.delete(block.id));
// Re-paint only for foreign changes (undo/redo, commands, remote, another block)
// — never for our own keystrokes (origin === block.id), where the DOM is current.
watch(
() => block.content,
() => {
if (ctx.composing.value || ctx.lastOrigin.value === block.id || !hostEl)
return;
renderRuns(hostEl, nodeInline(block), ctx.registry);
},
{ flush: 'post' },
);
</script>
<template>
<Primitive
:ref="setHost"
:as="tag"
v-bind="hostAttrs"
data-block-content=""
:data-block-id="block.id"
:data-empty="isEmpty ? '' : undefined"
:data-placeholder="definition.placeholder"
/>
</template>
@@ -0,0 +1,133 @@
import { render } from 'vitest-browser-vue';
import { describe, expect, it } from 'vitest';
import { nextTick } from 'vue';
import { createDoc, createNode, textSelection } from '../../model';
import { createDefaultRegistry } from '../../preset';
import { createEditor, createEditorState, createTransaction } from '../../state';
import EditorRoot from '../EditorRoot.vue';
function para(id: string, text: string) {
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
}
function mount(blocks: Array<ReturnType<typeof para>>) {
const registry = createDefaultRegistry();
const editor = createEditor({ state: createEditorState({ registry, doc: createDoc(blocks) }) });
render(EditorRoot, { props: { editor, platform: 'mac' } });
return editor;
}
function selectNative(anchor: { node: Node; offset: number }, focus: { node: Node; offset: number }) {
const sel = getSelection()!;
sel.removeAllRanges();
const range = document.createRange();
range.setStart(anchor.node, anchor.offset);
range.setEnd(focus.node, focus.offset);
sel.addRange(range);
}
describe('EditorRoot (single contenteditable)', () => {
it('renders ONE editable root containing non-editable block elements', async () => {
mount([para('a', 'hello')]);
await nextTick();
const ce = document.querySelector('[data-editor-content]')!;
expect(ce.getAttribute('contenteditable')).toBe('true');
const host = document.querySelector('[data-block-content]') as HTMLElement;
expect(host.textContent).toBe('hello');
// The block element itself is NOT a separate editing host.
expect(host.getAttribute('contenteditable')).toBeNull();
});
it('maps a cross-block native selection to a cross-block model range', async () => {
const editor = mount([para('a', 'hello'), para('b', 'world')]);
await nextTick();
const hosts = document.querySelectorAll('[data-block-content]');
const aText = hosts[0]!.firstChild!; // text node "hello"
const bText = hosts[1]!.firstChild!; // text node "world"
selectNative({ node: aText, offset: 1 }, { node: bText, offset: 3 });
await nextTick();
await nextTick();
const sel = editor.state.selection;
expect(sel.kind).toBe('text');
if (sel.kind === 'text') {
expect(sel.anchor.blockId).toBe('a');
expect(sel.anchor.offset).toBe(1);
expect(sel.focus.blockId).toBe('b');
expect(sel.focus.offset).toBe(3);
}
});
it('writes a cross-block model selection back to a native range spanning blocks', async () => {
const editor = mount([para('a', 'hello'), para('b', 'world')]);
await nextTick();
editor.dispatch(createTransaction(editor.state).setSelection(
textSelection({ blockId: 'a', offset: 2 }, { blockId: 'b', offset: 4 }),
));
await nextTick();
await nextTick();
const sel = getSelection()!;
const hosts = document.querySelectorAll('[data-block-content]');
expect(hosts[0]!.contains(sel.anchorNode)).toBe(true);
expect(hosts[1]!.contains(sel.focusNode)).toBe(true);
expect(sel.isCollapsed).toBe(false);
});
it('applies bold via Mod-b to a selected range', async () => {
const editor = mount([para('a', 'hello')]);
await nextTick();
editor.dispatch(createTransaction(editor.state).setSelection(
textSelection({ blockId: 'a', offset: 0 }, { blockId: 'a', offset: 5 }),
));
await nextTick();
const root = document.querySelector<HTMLElement>('[data-editor-root]')!;
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true }));
await nextTick();
await nextTick();
expect(document.querySelector('[data-block-content] strong')?.textContent).toBe('hello');
});
it('splits a block on Enter', async () => {
const editor = mount([para('a', 'hello')]);
await nextTick();
editor.dispatch(createTransaction(editor.state).setSelection(textSelection({ blockId: 'a', offset: 2 })));
await nextTick();
const root = document.querySelector<HTMLElement>('[data-editor-root]')!;
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await nextTick();
await nextTick();
const hosts = document.querySelectorAll('[data-block-content]');
expect(hosts.length).toBe(2);
expect(hosts[0]!.textContent).toBe('he');
expect(hosts[1]!.textContent).toBe('llo');
});
it('merges into the previous block on Backspace at block start', async () => {
const editor = mount([para('a', 'foo'), para('b', 'bar')]);
await nextTick();
editor.dispatch(createTransaction(editor.state).setSelection(textSelection({ blockId: 'b', offset: 0 })));
await nextTick();
const root = document.querySelector<HTMLElement>('[data-editor-root]')!;
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true }));
await nextTick();
await nextTick();
const hosts = document.querySelectorAll('[data-block-content]');
expect(hosts.length).toBe(1);
expect(hosts[0]!.textContent).toBe('foobar');
});
});
+2
View File
@@ -0,0 +1,2 @@
export { useContextFactory } from './useContextFactory';
export { useEventListener } from './useEventListener';
@@ -0,0 +1,31 @@
import type { App, InjectionKey } from 'vue';
import { inject as vueInject, provide as vueProvide } from 'vue';
/**
* Factory for a strongly-typed provide/inject pair keyed by a unique Symbol.
* Local copy of the `@robonen/vue` helper so the editor stays self-contained.
*/
export function useContextFactory<ContextValue>(name: string) {
const injectionKey: InjectionKey<ContextValue> = Symbol(name);
const inject = <Fallback extends ContextValue = ContextValue>(fallback?: Fallback) => {
const context = vueInject(injectionKey, fallback);
if (context !== undefined)
return context;
throw new Error(`useContextFactory: '${name}' context is not provided`);
};
const provide = (context: ContextValue) => {
vueProvide(injectionKey, context);
return context;
};
const appProvide = (app: App) => (context: ContextValue) => {
app.provide(injectionKey, context);
return context;
};
return { inject, provide, appProvide, key: injectionKey };
}
@@ -0,0 +1,38 @@
import type { MaybeRefOrGetter } from 'vue';
import { onScopeDispose, toValue, watch } from 'vue';
type ListenerTarget = Window | Document | HTMLElement | null | undefined;
/**
* Attach an event listener to a (possibly reactive) target, re-binding when the
* target changes and cleaning up on scope dispose. Minimal local replacement for
* the `@robonen/vue` composable.
*/
export function useEventListener(
target: MaybeRefOrGetter<ListenerTarget>,
event: string,
handler: (event: Event) => void,
options?: boolean | AddEventListenerOptions,
): () => void {
let detach = (): void => {};
const stopWatch = watch(
() => toValue(target),
(el) => {
detach();
if (!el)
return;
el.addEventListener(event, handler, options);
detach = () => el.removeEventListener(event, handler, options);
},
{ immediate: true, flush: 'post' },
);
const stop = (): void => {
detach();
stopWatch();
};
onScopeDispose(stop);
return stop;
}
+35
View File
@@ -0,0 +1,35 @@
export type Platform = 'mac' | 'other';
/** Editor-wide configuration provided through the editor context. */
export interface EditorConfig {
/** Whether content is editable (false renders read-only). */
editable: boolean;
/** Platform for keybinding normalization (`Mod` → Cmd/Ctrl). */
platform: Platform;
/** Text direction. */
dir: 'ltr' | 'rtl';
/** Native spellcheck on the contenteditable hosts. */
spellcheck: boolean;
/** Show per-block drag handles for reordering. */
draggable: boolean;
}
/** Detect the platform from the user agent (defaults to `'other'` off-browser). */
export function detectPlatform(): Platform {
if (typeof navigator === 'undefined')
return 'other';
const probe = navigator.userAgent || '';
return /Mac|iPhone|iPad|iPod/.test(probe) ? 'mac' : 'other';
}
/** Build a config with sensible defaults. */
export function resolveConfig(partial?: Partial<EditorConfig>): EditorConfig {
return {
editable: partial?.editable ?? true,
platform: partial?.platform ?? detectPlatform(),
dir: partial?.dir ?? 'ltr',
spellcheck: partial?.spellcheck ?? true,
draggable: partial?.draggable ?? false,
};
}
+36
View File
@@ -0,0 +1,36 @@
import type { Ref, ShallowRef } from 'vue';
import type { Registry } from '../registry';
import { useContextFactory } from './composables';
import type { Command, Dispatch, Editor, EditorState } from '../state';
import type { EditorConfig } from './config';
import type { BlockElementRegistry, SelectionBridge } from './selection';
/** Everything child components and the input/selection plumbing need. */
export interface EditorContextValue {
/** The headless controller. */
editor: Editor;
/** Reactive mirror of `editor.state`, replaced wholesale per transaction. */
state: ShallowRef<EditorState>;
registry: Registry;
config: EditorConfig;
/** The single contenteditable root element (set by EditorContent). */
contentRoot: ShallowRef<HTMLElement | null>;
/** Block id → its (non-editable) block-content element. */
blockElements: BlockElementRegistry;
/** DOM ↔ model selection mapping. */
selection: SelectionBridge;
/** True while an IME composition is in flight (suppresses model sync). */
composing: Ref<boolean>;
/** Origin (`meta('origin')`) of the most recent transaction, if any. */
lastOrigin: Ref<string | undefined>;
dispatch: Dispatch;
/** Run a command against the current state. */
exec: (command: Command) => boolean;
/** Move real DOM focus + caret into a block. */
focusBlock: (blockId: string, offset: number | 'start' | 'end') => void;
}
export const {
inject: useEditorContext,
provide: provideEditorContext,
} = useContextFactory<EditorContextValue>('EditorContext');
+15
View File
@@ -0,0 +1,15 @@
export * from './primitive';
export * from './config';
export * from './context';
export * from './inline-content';
export * from './selection';
export * from './ui';
export { default as EditorRoot } from './EditorRoot.vue';
export type { EditorRootProps } from './EditorRoot.vue';
export { default as EditorContent } from './EditorContent.vue';
export type { EditorContentProps } from './EditorContent.vue';
export { default as BlockView } from './BlockView.vue';
export type { BlockViewProps } from './BlockView.vue';
export { default as TextBlockHost } from './TextBlockHost.vue';
export type { TextBlockHostProps } from './TextBlockHost.vue';
@@ -0,0 +1,2 @@
export { renderRuns, FILLER_ATTR } from './render';
export { parseRuns } from './parse';
@@ -0,0 +1,67 @@
import type { Inline, InlineNode, Mark } from '../../model';
import { normalizeInline, normalizeMarks } from '../../model';
import type { Registry } from '../../registry';
import { FILLER_ATTR } from './render';
// Zero-width space, built without embedding the literal character in source.
const ZWSP = new RegExp(String.fromCharCode(0x200B), 'g');
/** Marks contributed by a single element, via each mark's `parseDOM` rules. */
function marksForElement(el: HTMLElement, registry: Registry): Mark[] {
const marks: Mark[] = [];
for (const def of registry.allMarks()) {
for (const rule of def.spec.parseDOM) {
if (!rule.tag || !el.matches(rule.tag))
continue;
let attrs = rule.attrs;
if (rule.getAttrs) {
const got = rule.getAttrs(el);
if (got === false || got === null)
continue;
attrs = { ...(rule.attrs ?? {}), ...got };
}
marks.push(attrs && Object.keys(attrs).length > 0 ? { type: def.type, attrs } : { type: def.type });
break; // first matching rule wins for this mark
}
}
return marks;
}
function walk(node: Node, marks: readonly Mark[], out: InlineNode[], registry: Registry): void {
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
const text = (child.nodeValue ?? '').replace(ZWSP, '');
if (text)
out.push({ text, marks });
continue;
}
if (child.nodeType !== Node.ELEMENT_NODE)
continue;
const el = child as HTMLElement;
if (el.tagName === 'BR') {
if (!el.hasAttribute(FILLER_ATTR))
out.push({ text: '\n', marks }); // hard break
continue;
}
walk(el, normalizeMarks([...marks, ...marksForElement(el, registry)]), out, registry);
}
}
/**
* Parse a contenteditable host (or any DOM subtree, e.g. pasted HTML) back into
* normalized inline runs, resolving marks from the registry's `parseDOM` rules.
*/
export function parseRuns(host: HTMLElement, registry: Registry): Inline {
const out: InlineNode[] = [];
walk(host, [], out, registry);
return normalizeInline(out);
}
@@ -0,0 +1,90 @@
import type { Inline, Mark } from '../../model';
import type { Registry } from '../../registry';
import type { DOMOutputSpec } from '../../schema';
/** Attribute marking the filler `<br>` of an empty block (not a real newline). */
export const FILLER_ATTR = 'data-editor-br-filler';
function markRank(registry: Registry, mark: Mark): number {
return registry.getMark(mark.type)?.spec.rank ?? 0;
}
function isAttrsObject(value: unknown): value is Record<string, string> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Realize a mark's `toDOM` spec into a wrapper element (content appended later). */
function createWrapper(spec: DOMOutputSpec, markType: string): HTMLElement {
if (typeof spec === 'string') {
const el = document.createElement(spec);
el.setAttribute('data-mark', markType);
return el;
}
const [tag, ...rest] = spec as readonly unknown[];
const el = document.createElement(typeof tag === 'string' ? tag : 'span');
if (rest.length > 0 && isAttrsObject(rest[0])) {
for (const [key, value] of Object.entries(rest[0]))
el.setAttribute(key, String(value));
}
el.setAttribute('data-mark', markType);
return el;
}
/** Build the innermost content for a run, turning `\n` into hard-break `<br>`. */
function buildInner(text: string): DocumentFragment {
const frag = document.createDocumentFragment();
const segments = text.split('\n');
segments.forEach((segment, index) => {
if (index > 0)
frag.appendChild(document.createElement('br'));
if (segment.length > 0)
frag.appendChild(document.createTextNode(segment));
});
return frag;
}
function wrapWithMarks(inner: Node, marks: readonly Mark[], registry: Registry): Node {
let node = inner;
for (let i = marks.length - 1; i >= 0; i--) {
const def = registry.getMark(marks[i]!.type);
if (!def)
continue;
const wrapper = createWrapper(def.spec.toDOM(marks[i]!), marks[i]!.type);
wrapper.appendChild(node);
node = wrapper;
}
return node;
}
/**
* Render inline content into a contenteditable host imperatively (never via
* Vue's template diff, which would fight the caret). Marks nest by `rank`
* (lower = outer) for stable, deterministic output. An empty block gets a single
* filler `<br>` so it has height and a caret target.
*/
export function renderRuns(host: HTMLElement, inline: Inline, registry: Registry): void {
const frag = document.createDocumentFragment();
let total = 0;
for (const run of inline) {
total += run.text.length;
const marks = [...run.marks].sort((a, b) => markRank(registry, a) - markRank(registry, b));
frag.appendChild(wrapWithMarks(buildInner(run.text), marks, registry));
}
if (total === 0) {
const filler = document.createElement('br');
filler.setAttribute(FILLER_ATTR, '');
frag.appendChild(filler);
}
host.replaceChildren(frag);
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Whether a node is (inside) an atom's interactive control — a form field or a
* `contenteditable="false"` island. Events from these must NOT be treated as
* editor input: e.g. typing in an image's caption `<input>` bubbles up to the
* single contenteditable, and without this guard the editor would re-sync a text
* block and yank the caret to the start of the document.
*/
export function isInteractiveTarget(node: EventTarget | null): boolean {
return node instanceof Element
&& node.closest('input, textarea, select, button, [contenteditable="false"]') !== null;
}
@@ -0,0 +1,30 @@
import type { AllowedComponentProps, Component, IntrinsicElementAttributes, SetupContext, VNodeProps } from 'vue';
import { h } from 'vue';
import { renderSlotChild } from './Slot';
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
export interface PrimitiveProps {
as?: keyof IntrinsicElementAttributes | Component;
}
/**
* Polymorphic element renderer: renders `as` (a tag or component), or the single
* slotted child when `as === 'template'`. Local copy of the primitives helper.
*/
export function Primitive(props: PrimitiveProps & VNodeProps & AllowedComponentProps & Record<string, unknown>, ctx: FunctionalComponentContext) {
const as = props.as;
return as === 'template'
? renderSlotChild(ctx.slots, ctx.attrs)
: h(as!, ctx.attrs, ctx.slots);
}
Primitive.inheritAttrs = false;
Primitive.props = {
as: {
type: [String, Object],
default: 'div' as const,
},
};
+50
View File
@@ -0,0 +1,50 @@
import type { SetupContext, Slots, VNode } from 'vue';
import { Comment, Fragment, cloneVNode, warn } from 'vue';
import { getRawChildren } from './getRawChildren';
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
/**
* Renders a single child from the provided default slot, applying attrs to it.
* Shared between `<Slot>` and `<Primitive as="template">`.
*
* @param slots - Component slots
* @param attrs - Attrs to apply to the slotted child
* @returns Cloned VNode with merged attrs or null
*/
export function renderSlotChild(slots: Slots, attrs: Record<string, unknown>): VNode | null {
if (!slots.default) return null;
const raw = slots.default();
if (raw.length === 1) {
const only = raw[0] as VNode;
const t = only.type;
if (t !== Fragment && t !== Comment)
return cloneVNode(only, attrs, true);
}
const children = getRawChildren(raw);
if (!children.length) return null;
if (__DEV__ && children.length > 1) {
warn('<Slot> can only be used on a single element or component.');
}
return cloneVNode(children[0]!, attrs, true);
}
/**
* A component that renders a single child from its default slot, applying the
* provided attributes to it.
*
* @param _ - Props (unused)
* @param context - Setup context containing slots and attrs
* @returns Cloned VNode with merged attrs or null
*/
export function Slot(_: Record<string, unknown>, { slots, attrs }: FunctionalComponentContext) {
return renderSlotChild(slots, attrs);
}
Slot.inheritAttrs = false;
@@ -0,0 +1,43 @@
import type { VNode } from 'vue';
import { Comment, Fragment } from 'vue';
import { PatchFlags } from '@vue/shared';
/**
* Recursively extracts and flattens VNodes from potentially nested Fragments
* while filtering out Comment nodes. Local copy of the primitives helper to keep
* `@robonen/editor` self-contained.
*
* @param children - Array of VNodes to process
* @returns Flattened array of non-Comment VNodes
*/
export function getRawChildren(children: VNode[]): VNode[] {
const result: VNode[] = [];
flatten(children, result);
return result;
}
function flatten(children: VNode[], result: VNode[]): void {
let keyedFragmentCount = 0;
const startIdx = result.length;
for (let i = 0, len = children.length; i < len; i++) {
const child = children[i]!;
if (child.type === Fragment) {
if (child.patchFlag & PatchFlags.KEYED_FRAGMENT) {
keyedFragmentCount++;
}
flatten(child.children as VNode[], result);
}
else if (child.type !== Comment) {
result.push(child);
}
}
if (keyedFragmentCount > 1) {
for (let i = startIdx; i < result.length; i++) {
result[i]!.patchFlag = PatchFlags.BAIL;
}
}
}
+4
View File
@@ -0,0 +1,4 @@
export { Primitive } from './Primitive';
export type { PrimitiveProps } from './Primitive';
export { Slot, renderSlotChild } from './Slot';
export { getRawChildren } from './getRawChildren';
@@ -0,0 +1,25 @@
/** Maps block ids to their contenteditable host elements for selection/focus. */
export interface BlockElementRegistry {
set: (blockId: string, el: HTMLElement) => void;
delete: (blockId: string) => void;
get: (blockId: string) => HTMLElement | undefined;
}
export function createBlockElementRegistry(): BlockElementRegistry {
const map = new Map<string, HTMLElement>();
return {
set: (blockId, el) => void map.set(blockId, el),
delete: blockId => void map.delete(blockId),
get: blockId => map.get(blockId),
};
}
/** The nearest contenteditable block host containing `node`, or `null`. */
export function closestBlockHost(node: Node | null): HTMLElement | null {
if (!node)
return null;
const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement;
return el?.closest<HTMLElement>('[data-block-content]') ?? null;
}
+2
View File
@@ -0,0 +1,2 @@
export * from './block-host';
export * from './selection-bridge';
@@ -0,0 +1,195 @@
import type { Selection } from '../../model';
import { textSelection } from '../../model';
import { FILLER_ATTR } from '../inline-content';
import type { BlockElementRegistry } from './block-host';
import { closestBlockHost } from './block-host';
/** Maps the native `Selection`/`Range` (over the single editable root) to model coordinates and back. */
export interface SelectionBridge {
/** Read the native selection as a model selection (null if outside editor). */
read: () => Selection | null;
/** Apply a model selection to the native selection (focusing the root). */
write: (selection: Selection) => void;
/** Snapshot the current model selection. */
save: () => Selection | null;
/** Restore a previously saved selection. */
restore: (selection: Selection | null) => void;
domPointToOffset: (host: HTMLElement, node: Node, offset: number) => number;
offsetToDomPoint: (host: HTMLElement, offset: number) => { node: Node; offset: number };
}
function isFillerBr(node: Node): boolean {
return node.nodeType === Node.ELEMENT_NODE
&& (node as HTMLElement).tagName === 'BR'
&& (node as HTMLElement).hasAttribute(FILLER_ATTR);
}
/** Count model characters in a DOM subtree: text length + 1 per hard-break. */
function measureLength(node: Node): number {
let length = 0;
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
length += (child.nodeValue ?? '').length;
}
else if (child.nodeType === Node.ELEMENT_NODE) {
if ((child as HTMLElement).tagName === 'BR')
length += isFillerBr(child) ? 0 : 1;
else
length += measureLength(child);
}
}
return length;
}
function indexInParent(el: Node): number {
return el.parentNode ? Array.from(el.parentNode.childNodes).indexOf(el as ChildNode) : 0;
}
function getWindow(): Window | null {
return typeof globalThis.window === 'undefined' ? null : globalThis.window;
}
export function createSelectionBridge(
getRoot: () => HTMLElement | null,
blockElements: BlockElementRegistry,
): SelectionBridge {
/** DOM point → model character offset within one block-content element. */
function domPointToOffset(host: HTMLElement, node: Node, offset: number): number {
const range = host.ownerDocument.createRange();
range.selectNodeContents(host);
try {
range.setEnd(node, offset);
}
catch {
return measureLength(host);
}
return measureLength(range.cloneContents());
}
/** Model character offset → DOM point within one block-content element. */
function offsetToDomPoint(host: HTMLElement, offset: number): { node: Node; offset: number } {
let remaining = offset;
function search(node: Node): { node: Node; offset: number } | null {
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
const length = (child.nodeValue ?? '').length;
if (remaining <= length)
return { node: child, offset: remaining };
remaining -= length;
}
else if (child.nodeType === Node.ELEMENT_NODE) {
const el = child as HTMLElement;
if (el.tagName === 'BR') {
if (isFillerBr(el))
continue;
if (remaining === 0)
return { node: el.parentNode!, offset: indexInParent(el) };
remaining -= 1;
if (remaining === 0)
return { node: el.parentNode!, offset: indexInParent(el) + 1 };
}
else {
const found = search(el);
if (found)
return found;
}
}
}
return null;
}
return search(host) ?? { node: host, offset: 0 };
}
function hostFor(blockId: string): HTMLElement | null {
return blockElements.get(blockId) ?? null;
}
function read(): Selection | null {
const root = getRoot();
const domSel = getWindow()?.getSelection();
if (!root || !domSel || domSel.rangeCount === 0 || !domSel.anchorNode)
return null;
// Both endpoints must live inside our single editable root.
if (!root.contains(domSel.anchorNode))
return null;
const anchorHost = closestBlockHost(domSel.anchorNode);
const focusHost = closestBlockHost(domSel.focusNode) ?? anchorHost;
if (!anchorHost || !focusHost)
return null;
const anchorId = anchorHost.dataset['blockId'];
const focusId = focusHost.dataset['blockId'];
if (!anchorId || !focusId)
return null;
const anchorOffset = domPointToOffset(anchorHost, domSel.anchorNode, domSel.anchorOffset);
const focusOffset = domSel.focusNode
? domPointToOffset(focusHost, domSel.focusNode, domSel.focusOffset)
: anchorOffset;
return textSelection({ blockId: anchorId, offset: anchorOffset }, { blockId: focusId, offset: focusOffset });
}
function write(selection: Selection): void {
const root = getRoot();
const domSel = getWindow()?.getSelection();
if (!root || !domSel)
return;
if (selection.kind === 'node') {
// Block-level selection has no native text range; the visual highlight
// comes from [data-selected] on the block wrapper. Keep the editable root
// focused so keyboard commands (Backspace/Delete on the node) still reach it.
domSel.removeAllRanges();
if (root.isContentEditable && root.ownerDocument.activeElement !== root)
root.focus({ preventScroll: true });
return;
}
const anchorHost = hostFor(selection.anchor.blockId);
const focusHost = hostFor(selection.focus.blockId);
if (!anchorHost || !focusHost)
return;
// Focus the ONE editable root (not the block) so the caret renders.
if (root.isContentEditable && root.ownerDocument.activeElement !== root)
root.focus({ preventScroll: true });
const anchorPoint = offsetToDomPoint(anchorHost, selection.anchor.offset);
const focusPoint = offsetToDomPoint(focusHost, selection.focus.offset);
try {
domSel.setBaseAndExtent(anchorPoint.node, anchorPoint.offset, focusPoint.node, focusPoint.offset);
}
catch {
// Invalid DOM points (e.g. mid-reconcile) — ignore; the next reconcile fixes it.
}
}
return {
read,
write,
save: read,
restore: (selection) => {
if (selection)
write(selection);
},
domPointToOffset,
offsetToDomPoint,
};
}
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue';
import { isCollapsed } from '../../model';
import { isMarkActive, toggleMark } from '../../commands';
import { useEditorContext } from '../context';
import { useEventListener } from '../composables';
export interface EditorBubbleMenuProps {
/** Marks shown in the default toolbar (ignored when the default slot is used). */
marks?: string[];
}
const { marks = ['bold', 'italic', 'underline', 'strike', 'code'] } = defineProps<EditorBubbleMenuProps>();
const ctx = useEditorContext();
const reference = ref<{ getBoundingClientRect: () => DOMRect } | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const open = ref(false);
const rev = ref(0);
const { floatingStyles, update } = useFloating(reference, floatingEl, {
placement: 'top',
middleware: [offset(8), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
});
function selectionRect(): DOMRect | null {
const selection = typeof globalThis.window === 'undefined' ? null : globalThis.getSelection();
if (!selection || selection.rangeCount === 0)
return null;
const rect = selection.getRangeAt(0).getBoundingClientRect();
return rect.width || rect.height ? rect : null;
}
function refresh(): void {
rev.value += 1;
const sel = ctx.editor.state.selection;
const rect = selectionRect();
open.value = sel.kind === 'text' && !isCollapsed(sel) && !ctx.composing.value && rect !== null;
if (open.value) {
reference.value = { getBoundingClientRect: () => selectionRect() ?? new DOMRect() };
void update();
}
}
ctx.editor.on('transaction', refresh);
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', refresh);
onBeforeUnmount(() => ctx.editor.off('transaction', refresh));
function active(type: string): boolean {
return Boolean(rev.value >= 0 && isMarkActive(ctx.editor.state, type));
}
function toggle(type: string): void {
ctx.editor.command(toggleMark(type));
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
ref="floatingEl"
:style="floatingStyles"
class="editor-bubble-menu"
role="toolbar"
data-editor-bubble-menu=""
>
<slot :active="active" :toggle="toggle" :editor="ctx.editor">
<button
v-for="mark in marks"
:key="mark"
type="button"
:data-mark="mark"
:data-active="active(mark) || undefined"
@mousedown.prevent="toggle(mark)"
>
{{ mark }}
</button>
</slot>
</div>
</Teleport>
</template>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { RemoteCursor } from '../../crdt';
import { useEditorContext } from '../context';
export interface EditorRemoteCursorsProps {
cursors: readonly RemoteCursor[];
}
const props = defineProps<EditorRemoteCursorsProps>();
const ctx = useEditorContext();
interface Box {
top: number;
left: number;
width: number;
height: number;
}
interface RenderedCursor {
clientId: string;
name: string;
color: string;
caret: Box | null;
highlights: Box[];
}
const container = ref<HTMLElement | null>(null);
const rendered = ref<RenderedCursor[]>([]);
function domPoint(blockId: string, offset: number): { node: Node; offset: number } | null {
const host = ctx.blockElements.get(blockId);
return host ? ctx.selection.offsetToDomPoint(host, offset) : null;
}
function relativize(rect: DOMRect, base: DOMRect): Box {
return { top: rect.top - base.top, left: rect.left - base.left, width: rect.width, height: rect.height || 18 };
}
function recompute(): void {
const root = container.value;
if (!root || typeof document === 'undefined') {
rendered.value = [];
return;
}
// Measure against the overlay itself — it is the positioned ancestor the
// caret/highlight children are laid out in, so coordinates line up exactly.
const base = root.getBoundingClientRect();
const next: RenderedCursor[] = [];
for (const cursor of props.cursors) {
const selection = cursor.selection;
if (!selection || selection.kind !== 'text')
continue;
const focusPoint = domPoint(selection.focus.blockId, selection.focus.offset);
if (!focusPoint)
continue;
const caretRange = document.createRange();
caretRange.setStart(focusPoint.node, focusPoint.offset);
caretRange.collapse(true);
const caret = relativize(caretRange.getBoundingClientRect(), base);
const highlights: Box[] = [];
const anchorPoint = domPoint(selection.anchor.blockId, selection.anchor.offset);
const collapsed = selection.anchor.blockId === selection.focus.blockId && selection.anchor.offset === selection.focus.offset;
if (anchorPoint && !collapsed) {
const range = document.createRange();
range.setStart(anchorPoint.node, anchorPoint.offset);
range.setEnd(focusPoint.node, focusPoint.offset);
if (range.collapsed) {
// Selection runs backwards (focus before anchor) — swap the ends.
range.setStart(focusPoint.node, focusPoint.offset);
range.setEnd(anchorPoint.node, anchorPoint.offset);
}
for (const rect of Array.from(range.getClientRects())) {
if (rect.width > 0)
highlights.push(relativize(rect, base));
}
}
next.push({
clientId: cursor.clientId,
name: cursor.user?.name ?? 'Anon',
color: cursor.user?.color ?? '#ef4444',
caret,
highlights,
});
}
rendered.value = next;
}
function schedule(): void {
void nextTick(recompute);
}
watch(() => props.cursors, schedule, { deep: true });
ctx.editor.on('transaction', schedule);
onMounted(recompute);
onBeforeUnmount(() => ctx.editor.off('transaction', schedule));
</script>
<template>
<div ref="container" class="editor-remote-cursors" aria-hidden="true">
<template v-for="cursor in rendered" :key="cursor.clientId">
<div
v-for="(hl, i) in cursor.highlights"
:key="`${cursor.clientId}-hl-${i}`"
class="editor-remote-selection"
:style="{ top: `${hl.top}px`, left: `${hl.left}px`, width: `${hl.width}px`, height: `${hl.height}px`, '--cursor-color': cursor.color }"
/>
<div
v-if="cursor.caret"
class="editor-remote-caret"
:style="{ top: `${cursor.caret.top}px`, left: `${cursor.caret.left}px`, height: `${cursor.caret.height}px`, '--cursor-color': cursor.color }"
>
<span class="editor-remote-caret-label">{{ cursor.name }}</span>
</div>
</template>
</div>
</template>
+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue';
import { blockById, caret, createNode, inlineText, isCollapsed, nodeInline, nodeSelection } from '../../model';
import { createTransaction } from '../../state';
import { useEditorContext } from '../context';
import { useEventListener } from '../composables';
import type { SlashItem } from './slash-items';
import { getSlashItems } from './slash-items';
export interface EditorSlashMenuProps {
/** Character that opens the menu (default `'/'`). */
trigger?: string;
}
const { trigger = '/' } = defineProps<EditorSlashMenuProps>();
const ctx = useEditorContext();
const open = ref(false);
const items = ref<SlashItem[]>([]);
const highlighted = ref(0);
const reference = ref<{ getBoundingClientRect: () => DOMRect } | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
let triggerBlockId = '';
let triggerStart = 0;
let caretOffset = 0;
const { floatingStyles, update } = useFloating(reference, floatingEl, {
placement: 'bottom-start',
middleware: [offset(6), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
});
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function caretRect(): DOMRect | null {
const selection = typeof globalThis.window === 'undefined' ? null : globalThis.getSelection();
if (!selection || selection.rangeCount === 0)
return null;
const range = selection.getRangeAt(0);
const rects = range.getClientRects();
const rect = rects.length > 0 ? rects[0]! : range.getBoundingClientRect();
return rect.width || rect.height ? rect : null;
}
function close(): void {
open.value = false;
}
function refresh(): void {
const sel = ctx.editor.state.selection;
if (sel.kind !== 'text' || !isCollapsed(sel) || ctx.composing.value) {
close();
return;
}
const block = blockById(ctx.editor.state.doc, sel.focus.blockId);
const spec = block && ctx.editor.state.schema.nodeSpec(block.type);
if (!block || spec?.content.kind !== 'text' || spec.code) {
close();
return;
}
const before = inlineText(nodeInline(block)).slice(0, sel.focus.offset);
const match = new RegExp(`(?:^|\\s)${escapeRegExp(trigger)}([\\p{L}\\p{N}]*)$`, 'u').exec(before);
if (!match) {
close();
return;
}
const query = match[1] ?? '';
const next = getSlashItems(ctx.editor.state.registry, query);
if (next.length === 0) {
close();
return;
}
triggerBlockId = block.id;
caretOffset = sel.focus.offset;
triggerStart = caretOffset - query.length - trigger.length;
items.value = next;
highlighted.value = open.value ? Math.min(highlighted.value, next.length - 1) : 0;
if (!caretRect()) {
close();
return;
}
reference.value = { getBoundingClientRect: () => caretRect() ?? new DOMRect() };
open.value = true;
void update();
}
function selectItem(item: SlashItem): void {
const editor = ctx.editor;
const block = blockById(editor.state.doc, triggerBlockId);
if (!block) {
close();
return;
}
const def = editor.state.registry.getBlock(item.type);
const tr = createTransaction(editor.state).deleteText(triggerBlockId, triggerStart, caretOffset);
if (def?.spec.content.kind === 'atom') {
const node = createNode(item.type, { attrs: editor.state.schema.defaultAttrs(item.type) });
const index = editor.state.doc.content.findIndex(candidate => candidate.id === triggerBlockId);
tr.insertBlock(node, index + 1).setSelection(nodeSelection([node.id]));
}
else {
tr.setBlockType(triggerBlockId, item.type, editor.state.schema.defaultAttrs(item.type));
tr.setSelection(caret(triggerBlockId, triggerStart));
}
editor.dispatch(tr);
close();
}
function onKeydownCapture(event: KeyboardEvent): void {
if (!open.value || items.value.length === 0)
return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
event.stopImmediatePropagation();
highlighted.value = (highlighted.value + 1) % items.value.length;
break;
case 'ArrowUp':
event.preventDefault();
event.stopImmediatePropagation();
highlighted.value = (highlighted.value - 1 + items.value.length) % items.value.length;
break;
case 'Enter':
event.preventDefault();
event.stopImmediatePropagation();
selectItem(items.value[highlighted.value]!);
break;
case 'Escape':
event.preventDefault();
event.stopImmediatePropagation();
close();
break;
}
}
ctx.editor.on('transaction', refresh);
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', refresh);
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'keydown', onKeydownCapture as (event: Event) => void, { capture: true });
onBeforeUnmount(() => ctx.editor.off('transaction', refresh));
</script>
<template>
<Teleport to="body">
<div
v-if="open"
ref="floatingEl"
:style="floatingStyles"
class="editor-slash-menu"
role="listbox"
data-editor-slash-menu=""
>
<button
v-for="(item, index) in items"
:key="item.type"
type="button"
role="option"
:data-highlighted="index === highlighted || undefined"
:aria-selected="index === highlighted"
@mousedown.prevent="selectItem(item)"
@mousemove="highlighted = index"
>
<span class="slash-title">{{ item.title }}</span>
<span class="slash-group">{{ item.group }}</span>
</button>
</div>
</Teleport>
</template>
+8
View File
@@ -0,0 +1,8 @@
export * from './slash-items';
export { default as EditorBubbleMenu } from './EditorBubbleMenu.vue';
export type { EditorBubbleMenuProps } from './EditorBubbleMenu.vue';
export { default as EditorSlashMenu } from './EditorSlashMenu.vue';
export type { EditorSlashMenuProps } from './EditorSlashMenu.vue';
export { default as EditorRemoteCursors } from './EditorRemoteCursors.vue';
export type { EditorRemoteCursorsProps } from './EditorRemoteCursors.vue';
+33
View File
@@ -0,0 +1,33 @@
import type { Registry } from '../../registry';
/** A slash-menu entry derived from a block definition's metadata. */
export interface SlashItem {
type: string;
title: string;
group: string;
keywords: readonly string[];
}
/**
* Build slash-menu items from the registry, filtered by `query` against each
* block's title and keywords. Data-driven: any newly registered block with
* `meta` shows up automatically.
*/
export function getSlashItems(registry: Registry, query = ''): SlashItem[] {
const items: SlashItem[] = registry.listBlocks()
.filter(def => def.meta !== undefined)
.map(def => ({
type: def.type,
title: def.meta!.title,
group: def.meta!.group ?? 'blocks',
keywords: def.meta!.keywords ?? [],
}));
const q = query.trim().toLowerCase();
if (!q)
return items;
return items.filter(item =>
item.title.toLowerCase().includes(q) || item.keywords.some(keyword => keyword.toLowerCase().includes(q)),
);
}