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 @@
-
+
+
-
-
+
+
+
ctx.writekit.off('transaction', refresh));
:collision-padding="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();