diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js index 3a879d5f27b..ad9d60be31c 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js @@ -3426,9 +3426,6 @@ export class OdooEditor extends EventTarget { } hint.removeAttribute('placeholder'); } - - sanitize(element); - this._pluginCall('cleanForSave', [element]); // Clean the remaining ZeroWidthspaces added by the `fillEmpty` function // ( contain "data-oe-zws-empty-inline" attr) @@ -3449,6 +3446,8 @@ export class OdooEditor extends EventTarget { emptyElement.removeAttribute('data-oe-zws-empty-inline'); } } + sanitize(element); + // Remove contenteditable=false on elements for (const el of element.querySelectorAll('[contenteditable="false"]')) { if (!el.hasAttribute('data-oe-keep-contenteditable')) { diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/commands.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/commands.js index 5bcc8f193a8..038e4f3a4dd 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/commands.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/commands.js @@ -9,21 +9,18 @@ import { closestPath, DIRECTIONS, findNode, - getCursorDirection, getCursors, getDeepRange, getInSelection, getListMode, - getNormalizedCursorPosition, getSelectedNodes, getTraversedNodes, insertAndSelectZws, insertText, isBlock, - isFormat, - isSelectionFormat, isColorGradient, isContentTextNode, + isSelectionFormat, isShrunkBlock, isVisible, isVisibleStr, @@ -46,6 +43,7 @@ import { pxToFloat, getRowIndex, parseHTML, + formatSelection, } from '../utils/utils.js'; const TEXT_CLASSES_REGEX = /\btext-[^\s]*\b/g; @@ -133,250 +131,6 @@ function hasColor(element, mode) { getComputedStyle(element)[mode] !== getComputedStyle(parent)[mode]) ); } -/** - * This function abstracts the difficulty of applying a inline style to a - * selection. TODO: This implementations potentially adds one span per text - * node, in an ideal world it would wrap all concerned nodes in one span - * whenever possible. - * @param {Element => void} applyStyle Callback that receives an element to - * which the wanted style should be applied - * @param {string | [string, string]} [style] the format type to toggle or an - * array with the style property name and the value to apply to it - * @param {boolean} [shouldApply=true] set to false to undo a style rather than - * apply it. - * @returns {Element[]} the elements on which the style was changed. - */ -export function applyInlineStyle(editor, applyStyle, style, shouldApply=true) { - let selectedTextNodes, direction, startContainer, startOffset, endContainer, endOffset; - if (editor.editable.querySelector('.o_selected_td')) { - const selectedNodes = getSelectedNodes(editor.editable); - selectedTextNodes = selectedNodes.filter(node => node.nodeType === Node.TEXT_NODE); - direction = DIRECTIONS.RIGHT; - [startContainer, startOffset] = [selectedNodes[0], 0]; - [endContainer, endOffset] = [selectedNodes[selectedNodes.length - 1], selectedNodes[selectedNodes.length - 1].childNodes.length]; - } else { - getDeepRange(editor.editable, { splitText: true, select: true }); - const sel = editor.document.getSelection(); - const { startContainer, startOffset, endContainer, endOffset } = sel.getRangeAt(0); - const { anchorNode, anchorOffset, focusNode, focusOffset } = sel; - direction = getCursorDirection(anchorNode, anchorOffset, focusNode, focusOffset); - const [ - normalizedStartContainer, - normalizedStartOffset - ] = getNormalizedCursorPosition(startContainer, startOffset); - const [ - normalizedEndContainer, - normalizedEndOffset - ] = getNormalizedCursorPosition(endContainer, endOffset); - selectedTextNodes = getSelectedNodes(editor.editable).filter(node => { - const atLeastOneCharFromNodeInSelection = !( - (node === normalizedEndContainer && normalizedEndOffset === 0) || - (node === normalizedStartContainer && normalizedStartOffset === node.textContent.length) - ); - return isContentTextNode(node) && atLeastOneCharFromNodeInSelection; - }); - } - const textNodesToFormat = selectedTextNodes.filter(node => { - let isApplied; - if (Array.isArray(style) && style[style[0]]) { - let ancestor = node; - while (ancestor) { - if (ancestor.style[style[0]]) { - isApplied = ancestor.style[style[0]] === style[1]; - break; - } else { - ancestor = ancestor.parentElement; - } - } - } else { - isApplied = isFormat[style] && isFormat[style](node); - } - return shouldApply ? !isApplied : isApplied; - }); - const changedElements = []; - for (const textNode of textNodesToFormat) { - // If text node ends after the end of the selection, split it and - // keep the part that is inside. - if (endContainer === textNode && endOffset < textNode.textContent.length) { - // No reassignement needed, entirely dependent on the - // splitTextNode implementation. - splitTextNode(textNode, endOffset, DIRECTIONS.LEFT); - } - // If text node starts before the beginning of the selection, split it - // and keep the part that is inside as textNode. - if (startContainer === textNode && startOffset > 0) { - // No reassignement needed, entirely dependent on the - // splitTextNode implementation. - splitTextNode(textNode, startOffset, DIRECTIONS.RIGHT); - } - // If the parent is not inline or is not completely in the - // selection, wrap text node in inline node. Also skips tags to - // work with native `removeFormat` command - const siblings = [...textNode.parentElement.childNodes]; - if ( - isBlock(textNode.parentElement) || - !( - selectedTextNodes.includes(siblings[0]) && - selectedTextNodes.includes(siblings[siblings.length - 1]) - ) || - textNode.parentElement.tagName === 'A' - ) { - const newParent = document.createElement('span'); - textNode.before(newParent); - // Group selected text node siblings: - const newChildren = [textNode]; - let currentTextNode = textNode.nextSibling; - while (currentTextNode && textNodesToFormat.includes(currentTextNode)) { - newChildren.push(currentTextNode); - textNodesToFormat.splice(textNodesToFormat.indexOf(currentTextNode), 1); - currentTextNode = currentTextNode.nextSibling; - } - newParent.append(...newChildren); - } - applyStyle(textNode.parentElement); - changedElements.push(textNode.parentElement); - } - if (selectedTextNodes[0] && selectedTextNodes[0].textContent === '\u200B') { - setSelection(selectedTextNodes[0], 0); - } else if (selectedTextNodes.length) { - const firstNode = selectedTextNodes[0]; - const lastNode = selectedTextNodes[selectedTextNodes.length - 1]; - if (direction === DIRECTIONS.RIGHT) { - setSelection(firstNode, 0, lastNode, lastNode.length); - } else { - setSelection(lastNode, lastNode.length, firstNode, 0); - } - } - return changedElements; -} -const styles = { - bold: { - is: editable => isSelectionFormat(editable, 'bold'), - name: 'fontWeight', - value: 'bolder', - }, - italic: { - is: editable => isSelectionFormat(editable, 'italic'), - name: 'fontStyle', - value: 'italic', - }, - underline: { - is: editable => isSelectionFormat(editable, 'underline'), - name: 'textDecorationLine', - value: 'underline', - }, - strikeThrough: { - is: editable => isSelectionFormat(editable, 'strikeThrough'), - name: 'textDecorationLine', - value: 'line-through', - }, - switchDirection: { - is: editable => isSelectionFormat(editable, 'switchDirection'), - }, -}; - -export function toggleFormat(editor, format) { - const selectedTableCells = editor.editable.querySelectorAll('.o_selected_td'); - const selection = editor.document.getSelection(); - if (!selection.rangeCount && !selectedTableCells.length) return; - const wasCollapsed = selection.getRangeAt(0).collapsed && !selectedTableCells.length; - let zws; - if (wasCollapsed) { - if (selection.anchorNode.nodeType === Node.TEXT_NODE && selection.anchorNode.textContent === '\u200b') { - zws = selection.anchorNode; - selection.getRangeAt(0).selectNode(zws); - } else { - zws = insertAndSelectZws(selection); - } - } - getDeepRange(editor.editable, { splitText: true, select: true, correctTripleClick: true }); - const {anchorNode, anchorOffset, focusNode, focusOffset} = editor.document.getSelection(); - const style = styles[format]; - const selectedTextNodes = getSelectedNodes(editor.editable) - .filter(n => n.nodeType === Node.TEXT_NODE && n.nodeValue.length); - const isAlreadyFormatted = style.is(editor.editable); - let changedElements = []; - if (isAlreadyFormatted && style.name === 'textDecorationLine') { - for (const textNode of selectedTextNodes) { - const closestDecorated = closestElement(textNode, `[style*="text-decoration-line: ${style.value}"]`); - if (closestDecorated) { - const splitResult = splitAroundUntil(textNode, closestDecorated); - const decorationToRemove = splitResult[0] || splitResult[1] || closestDecorated; - decorationToRemove.style.removeProperty('text-decoration-line'); - if (!decorationToRemove.style.cssText) { - for (const child of decorationToRemove.childNodes) { - decorationToRemove.before(child); - changedElements.push(child); - } - decorationToRemove.remove(); - } - } - } - if (wasCollapsed) { - const siblings = [...zws.parentElement.childNodes]; - if ( - selectedTextNodes.includes(siblings[0]) && - selectedTextNodes.includes(siblings[siblings.length - 1]) - ) { - zws.parentElement.setAttribute('data-oe-zws-empty-inline', ''); - } else { - const span = document.createElement('span'); - span.setAttribute('data-oe-zws-empty-inline', ''); - zws.before(span); - span.append(zws); - - } - setSelection(zws, 1); - } else { - setSelection(anchorNode, anchorOffset, focusNode, focusOffset); - } - } else if (format === 'switchDirection') { - const defaultDirection = editor.options.direction; - for (const block of new Set(selectedTextNodes.map(textNode => closestBlock(textNode)))) { - if (isAlreadyFormatted) { - block.removeAttribute('dir'); - } else { - block.setAttribute('dir', defaultDirection === 'ltr' ? 'rtl' : 'ltr'); - } - changedElements.push(block); - } - } else { - changedElements = applyInlineStyle(editor, el => { - if (isAlreadyFormatted) { - const block = closestBlock(el); - el.style[style.name] = style.is(block) ? 'normal' : getComputedStyle(block)[style.name]; - } else if (style.name === 'textDecorationLine' && el.style[style.name]) { - // The (el) has a text decoration and we want to set - // another. We don't want to replace the old with the new, we - // want to add a new one (eg it was underlined, we want it also - // strikeThrough). - const newChild = document.createElement('span'); - const children = [...el.childNodes]; - el.prepend(newChild); - newChild.append(...children); - newChild.style[style.name] = style.value; - } else { - el.style[style.name] = style.value; - } - if (zws) { - const siblings = [...zws.parentElement.childNodes]; - if ( - selectedTextNodes.includes(siblings[0]) && - selectedTextNodes.includes(siblings[siblings.length - 1]) - ) { - zws.parentElement.setAttribute('data-oe-zws-empty-inline', ''); - } else { - const span = document.createElement('span'); - span.setAttribute('data-oe-zws-empty-inline', ''); - zws.before(span); - span.append(zws); - - } - } - }, format, !isAlreadyFormatted); - } - return changedElements; -} // This is a whitelist of the commands that are implemented by the // editor itself rather than the node prototypes. It might be @@ -586,12 +340,29 @@ export const editorCommands = { // Formats // ------------------------------------------------------------------------- - bold: editor => toggleFormat(editor, 'bold'), - italic: editor => toggleFormat(editor, 'italic'), - underline: editor => toggleFormat(editor, 'underline'), - strikeThrough: editor => toggleFormat(editor, 'strikeThrough'), + bold: editor => formatSelection(editor, 'bold'), + italic: editor => formatSelection(editor, 'italic'), + underline: editor => formatSelection(editor, 'underline'), + strikeThrough: editor => formatSelection(editor, 'strikeThrough'), + setFontSize: (editor, size) => formatSelection(editor, 'fontSize', {applyStyle: true, formatProps: {size}}), switchDirection: editor => { - const changedElements = toggleFormat(editor, 'switchDirection'); + getDeepRange(editor.editable, { splitText: true, select: true, correctTripleClick: true }); + const selection = editor.document.getSelection(); + const selectedTextNodes = [selection.anchorNode, ...getSelectedNodes(editor.editable), selection.focusNode] + .filter(n => n.nodeType === Node.TEXT_NODE && n.nodeValue.trim().length); + + const changedElements = []; + const defaultDirection = editor.options.direction; + const shouldApplyStyle = !isSelectionFormat(editor.editable, 'switchDirection'); + for (const block of new Set(selectedTextNodes.map(textNode => closestBlock(textNode)))) { + if (!shouldApplyStyle) { + block.removeAttribute('dir'); + } else { + block.setAttribute('dir', defaultDirection === 'ltr' ? 'rtl' : 'ltr'); + } + changedElements.push(block); + } + for (const element of changedElements) { const style = getComputedStyle(element); if (style.direction === 'ltr' && style.textAlign === 'right') { @@ -614,19 +385,6 @@ export const editorCommands = { justifyRight: editor => align(editor, 'right'), justifyCenter: editor => align(editor, 'center'), justifyFull: editor => align(editor, 'justify'), - /** - * @param {string} size A valid css size string - */ - setFontSize: (editor, size) => { - const selection = editor.document.getSelection(); - if (!selection.rangeCount) return; - if (selection.getRangeAt(0).collapsed) { - insertAndSelectZws(selection); - } - applyInlineStyle(editor, element => { - element.style.fontSize = size; - }, ['fontSize', size]); - }, // Link createLink: (editor, link, content) => { @@ -762,8 +520,7 @@ export const editorCommands = { // Partially selected : split it. const selectedChildren = children.filter(child => selectedNodes.includes(child)); if (selectedChildren.length) { - const splitResult = splitAroundUntil(selectedChildren, font); - font = splitResult[0] || splitResult[1] || font; + font = splitAroundUntil(selectedChildren, font); } else { font = []; } diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/sanitize.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/sanitize.js index 6bd02e7d485..d0162e6d1fb 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/sanitize.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/sanitize.js @@ -134,7 +134,7 @@ class Sanitize { if ( node.nodeType === Node.TEXT_NODE && node.textContent.includes('\u200B') && - node.parentElement.hasAttribute('oe-zws-empty-inline') && + node.parentElement.hasAttribute('data-oe-zws-empty-inline') && ( node.textContent.length > 1 || // There can be multiple ajacent text nodes, in which case diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js index 0c1ee3da727..5d320a827b4 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js @@ -870,6 +870,248 @@ export function preserveCursor(document) { }; } +//------------------------------------------------------------------------------ +// Format utils +//------------------------------------------------------------------------------ + +const formatsSpecs = { + italic: { + tagName: 'em', + isFormatted: isItalic, + isTag: (node) => ['EM', 'I'].includes(node.tagName), + hasStyle: (node) => Boolean(node.style && node.style['font-style']), + addStyle: (node) => node.style['font-style'] = 'italic', + addNeutralStyle: (node) => node.style['font-style'] = 'normal', + removeStyle: (node) => removeStyle(node, 'font-style'), + }, + bold: { + tagName: 'strong', + isFormatted: isBold, + isTag: (node) => ['STRONG', 'B'].includes(node.tagName), + hasStyle: (node) => Boolean(node.style && node.style['font-weight']), + addStyle: (node) => node.style['font-weight'] = 'bolder', + addNeutralStyle: (node) => { + node.style['font-weight'] = 'normal' + }, + removeStyle: (node) => removeStyle(node, 'font-weight'), + }, + underline: { + tagName: 'u', + isFormatted: isUnderline, + isTag: (node) => node.tagName === 'U', + hasStyle: (node) => node.style && node.style['text-decoration-line'].includes('underline'), + addStyle: (node) => node.style['text-decoration-line'] += ' underline', + removeStyle: (node) => removeStyle(node, 'text-decoration-line', 'underline'), + }, + strikeThrough: { + tagName: 's', + isFormatted: isStrikeThrough, + isTag: (node) => node.tagName === 'S', + hasStyle: (node) => node.style && node.style['text-decoration-line'].includes('line-through'), + addStyle: (node) => node.style['text-decoration-line'] += ' line-through', + removeStyle: (node) => removeStyle(node, 'text-decoration-line', 'line-through'), + }, + fontSize: { + isFormatted: isFontSize, + hasStyle: (node, props) => node.style && node.style['font-size'] === props.size, + addStyle: (node, props) => node.style['font-size'] = props.size, + removeStyle: (node) => removeStyle(node, 'font-size'), + }, + switchDirection: { + isFormatted: isDirectionSwitched, + } +} + +const removeStyle = (node, styleName, item) => { + if (item) { + const newStyle = node.style[styleName].split(' ').filter(x => x !== item).join(' '); + node.style[styleName] = newStyle || null; + } else { + node.style[styleName] = null; + } + if (node.getAttribute('style') === '') { + node.removeAttribute('style'); + } +}; +const getOrCreateSpan = (node, ancestors) => { + const span = ancestors.find((element) => element.tagName === 'SPAN' && element.isConnected); + if (span) { + return span; + } else { + const span = document.createElement('span'); + node.after(span); + span.append(node); + return span; + } +} +const removeFormat = (node, formatSpec) => { + node = closestElement(node); + if (formatSpec.hasStyle(node)) { + formatSpec.removeStyle(node); + if (['SPAN', 'FONT'].includes(node.tagName) && !node.getAttributeNames().length) { + return unwrapContents(node); + } + } + + if (formatSpec.isTag(node)) { + const attributesNames = node.getAttributeNames().filter((name)=> { + return name !== 'data-oe-zws-empty-inline'; + }); + if (attributesNames.length) { + // Change tag name + const newNode = document.createElement('span'); + while (node.firstChild) { + newNode.appendChild(node.firstChild); + } + for (let index = node.attributes.length - 1; index >= 0; --index) { + newNode.attributes.setNamedItem(node.attributes[index].cloneNode()); + } + node.parentNode.replaceChild(newNode, node); + } else { + unwrapContents(node); + } + } +} + +export const formatSelection = (editor, formatName, {applyStyle, formatProps} = {}) => { + const selection = editor.document.getSelection(); + let direction + let wasCollapsed; + if (editor.editable.querySelector('.o_selected_td')) { + direction = DIRECTIONS.RIGHT; + } else { + if (!selection.rangeCount) return; + wasCollapsed = selection.getRangeAt(0).collapsed; + + direction = getCursorDirection(selection.anchorNode, selection.anchorOffset, selection.focusNode, selection.focusOffset); + } + getDeepRange(editor.editable, { splitText: true, select: true, correctTripleClick: true }); + + if (typeof applyStyle === 'undefined') { + applyStyle = !isSelectionFormat(editor.editable, formatName); + } + + let zws; + if (wasCollapsed) { + if (selection.anchorNode.nodeType === Node.TEXT_NODE && selection.anchorNode.textContent === '\u200b') { + zws = selection.anchorNode; + selection.getRangeAt(0).selectNode(zws); + } else { + zws = insertAndSelectZws(selection); + } + getDeepRange(editor.editable, { splitText: true, select: true, correctTripleClick: true }); + } + + const selectedTextNodes = getSelectedNodes(editor.editable) + .filter(n => n.nodeType === Node.TEXT_NODE); + + const formatSpec = formatsSpecs[formatName]; + for (const selectedTextNode of selectedTextNodes) { + // Compute inline ancestors until finding one which is a block or has a class. + const inlineAncestors = []; + let node = selectedTextNode; + while ((node = node.parentElement) && (!isBlock(node) && !(node.classList && node.classList.length))) { + inlineAncestors.unshift(node); + } + const blockOrInlineClass = node; + + const firstBlockOrClassHasFormat = formatSpec.isFormatted(blockOrInlineClass, formatProps); + + let previousAncestorHasFormat = firstBlockOrClassHasFormat; + let lastAncestorInlineFormat; + + for (const ancestor of inlineAncestors) { + const ancestorIsTag = formatSpec.isTag && formatSpec.isTag(ancestor); + const ancestorHasStyle = formatSpec.hasStyle && formatSpec.hasStyle(ancestor, formatProps); + const ancestorTagIsFormated = ancestorIsTag || ancestorHasStyle; + const isUselessZwsSpan = + ancestor.tagName === 'SPAN' && + ancestor.hasAttribute('data-oe-zws-empty-inline') && + ancestor.getAttributeNames().length === 1; + const ancestorIsFormatted = formatSpec.isFormatted(ancestor, formatProps); + + if (ancestorTagIsFormated && previousAncestorHasFormat !== ancestorIsFormatted) { + if (lastAncestorInlineFormat) { + // Remove format on two node that switch the format to flatten the DOM. + const newLastAncestorInlineFormat = splitAroundUntil(ancestor, lastAncestorInlineFormat); + removeFormat(newLastAncestorInlineFormat, formatSpec); + removeFormat(ancestor, formatSpec); + lastAncestorInlineFormat = undefined; + } else { + lastAncestorInlineFormat = ancestor; + } + // if there is two consecutive format that are the same, remove one. + } else if ( + (ancestorIsTag || ancestorHasStyle) && + ( + previousAncestorHasFormat || + (!previousAncestorHasFormat && !ancestorIsFormatted) + ) + ) { + removeFormat(ancestor, formatSpec); + } else if (isUselessZwsSpan) { + unwrapContents(ancestor); + } + previousAncestorHasFormat = ancestor.parentElement && formatSpec.isFormatted(ancestor, formatProps); + } + + if (selectedTextNode.nodeType === Node.TEXT_NODE) { + const isLeafNodeFormated = formatSpec.isFormatted(selectedTextNode, formatProps); + if (isLeafNodeFormated !== applyStyle) { + if (lastAncestorInlineFormat) { + const splitNode = splitAroundUntil(selectedTextNode, lastAncestorInlineFormat); + removeFormat(splitNode, formatSpec); + } else { + if (firstBlockOrClassHasFormat && !applyStyle) { + formatSpec.addNeutralStyle(getOrCreateSpan(selectedTextNode, inlineAncestors)); + } else if (!firstBlockOrClassHasFormat && applyStyle) { + const tag = formatSpec.tagName && document.createElement(formatSpec.tagName); + if (tag) { + selectedTextNode.after(tag); + tag.append(selectedTextNode); + + if (!formatSpec.isFormatted(tag, formatProps)) { + tag.after(selectedTextNode); + tag.remove(); + formatSpec.addStyle(getOrCreateSpan(selectedTextNode, inlineAncestors), formatProps); + } + } else { + formatSpec.addStyle(getOrCreateSpan(selectedTextNode, inlineAncestors), formatProps); + } + } + } + } + } + } + + if (zws) { + const siblings = [...zws.parentElement.childNodes]; + if ( + selectedTextNodes.includes(siblings[0]) && + selectedTextNodes.includes(siblings[siblings.length - 1]) + ) { + zws.parentElement.setAttribute('data-oe-zws-empty-inline', ''); + } else { + const span = document.createElement('span'); + span.setAttribute('data-oe-zws-empty-inline', ''); + zws.before(span); + span.append(zws); + } + } + + if (selectedTextNodes[0] && selectedTextNodes[0].textContent === '\u200B') { + setSelection(selectedTextNodes[0], 0); + } else if (selectedTextNodes.length) { + const firstNode = selectedTextNodes[0]; + const lastNode = selectedTextNodes[selectedTextNodes.length - 1]; + if (direction === DIRECTIONS.RIGHT) { + setSelection(firstNode, 0, lastNode, lastNode.length, false); + } else { + setSelection(lastNode, lastNode.length, firstNode, 0, false); + } + } +} + //------------------------------------------------------------------------------ // DOM Info utils //------------------------------------------------------------------------------ @@ -1000,7 +1242,7 @@ export function isItalic(node) { export function isUnderline(node) { let parent = closestElement(node); while (parent) { - if (getComputedStyle(parent).textDecorationLine === 'underline') { + if (getComputedStyle(parent).textDecorationLine.includes('underline')) { return true; } parent = parent.parentElement; @@ -1016,13 +1258,26 @@ export function isUnderline(node) { export function isStrikeThrough(node) { let parent = closestElement(node); while (parent) { - if (getComputedStyle(parent).textDecorationLine === 'line-through') { + if (getComputedStyle(parent).textDecorationLine.includes('line-through')) { return true; } parent = parent.parentElement; } return false; } +/** + * Return true if the given node font-size is equal to `props.size`. + * + * @param {Object} props + * @param {Node} props.node A node to compare the font-size against. + * @param {String} props.size The font-size value of the node that will be + * checked against. + * @returns {boolean} + */ +export function isFontSize(node, props) { + const element = closestElement(node); + return getComputedStyle(element)['font-size'] === props.size; +} /** * Return true if the given node appears in a different direction than that of * the editable ('ltr' or 'rtl'). @@ -1038,13 +1293,6 @@ export function isStrikeThrough(node) { const defaultDirection = editable.getAttribute('dir'); return getComputedStyle(closestElement(node)).direction !== defaultDirection; } -export const isFormat = { - bold: isBold, - italic: isItalic, - underline: isUnderline, - strikeThrough: isStrikeThrough, - switchDirection: isDirectionSwitched, -}; /** * Return true if the current selection on the editable appears as the given * format. The selection is considered to appear as that format if every text @@ -1055,13 +1303,12 @@ export const isFormat = { * @returns {boolean} */ export function isSelectionFormat(editable, format) { - const selectedText = getSelectedNodes(editable) + const selectedNodes = getSelectedNodes(editable) .filter(n => n.nodeType === Node.TEXT_NODE && n.nodeValue.trim().length); - if (selectedText.length) { - return selectedText.every(n => isFormat[format](n.parentElement, editable)) - } else { - return isFormat[format](closestElement(editable.ownerDocument.getSelection().anchorNode), editable); - } + const isFormatted = formatsSpecs[format].isFormatted; + selectedNodes.push(closestElement(editable.ownerDocument.getSelection().anchorNode)); + selectedNodes.push(closestElement(editable.ownerDocument.getSelection().focusNode)); + return selectedNodes.every(n => isFormatted(n, editable)); } export function isUnbreakable(node) { @@ -1575,8 +1822,8 @@ export function splitElement(element, offset) { /** * Split around the given elements, until a given ancestor (included). Elements * will be removed in the process so caution is advised in dealing with their - * references. Returns a tuple containing the new elements on both sides of the - * split. + * references. Returns the new split root element that is a clone of + * limitAncestor or the original limitAncestor if no split occured. * * @see splitElement * @param {Node[] | Node} elements @@ -1585,29 +1832,34 @@ export function splitElement(element, offset) { */ export function splitAroundUntil(elements, limitAncestor) { elements = Array.isArray(elements) ? elements : [elements]; - let after = elements[elements.length - 1].nextSibling; - let newUntil = limitAncestor; + const firstNode = elements[0]; + const lastNode = elements[elements.length - 1]; + if ([firstNode, lastNode].includes(limitAncestor)) { + return limitAncestor; + } + let before = firstNode.previousSibling; + let after = lastNode.nextSibling; let beforeSplit, afterSplit; + if (!before && !after && elements[0] !== limitAncestor) { + return splitAroundUntil(elements[0].parentElement, limitAncestor); + } // Split up ancestors up to font while (after && after.parentElement !== limitAncestor) { afterSplit = splitElement(after.parentElement, childNodeIndex(after))[0]; - newUntil = afterSplit; - after = newUntil.nextSibling; + after = afterSplit.nextSibling; } if (after) { afterSplit = splitElement(limitAncestor, childNodeIndex(after))[0]; limitAncestor = afterSplit; } - let before = elements[0].previousSibling; while (before && before.parentElement !== limitAncestor) { beforeSplit = splitElement(before.parentElement, childNodeIndex(before) + 1)[1]; - newUntil = beforeSplit; - before = newUntil.previousSibling; + before = beforeSplit.previousSibling; } if (before) { beforeSplit = splitElement(limitAncestor, childNodeIndex(before) + 1)[1]; } - return [beforeSplit, afterSplit]; + return beforeSplit || afterSplit || limitAncestor; } export function insertText(sel, content) { diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/editor-test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/editor-test.js index af48f64d50d..028c0e85004 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/editor-test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/editor-test.js @@ -5,7 +5,6 @@ import './spec/editor.test.js'; import './spec/copyPaste.test.js'; import './spec/list.test.js'; import './spec/link.test.js'; -import './spec/fontSize.test.js'; import './spec/format.test.js'; import './spec/insertHTML.test.js'; import './spec/fontAwesome.test.js'; diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js index cd6d3de6117..f1d08103229 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js @@ -1,4 +1,3 @@ -import { applyInlineStyle } from '../../src/commands/commands.js'; import { OdooEditor } from '../../src/OdooEditor.js'; import { getTraversedNodes } from '../../src/utils/utils.js'; import { @@ -3062,18 +3061,18 @@ X[] contentAfter: '

ab x[]\u200B cd

', }); }); - it('should insert a char into a oe-zws-empty-inline span removing the zws and oe-zws-empty-inline', async () => { + it('should insert a char into a data-oe-zws-empty-inline span removing the zws and data-oe-zws-empty-inline', async () => { await testEditor(BasicEditor, { - contentBefore: '

ab[]\u200Bcd

', + contentBefore: '

ab[]\u200Bcd

', stepFunction: async editor => { await insertText(editor, 'x'); }, contentAfter: '

abx[]cd

', }); }); - it('should insert a char into a oe-zws-empty-inline span surrounded by space without removing the zws and oe-zws-empty-inline', async () => { + it('should insert a char into a data-oe-zws-empty-inline span surrounded by space without removing the zws and data-oe-zws-empty-inline', async () => { await testEditor(BasicEditor, { - contentBefore: '

ab[]\u200Bcd

', + contentBefore: '

ab[]\u200Bcd

', stepFunction: async editor => { await insertText(editor, 'x'); }, @@ -3653,12 +3652,8 @@ X[] stepFunction: async editor => { // simulate preview editor.historyPauseSteps(); - try { - applyInlineStyle(editor, (el) => el.style.color = 'lime'); - // a[bcd]e with bcd in lime - } finally { - editor.historyUnpauseSteps(); - } + editor.execCommand('bold'); + editor.historyUnpauseSteps(); // simulate preview's reset editor.historyRevertCurrentStep(); // back to initial state }, @@ -4262,11 +4257,11 @@ X[] 'ef' + '', stepFunction: async editor => editor.execCommand('bold'), - contentAfterEdit: '

a[bc

' + + contentAfterEdit: '

a[bc

' + '' + - '' + - '' + - '' + + '' + + '' + + '' + '
abcdef]abcdef]
', }); }); @@ -4279,11 +4274,11 @@ X[] '

a]bc

', stepFunction: async editor => editor.execCommand('bold'), contentAfterEdit: '' + - '' + - '' + - '' + + '' + + '' + + '' + '
[abcdef[abcdef
' + - '

a]bc

', + '

a]bc

', }); }); it('should apply bold to some characters, a table and some more characters', async () => { @@ -4296,13 +4291,13 @@ X[] '' + '

a]bc

', stepFunction: async editor => editor.execCommand('bold'), - contentAfterEdit: '

a[bc

' + + contentAfterEdit: '

a[bc

' + '' + - '' + - '' + - '' + + '' + + '' + + '' + '
abcdefabcdef
' + - '

a]bc

', + '

a]bc

', }); }); it('should apply bold to some characters, a table, some more characters and another table', async () => { @@ -4320,17 +4315,17 @@ X[] 'ef' + '', stepFunction: async editor => editor.execCommand('bold'), - contentAfterEdit: '

a[bc

' + + contentAfterEdit: '

a[bc

' + '' + - '' + - '' + - '' + + '' + + '' + + '' + '
abcdefabcdef
' + - '

abc

' + + '

abc

' + '' + - '' + - '' + - '' + + '' + + '' + + '' + '
abcdef]abcdef]
', }); }); @@ -4350,19 +4345,19 @@ X[] '' + '

a]bc

', stepFunction: async editor => editor.execCommand('bold'), - contentAfterEdit: '

a[bc

' + + contentAfterEdit: '

a[bc

' + '' + - '' + - '' + - '' + + '' + + '' + + '' + '
abcdefabcdef
' + - '

abc

' + + '

abc

' + '' + - '' + - '' + - '' + + '' + + '' + + '' + '
abcdefabcdef
' + - '

a]bc

', + '

a]bc

', }); }); }); @@ -4591,8 +4586,8 @@ X[] '', stepFunction: async editor => editor.execCommand('bold'), contentAfterEdit: '' + - '' + - '' + + '' + + '' + '' + '
[abcd][abcd]ef
', }); @@ -4606,9 +4601,9 @@ X[] 'abcdef', stepFunction: async editor => editor.execCommand('bold'), contentAfterEdit: '' + - '' + - '' + - '' + + '' + + '' + + '' + '
[abcdef][abcdef]
abcdef
', }); }); @@ -4634,17 +4629,17 @@ X[] stepFunction: async editor => editor.execCommand('bold'), contentAfterEdit: '' + '' + - '' + + '' + '' + '' + '' + '' + - '' + + '' + '' + '' + '' + '' + - '' + + '' + '' + '' + '' + @@ -4673,13 +4668,13 @@ X[] stepFunction: async editor => editor.execCommand('bold'), contentAfterEdit: '
[ab[abcdef
ababcdef
ab]ab]cdef
' + '' + - '' + - '' + + '' + + '' + '' + '' + '' + - '' + - '' + + '' + + '' + '' + '' + '' + @@ -4712,19 +4707,19 @@ X[] stepFunction: async editor => editor.execCommand('bold'), contentAfterEdit: '
[abcd[abcdef
abcd]abcd]ef
' + '' + - '' + - '' + - '' + + '' + + '' + + '' + '' + '' + - '' + - '' + - '' + + '' + + '' + + '' + '' + '' + - '' + - '' + - '' + + '' + + '' + + '' + '' + '
[abcdef[abcdef
abcdefabcdef
abcdef]abcdef]
', }); diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/fontSize.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/fontSize.test.js deleted file mode 100644 index 92ecb212249..00000000000 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/fontSize.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import { BasicEditor, testEditor } from '../utils.js'; - -const setFontSize = size => { - return async editor => { - await editor.execCommand('setFontSize', size); - }; -}; - -describe('FontSize', () => { - describe('setFontSize', () => { - it('should change the font size of a few characters', async () => { - await testEditor(BasicEditor, { - contentBefore: '

ab[cde]fg

', - stepFunction: setFontSize('10px'), - contentAfter: '

ab[cde]fg

', - }); - }); - it('should change the font size of a whole heading after a triple click', async () => { - await testEditor(BasicEditor, { - contentBefore: '

[ab

]cd

', - stepFunction: setFontSize('36px'), - contentAfter: '

[ab]

cd

', - }); - }); - it('should get ready to type with a different font size', async () => { - await testEditor(BasicEditor, { - contentBefore: '

ab[]cd

', - stepFunction: setFontSize('36px'), - contentAfter: '

ab[]\u200Bcd

', - }); - }); - }); -}); diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/format.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/format.test.js index 51014ea8928..bf8f2494a50 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/format.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/format.test.js @@ -1,5 +1,5 @@ +import { isSelectionFormat } from '../../src/utils/utils.js'; import { BasicEditor, testEditor, setTestSelection, Direction } from '../utils.js'; -import { applyInlineStyle } from '../../src/commands/commands.js'; const bold = async editor => { await editor.execCommand('bold'); @@ -13,177 +13,273 @@ const underline = async editor => { const strikeThrough = async editor => { await editor.execCommand('strikeThrough'); }; +const setFontSize = size => editor => editor.execCommand('setFontSize', size); + +const switchDirection = editor => editor.execCommand('switchDirection'); describe('Format', () => { - const b = (content, zws) => `${content}
`; - const notB = (content, weight, zws) => `${content}`; + const getZwsTag = (tagName, {style} = {}) => { + const styleAttr = style ? ` style="${style}"` : ''; + return (content, zws) => { + const zwsFirstAttr = zws === 'first' ? ' data-oe-zws-empty-inline=""' : ''; + const zwsLastAttr = zws === 'last' ? ' data-oe-zws-empty-inline=""' : ''; + return `<${tagName}${zwsFirstAttr}${styleAttr}${zwsLastAttr}>${content}` + }; + } + + const span = getZwsTag('span'); + + const strong = getZwsTag('strong'); + const notStrong = getZwsTag('span', {style: 'font-weight: normal;'}); + const spanBold = getZwsTag('span', {style: 'font-weight: bolder;'}); + const b = getZwsTag('b'); + const repeatWithBoldTags = async (cb) => { + for (const fn of [spanBold, b, strong]) { + await cb(fn); + } + } + + const em = getZwsTag('em'); + + const u = getZwsTag('u'); + + const s = getZwsTag('s'); + + describe('bold', () => { it('should make a few characters bold', async () => { await testEditor(BasicEditor, { contentBefore: '

ab[cde]fg

', stepFunction: bold, - contentAfter: `

ab${b(`[cde]`)}fg

`, + contentAfter: `

ab${strong(`[cde]`)}fg

`, }); }); it('should make a few characters not bold', async () => { await testEditor(BasicEditor, { - contentBefore: `

${b(`ab[cde]fg`)}

`, + contentBefore: `

${strong(`ab[cde]fg`)}

`, stepFunction: bold, - contentAfter: `

${b(`ab${notB(`[cde]`)}fg`)}

`, + contentAfter: `

${strong(`ab`)}[cde]${strong(`fg`)}

`, }); }); it('should make two paragraphs bold', async () => { await testEditor(BasicEditor, { contentBefore: '

[abc

def]

', stepFunction: bold, - contentAfter: `

${b(`[abc`)}

${b(`def]`)}

`, + contentAfter: `

${strong(`[abc`)}

${strong(`def]`)}

`, }); }); it('should make two paragraphs not bold', async () => { await testEditor(BasicEditor, { - contentBefore: `

${b(`[abc`)}

${b(`def]`)}

`, + contentBefore: `

${strong(`[abc`)}

${strong(`def]`)}

`, stepFunction: bold, - contentAfter: `

${notB(`[abc`)}

${notB(`def]`, 400)}

`, + contentAfter: `

[abc

def]

`, }); }); it('should make a whole heading bold after a triple click', async () => { await testEditor(BasicEditor, { - contentBefore: `

${notB(`[ab`)}

]cd

`, + contentBefore: `

${notStrong(`[ab`)}

]cd

`, stepFunction: bold, - // TODO: ideally should restore regular h1 without span instead. - contentAfter: `

${b(`[ab]`)}

cd

`, + contentAfter: `

[ab]

cd

`, }); }); it('should make a whole heading not bold after a triple click (heading is considered bold)', async () => { await testEditor(BasicEditor, { contentBefore: '

[ab

]cd

', stepFunction: bold, - contentAfter: `

${notB(`[ab]`)}

cd

`, + contentAfter: `

${notStrong(`[ab]`)}

cd

`, }); }); it('should make a selection starting with bold text fully bold', async () => { await testEditor(BasicEditor, { - contentBefore: `

${b(`[ab`)}

c]d

`, + contentBefore: `

${strong(`[ab`)}

c]d

`, stepFunction: bold, - contentAfter: `

${b(`[ab`)}

${b(`c]`)}d

`, + contentAfter: `

${strong(`[ab`)}

${strong(`c]`)}d

`, }); }); it('should make a selection with bold text in the middle fully bold', async () => { await testEditor(BasicEditor, { - contentBefore: `

[a${b(`b`)}

${b(`c`)}d]e

`, + contentBefore: `

[a${strong(`b`)}

${strong(`c`)}d]e

`, stepFunction: bold, - contentAfter: `

${b(`[ab`)}

${b(`cd]`)}e

`, + contentAfter: `

${strong(`[ab`)}

${strong(`cd]`)}e

`, }); }); it('should make a selection ending with bold text fully bold', async () => { await testEditor(BasicEditor, { - contentBefore: `

${notB(`[ab`)}

${b(`c]d`)}

`, + contentBefore: `

${notStrong(`[ab`)}

${strong(`c]d`)}

`, stepFunction: bold, - contentAfter: `

${b(`[ab`)}

${b(`c]d`)}

`, + contentAfter: `

[ab

${strong(`c]d`)}

`, }); }); it('should get ready to type in bold', async () => { await testEditor(BasicEditor, { contentBefore: '

ab[]cd

', stepFunction: bold, - contentAfterEdit: `

ab${b(`[]\u200B`, 'first')}cd

`, + contentAfterEdit: `

ab${strong(`[]\u200B`, 'first')}cd

`, contentAfter: `

ab[]cd

`, }); }); it('should get ready to type in not bold', async () => { await testEditor(BasicEditor, { - contentBefore: `

${b(`ab[]cd`)}

`, + contentBefore: `

${strong(`ab[]cd`)}

`, stepFunction: bold, - contentAfterEdit: `

${b(`ab${notB(`[]\u200B`, undefined, 'first')}cd`)}

`, - contentAfter: `

${b(`ab[]cd`)}

`, + contentAfterEdit: `

${strong(`ab`)}${span(`[]\u200B`, 'first')}${strong(`cd`)}

`, + contentAfter: `

${strong(`ab[]cd`)}

`, + }); + }); + + + it('should remove a bold tag that was redondant while performing the command', async () => { + await repeatWithBoldTags(async (tag) => { + await testEditor(BasicEditor, { + contentBefore: `

a${tag(`b${tag(`[c]`)}d`)}e

`, + stepFunction: bold, + contentAfter: `

a${tag('b')}[c]${tag('d')}e

`, + }); + }); + }); + it('should remove a bold tag that was redondant with different tags while performing the command', async () => { + await testEditor(BasicEditor, { + contentBefore: `

a${spanBold(`b${strong(`c${b(`[d]`)}e`)}f`)}g

`, + stepFunction: bold, + contentAfter: `

a${spanBold(`bc`)}[d]${spanBold(`ef`)}g

`, + }); + }); + + describe('inside container or inline with class already bold', () => { + it('should force the font-weight to normal with an inline with class', async () => { + await testEditor(BasicEditor, { + styleContent: `.boldClass { font-weight: bold; }`, + contentBefore: `
a[b]c
`, + stepFunction: bold, + contentAfter: `
a[b]c
`, + }); + }); + + it('should force the font-weight to normal', async () => { + await testEditor(BasicEditor, { + contentBefore: `

a[b]c

`, + stepFunction: bold, + contentAfter: `

a[b]c

`, + }); + }); + + it('should force the font-weight to normal while removing redundant tag', async () => { + await repeatWithBoldTags(async (tag) => { + await testEditor(BasicEditor, { + contentBefore: `

a${tag('[b]')}c

`, + stepFunction: bold, + contentAfter: `

a[b]c

`, + }); + }); + }); + }); + describe('inside container font-weight: 500 and strong being strong-weight: 500', () => { + it('should remove the redundant strong style and add span with a bolder font-weight', async () => { + await testEditor(BasicEditor, { + styleContent: `h1, strong {font-weight: 500;}`, + contentBefore: `

a${strong(`[b]`)}c

`, + stepFunction: bold, + contentAfter: `

a[b]c

`, + }); }); }); }); - const i = (content, zws) => `${content}`; - const notI = (content, zws) => `${content}`; + describe('bold and italic inside container already bold', () => { + it('should remove the redundant strong style and add span with a bolder font-weight', async () => { + await testEditor(BasicEditor, { + styleContent: `h1 {font-weight: bold;}`, + contentBefore: `

a[b]c

`, + stepFunction: (editor) => { + editor.execCommand('italic'); + editor.execCommand('bold'); + editor.execCommand('italic'); + }, + contentAfter: `

a[b]c

`, + }); + }); + }); + describe('italic', () => { it('should make a few characters italic', async () => { await testEditor(BasicEditor, { contentBefore: `

ab[cde]fg

`, stepFunction: italic, - contentAfter: `

ab${i(`[cde]`)}fg

`, + contentAfter: `

ab${em(`[cde]`)}fg

`, }); }); it('should make a few characters not italic', async () => { await testEditor(BasicEditor, { - contentBefore: `

${i(`ab[cde]fg`)}

`, + contentBefore: `

${em(`ab[cde]fg`)}

`, stepFunction: italic, - contentAfter: `

${i(`ab${notI(`[cde]`)}fg`)}

`, + contentAfter: `

${em(`ab`)}[cde]${em(`fg`)}

`, }); }); it('should make two paragraphs italic', async () => { await testEditor(BasicEditor, { contentBefore: '

[abc

def]

', stepFunction: italic, - contentAfter: `

${i(`[abc`)}

${i(`def]`)}

`, + contentAfter: `

${em(`[abc`)}

${em(`def]`)}

`, }); }); it('should make two paragraphs not italic', async () => { await testEditor(BasicEditor, { - contentBefore: `

${i(`[abc`)}

${i(`def]`)}

`, + contentBefore: `

${em(`[abc`)}

${em(`def]`)}

`, stepFunction: italic, - contentAfter: `

${notI(`[abc`)}

${notI(`def]`)}

`, + contentAfter: `

[abc

def]

`, }); }); it('should make a whole heading italic after a triple click', async () => { await testEditor(BasicEditor, { contentBefore: `

[ab

]cd

`, stepFunction: italic, - contentAfter: `

${i(`[ab]`)}

cd

`, + contentAfter: `

${em(`[ab]`)}

cd

`, }); }); it('should make a whole heading not italic after a triple click', async () => { await testEditor(BasicEditor, { - contentBefore: `

${i(`[ab`)}

]cd

`, + contentBefore: `

${em(`[ab`)}

]cd

`, stepFunction: italic, - // TODO: ideally should restore regular h1 without span instead. - contentAfter: `

${notI(`[ab]`)}

cd

`, + contentAfter: `

[ab]

cd

`, }); }); it('should make a selection starting with italic text fully italic', async () => { await testEditor(BasicEditor, { - contentBefore: `

${i(`[ab`)}

c]d

`, + contentBefore: `

${em(`[ab`)}

c]d

`, stepFunction: italic, - contentAfter: `

${i(`[ab`)}

${i(`c]`)}d

`, + contentAfter: `

${em(`[ab`)}

${em(`c]`)}d

`, }); }); it('should make a selection with italic text in the middle fully italic', async () => { await testEditor(BasicEditor, { - contentBefore: `

[a${i(`b`)}

${i(`c`)}d]e

`, + contentBefore: `

[a${em(`b`)}

${em(`c`)}d]e

`, stepFunction: italic, - contentAfter: `

${i(`[ab`)}

${i(`cd]`)}e

`, + contentAfter: `

${em(`[ab`)}

${em(`cd]`)}e

`, }); }); it('should make a selection ending with italic text fully italic', async () => { await testEditor(BasicEditor, { - contentBefore: `

[ab

${i(`c]d`)}

`, + contentBefore: `

[ab

${em(`c]d`)}

`, stepFunction: italic, - contentAfter: `

${i(`[ab`)}

${i(`c]d`)}

`, + contentAfter: `

${em(`[ab`)}

${em(`c]d`)}

`, }); }); it('should get ready to type in italic', async () => { await testEditor(BasicEditor, { contentBefore: `

ab[]cd

`, stepFunction: italic, - contentAfterEdit: `

ab${i(`[]\u200B`, 'first')}cd

`, + contentAfterEdit: `

ab${em(`[]\u200B`, 'first')}cd

`, contentAfter: `

ab[]cd

`, }); }); it('should get ready to type in not italic', async () => { await testEditor(BasicEditor, { - contentBefore: `

${i(`ab[]cd`)}

`, + contentBefore: `

${em(`ab[]cd`)}

`, stepFunction: italic, - contentAfterEdit: `

${i(`ab${notI(`[]\u200B`, 'first')}cd`)}

`, - contentAfter: `

${i(`ab[]cd`)}

`, + contentAfterEdit: `

${em(`ab`)}${span(`[]\u200B`, 'first')}${em(`cd`)}

`, + contentAfter: `

${em(`ab[]cd`)}

`, }); }); }); - const u = (content, zws) => `${content}`; describe('underline', () => { it('should make a few characters underline', async () => { await testEditor(BasicEditor, { @@ -196,7 +292,7 @@ describe('Format', () => { await testEditor(BasicEditor, { contentBefore: `

${u(`ab[cde]fg`)}

`, stepFunction: underline, - contentAfter: `

${u(`ab[`)}cde]${u(`fg`)}

`, + contentAfter: `

${u(`ab`)}[cde]${u(`fg`)}

`, }); }); it('should make two paragraphs underline', async () => { @@ -260,12 +356,11 @@ describe('Format', () => { await testEditor(BasicEditor, { contentBefore: `

${u(`ab[]cd`)}

`, stepFunction: underline, - contentAfterEdit: `

${u(`ab`)}\u200B[]${u(`cd`)}

`, - contentAfter: `

${u(`ab`)}[]${u(`cd`)}

`, + contentAfterEdit: `

${u(`ab`)}${span(`[]\u200B`, 'first')}${u(`cd`)}

`, + contentAfter: `

${u(`ab[]cd`)}

`, }); }); }); - const s = (content, zws) => `${content}`; describe('strikeThrough', () => { it('should make a few characters strikeThrough', async () => { await testEditor(BasicEditor, { @@ -278,7 +373,7 @@ describe('Format', () => { await testEditor(BasicEditor, { contentBefore: `

${s(`ab[cde]fg`)}

`, stepFunction: strikeThrough, - contentAfter: `

${s(`ab[`)}cde]${s(`fg`)}

`, + contentAfter: `

${s(`ab`)}[cde]${s(`fg`)}

`, }); }); it('should make a few characters strikeThrough then remove style inside', async () => { @@ -286,7 +381,7 @@ describe('Format', () => { contentBefore: `

ab[c d]ef

`, stepFunction: async editor => { await strikeThrough(editor); - const styleSpan = editor.editable.querySelector('span[style="text-decoration-line: line-through;"]').childNodes[0]; + const styleSpan = editor.editable.querySelector('s').childNodes[0]; const selection = { anchorNode: styleSpan, anchorOffset: 1, @@ -297,7 +392,7 @@ describe('Format', () => { await setTestSelection(selection); await strikeThrough(editor); }, - contentAfter: `

ab${s(`c[`)} ]${s(`d`)}ef

`, + contentAfter: `

ab${s(`c`)}[ ]${s(`d`)}ef

`, }); }); it('should make strikeThrough then more then remove', async () => { @@ -398,18 +493,19 @@ describe('Format', () => { await testEditor(BasicEditor, { contentBefore: `

${s(`ab[]cd`)}

`, stepFunction: strikeThrough, - contentAfterEdit: `

${s(`ab`)}\u200B[]${s(`cd`)}

`, - contentAfter: `

${s(`ab`)}[]${s(`cd`)}

`, + contentAfterEdit: `

${s(`ab`)}${span(`[]\u200B`, 'first')}${s(`cd`)}

`, + contentAfter: `

${s(`ab[]cd`)}

`, }); }); }); + describe('underline + strikeThrough', () => { it('should get ready to write in strikeThrough without underline (underline was first)', async () => { await testEditor(BasicEditor, { contentBefore: `

ab${u(s(`cd[]ef`))}

`, stepFunction: underline, - contentAfterEdit: `

ab${u(s(`cd`))}${s(`\u200b[]`, 'last')}${u(s(`ef`))}

`, - contentAfter: `

ab${u(s(`cd`))}[]${u(s(`ef`))}

`, + contentAfterEdit: `

ab${u(s(`cd`))}${s(`[]\u200b`, 'last')}${u(s(`ef`))}

`, + contentAfter: `

ab${u(s(`cd[]ef`))}

`, }); }); it('should restore underline after removing it (collapsed, strikeThrough)', async () => { @@ -417,22 +513,22 @@ describe('Format', () => { contentBefore: `

ab${u(s(`cd`))}${s(`\u200b[]`, 'first')}${u(s(`ef`))}

`, stepFunction: underline, contentAfterEdit: `

ab${u(s(`cd`))}${s(u(`[]\u200b`, 'first'), 'first')}${u(s(`ef`))}

`, - contentAfter: `

ab${u(s(`cd`))}[]${u(s(`ef`))}

`, + contentAfter: `

ab${u(s(`cd[]ef`))}

`, }); }); it('should remove underline after restoring it after removing it (collapsed, strikeThrough)', async () => { await testEditor(BasicEditor, { contentBefore: `

ab${u(s(`cd`))}${s(u(`[]\u200b`, 'first'))}${u(s(`ef`))}

`, stepFunction: underline, - contentAfterEdit: `

ab${u(s(`cd`))}${s(`\u200b[]`, 'last')}${u(s(`ef`))}

`, - contentAfter: `

ab${u(s(`cd`))}[]${u(s(`ef`))}

`, + contentAfterEdit: `

ab${u(s(`cd`))}${s(`[]\u200b`, 'last')}${u(s(`ef`))}

`, + contentAfter: `

ab${u(s(`cd[]ef`))}

`, }); }); it('should remove underline after restoring it and writing after removing it (collapsed, strikeThrough)', async () => { await testEditor(BasicEditor, { contentBefore: `

ab${u(s(`cd`))}${s(u(`ghi[]`))}${u(s(`ef`))}

`, stepFunction: underline, - contentAfterEdit: `

ab${u(s(`cd`))}${s(u(`ghi`) + `\u200b[]`)}${u(s(`ef`))}

`, + contentAfterEdit: `

ab${u(s(`cd`))}${s(`${u(`ghi`)}${span(`[]\u200b`, 'first')}`)}${u(s(`ef`))}

`, contentAfter: `

ab${u(s(`cd`))}${s(u(`ghi`) + `[]`)}${u(s(`ef`))}

`, }); }); @@ -452,9 +548,15 @@ describe('Format', () => { contentAfterEdit: `

ab${u(s(`cd`))}${s(`A${u(`B`)}${uselessSpan(`C[]`)}${uselessU}`)}${u(s(`ef`))}

`, }); }); + it('should remove only underline decoration on a span', async () => { + await testEditor(BasicEditor, { + contentBefore: `

[a]

`, + stepFunction: underline, + contentAfter: `

[a]

`, + }); + }); }); describe('underline + italic', () => { - const iAndU = (content, zws) => `${content}`; it('should get ready to write in italic and underline', async () => { await testEditor(BasicEditor, { contentBefore: `

ab[]cd

`, @@ -462,11 +564,11 @@ describe('Format', () => { await editor.execCommand('italic'); await editor.execCommand('underline'); }, - contentAfterEdit: `

ab${iAndU(`[]\u200B`, 'first')}cd

`, + contentAfterEdit: `

ab${em(u(`[]\u200B`, 'first'), 'first')}cd

`, contentAfter: `

ab[]cd

`, }); }); - it('should get ready to write in italic, after changing one\'s mind about underline', async () => { + it('should get ready to write in italic, after changing one\'s mind about underline (two consecutive at the end)', async () => { await testEditor(BasicEditor, { contentBefore: `

ab[]cd

`, stepFunction: async editor => { @@ -474,9 +576,11 @@ describe('Format', () => { await editor.execCommand('underline'); await editor.execCommand('underline'); }, - contentAfterEdit: `

ab${i(`\u200B[]`, 'first')}cd

`, + contentAfterEdit: `

ab${em(`[]\u200B`, 'first')}cd

`, contentAfter: `

ab[]cd

`, }); + }); + it('should get ready to write in italic, after changing one\'s mind about underline (separated by italic)', async () => { await testEditor(BasicEditor, { contentBefore: `

ab[]cd

`, stepFunction: async editor => { @@ -484,9 +588,11 @@ describe('Format', () => { await editor.execCommand('italic'); await editor.execCommand('underline'); }, - contentAfterEdit: `

ab${i(`\u200B[]`, 'first')}cd

`, + contentAfterEdit: `

ab${em(`[]\u200B`, 'first')}cd

`, contentAfter: `

ab[]cd

`, }); + }); + it('should get ready to write in italic, after changing one\'s mind about underline (two consecutive at the beginning)', async () => { await testEditor(BasicEditor, { contentBefore: `

ab[]cd

`, stepFunction: async editor => { @@ -494,47 +600,47 @@ describe('Format', () => { await editor.execCommand('underline'); await editor.execCommand('italic'); }, - contentAfterEdit: `

ab${i(`[]\u200B`, 'first')}cd

`, + contentAfterEdit: `

ab${em(`[]\u200B`, 'first')}cd

`, contentAfter: `

ab[]cd

`, }); }); it('should get ready to write in italic without underline (underline was first)', async () => { await testEditor(BasicEditor, { - contentBefore: `

ab${u(i(`cd[]ef`))}

`, + contentBefore: `

ab${u(em(`cd[]ef`))}

`, stepFunction: underline, - contentAfterEdit: `

ab${u(i(`cd`))}${i(`\u200b[]`, 'last')}${u(i(`ef`))}

`, - contentAfter: `

ab${u(i(`cd`))}[]${u(i(`ef`))}

`, + contentAfterEdit: `

ab${u(em(`cd`))}${em(`[]\u200b`, 'last')}${u(em(`ef`))}

`, + contentAfter: `

ab${u(em(`cd[]ef`))}

`, }); }); it('should restore underline after removing it (collapsed, italic)', async () => { await testEditor(BasicEditor, { - contentBefore: `

ab${u(i(`cd`))}${i(`\u200b[]`)}${u(i(`ef`))}

`, + contentBefore: `

ab${u(em(`cd`))}${em(`[]\u200b`)}${u(em(`ef`))}

`, stepFunction: underline, - contentAfterEdit: `

ab${u(i(`cd`))}${iAndU(`[]\u200b`, 'last')}${u(i(`ef`))}

`, - contentAfter: `

ab${u(i(`cd`))}[]${u(i(`ef`))}

`, + contentAfterEdit: `

ab${u(em(`cd`))}${em(u(`[]\u200b`, 'last'))}${u(em(`ef`))}

`, + contentAfter: `

ab${u(em(`cd`))}${em(`[]`)}${u(em(`ef`))}

`, }); }); it('should remove underline after restoring it after removing it (collapsed, italic)', async () => { await testEditor(BasicEditor, { - contentBefore: `

ab${u(i(`cd`))}${i(u(`[]\u200b`))}${u(i(`ef`))}

`, + contentBefore: `

ab${u(em(`cd`))}${em(u(`[]\u200b`))}${u(em(`ef`))}

`, stepFunction: underline, - contentAfterEdit: `

ab${u(i(`cd`))}${i(`\u200b[]`, 'last')}${u(i(`ef`))}

`, - contentAfter: `

ab${u(i(`cd`))}[]${u(i(`ef`))}

`, + contentAfterEdit: `

ab${u(em(`cd`))}${em(`[]\u200b`, 'last')}${u(em(`ef`))}

`, + contentAfter: `

ab${u(em(`cd[]ef`))}

`, }); }); it('should remove underline after restoring it and writing after removing it (collapsed, italic)', async () => { await testEditor(BasicEditor, { - contentBefore: `

ab${u(i(`cd`))}${i(u(`ghi[]`))}${u(i(`ef`))}

`, + contentBefore: `

ab${u(em(`cd`))}${em(u(`ghi[]`))}${u(em(`ef`))}

`, stepFunction: underline, - contentAfterEdit: `

ab${u(i(`cd`))}${i(u(`ghi`) + `\u200b[]`)}${u(i(`ef`))}

`, - contentAfter: `

ab${u(i(`cd`))}${i(u(`ghi`) + `[]`)}${u(i(`ef`))}

`, + contentAfterEdit: `

ab${u(em(`cd`))}${em(u(`ghi`) + `[]\u200b`)}${u(em(`ef`))}

`, + contentAfter: `

ab${u(em(`cd`))}${em(u(`ghi`) + `[]`)}${u(em(`ef`))}

`, }); }); it('should remove underline, write, restore underline, write, remove underline again, write (collapsed, italic)', async () => { const uselessSpan = content => `${content}`; const uselessU = u(''); // TODO: clean await testEditor(BasicEditor, { - contentBefore: `

ab${u(i(`cd[]ef`))}

`, + contentBefore: `

ab${u(em(`cd[]ef`))}

`, stepFunction: async editor => { await editor.execCommand('underline'); await editor.execCommand('insert', 'A'); @@ -543,18 +649,84 @@ describe('Format', () => { await editor.execCommand('underline'); await editor.execCommand('insert', 'C'); }, - contentAfter: `

ab${u(i(`cd`))}${i(`A${u(`B`)}${uselessSpan(`C[]`)}${uselessU}`)}${u(i(`ef`))}

`, + contentAfter: `

ab${u(em(`cd`))}${em(`A${u(`B`)}${uselessSpan(`C[]`)}${uselessU}`)}${u(em(`ef`))}

`, }); }); }); -}); -describe('applyInlineStyle', () => { - it('should apply style to selection only', async () => { + describe('setFontSize', () => { + it('should change the font size of a few characters', async () => { + await testEditor(BasicEditor, { + contentBefore: '

ab[cde]fg

', + stepFunction: setFontSize('10px'), + contentAfter: '

ab[cde]fg

', + }); + }); + it('should change the font size of a whole heading after a triple click', async () => { + await testEditor(BasicEditor, { + contentBefore: '

[ab

]cd

', + stepFunction: setFontSize('36px'), + contentAfter: '

[ab]

cd

', + }); + }); + it('should get ready to type with a different font size', async () => { + await testEditor(BasicEditor, { + contentBefore: '

ab[]cd

', + stepFunction: setFontSize('36px'), + contentAfterEdit: `

ab[]\u200Bcd

`, + contentAfter: '

ab[]cd

', + }); + }); + }); + + it('should add style to a span parent of an inline', async () => { await testEditor(BasicEditor, { - contentBefore: '

a[bc]def

', - stepFunction: editor => applyInlineStyle(editor, (el) => el.style.color = 'tomato'), - contentAfter: '

a[bc]def

', + contentBefore: `

a${strong(`[bc]`)}d

`, + stepFunction: setFontSize('10px'), + contentAfter: `

a${strong(`[bc]`)}d

`, + }); + }); + + describe('isSelectionFormat', () => { + it('return false for isSelectionFormat when partially selecting 2 text node, the anchor is formated and focus is not formated', async () => { + await testEditor(BasicEditor, { + contentBefore: `

${strong(`a[b`)}

c]d

`, + stepFunction: (editor) => { + window.chai.expect(isSelectionFormat(editor.editable, 'bold')).to.be.equal(false); + }, + }); + }); + it('return false for isSelectionFormat when partially selecting 2 text node, the anchor is not formated and focus is formated', async () => { + await testEditor(BasicEditor, { + contentBefore: `

${strong(`a]b`)}

c[d

`, + stepFunction: (editor) => { + window.chai.expect(isSelectionFormat(editor.editable, 'bold')).to.be.equal(false); + }, + }); + }); + it('return false for isSelectionFormat when selecting 3 text node, the anchor and focus not formated and the text node in between formated', async () => { + await testEditor(BasicEditor, { + contentBefore: `

a[b

${strong(`c`)}

d]e

`, + stepFunction: (editor) => { + window.chai.expect(isSelectionFormat(editor.editable, 'bold')).to.be.equal(false); + }, + }); + }); + }); + describe('switchDirection', () => { + it('should switch direction on a collapsed range', async () => { + await testEditor(BasicEditor, { + contentBefore: `

a[]b

`, + stepFunction: switchDirection, + contentAfter: `

a[]b

`, + }); + }); + it('should switch direction on an uncollapsed range', async () => { + await testEditor(BasicEditor, { + contentBefore: `

a[b]c

`, + stepFunction: switchDirection, + contentAfter: `

a[b]c

`, + }); }); }); }); diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/utils.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/utils.test.js index b5cdd2a6013..63fe4fa93fc 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/utils.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/utils.test.js @@ -1549,13 +1549,10 @@ describe('Utils', () => { splitTextNode(cd, 1); const d = cd; const result = splitAroundUntil(d, p.childNodes[1]); - window.chai.expect(result.every(node => node.tagName === 'FONT')).to.be.ok; + window.chai.expect(result.tagName === 'FONT').to.be.ok; window.chai.expect(p.outerHTML).to.eql( '

abcdefg

' ); - window.chai.expect(result[0]).to.eql(p.childNodes[2]); - // "before" split happened so "after" split is not in the DOM anymore: - window.chai.expect(result[1].parentElement).to.eql(null); }); it('should split a slice of text from its inline ancestry', () => { const [p] = insertTestHtml('

abcdefghi

'); @@ -1570,21 +1567,27 @@ describe('Utils', () => { splitTextNode(cd, 1); const d = cd; const result = splitAroundUntil([d, d.nextSibling.nextSibling], p.childNodes[1]); - window.chai.expect(result.every(node => node.tagName === 'FONT')).to.be.ok; + window.chai.expect(result.tagName === 'FONT').to.be.ok; window.chai.expect(p.outerHTML).to.eql( '

abcdefghi

' ); - window.chai.expect(result[0]).to.eql(p.childNodes[2]); - // "before" split happened so "after" split is not in the DOM anymore: - window.chai.expect(result[1].parentElement).to.eql(null); + }); + it('should split from a textNode that has no siblings', () => { + const [p] = insertTestHtml('

abcdefg

'); + const font = p.querySelector('font'); + const cde = p.querySelector('span').firstChild; + const result = splitAroundUntil(cde, font); + window.chai.expect(result.tagName === 'FONT' && result !== font).to.be.ok; + window.chai.expect(p.outerHTML).to.eql('

abcdefg

'); }); it('should not do anything (nothing to split)', () => { - const [p] = insertTestHtml('

abcdefg

'); - const cde = p.childNodes[1].childNodes[1].firstChild; - const result = splitAroundUntil(cde, p.childNodes[1]); - window.chai.expect(result.every(node => node === undefined)).to.be.ok; - window.chai.expect(p.outerHTML).to.eql('

abcdefg

'); + const [p] = insertTestHtml('

abcde

'); + const bcd = p.querySelector('span').firstChild; + const result = splitAroundUntil(bcd, p.childNodes[1]); + window.chai.expect(result === p.childNodes[1]).to.be.ok; + window.chai.expect(p.outerHTML).to.eql('

abcde

'); }); + }); describe('CleanUp Html', () => { diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/utils.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/utils.js index ef5b4d8ea0c..63ec2d6396d 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/utils.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/utils.js @@ -277,6 +277,12 @@ export async function testEditor(Editor = OdooEditor, spec, options = {}) { const testNode = document.createElement('div'); document.querySelector('#editor-test-container').innerHTML = ''; document.querySelector('#editor-test-container').appendChild(testNode); + let styleTag; + if (spec.styleContent) { + styleTag = document.createElement('style'); + styleTag.textContent = spec.styleContent; + document.querySelector('#editor-test-container').appendChild(styleTag); + } // Add the content to edit and remove the "[]" markers *before* initializing // the editor as otherwise those would genererate mutations the editor would