feat(writekit): a way out of atoms, and a slash menu that behaves
Publish to NPM / Check version changes and publish (push) Successful in 9m58s

Two gaps an author hits within the first minute of using atom blocks:

- there was no way to add a paragraph after a non-text block. Enter on a
  selected atom now starts a paragraph below it (exitAtom, chained before
  splitBlock), and a click on the root's padding below a trailing atom
  does the same — ends-in-text just places the caret at the end
- the slash menu ignored its own overflow: keyboard navigation walked the
  highlight out of view (now scrollIntoView nearest), and a background
  wheel scroll tore the menu off its caret anchor (now prevented outside
  the menu; scrolling the list itself stays native)
- BlockMeta gains `description`; the menu shows a detail pane beside the
  list with the highlighted item's description, replaceable wholesale via
  the new #preview slot. Headless stays headless: the pane is unstyled
  text and appears only when there is a description or a slot. Preset
  blocks are all described

Both floating menus (slash, bubble) are re-layered to the combobox
convention: PopperRoot provides the positioning context OUTSIDE a bare
Portal, which resolves its target from the ConfigProvider's
teleportTarget — the previous hardcoded to="body" was overriding the
app's configured target. A Combobox itself is the wrong base here on
purpose: its keyboard lives on ComboboxInput, while a suggestion menu
must leave focus in the contenteditable — the editor is the input.

writekit 0.0.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 05:11:29 +07:00
parent 66d9faad22
commit 1d105b1f55
19 changed files with 308 additions and 78 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/writekit",
"version": "0.0.2",
"version": "0.0.3",
"license": "Apache-2.0",
"description": "Headless block-based rich-text writekit for Vue with a registry-driven schema and pluggable CRDT",
"keywords": [
+1 -1
View File
@@ -9,5 +9,5 @@ export const blockquote = defineBlock({
parseDOM: [{ tag: 'blockquote' }],
},
inputRules: [{ match: /^>\s$/ }],
meta: { title: 'Quote', icon: 'quote', keywords: ['quote', 'blockquote', 'citation'], group: 'basic' },
meta: { title: 'Quote', icon: 'quote', keywords: ['quote', 'blockquote', 'citation'], group: 'basic', description: 'Set a passage apart from the narration.' },
});
+1 -1
View File
@@ -13,5 +13,5 @@ export const callout = defineBlock({
getAttrs: (el: HTMLElement) => ({ variant: el.getAttribute('data-callout') ?? 'info' }),
}],
},
meta: { title: 'Callout', icon: 'info', keywords: ['callout', 'note', 'info', 'warning'], group: 'basic' },
meta: { title: 'Callout', icon: 'info', keywords: ['callout', 'note', 'info', 'warning'], group: 'basic', description: 'A highlighted note the eye cannot miss.' },
});
+1 -1
View File
@@ -12,5 +12,5 @@ export const codeBlock = defineBlock({
toDOM: (node: Node) => ['pre', { 'data-language': String(node.attrs['language'] ?? 'plain') }, 0],
parseDOM: [{ tag: 'pre' }],
},
meta: { title: 'Code block', icon: 'code', keywords: ['code', 'pre', 'snippet'], group: 'basic' },
meta: { title: 'Code block', icon: 'code', keywords: ['code', 'pre', 'snippet'], group: 'basic', description: 'Verbatim monospaced text; Enter stays inside.' },
});
+1 -1
View File
@@ -10,5 +10,5 @@ export const divider = defineBlock({
parseDOM: [{ tag: 'hr' }],
},
component: DividerBlock,
meta: { title: 'Divider', icon: 'minus', keywords: ['divider', 'hr', 'rule', 'separator'], group: 'media' },
meta: { title: 'Divider', icon: 'minus', keywords: ['divider', 'hr', 'rule', 'separator'], group: 'media', description: 'A horizontal rule between sections.' },
});
+1 -1
View File
@@ -14,5 +14,5 @@ export const heading = defineBlock({
parseDOM: LEVELS.map(level => ({ tag: `h${level}`, attrs: { level } })),
},
inputRules: LEVELS.map(level => ({ match: new RegExp(`^#{${level}}\\s$`), attrs: { level } })),
meta: { title: 'Heading', icon: 'heading', keywords: ['heading', 'title', 'h1', 'h2', 'h3'], group: 'basic' },
meta: { title: 'Heading', icon: 'heading', keywords: ['heading', 'title', 'h1', 'h2', 'h3'], group: 'basic', description: 'A section title, levels 16.' },
});
+1 -1
View File
@@ -23,5 +23,5 @@ export const image = defineBlock({
}],
},
component: ImageBlock,
meta: { title: 'Image', icon: 'image', keywords: ['image', 'img', 'picture', 'photo'], group: 'media' },
meta: { title: 'Image', icon: 'image', keywords: ['image', 'img', 'picture', 'photo'], group: 'media', description: 'An image with an optional caption.' },
});
+11 -5
View File
@@ -14,7 +14,7 @@ function indentOf(node: Node): number {
* `checked` for to-dos). Markers/numbering and indentation are presentation
* (CSS), so the model stays a simple flat block list that maps cleanly to a CRDT.
*/
function defineListBlock(options: { type: string; listType: ListType; title: string; keywords: readonly string[] }) {
function defineListBlock(options: { type: string; listType: ListType; title: string; keywords: readonly string[]; description?: string }) {
const todo = options.listType === 'todo';
const attrs: AttrsSpec = {
@@ -43,10 +43,16 @@ function defineListBlock(options: { type: string; listType: ListType; title: str
parseDOM: [{ tag: `[data-list='${options.listType}']` }],
},
inputRules,
meta: { title: options.title, icon: 'list', keywords: options.keywords, group: 'lists' },
meta: {
title: options.title,
icon: 'list',
keywords: options.keywords,
group: 'lists',
...(options.description !== undefined && { description: options.description }),
},
});
}
export const bulletedList = defineListBlock({ type: 'bulleted-list', listType: 'bullet', title: 'Bulleted list', keywords: ['ul', 'bullet', 'unordered', 'list'] });
export const numberedList = defineListBlock({ type: 'numbered-list', listType: 'ordered', title: 'Numbered list', keywords: ['ol', 'number', 'ordered', 'list'] });
export const todoList = defineListBlock({ type: 'todo-list', listType: 'todo', title: 'To-do list', keywords: ['todo', 'task', 'checkbox', 'check'] });
export const bulletedList = defineListBlock({ type: 'bulleted-list', listType: 'bullet', title: 'Bulleted list', keywords: ['ul', 'bullet', 'unordered', 'list'], description: 'Items marked with bullets; Tab indents.' });
export const numberedList = defineListBlock({ type: 'numbered-list', listType: 'ordered', title: 'Numbered list', keywords: ['ol', 'number', 'ordered', 'list'], description: 'Items numbered in order; Tab indents.' });
export const todoList = defineListBlock({ type: 'todo-list', listType: 'todo', title: 'To-do list', keywords: ['todo', 'task', 'checkbox', 'check'], description: 'Checkable tasks; Enter adds the next one.' });
+1 -1
View File
@@ -9,5 +9,5 @@ export const paragraph = defineBlock({
parseDOM: [{ tag: 'p' }],
},
placeholder: 'Write something…',
meta: { title: 'Paragraph', icon: 'text', keywords: ['paragraph', 'text', 'p'], group: 'basic' },
meta: { title: 'Paragraph', icon: 'text', keywords: ['paragraph', 'text', 'p'], group: 'basic', description: 'Plain prose — the default block.' },
});
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import { caret, createDoc, createNode, nodeInline, nodeText, textSelection } from '../../model';
import { caret, createDoc, createNode, nodeInline, nodeSelection, nodeText, textSelection } from '../../model';
import { createDefaultRegistry } from '../../preset';
import { createWritekit, createWritekitState } from '../../state';
import { joinBackward, splitBlock, toggleMark } from '..';
import { exitAtom, joinBackward, splitBlock, toggleMark } from '..';
function para(id: string, text: string) {
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
@@ -44,6 +44,31 @@ describe('commands', () => {
expect(writekit.state.doc.content.map(block => nodeText(block))).toEqual(['foobar']);
});
it('exitAtom starts a paragraph below a selected atom', () => {
const registry = createDefaultRegistry();
const writekit = createWritekit({
state: createWritekitState({
registry,
doc: createDoc([para('a', 'before'), createNode('divider', { id: 'd' })]),
selection: nodeSelection(['d']),
}),
});
expect(writekit.command(exitAtom)).toBe(true);
const types = writekit.state.doc.content.map(block => block.type);
expect(types).toEqual(['paragraph', 'divider', 'paragraph']);
const sel = writekit.state.selection;
expect(sel.kind).toBe('text');
expect(sel.kind === 'text' && sel.focus.blockId).toBe(writekit.state.doc.content[2]!.id);
});
it('exitAtom is a no-op for text selections', () => {
const writekit = writekitWith([para('a', 'hello')], caret('a', 2));
expect(writekit.command(exitAtom)).toBe(false);
});
it('undo restores the document after a split', () => {
const writekit = writekitWith([para('a', 'hello')], caret('a', 2));
writekit.command(splitBlock);
+31
View File
@@ -2,6 +2,7 @@ import type { Attrs, Node } from '../model';
import {
blockById,
caret,
createNode,
inlineLength,
isAcrossBlocks,
isCollapsed,
@@ -88,6 +89,36 @@ export const splitBlock: Command = (state, dispatch) => {
return true;
};
/**
* Enter with an atom selected: start a paragraph right below it.
*
* An atom (image, divider, an app's card) has no text position inside it, so
* without this the only way OUT of a selected atom — and the only way to write
* between two atoms, or after one that ends the document — was to abandon the
* keyboard. Mirrors `createParagraphNear` in the ProseMirror tradition.
*/
export const exitAtom: Command = (state, dispatch) => {
const sel = state.selection;
if (sel.kind !== 'node' || sel.ids.length === 0 || !state.registry.hasBlock('paragraph'))
return false;
const lastId = sel.ids.at(-1)!;
const index = state.doc.content.findIndex(block => block.id === lastId);
if (index === -1)
return false;
if (dispatch) {
const paragraph = createNode('paragraph');
dispatch(createTransaction(state)
.insertBlock(paragraph, index + 1)
.setSelection(caret(paragraph.id, 0)));
}
return true;
};
/** Insert a hard line break (Shift+Enter) inside the current block. */
export const insertHardBreak: Command = (state, dispatch) => {
const sel = state.selection;
+3 -1
View File
@@ -1,6 +1,7 @@
import {
chainCommands,
deleteSelection,
exitAtom,
indentListItem,
insertHardBreak,
joinBackward,
@@ -36,7 +37,8 @@ export function defaultKeymap(writekit: Writekit): Keymap {
'Mod-z': undo,
'Mod-Shift-z': redo,
'Mod-y': redo,
Enter: splitBlock,
// With an atom selected, Enter starts a paragraph below it; in text it splits.
Enter: chainCommands(exitAtom, splitBlock),
'Shift-Enter': insertHardBreak,
Backspace: chainCommands(deleteSelection, joinBackward),
Delete: chainCommands(deleteSelection, joinForward),
@@ -25,6 +25,8 @@ export interface BlockMeta {
readonly icon?: string;
readonly keywords?: readonly string[];
readonly group?: string;
/** One sentence for pickers (the slash menu shows it beside the list). */
readonly description?: string;
}
/** Optional block-specific behaviors used by core commands. */
+37 -1
View File
@@ -3,7 +3,7 @@ import type { PrimitiveProps } from './primitive';
</script>
<script setup lang="ts">
import { blockById, caret, inlineLength, isCollapsed, nodeInline } from '../model';
import { blockById, caret, createNode, inlineLength, isCollapsed, nodeInline } from '../model';
import { applyInputRule, deleteSelection, insertHardBreak, joinBackward, joinForward, splitBlock } from '../commands';
import { createTransaction } from '../state';
import { Primitive } from './primitive';
@@ -117,6 +117,41 @@ function onInput(event?: Event): void {
ctx.writekit.command(applyInputRule);
}
/**
* A click on the root's own padding below the last block means "write here".
* When the document ends in an atom there is no text position to click into at
* all — without this the only way to continue writing was the keyboard path
* (select the atom, press Enter). Ends-in-text just places the caret at the end.
*/
function onRootPointerDown(event: PointerEvent): void {
if (!ctx.config.editable || event.target !== ctx.contentRoot.value)
return;
const last = ctx.writekit.state.doc.content.at(-1);
if (!last)
return;
const lastEl = ctx.blockElements.get(last.id) ?? null;
if (lastEl && event.clientY <= lastEl.getBoundingClientRect().bottom)
return;
event.preventDefault();
if (ctx.writekit.state.schema.nodeSpec(last.type)?.content.kind === 'text') {
ctx.writekit.dispatch(createTransaction(ctx.writekit.state)
.setSelection(caret(last.id, inlineLength(nodeInline(last)))));
return;
}
if (!ctx.writekit.state.registry.hasBlock('paragraph'))
return;
const paragraph = createNode('paragraph');
ctx.writekit.dispatch(createTransaction(ctx.writekit.state)
.insertBlock(paragraph, ctx.writekit.state.doc.content.length)
.setSelection(caret(paragraph.id, 0)));
}
function onCompositionStart(event: CompositionEvent): void {
if (isInteractiveTarget(event.target))
return;
@@ -143,6 +178,7 @@ function onCompositionEnd(event: CompositionEvent): void {
:spellcheck="ctx.config.spellcheck"
@beforeinput="onBeforeInput"
@input="onInput"
@pointerdown="onRootPointerDown"
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
>
@@ -134,3 +134,57 @@ describe('WritekitRoot (single contenteditable)', () => {
expect(hosts[0]!.textContent).toBe('foobar');
});
});
describe('writing after a trailing atom', () => {
it('a click below the last block starts a paragraph when the doc ends in an atom', async () => {
const registry = createDefaultRegistry();
const writekit = createWritekit({
state: createWritekitState({
registry,
doc: createDoc([para('a', 'text'), createNode('divider', { id: 'd' })]),
}),
});
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
await nextTick();
const root = document.querySelector('[data-writekit-content]') as HTMLElement;
root.style.paddingBottom = '120px';
const rect = root.getBoundingClientRect();
root.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
clientX: rect.left + 10,
clientY: rect.bottom - 10,
}));
await nextTick();
expect(writekit.state.doc.content.map(block => block.type)).toEqual(['paragraph', 'divider', 'paragraph']);
expect(writekit.state.selection.kind).toBe('text');
});
it('a click below the last block just places the caret when it is text', async () => {
const registry = createDefaultRegistry();
const writekit = createWritekit({
state: createWritekitState({ registry, doc: createDoc([para('a', 'text')]) }),
});
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
await nextTick();
const root = document.querySelector('[data-writekit-content]') as HTMLElement;
root.style.paddingBottom = '120px';
const rect = root.getBoundingClientRect();
root.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
clientX: rect.left + 10,
clientY: rect.bottom - 10,
}));
await nextTick();
expect(writekit.state.doc.content).toHaveLength(1);
const sel = writekit.state.selection;
expect(sel.kind === 'text' && sel.focus.offset).toBe(4);
});
});
+1 -1
View File
@@ -1 +1 @@
export { useContextFactory, useEventListener } from '@robonen/vue';
export { unrefElement, useContextFactory, useEventListener } from '@robonen/vue';
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { onBeforeUnmount, ref, shallowRef } from 'vue';
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives';
import { isCollapsed } from '../../model';
import { isMarkActive, toggleMark } from '../../commands';
@@ -17,7 +17,7 @@ const ctx = useWritekitContext();
// Virtual reference (a `Measurable`) anchored to the selection rect — Popper
// positions against it with no trigger element. Reassigned on every refresh so
// PopperContent re-resolves position as the selection moves.
const reference = ref<{ getBoundingClientRect: () => DOMRect } | undefined>();
const reference = shallowRef<{ getBoundingClientRect: () => DOMRect } | undefined>();
const open = ref(false);
const rev = ref(0);
@@ -54,8 +54,11 @@ function toggle(type: string): void {
</script>
<template>
<Portal to="body">
<PopperRoot>
<!-- Combobox layering: PopperRoot provides the positioning context outside
the portal. The bare Portal resolves its target from the ConfigProvider's
teleportTarget (body unless the app overrides it). -->
<PopperRoot>
<Portal>
<PopperContent
v-if="open && reference"
:reference="reference"
@@ -83,6 +86,6 @@ function toggle(type: string): void {
</slot>
</DismissableLayer>
</PopperContent>
</PopperRoot>
</Portal>
</Portal>
</PopperRoot>
</template>
+123 -54
View File
@@ -1,38 +1,8 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives';
import { blockById, caret, createNode, inlineText, isCollapsed, nodeInline, nodeSelection } from '../../model';
import { createTransaction } from '../../state';
import { useWritekitContext } from '../context';
import { useEventListener } from '../composables';
import type { SlashItem } from './slash-items';
import { getSlashItems } from './slash-items';
export interface WritekitSlashMenuProps {
/** Character that opens the menu (default `'/'`). */
trigger?: string;
}
const { trigger = '/' } = defineProps<WritekitSlashMenuProps>();
const ctx = useWritekitContext();
const open = ref(false);
const items = ref<SlashItem[]>([]);
const highlighted = ref(0);
// Virtual reference (a `Measurable`) anchored to the caret rect; Popper positions
// against it with no trigger element. Focus stays in the contenteditable (so the
// user keeps typing to filter), so nav is driven by the capture-phase keydown
// below and the highlight is an index — not roving focus / listbox focus.
const reference = ref<{ getBoundingClientRect: () => DOMRect } | undefined>();
let triggerBlockId = '';
let triggerStart = 0;
let caretOffset = 0;
function escapeRegExp(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
<script lang="ts">
/** Regexp-special characters, escaped when the trigger is interpolated. */
const ESCAPE_RE = /[.*+?^${}()|[\]\\]/g;
/** The caret's client rect, when the native selection has a visible one. */
function caretRect(): DOMRect | null {
const selection = globalThis.window === undefined ? null : globalThis.getSelection();
if (!selection || selection.rangeCount === 0)
@@ -43,6 +13,60 @@ function caretRect(): DOMRect | null {
const rect = rects.length > 0 ? rects[0]! : range.getBoundingClientRect();
return rect.width || rect.height ? rect : null;
}
</script>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef, watch } from 'vue';
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives';
import { blockById, caret, createNode, inlineText, isCollapsed, nodeInline, nodeSelection } from '../../model';
import { createTransaction } from '../../state';
import { useWritekitContext } from '../context';
import { unrefElement, useEventListener } from '../composables';
import type { SlashItem } from './slash-items';
import { getSlashItems } from './slash-items';
export interface WritekitSlashMenuProps {
/** Character that opens the menu (default `'/'`). */
trigger?: string;
}
const { trigger = '/' } = defineProps<WritekitSlashMenuProps>();
/**
* Optional detail pane beside the list. Headless: the menu only knows WHICH
* item is highlighted; what a block looks like is the app's knowledge, so the
* pane renders the `preview` slot when given one and plain `meta.description`
* text otherwise. No slot and no description → no pane, same menu as before.
*/
const slots = defineSlots<{
preview?: (props: { item: SlashItem }) => unknown;
}>();
const ctx = useWritekitContext();
const open = ref(false);
const items = shallowRef<SlashItem[]>([]);
const highlighted = ref(0);
// Virtual reference (a `Measurable`) anchored to the caret rect; Popper positions
// against it with no trigger element. Focus stays in the contenteditable (so the
// user keeps typing to filter), so nav is driven by the capture-phase keydown
// below and the highlight is an index — not roving focus / listbox focus.
const reference = shallowRef<{ getBoundingClientRect: () => DOMRect } | undefined>();
// vue ≥3.5: a template ref inside v-for collects the elements in source order.
const layer = useTemplateRef<InstanceType<typeof DismissableLayer>>('layer');
const itemRefs = useTemplateRef<HTMLButtonElement[]>('options');
const layerEl = computed(() => unrefElement(layer.value));
const active = computed<SlashItem | undefined>(() => items.value[highlighted.value]);
const hasPreview = computed(() => slots.preview !== undefined || active.value?.description !== undefined);
/** `(start or whitespace) + trigger + query` immediately before the caret. */
const matcher = computed(() =>
new RegExp(`(?:^|\\s)${trigger.replaceAll(ESCAPE_RE, '\\$&')}([\\p{L}\\p{N}]*)$`, 'u'));
let triggerBlockId = '';
let triggerStart = 0;
let caretOffset = 0;
function close(): void {
open.value = false;
@@ -65,7 +89,7 @@ function refresh(): void {
}
const before = inlineText(nodeInline(block)).slice(0, sel.focus.offset);
const match = new RegExp(`(?:^|\\s)${escapeRegExp(trigger)}([\\p{L}\\p{N}]*)$`, 'u').exec(before);
const match = matcher.value.exec(before);
if (!match) {
close();
@@ -149,15 +173,41 @@ function onKeydownCapture(event: KeyboardEvent): void {
}
}
// Keyboard navigation must chase the highlight into view — a list longer than
// the menu's max-height otherwise walks the selection out of sight.
watch(highlighted, index => void nextTick(() => {
itemRefs.value?.[index]?.scrollIntoView({ block: 'nearest' });
}));
/**
* The menu is anchored to a caret rect that does NOT move with the page, so a
* background scroll visually tears the menu off its anchor. Scrolling inside
* the menu (a long block list) stays allowed.
*/
function onScrollIntent(event: Event): void {
if (!open.value)
return;
if (layerEl.value && event.target instanceof Node && layerEl.value.contains(event.target))
return;
event.preventDefault();
}
ctx.writekit.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 });
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'wheel', onScrollIntent, { capture: true, passive: false });
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'touchmove', onScrollIntent, { capture: true, passive: false });
onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
</script>
<template>
<Portal to="body">
<PopperRoot>
<!-- Combobox layering: PopperRoot provides the positioning context outside
the portal. The bare Portal resolves its target from the ConfigProvider's
teleportTarget (body unless the app overrides it). -->
<PopperRoot>
<Portal>
<PopperContent
v-if="open && reference"
:reference="reference"
@@ -167,27 +217,46 @@ onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
:collision-padding="8"
>
<DismissableLayer
class="writekit-slash-menu"
role="listbox"
data-writekit-slash-menu=""
ref="layer"
class="writekit-slash"
data-writekit-slash=""
@dismiss="close"
@focus-outside.prevent
>
<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"
<div
class="writekit-slash-menu"
role="listbox"
data-writekit-slash-menu=""
>
<span class="slash-title">{{ item.title }}</span>
<span class="slash-group">{{ item.group }}</span>
</button>
<button
v-for="(item, index) in items"
:key="item.type"
ref="options"
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>
<aside
v-if="active && hasPreview"
class="writekit-slash-preview"
data-writekit-slash-preview=""
aria-hidden="true"
>
<slot name="preview" :item="active">
<span class="slash-preview-title">{{ active.title }}</span>
<span class="slash-preview-text">{{ active.description }}</span>
</slot>
</aside>
</DismissableLayer>
</PopperContent>
</PopperRoot>
</Portal>
</Portal>
</PopperRoot>
</template>
+2
View File
@@ -6,6 +6,7 @@ export interface SlashItem {
title: string;
group: string;
keywords: readonly string[];
description?: string;
}
/**
@@ -21,6 +22,7 @@ export function getSlashItems(registry: Registry, query = ''): SlashItem[] {
title: def.meta!.title,
group: def.meta!.group ?? 'blocks',
keywords: def.meta!.keywords ?? [],
...(def.meta!.description !== undefined && { description: def.meta!.description }),
}));
const q = query.trim().toLowerCase();