feat(writekit): rename @robonen/editor to @robonen/writekit

Rename the rich-text editor package and all Editor* exports to Writekit*;
remove the old vue/editor tree.
This commit is contained in:
2026-06-15 16:54:06 +07:00
parent 55e78786d6
commit 263c32002f
149 changed files with 1563 additions and 1748 deletions
@@ -0,0 +1,136 @@
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');
});
});