diff --git a/assets/creator-studio/src/editor-runtime.ts b/assets/creator-studio/src/editor-runtime.ts index 9ac615fc..1321a038 100644 --- a/assets/creator-studio/src/editor-runtime.ts +++ b/assets/creator-studio/src/editor-runtime.ts @@ -9,6 +9,7 @@ /** 按键删除时作为整体处理的正文顶层块。 */ const STRUCTURAL_SELECTOR = 'table,pre,hr,figure,ul,ol,.quote_q,.tinymce-hide,.new-dplayer,.wp-block-embed'; const WRAPPED_MEDIA_SELECTOR = '.jio-math--block,img,iframe,video,audio,.jio-file-link'; +const INLINE_ATOM_SELECTOR = '.jio-footnote-ref,.jio-math:not(.jio-math--block)'; const NON_TEXT_CONTENT_SELECTOR = 'img,iframe,video,audio,table,pre,hr,figure,.jio-math,.jio-footnote-ref,.jio-file-link,.new-dplayer'; function isStructuralBlock(node: Element): boolean { @@ -27,6 +28,9 @@ function isStructuralBlock(node: Element): boolean { } function isAtEdge(range: Range, container: Element, backward: boolean): boolean { + if (container.tagName === 'PRE' && container.childNodes.length === 1 && container.firstChild?.nodeName === 'BR') { + return true; + } const remainder = range.cloneRange(); remainder.selectNodeContents(container); if (backward) { @@ -67,6 +71,46 @@ function selectedTopLevelBlock(range: Range, body: HTMLElement): Element | null return node?.nodeType === 1 && isStructuralBlock(node as Element) ? node as Element : null; } +function selectedInlineAtom(range: Range, body: HTMLElement): Element | null { + if (range.startContainer !== range.endContainer || range.endOffset !== range.startOffset + 1) { + return null; + } + const node = range.startContainer.childNodes[range.startOffset]; + return node?.nodeType === 1 && body.contains(node) && (node as Element).matches(INLINE_ATOM_SELECTOR) + ? node as Element : null; +} + +function adjacentNode(range: Range, body: HTMLElement, backward: boolean): Node | null { + let container = range.startContainer; + let offset = range.startOffset; + while (body.contains(container)) { + if (container.nodeType === 3) { + if (backward ? offset > 0 : offset < (container.textContent || '').length) { + return null; + } + const parent = container.parentNode; + if (!parent) return null; + offset = Array.prototype.indexOf.call(parent.childNodes, container) + (backward ? 0 : 1); + container = parent; + continue; + } + const candidate = container.childNodes[offset + (backward ? -1 : 0)]; + if (candidate) { + if ((candidate.nodeType === 3 && !candidate.textContent?.length) + || (candidate.nodeType === 1 && (candidate as Element).matches('br[data-mce-bogus]'))) { + offset += backward ? -1 : 1; + continue; + } + return candidate; + } + if (container === body || !container.parentNode) return null; + const parent = container.parentNode; + offset = Array.prototype.indexOf.call(parent.childNodes, container) + (backward ? 0 : 1); + container = parent; + } + return null; +} + function selectBlock(editor: any, event: any, block: Element): void { event.preventDefault(); editor.selection.select(block); @@ -76,8 +120,8 @@ function selectBlock(editor: any, event: any, block: Element): void { /** * Backspace / Delete 删除相邻的块级元素。 * - * 光标位于正文块边界时删除相邻的结构块;位于结构块自身的首尾时删除 - * 整个块。整块选区直接删除;文字选区和块内非边界位置交给 TinyMCE 处理。 + * 光标位于结构块边界时先选中目标,再按一次删除;行内注释与公式同理。 + * 文字选区和块内非边界位置交给 TinyMCE 处理。 */ export function removeAdjacentBlock(editor: any, event: any): void { const key = event.key || (event.keyCode === 8 ? 'Backspace' : event.keyCode === 46 ? 'Delete' : ''); @@ -96,25 +140,34 @@ export function removeAdjacentBlock(editor: any, event: any): void { } const collapsed = selection.isCollapsed(); const selected = collapsed ? null : selectedTopLevelBlock(range, body); - if (!collapsed && !selected) { + const selectedInline = collapsed ? null : selectedInlineAtom(range, body); + if (!collapsed && !selected && !selectedInline) { return; } const backward = key === 'Backspace'; + if (selectedInline) { + commitDeletion(editor, event, () => { + const parent = selectedInline.parentNode; + const offset = Array.prototype.indexOf.call(parent.childNodes, selectedInline); + editor.dom.remove(selectedInline); + selection.setCursorLocation(parent, offset); + }); + return; + } + if (collapsed) { + const adjacent = adjacentNode(range, body, backward); + if (adjacent?.nodeType === 1 && (adjacent as Element).matches(INLINE_ATOM_SELECTOR)) { + selectBlock(editor, event, adjacent as Element); + return; + } + } const current = selected ? null : topLevelBlock(range.startContainer, body); let target: Element | null = selected; if (current) { if (isBlankParagraph(current)) { const adjacent = backward ? current.previousElementSibling : current.nextElementSibling; if (adjacent && isStructuralBlock(adjacent) && isAtEdge(range, current, backward)) { - if (adjacent.matches('.tinymce-hide')) { - selectBlock(editor, event, adjacent); - } else { - commitDeletion(editor, event, () => { - const offset = Array.prototype.indexOf.call(body.childNodes, current); - editor.dom.remove(current); - selection.setCursorLocation(body, offset); - }); - } + selectBlock(editor, event, adjacent); } return; } @@ -144,38 +197,25 @@ export function removeAdjacentBlock(editor: any, event: any): void { if (!target || !isStructuralBlock(target)) { return; } - if (!selected && target.matches('.tinymce-hide')) { + if (!selected) { selectBlock(editor, event, target); return; } commitDeletion(editor, event, () => { - if (target === current || selected) { - const previous = target.previousElementSibling; - const next = target.nextElementSibling; - const destination = backward - ? [previous, next].find((item) => item && !isStructuralBlock(item)) - : [next, previous].find((item) => item && !isStructuralBlock(item)); - if (destination) { - editor.dom.remove(target); - selection.setCursorLocation(destination, destination === previous ? destination.childNodes.length : 0); - } else { - const paragraph = body.ownerDocument.createElement('p'); - paragraph.appendChild(body.ownerDocument.createElement('br')); - body.insertBefore(paragraph, target); - editor.dom.remove(target); - selection.setCursorLocation(paragraph, 0); - } - } else { - const next = target.nextSibling; + const previous = target.previousElementSibling; + const next = target.nextElementSibling; + const destination = backward + ? [previous, next].find((item) => item && !isStructuralBlock(item)) + : [next, previous].find((item) => item && !isStructuralBlock(item)); + if (destination) { editor.dom.remove(target); - if (!current && !Array.from(body.children).some((child) => !isStructuralBlock(child))) { - const paragraph = body.ownerDocument.createElement('p'); - paragraph.appendChild(body.ownerDocument.createElement('br')); - body.insertBefore(paragraph, next); - selection.setCursorLocation(paragraph, 0); - } else { - selection.setRng(range); - } + selection.setCursorLocation(destination, destination === previous ? destination.childNodes.length : 0); + } else { + const paragraph = body.ownerDocument.createElement('p'); + paragraph.appendChild(body.ownerDocument.createElement('br')); + body.insertBefore(paragraph, target); + editor.dom.remove(target); + selection.setCursorLocation(paragraph, 0); } }); } diff --git a/assets/creator-studio/src/utils/block-markup.ts b/assets/creator-studio/src/utils/block-markup.ts index 2ee6ee91..8b81645d 100644 --- a/assets/creator-studio/src/utils/block-markup.ts +++ b/assets/creator-studio/src/utils/block-markup.ts @@ -88,7 +88,7 @@ export function mathMarkup({ formula, display }: MathOptions): string { const attrs = `data-jio-math-source="${escapeHtml(formula.trim())}" data-jio-math-display="${display ? '1' : '0'}"`; return display ? `
${body}
` - : `${body}`; + : `${body}`; } /** diff --git a/assets/creator-studio/tests/block-deletion.test.cjs b/assets/creator-studio/tests/block-deletion.test.cjs index dee5e611..1e8b28f9 100644 --- a/assets/creator-studio/tests/block-deletion.test.cjs +++ b/assets/creator-studio/tests/block-deletion.test.cjs @@ -69,12 +69,14 @@ function fixture(html, nodeSelector, offset = 0) { return { body, editor, events, press }; } -test('Backspace deletes the adjacent table from a paragraph boundary and records one edit', () => { +test('Backspace selects the adjacent table before deleting it as one edit', () => { const f = fixture('| data |
after
', 'strong', 0); - const event = f.press('Backspace'); - assert.equal(event.prevented, true); + 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, ['undo', 'node', 'change']); + assert.deepEqual(f.events, ['node', 'undo', 'node', 'change']); }); test('Delete removes a following list only at the end of ordinary text', () => { @@ -84,12 +86,16 @@ test('Delete removes a following list only at the end of ordinary text', () => { 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')); }); @@ -97,14 +103,16 @@ test('a displayed formula is removed with its paragraph wrapper', () => { 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 removes the empty line first', () => { +test('Backspace on the line after a formula selects it without removing the line', () => { const f = fixture('x
| data |
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, '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}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 = 'secret
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); @@ -222,12 +323,15 @@ test('deleting the last structure creates an editable caret home without resetti 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('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); @@ -243,6 +348,7 @@ test('body-level deletion places the caret before the next structural block', () 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
'); });