From 6844ff5a3f01203a26514ae6ee80387bab43473a Mon Sep 17 00:00:00 2001
From: Antoine Guenet <age@odoo.com>
Date: Fri, 25 Feb 2022 09:52:16 +0000
Subject: [PATCH] [FIX] web_editor: apply format, font-size and color collapsed

When the selection is collapsed it was impossible to set a format (bold,
italic, underline, strikethrough), a font-size or a color. This makes it
possible by inserting and selecting a zero-width space first, then
placing the caret to its left when we're done.

task-2778416

X-original-commit: b4eb0fe69bce5ca18d8450cd7118740f1d31f780
Part-of: odoo/odoo#86930
---
 .../lib/odoo-editor/src/commands/commands.js  |  38 +-
 .../static/lib/odoo-editor/src/utils/utils.js |  17 +-
 .../lib/odoo-editor/test/editor-test.js       |   2 +
 .../lib/odoo-editor/test/spec/color.test.js   |  42 ++
 .../lib/odoo-editor/test/spec/editor.test.js  | 241 ----------
 .../odoo-editor/test/spec/fontSize.test.js    |   7 +
 .../lib/odoo-editor/test/spec/format.test.js  | 427 ++++++++++++++++++
 7 files changed, 524 insertions(+), 250 deletions(-)
 create mode 100644 addons/web_editor/static/lib/odoo-editor/test/spec/color.test.js
 create mode 100644 addons/web_editor/static/lib/odoo-editor/test/spec/format.test.js

diff --git a/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js b/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js
index 564a62326bd2..b6e60b2f44a6 100644
--- a/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js
+++ b/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js
@@ -16,6 +16,7 @@ import {
     getNormalizedCursorPosition,
     getSelectedNodes,
     getTraversedNodes,
+    insertAndSelectZws,
     insertText,
     isBlock,
     isBold,
@@ -214,6 +215,7 @@ function hasColor(element, mode) {
  * which the wanted style should be applied
  */
 export function applyInlineStyle(editor, applyStyle) {
+    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;
@@ -226,7 +228,7 @@ export function applyInlineStyle(editor, applyStyle) {
         normalizedEndContainer,
         normalizedEndOffset
     ] = getNormalizedCursorPosition(endContainer, endOffset)
-    const selectedTextNodes = getTraversedNodes(editor.editable).filter(node => {
+    const selectedTextNodes = getSelectedNodes(editor.editable).filter(node => {
         const atLeastOneCharFromNodeInSelection = !(
             (node === normalizedEndContainer && normalizedEndOffset === 0) ||
             (node === normalizedStartContainer && normalizedStartOffset === node.textContent.length)
@@ -266,7 +268,9 @@ export function applyInlineStyle(editor, applyStyle) {
         }
         applyStyle(textNode.parentElement);
     }
-    if (selectedTextNodes.length) {
+    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) {
@@ -289,21 +293,24 @@ const styles = {
     },
     underline: {
         is: isUnderline,
-        name: 'textDecoration',
+        name: 'textDecorationLine',
         value: 'underline',
     },
     strikeThrough: {
         is: isStrikeThrough,
-        name: 'textDecoration',
+        name: 'textDecorationLine',
         value: 'line-through',
     }
 }
 export function toggleFormat(editor, format) {
     const selection = editor.document.getSelection();
-    if (!selection.rangeCount || selection.getRangeAt(0).collapsed) return;
+    if (!selection.rangeCount) return;
+    if (selection.getRangeAt(0).collapsed) {
+        insertAndSelectZws(selection);
+    }
     getDeepRange(editor.editable, { splitText: true, select: true, correctTripleClick: true });
     const style = styles[format];
-    const isAlreadyFormatted = getSelectedNodes(editor.editable)
+    const isAlreadyFormatted = !!getSelectedNodes(editor.editable)
         .filter(n => n.nodeType === Node.TEXT_NODE && n.nodeValue.trim().length)
         .find(n => style.is(n.parentElement));
     applyInlineStyle(editor, el => {
@@ -430,7 +437,10 @@ export const editorCommands = {
      */
     setFontSize: (editor, size) => {
         const selection = editor.document.getSelection();
-        if (!selection.rangeCount || selection.getRangeAt(0).collapsed) return;
+        if (!selection.rangeCount) return;
+        if (selection.getRangeAt(0).collapsed) {
+            insertAndSelectZws(selection);
+        }
         applyInlineStyle(editor, element => {
             element.style.fontSize = size;
         });
@@ -545,6 +555,12 @@ export const editorCommands = {
             colorElement(element, color, mode);
             return;
         }
+        const selection = editor.document.getSelection();
+        let wasCollapsed = false;
+        if (selection.getRangeAt(0).collapsed) {
+            insertAndSelectZws(selection);
+            wasCollapsed = true;
+        }
         const range = getDeepRange(editor.editable, { splitText: true, select: true });
         if (!range) return;
         const restoreCursor = preserveCursor(editor.document);
@@ -599,6 +615,14 @@ export const editorCommands = {
             }
         }
         restoreCursor();
+        if (wasCollapsed) {
+            const newSelection = editor.document.getSelection();
+            const range = new Range();
+            range.setStart(newSelection.anchorNode, newSelection.anchorOffset);
+            range.collapse(true);
+            newSelection.removeAllRanges();
+            newSelection.addRange(range);
+        }
     },
     // Table
     insertTable: (editor, { rowNumber = 2, colNumber = 2 } = {}) => {
diff --git a/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js b/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js
index 201bb3a60d95..4e3a1c88cfa9 100644
--- a/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js
+++ b/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js
@@ -883,7 +883,7 @@ export function isItalic(node) {
  * @returns {boolean}
  */
 export function isUnderline(node) {
-    return getComputedStyle(closestElement(node)).textDecoration === 'underline';
+    return getComputedStyle(closestElement(node)).textDecorationLine === 'underline';
 }
 /**
  * Return true if the given node appears struck through.
@@ -892,7 +892,7 @@ export function isUnderline(node) {
  * @returns {boolean}
  */
 export function isStrikeThrough(node) {
-    return getComputedStyle(closestElement(node)).textDecoration === 'line-through';
+    return getComputedStyle(closestElement(node)).textDecorationLine === 'line-through';
 }
 
 export function isUnbreakable(node) {
@@ -1415,6 +1415,7 @@ export function insertText(sel, content) {
     sel.getRangeAt(0).insertNode(txt);
     restore();
     setSelection(...boundariesOut(txt), false);
+    return txt;
 }
 
 /**
@@ -1469,6 +1470,18 @@ export function fillEmpty(el) {
     }
     return fillers;
 }
+/**
+ * Takes a selection (assumed to be collapsed) and insert a zero-width space at
+ * its anchor point. Then, select that zero-width space.
+ *
+ * @param {Selection} selection
+ */
+export function insertAndSelectZws(selection) {
+    const offset = selection.anchorOffset;
+    const zws = insertText(selection, '\u200B');
+    splitTextNode(zws, offset);
+    selection.getRangeAt(0).selectNode(zws);
+}
 /**
  * Removes the given node if invisible and all its invisible ancestors.
  *
diff --git a/addons/web_editor/static/lib/odoo-editor/test/editor-test.js b/addons/web_editor/static/lib/odoo-editor/test/editor-test.js
index e0384918e048..48b1099af6cf 100644
--- a/addons/web_editor/static/lib/odoo-editor/test/editor-test.js
+++ b/addons/web_editor/static/lib/odoo-editor/test/editor-test.js
@@ -1,9 +1,11 @@
 import './spec/utils.test.js';
 import './spec/align.test.js';
+import './spec/color.test.js';
 import './spec/editor.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';
 import './spec/autostep.test.js';
diff --git a/addons/web_editor/static/lib/odoo-editor/test/spec/color.test.js b/addons/web_editor/static/lib/odoo-editor/test/spec/color.test.js
new file mode 100644
index 000000000000..78261c9450ba
--- /dev/null
+++ b/addons/web_editor/static/lib/odoo-editor/test/spec/color.test.js
@@ -0,0 +1,42 @@
+import { BasicEditor, testEditor } from '../utils.js';
+
+const setColor = (color, mode) => {
+    return async editor => {
+        await editor.execCommand('applyColor', color, mode);
+    };
+};
+
+describe('applyColor', () => {
+    it('should apply a color to a slice of text in a span in a font', async () => {
+        await testEditor(BasicEditor, {
+            contentBefore: '<p>a<font>b<span>c[def]g</span>h</font>i</p>',
+            stepFunction: setColor('rgb(255, 0, 0)', 'color'),
+            contentAfter: '<p>a<font>b<span>c</span></font>' +
+                '<font style="color: rgb(255, 0, 0);"><span>[def]</span></font>' +
+                '<font><span>g</span>h</font>i</p>',
+        });
+    });
+    it('should apply a background color to a slice of text in a span in a font', async () => {
+        await testEditor(BasicEditor, {
+            contentBefore: '<p>a<font>b<span>c[def]g</span>h</font>i</p>',
+            stepFunction: setColor('rgb(255, 0, 0)', 'backgroundColor'),
+            contentAfter: '<p>a<font>b<span>c</span></font>' +
+                '<font style="background-color: rgb(255, 0, 0);"><span>[def]</span></font>' +
+                '<font><span>g</span>h</font>i</p>',
+        });
+    });
+    it('should get ready to type with a different color', async () => {
+        await testEditor(BasicEditor, {
+            contentBefore: '<p>ab[]cd</p>',
+            stepFunction: setColor('rgb(255, 0, 0)', 'color'),
+            contentAfter: '<p>ab<font style="color: rgb(255, 0, 0);">[]\u200B</font>cd</p>',
+        });
+    });
+    it('should get ready to type with a different background color', async () => {
+        await testEditor(BasicEditor, {
+            contentBefore: '<p>ab[]cd</p>',
+            stepFunction: setColor('rgb(255, 0, 0)', 'backgroundColor'),
+            contentAfter: '<p>ab<font style="background-color: rgb(255, 0, 0);">[]\u200B</font>cd</p>',
+        });
+    });
+});
diff --git a/addons/web_editor/static/lib/odoo-editor/test/spec/editor.test.js b/addons/web_editor/static/lib/odoo-editor/test/spec/editor.test.js
index 759648c867ef..8222b24987bd 100644
--- a/addons/web_editor/static/lib/odoo-editor/test/spec/editor.test.js
+++ b/addons/web_editor/static/lib/odoo-editor/test/spec/editor.test.js
@@ -3016,235 +3016,6 @@ X[]
         });
     });
 
-    describe('applyInlineStyle', () => {
-        it('should apply style to selection only', async () => {
-            await testEditor(BasicEditor, {
-                contentBefore: '<p>a<span>[b<span>c]d</span>e</span>f</p>',
-                stepFunction: editor => applyInlineStyle(editor, (el) => el.style.color = 'tomato'),
-                contentAfter: '<p>a<span><span style="color: tomato;">[b</span><span><span style="color: tomato;">c]</span>d</span>e</span>f</p>',
-            });
-        });
-    });
-
-    describe('setTagName', () => {
-        describe('to paragraph', () => {
-            it('should turn a heading 1 into a paragraph', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>ab[]cd</h1>',
-                    stepFunction: editor => editor.execCommand('setTag', 'p'),
-                    contentAfter: '<p>ab[]cd</p>',
-                });
-            });
-            it('should turn a heading 1 into a paragraph (character selected)', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>a[b]c</h1>',
-                    stepFunction: editor => editor.execCommand('setTag', 'p'),
-                    contentAfter: '<p>a[b]c</p>',
-                });
-            });
-            it('should turn a heading 1, a paragraph and a heading 2 into three paragraphs', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>a[b</h1><p>cd</p><h2>e]f</h2>',
-                    stepFunction: editor => editor.execCommand('setTag', 'p'),
-                    contentAfter: '<p>a[b</p><p>cd</p><p>e]f</p>',
-                });
-            });
-            it.skip('should turn a heading 1 into a paragraph after a triple click', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>[ab</h1><h2>]cd</h2>',
-                    stepFunction: editor => editor.execCommand('setTag', 'p'),
-                    contentAfter: '<p>[ab</p><h2>]cd</h2>',
-                });
-            });
-            it('should not turn a div into a paragraph', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<div>[ab]</div>',
-                    stepFunction: editor => editor.execCommand('setTag', 'p'),
-                    contentAfter: '<div><p>[ab]</p></div>',
-                });
-            });
-        });
-        describe('to heading 1', () => {
-            it('should turn a paragraph into a heading 1', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<p>ab[]cd</p>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h1'),
-                    contentAfter: '<h1>ab[]cd</h1>',
-                });
-            });
-            it('should turn a paragraph into a heading 1 (character selected)', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<p>a[b]c</p>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h1'),
-                    contentAfter: '<h1>a[b]c</h1>',
-                });
-            });
-            it('should turn a paragraph, a heading 1 and a heading 2 into three headings 1', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<p>a[b</p><h1>cd</h1><h2>e]f</h2>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h1'),
-                    contentAfter: '<h1>a[b</h1><h1>cd</h1><h1>e]f</h1>',
-                });
-            });
-            it.skip('should turn a paragraph into a heading 1 after a triple click', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<p>[ab</p><h2>]cd</h2>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h1'),
-                    contentAfter: '<h1>[ab</h1><h2>]cd</h2>',
-                });
-            });
-            it('should not turn a div into a heading 1', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<div>[ab]</div>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h1'),
-                    contentAfter: '<div><h1>[ab]</h1></div>',
-                });
-            });
-            it('should remove the background image while turning a p>font into a heading 1>span', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<div><p><font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(255, 204, 51) 0%, rgb(226, 51, 255) 100%);">[ab]</font></p></div>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h1'),
-                    contentAfter: '<div><h1><span style="">[ab]</span></h1></div>',
-                });
-            });
-        });
-        describe('to heading 2', () => {
-            it('should turn a heading 1 into a heading 2', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>ab[]cd</h1>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h2'),
-                    contentAfter: '<h2>ab[]cd</h2>',
-                });
-            });
-            it('should turn a heading 1 into a heading 2 (character selected)', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>a[b]c</h1>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h2'),
-                    contentAfter: '<h2>a[b]c</h2>',
-                });
-            });
-            it('should turn a heading 1, a heading 2 and a paragraph into three headings 2', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>a[b</h1><h2>cd</h2><p>e]f</p>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h2'),
-                    contentAfter: '<h2>a[b</h2><h2>cd</h2><h2>e]f</h2>',
-                });
-            });
-            it.skip('should turn a paragraph into a heading 2 after a triple click', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<p>[ab</p><h1>]cd</h1>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h2'),
-                    contentAfter: '<h2>[ab</h2><h1>]cd</h1>',
-                });
-            });
-            it('should not turn a div into a heading 2', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<div>[ab]</div>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h2'),
-                    contentAfter: '<div><h2>[ab]</h2></div>',
-                });
-            });
-        });
-        describe('to heading 3', () => {
-            it('should turn a heading 1 into a heading 3', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>ab[]cd</h1>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h3'),
-                    contentAfter: '<h3>ab[]cd</h3>',
-                });
-            });
-            it('should turn a heading 1 into a heading 3 (character selected)', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>a[b]c</h1>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h3'),
-                    contentAfter: '<h3>a[b]c</h3>',
-                });
-            });
-            it('should turn a heading 1, a paragraph and a heading 2 into three headings 3', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>a[b</h1><p>cd</p><h2>e]f</h2>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h3'),
-                    contentAfter: '<h3>a[b</h3><h3>cd</h3><h3>e]f</h3>',
-                });
-            });
-            it.skip('should turn a paragraph into a heading 3 after a triple click', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<p>[ab</p><h1>]cd</h1>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h3'),
-                    contentAfter: '<h3>[ab</h3><h1>]cd</h1>',
-                });
-            });
-            it('should not turn a div into a heading 3', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<div>[ab]</div>',
-                    stepFunction: editor => editor.execCommand('setTag', 'h3'),
-                    contentAfter: '<div><h3>[ab]</h3></div>',
-                });
-            });
-        });
-        describe('to pre', () => {
-            it('should turn a heading 1 into a pre', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>ab[]cd</h1>',
-                    stepFunction: editor => editor.execCommand('setTag', 'pre'),
-                    contentAfter: '<pre>ab[]cd</pre>',
-                });
-            });
-            it('should turn a heading 1 into a pre (character selected)', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>a[b]c</h1>',
-                    stepFunction: editor => editor.execCommand('setTag', 'pre'),
-                    contentAfter: '<pre>a[b]c</pre>',
-                });
-            });
-            it('should turn a heading 1 a pre and a paragraph into three pres', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>a[b</h1><pre>cd</pre><p>e]f</p>',
-                    stepFunction: editor => editor.execCommand('setTag', 'pre'),
-                    contentAfter: '<pre>a[b</pre><pre>cd</pre><pre>e]f</pre>',
-                });
-            });
-        });
-        describe('to blockquote', () => {
-            it('should turn a blockquote into a paragraph', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>ab[]cd</h1>',
-                    stepFunction: editor => editor.execCommand('setTag', 'blockquote'),
-                    contentAfter: '<blockquote>ab[]cd</blockquote>',
-                });
-            });
-            it('should turn a heading 1 into a blockquote (character selected)', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>a[b]c</h1>',
-                    stepFunction: editor => editor.execCommand('setTag', 'blockquote'),
-                    contentAfter: '<blockquote>a[b]c</blockquote>',
-                });
-            });
-            it('should turn a heading 1, a paragraph and a heading 2 into three blockquote', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>a[b</h1><p>cd</p><h2>e]f</h2>',
-                    stepFunction: editor => editor.execCommand('setTag', 'blockquote'),
-                    contentAfter:
-                        '<blockquote>a[b</blockquote><blockquote>cd</blockquote><blockquote>e]f</blockquote>',
-                });
-            });
-            it.skip('should turn a heading 1 into a blockquote after a triple click', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<h1>[ab</h1><h2>]cd</h2>',
-                    stepFunction: editor => editor.execCommand('setTag', 'blockquote'),
-                    contentAfter: '<blockquote>[ab</blockquote><h2>]cd</h2>',
-                });
-            });
-            it('should not turn a div into a blockquote', async () => {
-                await testEditor(BasicEditor, {
-                    contentBefore: '<div>[ab]</div>',
-                    stepFunction: editor => editor.execCommand('setTag', 'blockquote'),
-                    contentAfter: '<div><blockquote>[ab]</blockquote></div>',
-                });
-            });
-        });
-    });
-
     describe('getTraversedNodes', () => {
         it('should return the anchor node of a collapsed selection', async () => {
             await testEditor(BasicEditor, {
@@ -3439,16 +3210,4 @@ X[]
             });
         });
     });
-
-    describe('applyColor', () => {
-        it('should apply a color to a slice of text in a span in a font', async () => {
-            await testEditor(BasicEditor, {
-                contentBefore: '<p>a<font>b<span>c[def]g</span>h</font>i</p>',
-                stepFunction: editor => editor.execCommand('applyColor', 'rgb(255, 0, 0)', 'color'),
-                contentAfter: '<p>a<font>b<span>c</span></font>' +
-                    '<font style="color: rgb(255, 0, 0);"><span>[def]</span></font>' +
-                    '<font><span>g</span>h</font>i</p>',
-            });
-        });
-    });
 });
diff --git a/addons/web_editor/static/lib/odoo-editor/test/spec/fontSize.test.js b/addons/web_editor/static/lib/odoo-editor/test/spec/fontSize.test.js
index 675f4860c94a..92ecb2122493 100644
--- a/addons/web_editor/static/lib/odoo-editor/test/spec/fontSize.test.js
+++ b/addons/web_editor/static/lib/odoo-editor/test/spec/fontSize.test.js
@@ -22,5 +22,12 @@ describe('FontSize', () => {
                 contentAfter: '<h1><span style="font-size: 36px;">[ab]</span></h1><p>cd</p>',
             });
         });
+        it('should get ready to type with a different font size', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>ab[]cd</p>',
+                stepFunction: setFontSize('36px'),
+                contentAfter: '<p>ab<span style="font-size: 36px;">[]\u200B</span>cd</p>',
+            });
+        });
     });
 });
diff --git a/addons/web_editor/static/lib/odoo-editor/test/spec/format.test.js b/addons/web_editor/static/lib/odoo-editor/test/spec/format.test.js
new file mode 100644
index 000000000000..77e86d31c799
--- /dev/null
+++ b/addons/web_editor/static/lib/odoo-editor/test/spec/format.test.js
@@ -0,0 +1,427 @@
+import { BasicEditor, testEditor } from '../utils.js';
+import { applyInlineStyle } from '../../src/commands/commands.js';
+
+const bold = async editor => {
+    await editor.execCommand('bold');
+};
+const italic = async editor => {
+    await editor.execCommand('italic');
+};
+const underline = async editor => {
+    await editor.execCommand('underline');
+};
+const strikeThrough = async editor => {
+    await editor.execCommand('strikeThrough');
+};
+
+describe('Format', () => {
+    describe('bold', () => {
+        it('should make a few characters bold', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>ab[cde]fg</p>',
+                stepFunction: bold,
+                contentAfter: '<p>ab<span style="font-weight: bolder;">[cde]</span>fg</p>',
+            });
+        });
+        it('should make a few characters not bold', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p><span style="font-weight: bolder;">ab[cde]fg</span></p>',
+                stepFunction: bold,
+                contentAfter: '<p><span style="font-weight: bolder;">ab<span style="font-weight: 400;">[cde]</span>fg</span></p>',
+            });
+        });
+        it('should make a whole heading bold after a triple click', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1><span style="font-weight: normal;">[ab</span></h1><p>]cd</p>',
+                stepFunction: bold,
+                // TODO: ideally should restore regular h1 without span instead.
+                contentAfter: '<h1><span style="font-weight: bolder;">[ab]</span></h1><p>cd</p>',
+            });
+        });
+        it('should make a whole heading not bold after a triple click (heading is considered bold)', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>[ab</h1><p>]cd</p>',
+                stepFunction: bold,
+                contentAfter: '<h1><span style="font-weight: normal;">[ab]</span></h1><p>cd</p>',
+            });
+        });
+        it('should get ready to type in bold', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>ab[]cd</p>',
+                stepFunction: bold,
+                contentAfter: '<p>ab<span style="font-weight: bolder;">[]\u200B</span>cd</p>',
+            });
+        });
+        it('should get ready to type in not bold', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p><span style="font-weight: bolder;">ab[]cd</span></p>',
+                stepFunction: bold,
+                contentAfter: '<p><span style="font-weight: bolder;">ab<span style="font-weight: 400;">[]\u200B</span>cd</span></p>',
+            });
+        });
+    });
+    describe('italic', () => {
+        it('should make a few characters italic', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>ab[cde]fg</p>',
+                stepFunction: italic,
+                contentAfter: '<p>ab<span style="font-style: italic;">[cde]</span>fg</p>',
+            });
+        });
+        it('should make a few characters not italic', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p><span style="font-style: italic;">ab[cde]fg</span></p>',
+                stepFunction: italic,
+                contentAfter: '<p><span style="font-style: italic;">ab<span style="font-style: normal;">[cde]</span>fg</span></p>',
+            });
+        });
+        it('should make a whole heading italic after a triple click', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>[ab</h1><p>]cd</p>',
+                stepFunction: italic,
+                contentAfter: '<h1><span style="font-style: italic;">[ab]</span></h1><p>cd</p>',
+            });
+        });
+        it('should make a whole heading not italic after a triple click', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1><span style="font-style: italic;">[ab</span></h1><p>]cd</p>',
+                stepFunction: italic,
+                // TODO: ideally should restore regular h1 without span instead.
+                contentAfter: '<h1><span style="font-style: normal;">[ab]</span></h1><p>cd</p>',
+            });
+        });
+        it('should get ready to type in italic', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>ab[]cd</p>',
+                stepFunction: italic,
+                contentAfter: '<p>ab<span style="font-style: italic;">[]\u200B</span>cd</p>',
+            });
+        });
+        it('should get ready to type in not italic', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p><span style="font-style: italic;">ab[]cd</span></p>',
+                stepFunction: italic,
+                contentAfter: '<p><span style="font-style: italic;">ab<span style="font-style: normal;">[]\u200B</span>cd</span></p>',
+            });
+        });
+    });
+    describe('underline', () => {
+        it('should make a few characters underline', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>ab[cde]fg</p>',
+                stepFunction: underline,
+                contentAfter: '<p>ab<span style="text-decoration-line: underline;">[cde]</span>fg</p>',
+            });
+        });
+        it('should make a few characters not underline', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p><span style="text-decoration-line: underline;">ab[cde]fg</span></p>',
+                stepFunction: underline,
+                contentAfter: '<p><span style="text-decoration-line: underline;">ab<span style="text-decoration-line: none;">[cde]</span>fg</span></p>',
+            });
+        });
+        it('should make a whole heading underline after a triple click', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>[ab</h1><p>]cd</p>',
+                stepFunction: underline,
+                contentAfter: '<h1><span style="text-decoration-line: underline;">[ab]</span></h1><p>cd</p>',
+            });
+        });
+        it('should make a whole heading not underline after a triple click', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1><span style="text-decoration-line: underline;">[ab</span></h1><p>]cd</p>',
+                stepFunction: underline,
+                // TODO: ideally should restore regular h1 without span instead.
+                contentAfter: '<h1><span style="text-decoration-line: none;">[ab]</span></h1><p>cd</p>',
+            });
+        });
+        it('should get ready to type in underline', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>ab[]cd</p>',
+                stepFunction: underline,
+                contentAfter: '<p>ab<span style="text-decoration-line: underline;">[]\u200B</span>cd</p>',
+            });
+        });
+        it('should get ready to type in not underline', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p><span style="text-decoration-line: underline;">ab[]cd</span></p>',
+                stepFunction: underline,
+                contentAfter: '<p><span style="text-decoration-line: underline;">ab<span style="text-decoration-line: none;">[]\u200B</span>cd</span></p>',
+            });
+        });
+    });
+    describe('strikeThrough', () => {
+        it('should make a few characters strikeThrough', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>ab[cde]fg</p>',
+                stepFunction: strikeThrough,
+                contentAfter: '<p>ab<span style="text-decoration-line: line-through;">[cde]</span>fg</p>',
+            });
+        });
+        it('should make a few characters not strikeThrough', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p><span style="text-decoration-line: line-through;">ab[cde]fg</span></p>',
+                stepFunction: strikeThrough,
+                contentAfter: '<p><span style="text-decoration-line: line-through;">ab<span style="text-decoration-line: none;">[cde]</span>fg</span></p>',
+            });
+        });
+        it('should make a whole heading strikeThrough after a triple click', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>[ab</h1><p>]cd</p>',
+                stepFunction: strikeThrough,
+                contentAfter: '<h1><span style="text-decoration-line: line-through;">[ab]</span></h1><p>cd</p>',
+            });
+        });
+        it('should make a whole heading not strikeThrough after a triple click', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1><span style="text-decoration-line: line-through;">[ab</span></h1><p>]cd</p>',
+                stepFunction: strikeThrough,
+                // TODO: ideally should restore regular h1 without span instead.
+                contentAfter: '<h1><span style="text-decoration-line: none;">[ab]</span></h1><p>cd</p>',
+            });
+        });
+        it('should get ready to type in strikeThrough', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>ab[]cd</p>',
+                stepFunction: strikeThrough,
+                contentAfter: '<p>ab<span style="text-decoration-line: line-through;">[]\u200B</span>cd</p>',
+            });
+        });
+        it('should get ready to type in not strikeThrough', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p><span style="text-decoration-line: line-through;">ab[]cd</span></p>',
+                stepFunction: strikeThrough,
+                contentAfter: '<p><span style="text-decoration-line: line-through;">ab<span style="text-decoration-line: none;">[]\u200B</span>cd</span></p>',
+            });
+        });
+    });
+});
+
+describe('applyInlineStyle', () => {
+    it('should apply style to selection only', async () => {
+        await testEditor(BasicEditor, {
+            contentBefore: '<p>a<span>[b<span>c]d</span>e</span>f</p>',
+            stepFunction: editor => applyInlineStyle(editor, (el) => el.style.color = 'tomato'),
+            contentAfter: '<p>a<span><span style="color: tomato;">[b</span><span><span style="color: tomato;">c]</span>d</span>e</span>f</p>',
+        });
+    });
+});
+
+describe('setTagName', () => {
+    describe('to paragraph', () => {
+        it('should turn a heading 1 into a paragraph', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>ab[]cd</h1>',
+                stepFunction: editor => editor.execCommand('setTag', 'p'),
+                contentAfter: '<p>ab[]cd</p>',
+            });
+        });
+        it('should turn a heading 1 into a paragraph (character selected)', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>a[b]c</h1>',
+                stepFunction: editor => editor.execCommand('setTag', 'p'),
+                contentAfter: '<p>a[b]c</p>',
+            });
+        });
+        it('should turn a heading 1, a paragraph and a heading 2 into three paragraphs', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>a[b</h1><p>cd</p><h2>e]f</h2>',
+                stepFunction: editor => editor.execCommand('setTag', 'p'),
+                contentAfter: '<p>a[b</p><p>cd</p><p>e]f</p>',
+            });
+        });
+        it.skip('should turn a heading 1 into a paragraph after a triple click', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>[ab</h1><h2>]cd</h2>',
+                stepFunction: editor => editor.execCommand('setTag', 'p'),
+                contentAfter: '<p>[ab</p><h2>]cd</h2>',
+            });
+        });
+        it('should not turn a div into a paragraph', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<div>[ab]</div>',
+                stepFunction: editor => editor.execCommand('setTag', 'p'),
+                contentAfter: '<div><p>[ab]</p></div>',
+            });
+        });
+    });
+    describe('to heading 1', () => {
+        it('should turn a paragraph into a heading 1', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>ab[]cd</p>',
+                stepFunction: editor => editor.execCommand('setTag', 'h1'),
+                contentAfter: '<h1>ab[]cd</h1>',
+            });
+        });
+        it('should turn a paragraph into a heading 1 (character selected)', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>a[b]c</p>',
+                stepFunction: editor => editor.execCommand('setTag', 'h1'),
+                contentAfter: '<h1>a[b]c</h1>',
+            });
+        });
+        it('should turn a paragraph, a heading 1 and a heading 2 into three headings 1', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>a[b</p><h1>cd</h1><h2>e]f</h2>',
+                stepFunction: editor => editor.execCommand('setTag', 'h1'),
+                contentAfter: '<h1>a[b</h1><h1>cd</h1><h1>e]f</h1>',
+            });
+        });
+        it.skip('should turn a paragraph into a heading 1 after a triple click', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>[ab</p><h2>]cd</h2>',
+                stepFunction: editor => editor.execCommand('setTag', 'h1'),
+                contentAfter: '<h1>[ab</h1><h2>]cd</h2>',
+            });
+        });
+        it('should not turn a div into a heading 1', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<div>[ab]</div>',
+                stepFunction: editor => editor.execCommand('setTag', 'h1'),
+                contentAfter: '<div><h1>[ab]</h1></div>',
+            });
+        });
+        it('should remove the background image while turning a p>font into a heading 1>span', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<div><p><font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(255, 204, 51) 0%, rgb(226, 51, 255) 100%);">[ab]</font></p></div>',
+                stepFunction: editor => editor.execCommand('setTag', 'h1'),
+                contentAfter: '<div><h1><span style="">[ab]</span></h1></div>',
+            });
+        });
+    });
+    describe('to heading 2', () => {
+        it('should turn a heading 1 into a heading 2', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>ab[]cd</h1>',
+                stepFunction: editor => editor.execCommand('setTag', 'h2'),
+                contentAfter: '<h2>ab[]cd</h2>',
+            });
+        });
+        it('should turn a heading 1 into a heading 2 (character selected)', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>a[b]c</h1>',
+                stepFunction: editor => editor.execCommand('setTag', 'h2'),
+                contentAfter: '<h2>a[b]c</h2>',
+            });
+        });
+        it('should turn a heading 1, a heading 2 and a paragraph into three headings 2', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>a[b</h1><h2>cd</h2><p>e]f</p>',
+                stepFunction: editor => editor.execCommand('setTag', 'h2'),
+                contentAfter: '<h2>a[b</h2><h2>cd</h2><h2>e]f</h2>',
+            });
+        });
+        it.skip('should turn a paragraph into a heading 2 after a triple click', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>[ab</p><h1>]cd</h1>',
+                stepFunction: editor => editor.execCommand('setTag', 'h2'),
+                contentAfter: '<h2>[ab</h2><h1>]cd</h1>',
+            });
+        });
+        it('should not turn a div into a heading 2', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<div>[ab]</div>',
+                stepFunction: editor => editor.execCommand('setTag', 'h2'),
+                contentAfter: '<div><h2>[ab]</h2></div>',
+            });
+        });
+    });
+    describe('to heading 3', () => {
+        it('should turn a heading 1 into a heading 3', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>ab[]cd</h1>',
+                stepFunction: editor => editor.execCommand('setTag', 'h3'),
+                contentAfter: '<h3>ab[]cd</h3>',
+            });
+        });
+        it('should turn a heading 1 into a heading 3 (character selected)', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>a[b]c</h1>',
+                stepFunction: editor => editor.execCommand('setTag', 'h3'),
+                contentAfter: '<h3>a[b]c</h3>',
+            });
+        });
+        it('should turn a heading 1, a paragraph and a heading 2 into three headings 3', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>a[b</h1><p>cd</p><h2>e]f</h2>',
+                stepFunction: editor => editor.execCommand('setTag', 'h3'),
+                contentAfter: '<h3>a[b</h3><h3>cd</h3><h3>e]f</h3>',
+            });
+        });
+        it.skip('should turn a paragraph into a heading 3 after a triple click', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<p>[ab</p><h1>]cd</h1>',
+                stepFunction: editor => editor.execCommand('setTag', 'h3'),
+                contentAfter: '<h3>[ab</h3><h1>]cd</h1>',
+            });
+        });
+        it('should not turn a div into a heading 3', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<div>[ab]</div>',
+                stepFunction: editor => editor.execCommand('setTag', 'h3'),
+                contentAfter: '<div><h3>[ab]</h3></div>',
+            });
+        });
+    });
+    describe('to pre', () => {
+        it('should turn a heading 1 into a pre', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>ab[]cd</h1>',
+                stepFunction: editor => editor.execCommand('setTag', 'pre'),
+                contentAfter: '<pre>ab[]cd</pre>',
+            });
+        });
+        it('should turn a heading 1 into a pre (character selected)', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>a[b]c</h1>',
+                stepFunction: editor => editor.execCommand('setTag', 'pre'),
+                contentAfter: '<pre>a[b]c</pre>',
+            });
+        });
+        it('should turn a heading 1 a pre and a paragraph into three pres', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>a[b</h1><pre>cd</pre><p>e]f</p>',
+                stepFunction: editor => editor.execCommand('setTag', 'pre'),
+                contentAfter: '<pre>a[b</pre><pre>cd</pre><pre>e]f</pre>',
+            });
+        });
+    });
+    describe('to blockquote', () => {
+        it('should turn a blockquote into a paragraph', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>ab[]cd</h1>',
+                stepFunction: editor => editor.execCommand('setTag', 'blockquote'),
+                contentAfter: '<blockquote>ab[]cd</blockquote>',
+            });
+        });
+        it('should turn a heading 1 into a blockquote (character selected)', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>a[b]c</h1>',
+                stepFunction: editor => editor.execCommand('setTag', 'blockquote'),
+                contentAfter: '<blockquote>a[b]c</blockquote>',
+            });
+        });
+        it('should turn a heading 1, a paragraph and a heading 2 into three blockquote', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>a[b</h1><p>cd</p><h2>e]f</h2>',
+                stepFunction: editor => editor.execCommand('setTag', 'blockquote'),
+                contentAfter:
+                    '<blockquote>a[b</blockquote><blockquote>cd</blockquote><blockquote>e]f</blockquote>',
+            });
+        });
+        it.skip('should turn a heading 1 into a blockquote after a triple click', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<h1>[ab</h1><h2>]cd</h2>',
+                stepFunction: editor => editor.execCommand('setTag', 'blockquote'),
+                contentAfter: '<blockquote>[ab</blockquote><h2>]cd</h2>',
+            });
+        });
+        it('should not turn a div into a blockquote', async () => {
+            await testEditor(BasicEditor, {
+                contentBefore: '<div>[ab]</div>',
+                stepFunction: editor => editor.execCommand('setTag', 'blockquote'),
+                contentAfter: '<div><blockquote>[ab]</blockquote></div>',
+            });
+        });
+    });
+});
-- 
GitLab