|
|
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: 'á' }
|
|
|
});
|
|
|
});
|
|
|
});
|