You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
590 lines
18 KiB
590 lines
18 KiB
// @vitest-environment jsdom
|
|
|
|
import { flushSync, tick } from 'svelte';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { createActiveDom } from '$adom';
|
|
import { state } from '$libs/reactive';
|
|
import type { Morfo } from '$uix/morfo';
|
|
import { Soma } from '$soma/core/soma.svelte';
|
|
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
|
import { FieldProvider } from '../field/field-provider.svelte';
|
|
|
|
import {
|
|
WordsCommandButtonProvider,
|
|
WordsContentProvider,
|
|
WordsHiddenInputProvider,
|
|
WordsProvider
|
|
} from './words-provider.svelte';
|
|
import type { WordsCommandName } from './types';
|
|
import {
|
|
createEmptyWordsDocument,
|
|
createList,
|
|
createListItem,
|
|
createParagraph,
|
|
createText,
|
|
renderWordsPlainText,
|
|
type WordsDocument,
|
|
type WordsSelection
|
|
} from './engine';
|
|
|
|
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
|
let result!: T;
|
|
const cleanup = $effect.root(() => {
|
|
result = fn();
|
|
});
|
|
return { result, cleanup };
|
|
}
|
|
|
|
function installSomaHarness() {
|
|
const dom = createActiveDom();
|
|
const logger = {
|
|
trace: vi.fn(),
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
fatal: vi.fn()
|
|
};
|
|
const soma = {
|
|
dom,
|
|
logger,
|
|
langs: {
|
|
ts: vi.fn((key: string) => key)
|
|
},
|
|
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
|
createSomaRuntime(morfo, {
|
|
dom,
|
|
translate: (key) => key,
|
|
...sources
|
|
})
|
|
} as unknown as Soma;
|
|
|
|
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
|
vi.spyOn(WordsProvider.ctx, 'set').mockImplementation((value) => value);
|
|
vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined);
|
|
|
|
return { dom, logger };
|
|
}
|
|
|
|
function wordsOpts(root = document.createElement('div'), value = createEmptyWordsDocument()) {
|
|
return {
|
|
id: state('words-root'),
|
|
ref: state<HTMLElement | null>(root),
|
|
contentId: state('words-content'),
|
|
value: state<WordsDocument>(value),
|
|
selection: state<WordsSelection | null>(null),
|
|
placeholder: state<string | undefined>('Start writing'),
|
|
name: state<string | undefined>('body'),
|
|
disabled: state(false),
|
|
readonly: state(false),
|
|
required: state(false),
|
|
invalid: state(false),
|
|
commitOnBlur: state(true),
|
|
ariaLabel: state<string | undefined>(undefined),
|
|
onValueChange: state<((value: WordsDocument) => void) | undefined>(undefined),
|
|
onSelectionChange: state<((value: WordsSelection | null) => void) | undefined>(undefined),
|
|
onValueCommit: state<
|
|
((value: WordsDocument, reason: 'programmatic' | 'blur' | 'button') => void) | undefined
|
|
>(undefined)
|
|
};
|
|
}
|
|
|
|
function mountRenderedSelection(root: HTMLElement, offset = 0) {
|
|
const text = root.querySelector('[data-words-node="text"]')?.firstChild;
|
|
if (!text) throw new Error('Words text node not rendered');
|
|
const range = document.createRange();
|
|
range.setStart(text, offset);
|
|
range.collapse(true);
|
|
const selection = document.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
}
|
|
|
|
function mountRenderedRange(root: HTMLElement, start: number, end: number) {
|
|
const text = root.querySelector('[data-words-node="text"]')?.firstChild;
|
|
if (!text) throw new Error('Words text node not rendered');
|
|
const range = document.createRange();
|
|
range.setStart(text, start);
|
|
range.setEnd(text, end);
|
|
const selection = document.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
}
|
|
|
|
describe('WordsProvider', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
it('projects provider/content attrs and exposes serialized hidden input', async () => {
|
|
const { dom } = installSomaHarness();
|
|
const root = document.createElement('div');
|
|
const contentEl = document.createElement('div');
|
|
const inputEl = document.createElement('input');
|
|
document.body.append(root, contentEl, inputEl);
|
|
const opts = wordsOpts(root, {
|
|
version: 1,
|
|
children: [createParagraph([createText('hello')])]
|
|
});
|
|
opts.required.current = true;
|
|
opts.invalid.current = true;
|
|
|
|
const { result, cleanup } = withEffectRoot(() => {
|
|
const provider = WordsProvider.create(opts);
|
|
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
|
|
const content = WordsContentProvider.create({
|
|
id: state('words-content'),
|
|
ref: state<HTMLElement | null>(contentEl)
|
|
});
|
|
const hidden = WordsHiddenInputProvider.create({
|
|
id: state('words-hidden'),
|
|
ref: state<HTMLElement | null>(inputEl)
|
|
});
|
|
provider.setContentRef(contentEl);
|
|
return { provider, content, hidden };
|
|
});
|
|
|
|
expect(result.provider.props).toMatchObject({
|
|
id: 'words-root',
|
|
'data-words': ''
|
|
});
|
|
expect(result.content.props).toMatchObject({
|
|
id: 'words-content',
|
|
'data-words-content': '',
|
|
role: 'textbox',
|
|
contenteditable: 'true',
|
|
'aria-multiline': 'true',
|
|
'aria-invalid': true
|
|
});
|
|
expect(result.hidden.props).toMatchObject({
|
|
id: 'words-hidden',
|
|
'data-words-hidden-input': '',
|
|
type: 'hidden',
|
|
name: 'body'
|
|
});
|
|
expect(result.hidden.props.value).toContain('hello');
|
|
|
|
await tick();
|
|
expect(root.getAttribute('data-required')).toBe('');
|
|
expect(root.getAttribute('data-invalid')).toBe('');
|
|
expect(root.hasAttribute('data-empty')).toBe(false);
|
|
expect(contentEl.getAttribute('data-block')).toBe('paragraph');
|
|
|
|
cleanup();
|
|
dom.dispose();
|
|
});
|
|
|
|
it('maps DOM selection plus beforeinput into document edits', () => {
|
|
const { dom } = installSomaHarness();
|
|
const root = document.createElement('div');
|
|
const contentEl = document.createElement('div');
|
|
document.body.append(root, contentEl);
|
|
const opts = wordsOpts(root);
|
|
const onValueChange = vi.fn();
|
|
opts.onValueChange.current = onValueChange;
|
|
|
|
const { result, cleanup } = withEffectRoot(() => {
|
|
const provider = WordsProvider.create(opts);
|
|
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
|
|
const content = WordsContentProvider.create({
|
|
id: state('words-content'),
|
|
ref: state<HTMLElement | null>(contentEl)
|
|
});
|
|
provider.setContentRef(contentEl);
|
|
return { provider, content };
|
|
});
|
|
|
|
contentEl.innerHTML = result.provider.html;
|
|
mountRenderedSelection(contentEl, 0);
|
|
result.content.props.onbeforeinput({
|
|
inputType: 'insertText',
|
|
data: 'Hi',
|
|
isComposing: false,
|
|
currentTarget: contentEl,
|
|
preventDefault: vi.fn()
|
|
} as never);
|
|
|
|
expect(renderWordsPlainText(opts.value.current)).toBe('Hi');
|
|
expect(onValueChange).toHaveBeenCalledOnce();
|
|
expect(result.provider.canUndo).toBe(true);
|
|
|
|
result.provider.undo(contentEl);
|
|
expect(renderWordsPlainText(opts.value.current)).toBe('');
|
|
expect(result.provider.canRedo).toBe(true);
|
|
|
|
cleanup();
|
|
dom.dispose();
|
|
});
|
|
|
|
it('groups contiguous typing beforeinput events into one undo snapshot', () => {
|
|
const { dom } = installSomaHarness();
|
|
const root = document.createElement('div');
|
|
const contentEl = document.createElement('div');
|
|
document.body.append(root, contentEl);
|
|
const opts = wordsOpts(root);
|
|
|
|
const { result, cleanup } = withEffectRoot(() => {
|
|
const provider = WordsProvider.create(opts);
|
|
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
|
|
const content = WordsContentProvider.create({
|
|
id: state('words-content'),
|
|
ref: state<HTMLElement | null>(contentEl)
|
|
});
|
|
provider.setContentRef(contentEl);
|
|
return { provider, content };
|
|
});
|
|
|
|
contentEl.innerHTML = result.provider.html;
|
|
mountRenderedSelection(contentEl, 0);
|
|
result.content.props.onbeforeinput({
|
|
inputType: 'insertText',
|
|
data: 'A',
|
|
isComposing: false,
|
|
currentTarget: contentEl,
|
|
preventDefault: vi.fn()
|
|
} as never);
|
|
|
|
contentEl.innerHTML = result.provider.html;
|
|
mountRenderedSelection(contentEl, 1);
|
|
result.content.props.onbeforeinput({
|
|
inputType: 'insertText',
|
|
data: 'B',
|
|
isComposing: false,
|
|
currentTarget: contentEl,
|
|
preventDefault: vi.fn()
|
|
} as never);
|
|
|
|
expect(renderWordsPlainText(opts.value.current)).toBe('AB');
|
|
expect(result.provider.canUndo).toBe(true);
|
|
|
|
result.provider.undo(contentEl);
|
|
expect(renderWordsPlainText(opts.value.current)).toBe('');
|
|
expect(result.provider.canUndo).toBe(false);
|
|
|
|
cleanup();
|
|
dom.dispose();
|
|
});
|
|
|
|
it('commits IME composition text through the provider', () => {
|
|
const { dom } = installSomaHarness();
|
|
const root = document.createElement('div');
|
|
const contentEl = document.createElement('div');
|
|
document.body.append(root, contentEl);
|
|
const opts = wordsOpts(root);
|
|
|
|
const { result, cleanup } = withEffectRoot(() => {
|
|
const provider = WordsProvider.create(opts);
|
|
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
|
|
const content = WordsContentProvider.create({
|
|
id: state('words-content'),
|
|
ref: state<HTMLElement | null>(contentEl)
|
|
});
|
|
provider.setContentRef(contentEl);
|
|
return { provider, content };
|
|
});
|
|
|
|
contentEl.innerHTML = result.provider.html;
|
|
mountRenderedSelection(contentEl, 0);
|
|
result.content.props.oncompositionstart({ data: 'a' } as never);
|
|
result.content.props.oncompositionupdate({ data: 'あ' } as never);
|
|
result.content.props.oncompositionend({ data: 'あ' } as never);
|
|
|
|
expect(renderWordsPlainText(opts.value.current)).toBe('あ');
|
|
expect(result.provider.composition.active).toBe(false);
|
|
|
|
cleanup();
|
|
dom.dispose();
|
|
});
|
|
|
|
it('exposes explicit import and clean export helpers', () => {
|
|
const { dom } = installSomaHarness();
|
|
const root = document.createElement('div');
|
|
document.body.append(root);
|
|
const opts = wordsOpts(root);
|
|
|
|
const { result, cleanup } = withEffectRoot(() => {
|
|
const provider = WordsProvider.create(opts);
|
|
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
|
|
return { provider };
|
|
});
|
|
|
|
result.provider.snippetProps.importContent('Alpha\nBeta', 'text');
|
|
expect(renderWordsPlainText(opts.value.current)).toBe('Alpha\nBeta');
|
|
expect(result.provider.snippetProps.exportContent('html')).toBe('<p>Alpha</p><p>Beta</p>');
|
|
expect(result.provider.snippetProps.exportContent('html')).not.toContain('data-words');
|
|
|
|
cleanup();
|
|
dom.dispose();
|
|
});
|
|
|
|
it('drives command buttons from active marks and history state', () => {
|
|
const { dom } = installSomaHarness();
|
|
const root = document.createElement('div');
|
|
const buttonEl = document.createElement('button');
|
|
document.body.append(root, buttonEl);
|
|
const opts = wordsOpts(root);
|
|
|
|
const { result, cleanup } = withEffectRoot(() => {
|
|
const provider = WordsProvider.create(opts);
|
|
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
|
|
const bold = WordsCommandButtonProvider.create({
|
|
id: state('words-bold'),
|
|
ref: state<HTMLElement | null>(buttonEl),
|
|
command: state<WordsCommandName>('bold'),
|
|
level: state<1 | 2 | 3 | undefined>(undefined),
|
|
listKind: state<'ordered' | 'unordered' | 'check' | undefined>(undefined)
|
|
});
|
|
return { provider, bold };
|
|
});
|
|
|
|
expect(result.bold.props).toMatchObject({
|
|
type: 'button',
|
|
'data-command': 'bold',
|
|
'aria-pressed': false
|
|
});
|
|
|
|
result.bold.props.onclick({ currentTarget: buttonEl } as never);
|
|
expect(result.provider.activeMarks).toEqual(['bold']);
|
|
expect(result.bold.props['aria-pressed']).toBe(true);
|
|
|
|
cleanup();
|
|
dom.dispose();
|
|
});
|
|
|
|
it('syncs a DOM range selection and formats that range from a toolbar command', () => {
|
|
const { dom } = installSomaHarness();
|
|
const root = document.createElement('div');
|
|
const contentEl = document.createElement('div');
|
|
const buttonEl = document.createElement('button');
|
|
document.body.append(root, contentEl, buttonEl);
|
|
const opts = wordsOpts(root, {
|
|
version: 1,
|
|
children: [createParagraph([createText('Hello')])]
|
|
});
|
|
|
|
const { result, cleanup } = withEffectRoot(() => {
|
|
const provider = WordsProvider.create(opts);
|
|
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
|
|
const content = WordsContentProvider.create({
|
|
id: state('words-content'),
|
|
ref: state<HTMLElement | null>(contentEl)
|
|
});
|
|
const bold = WordsCommandButtonProvider.create({
|
|
id: state('words-bold'),
|
|
ref: state<HTMLElement | null>(buttonEl),
|
|
command: state<WordsCommandName>('bold'),
|
|
level: state<1 | 2 | 3 | undefined>(undefined),
|
|
listKind: state<'ordered' | 'unordered' | 'check' | undefined>(undefined)
|
|
});
|
|
provider.setContentRef(contentEl);
|
|
return { provider, content, bold };
|
|
});
|
|
|
|
contentEl.innerHTML = result.provider.html;
|
|
mountRenderedRange(contentEl, 1, 4);
|
|
result.content.props.onselect(new Event('select') as never);
|
|
|
|
expect(result.provider.selection).toEqual({
|
|
anchor: { path: [0, 0], offset: 1 },
|
|
focus: { path: [0, 0], offset: 4 }
|
|
});
|
|
|
|
result.bold.props.onclick({ currentTarget: buttonEl } as never);
|
|
expect(result.provider.exportContent('html')).toBe('<p>H<strong>ell</strong>o</p>');
|
|
|
|
cleanup();
|
|
dom.dispose();
|
|
});
|
|
|
|
it('activates only the command button matching the current list kind', () => {
|
|
const { dom } = installSomaHarness();
|
|
const root = document.createElement('div');
|
|
const unorderedEl = document.createElement('button');
|
|
const orderedEl = document.createElement('button');
|
|
const checkEl = document.createElement('button');
|
|
document.body.append(root, unorderedEl, orderedEl, checkEl);
|
|
const opts = wordsOpts(root, {
|
|
version: 1,
|
|
children: [createList('ordered', [createListItem([createText('Item')])])]
|
|
});
|
|
opts.selection.current = {
|
|
anchor: { path: [0, 0, 0], offset: 0 },
|
|
focus: { path: [0, 0, 0], offset: 0 }
|
|
};
|
|
|
|
const { result, cleanup } = withEffectRoot(() => {
|
|
const provider = WordsProvider.create(opts);
|
|
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
|
|
const unordered = WordsCommandButtonProvider.create({
|
|
id: state('words-unordered'),
|
|
ref: state<HTMLElement | null>(unorderedEl),
|
|
command: state<WordsCommandName>('unordered-list'),
|
|
level: state<1 | 2 | 3 | undefined>(undefined),
|
|
listKind: state<'ordered' | 'unordered' | 'check' | undefined>('unordered')
|
|
});
|
|
const ordered = WordsCommandButtonProvider.create({
|
|
id: state('words-ordered'),
|
|
ref: state<HTMLElement | null>(orderedEl),
|
|
command: state<WordsCommandName>('ordered-list'),
|
|
level: state<1 | 2 | 3 | undefined>(undefined),
|
|
listKind: state<'ordered' | 'unordered' | 'check' | undefined>('ordered')
|
|
});
|
|
const check = WordsCommandButtonProvider.create({
|
|
id: state('words-check'),
|
|
ref: state<HTMLElement | null>(checkEl),
|
|
command: state<WordsCommandName>('check-list'),
|
|
level: state<1 | 2 | 3 | undefined>(undefined),
|
|
listKind: state<'ordered' | 'unordered' | 'check' | undefined>('check')
|
|
});
|
|
return { unordered, ordered, check };
|
|
});
|
|
|
|
expect(result.unordered.props['aria-pressed']).toBe(false);
|
|
expect(result.ordered.props['aria-pressed']).toBe(true);
|
|
expect(result.check.props['aria-pressed']).toBe(false);
|
|
|
|
cleanup();
|
|
dom.dispose();
|
|
});
|
|
|
|
it('syncs external value replacements without emitting user change callbacks', () => {
|
|
const { dom } = installSomaHarness();
|
|
const root = document.createElement('div');
|
|
document.body.append(root);
|
|
const opts = wordsOpts(root, {
|
|
version: 1,
|
|
children: [createParagraph([createText('Initial')])]
|
|
});
|
|
const onValueChange = vi.fn();
|
|
opts.onValueChange.current = onValueChange;
|
|
|
|
const { result, cleanup } = withEffectRoot(() => {
|
|
const provider = WordsProvider.create(opts);
|
|
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
|
|
return { provider };
|
|
});
|
|
|
|
onValueChange.mockClear();
|
|
opts.value.current = {
|
|
version: 1,
|
|
children: [createParagraph([createText('External')])]
|
|
};
|
|
flushSync();
|
|
|
|
expect(renderWordsPlainText(result.provider.document)).toBe('External');
|
|
expect(result.provider.dirty).toBe(false);
|
|
expect(result.provider.canUndo).toBe(false);
|
|
expect(onValueChange).not.toHaveBeenCalled();
|
|
|
|
cleanup();
|
|
dom.dispose();
|
|
});
|
|
|
|
it('syncs external selection changes without echoing onSelectionChange', () => {
|
|
const { dom } = installSomaHarness();
|
|
const root = document.createElement('div');
|
|
document.body.append(root);
|
|
const opts = wordsOpts(root, {
|
|
version: 1,
|
|
children: [createParagraph([createText('Hello')])]
|
|
});
|
|
const onSelectionChange = vi.fn();
|
|
opts.onSelectionChange.current = onSelectionChange;
|
|
|
|
const { result, cleanup } = withEffectRoot(() => {
|
|
const provider = WordsProvider.create(opts);
|
|
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
|
|
return { provider };
|
|
});
|
|
|
|
onSelectionChange.mockClear();
|
|
opts.selection.current = {
|
|
anchor: { path: [0, 0], offset: 2 },
|
|
focus: { path: [0, 0], offset: 2 }
|
|
};
|
|
flushSync();
|
|
|
|
expect(result.provider.selection).toEqual({
|
|
anchor: { path: [0, 0], offset: 2 },
|
|
focus: { path: [0, 0], offset: 2 }
|
|
});
|
|
expect(result.provider.activeMarks).toEqual([]);
|
|
expect(onSelectionChange).not.toHaveBeenCalled();
|
|
|
|
cleanup();
|
|
dom.dispose();
|
|
});
|
|
|
|
it('logs unsupported paste payloads through the UIX logger', () => {
|
|
const { dom, logger } = installSomaHarness();
|
|
const root = document.createElement('div');
|
|
const contentEl = document.createElement('div');
|
|
document.body.append(root, contentEl);
|
|
const opts = wordsOpts(root);
|
|
|
|
const { result, cleanup } = withEffectRoot(() => {
|
|
const provider = WordsProvider.create(opts);
|
|
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
|
|
const content = WordsContentProvider.create({
|
|
id: state('words-content'),
|
|
ref: state<HTMLElement | null>(contentEl)
|
|
});
|
|
provider.setContentRef(contentEl);
|
|
return { content };
|
|
});
|
|
|
|
result.content.props.onpaste({
|
|
currentTarget: contentEl,
|
|
clipboardData: {
|
|
getData: (type: string) => (type === 'text/html' ? '<p>unsafe</p>' : '')
|
|
},
|
|
preventDefault: vi.fn()
|
|
} as never);
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'soma.words',
|
|
'Ignored unsupported editor input: unsupported-html',
|
|
expect.objectContaining({ context: { reason: 'unsupported-html' } })
|
|
);
|
|
|
|
cleanup();
|
|
dom.dispose();
|
|
});
|
|
|
|
it('does not cancel native beforeinput when the engine does not own the input type', () => {
|
|
const { dom, logger } = installSomaHarness();
|
|
const root = document.createElement('div');
|
|
const contentEl = document.createElement('div');
|
|
document.body.append(root, contentEl);
|
|
const opts = wordsOpts(root);
|
|
|
|
const { result, cleanup } = withEffectRoot(() => {
|
|
const provider = WordsProvider.create(opts);
|
|
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
|
|
const content = WordsContentProvider.create({
|
|
id: state('words-content'),
|
|
ref: state<HTMLElement | null>(contentEl)
|
|
});
|
|
provider.setContentRef(contentEl);
|
|
return { content };
|
|
});
|
|
|
|
const preventDefault = vi.fn();
|
|
result.content.props.onbeforeinput({
|
|
inputType: 'insertFromYank',
|
|
data: null,
|
|
isComposing: false,
|
|
currentTarget: contentEl,
|
|
preventDefault
|
|
} as never);
|
|
|
|
expect(preventDefault).not.toHaveBeenCalled();
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
|
|
cleanup();
|
|
dom.dispose();
|
|
});
|
|
});
|