From c007dafae83ff236f371f1ba64416afc64d7e2b3 Mon Sep 17 00:00:00 2001 From: abd-msyukyu-odoo Date: Tue, 29 Mar 2022 09:11:05 +0000 Subject: [PATCH] [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 `

` by an empty `

` which is the default block for the editor. Task-id: 2794990 Part-of: odoo/odoo#86489 --- .../static/lib/odoo-editor/src/OdooEditor.js | 38 ++++- .../lib/odoo-editor/test/spec/editor.test.js | 139 +++++++++++++++--- .../static/lib/odoo-editor/test/utils.js | 8 +- 3 files changed, 152 insertions(+), 33 deletions(-) diff --git a/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js b/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js index a3eb46ded77..b716bbbcafd 100644 --- a/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js +++ b/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js @@ -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

if it is the last element of its + // parent. + ev.preventDefault(); + if (element.tagName !== 'P') { + // Replace an empty block which is not a

by a

+ 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 diff --git a/addons/web_editor/static/lib/odoo-editor/test/spec/editor.test.js b/addons/web_editor/static/lib/odoo-editor/test/spec/editor.test.js index 4e60b982902..7fb041b37ab 100644 --- a/addons/web_editor/static/lib/odoo-editor/test/spec/editor.test.js +++ b/addons/web_editor/static/lib/odoo-editor/test/spec/editor.test.js @@ -2351,6 +2351,97 @@ X[] }); }); + describe('CTRL+Backspace', () => { + it('should not remove the last p with ctrl+backspace', async () => { + await testEditor(BasicEditor, { + contentBefore: unformat(`

[]

`), + 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(`

[]

`), + }); + }); + it('should not remove the last p enclosed in a contenteditable=false with ctrl+backspace', async () => { + await testEditor(BasicEditor, { + contentBefore: unformat(` +

text

+
+

[]

+
`), + 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(` +

text

+
+

[]

+
`), + }); + }); + it('should add a


element when deleting the last child of the editable with ctrl+backspace', async () => { + await testEditor(BasicEditor, { + contentBefore: unformat(` +
+ []
+
`), + stepFunction: async editor => { + await keydown(editor.editable, 'Backspace', { ctrlKey: true }); + }, + contentAfter: unformat(`

[]

`), + }); + }); + it('should add a


element when deleting the last child of an element with ctrl+backspace', async () => { + await testEditor(BasicEditor, { + contentBefore: unformat(` +
+
+ []
+
+
`), + stepFunction: async editor => { + await keydown(editor.editable, 'Backspace', { ctrlKey: true }); + }, + contentAfter: unformat(` +
+

[]

+
`), + }); + }); + it('should correctly rollback a CTRL+BACKSPACE if the element should not have been removed', async () => { + await testEditor(BasicEditor, { + contentBefore: unformat(` +
+
+ []
+
+
`), + stepFunction: async editor => { + await keydown(editor.editable, 'Backspace', { ctrlKey: true }); + }, + contentAfter: unformat(` +
+
+ []
+
+
`), + }); + }); + }); + describe('insertParagraphBreak', () => { describe('Selection collapsed', () => { describe('Basic', () => { @@ -3359,7 +3450,7 @@ X[] await testEditor(BasicEditor, { contentBefore: '[ab]\u200Bcd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowRight', true); + await keydown(editor.editable, 'ArrowRight', { shiftKey: true }); }, contentAfter: '[ab\u200B]cd', // Final state: '[ab\u200Bc]d' @@ -3367,7 +3458,7 @@ X[] await testEditor(BasicEditor, { contentBefore: '[ab]\u200Bcd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowRight', true); + await keydown(editor.editable, 'ArrowRight', { shiftKey: true }); }, contentAfter: '[ab\u200B]cd', // Final state: '[ab\u200Bc]d' @@ -3377,7 +3468,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'a[b]\u200Bcd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowRight', true); + await keydown(editor.editable, 'ArrowRight', { shiftKey: true }); }, contentAfter: 'a[b\u200B]cd', // Final state: 'a[b\u200Bc]d' @@ -3385,7 +3476,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'a[b]\u200Bcd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowRight', true); + await keydown(editor.editable, 'ArrowRight', { shiftKey: true }); }, contentAfter: 'a[b\u200B]cd', // Final state: 'a[b\u200Bc]d' @@ -3395,7 +3486,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab[]\u200Bcd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowRight', true); + await keydown(editor.editable, 'ArrowRight', { shiftKey: true }); }, contentAfter: 'ab[\u200B]cd', // Final state: 'ab[\u200Bc]d' @@ -3403,7 +3494,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab[]\u200Bcd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowRight', true); + await keydown(editor.editable, 'ArrowRight', { shiftKey: true }); }, contentAfter: 'ab[\u200B]cd', // Final state: 'ab[\u200Bc]d' @@ -3413,7 +3504,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab]\u200B[cd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowRight', true); + await keydown(editor.editable, 'ArrowRight', { shiftKey: true }); }, contentAfter: 'ab\u200B[]cd', // Final state: 'ab\u200B[c]d' @@ -3421,7 +3512,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab]\u200B[cd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowRight', true); + await keydown(editor.editable, 'ArrowRight', { shiftKey: true }); }, contentAfter: 'ab\u200B[]cd', // Final state: 'ab\u200B[c]d' @@ -3429,7 +3520,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab]\u200B[cd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowRight', true); + await keydown(editor.editable, 'ArrowRight', { shiftKey: true }); }, contentAfter: 'ab\u200B[]cd', // Final state: 'ab\u200B[c]d' @@ -3437,7 +3528,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab]\u200B[cd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowRight', true); + await keydown(editor.editable, 'ArrowRight', { shiftKey: true }); }, contentAfter: 'ab\u200B[]cd', // Final state: 'ab\u200B[c]d' @@ -3447,7 +3538,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab]\u200Bc[d', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowRight', true); + await keydown(editor.editable, 'ArrowRight', { shiftKey: true }); }, contentAfter: 'ab\u200B]c[d', // Final state: 'ab\u200Bc[]d' @@ -3455,7 +3546,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab]\u200Bc[d', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowRight', true); + await keydown(editor.editable, 'ArrowRight', { shiftKey: true }); }, contentAfter: 'ab\u200B]c[d', // Final state: 'ab\u200Bc[]d' @@ -3485,7 +3576,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab\u200B[]cd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowLeft', true); + await keydown(editor.editable, 'ArrowLeft', { shiftKey: true }); }, contentAfter: 'ab]\u200B[cd', // Final state: 'a]b\u200B[cd' @@ -3493,7 +3584,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab\u200B[]cd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowLeft', true); + await keydown(editor.editable, 'ArrowLeft', { shiftKey: true }); }, contentAfter: 'ab]\u200B[cd', // Final state: 'a]b\u200B[cd' @@ -3503,7 +3594,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab\u200B]cd[', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowLeft', true); + await keydown(editor.editable, 'ArrowLeft', { shiftKey: true }); }, contentAfter: 'ab]\u200Bcd[', // Final state: 'a]b\u200Bcd[' @@ -3511,7 +3602,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab\u200B]cd[', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowLeft', true); + await keydown(editor.editable, 'ArrowLeft', { shiftKey: true }); }, contentAfter: 'ab]\u200Bcd[', // Final state: 'a]b\u200Bcd[' @@ -3521,7 +3612,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab\u200B]c[d', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowLeft', true); + await keydown(editor.editable, 'ArrowLeft', { shiftKey: true }); }, contentAfter: 'ab]\u200Bc[d', // Final state: 'a]b\u200Bc[d' @@ -3529,7 +3620,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab\u200B]c[d', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowLeft', true); + await keydown(editor.editable, 'ArrowLeft', { shiftKey: true }); }, contentAfter: 'ab]\u200Bc[d', // Final state: 'a]b\u200Bc[d' @@ -3539,7 +3630,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab[\u200B]cd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowLeft', true); + await keydown(editor.editable, 'ArrowLeft', { shiftKey: true }); }, contentAfter: 'ab[]\u200Bcd', // Final state: 'a]b[\u200Bcd' @@ -3547,7 +3638,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab[\u200B]cd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowLeft', true); + await keydown(editor.editable, 'ArrowLeft', { shiftKey: true }); }, contentAfter: 'ab[]\u200Bcd', // Final state: 'a]b[\u200Bcd' @@ -3555,7 +3646,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab[\u200B]cd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowLeft', true); + await keydown(editor.editable, 'ArrowLeft', { shiftKey: true }); }, contentAfter: 'ab[]\u200Bcd', // Final state: 'a]b[\u200Bcd' @@ -3563,7 +3654,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'ab[\u200B]cd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowLeft', true); + await keydown(editor.editable, 'ArrowLeft', { shiftKey: true }); }, contentAfter: 'ab[]\u200Bcd', // Final state: 'a]b[\u200Bcd' @@ -3573,7 +3664,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'a[b\u200B]cd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowLeft', true); + await keydown(editor.editable, 'ArrowLeft', { shiftKey: true }); }, contentAfter: 'a[b]\u200Bcd', // Final state: 'a[]b\u200Bcd' @@ -3581,7 +3672,7 @@ X[] await testEditor(BasicEditor, { contentBefore: 'a[b\u200B]cd', stepFunction: async editor => { - await keydown(editor.editable, 'ArrowLeft', true); + await keydown(editor.editable, 'ArrowLeft', { shiftKey: true }); }, contentAfter: 'a[b]\u200Bcd', // Final state: 'a[]b\u200Bcd' diff --git a/addons/web_editor/static/lib/odoo-editor/test/utils.js b/addons/web_editor/static/lib/odoo-editor/test/utils.js index 16006651136..503ce197ca4 100644 --- a/addons/web_editor/static/lib/odoo-editor/test/utils.js +++ b/addons/web_editor/static/lib/odoo-editor/test/utils.js @@ -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(); }