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.
svelte-kit-vice/src/uix/soma/components/words/engine/engine.test.ts

913 lines
27 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

import { describe, expect, it } from 'vitest';
import { createRequire } from 'node:module';
import {
WORDS_EMPTY_TEXT_ATTR,
WORDS_NODE_ATTR,
WORDS_PATH_ATTR,
actionFromPaste,
actionFromBeforeInput,
applyHistoryCommand,
applyWordsCommand,
batchHistoryCommands,
beginWordsComposition,
createCollapsedSelection,
createEmptyWordsDocument,
createLink,
createList,
createListItem,
createParagraph,
createText,
createWordsHistory,
createWordsState,
decodeWordsPath,
documentPlainText,
domTargetToWordsPoint,
domTargetToWordsSelection,
endWordsComposition,
encodeWordsDomSelectionTarget,
encodeWordsPath,
normalizeDocument,
parseWordsHtml,
parseWordsJson,
parseWordsMarkdown,
parseWordsPlainText,
redoWords,
renderWordsDomHtml,
renderWordsDocument,
resolveDomPointFromTarget,
restoreSelectionApiFromDomTarget,
serializeWordsJson,
serializeWordsHtml,
serializeWordsMarkdown,
selectionApiToWordsSelection,
updateWordsComposition,
undoWords,
type WordsDocument,
type WordsEditorState
} from './index';
const require = createRequire(import.meta.url);
const { JSDOM } = require('jsdom') as {
readonly JSDOM: new (html: string) => { readonly window: Window };
};
function stateWithText(text: string): WordsEditorState {
return createWordsState(
{
version: 1,
children: [createParagraph([createText(text)])]
},
createCollapsedSelection([0, 0], text.length)
);
}
describe('Words engine', () => {
it('normalizes invalid and empty documents into one paragraph', () => {
const result = normalizeDocument({
version: 99,
children: [
{ type: 'unknown' },
{ type: 'heading', level: 9, children: [] },
{
type: 'paragraph',
children: [
{ type: 'text', text: 'A', marks: ['bold', 'bogus', 'bold'] },
{ type: 'text', text: 'B', marks: ['bold'] }
]
}
]
});
expect(result.document.version).toBe(1);
expect(result.issues.map((issue) => issue.reason)).toContain('invalid-block');
expect(result.issues.map((issue) => issue.reason)).toContain('invalid-heading-level');
expect(result.issues.map((issue) => issue.reason)).toContain('invalid-mark');
expect(result.document.children).toHaveLength(2);
expect(result.document.children[0]).toMatchObject({
type: 'heading',
level: 1,
children: [{ type: 'text', text: '' }]
});
expect(result.document.children[1]).toMatchObject({
type: 'paragraph',
children: [{ type: 'text', text: 'AB', marks: ['bold'] }]
});
expect(normalizeDocument(null).document).toEqual(createEmptyWordsDocument());
});
it('rejects unsafe links but preserves their text', () => {
const result = normalizeDocument({
version: 1,
children: [
{
type: 'paragraph',
children: [
{
type: 'link',
href: 'javascript:alert(1)',
children: [{ type: 'text', text: 'click me' }]
}
]
}
]
});
expect(result.issues.map((issue) => issue.reason)).toContain('unsafe-url');
expect(result.document.children[0]).toMatchObject({
type: 'paragraph',
children: [{ type: 'text', text: 'click me' }]
});
});
it('flattens nested links during normalization and blocks inserting links inside links', () => {
const result = normalizeDocument({
version: 1,
children: [
createParagraph([
createLink('/outer', [createText('Outer '), createLink('/inner', [createText('Inner')])])
])
]
});
expect(result.issues.map((issue) => issue.reason)).toContain('nested-link');
expect(result.document.children[0]).toMatchObject({
type: 'paragraph',
children: [
{
type: 'link',
href: '/outer',
children: [{ type: 'text', text: 'Outer Inner' }]
}
]
});
const state = createWordsState(
result.document,
createCollapsedSelection([0, 0, 0], 'Outer'.length)
);
const nested = applyWordsCommand(state, { type: 'insertLink', href: '/nested' });
expect(nested.changed).toBe(false);
expect(nested.state.document).toEqual(state.document);
});
it('serializes and parses canonical JSON', () => {
const doc: WordsDocument = {
version: 1,
children: [createParagraph([createText('Hello', ['italic', 'bold'])])]
};
const json = serializeWordsJson(doc);
expect(parseWordsJson(json)).toEqual({
version: 1,
children: [createParagraph([createText('Hello', ['bold', 'italic'])])]
});
});
it('applies text, mark and block commands', () => {
let state = stateWithText('Hello');
let result = applyWordsCommand(state, { type: 'insertText', text: ' world' });
expect(result.changed).toBe(true);
expect(documentPlainText(result.state.document)).toBe('Hello world');
expect(result.state.selection).toEqual(createCollapsedSelection([0, 0], 11));
state = result.state;
result = applyWordsCommand(state, { type: 'toggleMark', mark: 'bold' });
expect(result.state.activeMarks).toEqual(['bold']);
expect(result.state.document.children[0]).toMatchObject({
type: 'paragraph',
children: [{ type: 'text', text: 'Hello world' }]
});
result = applyWordsCommand(result.state, { type: 'insertText', text: '!' });
expect(result.state.document.children[0]).toMatchObject({
type: 'paragraph',
children: [
{ type: 'text', text: 'Hello world' },
{ type: 'text', text: '!', marks: ['bold'] }
]
});
result = applyWordsCommand(result.state, { type: 'setBlock', block: 'heading', level: 2 });
expect(result.state.document.children[0]).toMatchObject({
type: 'heading',
level: 2
});
});
it('applies mark commands only to selected text ranges', () => {
const state: WordsEditorState = {
...stateWithText('Alpha beta'),
selection: {
anchor: { path: [0, 0], offset: 6 },
focus: { path: [0, 0], offset: 10 }
}
};
const result = applyWordsCommand(state, { type: 'toggleMark', mark: 'italic' });
expect(result.state.document.children[0]).toMatchObject({
type: 'paragraph',
children: [
{ type: 'text', text: 'Alpha ' },
{ type: 'text', text: 'beta', marks: ['italic'] }
]
});
});
it('toggles lists and links', () => {
let state = stateWithText('Alpha');
let result = applyWordsCommand(state, { type: 'toggleList', kind: 'check' });
expect(result.state.document.children[0]).toMatchObject({
type: 'list',
kind: 'check',
children: [{ type: 'list-item', children: [{ type: 'text', text: 'Alpha' }] }]
});
result = applyWordsCommand(result.state, { type: 'toggleList', kind: 'check' });
expect(result.state.document.children[0]).toMatchObject({
type: 'paragraph',
children: [{ type: 'text', text: 'Alpha' }]
});
state = {
...result.state,
selection: {
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 5 }
}
};
result = applyWordsCommand(state, { type: 'insertLink', href: 'https://active.dev' });
expect(result.state.document.children[0]).toMatchObject({
type: 'paragraph',
children: [
{
type: 'link',
href: 'https://active.dev',
children: [{ type: 'text', text: 'Alpha' }]
}
]
});
result = applyWordsCommand(
{ ...result.state, selection: createCollapsedSelection([0, 0], 0) },
{ type: 'unlink' }
);
expect(result.state.document.children[0]).toMatchObject({
type: 'paragraph',
children: [{ type: 'text', text: 'Alpha' }]
});
});
it('deletes by word or line instead of collapsing advanced beforeinput to one char', () => {
let state = stateWithText('Alpha beta');
let result = applyWordsCommand(state, { type: 'deleteWordBackward' });
expect(documentPlainText(result.state.document)).toBe('Alpha ');
expect(result.state.selection).toEqual(createCollapsedSelection([0, 0], 6));
state = stateWithText('Alpha beta');
result = applyWordsCommand(
{ ...state, selection: createCollapsedSelection([0, 0], 6) },
{ type: 'deleteWordForward' }
);
expect(documentPlainText(result.state.document)).toBe('Alpha ');
result = applyWordsCommand(state, { type: 'deleteLineBackward' });
expect(documentPlainText(result.state.document)).toBe('');
expect(actionFromBeforeInput({ inputType: 'deleteWordBackward' })).toEqual({
type: 'command',
command: { type: 'deleteWordBackward' },
batch: 'typing'
});
expect(actionFromBeforeInput({ inputType: 'deleteHardLineForward' })).toEqual({
type: 'command',
command: { type: 'deleteLineForward' },
batch: 'typing'
});
});
it('tracks undo and redo history around changed commands only', () => {
const initial = stateWithText('A');
let history = createWordsHistory(initial);
history = applyHistoryCommand(history, { type: 'insertText', text: 'B' });
history = applyHistoryCommand(history, { type: 'insertText', text: '' });
history = applyHistoryCommand(history, { type: 'insertText', text: 'C' });
expect(history.past).toHaveLength(2);
expect(documentPlainText(history.present.document)).toBe('ABC');
history = undoWords(history);
expect(documentPlainText(history.present.document)).toBe('AB');
history = undoWords(history);
expect(documentPlainText(history.present.document)).toBe('A');
history = redoWords(history);
expect(documentPlainText(history.present.document)).toBe('AB');
});
it('edits text inside list items with deep paths', () => {
let state = createWordsState(
{
version: 1,
children: [createList('unordered', [createListItem([createText('Item')])])]
},
createCollapsedSelection([0, 0, 0], 4)
);
let result = applyWordsCommand(state, { type: 'insertText', text: '!' });
expect(result.changed).toBe(true);
expect(result.state.document.children[0]).toMatchObject({
type: 'list',
children: [{ children: [{ type: 'text', text: 'Item!' }] }]
});
expect(result.state.selection).toEqual(createCollapsedSelection([0, 0, 0], 5));
state = {
...result.state,
selection: {
anchor: { path: [0, 0, 0], offset: 0 },
focus: { path: [0, 0, 0], offset: 4 }
}
};
result = applyWordsCommand(state, { type: 'toggleMark', mark: 'bold' });
expect(result.state.document.children[0]).toMatchObject({
type: 'list',
children: [
{
children: [
{ type: 'text', text: 'Item', marks: ['bold'] },
{ type: 'text', text: '!' }
]
}
]
});
});
it('keeps selection stable after normalization merges adjacent text', () => {
const state: WordsEditorState = {
document: {
version: 1,
children: [createParagraph([createText('AC', ['bold'])])]
},
selection: createCollapsedSelection([0, 0], 1),
activeMarks: ['bold']
};
const result = applyWordsCommand(state, { type: 'insertText', text: 'B' });
expect(result.state.document.children[0]).toMatchObject({
type: 'paragraph',
children: [{ type: 'text', text: 'ABC', marks: ['bold'] }]
});
expect(result.state.selection).toEqual(createCollapsedSelection([0, 0], 2));
});
it('replaces multi-block ranges with inserted text', () => {
const state = createWordsState(
{
version: 1,
children: [createParagraph([createText('Alpha')]), createParagraph([createText('Beta')])]
},
{
anchor: { path: [0, 0], offset: 2 },
focus: { path: [1, 0], offset: 2 }
}
);
const result = applyWordsCommand(state, { type: 'insertText', text: 'X' });
expect(documentPlainText(result.state.document)).toBe('AlXta');
expect(result.state.document.children).toHaveLength(1);
expect(result.state.selection).toEqual(createCollapsedSelection([0, 0], 3));
});
it('splits paragraphs and list items for Enter and merges them for Backspace', () => {
let state = stateWithText('Alpha');
let result = applyWordsCommand(
{ ...state, selection: createCollapsedSelection([0, 0], 2) },
{ type: 'insertParagraph' }
);
expect(result.state.document.children).toHaveLength(2);
expect(result.state.document.children[0]).toMatchObject({
type: 'paragraph',
children: [{ type: 'text', text: 'Al' }]
});
expect(result.state.document.children[1]).toMatchObject({
type: 'paragraph',
children: [{ type: 'text', text: 'pha' }]
});
result = applyWordsCommand(result.state, { type: 'deleteBackward' });
expect(documentPlainText(result.state.document)).toBe('Alpha');
expect(result.state.document.children).toHaveLength(1);
state = createWordsState(
{
version: 1,
children: [createList('unordered', [createListItem([createText('Item')])])]
},
createCollapsedSelection([0, 0, 0], 2)
);
result = applyWordsCommand(state, { type: 'insertParagraph' });
expect(result.state.document.children[0]).toMatchObject({
type: 'list',
children: [
{ type: 'list-item', children: [{ type: 'text', text: 'It' }] },
{ type: 'list-item', children: [{ type: 'text', text: 'em' }] }
]
});
});
it('converts list items to blocks and lets users leave lists', () => {
let state = createWordsState(
{
version: 1,
children: [
createList('unordered', [
createListItem([createText('One')]),
createListItem([createText('Two')])
])
]
},
createCollapsedSelection([0, 1, 0], 1)
);
let result = applyWordsCommand(state, { type: 'setBlock', block: 'paragraph' });
expect(result.state.document.children).toMatchObject([
{ type: 'paragraph', children: [{ type: 'text', text: 'One' }] },
{ type: 'paragraph', children: [{ type: 'text', text: 'Two' }] }
]);
expect(result.state.selection).toEqual(createCollapsedSelection([1, 0], 1));
state = createWordsState(
{
version: 1,
children: [
createList('unordered', [
createListItem([createText('Item')]),
createListItem([createText('')])
])
]
},
createCollapsedSelection([0, 1, 0], 0)
);
result = applyWordsCommand(state, { type: 'insertParagraph' });
expect(result.state.document.children).toMatchObject([
{ type: 'list', children: [{ children: [{ type: 'text', text: 'Item' }] }] },
{ type: 'paragraph', children: [{ type: 'text', text: '' }] }
]);
expect(result.state.selection).toEqual(createCollapsedSelection([1, 0], 0));
state = createWordsState(
{
version: 1,
children: [
createList('check', [
createListItem([createText('First')], true),
createListItem([createText('Second')])
])
]
},
createCollapsedSelection([0, 0, 0], 0)
);
result = applyWordsCommand(state, { type: 'deleteBackward' });
expect(result.state.document.children).toMatchObject([
{ type: 'paragraph', children: [{ type: 'text', text: 'First' }] },
{ type: 'list', kind: 'check', children: [{ children: [{ type: 'text', text: 'Second' }] }] }
]);
expect(result.state.selection).toEqual(createCollapsedSelection([0, 0], 0));
});
it('unlinks from an inner text selection and allows relative URLs', () => {
let state = createWordsState(
{
version: 1,
children: [createParagraph([createText('Docs')])]
},
{
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 4 }
}
);
let result = applyWordsCommand(state, { type: 'insertLink', href: '/docs' });
expect(result.state.document.children[0]).toMatchObject({
type: 'paragraph',
children: [{ type: 'link', href: '/docs', children: [{ type: 'text', text: 'Docs' }] }]
});
state = {
...result.state,
selection: createCollapsedSelection([0, 0, 0], 2)
};
result = applyWordsCommand(state, { type: 'unlink' });
expect(result.state.document.children[0]).toMatchObject({
type: 'paragraph',
children: [{ type: 'text', text: 'Docs' }]
});
});
it('does not normalize while serializing and batches history snapshots', () => {
const doc: WordsDocument = {
version: 1,
children: [createParagraph([createText('A'), createText('B')])]
};
expect(JSON.parse(serializeWordsJson(doc)).children[0].children).toHaveLength(2);
const initial = stateWithText('');
let history = createWordsHistory(initial, { limit: 2 });
history = batchHistoryCommands(history, [
{ type: 'insertText', text: 'A' },
{ type: 'insertText', text: 'B' },
{ type: 'insertText', text: 'C' }
]);
expect(history.past).toHaveLength(1);
expect(documentPlainText(history.present.document)).toBe('ABC');
expect(documentPlainText(undoWords(history).present.document)).toBe('');
history = applyHistoryCommand(history, { type: 'insertText', text: 'D' });
history = applyHistoryCommand(history, { type: 'insertText', text: 'E' });
history = applyHistoryCommand(history, { type: 'insertText', text: 'F' });
expect(history.past).toHaveLength(2);
});
it('keeps DOM render metadata separate from clean export formats', () => {
const doc: WordsDocument = {
version: 1,
children: [
createParagraph([
createText('A', ['bold']),
createLink('/docs', [createText('Docs', ['italic'])])
]),
createList('check', [
createListItem([createText('Done')], true),
createListItem([createText('')])
])
]
};
const domHtml = renderWordsDomHtml(doc);
expect(domHtml).toContain('data-words-path');
expect(domHtml).toContain('\u200b');
const cleanHtml = serializeWordsHtml(doc);
expect(cleanHtml).toBe(
'<p><strong>A</strong><a href="/docs"><em>Docs</em></a></p><ul><li><input type="checkbox" checked disabled> Done</li><li><input type="checkbox" disabled> </li></ul>'
);
expect(cleanHtml).not.toContain('data-words');
expect(cleanHtml).not.toContain('\u200b');
expect(parseWordsPlainText('A\r\nB\u0000')).toEqual({
version: 1,
children: [createParagraph([createText('A')]), createParagraph([createText('B')])]
});
});
it('imports and exports a conservative markdown format', () => {
const doc: WordsDocument = {
version: 1,
children: [
{ type: 'heading', level: 1, children: [createText('Title')] },
createParagraph([
createText('Read '),
createLink('/docs', [createText('docs', ['bold'])]),
createText(' and '),
createText('ship', ['italic'])
]),
createList('check', [
createListItem([createText('Done')], true),
createListItem([createText('Todo')])
])
]
};
expect(serializeWordsMarkdown(doc)).toBe(
'# Title\n\nRead [**docs**](/docs) and _ship_\n\n- [x] Done\n- [ ] Todo'
);
const imported = parseWordsMarkdown(
'# Title\n\nRead [**docs**](/docs) and _ship_\n\n- [x] Done\n- [ ] Todo'
);
expect(imported.children).toEqual([
{ type: 'heading', level: 1, children: [createText('Title')] },
createParagraph([
createText('Read '),
createLink('/docs', [createText('docs', ['bold'])]),
createText(' and '),
createText('ship', ['italic'])
]),
createList('check', [
createListItem([createText('Done')], true),
createListItem([createText('Todo')])
])
]);
});
it('imports safe html while dropping unsupported scripts and unsafe links', () => {
const imported = parseWordsHtml(
[
'<h2>Title</h2>',
'<p>Hello <strong>bold</strong> <a href="javascript:alert(1)">bad</a>',
'<a href="/docs" title="Docs">Docs</a><script>alert(1)</script></p>',
'<ul><li><input type="checkbox" checked disabled> Done</li>',
'<li><input type="checkbox"> Todo</li></ul>'
].join('')
);
expect(imported.children).toEqual([
{ type: 'heading', level: 2, children: [createText('Title')] },
createParagraph([
createText('Hello '),
createText('bold', ['bold']),
createText(' bad'),
createLink('/docs', [createText('Docs')], 'Docs')
]),
createList('check', [
createListItem([createText('Done')], true),
createListItem([createText('Todo')])
])
]);
expect(documentPlainText(imported)).not.toContain('alert');
});
it('imports and exports content through explicit commands', () => {
const initial = stateWithText('');
const imported = applyWordsCommand(initial, {
type: 'importContent',
format: 'text',
content: 'Alpha\nBeta'
});
expect(imported.changed).toBe(true);
expect(documentPlainText(imported.state.document)).toBe('Alpha\nBeta');
const exportedHtml = applyWordsCommand(imported.state, {
type: 'exportContent',
format: 'html'
});
expect(exportedHtml.changed).toBe(false);
expect(exportedHtml.exportedContent).toBe('<p>Alpha</p><p>Beta</p>');
const exportedJson = applyWordsCommand(imported.state, {
type: 'exportContent',
format: 'json'
});
expect(exportedJson.exportedContent).toBe(serializeWordsJson(imported.state.document));
const exportedMarkdown = applyWordsCommand(imported.state, {
type: 'exportContent',
format: 'markdown'
});
expect(exportedMarkdown.exportedContent).toBe('Alpha\n\nBeta');
const importedMarkdown = applyWordsCommand(initial, {
type: 'importContent',
format: 'markdown',
content: '## Notes\n\n- one\n- two'
});
expect(importedMarkdown.changed).toBe(true);
expect(documentPlainText(importedMarkdown.state.document)).toBe('Notes\none\ntwo');
const importedHtml = applyWordsCommand(initial, {
type: 'importContent',
format: 'html',
content: '<h1>Notes</h1><ol><li>one</li><li>two</li></ol>'
});
expect(importedHtml.changed).toBe(true);
expect(documentPlainText(importedHtml.state.document)).toBe('Notes\none\ntwo');
const invalid = applyWordsCommand(imported.state, {
type: 'importContent',
format: 'json',
content: '{'
});
expect(invalid.changed).toBe(false);
expect(invalid.state).toBe(imported.state);
});
it('renders stable data-words metadata for DOM reconciliation', () => {
const doc: WordsDocument = {
version: 1,
children: [
createParagraph([
createText('A', ['bold']),
createLink('/docs', [createText('Docs', ['italic'])])
]),
createList('check', [createListItem([createText('')])])
]
};
const rendered = renderWordsDocument(doc);
expect(rendered[0]).toMatchObject({
kind: 'element',
tag: 'p',
attrs: {
[WORDS_NODE_ATTR]: 'block',
[WORDS_PATH_ATTR]: '0',
'data-words-block': 'paragraph'
}
});
expect(rendered[0]?.children[0]).toMatchObject({
kind: 'element',
tag: 'span',
attrs: {
[WORDS_NODE_ATTR]: 'text',
[WORDS_PATH_ATTR]: '0.0',
'data-words-marks': 'bold'
},
children: [{ kind: 'text', text: 'A' }]
});
expect(rendered[0]?.children[1]).toMatchObject({
kind: 'element',
tag: 'a',
attrs: {
[WORDS_NODE_ATTR]: 'link',
[WORDS_PATH_ATTR]: '0.1',
href: '/docs'
}
});
expect(rendered[1]).toMatchObject({
tag: 'ul',
attrs: {
[WORDS_NODE_ATTR]: 'list',
[WORDS_PATH_ATTR]: '1',
'data-words-list-kind': 'check'
}
});
expect(rendered[1]?.children[0]).toMatchObject({
tag: 'li',
attrs: {
[WORDS_NODE_ATTR]: 'list-item',
[WORDS_PATH_ATTR]: '1.0',
'data-words-checked': 'false'
}
});
expect(
rendered[1]?.children[0]?.kind === 'element' ? rendered[1].children[0].children[0] : undefined
).toMatchObject({
kind: 'element',
attrs: {
[WORDS_NODE_ATTR]: 'text',
[WORDS_PATH_ATTR]: '1.0.0',
[WORDS_EMPTY_TEXT_ATTR]: ''
},
children: [{ kind: 'text', text: '\u200b' }]
});
});
it('maps encoded DOM targets back to clamped Words points and selections', () => {
const doc: WordsDocument = {
version: 1,
children: [createParagraph([createText('Alpha')])]
};
expect(encodeWordsPath([0, 2, 3])).toBe('0.2.3');
expect(decodeWordsPath('0.2.3')).toEqual([0, 2, 3]);
expect(decodeWordsPath('0.x')).toBeUndefined();
expect(domTargetToWordsPoint(doc, { path: '0.0', offset: 99 })).toEqual({
path: [0, 0],
offset: 5
});
expect(domTargetToWordsPoint(doc, { path: '1.0', offset: 0 })).toBeUndefined();
const selection = domTargetToWordsSelection(doc, {
anchor: { path: '0.0', offset: 1 },
focus: { path: '0.0', offset: 4 }
});
expect(selection).toEqual({
anchor: { path: [0, 0], offset: 1 },
focus: { path: [0, 0], offset: 4 }
});
expect(
encodeWordsDomSelectionTarget(doc, {
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 99 }
})
).toEqual({
anchor: { path: '0.0', offset: 0 },
focus: { path: '0.0', offset: 5 }
});
});
it('translates beforeinput intents to engine commands without leaking composition', () => {
expect(actionFromBeforeInput({ inputType: 'insertText', data: 'A' })).toEqual({
type: 'command',
command: { type: 'insertText', text: 'A' },
batch: 'typing'
});
expect(
actionFromBeforeInput({ inputType: 'insertText', data: 'A', isComposing: true })
).toEqual({
type: 'ignore',
reason: 'composition'
});
expect(actionFromBeforeInput({ inputType: 'insertParagraph' })).toEqual({
type: 'command',
command: { type: 'insertParagraph' }
});
expect(actionFromBeforeInput({ inputType: 'deleteContentBackward' })).toEqual({
type: 'command',
command: { type: 'deleteBackward' },
batch: 'typing'
});
expect(actionFromBeforeInput({ inputType: 'formatBold' })).toEqual({
type: 'command',
command: { type: 'toggleMark', mark: 'bold' }
});
expect(actionFromBeforeInput({ inputType: 'historyUndo' })).toEqual({
type: 'history',
direction: 'undo'
});
expect(actionFromBeforeInput({ inputType: 'insertFromPaste', data: 'x' })).toEqual({
type: 'ignore',
reason: 'unsupported'
});
});
it('maps browser Selection API ranges through data-words-path', () => {
const doc: WordsDocument = {
version: 1,
children: [createParagraph([createText('Alpha'), createLink('/docs', [createText('Docs')])])]
};
const dom = new JSDOM(`
<div id="editor">
<p data-words-node="block" data-words-path="0">
<span data-words-node="text" data-words-path="0.0">Alpha</span>
<a data-words-node="link" data-words-path="0.1" href="/docs">
<span data-words-node="text" data-words-path="0.1.0">Docs</span>
</a>
</p>
</div>
`);
const root = dom.window.document.getElementById('editor');
const alpha = root?.querySelector('[data-words-path="0.0"]')?.firstChild;
const docs = root?.querySelector('[data-words-path="0.1.0"]')?.firstChild;
const selection = dom.window.getSelection();
if (!root || !alpha || !docs || !selection) throw new Error('test-dom-missing');
const range = dom.window.document.createRange();
range.setStart(alpha, 2);
range.setEnd(docs, 3);
selection.removeAllRanges();
selection.addRange(range);
expect(selectionApiToWordsSelection(doc, root, selection)).toEqual({
anchor: { path: [0, 0], offset: 2 },
focus: { path: [0, 1, 0], offset: 3 }
});
});
it('restores browser Selection API ranges from encoded DOM targets', () => {
const dom = new JSDOM(`
<div id="editor">
<p data-words-node="block" data-words-path="0">
<span data-words-node="text" data-words-path="0.0">Alpha</span>
<span data-words-node="text" data-words-path="0.1" data-words-empty-text>​</span>
</p>
</div>
`);
const root = dom.window.document.getElementById('editor');
const selection = dom.window.getSelection();
if (!root || !selection) throw new Error('test-dom-missing');
const restored = restoreSelectionApiFromDomTarget(
root,
{
anchor: { path: '0.0', offset: 1 },
focus: { path: '0.1', offset: 9 }
},
selection
);
expect(restored).toBe(true);
expect(selection.anchorNode?.textContent).toBe('Alpha');
expect(selection.anchorOffset).toBe(1);
expect(selection.focusNode?.textContent).toBe('\u200b');
expect(selection.focusOffset).toBe(0);
expect(resolveDomPointFromTarget(root, { path: '0.0', offset: 99 })).toMatchObject({
offset: 5
});
});
it('normalizes paste and composition into explicit text commands', () => {
expect(actionFromPaste({ plainText: 'A\r\nB\u0000' })).toEqual({
type: 'command',
command: { type: 'insertText', text: 'A\nB' }
});
expect(actionFromPaste({ html: '<strong>A</strong>' })).toEqual({
type: 'ignore',
reason: 'unsupported-html'
});
let composition = beginWordsComposition('a');
composition = updateWordsComposition(composition, 'á');
expect(endWordsComposition(composition)).toEqual({
type: 'command',
command: { type: 'insertText', text: 'á' }
});
});
});

Powered by TurnKey Linux.