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
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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@robonen/writekit",
|
"name": "@robonen/writekit",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"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": [
|
||||||
|
|||||||
@@ -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.' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 1–6.' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.' });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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, insertHardBreak, joinBackward, joinForward, splitBlock } from '../commands';
|
||||||
import { createTransaction } from '../state';
|
import { createTransaction } from '../state';
|
||||||
import { Primitive } from './primitive';
|
import { Primitive } from './primitive';
|
||||||
@@ -117,6 +117,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 +178,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,57 @@ 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { useContextFactory, useEventListener } from '@robonen/vue';
|
export { unrefElement, useContextFactory, useEventListener } from '@robonen/vue';
|
||||||
|
|||||||
@@ -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
|
||||||
|
the portal. The bare Portal resolves its target from the ConfigProvider's
|
||||||
|
teleportTarget (body unless the app overrides it). -->
|
||||||
<PopperRoot>
|
<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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
the portal. The bare Portal resolves its target from the ConfigProvider's
|
||||||
|
teleportTarget (body unless the app overrides it). -->
|
||||||
<PopperRoot>
|
<PopperRoot>
|
||||||
|
<Portal>
|
||||||
<PopperContent
|
<PopperContent
|
||||||
v-if="open && reference"
|
v-if="open && reference"
|
||||||
:reference="reference"
|
:reference="reference"
|
||||||
@@ -167,15 +217,21 @@ onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
|
|||||||
:collision-padding="8"
|
:collision-padding="8"
|
||||||
>
|
>
|
||||||
<DismissableLayer
|
<DismissableLayer
|
||||||
|
ref="layer"
|
||||||
|
class="writekit-slash"
|
||||||
|
data-writekit-slash=""
|
||||||
|
@dismiss="close"
|
||||||
|
@focus-outside.prevent
|
||||||
|
>
|
||||||
|
<div
|
||||||
class="writekit-slash-menu"
|
class="writekit-slash-menu"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
data-writekit-slash-menu=""
|
data-writekit-slash-menu=""
|
||||||
@dismiss="close"
|
|
||||||
@focus-outside.prevent
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="(item, index) in items"
|
v-for="(item, index) in items"
|
||||||
:key="item.type"
|
:key="item.type"
|
||||||
|
ref="options"
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
:data-highlighted="index === highlighted || undefined"
|
:data-highlighted="index === highlighted || undefined"
|
||||||
@@ -186,8 +242,21 @@ onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
|
|||||||
<span class="slash-title">{{ item.title }}</span>
|
<span class="slash-title">{{ item.title }}</span>
|
||||||
<span class="slash-group">{{ item.group }}</span>
|
<span class="slash-group">{{ item.group }}</span>
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user