[FIX] web_editor: keep cursor in empty inline tag

Ensure the user can keep typing inside a newly emptied inline tag:

* Add Zero-width Spaces (ZWS) in empty html tag

> We add zero-width spaces in elements in certain cases where they would
> not be correctly displayed by the browser if they were truly empty.
> This also ensure the user cursor can stay in an empty elements,
> and thus we keep the ability to add content in those elements.
>
> See the `fillEmpty` utils function.

* Remove lingering zero-width spaces

> However, we never remove those zero-width spaces when they are not
> useful anymore. In particular, keeping those invisible characters in the
> content prevents the ecommerce from saving the new price of a product if
> the previous price was previously completely emptied during edition.
> This commit fixes that.
>
> The issue didn't happen with the previous editor because they were
> using non-breaking space instead, which is removed by the text_content
> method of lxml. However, non-breaking space were visible during edition,
> which was weird on its own.
>
> Some changes had to be made to the deleteForward and backward commands,
> to ensure the new behaviors.
> Author : @dmo-odoo

* Adapt the deleteForward and backward commands

> To ensure the added ZWS do not break the Deletes behaviors,
> we need to adapt the `deleteForward` and `deleteBackward` commands.

Co-authored-by David Monjoie <dmo@odoo.com>

task-2622455

closes odoo/odoo#84891

X-original-commit: 4360643a446271d4ff1898d67a5b5287722a0820
Signed-off-by: David Monjoie (dmo) <dmo@odoo.com>
This commit is contained in:
Sébastien Geelen (sge)
2022-02-18 14:29:30 +00:00
parent 9e12a209de
commit b160eabafd
6 changed files with 254 additions and 23 deletions
@@ -224,6 +224,7 @@ export class OdooEditor extends EventTarget {
if (editable.innerHTML.trim() === '') {
editable.innerHTML = '<p><br></p>';
}
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.
@@ -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)
* <=> <p>abc[]<i>def</i></p> + 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();
@@ -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();
@@ -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 <li>
if (node.nodeName === 'P' && node.parentElement.tagName === 'LI') {
const next = node.nextSibling;
@@ -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
@@ -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',