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>) { 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('[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('[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('[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); }); });