3 Commits

Author SHA1 Message Date
robonen 551b3bd921 fix(writekit): a node selection is a real DOM range, not an absence of one
Publish to NPM / Check version changes and publish (push) Successful in 10m0s
Selecting an atom used to clear every native range and leave the editable
root focused with NO selection. The browser then invents a caret at the
START of the content, `selectionchange` reads it, and the model's node
selection is overwritten by a text caret in the first block — so the
Enter meant to exit the freshly inserted atom split the opening
paragraph, and typing replaced its text. Reproduced live within a minute
of using the slash menu.

- the bridge now writes a node selection as `range.selectNode(blockEl)`;
  the read path maps that range to null, so selectionchange keeps its
  hands off the model
- beforeinput guards the node-selection state: browser edits through the
  element-wrapping range are prevented; delete falls through to
  deleteSelection, insertParagraph to exitAtom
- browser regression test walks the exact race: select atom → DOM range
  exists → selectionchange rewrites nothing → Enter lands a paragraph
  below the atom

writekit 0.0.4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 05:52:54 +07:00
robonen 1d105b1f55 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>
2026-08-11 05:28:06 +07:00
robonen 66d9faad22 build: bump privitives to 0.0.6 and writekit to 0.0.2
Publish to NPM / Check version changes and publish (push) Successful in 9m53s
2026-08-11 03:45:34 +07:00
22 changed files with 383 additions and 84 deletions
+1 -1
View File
@@ -2,6 +2,6 @@
"$schema": "https://jsr.io/schema/config-file.v1.json", "$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/primitives", "name": "@robonen/primitives",
"license": "Apache-2.0", "license": "Apache-2.0",
"version": "0.0.5", "version": "0.0.6",
"exports": "./src/index.ts" "exports": "./src/index.ts"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/primitives", "name": "@robonen/primitives",
"version": "0.0.5", "version": "0.0.6",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Collection of UI primitives", "description": "Collection of UI primitives",
"keywords": [ "keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/writekit", "name": "@robonen/writekit",
"version": "0.0.1", "version": "0.0.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Headless block-based rich-text writekit for Vue with a registry-driven schema and pluggable CRDT", "description": "Headless block-based rich-text writekit for Vue with a registry-driven schema and pluggable CRDT",
"keywords": [ "keywords": [
+1 -1
View File
@@ -9,5 +9,5 @@ export const blockquote = defineBlock({
parseDOM: [{ tag: 'blockquote' }], parseDOM: [{ tag: 'blockquote' }],
}, },
inputRules: [{ match: /^>\s$/ }], 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' }), 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], toDOM: (node: Node) => ['pre', { 'data-language': String(node.attrs['language'] ?? 'plain') }, 0],
parseDOM: [{ tag: 'pre' }], 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' }], parseDOM: [{ tag: 'hr' }],
}, },
component: DividerBlock, 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 } })), parseDOM: LEVELS.map(level => ({ tag: `h${level}`, attrs: { level } })),
}, },
inputRules: LEVELS.map(level => ({ match: new RegExp(`^#{${level}}\\s$`), 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, 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 * `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. * (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 todo = options.listType === 'todo';
const attrs: AttrsSpec = { const attrs: AttrsSpec = {
@@ -43,10 +43,16 @@ function defineListBlock(options: { type: string; listType: ListType; title: str
parseDOM: [{ tag: `[data-list='${options.listType}']` }], parseDOM: [{ tag: `[data-list='${options.listType}']` }],
}, },
inputRules, 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 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'] }); 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'] }); 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' }], parseDOM: [{ tag: 'p' }],
}, },
placeholder: 'Write something…', 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 { 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 { createDefaultRegistry } from '../../preset';
import { createWritekit, createWritekitState } from '../../state'; import { createWritekit, createWritekitState } from '../../state';
import { joinBackward, splitBlock, toggleMark } from '..'; import { exitAtom, joinBackward, splitBlock, toggleMark } from '..';
function para(id: string, text: string) { function para(id: string, text: string) {
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] }); 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']); 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', () => { it('undo restores the document after a split', () => {
const writekit = writekitWith([para('a', 'hello')], caret('a', 2)); const writekit = writekitWith([para('a', 'hello')], caret('a', 2));
writekit.command(splitBlock); writekit.command(splitBlock);
+31
View File
@@ -2,6 +2,7 @@ import type { Attrs, Node } from '../model';
import { import {
blockById, blockById,
caret, caret,
createNode,
inlineLength, inlineLength,
isAcrossBlocks, isAcrossBlocks,
isCollapsed, isCollapsed,
@@ -88,6 +89,36 @@ export const splitBlock: Command = (state, dispatch) => {
return true; 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. */ /** Insert a hard line break (Shift+Enter) inside the current block. */
export const insertHardBreak: Command = (state, dispatch) => { export const insertHardBreak: Command = (state, dispatch) => {
const sel = state.selection; const sel = state.selection;
+3 -1
View File
@@ -1,6 +1,7 @@
import { import {
chainCommands, chainCommands,
deleteSelection, deleteSelection,
exitAtom,
indentListItem, indentListItem,
insertHardBreak, insertHardBreak,
joinBackward, joinBackward,
@@ -36,7 +37,8 @@ export function defaultKeymap(writekit: Writekit): Keymap {
'Mod-z': undo, 'Mod-z': undo,
'Mod-Shift-z': redo, 'Mod-Shift-z': redo,
'Mod-y': 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, 'Shift-Enter': insertHardBreak,
Backspace: chainCommands(deleteSelection, joinBackward), Backspace: chainCommands(deleteSelection, joinBackward),
Delete: chainCommands(deleteSelection, joinForward), Delete: chainCommands(deleteSelection, joinForward),
@@ -25,6 +25,8 @@ export interface BlockMeta {
readonly icon?: string; readonly icon?: string;
readonly keywords?: readonly string[]; readonly keywords?: readonly string[];
readonly group?: 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. */ /** Optional block-specific behaviors used by core commands. */
+52 -2
View File
@@ -3,8 +3,8 @@ import type { PrimitiveProps } from './primitive';
</script> </script>
<script setup lang="ts"> <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 { applyInputRule, deleteSelection, exitAtom, insertHardBreak, joinBackward, joinForward, splitBlock } from '../commands';
import { createTransaction } from '../state'; import { createTransaction } from '../state';
import { Primitive } from './primitive'; import { Primitive } from './primitive';
import { useWritekitContext } from './context'; import { useWritekitContext } from './context';
@@ -35,6 +35,20 @@ function onBeforeInput(event: InputEvent): void {
if (!type.startsWith('insert') && !type.startsWith('delete')) if (!type.startsWith('insert') && !type.startsWith('delete'))
return; return;
// With an atom selected the native range wraps the block element; letting the
// browser edit through it would rewrite DOM the model never agreed to.
const modelSel = ctx.writekit.state.selection;
if (modelSel.kind === 'node') {
event.preventDefault();
if (type.startsWith('delete'))
ctx.writekit.command(deleteSelection);
else if (type === 'insertParagraph')
ctx.writekit.command(exitAtom);
return;
}
const sel = ctx.selection.read(); const sel = ctx.selection.read();
if (!sel || sel.kind !== 'text') if (!sel || sel.kind !== 'text')
return; return;
@@ -117,6 +131,41 @@ function onInput(event?: Event): void {
ctx.writekit.command(applyInputRule); 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 { function onCompositionStart(event: CompositionEvent): void {
if (isInteractiveTarget(event.target)) if (isInteractiveTarget(event.target))
return; return;
@@ -143,6 +192,7 @@ function onCompositionEnd(event: CompositionEvent): void {
:spellcheck="ctx.config.spellcheck" :spellcheck="ctx.config.spellcheck"
@beforeinput="onBeforeInput" @beforeinput="onBeforeInput"
@input="onInput" @input="onInput"
@pointerdown="onRootPointerDown"
@compositionstart="onCompositionStart" @compositionstart="onCompositionStart"
@compositionend="onCompositionEnd" @compositionend="onCompositionEnd"
> >
@@ -134,3 +134,97 @@ describe('WritekitRoot (single contenteditable)', () => {
expect(hosts[0]!.textContent).toBe('foobar'); 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);
});
});
describe('node selection survives the DOM', () => {
it('is represented as a real range, so selectionchange cannot overwrite it', async () => {
const registry = createDefaultRegistry();
const writekit = createWritekit({
state: createWritekitState({
registry,
doc: createDoc([para('a', 'first paragraph'), createNode('divider', { id: 'd' })]),
}),
});
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
await nextTick();
writekit.dispatch(createTransaction(writekit.state).setSelection({ kind: 'node', ids: ['d'] }));
await nextTick();
await new Promise(resolve => requestAnimationFrame(resolve));
// The selection exists in the DOM as a range around the atom's element —
// a focused editable with NO range makes the browser invent a caret at the
// start of the content, which used to overwrite the model's node selection.
const domSel = getSelection()!;
expect(domSel.rangeCount).toBe(1);
const selected = domSel.getRangeAt(0).cloneContents().querySelector('[data-block-id="d"]');
expect(selected).not.toBeNull();
// A selectionchange pass over that range must NOT rewrite the model.
document.dispatchEvent(new Event('selectionchange'));
await nextTick();
expect(writekit.state.selection).toEqual({ kind: 'node', ids: ['d'] });
// Enter on the selected atom exits into a fresh paragraph below it.
const root = document.querySelector('[data-writekit-root]') as HTMLElement;
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await nextTick();
expect(writekit.state.doc.content.map(block => block.type)).toEqual(['paragraph', 'divider', 'paragraph']);
const sel = writekit.state.selection;
expect(sel.kind === 'text' && sel.focus.blockId).toBe(writekit.state.doc.content[2]!.id);
});
});
+1 -1
View File
@@ -1 +1 @@
export { useContextFactory, useEventListener } from '@robonen/vue'; export { unrefElement, useContextFactory, useEventListener } from '@robonen/vue';
@@ -151,10 +151,25 @@ export function createSelectionBridge(
return; return;
if (selection.kind === 'node') { if (selection.kind === 'node') {
// Block-level selection has no native text range; the visual highlight // The node selection must exist in the DOM too, as a range around the
// comes from [data-selected] on the block wrapper. Keep the editable root // block element. Merely clearing the ranges left a focused editable with
// focused so keyboard commands (Backspace/Delete on the node) still reach it. // no selection — the browser then invents a caret at the START of the
// content, `selectionchange` reads it, and the model's node selection
// gets overwritten by a text caret in the first block (the Enter meant
// for the atom split the opening paragraph instead).
domSel.removeAllRanges(); domSel.removeAllRanges();
const lastId = selection.ids.at(-1);
const el = lastId === undefined
? null
: root.querySelector(`[data-block-id="${CSS.escape(lastId)}"]`);
if (el) {
const range = root.ownerDocument.createRange();
range.selectNode(el);
domSel.addRange(range);
}
if (root.isContentEditable && root.ownerDocument.activeElement !== root) if (root.isContentEditable && root.ownerDocument.activeElement !== root)
root.focus({ preventScroll: true }); root.focus({ preventScroll: true });
return; return;
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'; import { onBeforeUnmount, ref, shallowRef } from 'vue';
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives'; import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives';
import { isCollapsed } from '../../model'; import { isCollapsed } from '../../model';
import { isMarkActive, toggleMark } from '../../commands'; import { isMarkActive, toggleMark } from '../../commands';
@@ -17,7 +17,7 @@ const ctx = useWritekitContext();
// Virtual reference (a `Measurable`) anchored to the selection rect Popper // Virtual reference (a `Measurable`) anchored to the selection rect Popper
// positions against it with no trigger element. Reassigned on every refresh so // positions against it with no trigger element. Reassigned on every refresh so
// PopperContent re-resolves position as the selection moves. // 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 open = ref(false);
const rev = ref(0); const rev = ref(0);
@@ -54,8 +54,11 @@ function toggle(type: string): void {
</script> </script>
<template> <template>
<Portal to="body"> <!-- Combobox layering: PopperRoot provides the positioning context outside
<PopperRoot> the portal. The bare Portal resolves its target from the ConfigProvider's
teleportTarget (body unless the app overrides it). -->
<PopperRoot>
<Portal>
<PopperContent <PopperContent
v-if="open && reference" v-if="open && reference"
:reference="reference" :reference="reference"
@@ -83,6 +86,6 @@ function toggle(type: string): void {
</slot> </slot>
</DismissableLayer> </DismissableLayer>
</PopperContent> </PopperContent>
</PopperRoot> </Portal>
</Portal> </PopperRoot>
</template> </template>
+123 -54
View File
@@ -1,38 +1,8 @@
<script setup lang="ts"> <script lang="ts">
import { onBeforeUnmount, ref } from 'vue'; /** Regexp-special characters, escaped when the trigger is interpolated. */
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives'; const ESCAPE_RE = /[.*+?^${}()|[\]\\]/g;
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, '\\$&');
}
/** The caret's client rect, when the native selection has a visible one. */
function caretRect(): DOMRect | null { function caretRect(): DOMRect | null {
const selection = globalThis.window === undefined ? null : globalThis.getSelection(); const selection = globalThis.window === undefined ? null : globalThis.getSelection();
if (!selection || selection.rangeCount === 0) if (!selection || selection.rangeCount === 0)
@@ -43,6 +13,60 @@ function caretRect(): DOMRect | null {
const rect = rects.length > 0 ? rects[0]! : range.getBoundingClientRect(); const rect = rects.length > 0 ? rects[0]! : range.getBoundingClientRect();
return rect.width || rect.height ? rect : null; 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 { function close(): void {
open.value = false; open.value = false;
@@ -65,7 +89,7 @@ function refresh(): void {
} }
const before = inlineText(nodeInline(block)).slice(0, sel.focus.offset); 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) { if (!match) {
close(); 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); ctx.writekit.on('transaction', refresh);
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', 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), '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)); onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
</script> </script>
<template> <template>
<Portal to="body"> <!-- Combobox layering: PopperRoot provides the positioning context outside
<PopperRoot> the portal. The bare Portal resolves its target from the ConfigProvider's
teleportTarget (body unless the app overrides it). -->
<PopperRoot>
<Portal>
<PopperContent <PopperContent
v-if="open && reference" v-if="open && reference"
:reference="reference" :reference="reference"
@@ -167,27 +217,46 @@ onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
:collision-padding="8" :collision-padding="8"
> >
<DismissableLayer <DismissableLayer
class="writekit-slash-menu" ref="layer"
role="listbox" class="writekit-slash"
data-writekit-slash-menu="" data-writekit-slash=""
@dismiss="close" @dismiss="close"
@focus-outside.prevent @focus-outside.prevent
> >
<button <div
v-for="(item, index) in items" class="writekit-slash-menu"
:key="item.type" role="listbox"
type="button" data-writekit-slash-menu=""
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> <button
<span class="slash-group">{{ item.group }}</span> v-for="(item, index) in items"
</button> :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> </DismissableLayer>
</PopperContent> </PopperContent>
</PopperRoot> </Portal>
</Portal> </PopperRoot>
</template> </template>
+2
View File
@@ -6,6 +6,7 @@ export interface SlashItem {
title: string; title: string;
group: string; group: string;
keywords: readonly string[]; keywords: readonly string[];
description?: string;
} }
/** /**
@@ -21,6 +22,7 @@ export function getSlashItems(registry: Registry, query = ''): SlashItem[] {
title: def.meta!.title, title: def.meta!.title,
group: def.meta!.group ?? 'blocks', group: def.meta!.group ?? 'blocks',
keywords: def.meta!.keywords ?? [], keywords: def.meta!.keywords ?? [],
...(def.meta!.description !== undefined && { description: def.meta!.description }),
})); }));
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();