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

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

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

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

writekit 0.0.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 05:11:29 +07:00
parent 66d9faad22
commit 1d105b1f55
19 changed files with 308 additions and 78 deletions
@@ -134,3 +134,57 @@ describe('WritekitRoot (single contenteditable)', () => {
expect(hosts[0]!.textContent).toBe('foobar');
});
});
describe('writing after a trailing atom', () => {
it('a click below the last block starts a paragraph when the doc ends in an atom', async () => {
const registry = createDefaultRegistry();
const writekit = createWritekit({
state: createWritekitState({
registry,
doc: createDoc([para('a', 'text'), createNode('divider', { id: 'd' })]),
}),
});
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
await nextTick();
const root = document.querySelector('[data-writekit-content]') as HTMLElement;
root.style.paddingBottom = '120px';
const rect = root.getBoundingClientRect();
root.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
clientX: rect.left + 10,
clientY: rect.bottom - 10,
}));
await nextTick();
expect(writekit.state.doc.content.map(block => block.type)).toEqual(['paragraph', 'divider', 'paragraph']);
expect(writekit.state.selection.kind).toBe('text');
});
it('a click below the last block just places the caret when it is text', async () => {
const registry = createDefaultRegistry();
const writekit = createWritekit({
state: createWritekitState({ registry, doc: createDoc([para('a', 'text')]) }),
});
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
await nextTick();
const root = document.querySelector('[data-writekit-content]') as HTMLElement;
root.style.paddingBottom = '120px';
const rect = root.getBoundingClientRect();
root.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
clientX: rect.left + 10,
clientY: rect.bottom - 10,
}));
await nextTick();
expect(writekit.state.doc.content).toHaveLength(1);
const sel = writekit.state.selection;
expect(sel.kind === 'text' && sel.focus.offset).toBe(4);
});
});