198 lines
8.7 KiB
JavaScript
198 lines
8.7 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
const { JSDOM } = require('jsdom');
|
|
const ts = require('typescript');
|
|
|
|
const source = fs.readFileSync(path.join(__dirname, '../src/editor-runtime.ts'), 'utf8');
|
|
const compiled = ts.transpileModule(source, {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
|
}).outputText;
|
|
const runtime = {};
|
|
new Function('exports', compiled)(runtime);
|
|
const { removeAdjacentBlock } = runtime;
|
|
const serializeSource = fs.readFileSync(path.join(__dirname, '../src/utils/serialize.ts'), 'utf8');
|
|
const serializeCompiled = ts.transpileModule(serializeSource, {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
|
}).outputText;
|
|
const serializer = {};
|
|
new Function('exports', serializeCompiled)(serializer);
|
|
|
|
function fixture(html, nodeSelector, offset = 0) {
|
|
const dom = new JSDOM(`<!doctype html><body contenteditable="true">${html}</body>`);
|
|
const body = dom.window.document.body;
|
|
const node = nodeSelector === 'body' ? body : body.querySelector(nodeSelector);
|
|
let range = dom.window.document.createRange();
|
|
range.setStart(node, offset);
|
|
range.collapse(true);
|
|
const events = [];
|
|
const editor = {
|
|
getBody: () => body,
|
|
selection: {
|
|
isCollapsed: () => range.collapsed,
|
|
getRng: () => range,
|
|
setRng: (next) => { range = next; },
|
|
setCursorLocation: (target, at) => {
|
|
range = dom.window.document.createRange();
|
|
range.setStart(target, at);
|
|
range.collapse(true);
|
|
},
|
|
},
|
|
dom: { remove: (target) => target.remove() },
|
|
undoManager: { transact: (action) => { events.push('undo'); action(); } },
|
|
nodeChanged: () => events.push('node'),
|
|
fire: (name) => events.push(name),
|
|
};
|
|
const press = (key, options = {}) => {
|
|
const event = {
|
|
key,
|
|
prevented: false,
|
|
preventDefault() { this.prevented = true; },
|
|
...options,
|
|
};
|
|
removeAdjacentBlock(editor, event);
|
|
return event;
|
|
};
|
|
return { body, editor, events, press };
|
|
}
|
|
|
|
test('Backspace deletes the adjacent table from a paragraph boundary and records one edit', () => {
|
|
const f = fixture('<table><tbody><tr><td>data</td></tr></tbody></table><p><strong>after</strong></p>', 'strong', 0);
|
|
const event = f.press('Backspace');
|
|
assert.equal(event.prevented, true);
|
|
assert.equal(f.body.innerHTML, '<p><strong>after</strong></p>');
|
|
assert.deepEqual(f.events, ['undo', 'node', 'change']);
|
|
});
|
|
|
|
test('Delete removes a following list only at the end of ordinary text', () => {
|
|
const f = fixture('<p>before</p><ul><li>item</li></ul>', 'p', 0);
|
|
f.editor.selection.setCursorLocation(f.body.querySelector('p').firstChild, 1);
|
|
assert.equal(f.press('Delete').prevented, false);
|
|
assert.equal(f.body.querySelectorAll('ul').length, 1);
|
|
f.editor.selection.setCursorLocation(f.body.querySelector('p').firstChild, 6);
|
|
assert.equal(f.press('Delete').prevented, true);
|
|
assert.equal(f.body.innerHTML, '<p>before</p>');
|
|
});
|
|
|
|
test('a displayed formula is removed with its paragraph wrapper', () => {
|
|
const f = fixture('<p><span class="jio-math jio-math--block" contenteditable="false">x</span><br data-mce-bogus="1"></p><p>next</p>', 'p', 1);
|
|
assert.equal(f.press('Backspace').prevented, true);
|
|
assert.equal(f.body.innerHTML, '<p>next</p>');
|
|
assert.equal(f.editor.selection.getRng().startContainer, f.body.querySelector('p'));
|
|
});
|
|
|
|
test('Delete before a displayed formula removes only its paragraph', () => {
|
|
const f = fixture('<p>before</p><p><span class="jio-math jio-math--block" contenteditable="false">x</span></p>', 'p:nth-child(2)', 0);
|
|
assert.equal(f.press('Delete').prevented, true);
|
|
assert.equal(f.body.innerHTML, '<p>before</p>');
|
|
});
|
|
|
|
test('Backspace on the line after a formula removes the empty line first', () => {
|
|
const f = fixture('<p><span class="jio-math jio-math--block" contenteditable="false">x</span></p><p><br data-mce-bogus="1"></p>', 'p:nth-child(2)', 0);
|
|
assert.equal(f.press('Backspace').prevented, true);
|
|
assert.equal(f.body.querySelectorAll('.jio-math--block').length, 1);
|
|
assert.equal(f.body.children.length, 1);
|
|
assert.equal(f.editor.selection.getRng().startContainer, f.body);
|
|
assert.equal(f.press('Backspace').prevented, true);
|
|
assert.equal(f.body.querySelectorAll('.jio-math--block').length, 0);
|
|
assert.equal(f.body.firstElementChild.tagName, 'P');
|
|
});
|
|
|
|
test('Backspace on an empty line after a table preserves the table', () => {
|
|
const f = fixture('<table><tbody><tr><td>data</td></tr></tbody></table><p><br></p>', 'p', 0);
|
|
assert.equal(f.press('Backspace').prevented, true);
|
|
assert.equal(f.body.querySelectorAll('table').length, 1);
|
|
assert.equal(f.body.querySelectorAll('p').length, 0);
|
|
});
|
|
|
|
test('a saved display formula restores as one paragraph without phantom lines', () => {
|
|
const dom = new JSDOM('<!doctype html><body></body>');
|
|
const previousDocument = global.document;
|
|
global.document = dom.window.document;
|
|
try {
|
|
const saved = serializer.serializeBlocks('<p><span class="jio-math jio-math--block" data-jio-math-source="x^2" data-jio-math-display="1" contenteditable="false">x<sup>2</sup></span></p>');
|
|
dom.window.document.body.innerHTML = serializer.hydrateBlocks(saved);
|
|
assert.equal(dom.window.document.body.querySelectorAll('p').length, 1);
|
|
assert.equal(dom.window.document.body.querySelectorAll('.jio-math--block').length, 1);
|
|
} finally {
|
|
global.document = previousDocument;
|
|
}
|
|
});
|
|
|
|
test('a real line break inside a paragraph is not a block boundary', () => {
|
|
const f = fixture('<hr><p><br>text</p>', 'p', 1);
|
|
assert.equal(f.press('Backspace').prevented, false);
|
|
assert.equal(f.body.querySelectorAll('hr').length, 1);
|
|
});
|
|
|
|
test('nested quote and hidden-content editing boundaries remove the whole outer block', () => {
|
|
for (const html of [
|
|
'<div class="quote_q" contenteditable="false"><div contenteditable="true"><p>quote</p></div></div>',
|
|
'<div class="tinymce-hide"><p class="hide-before">[vip]</p><div contenteditable="true"><p>secret</p></div><p class="hide-after">[/vip]</p></div>',
|
|
]) {
|
|
const f = fixture(`${html}<p>next</p>`, '[contenteditable="true"] p', 0);
|
|
assert.equal(f.press('Backspace').prevented, true);
|
|
assert.equal(f.body.innerHTML, '<p>next</p>');
|
|
}
|
|
});
|
|
|
|
test('deleting the last structure creates an editable caret home without resetting content', () => {
|
|
const f = fixture('<pre>code</pre>', 'pre', 0);
|
|
assert.equal(f.press('Backspace').prevented, true);
|
|
assert.equal(f.body.children.length, 1);
|
|
assert.equal(f.body.firstElementChild.tagName, 'P');
|
|
assert.equal(f.editor.selection.getRng().startContainer, f.body.firstElementChild);
|
|
});
|
|
|
|
test('body-level caret can delete an adjacent divider', () => {
|
|
const f = fixture('<hr><p>after</p>', 'body', 1);
|
|
assert.equal(f.press('Backspace').prevented, true);
|
|
assert.equal(f.body.innerHTML, '<p>after</p>');
|
|
});
|
|
|
|
test('body-level deletion of the only divider leaves a caret home', () => {
|
|
const f = fixture('<hr>', 'body', 1);
|
|
assert.equal(f.press('Backspace').prevented, true);
|
|
assert.equal(f.body.firstElementChild.tagName, 'P');
|
|
assert.equal(f.editor.selection.getRng().startContainer, f.body.firstElementChild);
|
|
});
|
|
|
|
test('body-level deletion places the caret before the next structural block', () => {
|
|
const f = fixture('<hr><pre>code</pre>', 'body', 1);
|
|
assert.equal(f.press('Backspace').prevented, true);
|
|
assert.equal(f.body.firstElementChild.tagName, 'P');
|
|
assert.equal(f.body.lastElementChild.tagName, 'PRE');
|
|
assert.equal(f.editor.selection.getRng().startContainer, f.body.firstElementChild);
|
|
});
|
|
|
|
test('media-only paragraphs are removed with their media', () => {
|
|
const f = fixture('<p><img src="example.jpg"><br data-mce-bogus="1"></p><p>next</p>', 'p', 1);
|
|
assert.equal(f.press('Backspace').prevented, true);
|
|
assert.equal(f.body.innerHTML, '<p>next</p>');
|
|
});
|
|
|
|
test('editing inside a table or list keeps the native key behavior', () => {
|
|
for (const html of [
|
|
'<table><tbody><tr><td>cell</td></tr></tbody></table>',
|
|
'<ul><li>item</li></ul>',
|
|
]) {
|
|
const f = fixture(html, html.startsWith('<table>') ? 'td' : 'li', 0);
|
|
assert.equal(f.press('Backspace').prevented, false);
|
|
assert.equal(f.body.firstElementChild.outerHTML, html);
|
|
}
|
|
});
|
|
|
|
test('selection and modified keys are left to native editing', () => {
|
|
const f = fixture('<hr><p>after</p>', 'p', 0);
|
|
assert.equal(f.press('Backspace', { ctrlKey: true }).prevented, false);
|
|
assert.equal(f.press('Backspace', { isComposing: true }).prevented, false);
|
|
const range = f.editor.selection.getRng();
|
|
range.selectNodeContents(f.body.querySelector('p'));
|
|
assert.equal(f.press('Backspace').prevented, false);
|
|
assert.equal(f.body.querySelectorAll('hr').length, 1);
|
|
assert.deepEqual(f.events, []);
|
|
});
|