replace stale web_editor with html_editor and html_builder for 19.0

web_editor was removed in Odoo 19.0 and replaced by html_editor
and html_builder. The old web_editor was incorrectly included in
the 19.0 vanilla import.

🤖 assisted by claude
This commit is contained in:
Ernad Husremovic 2026-03-09 15:31:13 +01:00
parent 4b94f0abc5
commit f866779561
1513 changed files with 396049 additions and 358525 deletions

View file

@ -0,0 +1,27 @@
import { closestBlock } from "@html_editor/utils/blocks";
import { isVisibleTextNode } from "@html_editor/utils/dom_info";
import { describe, expect, test } from "@odoo/hoot";
import { insertTestHtml } from "../_helpers/editor";
describe("closestBlock", () => {
test("should find the closest block of a deeply nested text node", () => {
const [div] = insertTestHtml("<div><div><p>ab<b><i><u>cd</u></i></b>ef</p></div></div>");
const p = div.firstChild.firstChild;
const cd = p.childNodes[1].firstChild.firstChild.firstChild;
const result = closestBlock(cd);
expect(result).toBe(p);
});
test("should find that the closest block to a block is itself", () => {
const [div] = insertTestHtml("<div><div><p>ab</p></div></div>");
const p = div.firstChild.firstChild;
const result = closestBlock(p);
expect(result).toBe(p);
});
test("should return null if no block ancestor", () => {
const node = document.createTextNode("\n ");
expect(closestBlock(node)).toBe(null);
expect(isVisibleTextNode(node)).toBe(false);
});
});

View file

@ -0,0 +1,53 @@
import { rgbToHex, rgbaToHex, blendColors } from "@web/core/utils/colors";
import { expect, getFixture, test } from "@odoo/hoot";
test("should convert an rgb and rgba color to hexadecimal", async () => {
expect(rgbToHex("rgb(0, 0, 255)")).toBe("#0000ff");
expect(rgbToHex("rgb(0,0,255)")).toBe("#0000ff");
expect(rgbaToHex("rgba(0, 0, 255, 0.5)")).toBe("#0000FF80");
});
test("should convert an rgba color to hexadecimal (background is hexadecimal)", async () => {
const parent = getFixture();
const node = document.createElement("div");
parent.style.backgroundColor = "#ff0000"; // red, should be irrelevant
node.style.backgroundColor = "#0000ff"; // blue
parent.append(node);
// white with 50% opacity over blue = light blue
expect(rgbToHex("rgba(255, 255, 255, 0.5)", node)).toBe("#7f7fff");
expect(blendColors("rgba(255, 255, 255, 0.5)", node)).toBe("#8080ff");
});
test("should convert an rgba color to hexadecimal (background is color name)", async () => {
const parent = getFixture();
const node = document.createElement("div");
parent.style.backgroundColor = "#ff0000"; // red, should be irrelevant
node.style.backgroundColor = "blue"; // blue
parent.append(node);
// white with 50% opacity over blue = light blue
expect(rgbToHex("rgba(255, 255, 255, 0.5)", node)).toBe("#7f7fff");
expect(blendColors("rgba(255, 255, 255, 0.5)", node)).toBe("#8080ff");
});
test("should convert an rgba color to hexadecimal (background is rgb)", async () => {
const parent = getFixture();
const node = document.createElement("div");
parent.style.backgroundColor = "#ff0000"; // red, should be irrelevant
node.style.backgroundColor = "rgb(0, 0, 255)"; // blue
parent.append(node);
// white with 50% opacity over blue = light blue
expect(rgbToHex("rgba(255, 255, 255, 0.5)", node)).toBe("#7f7fff");
expect(blendColors("rgba(255, 255, 255, 0.5)", node)).toBe("#8080ff");
parent.remove();
});
test("should convert an rgba color to hexadecimal (background is rgba)", async () => {
const parent = getFixture();
const node = document.createElement("div");
parent.style.backgroundColor = "rgb(255, 0, 0)"; // red
node.style.backgroundColor = "rgba(0, 0, 255, 0.5)"; // blue
parent.append(node);
// white with 50% opacity over blue with 50% opacity over red = light purple
expect(rgbToHex("rgba(255, 255, 255, 0.5)", node)).toBe("#bf7fbf");
expect(blendColors("rgba(255, 255, 255, 0.5)", node)).toBe("#c080c0");
});

View file

@ -0,0 +1,406 @@
import { describe, expect, test } from "@odoo/hoot";
import { setupEditor } from "../_helpers/editor";
import {
cleanTextNode,
fillEmpty,
splitTextNode,
wrapInlinesInBlocks,
} from "@html_editor/utils/dom";
import { getContent } from "../_helpers/selection";
import { parseHTML } from "@html_editor/utils/html";
import { unformat } from "../_helpers/format";
import { queryOne } from "@odoo/hoot-dom";
describe("splitAroundUntil", () => {
test("should split a slice of text from its inline ancestry (1)", async () => {
const { editor, el } = await setupEditor("<p>a<font>b<span>cde</span>f</font>g</p>");
const [p] = el.childNodes;
const cde = p.childNodes[1].childNodes[1].firstChild;
// We want to test with "cde" being three separate text nodes.
splitTextNode(cde, 2);
const cd = cde.previousSibling;
splitTextNode(cd, 1);
const d = cd;
const result = editor.shared.split.splitAroundUntil(d, p.childNodes[1]);
expect(result.tagName).toBe("FONT");
expect(p.outerHTML).toBe(
"<p>a<font>b<span>c</span></font><font><span>d</span></font><font><span>e</span>f</font>g</p>"
);
});
test("should split a slice of text from its inline ancestry (2)", async () => {
const { editor, el } = await setupEditor("<p>a<font>b<span>cdefg</span>h</font>i</p>");
const [p] = el.childNodes;
const cdefg = p.childNodes[1].childNodes[1].firstChild;
// We want to test with "cdefg" being five separate text nodes.
splitTextNode(cdefg, 4);
const cdef = cdefg.previousSibling;
splitTextNode(cdef, 3);
const cde = cdef.previousSibling;
splitTextNode(cde, 2);
const cd = cde.previousSibling;
splitTextNode(cd, 1);
const d = cd;
const result = editor.shared.split.splitAroundUntil(
[d, d.nextSibling.nextSibling],
p.childNodes[1]
);
expect(result.tagName).toBe("FONT");
expect(p.outerHTML).toBe(
"<p>a<font>b<span>c</span></font><font><span>def</span></font><font><span>g</span>h</font>i</p>"
);
});
test("should split from a textNode that has no siblings", async () => {
const { editor, el } = await setupEditor("<p>a<font>b<span>cde</span>f</font>g</p>");
const [p] = el.childNodes;
const font = p.querySelector("font");
const cde = p.querySelector("span").firstChild;
const result = editor.shared.split.splitAroundUntil(cde, font);
expect(result.tagName).toBe("FONT");
expect(result).not.toBe(font);
expect(p.outerHTML).toBe(
"<p>a<font>b</font><font><span>cde</span></font><font>f</font>g</p>"
);
});
test("should not do anything (nothing to split)", async () => {
const { editor, el } = await setupEditor("<p>a<font><span>bcd</span></font>e</p>");
const [p] = el.childNodes;
const bcd = p.querySelector("span").firstChild;
const result = editor.shared.split.splitAroundUntil(bcd, p.childNodes[1]);
expect(result).toBe(p.childNodes[1]);
expect(p.outerHTML).toBe("<p>a<font><span>bcd</span></font>e</p>");
});
test("should split when node is first child of inline ancestry (1)", async () => {
const { editor, el } = await setupEditor("<p>a<font>b<span>cde</span>f</font>g</p>");
const [p] = el.childNodes;
const cde = p.childNodes[1].childNodes[1].firstChild;
splitTextNode(cde, 2);
const cd = cde.previousSibling;
const result = editor.shared.split.splitAroundUntil(cd, p.childNodes[1]);
expect(result.tagName).toBe("FONT");
expect(p.outerHTML).toBe(
"<p>a<font>b</font><font><span>cd</span></font><font><span>e</span>f</font>g</p>"
);
});
test("should split when node is first child of inline ancestry (2)", async () => {
const { editor, el } = await setupEditor("<p>a<font><span>bcd</span></font>e</p>");
const [p] = el.childNodes;
const bcd = p.childNodes[1].childNodes[0].firstChild;
splitTextNode(bcd, 2);
const bc = bcd.previousSibling;
const result = editor.shared.split.splitAroundUntil(bc, p.childNodes[1]);
expect(result.tagName).toBe("FONT");
expect(p.outerHTML).toBe(
"<p>a<font><span>bc</span></font><font><span>d</span></font>e</p>"
);
});
test("should split when node is first child of inline ancestry (3)", async () => {
const { editor, el } = await setupEditor("<p>a<font>b<span>cde</span></font>f</p>");
const [p] = el.childNodes;
const cde = p.childNodes[1].childNodes[1].firstChild;
splitTextNode(cde, 2);
const cd = cde.previousSibling;
const result = editor.shared.split.splitAroundUntil(cd, p.childNodes[1]);
expect(result.tagName).toBe("FONT");
expect(p.outerHTML).toBe(
"<p>a<font>b</font><font><span>cd</span></font><font><span>e</span></font>f</p>"
);
});
test("should split when node is last child of inline ancestry (1)", async () => {
const { editor, el } = await setupEditor("<p>a<font>b<span>cde</span>f</font>g</p>");
const [p] = el.childNodes;
const cde = p.childNodes[1].childNodes[1].firstChild;
splitTextNode(cde, 2);
const result = editor.shared.split.splitAroundUntil(cde, p.childNodes[1]);
expect(result.tagName).toBe("FONT");
expect(p.outerHTML).toBe(
"<p>a<font>b<span>cd</span></font><font><span>e</span></font><font>f</font>g</p>"
);
});
test("should split when node is last child of inline ancestry (2)", async () => {
const { editor, el } = await setupEditor("<p>a<font><span>bcd</span></font>e</p>");
const [p] = el.childNodes;
const bcd = p.childNodes[1].childNodes[0].firstChild;
splitTextNode(bcd, 2);
const result = editor.shared.split.splitAroundUntil(bcd, p.childNodes[1]);
expect(result.tagName).toBe("FONT");
expect(p.outerHTML).toBe(
"<p>a<font><span>bc</span></font><font><span>d</span></font>e</p>"
);
});
test("should split when node is last child of inline ancestry (3)", async () => {
const { editor, el } = await setupEditor("<p>a<font><span>bcd</span>e</font>f</p>");
const [p] = el.childNodes;
const bcd = p.childNodes[1].childNodes[0].firstChild;
splitTextNode(bcd, 2);
const result = editor.shared.split.splitAroundUntil(bcd, p.childNodes[1]);
expect(result.tagName).toBe("FONT");
expect(p.outerHTML).toBe(
"<p>a<font><span>bc</span></font><font><span>d</span></font><font>e</font>f</p>"
);
});
test("should split a multi-node inline range near end of ancestry", async () => {
const { editor, el } = await setupEditor(
"<p>a<font>b<strong>cde</strong>fgh<u>ijk</u>l</font>m</p>"
);
const [p] = el.childNodes;
const cde = queryOne("strong").firstChild;
const ijk = queryOne("u").firstChild;
const result = editor.shared.split.splitAroundUntil([cde, ijk], p.childNodes[1]);
expect(result.tagName).toBe("FONT");
expect(p.outerHTML).toBe(
"<p>a<font>b</font><font><strong>cde</strong>fgh<u>ijk</u></font><font>l</font>m</p>"
);
});
});
describe("cleanTextNode", () => {
test("should remove ZWS before cursor and preserve it", async () => {
const { editor, el } = await setupEditor("<p>\u200B[]text</p>");
const cursors = editor.shared.selection.preserveSelection();
cleanTextNode(el.querySelector("p").firstChild, "\u200B", cursors);
cursors.restore();
expect(getContent(el)).toBe("<p>[]text</p>");
});
test("should remove ZWS before cursor and preserve it (2)", async () => {
const { editor, el } = await setupEditor("<p>\u200Bt[]ext</p>");
const cursors = editor.shared.selection.preserveSelection();
cleanTextNode(el.querySelector("p").firstChild, "\u200B", cursors);
cursors.restore();
expect(getContent(el)).toBe("<p>t[]ext</p>");
});
test("should remove ZWS after cursor and preserve it", async () => {
const { editor, el } = await setupEditor("<p>text[]\u200B</p>");
const cursors = editor.shared.selection.preserveSelection();
cleanTextNode(el.querySelector("p").firstChild, "\u200B", cursors);
cursors.restore();
expect(getContent(el)).toBe("<p>text[]</p>");
});
test("should remove ZWS after cursor and preserve it (2)", async () => {
const { editor, el } = await setupEditor("<p>t[]ext\u200B</p>");
const cursors = editor.shared.selection.preserveSelection();
cleanTextNode(el.querySelector("p").firstChild, "\u200B", cursors);
cursors.restore();
expect(getContent(el)).toBe("<p>t[]ext</p>");
});
test("should remove multiple ZWS preserving cursor", async () => {
const { editor, el } = await setupEditor("<p>\u200Bt\u200Be[]\u200Bxt\u200B</p>");
const cursors = editor.shared.selection.preserveSelection();
cleanTextNode(el.querySelector("p").firstChild, "\u200B", cursors);
cursors.restore();
expect(getContent(el)).toBe("<p>te[]xt</p>");
});
test("should remove multiple ZWNBSP preserving cursor", async () => {
const { editor, el } = await setupEditor("<p>\uFEFFt\uFEFFe[]\uFEFFxt\uFEFF</p>");
const cursors = editor.shared.selection.preserveSelection();
cleanTextNode(el.querySelector("p").firstChild, "\uFEFF", cursors);
cursors.restore();
expect(getContent(el)).toBe("<p>te[]xt</p>");
});
});
describe("wrapInlinesInBlocks", () => {
test("should wrap text node in P", async () => {
const div = document.createElement("div");
div.innerHTML = "text";
wrapInlinesInBlocks(div);
expect(div.innerHTML).toBe("<p>text</p>");
});
test("should wrap inline element in P", async () => {
const div = document.createElement("div");
div.innerHTML = "<strong>text</strong>";
wrapInlinesInBlocks(div);
expect(div.innerHTML).toBe("<p><strong>text</strong></p>");
});
test("should not do anything to block element", async () => {
const div = document.createElement("div");
div.innerHTML = "<p>text</p>";
wrapInlinesInBlocks(div);
expect(div.innerHTML).toBe("<p>text</p>");
});
test("should wrap inlines in P", async () => {
const div = document.createElement("div");
div.innerHTML = "textnode<strong>inline</strong><p>p</p>";
wrapInlinesInBlocks(div);
expect(div.innerHTML).toBe("<p>textnode<strong>inline</strong></p><p>p</p>");
});
test("should wrap inlines in P (2)", async () => {
const div = document.createElement("div");
div.innerHTML = "<strong>inline</strong><p>p</p>textnode";
wrapInlinesInBlocks(div);
expect(div.innerHTML).toBe("<p><strong>inline</strong></p><p>p</p><p>textnode</p>");
});
test("should turn a BR into a paragraph break", async () => {
const div = document.createElement("div");
div.innerHTML = "abc<br>def";
wrapInlinesInBlocks(div);
expect(div.innerHTML).toBe("<p>abc</p><p>def</p>");
});
test("should remove a BR that has no effect", async () => {
const div = document.createElement("div");
div.innerHTML = "abc<br>def<br>";
wrapInlinesInBlocks(div);
expect(div.innerHTML).toBe("<p>abc</p><p>def</p>");
});
test("empty lines should become empty paragraphs", async () => {
const div = document.createElement("div");
div.innerHTML = "abc<br><br>def";
wrapInlinesInBlocks(div);
expect(div.innerHTML).toBe("<p>abc</p><p><br></p><p>def</p>");
});
test("empty lines should become empty paragraphs (2)", async () => {
const div = document.createElement("div");
div.innerHTML = "<br>";
wrapInlinesInBlocks(div);
expect(div.innerHTML).toBe("<p><br></p>");
});
test("empty lines should become empty paragraphs (3)", async () => {
const div = document.createElement("div");
div.innerHTML = "<br>abc";
wrapInlinesInBlocks(div);
expect(div.innerHTML).toBe("<p><br></p><p>abc</p>");
});
test("mix: handle blocks, inlines and BRs", async () => {
const div = document.createElement("div");
div.innerHTML = "a<br><strong>b</strong><h1>c</h1><br>d<h2>e</h2><br>";
wrapInlinesInBlocks(div);
expect(div.innerHTML).toBe(
"<p>a</p><p><strong>b</strong></p><h1>c</h1><p><br></p><p>d</p><h2>e</h2><p><br></p>"
);
});
test("wrap block with display style inline in div", async () => {
// Second part should be wrapped automatically during initElementForEdition
const { el, editor } = await setupEditor(
`<div><br></div><div contenteditable="false" style="display: inline;">inline</div>`
);
const div = el.querySelector("div");
editor.shared.selection.setSelection({ anchorNode: div, anchorOffset: 0 });
editor.shared.selection.focusEditable();
// dom insert should take care not to insert inline content at the root
// inline non-phrasing content should not be added in a paragraph-related
// element.
editor.shared.dom.insert(
parseHTML(
editor.document,
`<div contenteditable="false" style="display: inline;">inline</div>`
)
);
editor.shared.history.addStep();
// It is debatable whether an empty paragraph-related element
// should be kept or not after inserting an inline flow-content element
// (which would be wrapped inside a div, not in the paragraph-related
// element).
expect(getContent(el)).toBe(
unformat(`
<div>
<div contenteditable="false" style="display: inline;">inline</div>[]
</div>
<div class="o-paragraph"><br></div>
<div>
<div contenteditable="false" style="display: inline;">inline</div>
</div>
`)
);
});
test("wrap a mix of inline elements in div", async () => {
// Second part should be wrapped automatically during initElementForEdition
const { el, editor } = await setupEditor(
`<div><br></div>text<div contenteditable="false" style="display: inline;">inline</div><span class="a">span</span>`
);
const div = el.querySelector("div");
editor.shared.selection.setSelection({ anchorNode: div, anchorOffset: 0 });
editor.shared.selection.focusEditable();
// dom insert should take care not to insert inline content at the root
// inline non-phrasing content should not be added in a paragraph-related
// element.
editor.shared.dom.insert(
parseHTML(
editor.document,
`text<div contenteditable="false" style="display: inline;">inline</div><span class="a">span</span>`
)
);
editor.shared.history.addStep();
expect(getContent(el)).toBe(
unformat(`
<div class="o-paragraph">text</div>
<div>
<div contenteditable="false" style="display: inline;">inline</div><span class="a">span</span>[]
</div>
<div class="o-paragraph"><br></div>
<div>
text
<div contenteditable="false" style="display: inline;">inline</div>
<span class="a">span</span>
</div>
`)
);
});
test("wrap a mix of inline elements in div with br", async () => {
// Second part should be wrapped automatically during initElementForEdition
const { el, editor } = await setupEditor(
`<div>[]<br></div>text<br><div contenteditable="false" style="display: inline;">inline</div><br><span class="a">span</span>`
);
const div = el.querySelector("div");
editor.shared.selection.setSelection({ anchorNode: div, anchorOffset: 0 });
// dom insert should take care not to insert inline content at the root
// inline non-phrasing content should not be added in a paragraph-related
// element.
editor.shared.dom.insert(
parseHTML(
editor.document,
`text<br><div contenteditable="false" style="display: inline;">inline</div><br><span class="a">span</span>`
)
);
editor.shared.history.addStep();
expect(getContent(el)).toBe(
unformat(`
<div class="o-paragraph">text</div>
<div>
<div contenteditable="false" style="display: inline;">inline</div>
</div>
<p>
<span class="a">span</span>[]
</p>
<div class="o-paragraph">text</div>
<div>
<div contenteditable="false" style="display: inline;">inline</div>
</div>
<div class="o-paragraph">
<span class="a">span</span>
</div>
`)
);
});
test("should wrap inlines in P, preserving space", async () => {
const div = document.createElement("div");
div.innerHTML = "<span>abc</span> <span>def</span>";
wrapInlinesInBlocks(div);
expect(div.innerHTML).toBe("<p><span>abc</span> <span>def</span></p>");
});
});
describe("fillEmpty", () => {
test("should not add fill a shrunk protected block, nor add a ZWS to it", async () => {
const { el } = await setupEditor('<div data-oe-protected="true"></div>');
expect(el.innerHTML).toBe('<div data-oe-protected="true" contenteditable="false"></div>');
const div = el.firstChild;
fillEmpty(div);
expect(el.innerHTML).toBe('<div data-oe-protected="true" contenteditable="false"></div>');
});
test("should not fill a block containing a canvas", async () => {
const { el } = await setupEditor("<div><canvas></canvas></div>");
expect(el.innerHTML).toBe('<div class="o-paragraph"><canvas></canvas></div>');
const div = el.firstChild;
fillEmpty(div);
expect(el.innerHTML).toBe('<div class="o-paragraph"><canvas></canvas></div>');
});
});

View file

@ -0,0 +1,486 @@
import {
areSimilarElements,
getDeepestPosition,
isEmptyBlock,
isShrunkBlock,
isVisible,
isVisibleTextNode,
nextLeaf,
previousLeaf,
} from "@html_editor/utils/dom_info";
import { describe, expect, test } from "@odoo/hoot";
import { insertTestHtml } from "../_helpers/editor";
const base64Img =
"data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUA\n AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO\n 9TXL0Y4OHwAAAABJRU5ErkJggg==";
describe("previousLeaf", () => {
test("should find the previous leaf of a deeply nested node", () => {
const [div] = insertTestHtml(
"<div><div><p><b>ab<i>cd<u>ef</u>gh</i></b><span>ij</span>kl</p></div></div>"
);
const editable = div.parentElement;
const p = div.firstChild.firstChild;
const gh = p.firstChild.childNodes[1].childNodes[2];
const ij = p.childNodes[1].firstChild;
const result = previousLeaf(ij, editable);
expect(result).toBe(gh);
});
test("should find no previous leaf and return undefined", () => {
const [div] = insertTestHtml(
"<div><div><p><b>ab<i>cd<u>ef</u>gh</i></b><span>ij</span>kl</p></div></div>"
);
const editable = div.parentElement;
const p = div.firstChild.firstChild;
const ab = p.firstChild.firstChild;
const result = previousLeaf(ab, editable);
expect(result).toBe(undefined);
});
test("should find the previous leaf of a deeply nested node, skipping invisible nodes", () => {
const [div] = insertTestHtml(
`<div>
<div>
<p>
<b>ab<i>cd<u>ef</u>gh</i></b>
</p>
<p>
<span>ij</span>kl
</p>
</div>
</div>`
);
const editable = div.parentElement;
const p1 = div.childNodes[1].childNodes[1];
const gh = p1.childNodes[1].childNodes[1].childNodes[2];
const p2 = div.childNodes[1].childNodes[3];
const ij = p2.childNodes[1].firstChild;
const result = previousLeaf(ij, editable, true);
expect(result).toBe(gh);
});
test("should find no previous leaf, skipping invisible nodes, and return undefined", () => {
const [div] = insertTestHtml(
`<div>
<div>
<p>
<b>ab<i>cd<u>ef</u>gh</i></b>
</p>
<p>
<span>ij</span>kl
</p>
</div>
</div>`
);
const editable = div.parentElement;
const p1 = div.childNodes[1].childNodes[1];
const ab = p1.childNodes[1].firstChild;
const result = previousLeaf(ab, editable, true);
expect(result).toBe(undefined);
});
test("should find the previous leaf of a deeply nested node to be whitespace", () => {
const [div] = insertTestHtml(
`<div>
<div>
<p>
<b>ab<i>cd<u>ef</u>gh</i></b>
</p>
<p>
<span>ij</span>kl
</p>
</div>
</div>`
);
const editable = div.parentElement;
const p2 = div.childNodes[1].childNodes[3];
const whitespace = p2.firstChild;
const ij = p2.childNodes[1].firstChild;
const result = previousLeaf(ij, editable);
expect(result).toBe(whitespace);
expect(whitespace.nodeType).toBe(Node.TEXT_NODE);
expect(whitespace.textContent).toBe(`
`);
expect(isVisibleTextNode(whitespace)).toBe(false);
});
});
describe("nextLeaf", () => {
// TODO @phoenix: add nextLeaf test cases when we add it in the code base
test("should find the next leaf of a deeply nested node", () => {
const [div] = insertTestHtml(
"<div><div><p><b>ab<i>cd<u>ef</u>gh</i></b><span>ij</span>kl</p></div></div>"
);
const editable = div.parentElement;
const p = div.firstChild.firstChild;
const gh = p.firstChild.childNodes[1].childNodes[2];
const ij = p.childNodes[1].firstChild;
const result = nextLeaf(gh, editable);
expect(result).toBe(ij);
});
test("should find no next leaf and return undefined", () => {
const [div] = insertTestHtml(
"<div><div><p><b>ab<i>cd<u>ef</u>gh</i></b><span>ij</span>kl</p></div></div>"
);
const editable = div.parentElement;
const p = div.firstChild.firstChild;
const kl = p.childNodes[2];
const result = nextLeaf(kl, editable);
expect(result).toBe(undefined);
});
test("should find the next leaf of a deeply nested node, skipping invisible nodes", () => {
const [div] = insertTestHtml(
`<div>
<div>
<p>
<b>ab<i>cd<u>ef</u>gh</i></b>
</p>
<p>
<span>ij</span>kl
</p>
</div>
</div>`
);
const editable = div.parentElement;
const p1 = div.childNodes[1].childNodes[1];
const gh = p1.childNodes[1].childNodes[1].childNodes[2];
const p2 = div.childNodes[1].childNodes[3];
const ij = p2.childNodes[1].firstChild;
const result = nextLeaf(gh, editable, true);
expect(result).toBe(ij);
});
test("should find no next leaf, skipping invisible nodes, and return undefined", () => {
const [div] = insertTestHtml(
`<div>
<div>
<p>
<b>ab<i>cd<u>ef</u>gh</i></b>
</p>
<p>
<span>ij</span>kl
</p>
</div>
</div>`
);
const editable = div.parentElement;
const p2 = div.childNodes[1].childNodes[3];
const kl = p2.childNodes[2];
const result = nextLeaf(kl, editable, true);
expect(result).toBe(undefined);
});
test("should find the next leaf of a deeply nested node to be whitespace", () => {
const [div] = insertTestHtml(
`<div>
<div>
<p>
<b>ab<i>cd<u>ef</u>gh</i></b>
</p>
<p>
<span>ij</span>kl
</p>
</div>
</div>`
);
const editable = div.parentElement;
const p2 = div.childNodes[1].childNodes[3];
const kl = p2.childNodes[2];
const whitespace = div.childNodes[1].childNodes[4];
const result = nextLeaf(kl, editable);
expect(result).toBe(whitespace);
expect(whitespace.nodeType).toBe(Node.TEXT_NODE);
expect(whitespace.textContent).toBe(`
`);
expect(isVisibleTextNode(whitespace)).toBe(false);
});
});
describe("isVisible", () => {
describe("textNode", () => {
test("should identify an invisible textnode at the beginning of a paragraph before an inline node", () => {
const [p] = insertTestHtml("<p> <i>a</i></p>");
const result = isVisible(p.firstChild);
expect(result).not.toBe(true);
});
test("should identify invisible string space at the end of a paragraph after an inline node", () => {
const [p] = insertTestHtml("<p><i>a</i> </p>");
const result = isVisible(p.lastChild);
expect(result).not.toBe(true);
});
test("should identify a single visible space in an inline node in the middle of a paragraph", () => {
const [p] = insertTestHtml("<p>a<i> </i>b</p>");
const result = isVisible(p.querySelector("i").firstChild);
expect(result).toBe(true);
});
test("should identify a visible string with only one visible space in an inline node in the middle of a paragraph", () => {
const [p] = insertTestHtml("<p>a<i> </i>b</p>");
const result = isVisible(p.querySelector("i").firstChild);
expect(result).toBe(true);
});
test("should identify a visible space in the middle of a paragraph", () => {
const [p] = insertTestHtml("<p></p>");
// insert 'a b' as three separate text node inside p
const textNodes = "a b".split("").map((char) => {
const textNode = document.createTextNode(char);
p.appendChild(textNode);
return textNode;
});
const result = isVisible(textNodes[1]);
expect(result).toBe(true);
});
test("should identify a visible string space in the middle of a paragraph", () => {
const [p] = insertTestHtml("<p></p>");
// inserts 'a', ' ' and 'b' as 3 separate text nodes inside p
const textNodes = ["a", " ", "b"].map((char) => {
const textNode = document.createTextNode(char);
p.appendChild(textNode);
return textNode;
});
const result = isVisible(textNodes[1]);
expect(result).toBe(true);
});
test("should identify the first space in a series of spaces as in the middle of a paragraph as visible", () => {
const [p] = insertTestHtml("<p></p>");
// inserts 'a b' as 5 separate text nodes inside p
const textNodes = "a b".split("").map((char) => {
const textNode = document.createTextNode(char);
p.appendChild(textNode);
return textNode;
});
const result = isVisible(textNodes[1]);
expect(result).toBe(true);
});
test("should identify the second space in a series of spaces in the middle of a paragraph as invisible", () => {
const [p] = insertTestHtml("<p></p>");
// inserts 'a b' as 5 separate text nodes inside p
const textNodes = "a b".split("").map((char) => {
const textNode = document.createTextNode(char);
p.appendChild(textNode);
return textNode;
});
const result = isVisible(textNodes[2]);
expect(result).not.toBe(true);
});
test("should identify empty text node as invisible", () => {
const [p] = insertTestHtml("<p></p>");
// inserts 'a b' as 5 separate text nodes inside p
const textNode = document.createTextNode("");
p.appendChild(textNode);
const result = isVisible(textNode);
expect(result).not.toBe(true);
});
test("should identify a space between to visible char in inline nodes as visible", () => {
const [p] = insertTestHtml("<p><i>a</i> <i>b</i></p>");
const textNode = p.firstChild.nextSibling;
const result = isVisible(textNode);
expect(result).toBe(true);
});
});
});
describe("getDeepestPosition", () => {
test("should get deepest position for text within paragraph", () => {
const [p] = insertTestHtml("<p>abc</p>");
const editable = p.parentElement;
const abc = p.firstChild;
let [node, offset] = getDeepestPosition(editable, 0);
expect([node, offset]).toEqual([abc, 0]);
[node, offset] = getDeepestPosition(editable, 1);
expect([node, offset]).toEqual([abc, 3]);
});
test("should get deepest position within nested formatting tags", () => {
const [p] = insertTestHtml("<p><span><b><i><u>abc</u></i></b></span></p>");
const editable = p.parentElement;
const abc = p.firstChild.firstChild.firstChild.firstChild.firstChild;
let [node, offset] = getDeepestPosition(editable, 0);
expect([node, offset]).toEqual([abc, 0]);
[node, offset] = getDeepestPosition(editable, 1);
expect([node, offset]).toEqual([abc, 3]);
});
test("should get deepest position in multiple paragraph", () => {
const [p1, p2] = insertTestHtml("<p>abc</p><p>def</p>");
const editable = p1.parentElement;
const abc = p1.firstChild;
const def = p2.firstChild;
let [node, offset] = getDeepestPosition(editable, 0);
expect([node, offset]).toEqual([abc, 0]);
[node, offset] = getDeepestPosition(editable, 1);
expect([node, offset]).toEqual([def, 0]);
[node, offset] = getDeepestPosition(editable, 2);
expect([node, offset]).toEqual([def, 3]);
});
test("should get deepest position for node with invisible element", () => {
const [p1] = insertTestHtml("<p></p><p>def</p>");
const editable = p1.parentElement;
const def = editable.lastChild.firstChild;
let [node, offset] = getDeepestPosition(editable, 0);
expect([node, offset]).toEqual([def, 0]);
[node, offset] = getDeepestPosition(editable, 2);
expect([node, offset]).toEqual([def, 3]);
});
test("should get deepest position for invisible block element", () => {
const [p1] = insertTestHtml("<p></p><p>def</p>");
const [node, offset] = getDeepestPosition(p1, 0);
expect([node, offset]).toEqual([p1, 0]);
});
test("should get deepest position for invisible block element(2)", () => {
const [p1] = insertTestHtml("<p>abc</p><p></p>");
const p2 = p1.nextSibling;
const [node, offset] = getDeepestPosition(p2, 0);
expect([node, offset]).toEqual([p2, 0]);
});
test("should get deepest position for elements containing invisible text nodes", () => {
const [p] = insertTestHtml(
`<p>
<i>a</i>
</p>`
);
const editable = p.parentElement;
const a = editable.firstChild.childNodes[1].firstChild;
let [node, offset] = getDeepestPosition(editable, 0);
expect([node, offset]).toEqual([a, 0]);
[node, offset] = getDeepestPosition(editable, 1);
expect([node, offset]).toEqual([a, 1]);
});
test("should not skip zwnbsp", () => {
const [a] = insertTestHtml('\ufeff<a href="#">abc</a>');
const editable = a.parentElement;
const zwnbsp = editable.firstChild;
const [node, offset] = getDeepestPosition(editable, 0);
expect([node, offset]).toEqual([zwnbsp, 0]);
});
});
describe("isEmptyBlock", () => {
test("should identify empty p element", () => {
const [p] = insertTestHtml("<p></p>");
const result = isEmptyBlock(p);
expect(result).toBe(true);
});
test("should identify p with single br tag as empty and multiple br tag as non-empty", () => {
const [p1, p2] = insertTestHtml("<p><br></p><p><br><br></p>");
const result1 = isEmptyBlock(p1);
const result2 = isEmptyBlock(p2);
expect(result1).toBe(true);
expect(result2).toBe(false);
});
test("should identify p element with text content as non-empty", () => {
const [p] = insertTestHtml("<p>abc</p>");
const result1 = isEmptyBlock(p);
const result2 = isEmptyBlock(p.firstChild);
expect(result1).toBe(false);
expect(result2).toBe(false);
});
test("should identify a empty span with display block", () => {
const [span] = insertTestHtml('<span style="display: block;"><br></span>');
const result = isEmptyBlock(span);
expect(result).toBe(true);
});
test("should identify span with icon classes as non-empty", () => {
const [span] = insertTestHtml('<span class="fa fa-trash-o"></span>');
const result = isEmptyBlock(span);
expect(result).toBe(false);
});
test("should identify img element as non-empty", () => {
const [img] = insertTestHtml(`<img src="${base64Img}" alt="image">`);
const result = isEmptyBlock(img);
expect(result).toBe(false);
});
test("should identify empty a tag as non-empty", () => {
const [a] = insertTestHtml("<a></a>");
const result = isEmptyBlock(a);
expect(result).toBe(false);
});
test("should identify a tag with text as non-empty", () => {
const [a] = insertTestHtml('<a href="#">Link text</a>');
const result = isEmptyBlock(a);
expect(result).toBe(false);
});
test("should return false for a p containing media element", () => {
const [p] = insertTestHtml(
'<p><a href="#" title="document" data-mimetype="application/pdf" class="o_image" contenteditable="false"></a></p>'
);
const result = isEmptyBlock(p);
expect(result).toBe(false);
});
test("should identify a div contains button without text content as non-empty", () => {
const [div] = insertTestHtml("<div><button></button></div>");
const result = isEmptyBlock(div);
expect(result).toBe(false);
});
});
describe("isShrunkBlock", () => {
test("should not consider a HR as a shrunk block", () => {
const [hr] = insertTestHtml("<hr>");
const result = isShrunkBlock(hr);
expect(result).toBe(false);
});
test("should not consider a block containing a canvas as a shrunk block", () => {
const [canvas] = insertTestHtml("<canvas></canvas>");
const result = isShrunkBlock(canvas);
expect(result).toBe(false);
});
});
describe("areSimilarElements", () => {
test("should consider elements with same classes and styles in different orders as similar", () => {
const [span1, span2] = insertTestHtml(
"<span class='first second' style='color: red; color2: blue'>hello</span><span class='second first' style='color2: blue; color: red'>world</span>"
);
const result = areSimilarElements(span1, span2);
expect(result).toBe(true);
});
test("return false when the number of styles are different", () => {
const [span1, span2] = insertTestHtml(
"<span class='first second' style='color: red; color2: blue'>hello</span><span class='second first' style='color2: blue;'>world</span>"
);
const result = areSimilarElements(span1, span2);
expect(result).toBe(false);
});
test("return false when the number of classes are different", () => {
const [span1, span2] = insertTestHtml(
"<span class='first' style='color: red; color2: blue'>hello</span><span class='second first' style='color2: blue;'>world</span>"
);
const result = areSimilarElements(span1, span2);
expect(result).toBe(false);
});
test("return false when classes are different", () => {
const [span1, span2] = insertTestHtml(
"<span class='first' style='color: red; color2: blue'>hello</span><span class='second' style='color2: blue;'>world</span>"
);
const result = areSimilarElements(span1, span2);
expect(result).toBe(false);
});
test("return false when styles are different", () => {
const [span1, span2] = insertTestHtml(
"<span class='first second' style='color2: blue'>hello</span><span class='second first' style='color2: blue; color: red'>world</span>"
);
const result = areSimilarElements(span1, span2);
expect(result).toBe(false);
});
});

View file

@ -0,0 +1,307 @@
import { CTYPES } from "@html_editor/utils/content_types";
import { enforceWhitespace, getState, restoreState } from "@html_editor/utils/dom_state";
import { DIRECTIONS } from "@html_editor/utils/position";
import { describe, expect, test } from "@odoo/hoot";
import { setupEditor } from "../_helpers/editor";
import { splitTextNode } from "@html_editor/utils/dom";
describe("getState", () => {
test("should recognize invisible space to the right", async () => {
// We'll be looking to the right while standing at `a[] `.
const { el } = await setupEditor("<p>a </p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 1); // "a"" "
expect(p.childNodes.length).toBe(2);
const position = [p, 1]; // `<p>"a"[]" "</p>`
expect(getState(...position, DIRECTIONS.RIGHT)).toEqual({
// We look to the right of "a" (`a[] `):
node: p.firstChild, // "a"
direction: DIRECTIONS.RIGHT,
// The browser strips the space away so we ignore it and see
// `</p>`: the closing tag from the inside.
cType: CTYPES.BLOCK_INSIDE,
});
});
test("should recognize invisible space to the right (among consecutive space within content)", async () => {
// We'll be looking to the right while standing at `a [] `. The
// first space is visible, the rest isn't.
const { el } = await setupEditor("<p>a b</p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 2); // "a "" b"
expect(p.childNodes.length).toBe(2);
const position = [p, 1]; // `<p>"a "[]" b"</p>`
expect(getState(...position, DIRECTIONS.RIGHT)).toEqual({
// We look to the right of "a " (`a []`):
node: p.firstChild, // "a "
direction: DIRECTIONS.RIGHT,
// The browser strips the space away so we ignore it and see
// "b": visible content.
cType: CTYPES.CONTENT,
});
});
test("should recognize visible space to the left (followed by consecutive space within content)", async () => {
// We'll be looking to the left while standing at `[] b`. The
// first space is visible, the rest isn't.
const { el } = await setupEditor("<p>a b</p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 2); // "a "" b"
expect(p.childNodes.length).toBe(2);
const position = [p, 1]; // `<p>"a "[]" b"</p>`
expect(getState(...position, DIRECTIONS.LEFT)).toEqual({
// We look to the left of " b" (`[] b`):
node: p.lastChild, // "a"
direction: DIRECTIONS.LEFT,
// Left of " b" we see visible space that we should
// preserve.
cType: CTYPES.SPACE,
});
});
test("should recognize invisible space to the left (nothing after)", async () => {
// We'll be looking to the left while standing at ` [] `.
const { el } = await setupEditor("<p> </p>");
const p = el.firstChild;
p.append(document.createTextNode("")); // " """
expect(getState(p, 1, DIRECTIONS.LEFT)).toEqual({
// We look to the left of " " (` []`):
node: p.lastChild, // ""
direction: DIRECTIONS.LEFT,
// The browser strips the space away so we ignore it and see
// `<p>`: the opening tag from the inside.
cType: CTYPES.BLOCK_INSIDE,
});
});
test("should recognize invisible space to the left (more space after)", async () => {
// We'll be looking to the left while standing at ` [] `.
const { el } = await setupEditor("<p> </p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 1); // " "" "
expect(getState(p, 1, DIRECTIONS.LEFT)).toEqual({
// We look to the left of " " (` [] `):
node: p.lastChild, // " ".
direction: DIRECTIONS.LEFT,
// The browser strips the space away so we ignore it and see
// `<p>`: the opening tag from the inside.
cType: CTYPES.BLOCK_INSIDE,
});
});
test("should recognize invisible space to the left (br after)", async () => {
// We'll be looking to the left while standing at ` [] `.
const { el } = await setupEditor("<p> <br></p>");
const p = el.firstChild;
expect(getState(p, 1, DIRECTIONS.LEFT)).toEqual({
// We look to the left of the br element (` []<br>`):
node: p.lastChild, // `<br>`.
direction: DIRECTIONS.LEFT,
// The browser strips the space away so we ignore it and see
// `<p>`: the opening tag from the inside.
cType: CTYPES.BLOCK_INSIDE,
});
});
});
describe("restoreState", () => {
test("should restore invisible space to the left (looking right)", async () => {
// We'll be restoring the state of "a []" in `<p>a </p>`.
const { el } = await setupEditor("<p>a b</p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 2); // "a ""b"
const rule = restoreState({
// We look to the right of "a " (`a []b`) to see if we need
// to preserve the space at the end of "a ":
node: p.firstChild, // "a "
direction: DIRECTIONS.RIGHT,
// The DOM used to be `<p>a </p>` so to the right of "a " we
// used to see `</p>`: the closing tag from the inside.
cType: CTYPES.BLOCK_INSIDE,
});
// Now looking to the right of "a " we see "b", which is content
// and makes the formerly invisible space visible. We should get
// back a rule that will enforce the invisibility of the space.
expect(rule.spaceVisibility).not.toBe(true);
});
test("should restore visible space to the left (looking right) (among consecutive space within content)", async () => {
// We'll be restoring the state of "a []" in `<p>a b</p>`.
// The first space is visible, the rest isn't.
const { el } = await setupEditor("<p>a </p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 2); // "a "" "
const rule = restoreState({
// We look to the right of "a " (`a []`) to see if we need
// to preserve the space at the end of "a ":
node: p.firstChild, // "a "
direction: DIRECTIONS.RIGHT,
// The DOM used to be `<p>a b</p>` so to the right of "a " we
// used to see "b" which is visible content.
cType: CTYPES.CONTENT,
});
// Now looking to the right of "a " we see `</p>`: the closing
// tag, from the inside. This makes the formerly visible space
// invisible. We should get back a rule that will enforce the
// visibility of the space.
expect(rule.spaceVisibility).toBe(true);
});
test("should restore visible space to the right (looking left) (followed by consecutive space within content)", async () => {
// We'll be restoring the state of "[] b" in `<p>a b</p>`.
// The first space is visible, the rest isn't.
const { el } = await setupEditor("<p>a </p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 2); // "a "" "
const rule = restoreState({
// We look to the left of " " (`[] `) to see if we need
// to preserve the space of " ":
node: p.lastChild, // " "
direction: DIRECTIONS.LEFT,
// The DOM used to be `<p>a b</p>` so to the left of " b" we
// used to see " " which is visible space.
cType: CTYPES.SPACE,
});
// Now looking to the left of " " we see " " which is now
// invisible. This means the space we're examining is also still
// invisible. Since it should be invisible, we should get back a
// rule that will enforce the invisibility of the space (but no
// rule would work as well).
expect(rule.spaceVisibility).not.toBe(true);
});
test("should restore invisible space to the right (looking left) (nothing after)", async () => {
// We'll be restoring the state of " []" in `<p> </p>`.
const { el } = await setupEditor("<p>a </p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 1); // "a"" "
const rule = restoreState({
// We look to the left of " " (`a[] `) to see if we need
// to preserve the space of " ":
node: p.lastChild, // " "
direction: DIRECTIONS.LEFT,
// The DOM used to be `<p> </p>` so to the left of " " we
// used to see `<p>`: the opening tag from the inside.
cType: CTYPES.BLOCK_INSIDE,
});
// Now looking to the left of " " we see "a", which is content
// but since it's to the left of our space it has no incidence
// on its visibility. Either way it should be invisible so we
// should get back a rule that will enforce the invisibility of
// the space (but no rule would work as well).
expect(rule.spaceVisibility).not.toBe(true);
});
test("should restore invisible space to the right (looking left) (more space after)", async () => {
// We'll be restoring the state of " [] " in `<p> </p>`.
const { el } = await setupEditor("<p>a </p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 2); // "a "" "
const rule = restoreState({
// We look to the left of " " (`a [] `) to see if we need
// to preserve the space of " ":
node: p.lastChild, // " "
direction: DIRECTIONS.LEFT,
// The DOM used to be `<p> </p>` so to the left of " "
// we used to see `<p>`: the opening tag from the inside.
cType: CTYPES.BLOCK_INSIDE,
});
// Now looking to the left of " " we see "a", which is content
// but since it's to the left of our space it has no incidence
// on its visibility. Either way it should be invisible so we
// should get back a rule that will enforce the invisibility of
// the space (but no rule would work as well).
expect(rule.spaceVisibility).not.toBe(true);
});
test("should restore invisible space to the right (looking left) (br after)", async () => {
// We'll be restoring the state of " []<br>" in `<p> []<br></p>`.
const { el } = await setupEditor("<p>a <br></p>");
const p = el.firstChild;
const rule = restoreState({
// We look to the left of `<br>` (`a []<br>`):
node: p.lastChild, // `<br>`
direction: DIRECTIONS.LEFT,
// The DOM used to be `<p> <br></p>` so to the left of
// `<br>` we used to see `<p>`: the opening tag from the
// inside.
cType: CTYPES.BLOCK_INSIDE,
});
// Now looking to the left of `<br>` we see "a", which is
// content but since it's to the left of our space it has no
// incidence on its visibility. Either way it should be
// invisible so we should get back a rule that will enforce the
// invisibility of the space (but no rule would work as well).
expect(rule.spaceVisibility).not.toBe(true);
});
});
describe("enforceWhitespace", () => {
test("should enforce invisible space to the left", async () => {
// We'll be making the space between "a" and "b" invisible.
const { el } = await setupEditor("<p>a b</p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 2); // "a ""b"
// We look to the left while standing at "a []":
enforceWhitespace(p, 1, DIRECTIONS.LEFT, { spaceVisibility: false });
expect(p.innerHTML).toBe("ab");
});
test("should restore visible space to the left (among consecutive space within content)", async () => {
// We'll be making the first space after "a" visible.
const { el } = await setupEditor("<p>a </p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 2); // "a "" "
// We look to the left while standing at "a []":
enforceWhitespace(p, 1, DIRECTIONS.LEFT, { spaceVisibility: true });
expect(p.innerHTML).toBe("a&nbsp; ");
});
test("should not enforce already invisible space to the right (followed by consecutive space within content)", async () => {
// We'll be keeping the last (invisible) space after "a" (we
// could remove it but we don't need to - mostly we should not
// make it visible).
const { el } = await setupEditor("<p>a </p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 2); // "a "" "
// We look to the left while standing at "a []":
enforceWhitespace(p, 0, DIRECTIONS.RIGHT, { spaceVisibility: false });
expect(p.innerHTML).toBe("a ");
});
test("should not enforce already invisible space to the right (nothing after)", async () => {
// We'll be keeping the invisible space after "a" (we could
// remove it but we don't need to - mostly we should not make it
// visible).
const { el } = await setupEditor("<p>a </p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 1); // "a"" "
// We look to the right while standing at "a[]":
enforceWhitespace(p, 0, DIRECTIONS.RIGHT, { spaceVisibility: false });
expect(p.innerHTML).toBe("a ");
});
test("should not enforce already invisible space to the left (more space after)", async () => {
// We'll be keeping the invisible space after "a" (we could
// remove it but we don't need to - mostly we should not make it
// visible).
const { el } = await setupEditor("<p>a </p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 1); // "a"" "
// We look to the right while standing at "a[]":
enforceWhitespace(p, 0, DIRECTIONS.RIGHT, { spaceVisibility: false });
expect(p.innerHTML).toBe("a ");
});
test("should not enforce already invisible space to the left (br after)", async () => {
// We'll be keeping the invisible space after "a" (we could
// remove it but we don't need to - mostly we should not make it
// visible).
const { el } = await setupEditor("<p>a <br></p>");
const p = el.firstChild;
splitTextNode(p.firstChild, 1); // "a"" "
// We look to the right while standing at "a[]":
enforceWhitespace(p, 0, DIRECTIONS.RIGHT, { spaceVisibility: false });
expect(p.innerHTML).toBe("a <br>");
});
});

View file

@ -0,0 +1,339 @@
import { isBlock } from "@html_editor/utils/blocks";
import {
ancestors,
closestElement,
descendants,
firstLeaf,
getAdjacentNextSiblings,
getAdjacentPreviousSiblings,
getAdjacents,
lastLeaf,
getCommonAncestor,
} from "@html_editor/utils/dom_traversal";
import { describe, expect, getFixture, test } from "@odoo/hoot";
import { insertTestHtml } from "../_helpers/editor";
import { unformat } from "../_helpers/format";
describe("closestElement", () => {
test("should find the closest element to a text node", () => {
const [div] = insertTestHtml("<div><p>abc</p></div>");
const p = div.firstChild;
const abc = p.firstChild;
const result = closestElement(abc);
expect(result).toBe(p);
});
test("should find that the closest element to an element is itself", () => {
const [p] = insertTestHtml("<p>abc</p>");
const result = closestElement(p);
expect(result).toBe(p);
});
test("should not find a node which is not contained inside a .odoo-editor-editable", () => {
const [div] = insertTestHtml(`<div><p>abc</p></div>`);
const p = div.querySelector("p");
let result = closestElement(p, "div");
expect(result).toBe(div);
const fixture = getFixture();
fixture.classList.remove("odoo-editor-editable");
result = closestElement(p, "div");
expect(result).toBe(null);
});
test("should find a disconnected node even if not contained inside a .odoo-editor-editable element", () => {
const [div] = insertTestHtml(`<div><p>abc</p></div>`);
const p = div.querySelector("p");
div.remove();
const result = closestElement(p, "div");
expect(result).toBe(div);
});
});
describe("ancestors", () => {
test("should find all the ancestors of a text node", () => {
const [div] = insertTestHtml(
"<div><div><div><p>abc</p><div><p>def</p></div></div></div></div>"
);
const editable = div.parentElement;
const abcAncestors = [
editable,
div,
div.firstChild,
div.firstChild.firstChild,
div.firstChild.firstChild.firstChild,
].reverse();
const abc = abcAncestors[0].firstChild;
const result = ancestors(abc, editable);
expect(result).toEqual(abcAncestors);
});
test("should find only the editable", () => {
const [p] = insertTestHtml("<p>abc</p>");
const editable = p.parentElement;
const result = ancestors(p, editable);
expect(result).toEqual([editable]);
});
});
describe("descendants", () => {
test("should find all the descendants of a div in depth-first order", () => {
const [div] = insertTestHtml(
"<div><div><div><p>abc</p><div><p>def</p></div></div></div></div>"
);
expect(descendants(div)).toEqual([
div.firstChild, // <div><div>...
div.firstChild.firstChild, // <div><div><div>...
div.firstChild.firstChild.firstChild, // <p>abc</p>
div.firstChild.firstChild.firstChild.firstChild, // "abc"
div.firstChild.firstChild.childNodes[1], // <div><p>def</p></div>
div.firstChild.firstChild.childNodes[1].firstChild, // <p>def</p>
div.firstChild.firstChild.childNodes[1].firstChild.firstChild, // "def"
]);
});
});
describe("lastLeaf", () => {
test("should find the last leaf of a child-rich block", () => {
const [div] = insertTestHtml(
"<div><div><p>ab<span>cd</span><b><i><u>ef</u></i></b></p></div></div>"
);
const p = div.firstChild.firstChild;
const ef = p.childNodes[2].firstChild.firstChild.firstChild;
const result = lastLeaf(div);
expect(result).toBe(ef);
});
test("should find that the last closest block descendant of a child-rich block is itself", () => {
const [div] = insertTestHtml(
"<div><div><p>ab<span>cd</span><b><i><u>ef</u></i></b></p></div></div>"
);
const result = lastLeaf(div, isBlock);
expect(result).toBe(div);
});
test("should find no last closest block descendant of a child-rich inline and return its last leaf instead", () => {
const [div] = insertTestHtml(
"<div><div><p>ab<span>cd</span><b><i><u>ef</u></i></b></p></div></div>"
);
const b = div.firstChild.firstChild.childNodes[2];
const ef = b.firstChild.firstChild.firstChild;
const result = lastLeaf(b, isBlock);
expect(result).toBe(ef);
});
});
describe("firstLeaf", () => {
test("should find the first leaf of a child-rich block", () => {
const [div] = insertTestHtml(
"<div><div><p><b><i><u>ab</u></i></b><span>cd</span>ef</p></div></div>"
);
const p = div.firstChild.firstChild;
const ab = p.firstChild.firstChild.firstChild.firstChild;
const result = firstLeaf(div);
expect(result).toBe(ab);
});
test("should find that the first closest block descendant of a child-rich block is itself", () => {
const [div] = insertTestHtml(
"<div><div><p>ab<span>cd</span><b><i><u>ef</u></i></b></p></div></div>"
);
const result = firstLeaf(div, isBlock);
expect(result).toBe(div);
});
test("should find no first closest block descendant of a child-rich inline and return its first leaf instead", () => {
const [div] = insertTestHtml(
"<div><div><p><b><i><u>ab</u></i></b><span>cd</span>ef</p></div></div>"
);
const b = div.firstChild.firstChild.firstChild;
const ab = b.firstChild.firstChild.firstChild;
const result = firstLeaf(b, isBlock);
expect(result).toBe(ab);
});
});
describe("getAdjacentPreviousSiblings", () => {
test("should find the adjacent previous siblings of a deeply nested node", () => {
const [p] = insertTestHtml("<p><b>ab<i>cd<u>ef</u>gh<span>ij</span>kl</i>mn</b>op</p>");
const gh = p.firstChild.childNodes[1].childNodes[2];
const u = gh.previousSibling;
const cd = u.previousSibling;
const result = getAdjacentPreviousSiblings(gh);
expect(result).toEqual([u, cd]);
});
test("should find no adjacent previous siblings of a deeply nested node", () => {
const [p] = insertTestHtml("<p><b>ab<i>cd<u>ef</u>gh<span>ij</span>kl</i>mn</b>op</p>");
const ij = p.firstChild.childNodes[1].childNodes[3].firstChild;
const result = getAdjacentPreviousSiblings(ij);
expect(result).toEqual([]);
});
test("should find only the adjacent previous siblings of a deeply nested node that are elements", () => {
const [p] = insertTestHtml("<p><b>ab<i>cd<u>ef</u>gh<span>ij</span>kl</i>mn</b>op</p>");
const gh = p.firstChild.childNodes[1].childNodes[2];
const u = gh.previousSibling;
const result = getAdjacentPreviousSiblings(
gh,
(node) => node.nodeType === Node.ELEMENT_NODE
);
expect(result).toEqual([u]);
});
test("should find only the adjacent previous siblings of a deeply nested node that are text nodes (none)", () => {
const [p] = insertTestHtml("<p><b>ab<i>cd<u>ef</u>gh<span>ij</span>kl</i>mn</b>op</p>");
const gh = p.firstChild.childNodes[1].childNodes[2];
const result = getAdjacentPreviousSiblings(gh, (node) => node.nodeType === Node.TEXT_NODE);
expect(result).toEqual([]);
});
});
describe("getAdjacentNextSiblings", () => {
test("should find the adjacent next siblings of a deeply nested node", () => {
const [p] = insertTestHtml("<p><b>ab<i>cd<u>ef</u>gh<span>ij</span>kl</i>mn</b>op</p>");
const gh = p.firstChild.childNodes[1].childNodes[2];
const span = gh.nextSibling;
const kl = span.nextSibling;
const result = getAdjacentNextSiblings(gh);
expect(result).toEqual([span, kl]);
});
test("should find no adjacent next siblings of a deeply nested node", () => {
const [p] = insertTestHtml("<p><b>ab<i>cd<u>ef</u>gh<span>ij</span>kl</i>mn</b>op</p>");
const ij = p.firstChild.childNodes[1].childNodes[3].firstChild;
const result = getAdjacentNextSiblings(ij);
expect(result).toEqual([]);
});
test("should find only the adjacent next siblings of a deeply nested node that are elements", () => {
const [p] = insertTestHtml("<p><b>ab<i>cd<u>ef</u>gh<span>ij</span>kl</i>mn</b>op</p>");
const gh = p.firstChild.childNodes[1].childNodes[2];
const span = gh.nextSibling;
const result = getAdjacentNextSiblings(gh, (node) => node.nodeType === Node.ELEMENT_NODE);
expect(result).toEqual([span]);
});
test("should find only the adjacent next siblings of a deeply nested node that are text nodes (none)", () => {
const [p] = insertTestHtml("<p><b>ab<i>cd<u>ef</u>gh<span>ij</span>kl</i>mn</b>op</p>");
const gh = p.firstChild.childNodes[1].childNodes[2];
const result = getAdjacentNextSiblings(gh, (node) => node.nodeType === Node.TEXT_NODE);
expect(result).toEqual([]);
});
});
describe("getAdjacents", () => {
test("should find the adjacent siblings of a deeply nested node", () => {
const [p] = insertTestHtml("<p><b>ab<i>cd<u>ef</u>gh<span>ij</span>kl</i>mn</b>op</p>");
const gh = p.firstChild.childNodes[1].childNodes[2];
const u = gh.previousSibling;
const cd = u.previousSibling;
const span = gh.nextSibling;
const kl = span.nextSibling;
const result = getAdjacents(gh);
expect(result).toEqual([cd, u, gh, span, kl]);
});
test("should find no adjacent siblings of a deeply nested node", () => {
const [p] = insertTestHtml("<p><b>ab<i>cd<u>ef</u>gh<span>ij</span>kl</i>mn</b>op</p>");
const ij = p.firstChild.childNodes[1].childNodes[3].firstChild;
const result = getAdjacents(ij);
expect(result).toEqual([ij]);
});
test("should find the adjacent siblings of a deeply nested node that are elements", () => {
const [p] = insertTestHtml(
"<p><b>ab<i>cd<u>ef</u><span>gh</span><span>ij</span>kl</i>mn</b>op</p>"
);
const gh = p.firstChild.childNodes[1].childNodes[2];
const u = gh.previousSibling;
const span = gh.nextSibling;
const result = getAdjacents(gh, (node) => node.nodeType === Node.ELEMENT_NODE);
expect(result).toEqual([u, gh, span]);
});
test("should return an empty array if the given node is not satisfying the given predicate", () => {
const [p] = insertTestHtml(
"<p><b>ab<i>cd<u>ef</u><a>gh</a>ij<span>kl</span>mn</i>op</b>qr</p>"
);
const a = p.querySelector("a");
const result = getAdjacents(a, (node) => node.nodeType === Node.TEXT_NODE);
expect(result).toEqual([]);
});
});
describe("getCommonAncestor", () => {
let root, p1, p2, p3, span1, span2, li1, li2, li3, ol;
const prepareHtml = () => {
[root] = insertTestHtml(
unformat(`
<div>
<p> paragraph 1 </p>
<p>
paragraph 2
<span> span1 </span>
<span> span2 </span>
<p/>
<ul>
<li><p> list item 1 </p>
<ol>
<li> list item 2 </li>
<li> list item 3 </li>
</ol>
</li>
</ul>
</div>
`)
);
[p1, p2, p3] = root.querySelectorAll("p");
[span1, span2] = root.querySelectorAll("span");
[li1, li2, li3] = root.querySelectorAll("li");
[ol] = root.querySelectorAll("ol");
};
test("should return null if no nodes are provided", () => {
prepareHtml();
const result = getCommonAncestor([]);
expect(result).toBe(null);
});
test("should return the node itself if only one node is provided", () => {
prepareHtml();
const result = getCommonAncestor([p1]);
expect(result).toBe(p1);
});
test("should return the node itself if the same node is provided twice", () => {
prepareHtml();
const result = getCommonAncestor([p1, p1]);
expect(result).toBe(p1);
});
test("should return null if there's no common ancestor within the root", () => {
prepareHtml();
let result = getCommonAncestor([span1, span2], p1);
expect(result).toBe(null);
result = getCommonAncestor([ol], p3);
expect(result).toBe(null);
});
test("should return the common ancestor element of two nodes", () => {
prepareHtml();
let result = getCommonAncestor([span1, span2]);
expect(result).toBe(p2);
result = getCommonAncestor([li2, li3]);
expect(result).toBe(ol);
});
test("should return the common ancestor element of multiple nodes", () => {
prepareHtml();
let result = getCommonAncestor([li1, li2, li3], root);
expect(result).toBe(li1);
result = getCommonAncestor([p2, span1, span2], root);
expect(result).toBe(p2);
result = getCommonAncestor([span1, li1, ol], root);
expect(result).toBe(root);
});
});

View file

@ -0,0 +1,105 @@
import { describe, expect, test } from "@odoo/hoot";
import { unformat } from "../_helpers/format";
import { base64Img } from "../_helpers/editor";
describe("unformat", () => {
test("should trim space between a tag name and an attribute", () => {
expect(
unformat(`<div
class="something">`)
).toBe(`<div class="something">`);
});
test("should trim space at the beginning and end of the string", () => {
expect(
unformat(`
<div>abc</div>
`)
).toBe(`<div>abc</div>`);
});
test("should trim space between a node and its text content", () => {
expect(
unformat(
`<div>
abc
</div>`
)
).toBe(`<div>abc</div>`);
});
test("should trim space between nodes", () => {
expect(
unformat(
`<div>abc</div>
<p>def</p>`
)
).toBe(`<div>abc</div><p>def</p>`);
});
test("should not trim space between words in text content", () => {
expect(unformat(`<div>some content</div>`)).toBe(`<div>some content</div>`);
});
test("should not remove feff characters", () => {
expect(
unformat(
`<div>
text \ufeff
</div>`
)
).toBe(`<div>text \ufeff</div>`);
});
test("should not remove spaces within an attribute", () => {
const html = `<img src="${base64Img}" class="a b"/>`;
expect(unformat(html)).toBe(html);
});
test("should unformat a complex structure", () => {
expect(
unformat(`
<div>
abc
\ufeff
<span
class="something"
contenteditable
style='font-size: 5px; font-family:"Helvetica Neue";'
data-attr="console.log('it works')"
/>
def ghi
<br><br>
</div>
<p>jkl</p>
<div class="hello"/>
<fake-node fake="true"
style='font-size: 5px; font-family:"Helvetica Neue";'>
<div>mno</div>
<p><span>pqr</span>
stu<b>
vwx
</b>
</p>
</fake-node>
`)
).toBe(
`<div>` +
`abc
\ufeff` +
`<span ` +
`class="something" ` +
`contenteditable ` +
`style='font-size: 5px; font-family:"Helvetica Neue";' ` +
`data-attr="console.log('it works')"` +
`/>` +
`def ghi` +
`<br><br>` +
`</div>` +
`<p>jkl</p>` +
`<div class="hello"/>` +
`<fake-node fake="true" ` +
`style='font-size: 5px; font-family:"Helvetica Neue";'>` +
`<div>mno</div>` +
`<p><span>pqr</span>` +
`stu<b>` +
`vwx` +
`</b>` +
`</p>` +
`</fake-node>`
);
});
});

View file

@ -0,0 +1,14 @@
import { convertNumericToUnit, getHtmlStyle } from "@html_editor/utils/formatting";
import { describe, expect, test } from "@odoo/hoot";
describe("NumericToUnitConverter", () => {
test("Convert with maximum float precision", () => {
// The conversion might give a result off by exactly `Number.EPSILON`.
// However `toBeCloseTo` only succeed if the result margin is strictly
// less than the expected margin. So `2 * Number.EPSILON` is used.
expect(convertNumericToUnit(1400, "ms", "s")).toBeCloseTo(1.4, {
margin: 2 * Number.EPSILON,
});
expect(convertNumericToUnit(19, "px", "rem", getHtmlStyle(document))).toBe(1.1875);
});
});

View file

@ -0,0 +1,110 @@
import {
getAffineApproximation,
getProjective,
transform,
} from "@html_editor/utils/perspective_utils";
const epsilon = 100 * Number.EPSILON;
function midpoint([x0, y0], [x1, y1], weight = 0.5) {
return [weight * (x0 + y0), (1.0 - weight) * (x1 + y1)];
}
function pointEqual(a, b) {
expect(Math.abs(a[0] - b[0])).toBeLessThan(epsilon);
expect(Math.abs(a[1] - b[1])).toBeLessThan(epsilon);
}
function notPointEqual(a, b) {
expect(Math.abs(a[0] - b[0]) > epsilon || Math.abs(a[1] - b[1]) > epsilon).toBe(true);
}
import { describe, expect, test } from "@odoo/hoot";
describe("Perspective Utils", () => {
test("Should correctly transform 2D points using a projective transformation", async () => {
const translation = [
[0, 0, 3],
[0, 0, 5],
[0, 0, 1],
];
pointEqual(transform(translation, [0, 0]), [3, 5]);
const scale = [
[2, 0, 0],
[0, 0.5, 0],
[0, 0, 1],
];
pointEqual(transform(scale, [1, 1]), [2, 0.5]);
const perspective = [
[1, 0, 0],
[0, 1, 0],
[1, 1, 1],
];
pointEqual(transform(perspective, [4, 5]), [0.4, 0.5]);
});
test("Should find an affine approximation of a projective transformation", async () => {
const a = [0, 0];
const b = [1, 0];
const c = [0, 1];
const projective = [
[1, 2, 3],
[2, 4, 5],
[2, 2, 1],
];
const affine = getAffineApproximation(projective, [a, b, c]);
pointEqual(transform(projective, a), transform(affine, a));
pointEqual(transform(projective, b), transform(affine, b));
pointEqual(transform(projective, c), transform(affine, c));
notPointEqual((projective, [1, 1]), transform(affine, [1, 1]));
});
test("Should identically transform common edge points of two affine approximations", async () => {
const a = [0, 0];
const b = [1, 0];
const c = [1, 1];
const d = [0, 1];
const projective = [
[1, 2, 3],
[2, 4, 5],
[2, 2, 1],
];
const affine1 = getAffineApproximation(projective, [a, b, d]);
const affine2 = getAffineApproximation(projective, [b, c, d]);
pointEqual(
transform(affine1, midpoint(b, d, 0.2)),
transform(affine2, midpoint(b, d, 0.2))
);
pointEqual(
transform(affine1, midpoint(b, d, 0.5)),
transform(affine2, midpoint(b, d, 0.5))
);
pointEqual(
transform(affine1, midpoint(b, d, 0.8)),
transform(affine2, midpoint(b, d, 0.8))
);
});
test("Should find a projective transformation for a given quadrilateral", async () => {
const width = 2;
const height = 3;
const a = [0.1, 0.3];
const b = [1.9, 0.1];
const c = [1.7, 2.9];
const d = [0.1, 2.8];
const projective = getProjective(width, height, [a, b, c, d]);
pointEqual(a, transform(projective, [0, 0]));
pointEqual(b, transform(projective, [width, 0]));
pointEqual(c, transform(projective, [width, height]));
pointEqual(d, transform(projective, [0, height]));
});
});

View file

@ -0,0 +1,302 @@
import {
boundariesIn,
boundariesOut,
childNodeIndex,
endPos,
leftPos,
nodeSize,
rightPos,
startPos,
} from "@html_editor/utils/position";
import { describe, expect, test } from "@odoo/hoot";
import { insertTestHtml } from "../_helpers/editor";
describe("leftPos", () => {
test("should return the left position of a lonely text node", () => {
const [p] = insertTestHtml("<p>a</p>");
const a = p.firstChild;
const result = leftPos(a);
expect(result).toEqual([p, 0]);
});
test("should return the left position of an inline element", () => {
const [p] = insertTestHtml("<p><b>a</b></p>");
const b = p.childNodes[0];
const result = leftPos(b);
expect(result).toEqual([p, 0]);
});
test("should return the left position of an inline element with whitespace", () => {
const [p] = insertTestHtml(
`<p>
<b>a</b>
</p>`
);
const b = p.childNodes[1];
const result = leftPos(b);
expect(result).toEqual([p, 1]);
});
test("should return the left position of sibling-rich inline element", () => {
const [p] = insertTestHtml(
`<p>
abc<b>def</b>ghi<i>jkl</i><span><u>mno</u></span>pqr
</p>`
);
const i = p.childNodes[3];
const result = leftPos(i);
expect(result).toEqual([p, 3]);
});
});
describe("rightPos", () => {
test("should return the right position of a lonely text node", () => {
const [p] = insertTestHtml("<p>a</p>");
const a = p.firstChild;
const result = rightPos(a);
expect(result).toEqual([p, 1]);
});
test("should return the right position of an inline element", () => {
const [p] = insertTestHtml("<p><b>a</b></p>");
const b = p.childNodes[0];
const result = rightPos(b);
expect(result).toEqual([p, 1]);
});
test("should return the right position of an inline element with whitespace", () => {
const [p] = insertTestHtml(
`<p>
<b>a</b>
</p>`
);
const b = p.childNodes[1];
const result = rightPos(b);
expect(result).toEqual([p, 2]);
});
test("should return the right position of sibling-rich inline element", () => {
const [p] = insertTestHtml(
`<p>
abc<b>def</b>ghi<i>jkl</i><span><u>mno</u></span>pqr
</p>`
);
const i = p.childNodes[3];
const result = rightPos(i);
expect(result).toEqual([p, 4]);
});
});
describe("boundariesOut", () => {
test("should return the outside bounds of a lonely text node", () => {
const [p] = insertTestHtml("<p>a</p>");
const a = p.firstChild;
const result = boundariesOut(a);
expect(result).toEqual([p, 0, p, 1]);
});
test("should return the outside bounds of an inline element", () => {
const [p] = insertTestHtml("<p><b>a</b></p>");
const b = p.childNodes[0];
const result = boundariesOut(b);
expect(result).toEqual([p, 0, p, 1]);
});
test("should return the outside bounds of an inline element with whitespace", () => {
const [p] = insertTestHtml(
`<p>
<b>a</b>
</p>`
);
const b = p.childNodes[1];
const result = boundariesOut(b);
expect(result).toEqual([p, 1, p, 2]);
});
test("should return the outside bounds of sibling-rich inline element", () => {
const [p] = insertTestHtml(
`<p>
abc<b>def</b>ghi<i>jkl</i><span><u>mno</u></span>pqr
</p>`
);
const i = p.childNodes[3];
const result = boundariesOut(i);
expect(result).toEqual([p, 3, p, 4]);
});
});
describe("startPos", () => {
test("should return the start position of a lonely text node", () => {
const [p] = insertTestHtml("<p>a</p>");
const a = p.firstChild;
const result = startPos(a);
expect(result).toEqual([a, 0]);
});
test("should return the start position of an inline element", () => {
const [p] = insertTestHtml("<p><b>a</b></p>");
const b = p.childNodes[0];
const result = startPos(b);
expect(result).toEqual([b, 0]);
});
test("should return the start position of an inline element with whitespace", () => {
const [p] = insertTestHtml(
`<p>
<b>a</b>
</p>`
);
const b = p.childNodes[1];
const result = startPos(b);
expect(result).toEqual([b, 0]);
});
test("should return the start position of sibling-rich inline element", () => {
const [p] = insertTestHtml(
`<p>
abc<b>def</b>ghi<i>jkl</i><span><u>mno</u></span>pqr
</p>`
);
const i = p.childNodes[3];
const result = startPos(i);
expect(result).toEqual([i, 0]);
});
});
describe("endPos", () => {
test("should return the end position of a lonely text node", () => {
const [p] = insertTestHtml("<p>a</p>");
const a = p.firstChild;
const result = endPos(a);
expect(result).toEqual([a, 1]);
});
test("should return the end position of an inline element", () => {
const [p] = insertTestHtml("<p><b>a</b></p>");
const b = p.childNodes[0];
const result = endPos(b);
expect(result).toEqual([b, 1]);
});
test("should return the end position of an inline element with whitespace", () => {
const [p] = insertTestHtml(
`<p>
<b>a</b>
</p>`
);
const b = p.childNodes[1];
const result = endPos(b);
expect(result).toEqual([b, 1]);
});
test("should return the end position of sibling-rich inline element", () => {
const [p] = insertTestHtml(
`<p>
abc<b>def</b>ghi<i>jkl</i><span><u>mno</u></span>pqr
</p>`
);
const i = p.childNodes[3];
const result = endPos(i);
expect(result).toEqual([i, 1]);
});
});
describe("boundariesIn", () => {
test("should return the inside bounds of a lonely text node", () => {
const [p] = insertTestHtml("<p>a</p>");
const a = p.firstChild;
const result = boundariesIn(a);
expect(result).toEqual([a, 0, a, 1]);
});
test("should return the inside bounds of an inline element", () => {
const [p] = insertTestHtml("<p><b>a</b></p>");
const b = p.childNodes[0];
const result = boundariesIn(b);
expect(result).toEqual([b, 0, b, 1]);
});
test("should return the inside bounds of an inline element with whitespace", () => {
const [p] = insertTestHtml(
`<p>
<b>a</b>
</p>`
);
const b = p.childNodes[1];
const result = boundariesIn(b);
expect(result).toEqual([b, 0, b, 1]);
});
test("should return the inside bounds of sibling-rich inline element", () => {
const [p] = insertTestHtml(
`<p>
abc<b>def</b>ghi<i>jkl</i><span><u>mno</u></span>pqr
</p>`
);
const i = p.childNodes[3];
const result = boundariesIn(i);
expect(result).toEqual([i, 0, i, 1]);
});
});
describe("childNodeIndex", () => {
test("should return the index of a lonely text node", () => {
const [p] = insertTestHtml("<p>a</p>");
p.childNodes.forEach((child, index) => {
expect(childNodeIndex(child)).toBe(index);
});
});
test("should return the index of an inline element", () => {
const [p] = insertTestHtml("<p><b>a</b></p>");
p.childNodes.forEach((child, index) => {
expect(childNodeIndex(child)).toBe(index);
});
});
test("should return the index of an inline element with whitespace", () => {
const [p] = insertTestHtml(
`<p>
<b>a</b>
</p>`
);
p.childNodes.forEach((child, index) => {
expect(childNodeIndex(child)).toBe(index);
});
});
test("should return the index of sibling-rich inline element", () => {
const [p] = insertTestHtml(
`<p>
abc<b>def</b>ghi<i>jkl</i><span><u>mno</u></span>pqr
</p>`
);
p.childNodes.forEach((child, index) => {
expect(childNodeIndex(child)).toBe(index);
});
});
});
describe("nodeSize", () => {
test("should return the size of a simple element", () => {
const [p] = insertTestHtml("<p>a</p>");
const result = nodeSize(p);
expect(result).toBe(1);
});
test("should return the size of a text node", () => {
const [p] = insertTestHtml("<p>abc</p>");
const result = nodeSize(p.firstChild);
expect(result).toBe(3);
});
test("should return the size of a child-rich element", () => {
const [p] = insertTestHtml(
`<p>
a<b>bc</b>d<i>ef</i>
</p>`
);
const result = nodeSize(p);
expect(result).toBe(5);
});
});

View file

@ -0,0 +1,156 @@
import { expect, test } from "@odoo/hoot";
import { URL_REGEX } from "@html_editor/utils/regex";
function testUrlRegex(content, { expectedUrl, insideText } = {}) {
const message = expectedUrl
? `should have the text be "${content}" with one link ${expectedUrl}`
: `should be a link: ${content}`;
test(message, () => {
if (insideText) {
expectedUrl = expectedUrl || content;
content = `abc ${content} abc`;
}
if (expectedUrl) {
const match = content.match(URL_REGEX);
expect(expectedUrl).toBe(match && match[0]);
} else {
expect(content).toMatch(URL_REGEX);
}
});
}
function testNotUrlRegex(content, { insideText } = {}) {
test(`should NOT be/content a link: ${content}`, () => {
if (insideText) {
content = `abc ${content} abc`;
}
expect(content).not.toMatch(URL_REGEX);
});
}
testUrlRegex("google.com");
testUrlRegex("a google.com b", { expectedUrl: "google.com" });
// Url separator
testUrlRegex("google.com/", { expectedUrl: "google.com/" });
testUrlRegex("google.com?", { expectedUrl: "google.com?" });
testUrlRegex("google.com#", { expectedUrl: "google.com#" });
testUrlRegex("google.com!", { expectedUrl: "google.com" });
testUrlRegex("google.com)", { expectedUrl: "google.com" });
testUrlRegex("google.com(", { expectedUrl: "google.com" });
testUrlRegex("google.com/ a", { expectedUrl: "google.com/" });
testUrlRegex("google.com. a", { expectedUrl: "google.com" });
testUrlRegex("google.com, a", { expectedUrl: "google.com" });
// Some special characters should not be included if at the end.
testUrlRegex("google.com/.", { expectedUrl: "google.com/" });
testUrlRegex("google.com/,", { expectedUrl: "google.com/" });
testUrlRegex("google.com/)", { expectedUrl: "google.com/" });
testUrlRegex("google.com/]", { expectedUrl: "google.com/" });
testUrlRegex("google.com/}", { expectedUrl: "google.com/" });
testUrlRegex("google.com/'", { expectedUrl: "google.com/" });
testUrlRegex('google.com/"', { expectedUrl: "google.com/" });
testUrlRegex("google.com#.", { expectedUrl: "google.com#" });
testUrlRegex("google.com#,", { expectedUrl: "google.com#" });
testUrlRegex("google.com#)", { expectedUrl: "google.com#" });
testUrlRegex("google.com#]", { expectedUrl: "google.com#" });
testUrlRegex("google.com#}", { expectedUrl: "google.com#" });
testUrlRegex("google.com#'", { expectedUrl: "google.com#" });
testUrlRegex('google.com#"', { expectedUrl: "google.com#" });
testUrlRegex("google.com?,", { expectedUrl: "google.com?" });
testUrlRegex("google.com?.", { expectedUrl: "google.com?" });
testUrlRegex("google.com?)", { expectedUrl: "google.com?" });
testUrlRegex("google.com?]", { expectedUrl: "google.com?" });
testUrlRegex("google.com?}", { expectedUrl: "google.com?" });
testUrlRegex("google.com?'", { expectedUrl: "google.com?" });
testUrlRegex('google.com?"', { expectedUrl: "google.com?" });
// The previous special character should be included when they are nt at the end.
testUrlRegex("google.com/.a", { expectedUrl: "google.com/.a" });
testUrlRegex("google.com/,a", { expectedUrl: "google.com/,a" });
testUrlRegex("google.com/)a", { expectedUrl: "google.com/)a" });
testUrlRegex("google.com/]a", { expectedUrl: "google.com/]a" });
testUrlRegex("google.com/}a", { expectedUrl: "google.com/}a" });
testUrlRegex("google.com/'a", { expectedUrl: "google.com/'a" });
testUrlRegex('google.com/"a', { expectedUrl: 'google.com/"a' });
// Other special character can be included at the end.
testUrlRegex("google.com/(", { expectedUrl: "google.com/(" });
testUrlRegex("google.com/[", { expectedUrl: "google.com/[" });
testUrlRegex("google.com/{", { expectedUrl: "google.com/{" });
testUrlRegex("google.com?(", { expectedUrl: "google.com?(" });
testUrlRegex("google.com?[", { expectedUrl: "google.com?[" });
testUrlRegex("google.com?{", { expectedUrl: "google.com?{" });
testUrlRegex("google.com#(", { expectedUrl: "google.com#(" });
testUrlRegex("google.com#[", { expectedUrl: "google.com#[" });
testUrlRegex("google.com#{", { expectedUrl: "google.com#{" });
testUrlRegex("google.co.uk");
testUrlRegex("google123.com");
testUrlRegex("http://google.com");
testUrlRegex("http://google123.com");
testUrlRegex("https://google.com");
testUrlRegex("https://google123.com");
testUrlRegex("https://www.google.com");
testUrlRegex("https://google.shop");
testNotUrlRegex("google.shop");
testUrlRegex("google.com/");
testUrlRegex("google.com/path/123/abc/4");
testUrlRegex("http://google.com/");
testUrlRegex("http://google.com/home");
testUrlRegex("http://google.com/home/");
testUrlRegex("https://google.com/");
testUrlRegex("https://google.co.uk/");
testUrlRegex("https://www.google.com/");
testNotUrlRegex("google.shop/");
testUrlRegex("http://google.com/foo#test");
testUrlRegex("http://google.com/#test");
testNotUrlRegex("a.bcd.ef");
testUrlRegex("a.bc.de");
testNotUrlRegex("a.bc.d");
testNotUrlRegex("a.b.bc");
testNotUrlRegex("20.08.2022");
testNotUrlRegex("31.12");
// Url data and anchors count as part of the url.
testUrlRegex("google.com?data=hello", { expectedUrl: "google.com?data=hello" });
testUrlRegex("google.com/?data=hello", { expectedUrl: "google.com/?data=hello" });
testUrlRegex("google.com/foo/?data=hello", { expectedUrl: "google.com/foo/?data=hello" });
testUrlRegex("google.com/foo/?data1=hello1&data2=hello2", {
expectedUrl: "google.com/foo/?data1=hello1&data2=hello2",
});
testUrlRegex("google.com/.?data=hello", { expectedUrl: "google.com/.?data=hello" });
testUrlRegex("google.com?data=hello#anchor", { expectedUrl: "google.com?data=hello#anchor" });
testUrlRegex("google.com/?data=hello#anchor", { expectedUrl: "google.com/?data=hello#anchor" });
testUrlRegex("google.com/.?data=hello#anchor", { expectedUrl: "google.com/.?data=hello#anchor" });
testUrlRegex("google.com/foo/?data=hello&data2=foo#anchor", {
expectedUrl: "google.com/foo/?data=hello&data2=foo#anchor",
});
// Url containing some special characters
testUrlRegex("www.google.com/path/1-2-3", { expectedUrl: "www.google.com/path/1-2-3" });
testUrlRegex("https://google.com/abc..def", { expectedUrl: "https://google.com/abc..def" });
testUrlRegex("https://google.com/a/b+c@d", { expectedUrl: "https://google.com/a/b+c@d" });
testUrlRegex("sub.example-website.com", { expectedUrl: "sub.example-website.com" });
testUrlRegex("http://sub.example-website.com", { expectedUrl: "http://sub.example-website.com" });
testUrlRegex("http://user:password@example.com", {
expectedUrl: "http://user:password@example.com",
});
testUrlRegex("http://google.com/a_b", { expectedUrl: "http://google.com/a_b" });
testUrlRegex("https://google.com?query=ab.cd", { expectedUrl: "https://google.com?query=ab.cd" });
testUrlRegex(`google.com/'ab'/cd`, { expectedUrl: "google.com/'ab'/cd" });
testUrlRegex(`www.google.com/a!b/c?d,e,f#g!i`, { expectedUrl: "www.google.com/a!b/c?d,e,f#g!i" });
testUrlRegex(`www.google.com/a%b%c`, { expectedUrl: "www.google.com/a%b%c" });
testUrlRegex(`http://google.com?a.b.c&d!e#e'f`, { expectedUrl: "http://google.com?a.b.c&d!e#e'f" });
// URL inside text
testUrlRegex("foo.com", { insideText: true });
testNotUrlRegex("foo.else", { insideText: true });
testUrlRegex("www.abc.abc", { insideText: true });
testUrlRegex("abc.abc.com", { insideText: true });
testNotUrlRegex("abc.abc.abc", { insideText: true });
testUrlRegex("http://abc.abc.abc", { insideText: true });
testUrlRegex("https://abc.abc.abc", { insideText: true });
testUrlRegex("1234-abc.runbot007.odoo.com/web#id=3&menu_id=221", { insideText: true });
testUrlRegex("https://1234-abc.runbot007.odoo.com/web#id=3&menu_id=221", { insideText: true });

View file

@ -0,0 +1,10 @@
import { withSequence } from "@html_editor/utils/resource";
import { test, expect } from "@odoo/hoot";
test("withSequence throws if sequenceNumber is not a number", () => {
for (const value of [undefined, null, "bonjour", { random: "object" }, true, false]) {
expect(() => {
withSequence(value, { a: "resource" });
}).toThrow();
}
});

View file

@ -0,0 +1,165 @@
import { DIRECTIONS, nodeSize } from "@html_editor/utils/position";
import {
ensureFocus,
getAdjacentCharacter,
getCursorDirection,
} from "@html_editor/utils/selection";
import { describe, expect, test } from "@odoo/hoot";
import { dispatch } from "@odoo/hoot-dom";
import { insertText, setupEditor, testEditor } from "../_helpers/editor";
import { unformat } from "../_helpers/format";
import { setSelection } from "../_helpers/selection";
describe("ensureFocus", () => {
// TODO @phoenix: unskipped when ensureFocus is add in the code base
test.todo(
"should preserve the focus on the child of this.editable when executing a powerbox command even if it is enclosed in a contenteditable=false",
async () => {
await testEditor({
contentBefore: unformat(`
<div contenteditable="false"><div contenteditable="true">
<p>[]<br></p>
</div></div>
<p><br></p>`),
stepFunction: async (editor) => {
const sel = document.getSelection();
const element = sel.anchorNode;
await dispatch(editor.editable, "keydown", { key: "/" });
await insertText(editor, "/");
await dispatch(editor.editable, "keyup", { key: "/" });
await insertText(editor, "h2");
await dispatch(element, "keyup", { key: "2" });
await dispatch(editor.editable, "keydown", { key: "Enter" });
const activeElement = document.activeElement;
editor.shared.selection.setCursorStart(activeElement.lastElementChild);
// TODO @phoenix still need it ?
// await nextTickFrame();
},
contentAfter: unformat(`
<div contenteditable="false"><div contenteditable="true">
<h2>[]<br></h2>
</div></div>
<p><br></p>`),
});
}
);
test.todo(
"should preserve the focus on the child of this.editable even if it is enclosed in a contenteditable=false",
async () => {
await testEditor({
contentBefore: unformat(`
<div contenteditable="false"><div contenteditable="true">
<p>[]<br></p>
</div></div>
<p><br></p>`),
stepFunction: async (editor) => {
ensureFocus(editor.editable);
// TODO @phoenix still need it ?
// await nextTickFrame();
let activeElement = document.activeElement;
editor.shared.selection.setCursorStart(activeElement.lastElementChild);
await insertText(editor, "focusWasConserved");
// Proof that a simple call to Element.focus would change
// the focus in this case.
editor.editable.focus();
// TODO @phoenix still need it ?
// await nextTickFrame();
activeElement = document.activeElement;
editor.shared.selection.setCursorStart(activeElement.lastElementChild);
// TODO @phoenix still need it ?
// await nextTickFrame();
},
contentAfter: unformat(`
<div contenteditable="false"><div contenteditable="true">
<p>focusWasConserved</p>
</div></div>
<p>[]<br></p>`),
});
}
);
test.todo(
"should update the focus when the active element is not the focus target",
async () => {
await testEditor({
contentBefore: unformat(`
<div contenteditable="false"><div contenteditable="true">
<p>[]<br></p>
</div></div>
<div contenteditable="false"><div id="target" contenteditable="true">
<p><br></p>
</div></div>`),
stepFunction: async (editor) => {
const element = editor.editable.querySelector("#target");
ensureFocus(element);
// TODO @phoenix still need it ?
// await nextTickFrame();
const activeElement = document.activeElement;
editor.shared.selection.setCursorStart(activeElement.lastElementChild);
// TODO @phoenix still need it ?
// await nextTickFrame();
},
contentAfter: unformat(`
<div contenteditable="false"><div contenteditable="true">
<p><br></p>
</div></div>
<div contenteditable="false"><div id="target" contenteditable="true">
<p>[]<br></p>
</div></div>`),
});
}
);
});
describe("getCursorDirection", () => {
test("should identify a forward selection", async () => {
await testEditor({
contentBefore: "<p>a[bc]d</p>",
stepFunction: (editor) => {
const { anchorNode, anchorOffset, focusNode, focusOffset } =
editor.document.getSelection();
expect(getCursorDirection(anchorNode, anchorOffset, focusNode, focusOffset)).toBe(
DIRECTIONS.RIGHT
);
},
});
});
test("should identify a backward selection", async () => {
await testEditor({
contentBefore: "<p>a]bc[d</p>",
stepFunction: (editor) => {
const { anchorNode, anchorOffset, focusNode, focusOffset } =
editor.document.getSelection();
expect(getCursorDirection(anchorNode, anchorOffset, focusNode, focusOffset)).toBe(
DIRECTIONS.LEFT
);
},
});
});
test("should identify a collapsed selection", async () => {
await testEditor({
contentBefore: "<p>ab[]cd</p>",
stepFunction: (editor) => {
const { anchorNode, anchorOffset, focusNode, focusOffset } =
editor.document.getSelection();
expect(getCursorDirection(anchorNode, anchorOffset, focusNode, focusOffset)).toBe(
false
);
},
});
});
});
describe("getAdjacentCharacter", () => {
test("should return the ZWS character before the cursor", async () => {
const { editor, el } = await setupEditor("<p><span>abc</span>\u200b</p>");
const p = el.firstChild;
// Place the cursor at the end of the P (not in a leaf node)
setSelection({ anchorNode: p, anchorOffset: nodeSize(p) });
const selection = editor.document.getSelection();
expect(getAdjacentCharacter(selection, "previous", el)).toBe("\u200b");
});
});

View file

@ -0,0 +1,39 @@
import { describe, expect, test } from "@odoo/hoot";
import { trackOccurrences, trackOccurrencesPair } from "@html_editor/utils/tracking";
describe("trackOccurrences", () => {
test("should return true only the first occurrence of each key", () => {
const isFirstOccurrence = trackOccurrences();
expect(isFirstOccurrence("a")).toEqual(true);
expect(isFirstOccurrence("b")).toEqual(true);
expect(isFirstOccurrence("a")).toEqual(false);
expect(isFirstOccurrence("b")).toEqual(false);
expect(isFirstOccurrence("a")).toEqual(false);
expect(isFirstOccurrence("b")).toEqual(false);
expect(isFirstOccurrence("c")).toEqual(true);
expect(isFirstOccurrence("c")).toEqual(false);
});
});
describe("trackOccurrencesPair", () => {
test("should return true only the first occurrence of each tuple", () => {
const isFirstOccurrence = trackOccurrencesPair();
expect(isFirstOccurrence("a", "b")).toEqual(true);
expect(isFirstOccurrence("b", "a")).toEqual(true);
expect(isFirstOccurrence("b", "c")).toEqual(true);
expect(isFirstOccurrence("a", "b")).toEqual(false);
expect(isFirstOccurrence("b", "a")).toEqual(false);
expect(isFirstOccurrence("b", "c")).toEqual(false);
expect(isFirstOccurrence("d", "e")).toEqual(true);
expect(isFirstOccurrence("a", "b")).toEqual(false);
expect(isFirstOccurrence("b", "a")).toEqual(false);
expect(isFirstOccurrence("b", "c")).toEqual(false);
expect(isFirstOccurrence("d", "e")).toEqual(false);
});
});