diff --git a/addons/web_editor/static/lib/jabberwock/jabberwock.js b/addons/web_editor/static/lib/jabberwock/jabberwock.js index a0b43dd5b4d..81fdb9b499f 100644 --- a/addons/web_editor/static/lib/jabberwock/jabberwock.js +++ b/addons/web_editor/static/lib/jabberwock/jabberwock.js @@ -5516,7 +5516,17 @@ odoo.define('web_editor.jabberwock', (function(require) { } } else { + const shouldDeleteLine = !range.start.previousSibling() && + !range.end.previousSibling() && + range.startContainer !== range.endContainer; + const oldEndContainer = range.endContainer; range.empty(); + // This handles the special case of a triple click followed by a + // backspace: the line should be deleted with its container (no + // merging). + if (shouldDeleteLine) { + range.startContainer.replaceWith(oldEndContainer); + } } } /** @@ -5568,7 +5578,17 @@ odoo.define('web_editor.jabberwock', (function(require) { } } else { + const shouldDeleteLine = !range.start.previousSibling() && + !range.end.previousSibling() && + range.startContainer !== range.endContainer; + const oldEndContainer = range.endContainer; range.empty(); + // This handles the special case of a triple click followed by a + // delete: the line should be deleted with its container (no + // merging). + if (shouldDeleteLine) { + range.startContainer.replaceWith(oldEndContainer); + } } } async deleteWord(params) { @@ -8191,6 +8211,7 @@ odoo.define('web_editor.jabberwock', (function(require) { 'TABLE', 'UL', // The following elements are not in the W3C list, for some reason. + 'SELECT', 'TR', 'TD', 'TBODY', @@ -8334,43 +8355,54 @@ odoo.define('web_editor.jabberwock', (function(require) { * @returns {boolean} */ function _isAtSegmentBreak(node, side) { - const siblingSide = side === 'start' ? 'previousSibling' : 'nextSibling'; - const sibling = node && node[siblingSide]; - const isAgainstAnotherSegment = _isAgainstAnotherSegment(node, side); + const direction = side === 'start' ? 'previous' : 'next'; + const sibling = _significantSibling(node, direction); + const isAgainstAnotherSegment = sibling && _isSegment(sibling); const isAtEdgeOfOwnSegment = _isBlockEdge(node, side); // In the DOM, a space before a BR is rendered but a space after a BR isn't. const isBeforeBR = side === 'end' && sibling && nodeName(sibling) === 'BR'; return (isAgainstAnotherSegment && !isBeforeBR) || isAtEdgeOfOwnSegment; } /** - * Return true if the given node is just before or just after another segment. - * Eg:
abc
def
-> abc is before another segment (div). - * Eg:
abc
def
-> abc is before another segment - * (div). + * Return the next|previous "significant" node. Nodes to skip are empty text + * nodes (text nodes with no text content or only whitespace), and hidden + * inputs. + * If `searchUp` is true (which is the default), the next|previous "significant" + * node may be a cousin. * * @param {Node} node - * @param {'start'|'end'} side - * @returns {boolean} + * @param {'previous'|'next'} direction + * @param {boolean} [searchUp] (default: true) + * @returns {Node} */ - function _isAgainstAnotherSegment(node, side) { - const siblingSide = side === 'start' ? 'previousSibling' : 'nextSibling'; - const sibling = node && node[siblingSide]; - if (sibling) { - return sibling && _isSegment(sibling); + function _significantSibling(node, direction, searchUp = true) { + const siblingSide = direction === 'previous' ? 'previousSibling' : 'nextSibling'; + let sibling = node && node[siblingSide]; + let lastExisting = sibling || node; + if (sibling && + // Sibling is an empty text node. + ((sibling.nodeType === Node.TEXT_NODE && + onlyTabsSpacesAndNewLines.test(sibling.textContent)) || + // Sibling is a hidden input. + (nodeName(sibling) === 'INPUT' && + sibling.getAttribute('type') === 'hidden'))) { + lastExisting = sibling; + sibling = sibling[siblingSide]; } - else { + if (!sibling && searchUp) { // Look further (eg.: `
abc
def
`: the // space should be removed). - let ancestor = node; - while (ancestor && !ancestor[siblingSide]) { + let ancestor = lastExisting; + while (ancestor && !_significantSibling(ancestor, direction, false)) { ancestor = ancestor.parentNode; } - let cousin = ancestor && !_isSegment(ancestor) && ancestor.nextSibling; - while (cousin && isInstanceOf(cousin, Text)) { - cousin = cousin.nextSibling; + sibling = + ancestor && !_isSegment(ancestor) && _significantSibling(ancestor, direction, false); + while (sibling && isInstanceOf(sibling, Text)) { + sibling = _significantSibling(sibling, direction, false); } - return cousin && _isSegment(cousin); } + return sibling; } /** * Return true if the node is a segment according to W3 formatting model. @@ -8408,13 +8440,13 @@ odoo.define('web_editor.jabberwock', (function(require) { } // Return true if no ancestor up to the first block ancestor has a // sibling on the specified side - const siblingSide = side === 'start' ? 'previousSibling' : 'nextSibling'; + const direction = side === 'start' ? 'previous' : 'next'; return ancestorsUpToBlock.every(ancestor => { - let sibling = ancestor[siblingSide]; + let sibling = _significantSibling(ancestor, direction); while (sibling && isInstanceOf(sibling, Text) && sibling.textContent.match(onlyTabsSpacesAndNewLines)) { - sibling = sibling[siblingSide]; + sibling = _significantSibling(sibling, direction); } return !sibling; }); @@ -9170,6 +9202,7 @@ odoo.define('web_editor.jabberwock', (function(require) { * @param context */ async parse(item) { + var _a; const children = Array.from(item.childNodes); const nodes = []; let inlinesContainer; @@ -9235,6 +9268,8 @@ odoo.define('web_editor.jabberwock', (function(require) { return [container]; } else if ((nodes.length === 1 && + // We can't remove container that contain Attributes + ((_a = nodes[0].modifiers.find(Attributes)) === null || _a === void 0 ? void 0 : _a.keys()) === undefined && // TODO: we need some sort of PhrasingContainer class for this. (nodes[0] instanceof ParagraphNode || nodes[0] instanceof HeadingNode || @@ -16349,7 +16384,9 @@ odoo.define('web_editor.jabberwock', (function(require) { domLayoutEngine.location = this.configuration.location; await domLayoutEngine.start(); window.addEventListener('keydown', this.processKeydown, true); + window.addEventListener('keypress', this.processKeydown, true); window.addEventListener('keydown-iframe', this.processKeydown, true); + window.addEventListener('keypress-iframe', this.processKeydown, true); } async stop() { clearTimeout(this._debounce); @@ -16358,7 +16395,9 @@ odoo.define('web_editor.jabberwock', (function(require) { window.removeEventListener(eventName + '-iframe', this._checkFocusChanged, true); }); window.removeEventListener('keydown', this.processKeydown, true); + window.removeEventListener('keypress', this.processKeydown, true); window.removeEventListener('keydown-iframe', this.processKeydown, true); + window.removeEventListener('keypress-iframe', this.processKeydown, true); const layout = this.dependencies.get(Layout); const domLayoutEngine = layout.engines.dom; await domLayoutEngine.stop(); @@ -16370,6 +16409,8 @@ odoo.define('web_editor.jabberwock', (function(require) { /** * KeyboardEvent listener to be added to the DOM that calls `execCommand` if * the keys pressed match one of the shortcut registered in the keymap. + * If a shortcut is detected, then a preventDefault is performed an key + * event. * * @param event */ @@ -16396,10 +16437,13 @@ odoo.define('web_editor.jabberwock', (function(require) { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); - await Promise.all([ - this.editor.dispatcher.dispatch('@preKeydownCommand', {}), - processingContext.execCommand(command.commandId, params), - ]); + // Prevent default the keypress but don't trigger the command for this. + if (event.type.includes('keydown')) { + await Promise.all([ + this.editor.dispatcher.dispatch('@preKeydownCommand', {}), + processingContext.execCommand(command.commandId, params), + ]); + } return command.commandId; } } @@ -17325,6 +17369,17 @@ odoo.define('web_editor.jabberwock', (function(require) { this._isInEditable = _isInEditable; this._isInEditor = _isInEditor; this._triggerEventBatchOutside = _triggerEventBatchOutside; + /** + * The normalizer detects if there is a auto-completion feature and that it + * must therefore be taken into account so as not to cancel modifications + * which would alter its correct functioning. + * By default, mobile browsers are considered to have an auto-completion. + * The detection is then done on the use of the composition events. + * A device can have a spell checker without auto-completion, the chrome + * spell checker trigger a replacement inputEvent without using composition + * events. + */ + this.hasSpellchecker = /Android|Mobile|Phone|webOS|iPad|iPod|BlackBerry|Opera Mini/i.test(navigator.userAgent); /** * Event listeners that are bound in the DOM by the normalizer on creation * and unbound on destroy. @@ -17437,7 +17492,7 @@ odoo.define('web_editor.jabberwock', (function(require) { this._bindEvent(root, 'onkeyup', this._updateModifiersKeys); this._bindEventInEditable(root, 'keydown', this._onKeyDownOrKeyPress); this._bindEventInEditable(root, 'keypress', this._onKeyDownOrKeyPress); - this._bindEventInEditable(root, 'compositionstart', this._registerEvent); + this._bindEventInEditable(root, 'compositionstart', this._onCompositionStart); this._bindEventInEditable(root, 'compositionupdate', this._registerEvent); this._bindEventInEditable(root, 'compositionend', this._registerEvent); this._bindEventInEditable(root, 'beforeinput', this._registerEvent); @@ -17636,7 +17691,8 @@ odoo.define('web_editor.jabberwock', (function(require) { // events occuring during one tick is not enough so we need to delay the // analysis after we observe events during two ticks instead. const needSecondTickObservation = currentStackObservation._events.every(ev => { - return !MultiStackEventTypes.includes(ev.type); + return (!MultiStackEventTypes.includes(ev.type) && + !currentStackObservation.flickeringRemoved.has(ev)); }); if (needSecondTickObservation && !secondTickObservation) { return await new Promise((resolve) => { @@ -17661,6 +17717,7 @@ odoo.define('web_editor.jabberwock', (function(require) { keydownEvent.key === 'Unidentified'; const inferredKeydownEvent = keydownEvent && keydownEvent.key === 'Unidentified' && + inputEvent && this._inferKeydownEvent(inputEvent); // // First pass to get the informations @@ -17682,7 +17739,10 @@ odoo.define('web_editor.jabberwock', (function(require) { const inputType = (cutEvent && 'deleteByCut') || (dropEvent && 'insertFromDrop') || (pasteEvent && 'insertFromPaste') || - (key === 'Enter' && (inputEvent === null || inputEvent === void 0 ? void 0 : inputEvent.inputType) === 'insertText' && 'insertLineBreak') || + (key === 'Enter' && + keydownEvent.shiftKey && + (inputEvent === null || inputEvent === void 0 ? void 0 : inputEvent.inputType) === 'insertText' && + 'insertLineBreak') || (inputEvent && inputEvent.inputType); // In case of accent inserted from a Mac, check that the char before was // one of the special accent temporarily inserted in the DOM (e.g. '^', @@ -17708,9 +17768,12 @@ odoo.define('web_editor.jabberwock', (function(require) { // multiples keys pushed. if (currentStackObservation._multiKeyStack.length > 1 && possibleMultiKeydown) { currentStackObservation._multiKeyStack.map(keydownMap => { - const keyboardAction = this._getKeyboardAction(currentStackObservation.mutations, keydownMap.keydown.key, (keydownMap.input && keydownMap.input.inputType) || '', !!mutatedElements.size); - if (keyboardAction) { - normalizedActions.push(keyboardAction); + if (mutatedElements.size || + currentStackObservation.flickeringRemoved.has(keydownMap.keydown)) { + const keyboardAction = this._getKeyboardAction(currentStackObservation.mutations, keydownMap.keydown.key, (keydownMap.input && keydownMap.input.inputType) || ''); + if (keyboardAction) { + normalizedActions.push(keyboardAction); + } } }); } @@ -17736,9 +17799,12 @@ odoo.define('web_editor.jabberwock', (function(require) { } else if (normalizedActions.length === 0 && ((!compositionEvent && key) || isCompositionKeyboard || isVirtualKeyboard)) { - const keyboardAction = this._getKeyboardAction(currentStackObservation.mutations, key, inputType, !!mutatedElements.size, keydownEvent); - if (keyboardAction) { - normalizedActions.push(keyboardAction); + if (mutatedElements.size || + currentStackObservation.flickeringRemoved.has(keydownEvent)) { + const keyboardAction = this._getKeyboardAction(currentStackObservation.mutations, key, inputType, keydownEvent); + if (keyboardAction) { + normalizedActions.push(keyboardAction); + } } if (compositionReplaceOneChar) { normalizedActions = compositionData.actions; @@ -17797,6 +17863,7 @@ odoo.define('web_editor.jabberwock', (function(require) { _multiKeyStack: [], _eventsMap: {}, mutations: undefined, + flickeringRemoved: new Set(), }; } _getCompositionData(mutations, compositionEvent, inputEvent) { @@ -17847,9 +17914,8 @@ odoo.define('web_editor.jabberwock', (function(require) { * @param hasMutataedElements * @param isMultiKey */ - _getKeyboardAction(mutations, key, inputType, hasMutatedElements, keydownEvent) { - const isInsertOrRemoveAction = hasMutatedElements && !inputTypeCommands.has(inputType); - if (isInsertOrRemoveAction) { + _getKeyboardAction(mutations, key, inputType, keydownEvent) { + if (!inputTypeCommands.has(inputType)) { if (key === 'Backspace' || key === 'Delete') { return this._getRemoveAction(mutations, key, inputType, keydownEvent); } @@ -18455,6 +18521,15 @@ odoo.define('web_editor.jabberwock', (function(require) { // menu ends up opening, the user is definitely not selecting. this._mousedownInEditable = false; } + /** + * Catch start composition event + * + * @param {CompositionEvent} ev + */ + _onCompositionStart(ev) { + this.hasSpellchecker = true; + this._registerEvent(ev); + } /** * Catch Enter, Backspace, Delete and insert actions * @@ -18470,6 +18545,96 @@ odoo.define('web_editor.jabberwock', (function(require) { } const [offsetNode, offset] = targetDeepest(selection.anchorNode, selection.anchorOffset); this._initialCaretPosition = { offsetNode, offset }; + Promise.resolve().then(() => { + // Wait the next micro task to allow the external features to + // prevent the default and cancel the normalized keypress. + if (!ev.defaultPrevented && this._isSafeToPreventKeyboardEvent(ev, selection)) { + ev.preventDefault(); + this.currentStackObservation.flickeringRemoved.add(ev); + } + }); + } + /** + * Check the selection to preventDefault events (Enter, Backspace, Delete) + * and remove flickering without break the browser auto completion and + * spellcheckers. + * + * @param {KeyboardEvent} ev + */ + _isSafeToPreventKeyboardEvent(ev, selection) { + const code = ev.code || ev.key; + if (this._modifierKeys.ctrlKey || + this._modifierKeys.altKey || + this._modifierKeys.metaKey || + this._modifierKeys.shiftKey || + (code !== 'Backspace' && code !== 'Delete' && code !== 'Enter')) { + // If a control key is applied, you must let the browser apply its + // own behavior because you do not know the command in advance. + return false; + } + if (!this.hasSpellchecker) { + // there is no risk of breaking the spellcheckers if there is none. + return true; + } + if (isBlock(selection.anchorNode) || + (selection.anchorNode !== selection.focusNode && isBlock(selection.focusNode))) { + // If the range is on a block, there is no risk of altering the + // behavior of the spellcheckers. + return true; + } + const previous = this._geSiblingInlineText(selection.anchorNode, selection.anchorOffset, true); + const next = this._geSiblingInlineText(selection.focusNode, selection.focusOffset, false); + const regExpChar = /[^\s\t\r\n\u00A0\u200b]/; + if (code === 'Backspace' && + !regExpChar.test(previous.slice(-2)) && + !regExpChar.test(next[0] || '')) { + return true; + } + if (code === 'Delete' && + !regExpChar.test(previous[previous.length - 1] || '') && + !regExpChar.test(next.slice(0, 2))) { + return true; + } + if (code === 'Enter' && !regExpChar.test(previous.slice(-2))) { + return true; + } + } + /** + * Return the sibling text passing through inlines formatting. + * + * @param {KeyboardEvent} ev + */ + _geSiblingInlineText(node, offset, before = true) { + let text = ''; + while (node) { + if (node.nodeType === Node.TEXT_NODE) { + if (before) { + text += node.textContent.slice(0, offset); + } + else { + text += node.textContent.slice(offset); + } + } + else if (isBlock(node)) { + return text; + } + else { + text += node.textContent; + } + let sibling = node[before ? 'previousSibling' : 'nextSibling']; + while (node && !sibling && node.parentNode) { + if (isBlock(node.parentNode)) { + return text; + } + else { + node = node.parentNode; + sibling = node[before ? 'previousSibling' : 'nextSibling']; + } + } + node = sibling; + offset = before ? nodeLength(sibling) : 0; + } + return text; } /** * Set internal properties of the pointer down event to retrieve them later @@ -20952,6 +21117,37 @@ odoo.define('web_editor.jabberwock', (function(require) { }; return context.execCommand(domHelpersToggleClass); } + /** + * Add (state: true) or remove (state: false) a class or a list of classes + * from a DOM node or a list of DOM nodes. + * + * @param context + * @param originalDomNode + * @param className + * @param state + */ + async setClass(context, originalDomNode, className, state) { + const domHelpersSetClass = async () => { + var _a; + const classes = Array.isArray(className) ? className : [className]; + const domNodes = Array.isArray(originalDomNode) ? originalDomNode : [originalDomNode]; + for (const domNode of domNodes) { + const Attributes = this._getAttributesConstructor(domNode); + for (const node of this.getNodes(domNode)) { + const classList = (_a = node.modifiers.find(Attributes)) === null || _a === void 0 ? void 0 : _a.classList; + for (const oneClass of classes) { + if (state && !(classList === null || classList === void 0 ? void 0 : classList.has(oneClass))) { + node.modifiers.get(Attributes).classList.add(oneClass); + } + else if (!state && (classList === null || classList === void 0 ? void 0 : classList.has(oneClass))) { + classList.remove(oneClass); + } + } + } + } + }; + return context.execCommand(domHelpersSetClass); + } /** * Set an attribute on a DOM node or a list of DOM nodes. *