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',