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 fc010a610b2..f1f0e2763fc 100644
--- a/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js
+++ b/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js
@@ -224,6 +224,7 @@ export class OdooEditor extends EventTarget {
if (editable.innerHTML.trim() === '') {
editable.innerHTML = '
';
}
+ this.initElementForEdition(editable);
// Convention: root node is ID root.
editable.oid = 'root';
@@ -1343,11 +1344,7 @@ export class OdooEditor extends EventTarget {
}
if (joinWith) {
const el = closestElement(joinWith);
- const { zws } = fillEmpty(el);
- if (zws) {
- // ZWS selection in OdooEditor is not working in current version of firefox (since v93.0)
- setSelection(zws, 0, zws, this.isFirefox ? 0 : nodeSize(zws));
- }
+ fillEmpty(el);
}
}
@@ -1447,6 +1444,20 @@ export class OdooEditor extends EventTarget {
return false;
}
if (!sel.isCollapsed && BACKSPACE_FIRST_COMMANDS.includes(method)) {
+ let range = getDeepRange(this.editable, {sel, splitText: true, select: true, correctTripleClick: true});
+ if (range &&
+ range.startContainer === range.endContainer &&
+ range.endContainer.nodeType === Node.TEXT_NODE &&
+ range.cloneContents().textContent === '\u200B'
+ ) {
+ // We Collapse the selection and bypass deleteRange
+ // if the range content is only one ZWS.
+ sel.collapseToStart();
+ if (BACKSPACE_ONLY_COMMANDS.includes(method)) {
+ this._applyRawCommand(method);
+ }
+ return;
+ }
this.deleteRange(sel);
if (BACKSPACE_ONLY_COMMANDS.includes(method)) {
return true;
@@ -1573,6 +1584,21 @@ export class OdooEditor extends EventTarget {
useCache ? this._latestComputedSelection : this._computeHistorySelection(),
) || {};
}
+ /**
+ * Return true if the latest computed selection was inside an empty inline tag
+ *
+ * @private
+ * @return {boolean}
+ */
+ _isLatestComputedSelectionInsideEmptyInlineTag() {
+ if (!this._latestComputedSelection) {
+ return false;
+ }
+ const anchorNode = this._latestComputedSelection.anchorNode;
+ const focusNode = this._latestComputedSelection.focusNode;
+ const parentTextContent = anchorNode.parentElement? anchorNode.parentElement.textContent : null;
+ return anchorNode === focusNode && (parentTextContent === '' || parentTextContent === '\u200B')
+ }
/**
* Get the step index in the history to undo.
* Return -1 if no undo index can be found.
@@ -2188,12 +2214,28 @@ export class OdooEditor extends EventTarget {
const selection = this.document.getSelection();
// Detect that text was selected and change behavior only if it is the case,
// since it is the only text insertion case that may cause problems.
- if (anchorNodeOid !== focusNodeOid || anchorOffset !== focusOffset) {
+ const wasTextSelected = anchorNodeOid !== focusNodeOid || anchorOffset !== focusOffset;
+ // Unit tests events are not trusted by the browser,
+ // the insertText has to be done manualy.
+ const isUnitTests = !ev.isTrusted && this.testMode;
+ // we cannot trust the browser to keep the selection inside empty tags.
+ const latestSelectionInsideEmptyTag = this._isLatestComputedSelectionInsideEmptyInlineTag();
+ if (wasTextSelected || isUnitTests || latestSelectionInsideEmptyTag) {
ev.preventDefault();
- this._applyRawCommand('oDeleteBackward');
+ if (!isUnitTests) {
+ // First we need to undo the character inserted by the browser.
+ // Since the unit test Event is not trusted by the browser, we don't
+ // need to undo the char during the unit tests.
+ // @see https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted
+ this._applyRawCommand('oDeleteBackward');
+ }
+ if (latestSelectionInsideEmptyTag) {
+ // Restore the selection inside the empty Element.
+ const selectionBackup = this._latestComputedSelection;
+ setSelection(selectionBackup.anchorNode, selectionBackup.anchorOffset);
+ }
insertText(selection, ev.data);
- const range = selection.getRangeAt(0);
- setSelection(range.endContainer, range.endOffset);
+ selection.collapseToEnd();
}
// Check for url after user insert a space so we won't transform an incomplete url.
if (
@@ -2316,6 +2358,22 @@ export class OdooEditor extends EventTarget {
this.editable.contains(selection.anchorNode) &&
this.editable.contains(selection.focusNode);
}
+
+ /**
+ * Returns true if the current selection content is only one ZWS
+ *
+ * @private
+ * @param {Object} selection
+ * @returns {boolean}
+ */
+ _isSelectionOnlyZws(selection) {
+ let range = selection.getRangeAt(0);
+ if (selection.isCollapsed || !range) {
+ return false;
+ }
+ return range.cloneContents().textContent === '\u200B';
+ }
+
getCurrentCollaborativeSelection() {
const selection = this._latestComputedSelection || this._computeHistorySelection();
if (!selection) return;
@@ -2335,8 +2393,60 @@ export class OdooEditor extends EventTarget {
this.cleanForSave();
this.observerActive();
}
+
+ /**
+ * initialise the provided element to be ready for edition
+ *
+ */
+ initElementForEdition(element = this.editable) {
+ // Flag elements with forced contenteditable=false.
+ // We need the flag to be able to leave the contentEditable
+ // at the end of the edition (see cleanForSave())
+ for (const el of element.querySelectorAll('[contenteditable="false"]')) {
+ el.setAttribute('data-oe-keep-contenteditable', '');
+ }
+ }
+
cleanForSave(element = this.editable) {
+ sanitize(element);
+
this._pluginCall('cleanForSave', [element]);
+ // Clean the remaining ZeroWidthspaces added by the `fillEmpty` function
+ // ( contain "data-oe-zws-empty-inline" attr)
+ // If the element contain more than just a ZWS,
+ // we remove it and clean the attribute.
+ // If the element have a class,
+ // we only remove the attribute to ensure we don't break some style.
+ // Otherwise we remove the entire inline element.
+ for (const emptyElement of element.querySelectorAll('[data-oe-zws-empty-inline]')) {
+ if (emptyElement.textContent.length === 1 && emptyElement.textContent.includes('\u200B')) {
+ if (emptyElement.classList.length > 0) {
+ emptyElement.removeAttribute('data-oe-zws-empty-inline');
+ } else {
+ emptyElement.remove();
+ }
+ } else {
+ emptyElement.textContent = emptyElement.textContent.replace('\u200B', '');
+ emptyElement.removeAttribute('data-oe-zws-empty-inline');
+ }
+ }
+ // Remove contenteditable=false on elements
+ for (const el of element.querySelectorAll('[contenteditable="false"]')) {
+ if (!el.hasAttribute('data-oe-keep-contenteditable')) {
+ el.removeAttribute('contenteditable');
+ }
+ }
+ // Remove data-oe-keep-contenteditable on elements
+ for (const el of element.querySelectorAll('[data-oe-keep-contenteditable]')) {
+ el.removeAttribute('data-oe-keep-contenteditable');
+ }
+
+ // Remove Zero Width Spzces on Font awesome elements
+ const faSelector = 'i.fa,span.fa,i.fab,span.fab,i.fad,span.fad,i.far,span.far';
+ for (const el of element.querySelectorAll(faSelector)) {
+ el.textContent = el.textContent.replace('\u200B', '');
+ }
+
}
/**
* Handle the hint preview for the commandbar.
diff --git a/addons/web_editor/static/lib/odoo-editor/src/commands/deleteBackward.js b/addons/web_editor/static/lib/odoo-editor/src/commands/deleteBackward.js
index 4e1bd054fd0..f0e34cc3b7b 100644
--- a/addons/web_editor/static/lib/odoo-editor/src/commands/deleteBackward.js
+++ b/addons/web_editor/static/lib/odoo-editor/src/commands/deleteBackward.js
@@ -20,7 +20,6 @@ import {
prepareUpdate,
setSelection,
splitTextNode,
- isUnbreakable,
isMediaElement,
isVisibleEmpty,
} from '../utils/utils.js';
@@ -45,15 +44,20 @@ Text.prototype.oDeleteBackward = function (offset, alreadyMoved = false) {
// Do remove the character, then restore the state of the surrounding parts.
const restore = prepareUpdate(parentNode, firstSplitOffset, parentNode, secondSplitOffset);
const isSpace = !isVisibleStr(middleNode) && !isInPre(middleNode);
+ const isZWS = middleNode.nodeValue === '\u200B';
middleNode.remove();
restore();
// If the removed element was not visible content, propagate the backspace.
if (
+ isZWS ||
isSpace &&
getState(parentNode, firstSplitOffset, DIRECTIONS.LEFT).cType !== CTYPES.CONTENT
) {
parentNode.oDeleteBackward(firstSplitOffset, alreadyMoved);
+ if (isZWS) {
+ fillEmpty(parentNode);
+ }
return;
}
@@ -62,6 +66,7 @@ Text.prototype.oDeleteBackward = function (offset, alreadyMoved = false) {
};
HTMLElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false) {
+ const contentIsZWS = this.textContent === '\u200B';
let moveDest;
if (offset) {
const leftNode = this.childNodes[offset - 1];
@@ -120,9 +125,9 @@ HTMLElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false)
* <=> abc[]def
+ BACKSPACE
*/
const parentOffset = childNodeIndex(this);
- if (!nodeSize(this)) {
- const visible = isVisible(this);
+ if (!nodeSize(this) || contentIsZWS) {
+ const visible = isVisible(this) && !contentIsZWS;
const restore = prepareUpdate(...boundariesOut(this));
this.remove();
restore();
diff --git a/addons/web_editor/static/lib/odoo-editor/src/commands/deleteForward.js b/addons/web_editor/static/lib/odoo-editor/src/commands/deleteForward.js
index e05d8aebb00..c9dcc5a3488 100644
--- a/addons/web_editor/static/lib/odoo-editor/src/commands/deleteForward.js
+++ b/addons/web_editor/static/lib/odoo-editor/src/commands/deleteForward.js
@@ -1,6 +1,5 @@
/** @odoo-module **/
import {
- childNodeIndex,
findNode,
isContentTextNode,
isVisibleEmpty,
@@ -14,14 +13,54 @@ import {
rightLeafOnlyNotBlockNotEditablePath,
rightLeafOnlyPathNotBlockNotEditablePath,
isNotEditableNode,
+ splitTextNode,
+ prepareUpdate,
+ isVisibleStr,
+ isInPre,
+ fillEmpty,
+ setSelection,
+ isZWS,
+ childNodeIndex, boundariesOut
} from '../utils/utils.js';
-Text.prototype.oDeleteForward = function (offset) {
- if (offset < this.length) {
- this.oDeleteBackward(offset + 1);
- } else {
- HTMLElement.prototype.oDeleteForward.call(this.parentNode, childNodeIndex(this) + 1);
+Text.prototype.oDeleteForward = function (offset, alreadyMoved = false) {
+ const parentNode = this.parentNode;
+
+ if (offset === this.nodeValue.length) {
+ // Delete at the end of a text node is not a specific case to
+ // handle, let the element implementation handle it.
+ HTMLElement.prototype.oDeleteForward.call(this, offset, alreadyMoved);
+ return;
}
+
+ // Get the size of the unicode character to remove.
+ const charSize = [...this.nodeValue.slice(0, offset + 1)].pop().length;
+ // Split around the character where the delete occurs.
+ const firstSplitOffset = splitTextNode(this, offset);
+ const secondSplitOffset = splitTextNode(parentNode.childNodes[firstSplitOffset], charSize);
+ const middleNode = parentNode.childNodes[firstSplitOffset];
+
+ // Do remove the character, then restore the state of the surrounding parts.
+ const restore = prepareUpdate(parentNode, firstSplitOffset, parentNode, secondSplitOffset);
+ const isSpace = !isVisibleStr(middleNode) && !isInPre(middleNode);
+ const isZWS = middleNode.nodeValue === '\u200B';
+ middleNode.remove();
+ restore();
+
+ // If the removed element was not visible content, propagate the delete.
+ if (
+ isZWS ||
+ (isSpace &&
+ getState(parentNode, firstSplitOffset, DIRECTIONS.RIGHT).cType !== CTYPES.CONTENT)
+ ) {
+ parentNode.oDeleteForward(firstSplitOffset, alreadyMoved);
+ if (isZWS) {
+ fillEmpty(parentNode);
+ }
+ return;
+ }
+ fillEmpty(parentNode);
+ setSelection(parentNode, firstSplitOffset);
};
HTMLElement.prototype.oDeleteForward = function (offset) {
@@ -29,6 +68,42 @@ HTMLElement.prototype.oDeleteForward = function (offset) {
isVisibleEmpty(node) || isContentTextNode(node) || isNotEditableNode(node);
const firstLeafNode = findNode(rightLeafOnlyNotBlockNotEditablePath(this, offset), filterFunc);
+ if (firstLeafNode &&
+ isZWS(firstLeafNode) &&
+ this.parentElement.hasAttribute('data-oe-zws-empty-inline')
+ ) {
+ const grandparent = this.parentElement.parentElement;
+ if (!grandparent) {
+ return;
+ }
+
+ const parentIndex = childNodeIndex(this.parentElement);
+ const restore = prepareUpdate(...boundariesOut(this.parentElement));
+ this.parentElement.remove();
+ restore();
+ HTMLElement.prototype.oDeleteForward.call(grandparent, parentIndex);
+ return;
+ }
+ if (
+ this.hasAttribute &&
+ this.hasAttribute('data-oe-zws-empty-inline') &&
+ (
+ isZWS(this) ||
+ (this.textContent === '' && this.childNodes.length === 0)
+ )
+ ) {
+ const parent = this.parentElement;
+ if (!parent) {
+ return;
+ }
+
+ const index = childNodeIndex(this);
+ const restore = prepareUpdate(...boundariesOut(this));
+ this.remove();
+ restore();
+ HTMLElement.prototype.oDeleteForward.call(parent, index);
+ return;
+ }
if (firstLeafNode && (isFontAwesome(firstLeafNode) || isNotEditableNode(firstLeafNode))) {
firstLeafNode.remove();
diff --git a/addons/web_editor/static/lib/odoo-editor/src/utils/sanitize.js b/addons/web_editor/static/lib/odoo-editor/src/utils/sanitize.js
index afac7471c29..fbc971db0a6 100644
--- a/addons/web_editor/static/lib/odoo-editor/src/utils/sanitize.js
+++ b/addons/web_editor/static/lib/odoo-editor/src/utils/sanitize.js
@@ -113,6 +113,30 @@ class Sanitize {
node = nodeP;
}
+ // Remove zero-width spaces added by `fillEmpty` when there is content.
+ if (
+ node.nodeType === Node.TEXT_NODE &&
+ node.textContent.includes('\u200B') &&
+ (
+ node.textContent.length > 1 ||
+ // There can be multiple ajacent text nodes, in which case the
+ // zero-width space is not needed either, despite being alone
+ // (length === 1) in its own text node.
+ Array.from(node.parentNode.childNodes).find(
+ sibling =>
+ sibling !== node &&
+ sibling.nodeType === Node.TEXT_NODE &&
+ sibling.length > 0
+ )
+ ) &&
+ !isBlock(node.parentElement)
+ ) {
+ const restoreCursor = preserveCursor(this.root.ownerDocument);
+ node.textContent = node.textContent.replace('\u200B', '');
+ node.parentElement.removeAttribute("data-oe-zws-empty-inline");
+ restoreCursor();
+ }
+
// Remove empty blocks in
if (node.nodeName === 'P' && node.parentElement.tagName === 'LI') {
const next = node.nextSibling;
diff --git a/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js b/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js
index 79269a6dbb9..08d17ded037 100644
--- a/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js
+++ b/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js
@@ -1,5 +1,4 @@
/** @odoo-module **/
-const INVISIBLE_REGEX = /\u200c/g;
export const DIRECTIONS = {
LEFT: false,
@@ -944,6 +943,12 @@ export function isFontAwesome(node) {
['fa', 'fab', 'fad', 'far'].some(faClass => node.classList.contains(faClass))
);
}
+export function isZWS(node) {
+ return (
+ node &&
+ node.textContent === '\u200B'
+ );
+}
export function isMediaElement(node) {
return (
isFontAwesome(node) ||
@@ -1246,7 +1251,7 @@ export function isFakeLineBreak(brEl) {
* @returns {boolean}
*/
export function isEmptyBlock(blockEl) {
- if (blockEl.nodeType !== Node.ELEMENT_NODE) {
+ if (!blockEl || blockEl.nodeType !== Node.ELEMENT_NODE) {
return false;
}
if (isVisibleStr(blockEl.textContent)) {
@@ -1437,10 +1442,23 @@ export function fillEmpty(el) {
blockEl.appendChild(br);
fillers.br = br;
}
- if (!el.textContent.length && isUnremovable(el) && !isBlock(el)) {
+ if (
+ !el.textContent.length &&
+ !isBlock(el) &&
+ el.nodeName !== 'BR' &&
+ !el.hasAttribute("data-oe-zws-empty-inline")
+ ) {
+ // As soon as there is actual content in the node, the zero-width space
+ // is removed by the sanitize function.
const zws = document.createTextNode('\u200B');
el.appendChild(zws);
+ el.setAttribute("data-oe-zws-empty-inline", "");
fillers.zws = zws;
+ const previousSibling = el.previousSibling;
+ if (previousSibling && previousSibling.nodeName === "BR") {
+ previousSibling.remove();
+ }
+ setSelection(zws, 0, zws, 0);
}
return fillers;
}
@@ -1635,7 +1653,7 @@ export function getState(el, offset, direction, leftCType) {
let lastSpace = null;
for (const node of domPath) {
if (node.nodeType === Node.TEXT_NODE) {
- const value = node.nodeValue.replace(INVISIBLE_REGEX, '');
+ const value = node.nodeValue;
// If we hit a text node, the state depends on the path direction:
// any space encountered backwards is a visible space if we hit
// visible content afterwards. If going forward, spaces are only
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
index 8afcca78e6a..a918a9b62f4 100644
--- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
@@ -1900,7 +1900,6 @@ const Wysiwyg = Widget.extend({
// remove ZeroWidthSpace from odoo field value
// ZeroWidthSpace may be present from OdooEditor edition process
let escapedHtml = this._getEscapedElement($el).prop('outerHTML');
- escapedHtml = escapedHtml.replace(/[\u200B-\u200D\uFEFF]/g, '');
return this._rpc({
model: 'ir.ui.view',