Files
tools/vue/writekit/src/view/__test__/writekit.browser.test.ts
T
robonen 551b3bd921
Publish to NPM / Check version changes and publish (push) Successful in 10m0s
fix(writekit): a node selection is a real DOM range, not an absence of one
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

231 lines
9.0 KiB
TypeScript

import { render } from 'vitest-browser-vue';
import { describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
import { createDoc, createNode, textSelection } from '../../model';
import { createDefaultRegistry } from '../../preset';
import { createTransaction, createWritekit, createWritekitState } from '../../state';
import WritekitRoot from '../WritekitRoot.vue';
function para(id: string, text: string) {
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
}
function mount(blocks: Array<ReturnType<typeof para>>) {
const registry = createDefaultRegistry();
const writekit = createWritekit({ state: createWritekitState({ registry, doc: createDoc(blocks) }) });
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
return writekit;
}
function selectNative(anchor: { node: Node; offset: number }, focus: { node: Node; offset: number }) {
const sel = getSelection()!;
sel.removeAllRanges();
const range = document.createRange();
range.setStart(anchor.node, anchor.offset);
range.setEnd(focus.node, focus.offset);
sel.addRange(range);
}
describe('WritekitRoot (single contenteditable)', () => {
it('renders ONE editable root containing non-editable block elements', async () => {
mount([para('a', 'hello')]);
await nextTick();
const ce = document.querySelector('[data-writekit-content]')!;
expect(ce.getAttribute('contenteditable')).toBe('true');
const host = document.querySelector('[data-block-content]') as HTMLElement;
expect(host.textContent).toBe('hello');
// The block element itself is NOT a separate editing host.
expect(host.getAttribute('contenteditable')).toBeNull();
});
it('maps a cross-block native selection to a cross-block model range', async () => {
const writekit = mount([para('a', 'hello'), para('b', 'world')]);
await nextTick();
const hosts = document.querySelectorAll('[data-block-content]');
const aText = hosts[0]!.firstChild!; // text node "hello"
const bText = hosts[1]!.firstChild!; // text node "world"
selectNative({ node: aText, offset: 1 }, { node: bText, offset: 3 });
// `selectionchange` is dispatched on a macrotask, so awaiting microtasks
// (nextTick) isn't enough — poll until the writekit has synced the model.
const sel = await vi.waitFor(() => {
const s = writekit.state.selection;
if (s.kind !== 'text' || s.anchor.offset !== 1)
throw new Error('selection not synced yet');
return s;
});
expect(sel.anchor.blockId).toBe('a');
expect(sel.anchor.offset).toBe(1);
expect(sel.focus.blockId).toBe('b');
expect(sel.focus.offset).toBe(3);
});
it('writes a cross-block model selection back to a native range spanning blocks', async () => {
const writekit = mount([para('a', 'hello'), para('b', 'world')]);
await nextTick();
writekit.dispatch(createTransaction(writekit.state).setSelection(
textSelection({ blockId: 'a', offset: 2 }, { blockId: 'b', offset: 4 }),
));
await nextTick();
await nextTick();
const sel = getSelection()!;
const hosts = document.querySelectorAll('[data-block-content]');
expect(hosts[0]!.contains(sel.anchorNode)).toBe(true);
expect(hosts[1]!.contains(sel.focusNode)).toBe(true);
expect(sel.isCollapsed).toBe(false);
});
it('applies bold via Mod-b to a selected range', async () => {
const writekit = mount([para('a', 'hello')]);
await nextTick();
writekit.dispatch(createTransaction(writekit.state).setSelection(
textSelection({ blockId: 'a', offset: 0 }, { blockId: 'a', offset: 5 }),
));
await nextTick();
const root = document.querySelector<HTMLElement>('[data-writekit-root]')!;
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true }));
await nextTick();
await nextTick();
expect(document.querySelector('[data-block-content] strong')?.textContent).toBe('hello');
});
it('splits a block on Enter', async () => {
const writekit = mount([para('a', 'hello')]);
await nextTick();
writekit.dispatch(createTransaction(writekit.state).setSelection(textSelection({ blockId: 'a', offset: 2 })));
await nextTick();
const root = document.querySelector<HTMLElement>('[data-writekit-root]')!;
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await nextTick();
await nextTick();
const hosts = document.querySelectorAll('[data-block-content]');
expect(hosts.length).toBe(2);
expect(hosts[0]!.textContent).toBe('he');
expect(hosts[1]!.textContent).toBe('llo');
});
it('merges into the previous block on Backspace at block start', async () => {
const writekit = mount([para('a', 'foo'), para('b', 'bar')]);
await nextTick();
writekit.dispatch(createTransaction(writekit.state).setSelection(textSelection({ blockId: 'b', offset: 0 })));
await nextTick();
const root = document.querySelector<HTMLElement>('[data-writekit-root]')!;
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true }));
await nextTick();
await nextTick();
const hosts = document.querySelectorAll('[data-block-content]');
expect(hosts.length).toBe(1);
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);
});
});