[IMP] web_editor: add failsafe upon CTRL+BACKSPACE

Prevents the user to remove the last child (if it is an empty block) of an
editable, in order to avoid the case where the user can add text nodes directly
in a block where it should not be possible (i.e. directly under this.editable).

Replaces any empty block other than `<P>` by an empty `<P>` which is the default
block for the editor.

Task-id: 2794990
Part-of: odoo/odoo#86489
This commit is contained in:
abd-msyukyu-odoo
2022-05-05 11:41:55 +02:00
parent e82e96f173
commit c007dafae8
3 changed files with 152 additions and 33 deletions
@@ -25,6 +25,7 @@ import {
isColorGradient,
nodeSize,
preserveCursor,
setCursorStart,
setSelection,
startPos,
toggleClass,
@@ -2403,15 +2404,38 @@ export class OdooEditor extends EventTarget {
this.deleteRange(selection);
}
}
if (ev.key === 'Backspace' && !ev.ctrlKey && !ev.metaKey) {
if (ev.key === 'Backspace') {
// backspace
// We need to hijack it because firefox doesn't trigger a
// deleteBackward input event with a collapsed selection in front of
// a contentEditable="false" (eg: font awesome).
const selection = this.document.getSelection();
if (selection.isCollapsed) {
ev.preventDefault();
this._applyCommand('oDeleteBackward');
if (!ev.ctrlKey && !ev.metaKey) {
if (selection.isCollapsed) {
// We need to hijack it because firefox doesn't trigger a
// deleteBackward input event with a collapsed selection in
// front of a contentEditable="false" (eg: font awesome).
ev.preventDefault();
this._applyCommand('oDeleteBackward');
}
} else if (selection.isCollapsed && selection.anchorNode) {
const anchor = (selection.anchorNode.nodeType !== Node.TEXT_NODE && selection.anchorOffset) ?
selection.anchorNode[selection.anchorOffset] : selection.anchorNode;
const element = closestBlock(anchor);
if (isEmptyBlock(element) && element.parentElement.children.length === 1) {
// Prevent removing a <p> if it is the last element of its
// parent.
ev.preventDefault();
if (element.tagName !== 'P') {
// Replace an empty block which is not a <p> by a <p>
const paragraph = this.document.createElement('P');
const br = this.document.createElement('BR');
paragraph.append(br);
element.before(paragraph);
const result = this._protect(() => element.remove());
if (result !== UNBREAKABLE_ROLLBACK_CODE && result !== UNREMOVABLE_ROLLBACK_CODE) {
setCursorStart(paragraph);
this.historyStep();
}
}
}
}
} else if (ev.key === 'Tab') {
// Tab
@@ -2351,6 +2351,97 @@ X[]
});
});
describe('CTRL+Backspace', () => {
it('should not remove the last p with ctrl+backspace', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(`<p>[]<br></p>`),
stepFunction: async editor => {
editor.editable.addEventListener('keydown', ev => {
// simulation of the browser default behavior
if (ev.key === 'Backspace' && ev.ctrlKey === true && !ev.defaultPrevented) {
const sel = document.getSelection();
sel.anchorNode.remove();
}
});
await keydown(editor.editable, 'Backspace', { ctrlKey: true, cancelable: true });
},
contentAfter: unformat(`<p>[]<br></p>`),
});
});
it('should not remove the last p enclosed in a contenteditable=false with ctrl+backspace', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(`
<p>text</p>
<div contenteditable="false"><div contenteditable="true">
<p>[]<br></p>
</div></div>`),
stepFunction: async editor => {
editor.editable.addEventListener('keydown', ev => {
// simulation of the browser default behavior
if (ev.key === 'Backspace' && ev.ctrlKey === true && !ev.defaultPrevented) {
const sel = document.getSelection();
sel.anchorNode.remove();
}
});
await keydown(editor.editable, 'Backspace', { ctrlKey: true, cancelable: true });
},
contentAfter: unformat(`
<p>text</p>
<div contenteditable="false"><div contenteditable="true">
<p>[]<br></p>
</div></div>`),
});
});
it('should add a <p><br></p> element when deleting the last child of the editable with ctrl+backspace', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(`
<blockquote>
[]<br>
</blockquote>`),
stepFunction: async editor => {
await keydown(editor.editable, 'Backspace', { ctrlKey: true });
},
contentAfter: unformat(`<p>[]<br></p>`),
});
});
it('should add a <p><br></p> element when deleting the last child of an element with ctrl+backspace', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(`
<div contenteditable="false"><div contenteditable="true">
<blockquote>
[]<br>
</blockquote>
</div></div>`),
stepFunction: async editor => {
await keydown(editor.editable, 'Backspace', { ctrlKey: true });
},
contentAfter: unformat(`
<div contenteditable="false"><div contenteditable="true">
<p>[]<br></p>
</div></div>`),
});
});
it('should correctly rollback a CTRL+BACKSPACE if the element should not have been removed', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(`
<div contenteditable="false"><div contenteditable="true">
<blockquote class="oe_unremovable">
[]<br>
</blockquote>
</div></div>`),
stepFunction: async editor => {
await keydown(editor.editable, 'Backspace', { ctrlKey: true });
},
contentAfter: unformat(`
<div contenteditable="false"><div contenteditable="true">
<blockquote class="oe_unremovable">
[]<br>
</blockquote>
</div></div>`),
});
});
});
describe('insertParagraphBreak', () => {
describe('Selection collapsed', () => {
describe('Basic', () => {
@@ -3359,7 +3450,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: '[ab]<span>\u200B</span>cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowRight', true);
await keydown(editor.editable, 'ArrowRight', { shiftKey: true });
},
contentAfter: '[ab<span>\u200B]</span>cd',
// Final state: '[ab<span>\u200B</span>c]d'
@@ -3367,7 +3458,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: '[ab<span>]\u200B</span>cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowRight', true);
await keydown(editor.editable, 'ArrowRight', { shiftKey: true });
},
contentAfter: '[ab<span>\u200B]</span>cd',
// Final state: '[ab<span>\u200B</span>c]d'
@@ -3377,7 +3468,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'a[b]<span>\u200B</span>cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowRight', true);
await keydown(editor.editable, 'ArrowRight', { shiftKey: true });
},
contentAfter: 'a[b<span>\u200B]</span>cd',
// Final state: 'a[b<span>\u200B</span>c]d'
@@ -3385,7 +3476,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'a[b<span>]\u200B</span>cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowRight', true);
await keydown(editor.editable, 'ArrowRight', { shiftKey: true });
},
contentAfter: 'a[b<span>\u200B]</span>cd',
// Final state: 'a[b<span>\u200B</span>c]d'
@@ -3395,7 +3486,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab[]<span>\u200B</span>cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowRight', true);
await keydown(editor.editable, 'ArrowRight', { shiftKey: true });
},
contentAfter: 'ab[<span>\u200B]</span>cd',
// Final state: 'ab[<span>\u200B</span>c]d'
@@ -3403,7 +3494,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab<span>[]\u200B</span>cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowRight', true);
await keydown(editor.editable, 'ArrowRight', { shiftKey: true });
},
contentAfter: 'ab<span>[\u200B]</span>cd',
// Final state: 'ab<span>[\u200B</span>c]d'
@@ -3413,7 +3504,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab<span>]\u200B[</span>cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowRight', true);
await keydown(editor.editable, 'ArrowRight', { shiftKey: true });
},
contentAfter: 'ab<span>\u200B[]</span>cd',
// Final state: 'ab<span>\u200B</span>[c]d'
@@ -3421,7 +3512,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab<span>]\u200B</span>[cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowRight', true);
await keydown(editor.editable, 'ArrowRight', { shiftKey: true });
},
contentAfter: 'ab<span>\u200B[]</span>cd',
// Final state: 'ab<span>\u200B</span>[c]d'
@@ -3429,7 +3520,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab]<span>\u200B</span>[cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowRight', true);
await keydown(editor.editable, 'ArrowRight', { shiftKey: true });
},
contentAfter: 'ab<span>\u200B[]</span>cd',
// Final state: 'ab<span>\u200B</span>[c]d'
@@ -3437,7 +3528,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab]<span>\u200B[</span>cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowRight', true);
await keydown(editor.editable, 'ArrowRight', { shiftKey: true });
},
contentAfter: 'ab<span>\u200B[]</span>cd',
// Final state: 'ab<span>\u200B</span>[c]d'
@@ -3447,7 +3538,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab<span>]\u200B</span>c[d',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowRight', true);
await keydown(editor.editable, 'ArrowRight', { shiftKey: true });
},
contentAfter: 'ab<span>\u200B]</span>c[d',
// Final state: 'ab<span>\u200B</span>c[]d'
@@ -3455,7 +3546,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab]<span>\u200B</span>c[d',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowRight', true);
await keydown(editor.editable, 'ArrowRight', { shiftKey: true });
},
contentAfter: 'ab<span>\u200B]</span>c[d',
// Final state: 'ab<span>\u200B</span>c[]d'
@@ -3485,7 +3576,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab<span>\u200B[]</span>cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowLeft', true);
await keydown(editor.editable, 'ArrowLeft', { shiftKey: true });
},
contentAfter: 'ab<span>]\u200B[</span>cd',
// Final state: 'a]b<span>\u200B[</span>cd'
@@ -3493,7 +3584,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab<span>\u200B</span>[]cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowLeft', true);
await keydown(editor.editable, 'ArrowLeft', { shiftKey: true });
},
contentAfter: 'ab<span>]\u200B[</span>cd',
// Final state: 'a]b<span>\u200B[</span>cd'
@@ -3503,7 +3594,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab<span>\u200B</span>]cd[',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowLeft', true);
await keydown(editor.editable, 'ArrowLeft', { shiftKey: true });
},
contentAfter: 'ab<span>]\u200B</span>cd[',
// Final state: 'a]b<span>\u200B</span>cd['
@@ -3511,7 +3602,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab<span>\u200B]</span>cd[',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowLeft', true);
await keydown(editor.editable, 'ArrowLeft', { shiftKey: true });
},
contentAfter: 'ab<span>]\u200B</span>cd[',
// Final state: 'a]b<span>\u200B</span>cd['
@@ -3521,7 +3612,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab<span>\u200B</span>]c[d',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowLeft', true);
await keydown(editor.editable, 'ArrowLeft', { shiftKey: true });
},
contentAfter: 'ab<span>]\u200B</span>c[d',
// Final state: 'a]b<span>\u200B</span>c[d'
@@ -3529,7 +3620,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab<span>\u200B]</span>c[d',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowLeft', true);
await keydown(editor.editable, 'ArrowLeft', { shiftKey: true });
},
contentAfter: 'ab<span>]\u200B</span>c[d',
// Final state: 'a]b<span>\u200B</span>c[d'
@@ -3539,7 +3630,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab<span>[\u200B]</span>cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowLeft', true);
await keydown(editor.editable, 'ArrowLeft', { shiftKey: true });
},
contentAfter: 'ab<span>[]\u200B</span>cd',
// Final state: 'a]b<span>[\u200B</span>cd'
@@ -3547,7 +3638,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab<span>[\u200B</span>]cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowLeft', true);
await keydown(editor.editable, 'ArrowLeft', { shiftKey: true });
},
contentAfter: 'ab<span>[]\u200B</span>cd',
// Final state: 'a]b<span>[\u200B</span>cd'
@@ -3555,7 +3646,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab[<span>\u200B]</span>cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowLeft', true);
await keydown(editor.editable, 'ArrowLeft', { shiftKey: true });
},
contentAfter: 'ab[<span>]\u200B</span>cd',
// Final state: 'a]b[<span>\u200B</span>cd'
@@ -3563,7 +3654,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'ab[<span>\u200B</span>]cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowLeft', true);
await keydown(editor.editable, 'ArrowLeft', { shiftKey: true });
},
contentAfter: 'ab[<span>]\u200B</span>cd',
// Final state: 'a]b[<span>\u200B</span>cd'
@@ -3573,7 +3664,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'a[b<span>\u200B]</span>cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowLeft', true);
await keydown(editor.editable, 'ArrowLeft', { shiftKey: true });
},
contentAfter: 'a[b<span>]\u200B</span>cd',
// Final state: 'a[]b<span>\u200B</span>cd'
@@ -3581,7 +3672,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: 'a[b<span>\u200B</span>]cd',
stepFunction: async editor => {
await keydown(editor.editable, 'ArrowLeft', true);
await keydown(editor.editable, 'ArrowLeft', { shiftKey: true });
},
contentAfter: 'a[b<span>]\u200B</span>cd',
// Final state: 'a[]b<span>\u200B</span>cd'
@@ -412,8 +412,12 @@ export async function click(el, options) {
await nextTickFrame();
}
export async function keydown(editable, key, shiftKey) {
const ev = new KeyboardEvent('keydown', { key, shiftKey: !!shiftKey });
export async function keydown(editable, key, options = {}) {
options = Object.assign(
{ key: key },
options
);
const ev = new KeyboardEvent('keydown', options);
editable.dispatchEvent(ev);
await nextTickFrame();
}