diff --git a/addons/web_editor/static/lib/odoo-editor/odoo-editor.js b/addons/web_editor/static/lib/odoo-editor/odoo-editor.js index 5df2e7d03f2..e234fc1965c 100644 --- a/addons/web_editor/static/lib/odoo-editor/odoo-editor.js +++ b/addons/web_editor/static/lib/odoo-editor/odoo-editor.js @@ -28,6 +28,9 @@ var exportVariable = (function (exports) { BR: CTYPES.BR, }; + const URL_REGEX = /((?:https?:\/\/)?(?:[a-z0-9-]{1,63}\.){1,2}[a-z]{2,15}(?:\/[^\s]*)?)/gi; + const URL_REGEX_WITH_INFOS = /((https?:\/\/)?([a-z0-9-]{1,63}\.){1,2}[a-z]{2,15}(\/[^\s]*)?)/gi; + //------------------------------------------------------------------------------ // Position and sizes //------------------------------------------------------------------------------ @@ -640,7 +643,12 @@ var exportVariable = (function (exports) { // A selection spanning multiple nodes and ending at position 0 of a // node, like the one resulting from a triple click, are corrected so // that it ends at the last position of the previous node instead. - if (correctTripleClick && !endOffset && !end.previousSibling) { + const beforeEnd = end.previousSibling; + if ( + correctTripleClick && + !endOffset && + (!beforeEnd || (beforeEnd.nodeType === Node.TEXT_NODE && !isVisibleStr(beforeEnd))) + ) { const previous = previousLeaf(end, editable, true); if (previous && closestElement(previous).isContentEditable) { [end, endOffset] = [previous, nodeSize(previous)]; @@ -915,6 +923,26 @@ var exportVariable = (function (exports) { ); } + /** + * Returns an array of url infos for url matched in the given string. + * + * @param {String} string + * @returns {Array} + */ + function getUrlsInfosInString(string) { + let infos = [], + match; + while ((match = URL_REGEX_WITH_INFOS.exec(string))) { + infos.push({ + url: match[2] ? match[0] : 'https://' + match[0], + label: match[0], + index: match.index, + length: match[0].length, + }); + } + return infos; + } + // optimize: use the parent Oid to speed up detection function getOuid(node, optimize = false) { while (node && !isUnbreakable(node)) { @@ -1232,15 +1260,27 @@ var exportVariable = (function (exports) { /** * Add a BR in the given node if its closest ancestor block has nothing to make - * it visible. + * it visible, and/or add a zero-width space in the given node if it's an empty + * inline unremovable so the cursor can stay in it. * * @param {HTMLElement} el + * @returns {Object} { br: the inserted
if any, + * zws: the inserted zero-width space if any } */ function fillEmpty(el) { + const fillers = {}; const blockEl = closestBlock(el); if (isShrunkBlock(blockEl)) { - blockEl.appendChild(document.createElement('br')); + const br = document.createElement('br'); + blockEl.appendChild(br); + fillers.br = br; } + if (!el.textContent.length && isUnremovable(el) && !isBlock(el)) { + const zws = document.createTextNode('\u200B'); + el.appendChild(zws); + fillers.zws = zws; + } + return fillers; } /** * Removes the given node if invisible and all its invisible ancestors. @@ -1745,7 +1785,7 @@ var exportVariable = (function (exports) { ); } - function getRangePosition(el, options = {}) { + function getRangePosition(el, document, options = {}) { const selection = document.getSelection(); if (!selection.isCollapsed || !selection.rangeCount) return; const range = selection.getRangeAt(0); @@ -2818,6 +2858,9 @@ var exportVariable = (function (exports) { if (res) { setCursor(sel.anchorNode, sel.anchorOffset, sel.focusNode, sel.focusOffset); const node = findNode(closestPath(sel.focusNode), node => node.tagName === 'A'); + for (const [param, value] of Object.entries(editor.options.defaultLinkAttributes)) { + node.setAttribute(param, `${value}`); + } const pos = [node.parentElement, childNodeIndex(node) + 1]; setCursor(...pos, ...pos, false); } @@ -3316,6 +3359,9 @@ var exportVariable = (function (exports) { this._mainWrapperElement = document.createElement('div'); this._mainWrapperElement.className = 'oe-commandbar-mainWrapper'; this.el.append(this._mainWrapperElement); + this.el.addEventListener('mousedown', event => { + event.stopPropagation(); + }); } destroy() { @@ -3407,6 +3453,7 @@ var exportVariable = (function (exports) { event => { this._currentValidate(); event.preventDefault(); + event.stopPropagation(); }, true, ); @@ -3556,7 +3603,7 @@ var exportVariable = (function (exports) { } _resetPosition() { - const position = getRangePosition(this.el); + const position = getRangePosition(this.el, this.options.document); if (!position) { this.hide(); return; @@ -3701,7 +3748,7 @@ var exportVariable = (function (exports) { } }; - const offset = getRangePosition(this.el); + const offset = getRangePosition(this.el, this.options.document); this.el.style.left = `${offset.left}px`; this.el.style.top = `${offset.top}px`; @@ -3754,6 +3801,7 @@ var exportVariable = (function (exports) { }, toSanitize: true, isRootEditable: true, + defaultLinkAttributes: {}, getContentEditableAreas: () => [], _t: string => string, }, @@ -4547,8 +4595,11 @@ var exportVariable = (function (exports) { fillEmpty(closestBlock(range.endContainer)); // Ensure trailing space remains visible. const joinWith = range.endContainer; + const joinSibling = joinWith && joinWith.nextSibling; const oldText = joinWith.textContent; - if (joinWith && oldText.endsWith(' ')) { + const hasSpaceAfter = joinSibling && joinSibling.textContent.startsWith(' '); + const shouldPreserveSpace = (doJoin || hasSpaceAfter) && joinWith && oldText.endsWith(' '); + if (shouldPreserveSpace) { joinWith.textContent = oldText.replace(/ $/, '\u00A0'); setCursor(joinWith, nodeSize(joinWith)); } @@ -4576,14 +4627,20 @@ var exportVariable = (function (exports) { } next = joinWith && joinWith.nextSibling; if ( - joinWith && - oldText.endsWith(' ') && + shouldPreserveSpace && !(next && next.nodeType === Node.TEXT_NODE && next.textContent.startsWith(' ')) ) { // Restore the text we modified in order to preserve trailing space. joinWith.textContent = oldText; setCursor(joinWith, nodeSize(joinWith)); } + if (joinWith) { + const el = closestElement(joinWith); + const { zws } = fillEmpty(el); + if (zws) { + setCursor(zws, 0, zws, nodeSize(zws)); + } + } } updateColorpickerLabels(params = {}) { @@ -4628,6 +4685,13 @@ var exportVariable = (function (exports) { */ _applyRawCommand(method, ...args) { const sel = this.document.getSelection(); + if ( + !this.editable.contains(sel.anchorNode) || + (sel.anchorNode !== sel.focusNode && !this.editable.contains(sel.focusNode)) + ) { + // Do not apply commands out of the editable area. + return false; + } if (!sel.isCollapsed && BACKSPACE_FIRST_COMMANDS.includes(method)) { this.deleteRange(sel); if (BACKSPACE_ONLY_COMMANDS.includes(method)) { @@ -4866,15 +4930,15 @@ var exportVariable = (function (exports) { groupName: 'Basic blocks', title: 'Checklist', description: 'Track tasks with a checklist.', - fontawesome: 'fa-tasks', + fontawesome: 'fa-check-square-o', callback: () => { this.execCommand('toggleList', 'CL'); }, }, { groupName: 'Basic blocks', - title: 'Horizontal rule', - description: 'Insert an horizantal rule.', + title: 'Separator', + description: 'Insert an horizontal rule separator.', fontawesome: 'fa-minus', callback: () => { this.execCommand('insertHorizontalRule'); @@ -4898,6 +4962,7 @@ var exportVariable = (function (exports) { } this.commandBar = new CommandBar({ editable: this.editable, + document: this.document, _t: this.options._t, onShow: () => { this.commandbarTablePicker.hide(); @@ -5146,6 +5211,12 @@ var exportVariable = (function (exports) { const range = selection.getRangeAt(0); setCursor(range.endContainer, range.endOffset); } + // Check for url after user insert a space so we won't transform an incomplete url. + if (ev.data.includes(' ') && selection && selection.anchorNode) { + ev.preventDefault(); + this._convertUrlInElement(closestElement(selection.anchorNode)); + this.execCommand('insertText', ev.data); + } this.sanitize(); this.historyStep(); } else if (ev.inputType === 'insertLineBreak') { @@ -5192,10 +5263,10 @@ var exportVariable = (function (exports) { if (closestTag === 'LI') { this._applyCommand('indentList', ev.shiftKey ? 'outdent' : 'indent'); - ev.preventDefault(); } else if (closestTag === 'TABLE') { this._onTabulationInTable(ev); - ev.preventDefault(); + } else if (!ev.shiftKey) { + this.execCommand('insertText', '\u00A0 \u00A0\u00A0'); } ev.preventDefault(); } else if (IS_KEYBOARD_EVENT_UNDO(ev)) { @@ -5403,13 +5474,81 @@ var exportVariable = (function (exports) { } } + /** + * Convert valid url text into links inside the given element. + * + * @param {HTMLElement} el + */ + _convertUrlInElement(el) { + // We will not replace url inside already existing Link element. + if (el.tagName === 'A') { + return; + } + + for (let child of el.childNodes) { + if (child.nodeType === Node.TEXT_NODE && child.length > 3) { + const childStr = child.nodeValue; + const matches = getUrlsInfosInString(childStr); + if (matches.length) { + // We only to take care of the first match. + // The method `_createLinkWithUrlInTextNode` will split the text node, + // the other url matches will then be matched again in the nexts loops of el.childnodes. + this._createLinkWithUrlInTextNode( + child, + matches[0].url, + matches[0].index, + matches[0].length, + ); + } + } + } + } + + /** + * Create a Link in the node text based on the given data + * + * @param {Node} textNode + * @param {String} url + * @param {int} index + * @param {int} length + */ + _createLinkWithUrlInTextNode(textNode, url, index, length) { + setCursor(textNode, index, textNode, index + length); + this.document.execCommand('createLink', false, url); + const sel = this.document.getSelection(); + const link = closestElement(sel.anchorNode, 'a'); + for (const [param, value] of Object.entries(this.options.defaultLinkAttributes)) { + link.setAttribute(param, `${value}`); + } + sel.collapseToEnd(); + } + /** * Prevent the pasting of HTML and paste text only instead. */ _onPaste(ev) { ev.preventDefault(); const pastedText = (ev.originalEvent || ev).clipboardData.getData('text/plain'); - this.execCommand('insertText', pastedText); + const splitAroundUrl = pastedText.split(URL_REGEX); + const linkAttrs = + Object.entries(this.options.defaultLinkAttributes) + .map(entry => entry.join('="')) + .join('" ') + '" '; + + for (let i = 0; i < splitAroundUrl.length; i++) { + // Even indexes will always be plain text, and odd indexes will always be URL. + if (i % 2) { + const url = /^https?:\/\//gi.test(splitAroundUrl[i]) + ? splitAroundUrl[i] + : 'https://' + splitAroundUrl[i]; + this.execCommand( + 'insertHTML', + `${splitAroundUrl[i]}`, + ); + } else if (splitAroundUrl[i] !== '') { + this.execCommand('insertText', splitAroundUrl[i]); + } + } } /** @@ -5552,6 +5691,8 @@ var exportVariable = (function (exports) { exports.OdooEditor = OdooEditor; exports.UNBREAKABLE_ROLLBACK_CODE = UNBREAKABLE_ROLLBACK_CODE; exports.UNREMOVABLE_ROLLBACK_CODE = UNREMOVABLE_ROLLBACK_CODE; + exports.URL_REGEX = URL_REGEX; + exports.URL_REGEX_WITH_INFOS = URL_REGEX_WITH_INFOS; exports.ancestors = ancestors; exports.boundariesIn = boundariesIn; exports.boundariesOut = boundariesOut; @@ -5585,6 +5726,7 @@ var exportVariable = (function (exports) { exports.getSelectedNodes = getSelectedNodes; exports.getState = getState; exports.getTraversedNodes = getTraversedNodes; + exports.getUrlsInfosInString = getUrlsInfosInString; exports.insertListAfter = insertListAfter; exports.insertText = insertText; exports.isBlock = isBlock;