[FIX] web_editor: backspace should keep current node

Before this commit:

On backspace current node is removed and it's content were moved to it's
preceding node.

After this commit:

Now keeping the current node it removes preceding node if empty and if not empty
then merge current node to previous node.

Task-2909314
task-3186679

X-original-commit: bca7f0455ce56268a592c924ac22f9f2dc6541db
Part-of: odoo/odoo#134327
Co-authored-by: Sanjay Sharma <shsa@odoo.com>
This commit is contained in:
Sébastien Geelen (sge)
2023-09-06 05:53:48 +00:00
co-authored by Sanjay Sharma
parent 78fcad9176
commit d8d30bc7b1
4 changed files with 214 additions and 88 deletions
@@ -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
@@ -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)
* <p><br></p><h1>[]def</h1> + BACKSPACE
* <=> <h1>[]def</h1>
*
* E.g. (previousEl != empty)
* <h3>abc</h3><h1>[]def</h1> + BACKSPACE
* <=> <h3>abc[]def</h3>
*/
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
@@ -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
//------------------------------------------------------------------------------
@@ -836,12 +836,11 @@ X[]
contentAfter: '<h1>ab[]</h1>',
});
});
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: '<p><br>[]</p><h1>ab</h1>',
stepFunction: deleteForward,
// JW cAfter: '<h1>[]ab</h1>',
contentAfter: '<p>[]ab</p>',
contentAfter: '<h1>[]ab</h1>',
});
});
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: '<p class="a"><br>[]</p><p>abc</p>',
stepFunction: deleteForward,
// JW cAfter: '<p>[]abc</p>',
contentAfter: '<p class="a">[]abc</p>',
contentAfter: '<p>[]abc</p>',
});
});
it('should merge two paragraphs with spans of same classes', async () => {
@@ -1248,28 +1246,92 @@ X[]
contentAfter: '<h1>ab []gh</h1>',
});
});
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: '<h1><b>[abcd</b></h1><p>ef]gh</p>',
contentBefore: '<h1><b>[abcd</b></h1><p>ef]gh1</p>',
stepFunction: deleteForward,
contentAfter: '<h1>[]gh</h1>',
contentAfter: '<p>[]gh1</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<h1>[<b>abcd</b></h1><p>ef]gh</p>',
contentBefore: '<h1>[<b>abcd</b></h1><p>ef]gh2</p>',
stepFunction: deleteForward,
contentAfter: '<h1>[]gh</h1>',
contentAfter: '<p>[]gh2</p>',
});
// Backward selection
await testEditor(BasicEditor, {
contentBefore: '<h1><b>]abcd</b></h1><p>ef[gh</p>',
contentBefore: '<h1><b>]abcd</b></h1><p>ef[gh3</p>',
stepFunction: deleteForward,
contentAfter: '<h1>[]gh</h1>',
contentAfter: '<p>[]gh3</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<h1>]<b>abcd</b></h1><p>ef[gh</p>',
contentBefore: '<h1>]<b>abcd</b></h1><p>ef[gh4</p>',
stepFunction: deleteForward,
contentAfter: '<h1>[]gh</h1>',
contentAfter: '<p>[]gh4</p>',
});
});
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: '<p>content</p><h1><b>[abcd</b></h1><p>ef]gh1</p>',
stepFunction: deleteForward,
contentAfter: '<p>content</p><p>[]gh1</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p>content</p><h1>[<b>abcd</b></h1><p>ef]gh2</p>',
stepFunction: deleteForward,
contentAfter: '<p>content</p><p>[]gh2</p>',
});
});
it('should delete a selection from the beginning of a heading1 to the end of a paragraph', async () => {
//Forward selection
await testEditor(BasicEditor, {
contentBefore: '<h1>[abcd</h1><p>ef]</p><h2>1</h2>',
stepFunction: deleteForward,
contentAfter: '<h1>[]<br></h1><h2>1</h2>',
});
await testEditor(BasicEditor, {
contentBefore: '<h1>[abcd</h1><p>ef]</p><h2>2</h2>',
stepFunction: deleteForward,
contentAfter: '<h1>[]<br></h1><h2>2</h2>',
});
// Backward selection
await testEditor(BasicEditor, {
contentBefore: '<h1>]abcd</h1><p>ef[</p><h2>3</h2>',
stepFunction: deleteForward,
contentAfter: '<h1>[]<br></h1><h2>3</h2>',
});
await testEditor(BasicEditor, {
contentBefore: '<h1>]abcd</h1><p>ef[</p><h2>4</h2>',
stepFunction: deleteForward,
contentAfter: '<h1>[]<br></h1><h2>4</h2>',
});
});
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: '<h1><u>[abcd</u></h1><p>ef]</p><h2>1</h2>',
stepFunction: deleteForward,
contentAfterEdit: '<h1><u data-oe-zws-empty-inline="">[]\u200B</u><br></h1><h2>1</h2>',
contentAfter: '<h1>[]<br></h1><h2>1</h2>',
});
await testEditor(BasicEditor, {
contentBefore: '<h1>[<u>abcd</u></h1><p>ef]</p><h2>2</h2>',
stepFunction: deleteForward,
contentAfterEdit: '<h1><u data-oe-zws-empty-inline="">[]\u200B</u><br></h1><h2>2</h2>',
contentAfter: '<h1>[]<br></h1><h2>2</h2>',
});
// Backward selection
await testEditor(BasicEditor, {
contentBefore: '<h1><u>]abcd</u></h1><p>ef[</p><h2>3</h2>',
stepFunction: deleteForward,
contentAfterEdit: '<h1><u data-oe-zws-empty-inline="">[]\u200B</u><br></h1><h2>3</h2>',
contentAfter: '<h1>[]<br></h1><h2>3</h2>',
});
await testEditor(BasicEditor, {
contentBefore: '<h1>]<u>abcd</u></h1><p>ef[</p><h2>4</h2>',
stepFunction: deleteForward,
contentAfterEdit: '<h1><u data-oe-zws-empty-inline="">[]\u200B</u><br></h1><h2>4</h2>',
contentAfter: '<h1>[]<br></h1><h2>4</h2>',
});
});
it('should not break unbreakables', async () => {
@@ -1726,94 +1788,115 @@ X[]
});
it('should not break unbreakables', async () => {
await testEditor(BasicEditor, {
contentBefore: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">[]<br></div>` +
`<div class="oe_unbreakable">abc</div></div></div></div>`,
contentBefore: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">[]<br></div>` +
`<div class="oe_unbreakable">abc</div>` +
`</div>`,
stepFunction: deleteBackward,
contentAfter: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">[]<br></div>` +
`<div class="oe_unbreakable">abc</div></div></div></div>`,
contentAfter: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">[]<br></div>` +
`<div class="oe_unbreakable">abc</div>` +
`</div>`,
});
await testEditor(BasicEditor, {
contentBefore: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">[ab</div>` +
contentBefore: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">[ab</div>` +
`<div class="oe_unbreakable">cd</div>` +
`<div class="oe_unbreakable">e]f</div></div></div></div>`,
`<div class="oe_unbreakable">e]f1</div>` +
`</div>`,
stepFunction: deleteBackward,
contentAfter: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">[]<br></div>` +
`<div class="oe_unbreakable">f</div></div></div></div>`,
contentAfter: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">[]<br></div>` +
`<div class="oe_unbreakable">f1</div>` +
`</div>`,
});
await testEditor(BasicEditor, {
contentBefore: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">a[b</div>` +
contentBefore: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">a[b</div>` +
`<div class="oe_unbreakable">cd</div>` +
`<div class="oe_unbreakable">e]f</div></div></div></div>`,
`<div class="oe_unbreakable">e]f2</div>` +
`</div>`,
stepFunction: deleteBackward,
contentAfter: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">a[]</div>` +
`<div class="oe_unbreakable">f</div></div></div></div>`,
contentAfter: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">a[]</div>` +
`<div class="oe_unbreakable">f2</div>` +
`</div>`,
});
await testEditor(BasicEditor, {
contentBefore: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">a[b</div>` +
contentBefore: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">3a[b</div>` +
`<div class="oe_unbreakable">cd</div>` +
`<div class="oe_unbreakable">ef]</div></div></div></div>`,
`<div class="oe_unbreakable">ef]</div>` +
`</div>`,
stepFunction: deleteBackward,
contentAfter: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">a[]</div>` +
`</div></div></div>`,
contentAfter: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">3a[]</div>` +
`</div>`,
});
await testEditor(BasicEditor, {
contentBefore: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">[ab</div>` +
contentBefore: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">[ab</div>` +
`<div class="oe_unbreakable">cd</div>` +
`<div class="oe_unbreakable">ef]</div></div></div></div>`,
`<div class="oe_unbreakable">ef4]</div>` +
`</div>`,
stepFunction: deleteBackward,
contentAfter: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">[]<br></div>` +
`</div></div></div>`,
contentAfter: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">[]<br></div>` +
`</div>`,
});
await testEditor(BasicEditor, {
contentBefore: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">[ab</div>` +
contentBefore: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">[ab</div>` +
`<div class="oe_unbreakable">cd</div>` +
`<div class="oe_unbreakable">ef</div></div>` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">gh</div>` +
`<div class="oe_unbreakable">ef</div>` +
`</div>` +
`<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">gh</div>` +
`<div class="oe_unbreakable">ij</div>` +
`<div class="oe_unbreakable">k]l</div></div></div></div>`,
`<div class="oe_unbreakable">k]l5</div>` +
`</div>`,
stepFunction: deleteBackward,
contentAfter: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">[]<br></div>` +
`</div><div class="oe_unbreakable">` +
`<div class="oe_unbreakable">l</div></div></div></div>`,
contentAfter: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">[]<br></div>` +
`</div>` +
`<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">l5</div>` +
`</div>`,
});
await testEditor(BasicEditor, {
contentBefore: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">a[b</div>` +
contentBefore: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">a[b</div>` +
`<div class="oe_unbreakable">cd</div>` +
`<div class="oe_unbreakable">ef</div></div>` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">gh</div>` +
`<div class="oe_unbreakable">ef</div>` +
`</div>` +
`<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">gh</div>` +
`<div class="oe_unbreakable">ij</div>` +
`<div class="oe_unbreakable">k]l</div></div></div></div>`,
`<div class="oe_unbreakable">k]l6</div>` +
`</div>`,
stepFunction: deleteBackward,
contentAfter: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">a[]</div>` +
`</div><div class="oe_unbreakable">` +
`<div class="oe_unbreakable">l</div></div></div></div>`,
contentAfter: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">a[]</div>` +
`</div>` +
`<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">l6</div>` +
`</div>`,
});
await testEditor(BasicEditor, {
contentBefore: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">a[b</div>` +
contentBefore: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">7a[b</div>` +
`<div class="oe_unbreakable">cd</div>` +
`<div class="oe_unbreakable">ef</div></div>` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">gh</div>` +
`<div class="oe_unbreakable">ef</div>` +
`</div>` +
`<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">gh</div>` +
`<div class="oe_unbreakable">ij</div>` +
`<div class="oe_unbreakable">kl]</div></div></div></div>`,
`<div class="oe_unbreakable">kl]</div>` +
`</div>`,
stepFunction: editor => deleteBackward(editor),
contentAfter: `<div class="oe_unbreakable"><div class="oe_unbreakable">` +
`<div class="oe_unbreakable"><div class="oe_unbreakable">a[]</div>` +
`</div></div></div>`,
contentAfter: `<div class="oe_unbreakable">` +
`<div class="oe_unbreakable">7a[]</div>` +
`</div>`,
});
});
it('should merge the following inline text node', async () => {
@@ -2278,12 +2361,11 @@ X[]
contentAfter: '<h1>ab[]</h1>',
});
});
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: '<p><br></p><h1>[]ab</h1>',
stepFunction: deleteBackward,
// JW cAfter: '<h1>[]ab</h1>',
contentAfter: '<p>[]ab</p>',
contentAfter: '<h1>[]ab</h1>',
});
});
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: '<p class="a"><br></p><p>[]abc</p>',
stepFunction: deleteBackward,
// JW cAfter: '<p>[]abc</p>',
contentAfter: '<p class="a">[]abc</p>',
contentAfter: '<p>[]abc</p>',
});
});
it('should merge two paragraphs with spans of same classes', async () => {
@@ -2602,7 +2683,7 @@ X[]
await testEditor(BasicEditor, {
contentBefore: '<p>abc</p><h1><br></h1><p>[]def</p>',
stepFunction: deleteBackward,
contentAfter: '<p>abc</p><h1>[]def</h1>',
contentAfter: '<p>abc</p><p>[]def</p>',
});
});
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: '<h1><b>[abcd</b></h1><p>ef]gh</p>',
contentBefore: '<h1><b>[abcd</b></h1><p>ef]gh1</p>',
stepFunction: deleteBackward,
contentAfter: '<h1>[]gh</h1>',
contentAfter: '<p>[]gh1</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<h1>[<b>abcd</b></h1><p>ef]gh</p>',
contentBefore: '<h1>[<b>abcd</b></h1><p>ef]gh2</p>',
stepFunction: deleteBackward,
contentAfter: '<h1>[]gh</h1>',
contentAfter: '<p>[]gh2</p>',
});
// Backward selection
await testEditor(BasicEditor, {
contentBefore: '<h1><b>]abcd</b></h1><p>ef[gh</p>',
contentBefore: '<h1><b>]abcd</b></h1><p>ef[gh3</p>',
stepFunction: deleteBackward,
contentAfter: '<h1>[]gh</h1>',
contentAfter: '<p>[]gh3</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<h1>]<b>abcd</b></h1><p>ef[gh</p>',
contentBefore: '<h1>]<b>abcd</b></h1><p>ef[gh4</p>',
stepFunction: deleteBackward,
contentAfter: '<h1>[]gh</h1>',
contentAfter: '<p>[]gh4</p>',
});
});
it('should delete a heading (triple click backspace)', async () => {