import { describe, expect, test } from "@odoo/hoot"; import { click, keyDown, keyUp, manuallyDispatchProgrammaticEvent, pointerDown, pointerUp, press, queryAll, queryAllTexts, waitFor, waitForNone, waitUntil, } from "@odoo/hoot-dom"; import { advanceTime, animationFrame, tick } from "@odoo/hoot-mock"; import { contains, patchTranslations, patchWithCleanup } from "@web/../tests/web_test_helpers"; import { fontSizeItems, fontItems } 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, } from "./_helpers/selection"; import { withSequence } from "@html_editor/utils/resource"; import { strong } from "./_helpers/tags"; 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 expect(".o-we-toolbar").toHaveCount(0); setContent(el, "[test]
"); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveCount(1); // set a collapsed selection to close toolbar setContent(el, "test[]
"); await waitUntil(() => !document.querySelector(".o-we-toolbar")); expect(".o-we-toolbar").toHaveCount(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 expect(".o-we-toolbar").toHaveCount(0); setContent(el, "[test]
"); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveCount(1); setContent(el, "test[]
"); await animationFrame(); expect(".o-we-toolbar").toHaveCount(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 waitForNone(".o-we-toolbar"); expect(".o-we-toolbar").toHaveCount(0); }); test("toolbar works: can format bold", async () => { const { el } = await setupEditor("test
"); expect(getContent(el)).toBe("test
"); // set selection to open toolbar expect(".o-we-toolbar").toHaveCount(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 expect(".o-we-toolbar").toHaveCount(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 waitFor(".o-we-toolbar"); // check that bold button is not active expect(".btn[name='bold']").not.toHaveClass("active"); // click on toggle bold await contains(".btn[name='bold']").click(); expect(getContent(el)).toBe("[test] some text
"); expect(".btn[name='bold']").toHaveClass("active"); // 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 works: can select font", async () => { const { el } = await setupEditor("test
"); expect(getContent(el)).toBe("test
"); // set selection to open toolbar expect(".o-we-toolbar").toHaveCount(0); setContent(el, "[test]
"); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar [name='font']").toHaveText("Normal"); 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"); for (const item of fontItems) { 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 fontItems) { // 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 [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 [name='font']").toHaveText("Normal"); 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 [name='font']").toHaveText("Normal"); 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 expect(".o-we-toolbar").toHaveCount(0); setContent(el, "[test]
"); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar [name='font-size']").toHaveText( getFontSizeFromVar("body-font-size").toString() ); await contains(".o-we-toolbar [name='font-size'] .dropdown-toggle").click(); const sizes = new Set( fontSizeItems.map((item) => { return getFontSizeFromVar(item.variableName).toString(); }) ); expect(queryAllTexts(".o_font_selector_menu .dropdown-item")).toEqual([...sizes]); const h1Size = getFontSizeFromVar("h1-font-size").toString(); await contains(`.o_font_selector_menu .dropdown-item:contains('${h1Size}')`).click(); expect(getContent(el)).toBe(`[test]
`); expect(".o-we-toolbar [name='font-size']").toHaveText(h1Size); await contains(".o-we-toolbar [name='font-size'] .dropdown-toggle").click(); const oSmallSize = getFontSizeFromVar("small-font-size").toString(); await contains(`.o_font_selector_menu .dropdown-item:contains('${oSmallSize}')`).click(); expect(getContent(el)).toBe(`[test]
`); expect(".o-we-toolbar [name='font-size']").toHaveText(oSmallSize); }); 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 expect(".o-we-toolbar").toHaveCount(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"); await contains(".o-we-toolbar [name='font-size'] .dropdown-toggle").click(); await animationFrame(); const h1Size = getFontSizeFromVar("h1-font-size").toString(); await contains(`.o_font_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(".o-we-toolbar [name='font-size']").toHaveText(`${h1Size}`); }); test.tags("desktop"); test("toolbar should not open on keypress tab inside table", async () => { const contentBefore = unformat(`[]ab |
cd |
ab |
cd[] |
[] |
|
[ab] |
cd |
ab |
cd[] |
test
"); expect(".o-we-toolbar").toHaveCount(0); setContent(el, "[test]
"); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveCount(1); const selection = document.getSelection(); selection.removeAllRanges(); setContent(el, "abc
"); await waitUntil(() => !document.querySelector(".o-we-toolbar")); expect(".o-we-toolbar").toHaveCount(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) => { return !!nodeList.find((node) => node.tagName === "DIV"); }, }, ], user_commands: { id: "test_cmd", run: () => null }, toolbar_groups: withSequence(24, { id: "test_group", namespace: "aNamespace" }), toolbar_items: [ { id: "test_btn", groupId: "test_group", commandId: "test_cmd", title: "Test Button", icon: "fa-square", }, ], }; } const { el } = await setupEditor("abc
[abc]
[Foo]
[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(item.title.toString()).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("close the toolbar if the selection contains any nodes (traverseNode = [])", async () => { const { el } = await setupEditor("a
b
"); expect(".o-we-toolbar").toHaveCount(0); setContent(el, "[a
]b
"); await tick(); // selectionChange await animationFrame(); expect(".o-we-toolbar").toHaveCount(1); // This selection is possible when you double-click at the end of a line. setContent(el, "a[
]b
"); await tick(); // selectionChange await animationFrame(); expect(".o-we-toolbar").toHaveCount(0); }); test.tags("desktop"); test("close the toolbar if the selection contains any nodes (traverseNode = [], ignore whitespace)", async () => { const { el } = await setupEditor("a
\nb
"); expect(".o-we-toolbar").toHaveCount(0); setContent(el, "[a
\n]b
"); await tick(); // selectionChange await animationFrame(); expect(".o-we-toolbar").toHaveCount(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(); expect(".o-we-toolbar").toHaveCount(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
`); expect(".o-we-toolbar").toHaveCount(0); setContent(el, `a[b${strong("\u200B", "first")}c]d
`); await tick(); // selectionChange await animationFrame(); expect(".o-we-toolbar").toHaveCount(1); setContent(el, `ab${strong("[\u200B]", "first")}cd
`); await tick(); // selectionChange await animationFrame(); expect(".o-we-toolbar").toHaveCount(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
"); expect(".o-we-toolbar").toHaveCount(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(); expect(".o-we-toolbar").toHaveCount(0); await pointerUp(el); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveCount(1); }); test("toolbar should open on mouseup after selecting text (even if mouseup happens outside the editable)", async () => { const { el } = await setupEditor("test
"); expect(".o-we-toolbar").toHaveCount(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(); expect(".o-we-toolbar").toHaveCount(0); await pointerUp(el.ownerDocument); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveCount(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 waitForNone(".o-we-toolbar"); expect(".o-we-toolbar").toHaveCount(0); await pointerUp(el); await tick(); expect(getContent(el)).toBe("test
[]text
"); await animationFrame(); expect(".o-we-toolbar").toHaveCount(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 waitForNone(".o-we-toolbar"); expect(".o-we-toolbar").toHaveCount(0); await pointerUp(el); setContent(el, "[]test
"); await tick(); await animationFrame(); expect(".o-we-toolbar").toHaveCount(0); }); const firstClick = async (target) => { manuallyDispatchProgrammaticEvent(target, "mousedown", { detail: 1 }); setSelection({ anchorNode: target, anchorOffset: 0 }); await tick(); // selectionChange manuallyDispatchProgrammaticEvent(target, "mouseup", { detail: 1 }); manuallyDispatchProgrammaticEvent(target, "click", { detail: 1 }); await tick(); }; const secondClick = async (target) => { manuallyDispatchProgrammaticEvent(target, "mousedown", { detail: 2 }); const document = target.ownerDocument; document.getSelection().modify("extend", "forward", "word"); await tick(); // selectionChange manuallyDispatchProgrammaticEvent(target, "mouseup", { detail: 2 }); manuallyDispatchProgrammaticEvent(target, "click", { detail: 2 }); await tick(); }; const thirdClick = async (target) => { manuallyDispatchProgrammaticEvent(target, "mousedown", { detail: 3 }); const document = target.ownerDocument; document.getSelection().modify("extend", "forward", "paragraphboundary"); await tick(); // selectionChange manuallyDispatchProgrammaticEvent(target, "mouseup", { detail: 3 }); manuallyDispatchProgrammaticEvent(target, "click", { detail: 3 }); await tick(); }; test("toolbar should open on double click", async () => { const { el } = await setupEditor("test
"); const p = el.firstElementChild; // Double click await firstClick(p); await secondClick(p); expect(getContent(el)).toBe("[test]
"); // toolbar open after double click is debounced await advanceTime(500); expect(".o-we-toolbar").toHaveCount(1); }); test("toolbar should open on triple click", async () => { const { el } = await setupEditor("test text
"); const p = el.firstElementChild; // Triple click await firstClick(p); await secondClick(p); await thirdClick(p); expect(getContent(el)).toBe("[test text]
"); // toolbar open after triple click is debounced await advanceTime(500); expect(".o-we-toolbar").toHaveCount(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 expect(".o-we-toolbar").toHaveCount(0); // Third click await thirdClick(p); expect(getContent(el)).toBe("[test text]
"); await advanceTime(500); expect(".o-we-toolbar").toHaveCount(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 firstClick(p); await secondClick(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 expect(".o-we-toolbar").toHaveCount(0); // Mouse up manuallyDispatchProgrammaticEvent(p, "mouseup", { detail: 3 }); manuallyDispatchProgrammaticEvent(p, "click", { detail: 3 }); await advanceTime(500); expect(".o-we-toolbar").toHaveCount(1); }); }); describe("keyboard", () => { test("toolbar should not open on keydown Arrow (only after keyup)", async () => { const { el } = await setupEditor("[]test
"); expect(".o-we-toolbar").toHaveCount(0); await keyDown(["Shift", "ArrowRight"]); setContent(el, "[t]est
"); await tick(); // selectionChange await animationFrame(); expect(".o-we-toolbar").toHaveCount(0); await keyUp(["Shift", "ArrowRight"]); await advanceTime(500); // Toolbar open on keyup is debounced expect(".o-we-toolbar").toHaveCount(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"); expect(".o-we-toolbar").toHaveCount(0); // Toolbar should open after keyup await keyUp(["Shift", "ArrowRight"]); await advanceTime(500); // toolbar open on keyup is debounced expect(".o-we-toolbar").toHaveCount(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(); expect(".o-we-toolbar").toHaveCount(1); await keyUp(["Shift"]); await tick(); expect(".o-we-toolbar").toHaveCount(1); // Toolbar should not close on keydown ctrl await keyDown(["Control"]); await tick(); expect(".o-we-toolbar").toHaveCount(1); await keyUp(["Control"]); await tick(); expect(".o-we-toolbar").toHaveCount(1); }); test("toolbar should not open between keystrokes separated by a short interval", async () => { const { el } = await setupEditor("[]test
"); expect(".o-we-toolbar").toHaveCount(0); // Keystroke # 1 await keyDown(["Shift", "ArrowRight"]); setContent(el, "[t]est
"); await tick(); // selectionChange await keyUp(["Shift", "ArrowRight"]); await advanceTime(100); expect(".o-we-toolbar").toHaveCount(0); // Keystroke # 2 await keyDown(["Shift", "ArrowRight"]); setContent(el, "[te]st
"); await tick(); // selectionChange await keyUp(["Shift", "ArrowRight"]); await advanceTime(100); expect(".o-we-toolbar").toHaveCount(0); // Toolbar opens some time after the last keyup await advanceTime(500); expect(".o-we-toolbar").toHaveCount(1); }); }); });