fix(creator-studio): unify block and inline marker deletion
This commit is contained in:
1 parent
d391b0fd00
commit
30ceab4615
3 files changed
+193
-47
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? `<p><span class="jio-math jio-math--block" ${attrs} contenteditable="false">${body}</span></p>`
|
||||
: `<span class="jio-math" ${attrs}>${body}</span>`;
|
||||
: `<span class="jio-math" ${attrs} contenteditable="false">${body}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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('<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.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, '<p><strong>after</strong></p>');
|
||||
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, '<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.querySelectorAll('.jio-math--block').length, 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'));
|
||||
});
|
||||
@@ -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('<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.querySelectorAll('.jio-math--block').length, 1);
|
||||
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', () => {
|
||||
test('Backspace on the line after a formula selects it without removing the line', () => {
|
||||
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.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);
|
||||
@@ -115,7 +123,7 @@ 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);
|
||||
assert.equal(f.body.querySelectorAll('p').length, 1);
|
||||
});
|
||||
|
||||
test('a saved display formula restores as one paragraph without phantom lines', () => {
|
||||
@@ -141,9 +149,100 @@ test('a real line break inside a paragraph is not a block boundary', () => {
|
||||
test('a quote can be removed from its inner editing boundary', () => {
|
||||
const f = fixture('<div class="quote_q" contenteditable="false"><div contenteditable="true"><p>quote</p></div></div><p>next</p>', '[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, '<p>next</p>');
|
||||
});
|
||||
|
||||
test('a code block stays editable until its boundary is used to select it', () => {
|
||||
const f = fixture('<pre>code</pre><p>next</p>', '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, '<p>next</p>');
|
||||
});
|
||||
|
||||
test('the placeholder break in an empty code block does not require an extra key press', () => {
|
||||
const f = fixture('<pre><br></pre><p>next</p>', 'pre', 1);
|
||||
assert.equal(f.press('Backspace').prevented, true);
|
||||
assert.equal(f.body.querySelector('pre')?.innerHTML, '<br>');
|
||||
assert.equal(f.press('Backspace').prevented, true);
|
||||
assert.equal(f.body.innerHTML, '<p>next</p>');
|
||||
});
|
||||
|
||||
test('every standalone block selects from the following line before removal', () => {
|
||||
const blocks = [
|
||||
markup.dividerMarkup().replace('<p></p>', ''),
|
||||
markup.quoteMarkup('<p>quote</p>', '').replace(/<p><\/p>$/, ''),
|
||||
markup.codeBlockMarkup().replace('<p></p>', ''),
|
||||
markup.tableMarkup({ rows: 1, cols: 1, header: false, striped: false }).replace('<p></p>', ''),
|
||||
markup.mathMarkup({ formula: 'x^2', display: true }),
|
||||
];
|
||||
for (const block of blocks) {
|
||||
const f = fixture(`${block}<p><br data-mce-bogus="1"></p>`, '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('<p></p>', ''),
|
||||
markup.quoteMarkup('<p>quote</p>', '').replace(/<p><\/p>$/, ''),
|
||||
markup.codeBlockMarkup().replace('<p></p>', ''),
|
||||
markup.tableMarkup({ rows: 1, cols: 1, header: false, striped: false }).replace('<p></p>', ''),
|
||||
markup.mathMarkup({ formula: 'x^2', display: true }),
|
||||
];
|
||||
for (const block of blocks) {
|
||||
const f = fixture(`<p>before</p>${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, '<p>before</p>', block);
|
||||
}
|
||||
});
|
||||
|
||||
test('an inline footnote is selected from either side before deletion', () => {
|
||||
const note = '<sup class="jio-footnote-ref" data-jio-footnote="note" contenteditable="false">note</sup>';
|
||||
for (const key of ['Delete', 'Backspace']) {
|
||||
const f = fixture(`<p>before${note}after</p>`, '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, '<p>beforeafter</p>');
|
||||
}
|
||||
});
|
||||
|
||||
test('Delete inside adjacent text does not select the footnote', () => {
|
||||
const f = fixture('<p>before<sup class="jio-footnote-ref" contenteditable="false">note</sup></p>', '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(`<p>before${html}after</p>`, '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, '<p>beforeafter</p>');
|
||||
});
|
||||
|
||||
const hiddenBlock = '<div class="tinymce-hide" contenteditable="false"><p class="hide-before">[vip]</p><div contenteditable="true"><p>secret</p></div><p class="hide-after">[/vip]</p></div>';
|
||||
|
||||
test('new hidden blocks keep shortcodes outside the editable content', () => {
|
||||
@@ -214,6 +313,8 @@ test('hydration restores the hidden block editing boundary', () => {
|
||||
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.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('<hr><p>after</p>', '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, '<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.press('Backspace').prevented, true);
|
||||
assert.equal(f.body.firstElementChild.tagName, 'P');
|
||||
assert.equal(f.editor.selection.getRng().startContainer, f.body.firstElementChild);
|
||||
});
|
||||
@@ -235,6 +339,7 @@ test('body-level deletion of the only divider leaves a caret home', () => {
|
||||
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.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('<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.press('Backspace').prevented, true);
|
||||
assert.equal(f.body.innerHTML, '<p>next</p>');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user