import { withSequence } from "@html_editor/utils/resource"; import { describe, expect, test } from "@odoo/hoot"; import { click, delay, getActiveElement, keyDown, keyUp, manuallyDispatchProgrammaticEvent, pointerDown, pointerUp, press, queryAll, queryAllTexts, queryOne, waitFor, waitForNone, } from "@odoo/hoot-dom"; import { advanceTime, animationFrame, tick } from "@odoo/hoot-mock"; import { contains, onRpc, patchTranslations, patchWithCleanup, defineModels, fields, models, mountView, } from "@web/../tests/web_test_helpers"; import { fontSizeItems } from "../src/main/font/font_plugin"; import { Plugin } from "../src/plugin"; import { MAIN_PLUGINS } from "../src/plugin_sets"; import { convertNumericToUnit, getCSSVariableValue, getHtmlStyle } from "../src/utils/formatting"; import { setupEditor } from "./_helpers/editor"; import { unformat } from "./_helpers/format"; import { getContent, moveSelectionOutsideEditor, setContent, setSelection, simulateDoubleClickSelect, simulateTripleClickSelect, firstClick, secondClick, thirdClick, } from "./_helpers/selection"; import { strong } from "./_helpers/tags"; import { insertText } from "./_helpers/user_actions"; import { expandToolbar } from "./_helpers/toolbar"; import { nodeSize } from "@html_editor/utils/position"; import { expectElementCount } from "./_helpers/ui_expectations"; import { ToolbarPlugin } from "@html_editor/main/toolbar/toolbar_plugin"; import { ImageCrop } from "@html_editor/main/media/image_crop"; test.tags("desktop"); test("toolbar is only visible when selection is not collapsed in desktop", async () => { const { el } = await setupEditor("
test
"); // set a non-collapsed selection to open toolbar await expectElementCount(".o-we-toolbar", 0); setContent(el, "[test]
"); await expectElementCount(".o-we-toolbar", 1); // set a collapsed selection to close toolbar setContent(el, "test[]
"); await expectElementCount(".o-we-toolbar", 0); }); test.tags("mobile"); test("toolbar is also visible when selection is collapsed in mobile", async () => { const { el } = await setupEditor("test
"); // set a non-collapsed selection to open toolbar await expectElementCount(".o-we-toolbar", 0); setContent(el, "[test]
"); await expectElementCount(".o-we-toolbar", 1); setContent(el, "test[]
"); await animationFrame(); await expectElementCount(".o-we-toolbar", 1); }); test("toolbar closes when selection leaves editor", async () => { const { el } = await setupEditor("test
"); setContent(el, "[test]
"); await waitFor(".o-we-toolbar"); await click(document.body); moveSelectionOutsideEditor(); await expectElementCount(".o-we-toolbar", 0); }); test("toolbar works: can format bold", async () => { const { el } = await setupEditor("test
"); expect(getContent(el)).toBe("test
"); // set selection to open toolbar await expectElementCount(".o-we-toolbar", 0); setContent(el, "[test]
"); await waitFor(".o-we-toolbar"); // click on toggle bold await contains(".btn[name='bold']").click(); expect(getContent(el)).toBe("[test]
"); }); test.tags("iframe"); test("toolbar in an iframe works: can format bold", async () => { const { el } = await setupEditor("test
", { props: { iframe: true } }); expect("iframe").toHaveCount(1); expect(getContent(el)).toBe("test
"); // set selection to open toolbar await expectElementCount(".o-we-toolbar", 0); setContent(el, "[test]
"); await waitFor(".o-we-toolbar"); // click on toggle bold await contains(".btn[name='bold']").click(); expect(getContent(el)).toBe("[test]
"); }); test("toolbar buttons react to selection change", async () => { const { el } = await setupEditor("test some text
"); // set selection to open toolbar setContent(el, "[test] some text
"); await expandToolbar(); // check that bold button is not active expect(".btn[name='bold']").not.toHaveClass("active"); // check that remove format buton isdisabled and have correct title expect(".btn[name='remove_format']").toHaveAttribute("disabled"); expect(".btn[name='remove_format']").toHaveAttribute("title", "Selection has no format"); // click on toggle bold await contains(".btn[name='bold']").click(); await waitFor(".btn[name='bold'].active"); expect(getContent(el)).toBe("[test] some text
"); expect(".btn[name='bold']").toHaveClass("active"); expect(".btn[name='remove_format']").not.toHaveAttribute("disabled"); expect(".btn[name='remove_format']").toHaveAttribute("title", "Remove Format"); // set selection where text is not bold setContent(el, "test some [text]
"); await waitFor(".btn[name='bold']:not(.active)"); expect(".btn[name='bold']").not.toHaveClass("active"); // set selection again where text is bold setContent(el, "[test] some text
"); await waitFor(".btn[name='bold'].active"); expect(".btn[name='bold']").toHaveClass("active"); }); test("toolbar buttons react to selection change (2)", async () => { const { el } = await setupEditor("test [some] some text
"); await waitFor(".o-we-toolbar"); expect(".btn[name='bold']").toHaveClass("active"); // extends selection to include non-bold text setContent(el, "test [some some] text
"); // @todo @phoenix: investigate why waiting for animation frame is (sometimes) not enough await waitFor(".btn[name='bold']:not(.active)"); expect(".btn[name='bold']").not.toHaveClass("active"); // change selection to come back into bold text setContent(el, "test [so]me some text
"); await waitFor(".btn[name='bold'].active"); expect(".btn[name='bold']").toHaveClass("active"); }); test("toolbar list buttons react to selection change", async () => { const { el } = await setupEditor("[abc]
"); expect(".btn[name='bulleted_list']").not.toHaveClass("active"); expect(".btn[name='numbered_list']").not.toHaveClass("active"); expect(".btn[name='checklist']").not.toHaveClass("active"); }); test("toolbar link buttons react to selection change", async () => { const { el } = await setupEditor("th[is is a] link test!
"); await waitFor(".o-we-toolbar"); expect(".btn[name='link']").toHaveCount(1); expect(".btn[name='link']").not.toHaveClass("active"); expect(".btn[name='unlink']").toHaveCount(0); setContent(el, "th[is is a li]nk test!
"); await waitFor(".btn[name='link'].active"); expect(".btn[name='link']").toHaveCount(1); expect(".btn[name='link']").toHaveClass("active"); expect(".btn[name='unlink']").toHaveCount(1); setContent(el, "th[is is a link tes]t!
"); await waitFor(".btn[name='link']:not(.active)"); expect(".btn[name='link']").toHaveCount(1); expect(".btn[name='link']").not.toHaveClass("active"); expect(".btn[name='unlink']").toHaveCount(1); }); test("toolbar unlink button should be disabled when link is unremovable", async () => { await setupEditor('abc[d]e
'); await waitFor(".o-we-toolbar"); expect(".btn[name='link']").toHaveCount(1); expect(".btn[name='link']").toHaveClass("active"); expect(".btn[name='unlink']").toHaveCount(1); expect(".btn[name='unlink']").toHaveClass("disabled"); }); test("toolbar format buttons should react to format change", async () => { await setupEditor( `[abc
def]
`); await waitFor(".o-we-toolbar"); expect(".btn[name='bold']").not.toHaveClass("active"); await contains(".btn[name='bold']").click(); await animationFrame(); expect(".btn[name='bold']").toHaveClass("active"); }); test("toolbar disable link button when selection cross blocks", async () => { await setupEditor("b
[ |
|
] |
[test.com]
`); await waitFor(".o-we-toolbar"); expect(".btn[name='link']").not.toHaveClass("disabled"); }); test("toolbar works: can select font", async () => { const { el } = await setupEditor("test
"); expect(getContent(el)).toBe("test
"); // set selection to open toolbar await expectElementCount(".o-we-toolbar", 0); setContent(el, "[test]
"); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar .btn[name='font']").toHaveText("Paragraph"); await contains(".o-we-toolbar [name='font'] .dropdown-toggle").click(); await contains(".o_font_selector_menu .dropdown-item:contains('Header 2')").click(); expect(getContent(el)).toBe("[test]
"); await waitFor(".o-we-toolbar"); const items = editor.getResource("font_items"); for (const item of items) { await contains(".o-we-toolbar [name='font'] .dropdown-toggle").click(); await animationFrame(); const name = item.name.toString(); let selector = `.o_font_selector_menu .dropdown-item:contains('${name}')`; for (const tempItem of items) { // we need to exclude the font names which have the current name as a substring. if (tempItem === item) { continue; } const tempItemName = tempItem.name.toString(); if (tempItemName.includes(name)) { selector += `:not(:contains(${tempItemName}))`; } } await contains(selector).click(); await animationFrame(); expect(".o-we-toolbar .btn[name='font']").toHaveText(name); } }); test("toolbar works: show the right font name after undo", async () => { const { el } = await setupEditor("[test]
"); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar .btn[name='font']").toHaveText("Paragraph"); await contains(".o-we-toolbar [name='font'] .dropdown-toggle").click(); await contains(".o_font_selector_menu .dropdown-item:contains('Header 2')").click(); expect(getContent(el)).toBe("[test]
"); expect(".o-we-toolbar .btn[name='font']").toHaveText("Paragraph"); await press(["ctrl", "y"]); await animationFrame(); expect(getContent(el)).toBe("test
"); expect(getContent(el)).toBe("test
"); const style = getHtmlStyle(document); const getFontSizeFromVar = (cssVar) => { const strValue = getCSSVariableValue(cssVar, style); const remValue = parseFloat(strValue); const pxValue = convertNumericToUnit(remValue, "rem", "px", style); return Math.round(pxValue); }; // set selection to open toolbar await expectElementCount(".o-we-toolbar", 0); setContent(el, "[test]
"); await waitFor(".o-we-toolbar"); const iframeEl = queryOne(".o-we-toolbar [name='font_size_selector'] iframe"); const inputEl = iframeEl.contentWindow.document?.querySelector("input"); expect(inputEl).toHaveValue(getFontSizeFromVar("body-font-size").toString()); await contains(".o-we-toolbar [name='font_size_selector'].dropdown-toggle").click(); const sizes = [...new Set(fontSizeItems.map((item) => getFontSizeFromVar(item.variableName)))] .sort((a, b) => a - b) .map(String); expect(queryAllTexts(".o_font_size_selector_menu .dropdown-item")).toEqual([...sizes]); const h1Size = getFontSizeFromVar("h1-font-size").toString(); await contains(`.o_font_size_selector_menu .dropdown-item:contains('${h1Size}')`).click(); expect(getContent(el)).toBe(`[test]
`); expect(inputEl).toHaveValue(h1Size); await contains(".o-we-toolbar [name='font_size_selector'].dropdown-toggle").click(); const oSmallSize = getFontSizeFromVar("small-font-size").toString(); await contains(`.o_font_size_selector_menu .dropdown-item:contains('${oSmallSize}')`).click(); expect(getContent(el)).toBe(`[test]
`); expect(inputEl).toHaveValue(oSmallSize); }); test("toolbar works: change font size correctly when closest block element has already font size class", async () => { const { el } = await setupEditor(`[test
]
[test
]
[test]
"); await expandToolbar(); expect("button[name='text_align']").toHaveCount(1); expect("button[name='text_align'] span").toHaveInnerHTML(` `); await click("button[name='text_align']"); await contains(".o-we-toolbar-dropdown .btn.fa-align-center").click(); expect(getContent(el)).toBe(`[test]
`); expect("button[name='text_align'] span").toHaveInnerHTML(` `); await press(["ctrl", "z"]); await animationFrame(); expect(getContent(el)).toBe(`[test]
`); expect("button[name='text_align'] span").toHaveInnerHTML(` `); await press(["ctrl", "y"]); await animationFrame(); expect(getContent(el)).toBe(`[test]
`); expect("button[name='text_align'] span").toHaveInnerHTML(` `); }); test("should focus the editable area after selecting a font size item", async () => { const { editor, el } = await setupEditor("[test]
"); await expectElementCount(".o-we-toolbar", 1); const iframeEl = queryOne(".o-we-toolbar [name='font_size_selector'] iframe"); const inputEl = iframeEl.contentWindow.document?.querySelector("input"); await contains(".o-we-toolbar [name='font_size_selector']").click(); expect(getActiveElement()).toBe(inputEl); await waitFor(".o_font_size_selector_menu .dropdown-item:contains('21')"); await contains(".o_font_size_selector_menu .dropdown-item:contains('21')").click(); expect(getActiveElement()).toBe(editor.editable); expect(getActiveElement()).not.toBe(inputEl); expect(getContent(el)).toBe(`[test]
`); }); test.tags("desktop"); test("toolbar works: display correct font size on select all", async () => { const { el } = await setupEditor("test
"); expect(getContent(el)).toBe("test
"); // set selection to open toolbar await expectElementCount(".o-we-toolbar", 0); setContent(el, "[test]
"); const style = getHtmlStyle(document); const getFontSizeFromVar = (cssVar) => { const strValue = getCSSVariableValue(cssVar, style); const remValue = parseFloat(strValue); const pxValue = convertNumericToUnit(remValue, "rem", "px", style); return Math.round(pxValue); }; await waitFor(".o-we-toolbar"); const iframeEl = queryOne(".o-we-toolbar [name='font_size_selector'] iframe"); const inputEl = iframeEl.contentWindow.document?.querySelector("input"); await contains(".o-we-toolbar [name='font_size_selector'].dropdown-toggle").click(); await animationFrame(); const h1Size = getFontSizeFromVar("h1-font-size").toString(); await contains(`.o_font_size_selector_menu .dropdown-item:contains('${h1Size}')`).click(); expect(getContent(el)).toBe(`[test]
`); setContent(el, `te[]st
`); await waitForNone(".o-we-toolbar"); await press(["ctrl", "a"]); // Select all await waitFor(".o-we-toolbar"); expect(inputEl).toHaveValue(`${h1Size}`); }); test("toolbar works: displays correct font size on input", async () => { const { el } = await setupEditor("[test]
"); await waitFor(".o-we-toolbar"); const iframeEl = queryOne(".o-we-toolbar [name='font_size_selector'] iframe"); expect(iframeEl).toHaveCount(1); const inputEl = iframeEl.contentWindow.document?.querySelector("input"); await contains(inputEl).click(); // Ensure that the input has the default font size value. expect(inputEl).toHaveValue("14"); expect(".o_font_size_selector_menu").toHaveCount(1); // Ensure that the selection is still present in the editable. expect(getContent(el)).toBe(`[test]
`); expect(getActiveElement()).toBe(inputEl); await press("8"); expect(inputEl).toHaveValue("8"); await advanceTime(200); expect(".o_font_size_selector_menu").toHaveCount(1); expect(getContent(el)).toBe(`[test]
`); await expectElementCount(".o-we-toolbar", 1); }); test("toolbar works: font size dropdown closes on Enter and Tab key press", async () => { await setupEditor("[test]
"); await waitFor(".o-we-toolbar"); const iframeEl = queryOne(".o-we-toolbar [name='font_size_selector'] iframe"); expect(iframeEl).toHaveCount(1); const inputEl = iframeEl.contentWindow.document?.querySelector("input"); await contains(inputEl).click(); expect(".o_font_size_selector_menu").toHaveCount(1); await press("Enter"); await animationFrame(); expect(".o_font_size_selector_menu").toHaveCount(0); await contains(inputEl).click(); expect(".o_font_size_selector_menu").toHaveCount(1); await press("Tab"); await animationFrame(); expect(".o_font_size_selector_menu").toHaveCount(0); }); test("toolbar works: ArrowUp/Down moves focus to font size dropdown", async () => { await setupEditor("[test]
"); await waitFor(".o-we-toolbar"); const iframeEl = queryOne(".o-we-toolbar [name='font_size_selector'] iframe"); expect(iframeEl).toHaveCount(1); const inputEl = iframeEl.contentWindow.document?.querySelector("input"); await contains(inputEl).click(); expect(".o_font_size_selector_menu").toHaveCount(1); expect(getActiveElement()).toBe(inputEl); const fontSizeSelectorMenu = queryOne(".o_font_size_selector_menu div"); await press("ArrowDown"); await animationFrame(); expect(".o_font_size_selector_menu").toHaveCount(1); expect(getActiveElement()).toBe(fontSizeSelectorMenu.firstElementChild); await contains(inputEl).click(); expect(".o_font_size_selector_menu").toHaveCount(1); await press("ArrowUp"); await animationFrame(); expect(".o_font_size_selector_menu").toHaveCount(1); expect(getActiveElement()).toBe(fontSizeSelectorMenu.lastElementChild); }); test.tags("desktop"); test("toolbar should not open on keypress tab inside table", async () => { const contentBefore = unformat(`[]ab |
cd |
ab |
cd[] |
[] |
|
|
abcdefghijklmno abcdefghijklmnopqrs abcdefg |
|
to end of last
. setSelection({ anchorNode: firstP.firstChild, anchorOffset: 0, focusNode: lastP.firstChild, focusOffset: nodeSize(lastP.firstChild), }); await animationFrame(); // Get bounding rect of selection range. const range = document.createRange(); range.setStart(lastP.firstChild, 0); range.setEnd(lastP.firstChild, nodeSize(lastP.firstChild)); const rect = range.getBoundingClientRect(); // Simulate mousemove and mouseup events to complete the selection. manuallyDispatchProgrammaticEvent(lastP, "mousemove", { detail: 1, clientX: rect.right, clientY: rect.top, }); manuallyDispatchProgrammaticEvent(lastP, "mousemove", { detail: 1, clientX: rect.right + 5, clientY: rect.top, }); manuallyDispatchProgrammaticEvent(lastP, "mouseup", { detail: 1, clientX: rect.right + 5, clientY: rect.top, }); await animationFrame(); await tick(); expect(firstTd).toHaveClass("o_selected_td"); await expectElementCount(".o-we-toolbar", 1); }); test.tags("desktop"); test("toolbar should close on keypress tab inside table", async () => { const contentBefore = unformat(`
[ab] |
cd |
ab |
cd[] |
| [1 | 3 | |
| 4 | 5] | 6 |
| [1 | 3 | |
| 4 | 5] | 6 |
| 1 | [ |
| 3 | 4] |
| 1 | [ |
| 3 | 4] |
| 1 | [ |
| 3 | 4] |
| 1 | [ |
| 3 | 4] |
test
"); await expectElementCount(".o-we-toolbar", 0); setContent(el, "[test]
"); await expectElementCount(".o-we-toolbar", 1); const selection = document.getSelection(); selection.removeAllRanges(); setContent(el, "abc
"); await expectElementCount(".o-we-toolbar", 0); }); test("toolbar correctly show namespace button group and stop showing when namespace change", async () => { class TestPlugin extends Plugin { static id = "TestPlugin"; resources = { toolbar_namespaces: [ { id: "aNamespace", isApplied: (nodeList) => !!nodeList.find((node) => node.tagName === "DIV"), }, ], user_commands: { id: "test_cmd", run: () => null }, toolbar_groups: withSequence(24, { id: "test_group", namespaces: ["aNamespace"] }), toolbar_items: [ { id: "test_btn", groupId: "test_group", commandId: "test_cmd", description: "Test Button", icon: "fa-square", }, ], }; } const { el } = await setupEditor("abc
[abc]
[Foo]
[test]
"); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveAttribute("data-namespace", "compact"); await expandToolbar(); expect(".o-we-toolbar").toHaveAttribute("data-namespace", "expanded"); }); const patchToUseOnlyTestButtons = () => patchWithCleanup(ToolbarPlugin.prototype, { getResource(resourceName) { const result = super.getResource(resourceName); if (resourceName === "toolbar_groups") { return result.filter((group) => ["expand_toolbar", "test_group"].includes(group.id) ); } return result; }, }); let id = 0; const makeTestButton = (obj) => ({ id: `btn_${id++}`, groupId: "test_group", commandId: "test_cmd", description: "Test Button", icon: "fa-square", ...obj, }); const repeat = (count, fn) => Array.from({ length: count }, fn); test("toolbar should not open in compact mode if expanded toolbar has less than 7 items", async () => { class TestPlugin extends Plugin { static id = "TestPlugin"; resources = { user_commands: { id: "test_cmd", run: () => null }, toolbar_groups: { id: "test_group" }, toolbar_items: [ // 3 buttons in compact and expanded namespaces ...repeat(3, () => makeTestButton({ namespaces: ["compact", "expanded"] })), // 3 buttons in expanded only namespace ...repeat(3, () => makeTestButton({ namespaces: ["expanded"] })), ], }; } patchToUseOnlyTestButtons(); await setupEditor("[test]
", { config: { Plugins: [...MAIN_PLUGINS, TestPlugin] }, }); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveAttribute("data-namespace", "expanded"); }); test("toolbar should open in compact mode if expanded toolbar is big enough (>= 7 items)", async () => { class TestPlugin extends Plugin { static id = "TestPlugin"; resources = { user_commands: { id: "test_cmd", run: () => null }, toolbar_groups: { id: "test_group" }, toolbar_items: [ // 3 buttons in compact and expanded namespaces ...repeat(3, () => makeTestButton({ namespaces: ["compact", "expanded"] })), // 4 buttons in expanded only namespace ...repeat(4, () => makeTestButton({ namespaces: ["expanded"] })), ], }; } patchToUseOnlyTestButtons(); await setupEditor("[test]
", { config: { Plugins: [...MAIN_PLUGINS, TestPlugin] }, }); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveAttribute("data-namespace", "compact"); }); test("toolbar should not open in compact mode if expanded toolbar has only one extra item", async () => { class TestPlugin extends Plugin { static id = "TestPlugin"; resources = { user_commands: { id: "test_cmd", run: () => null }, toolbar_groups: { id: "test_group" }, toolbar_items: [ // 10 buttons in compact and expanded namespaces ...repeat(10, () => makeTestButton({ namespaces: ["compact", "expanded"] })), // 1 button in expanded only namespace makeTestButton({ namespaces: ["expanded"] }), ], }; } patchToUseOnlyTestButtons(); await setupEditor("[test]
", { config: { Plugins: [...MAIN_PLUGINS, TestPlugin] }, }); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveAttribute("data-namespace", "expanded"); }); test("toolbar should open in compact mode if expanded toolbar has more than one extra item", async () => { class TestPlugin extends Plugin { static id = "TestPlugin"; resources = { user_commands: { id: "test_cmd", run: () => null }, toolbar_groups: { id: "test_group" }, toolbar_items: [ // 10 buttons in compact and expanded namespaces ...repeat(10, () => makeTestButton({ namespaces: ["compact", "expanded"] })), // 2 buttons in expanded only namespace makeTestButton({ namespaces: ["expanded"] }), makeTestButton({ namespaces: ["expanded"] }), ], }; } patchToUseOnlyTestButtons(); await setupEditor("[test]
", { config: { Plugins: [...MAIN_PLUGINS, TestPlugin] }, }); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveAttribute("data-namespace", "compact"); await expandToolbar(); expect(".o-we-toolbar").toHaveAttribute("data-namespace", "expanded"); }); }); test.tags("desktop"); test("expanded toolbar reopens in 'compact' namespace by default after closing", async () => { const { el } = await setupEditor("[test]
"); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveAttribute("data-namespace", "compact"); await expandToolbar(); expect(".o-we-toolbar").toHaveAttribute("data-namespace", "expanded"); // Collapse selection setContent(el, "test[]
"); await waitForNone(".o-we-toolbar"); // Reopen toolbar setContent(el, "[test]
"); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveAttribute("data-namespace", "compact"); }); test("toolbar items without namespace default to 'expanded'", async () => { class TestPlugin extends Plugin { static id = "TestPlugin"; resources = { user_commands: { id: "test_cmd", run: () => null }, toolbar_groups: { id: "test_group" }, toolbar_items: [ { id: "test_btn", groupId: "test_group", commandId: "test_cmd", description: "Test Button", icon: "fa-square", }, ], }; } await setupEditor("[test]
", { config: { Plugins: [...MAIN_PLUGINS, TestPlugin] }, }); await waitFor(".o-we-toolbar"); // Test button in not present in compact toolbar expect(".o-we-toolbar .btn[name='test_btn']").toHaveCount(0); await expandToolbar(); // Test button is present in expanded toolbar by default expect(".o-we-toolbar .btn[name='test_btn']").toHaveCount(1); }); test("toolbar should open with image namespace the selection spans an image and whitespace", async () => { const { el } = await setupEditor(`[abc]
`); // Make sure we start with a compact toolbar so we know that at the end when // we don't anymore it did in fact change and we're not just lagging behind // the DOM. await animationFrame(); await expectElementCount(".o-we-toolbar", 1); expect(queryOne(".o-we-toolbar").dataset.namespace).toBe("compact"); expect(queryAll(".o-we-toolbar .btn-group[name='font']").length).toBe(1); expect(queryAll(".o-we-toolbar .btn-group[name='decoration']").length).toBe(1); setContent( el, `[
]
[Foo]
[test]
"); await waitFor(".o-we-toolbar"); const buttonGroups = queryAll(".o-we-toolbar .btn-group"); for (const group of buttonGroups) { for (let i = 0; i < group.children.length; i++) { const button = group.children[i]; const computedStyle = getComputedStyle(button); const borderRadius = Object.fromEntries( ["top-left", "top-right", "bottom-left", "bottom-right"].map((corner) => [ corner, Number.parseInt(computedStyle[`border-${corner}-radius`]), ]) ); // Should have rounded corners on the left only if first button if (i === 0) { expect(borderRadius["top-left"]).toBeGreaterThan(0); expect(borderRadius["bottom-left"]).toBeGreaterThan(0); } else { expect(borderRadius["top-left"]).toBe(0); expect(borderRadius["bottom-left"]).toBe(0); } // Should have rounded corners on the right only if last button if (i === group.children.length - 1) { expect(borderRadius["top-right"]).toBeGreaterThan(0); expect(borderRadius["bottom-right"]).toBeGreaterThan(0); } else { expect(borderRadius["top-right"]).toBe(0); expect(borderRadius["bottom-right"]).toBe(0); } } } }); test("toolbar buttons should have title attribute", async () => { await setupEditor("[abc]
"); // Check that every registered button has the result of the call to _t postPatchPlugins .get("toolbar") .getButtons() .forEach((item) => { // item.label could be a LazyTranslatedString so we ensure it is a string with toString() expect(itemDescriptionString(item)).toBe("Translated"); }); await waitFor(".o-we-toolbar"); // Check that every button has a title attribute with the translated description for (const button of queryAll(".o-we-toolbar button")) { expect(button).toHaveAttribute("title", "Translated"); } }); test.tags("desktop"); test("keep the toolbar if the selection crosses two blocks, even if their contents aren't selected", async () => { const { el } = await setupEditor("a
b
"); await expectElementCount(".o-we-toolbar", 0); setContent(el, "[a
]b
"); await tick(); // selectionChange await animationFrame(); await expectElementCount(".o-we-toolbar", 1); // This selection is possible when you double-click at the end of a line. setContent(el, "a[
]b
"); await tick(); // selectionChange await animationFrame(); await expectElementCount(".o-we-toolbar", 1); }); test.tags("desktop"); test("keep the toolbar if the selection crosses two blocks, even if their contents aren't selected (ignore whitespace)", async () => { const { el } = await setupEditor("a
\nb
"); await expectElementCount(".o-we-toolbar", 0); setContent(el, "[a
\n]b
"); await tick(); // selectionChange await animationFrame(); await expectElementCount(".o-we-toolbar", 1); // This selection is possible when you double-click at the end of a line. setContent(el, "a[
\n]b
"); await tick(); // selectionChange await animationFrame(); await expectElementCount(".o-we-toolbar", 1); }); test.tags("desktop"); test("toolbar should close on open link popover", async () => { await setupEditor("[a]
"); await expectElementCount(".o-we-toolbar", 1); await click(".o-we-toolbar .fa-link"); await expectElementCount(".o-we-toolbar", 0); }); test.tags("desktop", "iframe"); test("toolbar should close on open link popover (iframe)", async () => { await setupEditor("[a]
", { props: { iframe: true } }); await expectElementCount(".o-we-toolbar", 1); await click(".o-we-toolbar .fa-link"); await expectElementCount(".o-we-toolbar", 0); }); test.tags("desktop"); test("toolbar should close on edit link from preview", async () => { await setupEditor(``); await expectElementCount(".o-we-toolbar", 1); await click(".o-we-toolbar .fa-link"); await waitFor(".o-we-linkpopover"); await click(".o_we_edit_link"); await expectElementCount(".o-we-toolbar", 0); }); test.tags("desktop"); test("close the toolbar if the selection contains any nodes (traverseNode = [], ignore zws)", async () => { const { el } = await setupEditor(`ab${strong("\u200B", "first")}cd
`); await expectElementCount(".o-we-toolbar", 0); setContent(el, `a[b${strong("\u200B", "first")}c]d
`); await tick(); // selectionChange await animationFrame(); await expectElementCount(".o-we-toolbar", 1); setContent(el, `ab${strong("[\u200B]", "first")}cd
`); await tick(); // selectionChange await animationFrame(); await expectElementCount(".o-we-toolbar", 0); }); test.tags("desktop"); test("should not close image cropper while loading media", async () => { const base64Image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="; // This promise is needed to ensure that the `show` method has completed // before clicking on `Discard` button as it sets `isCropperActive` true // at the end. In `closeCropper` method `isCropperActive` must be true // to close the cropper. const cropperReadyPromise = new Promise((resolve) => { patchWithCleanup(ImageCrop.prototype, { async show(...args) { await super.show(...args); resolve(); }, }); }); // Mock backend image RPCs onRpc("/html_editor/get_image_info", async () => { await delay(50); return { original: { image_src: base64Image }, }; }); // Setup editor with an image await setupEditor(`[]
[test]
test[]
[a]
", { props: { iframe: true } }); await expectElementCount(".o-we-toolbar", 1); // click outside the iframe await click(".o-main-components-container"); await expectElementCount(".o-we-toolbar", 0); }); describe.tags("desktop"); describe("toolbar open and close on user interaction", () => { describe("mouse", () => { test("toolbar should not open while mousedown (only after mouseup)", async () => { const { el } = await setupEditor("test
"); await expectElementCount(".o-we-toolbar", 0); await pointerDown(el); //[]test
setSelection({ anchorNode: el.children[0], anchorOffset: 0 }); await tick(); // selectionChange // Simulate extending the selection with mousedown //[test]
setSelection({ anchorNode: el.children[0], anchorOffset: 0, focusOffset: 1 }); await tick(); // selectionChange await animationFrame(); await expectElementCount(".o-we-toolbar", 0); await pointerUp(el); await expectElementCount(".o-we-toolbar", 1); }); test("toolbar should open on mouseup after selecting text (even if mouseup happens outside the editable)", async () => { const { el } = await setupEditor("test
"); await expectElementCount(".o-we-toolbar", 0); await pointerDown(el); //[]test
setSelection({ anchorNode: el.children[0], anchorOffset: 0 }); await tick(); // selectionChange // Simulate extending the selection with mousedown //[test]
setSelection({ anchorNode: el.children[0], anchorOffset: 0, focusOffset: 1 }); await tick(); // selectionChange await animationFrame(); await expectElementCount(".o-we-toolbar", 0); await pointerUp(el.ownerDocument); await expectElementCount(".o-we-toolbar", 1); }); test("toolbar should close on mousedown", async () => { const { el } = await setupEditor("[test]
text
"); await waitFor(".o-we-toolbar"); await pointerDown(el); //test
[]text
setSelection({ anchorNode: el.children[1], anchorOffset: 0 }); await tick(); // selectionChange await expectElementCount(".o-we-toolbar", 0); await pointerUp(el); await tick(); expect(getContent(el)).toBe("test
[]text
"); await animationFrame(); await expectElementCount(".o-we-toolbar", 0); }); test("toolbar should close on mousedown (2)", async () => { const { el } = await setupEditor("[test]
"); /** @todo fix warnings */ patchWithCleanup(console, { warn: () => {} }); await waitFor(".o-we-toolbar"); // Mousedown on the selected text: it does not change the selection until mouseup await pointerDown(el); await tick(); await expectElementCount(".o-we-toolbar", 0); await pointerUp(el); setContent(el, "[]test
"); await tick(); await animationFrame(); await expectElementCount(".o-we-toolbar", 0); }); test("toolbar should open on double click", async () => { const { el } = await setupEditor("test
"); const p = el.firstElementChild; await simulateDoubleClickSelect(p); expect(getContent(el)).toBe("[test]
"); // toolbar open after double click is debounced await advanceTime(500); await expectElementCount(".o-we-toolbar", 1); }); test("toolbar should open on triple click", async () => { const { el } = await setupEditor("test text
"); const p = el.firstElementChild; await simulateTripleClickSelect(p); expect(getContent(el)).toBe("[test text]
"); // toolbar open after triple click is debounced await advanceTime(500); await expectElementCount(".o-we-toolbar", 1); }); test("toolbar should not open between double and triple click", async () => { const { el } = await setupEditor("test text
"); const p = el.firstElementChild; // Double click await firstClick(p); await secondClick(p); expect(getContent(el)).toBe("[test] text
"); await advanceTime(100); // Toolbar is not open yet, waiting for a possible third click await expectElementCount(".o-we-toolbar", 0); // Third click await thirdClick(p); expect(getContent(el)).toBe("[test text]
"); await advanceTime(500); await expectElementCount(".o-we-toolbar", 1); }); test("toolbar should not open after triple click while mouse is down", async () => { const { el } = await setupEditor("test text
"); const p = el.firstElementChild; await simulateDoubleClickSelect(p); await pointerDown(p); manuallyDispatchProgrammaticEvent(p, "mousedown", { detail: 3 }); setSelection({ anchorNode: p, anchorOffset: 0, focusOffset: 1 }); await tick(); // selectionChange expect(getContent(el)).toBe("[test text]
"); await advanceTime(500); // Toolbar is not open yet, waiting for mouseup await expectElementCount(".o-we-toolbar", 0); // Mouse up manuallyDispatchProgrammaticEvent(p, "mouseup", { detail: 3 }); manuallyDispatchProgrammaticEvent(p, "click", { detail: 3 }); await advanceTime(500); await expectElementCount(".o-we-toolbar", 1); }); test("toolbar should not move on click toolbar button", async () => { const { el } = await setupEditor( `aaaaaaaaaaaaa [test] bbbbbbbbbbbbb
` ); await animationFrame(); await expectElementCount(".o-we-toolbar", 1); const overlay = queryOne(".o-we-toolbar").parentElement; const position = { top: overlay.style.top, left: overlay.style.left, }; await contains(".o-we-toolbar button[name='bold']").click(); expect(getContent(el)).toBe( `aaaaaaaaaaaaa [test] bbbbbbbbbbbbb
` ); expect({ top: overlay.style.top, left: overlay.style.left }).toEqual(position); expect(overlay.style.visibility).toBe("visible"); }); }); describe("keyboard", () => { test("toolbar should not open on keydown Arrow (only after keyup)", async () => { const { el } = await setupEditor("[]test
"); await expectElementCount(".o-we-toolbar", 0); await keyDown(["Shift", "ArrowRight"]); setContent(el, "[t]est
"); await tick(); // selectionChange await animationFrame(); await expectElementCount(".o-we-toolbar", 0); await keyUp(["Shift", "ArrowRight"]); await advanceTime(500); // Toolbar open on keyup is debounced await expectElementCount(".o-we-toolbar", 1); }); test("toolbar should close on keydown Arrow", async () => { const { el } = await setupEditor("[tes]t
"); await waitFor(".o-we-toolbar"); // Toolbar should close on keydown await keyDown(["Shift", "ArrowRight"]); setContent(el, "[test]
"); await tick(); // selectionChange await waitForNone(".o-we-toolbar"); await expectElementCount(".o-we-toolbar", 0); // Toolbar should open after keyup await keyUp(["Shift", "ArrowRight"]); await advanceTime(500); // toolbar open on keyup is debounced await expectElementCount(".o-we-toolbar", 1); }); test("toolbar should not close on keydown shift or control", async () => { await setupEditor("[tes]t
"); await waitFor(".o-we-toolbar"); // Toolbar should not close on keydown shift await keyDown(["Shift"]); await tick(); await expectElementCount(".o-we-toolbar", 1); await keyUp(["Shift"]); await tick(); await expectElementCount(".o-we-toolbar", 1); // Toolbar should not close on keydown ctrl await keyDown(["Control"]); await tick(); await expectElementCount(".o-we-toolbar", 1); await keyUp(["Control"]); await tick(); await expectElementCount(".o-we-toolbar", 1); }); test("toolbar should not open between keystrokes separated by a short interval", async () => { const { el } = await setupEditor("[]test
"); await expectElementCount(".o-we-toolbar", 0); // Keystroke # 1 await keyDown(["Shift", "ArrowRight"]); setContent(el, "[t]est
"); await tick(); // selectionChange await keyUp(["Shift", "ArrowRight"]); await advanceTime(100); await expectElementCount(".o-we-toolbar", 0); // Keystroke # 2 await keyDown(["Shift", "ArrowRight"]); setContent(el, "[te]st
"); await tick(); // selectionChange await keyUp(["Shift", "ArrowRight"]); await advanceTime(100); await expectElementCount(".o-we-toolbar", 0); // Toolbar opens some time after the last keyup await advanceTime(500); await expectElementCount(".o-we-toolbar", 1); }); test("toolbar should not open with a collapsed selection inside a contenteditable=false", async () => { await setupEditor(`[]test
[test]
test
"); setContent(el, "test[]
"); await expectElementCount(".o-we-toolbar", 1); // Check that the history buttons are present and disabled expect(".btn[name='undo']").toHaveClass("disabled"); expect(".btn[name='redo']").toHaveClass("disabled"); // Make changes await insertText(editor, "X"); expect(getContent(el)).toBe("testX[]
"); // Undo becomes available await waitFor(".btn[name='undo']:not(.disabled)"); expect(".btn[name='undo']").not.toHaveClass("disabled"); expect(".btn[name='redo']").toHaveClass("disabled"); // Click on undo click(".btn[name='undo']"); await animationFrame(); expect(getContent(el)).toBe("test[]
"); // Redo becomes available, and undo disabled await waitFor(".btn[name='redo']:not(.disabled)"); expect(".btn[name='undo']").toHaveClass("disabled"); expect(".btn[name='redo']").not.toHaveClass("disabled"); // Click on redo click(".btn[name='redo']"); await animationFrame(); expect(getContent(el)).toBe("testX[]
"); // Same state as before (can undo, cannot redo) await waitFor(".btn[name='undo']:not(.disabled)"); expect(".btn[name='undo']").not.toHaveClass("disabled"); expect(".btn[name='redo']").toHaveClass("disabled"); }); }); test("toolbar update should be run only once", async () => { let counter = 0; patchWithCleanup(ToolbarPlugin.prototype, { _updateToolbar(...args) { super._updateToolbar(...args); counter++; }, }); const { el } = await setupEditor("[test]
"); await waitFor(".o-we-toolbar"); counter = 0; click(".o-we-toolbar .btn[name='bold']"); await animationFrame(); expect(getContent(el)).toBe("[test]
"); expect(counter).toBe(1); }); test("toolbar strikethrough buttons should not be active when checked list is strikethrough using o_checked class", async () => { const { el } = await setupEditor( 'text
".repeat(50) }]; } defineModels([Test]); await mountView({ type: "form", resId: 1, resModel: "test", arch: ` `, }); const top = (rangeOrElement) => rangeOrElement.getBoundingClientRect().top; const bottom = (elementOrRange) => elementOrRange.getBoundingClientRect().bottom; const scrollableElement = queryOne(".o_content"); const editable = queryOne(".odoo-editor-editable"); // Select a paragraph in the middle of the text const fifthParagraph = editable.children[5]; setSelection({ anchorNode: fifthParagraph, anchorOffset: 0, focusNode: fifthParagraph, focusOffset: 1, }); const range = document.getSelection().getRangeAt(0); await expandToolbar(); const toolbar = queryOne(".o-we-toolbar"); // Toolbar should be above the selection expect(bottom(toolbar)).toBeLessThan(top(range)); // Color selector await contains(".o-we-toolbar .o-select-color-foreground").click(); await expectElementCount(".o_font_color_selector", 1); const colorSelector = queryOne(".o_font_color_selector"); // Scroll down to bring the toolbar close to the top let scrollStep = top(toolbar) - top(scrollableElement); scrollableElement.scrollTop += scrollStep; await animationFrame(); // Toolbar should be below the selection expect(top(toolbar)).toBeGreaterThan(bottom(range)); // Scroll down to make the toolbar overflow the scroll container scrollStep = top(toolbar) - top(scrollableElement); scrollableElement.scrollTop += scrollStep; await animationFrame(); await advanceTime(200); // Toolbar should be invisible expect(toolbar).not.toBeVisible(); // Color selector should be invisible expect(colorSelector).not.toBeVisible(); // Scroll up to make toolbar visible scrollableElement.scrollTop = 0; await animationFrame(); await advanceTime(200); expect(toolbar).toBeVisible(); // Color selector should be visible along with toolbar expect(colorSelector).toBeVisible(); // Font selector await contains(".o-we-toolbar [name='font'] .dropdown-toggle").click(); await expectElementCount(".o_font_selector_menu", 1); const fontSelector = queryOne(".o_font_selector_menu"); // Scroll down to make the toolbar overflow the scroll container scrollStep = top(toolbar) - top(scrollableElement); scrollableElement.scrollTop += scrollStep; await animationFrame(); // Toolbar should be invisible expect(toolbar).not.toBeVisible(); // Font selector should be invisible expect(fontSelector).not.toBeVisible(); // Scroll up to make toolbar visible scrollableElement.scrollTop -= scrollStep; await animationFrame(); expect(toolbar).toBeVisible(); // Font selector should be visible expect(fontSelector).toBeVisible(); }); test.tags("desktop"); test("toolbar should not be displayed when only invisible nodes are selected", async () => { const { el } = await setupEditor( `[abc]
abc