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 ab[]\u200Bcd ab[]\u200Bcd abx[]cd ab[]\u200Bcd ab[]\u200Bcd
a[bc
' + + contentAfterEdit: 'a[bc
' + '| ab | ' + - 'cd | ' + - 'ef] | ' + + 'ab | ' + + 'cd | ' + + 'ef] | ' + '
a]bc
', stepFunction: async editor => editor.execCommand('bold'), contentAfterEdit: '| [ab | ' + - 'cd | ' + - 'ef | ' + + '[ab | ' + + 'cd | ' + + 'ef | ' + '
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
' + '| ab | ' + - 'cd | ' + - 'ef | ' + + 'ab | ' + + 'cd | ' + + 'ef | ' + '
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[] 'a[bc
' + + contentAfterEdit: 'a[bc
' + '| ab | ' + - 'cd | ' + - 'ef | ' + + 'ab | ' + + 'cd | ' + + 'ef | ' + '
abc
' + + 'abc
' + '| ab | ' + - 'cd | ' + - 'ef] | ' + + 'ab | ' + + 'cd | ' + + 'ef] | ' + '
a]bc
', stepFunction: async editor => editor.execCommand('bold'), - contentAfterEdit: 'a[bc
' + + contentAfterEdit: 'a[bc
' + '| ab | ' + - 'cd | ' + - 'ef | ' + + 'ab | ' + + 'cd | ' + + 'ef | ' + '
abc
' + + 'abc
' + '| ab | ' + - 'cd | ' + - 'ef | ' + + 'ab | ' + + 'cd | ' + + 'ef | ' + '
a]bc
', + 'a]bc
', }); }); }); @@ -4591,8 +4586,8 @@ X[] '', stepFunction: async editor => editor.execCommand('bold'), contentAfterEdit: '| [ab | ' + - 'cd] | ' + + '[ab | ' + + 'cd] | ' + 'ef | ' + '
| [ab | ' + - 'cd | ' + - 'ef] | ' + + '[ab | ' + + 'cd | ' + + 'ef] | ' + '
| ab | cd | ef |
| [ab | ' + + '[ab | ' + 'cd | ' + 'ef | ' + '||||||||||||||
| ab | ' + + 'ab | ' + 'cd | ' + 'ef | ' + '||||||||||||||
| ab] | ' + + 'ab] | ' + 'cd | ' + 'ef | ' + '
| [ab | ' + - 'cd | ' + + '[ab | ' + + 'cd | ' + 'ef | ' + '|||||||||||||
| ab | ' + - 'cd] | ' + + 'ab | ' + + 'cd] | ' + 'ef | ' + '|||||||||||||
| [ab | ' + - 'cd | ' + - 'ef | ' + + '[ab | ' + + 'cd | ' + + 'ef | ' + '
| ab | ' + - 'cd | ' + - 'ef | ' + + 'ab | ' + + 'cd | ' + + 'ef | ' + '
| ab | ' + - 'cd | ' + - 'ef] | ' + + 'ab | ' + + 'cd | ' + + 'ef] | ' + '
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: ']cd
', - stepFunction: setFontSize('36px'), - contentAfter: '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}${tagName}>` + }; + } + + 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: `]cd
`, + contentBefore: `]cd
`, stepFunction: bold, - // TODO: ideally should restore regular h1 without span instead. - contentAfter: `cd
`, + contentAfter: `cd
`, }); }); it('should make a whole heading not bold after a triple click (heading is considered bold)', async () => { await testEditor(BasicEditor, { contentBefore: ']cd
', stepFunction: bold, - contentAfter: `cd
`, + contentAfter: `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: `${b(`c]d`)}
`, + contentBefore: `${strong(`c]d`)}
`, stepFunction: bold, - contentAfter: `${b(`c]d`)}
`, + contentAfter: `${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: `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: `]cd
`, stepFunction: italic, - contentAfter: `cd
`, + contentAfter: `cd
`, }); }); it('should make a whole heading not italic after a triple click', async () => { await testEditor(BasicEditor, { - contentBefore: `]cd
`, + contentBefore: `]cd
`, stepFunction: italic, - // TODO: ideally should restore regular h1 without span instead. - contentAfter: `cd
`, + contentAfter: `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: ']cd
', + stepFunction: setFontSize('36px'), + contentAfter: '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