551b3bd921
Publish to NPM / Check version changes and publish (push) Successful in 10m0s
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>
231 lines
9.0 KiB
TypeScript
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);
|
|
});
|
|
});
|