'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); const markupSource = fs.readFileSync(path.join(__dirname, '../src/utils/block-markup.ts'), 'utf8'); const markupCompiled = ts.transpileModule(markupSource, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText; const markup = {}; new Function('exports', 'require', markupCompiled)(markup, () => ({ __: (value) => value })); function fixture(html, nodeSelector, offset = 0) { const dom = new JSDOM(`${html}`); 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); }, select: (target) => { range = dom.window.document.createRange(); range.selectNode(target); }, }, 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 selects the adjacent table before deleting it as one edit', () => { const f = fixture('
data

after

', 'strong', 0); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.querySelectorAll('table').length, 1); assert.equal(f.editor.selection.getRng().startContainer, f.body); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.innerHTML, '

after

'); assert.deepEqual(f.events, ['node', 'undo', 'node', 'change']); }); test('Delete removes a following list only at the end of ordinary text', () => { const f = fixture('

before

', '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.querySelectorAll('ul').length, 1); assert.equal(f.press('Delete').prevented, true); assert.equal(f.body.innerHTML, '

before

'); }); test('a displayed formula is removed with its paragraph wrapper', () => { const f = fixture('

x

next

', 'p', 1); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.querySelectorAll('.jio-math--block').length, 1); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.innerHTML, '

next

'); assert.equal(f.editor.selection.getRng().startContainer, f.body.querySelector('p')); }); test('Delete before a displayed formula removes only its paragraph', () => { const f = fixture('

before

x

', 'p:nth-child(2)', 0); assert.equal(f.press('Delete').prevented, true); assert.equal(f.body.querySelectorAll('.jio-math--block').length, 1); assert.equal(f.press('Delete').prevented, true); assert.equal(f.body.innerHTML, '

before

'); }); test('Backspace on the line after a formula selects it without removing the line', () => { const f = fixture('

x


', '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, 2); 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('
data


', 'p', 0); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.querySelectorAll('table').length, 1); assert.equal(f.body.querySelectorAll('p').length, 1); }); test('a saved display formula restores as one paragraph without phantom lines', () => { const dom = new JSDOM(''); const previousDocument = global.document; global.document = dom.window.document; try { const saved = serializer.serializeBlocks('

x2

'); 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('


text

', 'p', 1); assert.equal(f.press('Backspace').prevented, false); assert.equal(f.body.querySelectorAll('hr').length, 1); }); test('a quote can be removed from its inner editing boundary', () => { const f = fixture('

quote

next

', '[contenteditable="true"] p', 0); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.querySelectorAll('.quote_q').length, 1); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.innerHTML, '

next

'); }); test('a code block stays editable until its boundary is used to select it', () => { const f = fixture('
code

next

', 'pre', 0); f.editor.selection.setCursorLocation(f.body.querySelector('pre').firstChild, 2); assert.equal(f.press('Backspace').prevented, false); assert.equal(f.body.querySelector('pre').textContent, 'code'); f.editor.selection.setCursorLocation(f.body.querySelector('pre').firstChild, 0); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.querySelector('pre').textContent, 'code'); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.innerHTML, '

next

'); }); test('the placeholder break in an empty code block does not require an extra key press', () => { const f = fixture('

next

', 'pre', 1); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.querySelector('pre')?.innerHTML, '
'); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.innerHTML, '

next

'); }); test('every standalone block selects from the following line before removal', () => { const blocks = [ markup.dividerMarkup().replace('

', ''), markup.quoteMarkup('

quote

', '').replace(/

<\/p>$/, ''), markup.codeBlockMarkup().replace('

', ''), markup.tableMarkup({ rows: 1, cols: 1, header: false, striped: false }).replace('

', ''), markup.mathMarkup({ formula: 'x^2', display: true }), ]; for (const block of blocks) { const f = fixture(`${block}


`, 'body > p:last-child', 0); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.children.length, 2, block); assert.equal(f.editor.selection.getRng().startContainer, f.body, block); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.children.length, 1, block); assert.equal(f.body.firstElementChild.tagName, 'P', block); } }); test('every standalone block selects from the preceding line before Delete removes it', () => { const blocks = [ markup.dividerMarkup().replace('

', ''), markup.quoteMarkup('

quote

', '').replace(/

<\/p>$/, ''), markup.codeBlockMarkup().replace('

', ''), markup.tableMarkup({ rows: 1, cols: 1, header: false, striped: false }).replace('

', ''), markup.mathMarkup({ formula: 'x^2', display: true }), ]; for (const block of blocks) { const f = fixture(`

before

${block}`, 'body > p:first-child', 1); assert.equal(f.press('Delete').prevented, true); assert.equal(f.body.children.length, 2, block); assert.equal(f.editor.selection.getRng().startContainer, f.body, block); assert.equal(f.press('Delete').prevented, true); assert.equal(f.body.innerHTML, '

before

', block); } }); test('an inline footnote is selected from either side before deletion', () => { const note = 'note'; for (const key of ['Delete', 'Backspace']) { const f = fixture(`

before${note}after

`, 'p', 0); const paragraph = f.body.querySelector('p'); f.editor.selection.setCursorLocation(key === 'Delete' ? paragraph.firstChild : paragraph.lastChild, key === 'Delete' ? 6 : 0); assert.equal(f.press(key).prevented, true); assert.equal(f.body.querySelectorAll('.jio-footnote-ref').length, 1); assert.equal(f.press(key).prevented, true); assert.equal(f.body.innerHTML, '

beforeafter

'); } }); test('Delete inside adjacent text does not select the footnote', () => { const f = fixture('

beforenote

', 'p', 0); f.editor.selection.setCursorLocation(f.body.querySelector('p').firstChild, 3); assert.equal(f.press('Delete').prevented, false); assert.equal(f.body.querySelectorAll('.jio-footnote-ref').length, 1); }); test('an inline formula uses the same two-step deletion and is noneditable on insertion', () => { const html = markup.mathMarkup({ formula: 'x^2', display: false }); const f = fixture(`

before${html}after

`, 'p', 0); const formula = f.body.querySelector('.jio-math'); assert.equal(formula.getAttribute('contenteditable'), 'false'); f.editor.selection.setCursorLocation(f.body.querySelector('p').lastChild, 0); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.querySelectorAll('.jio-math').length, 1); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.innerHTML, '

beforeafter

'); }); const hiddenBlock = '

[vip]

secret

[/vip]

'; test('new hidden blocks keep shortcodes outside the editable content', () => { const dom = new JSDOM(''); dom.window.document.body.innerHTML = markup.hiddenContentMarkup('vip', '

secret

'); const block = dom.window.document.body.querySelector('.tinymce-hide'); assert.equal(block.getAttribute('contenteditable'), 'false'); assert.equal(block.querySelector(':scope > div').getAttribute('contenteditable'), 'true'); assert.equal(block.querySelector('.hide-before').textContent, '[vip]'); assert.equal(block.querySelector('.hide-after').textContent, '[/vip]'); }); test('Backspace after a hidden block selects it first, then removes it in one more press', () => { const f = fixture(`${hiddenBlock}


`, 'body > p', 0); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.querySelectorAll('.tinymce-hide').length, 1); assert.equal(f.editor.selection.getRng().startContainer, f.body); assert.equal(f.editor.selection.getRng().endOffset, 1); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.querySelectorAll('.tinymce-hide').length, 0); assert.equal(f.body.firstElementChild.tagName, 'P'); assert.deepEqual(f.events, ['node', 'undo', 'node', 'change']); }); test('editing hidden text stays native; Backspace at its start selects the whole block', () => { const f = fixture(`${hiddenBlock}

next

`, '.tinymce-hide [contenteditable="true"] p', 0); const text = f.body.querySelector('.tinymce-hide [contenteditable="true"] p').firstChild; f.editor.selection.setCursorLocation(text, 3); assert.equal(f.press('Backspace').prevented, false); assert.equal(f.body.querySelector('.tinymce-hide [contenteditable="true"] p').textContent, 'secret'); f.editor.selection.setCursorLocation(text, 0); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.editor.selection.getRng().endOffset, 1); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.innerHTML, '

next

'); }); test('a manually selected hidden block deletes without repeated native key presses', () => { const f = fixture(hiddenBlock, 'body', 0); f.editor.selection.select(f.body.firstElementChild); assert.equal(f.press('Delete').prevented, true); assert.equal(f.body.querySelector('.tinymce-hide'), null); assert.equal(f.body.firstElementChild.tagName, 'P'); }); test('selecting only hidden-block text does not delete its container', () => { const f = fixture(hiddenBlock, '.tinymce-hide [contenteditable="true"] p', 0); const range = f.editor.selection.getRng(); range.selectNodeContents(f.body.querySelector('.tinymce-hide [contenteditable="true"] p')); assert.equal(f.press('Backspace').prevented, false); assert.equal(f.body.querySelectorAll('.tinymce-hide').length, 1); }); test('hydration restores the hidden block editing boundary', () => { const dom = new JSDOM(''); const previousDocument = global.document; global.document = dom.window.document; try { dom.window.document.body.innerHTML = serializer.hydrateBlocks(hiddenBlock.replace(' contenteditable="false"', '').replace(' contenteditable="true"', '')); const block = dom.window.document.body.querySelector('.tinymce-hide'); assert.equal(block.getAttribute('contenteditable'), 'false'); assert.equal(block.querySelector(':scope > div').getAttribute('contenteditable'), 'true'); } finally { global.document = previousDocument; } }); test('deleting the last structure creates an editable caret home without resetting content', () => { const f = fixture('
code
', 'pre', 0); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.firstElementChild.tagName, 'PRE'); 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('

after

', 'body', 1); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.querySelectorAll('hr').length, 1); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.innerHTML, '

after

'); }); test('body-level deletion of the only divider leaves a caret home', () => { const f = fixture('
', 'body', 1); assert.equal(f.press('Backspace').prevented, true); 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('
code
', 'body', 1); assert.equal(f.press('Backspace').prevented, true); 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('


next

', 'p', 1); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.innerHTML, '

next

'); }); test('editing inside a table or list keeps the native key behavior', () => { for (const html of [ '
cell
', '', ]) { const f = fixture(html, html.startsWith('') ? '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('

after

', '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, []); });