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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user