diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js index ee68cd8acbb..0098ded098c 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js @@ -775,7 +775,6 @@ export class OdooEditor extends EventTarget { sanitize(target) { this.observerFlush(); - let record; if (!target) { // If the target is not given, @@ -783,6 +782,9 @@ export class OdooEditor extends EventTarget { // in the mutations from the last step. for (record of this._currentStep.mutations) { const node = this.idFind(record.parentId || record.id) || this.editable; + if (!this.editable.contains(node)) { + continue; + } target = target ? commonParentGet(target, node, this.editable) : node; @@ -2028,7 +2030,9 @@ export class OdooEditor extends EventTarget { insertedZws = zws; } let start = range.startContainer; + const startBlock = closestBlock(start); let end = range.endContainer; + const endBlock = closestBlock(end); // Let the DOM split and delete the range. const doJoin = (closestBlock(start) !== closestBlock(range.commonAncestorContainer) || @@ -2100,6 +2104,21 @@ export class OdooEditor extends EventTarget { break; } } + // If the oDeleteBackward loop have emptied the start block and the + // range end in another element (rangeStart != rangeEnd), we delete + // the start block and move the cursor to the end block. + if ( + startBlock && + startBlock.textContent === '\u200B' && + endBlock && + startBlock !== endBlock && + !isEmptyBlock(endBlock) && + paragraphRelatedElements.includes(endBlock.nodeName) + ) { + startBlock.remove(); + setSelection(endBlock, 0); + fillEmpty(endBlock); + } if (insertedZws) { // Remove the zero-width space (zws) that was added to preserve the // parent styles, then call `fillEmpty` to properly add a flagged diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/deleteBackward.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/deleteBackward.js index 35feccad89f..661ef46f35d 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/deleteBackward.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/deleteBackward.js @@ -27,6 +27,7 @@ import { isNotEditableNode, createDOMPathGenerator, closestElement, + closestBlock, } from '../utils/utils.js'; Text.prototype.oDeleteBackward = function (offset, alreadyMoved = false) { @@ -141,6 +142,29 @@ HTMLElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false, return; } + /** If we are at the beninning of a block node, + * And the previous node is empty, remove it. + * + * E.g. (previousEl == empty) + *


[]def

+ BACKSPACE + * <=>

[]def

+ * + * E.g. (previousEl != empty) + *

abc

[]def

+ BACKSPACE + * <=>

abc[]def

+ */ + const previousElementSiblingClosestBlock = closestBlock(this.previousElementSibling); + if ( + previousElementSiblingClosestBlock && + (isEmptyBlock(previousElementSiblingClosestBlock) || + previousElementSiblingClosestBlock.textContent === '\u200B') && + paragraphRelatedElements.includes(this.nodeName) + ) { + previousElementSiblingClosestBlock.remove(); + setSelection(this, 0); + return; + } + /** * Backspace at the beginning of a block node. If it doesn't have a left * block and it is one of the special block formatting tags below then diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js index 1d131757570..0384598605f 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js @@ -57,6 +57,8 @@ export const YOUTUBE_URL_GET_VIDEO_ID = /^(?:(?:https?:)?\/\/)?(?:(?:www|m)\.)?(?:youtube\.com|youtu\.be)(?:\/(?:[\w-]+\?v=|embed\/|v\/)?)([^\s?&#]+)(?:\S+)?$/i; export const EMAIL_REGEX = /^(mailto:)?[\w-.]+@(?:[\w-]+\.)+[\w-]{2,4}$/i; +export const PROTECTED_BLOCK_TAG = ['TR','TD','TABLE','TBODY','UL','OL','LI']; + //------------------------------------------------------------------------------ // Position and sizes //------------------------------------------------------------------------------ diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js index 33923d69590..1d55b7c778c 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js @@ -836,12 +836,11 @@ X[] contentAfter: '

ab[]

', }); }); - it('should merge a heading1 with text into an empty paragraph (keeping the heading)', async () => { + it('should remove empty paragraph (keeping the heading)', async () => { await testEditor(BasicEditor, { contentBefore: '


[]

ab

', stepFunction: deleteForward, - // JW cAfter: '

[]ab

', - contentAfter: '

[]ab

', + contentAfter: '

[]ab

', }); }); it('should merge a text following a paragraph (keeping the text)', async () => { @@ -858,12 +857,11 @@ X[] }); }); describe('With attributes', () => { - it('should merge a paragraph without class into an empty paragraph with a class', async () => { + it('should remove empty paragraph with class', async () => { await testEditor(BasicEditor, { contentBefore: '


[]

abc

', stepFunction: deleteForward, - // JW cAfter: '

[]abc

', - contentAfter: '

[]abc

', + contentAfter: '

[]abc

', }); }); it('should merge two paragraphs with spans of same classes', async () => { @@ -1248,28 +1246,92 @@ X[] contentAfter: '

ab []gh

', }); }); - it('should delete a selection from the beginning of a heading1 with a format to the middle of a paragraph', async () => { - // Forward selection + it('should delete a selection from the beginning of a heading1 with a format to the middle of a paragraph + start of editable', async () => { + //Forward selection await testEditor(BasicEditor, { - contentBefore: '

[abcd

ef]gh

', + contentBefore: '

[abcd

ef]gh1

', stepFunction: deleteForward, - contentAfter: '

[]gh

', + contentAfter: '

[]gh1

', }); await testEditor(BasicEditor, { - contentBefore: '

[abcd

ef]gh

', + contentBefore: '

[abcd

ef]gh2

', stepFunction: deleteForward, - contentAfter: '

[]gh

', + contentAfter: '

[]gh2

', }); // Backward selection await testEditor(BasicEditor, { - contentBefore: '

]abcd

ef[gh

', + contentBefore: '

]abcd

ef[gh3

', stepFunction: deleteForward, - contentAfter: '

[]gh

', + contentAfter: '

[]gh3

', }); await testEditor(BasicEditor, { - contentBefore: '

]abcd

ef[gh

', + contentBefore: '

]abcd

ef[gh4

', stepFunction: deleteForward, - contentAfter: '

[]gh

', + contentAfter: '

[]gh4

', + }); + }); + it('should delete a selection from the beginning of a heading1 with a format to the middle of a paragraph + content', async () => { + await testEditor(BasicEditor, { + contentBefore: '

content

[abcd

ef]gh1

', + stepFunction: deleteForward, + contentAfter: '

content

[]gh1

', + }); + await testEditor(BasicEditor, { + contentBefore: '

content

[abcd

ef]gh2

', + stepFunction: deleteForward, + contentAfter: '

content

[]gh2

', + }); + }); + it('should delete a selection from the beginning of a heading1 to the end of a paragraph', async () => { + //Forward selection + await testEditor(BasicEditor, { + contentBefore: '

[abcd

ef]

1

', + stepFunction: deleteForward, + contentAfter: '

[]

1

', + }); + await testEditor(BasicEditor, { + contentBefore: '

[abcd

ef]

2

', + stepFunction: deleteForward, + contentAfter: '

[]

2

', + }); + // Backward selection + await testEditor(BasicEditor, { + contentBefore: '

]abcd

ef[

3

', + stepFunction: deleteForward, + contentAfter: '

[]

3

', + }); + await testEditor(BasicEditor, { + contentBefore: '

]abcd

ef[

4

', + stepFunction: deleteForward, + contentAfter: '

[]

4

', + }); + }); + it('should delete a selection from the beginning of a heading1 with a format to the end of a paragraph', async () => { + //Forward selection + await testEditor(BasicEditor, { + contentBefore: '

[abcd

ef]

1

', + stepFunction: deleteForward, + contentAfterEdit: '

[]\u200B

1

', + contentAfter: '

[]

1

', + }); + await testEditor(BasicEditor, { + contentBefore: '

[abcd

ef]

2

', + stepFunction: deleteForward, + contentAfterEdit: '

[]\u200B

2

', + contentAfter: '

[]

2

', + }); + // Backward selection + await testEditor(BasicEditor, { + contentBefore: '

]abcd

ef[

3

', + stepFunction: deleteForward, + contentAfterEdit: '

[]\u200B

3

', + contentAfter: '

[]

3

', + }); + await testEditor(BasicEditor, { + contentBefore: '

]abcd

ef[

4

', + stepFunction: deleteForward, + contentAfterEdit: '

[]\u200B

4

', + contentAfter: '

[]

4

', }); }); it('should not break unbreakables', async () => { @@ -1726,94 +1788,115 @@ X[] }); it('should not break unbreakables', async () => { await testEditor(BasicEditor, { - contentBefore: `
` + - `
[]
` + - `
abc
`, + contentBefore: `
` + + `
[]
` + + `
abc
` + + `
`, stepFunction: deleteBackward, - contentAfter: `
` + - `
[]
` + - `
abc
`, + contentAfter: `
` + + `
[]
` + + `
abc
` + + `
`, }); await testEditor(BasicEditor, { - contentBefore: `
` + - `
[ab
` + + contentBefore: `
` + + `
[ab
` + `
cd
` + - `
e]f
`, + `
e]f1
` + + `
`, stepFunction: deleteBackward, - contentAfter: `
` + - `
[]
` + - `
f
`, + contentAfter: `
` + + `
[]
` + + `
f1
` + + `
`, }); await testEditor(BasicEditor, { - contentBefore: `
` + - `
a[b
` + + contentBefore: `
` + + `
a[b
` + `
cd
` + - `
e]f
`, + `
e]f2
` + + `
`, stepFunction: deleteBackward, - contentAfter: `
` + - `
a[]
` + - `
f
`, + contentAfter: `
` + + `
a[]
` + + `
f2
` + + `
`, }); await testEditor(BasicEditor, { - contentBefore: `
` + - `
a[b
` + + contentBefore: `
` + + `
3a[b
` + `
cd
` + - `
ef]
`, + `
ef]
` + + `
`, stepFunction: deleteBackward, - contentAfter: `
` + - `
a[]
` + - `
`, + contentAfter: `
` + + `
3a[]
` + + `
`, }); await testEditor(BasicEditor, { - contentBefore: `
` + - `
[ab
` + + contentBefore: `
` + + `
[ab
` + `
cd
` + - `
ef]
`, + `
ef4]
` + + `
`, stepFunction: deleteBackward, - contentAfter: `
` + - `
[]
` + - `
`, + contentAfter: `
` + + `
[]
` + + `
`, }); await testEditor(BasicEditor, { - contentBefore: `
` + - `
[ab
` + + contentBefore: `
` + + `
[ab
` + `
cd
` + - `
ef
` + - `
gh
` + + `
ef
` + + `
` + + `
` + + `
gh
` + `
ij
` + - `
k]l
`, + `
k]l5
` + + `
`, stepFunction: deleteBackward, - contentAfter: `
` + - `
[]
` + - `
` + - `
l
`, + contentAfter: `
` + + `
[]
` + + `
` + + `
` + + `
l5
` + + `
`, }); await testEditor(BasicEditor, { - contentBefore: `
` + - `
a[b
` + + contentBefore: `
` + + `
a[b
` + `
cd
` + - `
ef
` + - `
gh
` + + `
ef
` + + `
` + + `
` + + `
gh
` + `
ij
` + - `
k]l
`, + `
k]l6
` + + `
`, stepFunction: deleteBackward, - contentAfter: `
` + - `
a[]
` + - `
` + - `
l
`, + contentAfter: `
` + + `
a[]
` + + `
` + + `
` + + `
l6
` + + `
`, }); await testEditor(BasicEditor, { - contentBefore: `
` + - `
a[b
` + + contentBefore: `
` + + `
7a[b
` + `
cd
` + - `
ef
` + - `
gh
` + + `
ef
` + + `
` + + `
` + + `
gh
` + `
ij
` + - `
kl]
`, + `
kl]
` + + `
`, stepFunction: editor => deleteBackward(editor), - contentAfter: `
` + - `
a[]
` + - `
`, + contentAfter: `
` + + `
7a[]
` + + `
`, }); }); it('should merge the following inline text node', async () => { @@ -2278,12 +2361,11 @@ X[] contentAfter: '

ab[]

', }); }); - it('should merge a heading1 with text into an empty paragraph (keeping the heading)', async () => { + it('should remove empty paragraph (keeping the heading)', async () => { await testEditor(BasicEditor, { contentBefore: '


[]ab

', stepFunction: deleteBackward, - // JW cAfter: '

[]ab

', - contentAfter: '

[]ab

', + contentAfter: '

[]ab

', }); }); it('should merge with previous node (default behaviour)', async () => { @@ -2337,12 +2419,11 @@ X[] }); }); describe('With attributes', () => { - it('should merge a paragraph without class into an empty paragraph with a class', async () => { + it('should remove paragraph with class', async () => { await testEditor(BasicEditor, { contentBefore: '


[]abc

', stepFunction: deleteBackward, - // JW cAfter: '

[]abc

', - contentAfter: '

[]abc

', + contentAfter: '

[]abc

', }); }); it('should merge two paragraphs with spans of same classes', async () => { @@ -2602,7 +2683,7 @@ X[] await testEditor(BasicEditor, { contentBefore: '

abc


[]def

', stepFunction: deleteBackward, - contentAfter: '

abc

[]def

', + contentAfter: '

abc

[]def

', }); }); it('should remove only one br between contents', async () => { @@ -2829,25 +2910,25 @@ X[] it('should delete a selection from the beginning of a heading1 with a format to the middle of a paragraph', async () => { // Forward selection await testEditor(BasicEditor, { - contentBefore: '

[abcd

ef]gh

', + contentBefore: '

[abcd

ef]gh1

', stepFunction: deleteBackward, - contentAfter: '

[]gh

', + contentAfter: '

[]gh1

', }); await testEditor(BasicEditor, { - contentBefore: '

[abcd

ef]gh

', + contentBefore: '

[abcd

ef]gh2

', stepFunction: deleteBackward, - contentAfter: '

[]gh

', + contentAfter: '

[]gh2

', }); // Backward selection await testEditor(BasicEditor, { - contentBefore: '

]abcd

ef[gh

', + contentBefore: '

]abcd

ef[gh3

', stepFunction: deleteBackward, - contentAfter: '

[]gh

', + contentAfter: '

[]gh3

', }); await testEditor(BasicEditor, { - contentBefore: '

]abcd

ef[gh

', + contentBefore: '

]abcd

ef[gh4

', stepFunction: deleteBackward, - contentAfter: '

[]gh

', + contentAfter: '

[]gh4

', }); }); it('should delete a heading (triple click backspace)', async () => {