From 1d105b1f554218b8c7cc96fbb5933679f5258700 Mon Sep 17 00:00:00 2001 From: robonen Date: Tue, 11 Aug 2026 05:11:29 +0700 Subject: [PATCH] feat(writekit): a way out of atoms, and a slash menu that behaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- vue/writekit/package.json | 2 +- vue/writekit/src/blocks/blockquote.ts | 2 +- vue/writekit/src/blocks/callout.ts | 2 +- vue/writekit/src/blocks/code-block.ts | 2 +- vue/writekit/src/blocks/divider.ts | 2 +- vue/writekit/src/blocks/heading.ts | 2 +- vue/writekit/src/blocks/image.ts | 2 +- vue/writekit/src/blocks/list.ts | 16 +- vue/writekit/src/blocks/paragraph.ts | 2 +- .../src/commands/__test__/commands.test.ts | 29 ++- vue/writekit/src/commands/structure.ts | 31 +++ vue/writekit/src/keymap/defaults.ts | 4 +- vue/writekit/src/registry/define-block.ts | 2 + vue/writekit/src/view/WritekitContent.vue | 38 +++- .../view/__test__/writekit.browser.test.ts | 54 ++++++ vue/writekit/src/view/composables/index.ts | 2 +- .../src/view/ui/WritekitBubbleMenu.vue | 15 +- .../src/view/ui/WritekitSlashMenu.vue | 177 ++++++++++++------ vue/writekit/src/view/ui/slash-items.ts | 2 + 19 files changed, 308 insertions(+), 78 deletions(-) diff --git a/vue/writekit/package.json b/vue/writekit/package.json index 3f93b6e..c677059 100644 --- a/vue/writekit/package.json +++ b/vue/writekit/package.json @@ -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": [ diff --git a/vue/writekit/src/blocks/blockquote.ts b/vue/writekit/src/blocks/blockquote.ts index c1e8df5..42d6654 100644 --- a/vue/writekit/src/blocks/blockquote.ts +++ b/vue/writekit/src/blocks/blockquote.ts @@ -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.' }, }); diff --git a/vue/writekit/src/blocks/callout.ts b/vue/writekit/src/blocks/callout.ts index 4fcdb59..c6ea18f 100644 --- a/vue/writekit/src/blocks/callout.ts +++ b/vue/writekit/src/blocks/callout.ts @@ -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.' }, }); diff --git a/vue/writekit/src/blocks/code-block.ts b/vue/writekit/src/blocks/code-block.ts index 23ef279..25a430d 100644 --- a/vue/writekit/src/blocks/code-block.ts +++ b/vue/writekit/src/blocks/code-block.ts @@ -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.' }, }); diff --git a/vue/writekit/src/blocks/divider.ts b/vue/writekit/src/blocks/divider.ts index 23cfd3c..3aeb90d 100644 --- a/vue/writekit/src/blocks/divider.ts +++ b/vue/writekit/src/blocks/divider.ts @@ -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.' }, }); diff --git a/vue/writekit/src/blocks/heading.ts b/vue/writekit/src/blocks/heading.ts index 89bdeb1..a007eb9 100644 --- a/vue/writekit/src/blocks/heading.ts +++ b/vue/writekit/src/blocks/heading.ts @@ -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 1–6.' }, }); diff --git a/vue/writekit/src/blocks/image.ts b/vue/writekit/src/blocks/image.ts index 5d016d8..3e476c8 100644 --- a/vue/writekit/src/blocks/image.ts +++ b/vue/writekit/src/blocks/image.ts @@ -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.' }, }); diff --git a/vue/writekit/src/blocks/list.ts b/vue/writekit/src/blocks/list.ts index 6869924..c8c7aeb 100644 --- a/vue/writekit/src/blocks/list.ts +++ b/vue/writekit/src/blocks/list.ts @@ -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.' }); diff --git a/vue/writekit/src/blocks/paragraph.ts b/vue/writekit/src/blocks/paragraph.ts index d8a033b..634aa2f 100644 --- a/vue/writekit/src/blocks/paragraph.ts +++ b/vue/writekit/src/blocks/paragraph.ts @@ -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.' }, }); diff --git a/vue/writekit/src/commands/__test__/commands.test.ts b/vue/writekit/src/commands/__test__/commands.test.ts index bf6788d..e36d35c 100644 --- a/vue/writekit/src/commands/__test__/commands.test.ts +++ b/vue/writekit/src/commands/__test__/commands.test.ts @@ -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); diff --git a/vue/writekit/src/commands/structure.ts b/vue/writekit/src/commands/structure.ts index 8179c8e..bd28c0b 100644 --- a/vue/writekit/src/commands/structure.ts +++ b/vue/writekit/src/commands/structure.ts @@ -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; diff --git a/vue/writekit/src/keymap/defaults.ts b/vue/writekit/src/keymap/defaults.ts index 26ae57f..ddfaa03 100644 --- a/vue/writekit/src/keymap/defaults.ts +++ b/vue/writekit/src/keymap/defaults.ts @@ -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), diff --git a/vue/writekit/src/registry/define-block.ts b/vue/writekit/src/registry/define-block.ts index 538cd1a..c2e8aa5 100644 --- a/vue/writekit/src/registry/define-block.ts +++ b/vue/writekit/src/registry/define-block.ts @@ -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. */ diff --git a/vue/writekit/src/view/WritekitContent.vue b/vue/writekit/src/view/WritekitContent.vue index da919e3..8fef550 100644 --- a/vue/writekit/src/view/WritekitContent.vue +++ b/vue/writekit/src/view/WritekitContent.vue @@ -3,7 +3,7 @@ import type { PrimitiveProps } from './primitive'; diff --git a/vue/writekit/src/view/ui/WritekitSlashMenu.vue b/vue/writekit/src/view/ui/WritekitSlashMenu.vue index 1fd12c4..d3d7bec 100644 --- a/vue/writekit/src/view/ui/WritekitSlashMenu.vue +++ b/vue/writekit/src/view/ui/WritekitSlashMenu.vue @@ -1,38 +1,8 @@ - + + diff --git a/vue/writekit/src/view/ui/slash-items.ts b/vue/writekit/src/view/ui/slash-items.ts index 912b2fd..3d14f56 100644 --- a/vue/writekit/src/view/ui/slash-items.ts +++ b/vue/writekit/src/view/ui/slash-items.ts @@ -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();