[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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user