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,575 @@
import { parseHTML } from "@html_editor/utils/html";
import { describe, expect, test } from "@odoo/hoot";
import { tick } from "@odoo/hoot-mock";
import { setupEditor, testEditor } from "../_helpers/editor";
import { unformat } from "../_helpers/format";
import { getContent } from "../_helpers/selection";
import { cleanHints } from "../_helpers/dispatch";
import { MAIN_PLUGINS } from "@html_editor/plugin_sets";
import { addStep } from "../_helpers/user_actions";
import { Plugin } from "@html_editor/plugin";
function span(text) {
const span = document.createElement("span");
span.innerText = text;
span.classList.add("a");
return span;
}
const insertHTML = (html) => (editor) => {
editor.shared.dom.insert(parseHTML(editor.document, html));
editor.shared.history.addStep();
};
describe("collapsed selection", () => {
test("should insert html in an empty paragraph / empty editable", async () => {
await testEditor({
contentBefore: "<p>[]<br></p>",
stepFunction: insertHTML('<i class="fa fa-pastafarianism"></i>'),
contentAfterEdit:
'<p>\ufeff<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>\ufeff[]</p>',
contentAfter: '<p><i class="fa fa-pastafarianism"></i>[]</p>',
});
});
test("should insert html after an empty paragraph", async () => {
await testEditor({
// This scenario is only possible with the allowInlineAtRoot option.
contentBefore: "<p><br></p>[]",
stepFunction: insertHTML('<i class="fa fa-pastafarianism"></i>'),
contentAfterEdit:
'<p><br></p><i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>[]',
contentAfter: '<p><br></p><i class="fa fa-pastafarianism"></i>[]',
config: { allowInlineAtRoot: true },
});
});
test("should insert html between two letters", async () => {
await testEditor({
contentBefore: "<p>a[]b</p>",
stepFunction: insertHTML('<i class="fa fa-pastafarianism"></i>'),
contentAfterEdit:
'<p>a\ufeff<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>\ufeff[]b</p>',
contentAfter: '<p>a<i class="fa fa-pastafarianism"></i>[]b</p>',
});
});
test("should insert html in between naked text in the editable", async () => {
await testEditor({
contentBefore: "<p>a[]b</p>",
stepFunction: insertHTML('<i class="fa fa-pastafarianism"></i>'),
contentAfterEdit:
'<p>a\ufeff<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>\ufeff[]b</p>',
contentAfter: '<p>a<i class="fa fa-pastafarianism"></i>[]b</p>',
});
});
test("should insert several html nodes in between naked text in the editable", async () => {
await testEditor({
contentBefore: "<p>a[]e<br></p>",
stepFunction: insertHTML("<p>b</p><p>c</p><p>d</p>"),
contentAfter: "<p>ab</p><p>c</p><p>d[]e</p>",
});
});
test("should keep a paragraph after a div block", async () => {
await testEditor({
contentBefore: "<p>[]<br></p>",
stepFunction: insertHTML("<div><p>content</p></div>"),
contentAfter: "<div><p>content</p></div><p>[]<br></p>",
});
});
test("should not split a pre to insert another pre but just insert the text", async () => {
await testEditor({
contentBefore: "<pre>abc[]<br>ghi</pre>",
stepFunction: insertHTML("<pre>def</pre>"),
contentAfter: "<pre>abcdef[]<br>ghi</pre>",
});
});
test('should keep an "empty" block which contains fontawesome nodes when inserting multiple nodes', async () => {
await testEditor({
contentBefore: "<p>content[]</p>",
stepFunction: async (editor) => {
editor.shared.dom.insert(
parseHTML(
editor.document,
'<p>unwrapped</p><div><i class="fa fa-circle-o-notch"></i></div><p>culprit</p><p>after</p>'
)
);
editor.shared.history.addStep();
},
contentAfter:
'<p>contentunwrapped</p><div><i class="fa fa-circle-o-notch"></i></div><p>culprit</p><p>after[]</p>',
});
});
test("should not unwrap single node if the selection anchorNode is the editable", async () => {
await testEditor({
contentBefore: "<p>content</p>",
stepFunction: async (editor) => {
editor.shared.selection.setCursorEnd(editor.editable, false);
editor.shared.selection.focusEditable();
insertHTML("<p>def</p>")(editor);
},
contentAfter: "<p>content</p><p>def[]</p>",
});
});
test("should not unwrap nodes if the selection anchorNode is the editable", async () => {
await testEditor({
contentBefore: "<p>content</p>",
stepFunction: async (editor) => {
editor.shared.selection.setCursorEnd(editor.editable);
editor.shared.selection.focusEditable();
await tick();
insertHTML("<div>abc</div><p>def</p>")(editor);
},
contentAfter: "<p>content</p><div>abc</div><p>def[]</p>",
config: { allowInlineAtRoot: true },
});
});
test('should insert an "empty" block', async () => {
await testEditor({
contentBefore: "<p>abcd[]</p>",
stepFunction: insertHTML("<p>efgh</p><p></p>"),
contentAfter: "<p>abcdefgh</p><p>[]<br></p>",
});
});
test("never unwrap tables in breakable paragrap", async () => {
// P elements' content can only be "phrasing" content
// Adding a table within p is not possible
// We have split the p and insert the table unwrapped in between
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/p
// https://developer.mozilla.org/en-US/docs/Web/HTML/Content_categories#phrasing_content
const { editor } = await setupEditor(`<p>cont[]ent</p>`, {});
insertHTML("<table><tbody><tr><td/></tr></tbody></table>")(editor);
expect(getContent(editor.editable)).toBe(
`<p>cont</p><table><tbody><tr><td></td></tr></tbody></table><p>[]ent</p>`
);
});
test("should not unwrap table in unbreakable paragraph find a suitable spot to insert table element", async () => {
// P elements' content can only be "phrasing" content
// Adding a table within an unbreakable p is not possible
// We have to find a better spot to insert the table
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/p
// https://developer.mozilla.org/en-US/docs/Web/HTML/Content_categories#phrasing_content
const { editor } = await setupEditor(`<p class="oe_unbreakable">cont[]ent</p>`, {});
insertHTML("<table><tbody><tr><td/></tr></tbody></table>")(editor);
expect(getContent(editor.editable)).toBe(
`<p class="oe_unbreakable">content[]</p><table><tbody><tr><td></td></tr></tbody></table>`
);
});
test("stops at boundary when inserting unfit content", async () => {
// P elements' content can only be "phrasing" content
// This test forces to stop at the <p contenteditable="true" />
// This test is a bit odd and whitebox but this is because multiple
// parameters of the use case are interacting
const { editor } = await setupEditor(
`<div><p class="oe_unbreakable" contenteditable="true"><b class="oe_unbreakable">cont[]ent</b></p></div>`,
{}
);
insertHTML("<table><tbody><tr><td/></tr></tbody></table>")(editor);
expect(getContent(editor.editable)).toBe(
`<div><p class="oe_unbreakable" contenteditable="true"><b class="oe_unbreakable">content[]</b><table><tbody><tr><td></td></tr></tbody></table></p></div>`
);
});
test("Should ensure a paragraph after an inserted unbreakable (add)", async () => {
const { editor } = await setupEditor(`<p>cont[]</p>`, {});
insertHTML(`<p class="oe_unbreakable">1</p>`)(editor);
expect(getContent(editor.editable)).toBe(
`<p>cont</p><p class="oe_unbreakable">1[]</p><p><br></p>`
);
});
test("Should ensure a paragraph after an inserted unbreakable (keep)", async () => {
const { editor } = await setupEditor(`<p>cont[]</p><p>+</p>`, {});
insertHTML(`<p class="oe_unbreakable">1</p>`)(editor);
expect(getContent(editor.editable)).toBe(
`<p>cont</p><p class="oe_unbreakable">1[]</p><p>+</p>`
);
});
test("Should ensure a paragraph after inserting multiple unbreakables (add)", async () => {
const { editor } = await setupEditor(`<p>cont[]</p>`, {});
editor.shared.dom.insert(
parseHTML(
editor.document,
`<p class="oe_unbreakable">1</p><p class="oe_unbreakable">2</p>`
)
);
expect(getContent(editor.editable)).toBe(
`<p>cont</p><p class="oe_unbreakable">1</p><p class="oe_unbreakable">2[]</p><p><br></p>`
);
});
test("Should ensure a paragraph after inserting multiple unbreakables (keep)", async () => {
const { editor } = await setupEditor(`<p>cont[]</p><p>+</p>`, {});
editor.shared.dom.insert(
parseHTML(
editor.document,
`<p class="oe_unbreakable">1</p><p class="oe_unbreakable">2</p>`
)
);
expect(getContent(editor.editable)).toBe(
`<p>cont</p><p class="oe_unbreakable">1</p><p class="oe_unbreakable">2[]</p><p>+</p>`
);
});
test("should unwrap a paragraphRelated element inside another", async () => {
const { editor } = await setupEditor(`<p>cont[]ent</p>`, {});
insertHTML(`<p>in</p>`)(editor);
expect(getContent(editor.editable)).toBe(`<p>contin[]ent</p>`);
});
test("should unwrap a contenteditable='true' ancestor which is not descendant of a contenteditable='false'", async () => {
const { editor } = await setupEditor(`<p>cont[]ent</p>`, {});
insertHTML(`<p contenteditable="true">in</p>`)(editor);
expect(getContent(editor.editable)).toBe(`<p>contin[]ent</p>`);
});
test("should not unwrap a contenteditable='false'", async () => {
const { editor } = await setupEditor(`<p>cont[]ent</p>`, {});
insertHTML(`<p contenteditable="false">in</p>`)(editor);
expect(getContent(editor.editable)).toBe(
`<p>cont</p><p contenteditable="false">in</p><p>[]ent</p>`
);
});
test("should not unwrap an unsplittable", async () => {
const { editor } = await setupEditor(`<p>cont[]ent</p>`, {});
insertHTML(`<p class="oe_unbreakable">in</p>`)(editor);
expect(getContent(editor.editable)).toBe(
`<p>cont</p><p class="oe_unbreakable">in[]</p><p>ent</p>`
);
});
test("should normalize the parent when inserting a single element", async () => {
const { editor } = await setupEditor(`<p>[]<br></p>`, {});
editor.shared.dom.insert(
parseHTML(editor.document, `<p data-oe-protected="true">in</p>`).firstElementChild
);
editor.shared.history.addStep();
cleanHints(editor);
expect(getContent(editor.editable, { sortAttrs: true })).toBe(
`<p contenteditable="false" data-oe-protected="true">in</p><p>[]<br></p>`
);
});
test("insert inline in empty paragraph", async () => {
const { el, editor } = await setupEditor(`<p>[]<br></p>`);
insertHTML(`<span class="a">a</span>`)(editor);
expect(getContent(el)).toBe(`<p><span class="a">a</span>[]</p>`);
});
test("insert inline at the end of a paragraph", async () => {
const { el, editor } = await setupEditor(`<p>b[]</p>`);
insertHTML(`<span class="a">a</span>`)(editor);
expect(getContent(el)).toBe(`<p>b<span class="a">a</span>[]</p>`);
});
test("insert inline at the start of a paragraph", async () => {
const { el, editor } = await setupEditor(`<p>[]b</p>`);
insertHTML(`<span class="a">a</span>`)(editor);
expect(getContent(el)).toBe(`<p><span class="a">a</span>[]b</p>`);
});
test("insert inline at the middle of a paragraph", async () => {
const { el, editor } = await setupEditor(`<p>b[]c</p>`);
insertHTML(`<span class="a">a</span>`)(editor);
expect(getContent(el)).toBe(`<p>b<span class="a">a</span>[]c</p>`);
});
test("insert block in empty paragraph", async () => {
const { el, editor } = await setupEditor(`<p>[]<br></p>`);
insertHTML(`<div class="oe_unbreakable">a</div>`)(editor);
cleanHints(editor);
expect(getContent(el)).toBe(`<div class="oe_unbreakable">a</div><p>[]<br></p>`);
});
test("insert block at the end of a paragraph", async () => {
const { el, editor } = await setupEditor(`<p>b[]</p>`);
insertHTML(`<div class="oe_unbreakable">a</div>`)(editor);
cleanHints(editor);
expect(getContent(el)).toBe(`<p>b</p><div class="oe_unbreakable">a</div><p>[]<br></p>`);
});
test("insert block at the start of a paragraph", async () => {
const { el, editor } = await setupEditor(`<p>[]b</p>`);
insertHTML(`<div class="oe_unbreakable">a</div>`)(editor);
expect(getContent(el)).toBe(`<div class="oe_unbreakable">a</div><p>[]b</p>`);
});
test("insert block at the middle of a paragraph", async () => {
const { el, editor } = await setupEditor(`<p>b[]c</p>`);
insertHTML(`<div class="oe_unbreakable">a</div>`)(editor);
expect(getContent(el)).toBe(`<p>b</p><div class="oe_unbreakable">a</div><p>[]c</p>`);
});
test("insert content processed by a plugin", async () => {
class CustomPlugin extends Plugin {
static id = "customPlugin";
static dependencies = ["dom", "selection"];
resources = {
before_insert_processors: (container) => {
const second = this.editable.querySelector(".second");
this.dependencies.selection.setCursorStart(second);
container.replaceChildren(parseHTML(this.document, `<p>surprise</p>`));
return container;
},
};
}
const { el, editor } = await setupEditor(
`<p class="first">[]?</p><p class="second">!</p>`,
{
config: {
Plugins: [...MAIN_PLUGINS, CustomPlugin],
},
}
);
editor.shared.dom.insert("notasurprise");
addStep(editor);
expect(getContent(el)).toBe(`<p class="first">?</p><p class="second">surprise[]!</p>`);
});
});
describe("not collapsed selection", () => {
test("should delete selection and insert html in its place", async () => {
await testEditor({
contentBefore: "<p>[a]</p>",
stepFunction: insertHTML('<i class="fa fa-pastafarianism"></i>'),
contentAfterEdit:
'<p>\ufeff<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>\ufeff[]</p>',
contentAfter: '<p><i class="fa fa-pastafarianism"></i>[]</p>',
});
});
test("should delete selection and insert html in its place (2)", async () => {
await testEditor({
contentBefore: "<p>a[b]c</p>",
stepFunction: insertHTML('<i class="fa fa-pastafarianism"></i>'),
contentAfterEdit:
'<p>a\ufeff<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>\ufeff[]c</p>',
contentAfter: '<p>a<i class="fa fa-pastafarianism"></i>[]c</p>',
});
});
test("should delete selection and insert html in its place (3)", async () => {
await testEditor({
contentBefore: "<h1>[abc</h1><p>def]</p>",
stepFunction: async (editor) => {
// There's an empty text node after the paragraph:
editor.editable.lastChild.after(editor.document.createTextNode(""));
insertHTML("<p>ghi</p><p>jkl</p>")(editor);
},
contentAfter: "<p>ghi</p><p>jkl[]</p>",
});
});
test("should remove a fully selected table then insert a span before it", async () => {
await testEditor({
contentBefore: unformat(
`<p>a[b</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>k]l</p>`
),
stepFunction: (editor) => {
editor.shared.dom.insert(span("TEST"));
editor.shared.history.addStep();
},
contentAfter: '<p>a<span class="a">TEST</span>[]l</p>',
});
});
test("should only remove the text content of cells in a partly selected table", async () => {
await testEditor({
contentBefore: unformat(
`<table><tbody>
<tr><td>cd</td><td class="o_selected_td">e[f</td><td>gh</td></tr>
<tr><td>ij</td><td class="o_selected_td">k]l</td><td>mn</td></tr>
<tr><td>op</td><td>qr</td><td>st</td></tr>
</tbody></table>`
),
stepFunction: (editor) => {
editor.shared.dom.insert(span("TEST"));
editor.shared.history.addStep();
},
contentAfter: unformat(
`<table><tbody>
<tr><td>cd</td><td><p><span class="a">TEST</span>[]</p></td><td>gh</td></tr>
<tr><td>ij</td><td><p><br></p></td><td>mn</td></tr>
<tr><td>op</td><td>qr</td><td>st</td></tr>
</tbody></table>`
),
});
});
test("should remove some text and a table (even if the table is partly selected)", async () => {
await testEditor({
contentBefore: unformat(
`<p>a[b</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>g]h</td><td>ij</td></tr>
</tbody></table>
<p>kl</p>`
),
stepFunction: (editor) => {
editor.shared.dom.insert(span("TEST"));
editor.shared.history.addStep();
},
contentAfter: unformat(
`<p>a<span class="a">TEST</span>[]</p>
<p>kl</p>`
),
});
});
test("should remove a table and some text (even if the table is partly selected)", async () => {
await testEditor({
contentBefore: unformat(
`<p>ab</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>i[j</td></tr>
</tbody></table>
<p>k]l</p>`
),
stepFunction: (editor) => {
editor.shared.dom.insert(span("TEST"));
editor.shared.history.addStep();
},
contentAfter: unformat(
`<p>ab</p>
<p><span class="a">TEST</span>[]l</p>`
),
});
});
test("should remove some text, a table and some more text", async () => {
await testEditor({
contentBefore: unformat(
`<p>a[b</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>k]l</p>`
),
stepFunction: (editor) => {
editor.shared.dom.insert(span("TEST"));
editor.shared.history.addStep();
},
contentAfter: `<p>a<span class="a">TEST</span>[]l</p>`,
});
});
test("should remove a selection of several tables", async () => {
await testEditor({
contentBefore: unformat(
`<table><tbody>
<tr><td>cd</td><td>e[f</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<table><tbody>
<tr><td>cd</td><td>e]f</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>`
),
stepFunction: async (editor) => {
// Table selection happens on selectionchange event which is
// fired in the next tick.
await tick();
editor.shared.dom.insert(span("TEST"));
editor.shared.history.addStep();
},
contentAfter: `<p><span class="a">TEST</span>[]</p>`,
});
});
test("should remove a selection including several tables", async () => {
await testEditor({
contentBefore: unformat(
`<p>0[1</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>23</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>45</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>67]</p>`
),
stepFunction: (editor) => {
editor.shared.dom.insert(span("TEST"));
editor.shared.history.addStep();
},
contentAfter: `<p>0<span class="a">TEST</span>[]</p>`,
});
});
test("should remove everything, including several tables", async () => {
await testEditor({
contentBefore: unformat(
`<p>[01</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>23</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>45</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>67]</p>`
),
stepFunction: (editor) => {
editor.shared.dom.insert(span("TEST"));
editor.shared.history.addStep();
},
contentAfter: `<p><span class="a">TEST</span>[]</p>`,
});
});
test("should insert html containing ZWNBSP", async () => {
await testEditor({
contentBefore: "<p>[]<br></p>",
stepFunction: async (editor) => {
editor.shared.dom.insert(
parseHTML(
editor.document,
'<p>\uFEFF<a href="#">\uFEFFlink\uFEFF</a>\uFEFF</p><p>\uFEFF<a href="#">\uFEFFlink\uFEFF</a>\uFEFF</p>'
)
);
editor.shared.history.addStep();
},
contentAfter: '<p><a href="#">link</a></p><p><a href="#">link</a>[]</p>',
});
});
});

View file

@ -0,0 +1,448 @@
import { describe, test } from "@odoo/hoot";
import { testEditor } from "../_helpers/editor";
import { insertLineBreak } from "../_helpers/user_actions";
describe("Selection collapsed", () => {
describe("Basic", () => {
test("should insert a <br> into an empty paragraph", async () => {
await testEditor({
contentBefore: "<p>[]<br></p>",
stepFunction: insertLineBreak,
contentAfter: "<p><br>[]<br></p>",
});
// TODO this cannot actually be tested currently as a
// backspace/delete in that case is not even detected
// (no input event to rollback)
// await testEditor({
// contentBefore: '<p>[<br>]</p>',
// stepFunction: insertLineBreak,
// contentAfter: '<p><br>[]<br></p>',
// });
// TODO to check: the cursor cannot be in that position...
// await testEditor({
// contentBefore: '<p><br>[]</p>',
// stepFunction: insertLineBreak,
// contentAfter: '<p><br>[]<br></p>',
// });
});
test("should insert a <br> at the beggining of a paragraph", async () => {
await testEditor({
contentBefore: "<p>[]abc</p>",
stepFunction: insertLineBreak,
contentAfter: "<p><br>[]abc</p>",
});
await testEditor({
contentBefore: "<p>[] abc</p>",
stepFunction: insertLineBreak,
// The space should have been parsed away.
contentAfter: "<p><br>[]abc</p>",
});
});
test("should insert a <br> within text", async () => {
await testEditor({
contentBefore: "<p>ab[]cd</p>",
stepFunction: insertLineBreak,
contentAfter: "<p>ab<br>[]cd</p>",
});
await testEditor({
contentBefore: "<p>ab []cd</p>",
stepFunction: insertLineBreak,
// The space is converted to a non-breaking space so it
// is visible (because it's before a <br>).
contentAfter: "<p>ab&nbsp;<br>[]cd</p>",
});
await testEditor({
contentBefore: "<p>ab[] cd</p>",
stepFunction: insertLineBreak,
// The space is converted to a non-breaking space so it
// is visible (because it's after a <br>).
contentAfter: "<p>ab<br>[]&nbsp;cd</p>",
});
});
test("should insert a line break (2 <br>) at the end of a paragraph", async () => {
await testEditor({
contentBefore: "<p>abc[]</p>",
stepFunction: insertLineBreak,
// The second <br> is needed to make the first
// one visible.
contentAfter: "<p>abc<br>[]<br></p>",
});
});
});
describe("Consecutive", () => {
test("should insert two <br> at the beggining of an empty paragraph", async () => {
await testEditor({
contentBefore: "<p>[]<br></p>",
stepFunction: async (editor) => {
await insertLineBreak(editor);
await insertLineBreak(editor);
},
contentAfter: "<p><br><br>[]<br></p>",
});
// TODO this cannot actually be tested currently as a
// backspace/delete in that case is not even detected
// (no input event to rollback)
// await testEditor({
// contentBefore: '<p>[<br>]</p>',
// stepFunction: async (editor) => {
// await insertLineBreak(editor);
// await insertLineBreak(editor);
// },
// contentAfter: '<p><br><br>[]<br></p>',
// });
// TODO seems like a theoretical case, if needed it could
// be about checking at the start of the shift-enter if
// we are not between left-state BR and right-state block.
// await testEditor({
// contentBefore: '<p><br>[]</p>',
// stepFunction: async (editor) => {
// await insertLineBreak(editor);
// await insertLineBreak(editor);
// },
// contentAfter: '<p><br><br>[]<br></p>',
// });
});
test("should insert two <br> at the beggining of a paragraph", async () => {
await testEditor({
contentBefore: "<p>[]abc</p>",
stepFunction: async (editor) => {
await insertLineBreak(editor);
await insertLineBreak(editor);
},
contentAfter: "<p><br><br>[]abc</p>",
});
});
test("should insert two <br> within text", async () => {
await testEditor({
contentBefore: "<p>ab[]cd</p>",
stepFunction: async (editor) => {
await insertLineBreak(editor);
await insertLineBreak(editor);
},
contentAfter: "<p>ab<br><br>[]cd</p>",
});
});
test("should insert two line breaks (3 <br>) at the end of a paragraph", async () => {
await testEditor({
contentBefore: "<p>abc[]</p>",
stepFunction: async (editor) => {
await insertLineBreak(editor);
await insertLineBreak(editor);
},
// the last <br> is needed to make the first one
// visible.
contentAfter: "<p>abc<br><br>[]<br></p>",
});
});
});
describe("Format", () => {
test("should insert a <br> before a format node", async () => {
await testEditor({
contentBefore: "<p>abc[]<b>def</b></p>",
stepFunction: insertLineBreak,
contentAfter: "<p>abc<br><b>[]def</b></p>",
});
await testEditor({
// That selection is equivalent to []<b>
contentBefore: "<p>abc<b>[]def</b></p>",
stepFunction: insertLineBreak,
// JW cAfter: '<p>abc<br><b>[]def</b></p>',
contentAfter: "<p>abc<b><br>[]def</b></p>",
});
await testEditor({
contentBefore: "<p>abc <b>[]def</b></p>",
stepFunction: insertLineBreak,
// The space is converted to a non-breaking space so it
// is visible (because it's before a <br>).
contentAfter: "<p>abc&nbsp;<b><br>[]def</b></p>",
});
await testEditor({
contentBefore: "<p>abc<b>[] def </b></p>",
stepFunction: insertLineBreak,
// The space is converted to a non-breaking space so it
// is visible (because it's before a <br>).
contentAfter: "<p>abc<b><br>[]&nbsp;def </b></p>",
});
});
test("should insert a <br> after a format node", async () => {
await testEditor({
contentBefore: "<p><b>abc</b>[]def</p>",
stepFunction: insertLineBreak,
// JW cAfter: '<p><b>abc[]<br></b>def</p>',
contentAfter: "<p><b>abc</b><br>[]def</p>",
});
await testEditor({
// That selection is equivalent to </b>[]
contentBefore: "<p><b>abc[]</b>def</p>",
stepFunction: insertLineBreak,
// JW cAfter: '<p><b>abc[]<br></b>def</p>',
contentAfter: "<p><b>abc<br>[]</b>def</p>",
});
await testEditor({
contentBefore: "<p><b>abc[]</b> def</p>",
stepFunction: insertLineBreak,
contentAfterEdit: "<p><b>abc<br>[]\ufeff</b> def</p>",
// The space is converted to a non-breaking space so
// it is visible (because it's after a <br>).
// Visually, the caret does show _after_ the line
// break.
// JW cAfter: '<p><b>abc[]<br></b>&nbsp;def</p>',
contentAfter: "<p><b>abc<br>[]</b>&nbsp;def</p>",
});
await testEditor({
contentBefore: "<p><b>abc []</b>def</p>",
stepFunction: insertLineBreak,
// The space is converted to a non-breaking space so it
// is visible (because it's before a <br>).
contentAfter: "<p><b>abc&nbsp;<br>[]</b>def</p>",
});
});
test("should insert a <br> at the beginning of a format node", async () => {
await testEditor({
contentBefore: "<p>[]<b>abc</b></p>",
stepFunction: insertLineBreak,
contentAfter: "<p><b><br>[]abc</b></p>",
});
await testEditor({
// That selection is equivalent to []<b>
contentBefore: "<p><b>[]abc</b></p>",
stepFunction: insertLineBreak,
contentAfter: "<p><b><br>[]abc</b></p>",
});
await testEditor({
contentBefore: "<p><b>[] abc</b></p>",
stepFunction: insertLineBreak,
// The space should have been parsed away.
contentAfter: "<p><b><br>[]abc</b></p>",
});
});
test("should insert a <br> within a format node", async () => {
await testEditor({
contentBefore: "<p><b>ab[]cd</b></p>",
stepFunction: insertLineBreak,
contentAfter: "<p><b>ab<br>[]cd</b></p>",
});
await testEditor({
contentBefore: "<p><b>ab []cd</b></p>",
stepFunction: insertLineBreak,
// The space is converted to a non-breaking space so it
// is visible (because it's before a <br>).
contentAfter: "<p><b>ab&nbsp;<br>[]cd</b></p>",
});
await testEditor({
contentBefore: "<p><b>ab[] cd</b></p>",
stepFunction: insertLineBreak,
// The space is converted to a non-breaking
// space so it is visible.
contentAfter: "<p><b>ab<br>[]&nbsp;cd</b></p>",
});
});
test("should insert \uFEFF at the end of format node", async () => {
await testEditor({
contentBefore: "<p><b>abc[]</b><br><br></p>",
stepFunction: insertLineBreak,
contentAfterEdit: `<p><b>abc<br>[]\uFEFF</b><br><br></p>`,
contentAfter: "<p><b>abc<br>[]</b><br><br></p>",
});
});
test("should insert a line break (2 <br>) at the end of a format node", async () => {
await testEditor({
contentBefore: "<p><b>abc</b>[]</p>",
stepFunction: insertLineBreak,
// The second <br> is needed to make the first
// one visible.
contentAfter: "<p><b>abc<br>[]<br></b></p>",
});
await testEditor({
// That selection is equivalent to </b>[]
contentBefore: "<p><b>abc[]</b></p>",
stepFunction: insertLineBreak,
// The second <br> is needed to make the first
// one visible.
contentAfter: "<p><b>abc<br>[]<br></b></p>",
});
await testEditor({
contentBefore: "<p><b>abc[] </b></p>",
stepFunction: insertLineBreak,
// The space should have been parsed away.
// The second <br> is needed to make the first
// one visible.
contentAfter: "<p><b>abc<br>[]<br></b></p>",
});
});
});
describe("With attributes", () => {
test("should insert a line break before a span with class", async () => {
await testEditor({
contentBefore:
'<p><span class="a">dom to</span></p><p><span class="b">[]edit</span></p>',
stepFunction: insertLineBreak,
contentAfter:
'<p><span class="a">dom to</span></p><p><span class="b"><br>[]edit</span></p>',
});
});
test("should insert a line break within a span with a bold", async () => {
await testEditor({
contentBefore: '<p><span class="a"><b>ab[]cd</b></span></p>',
stepFunction: insertLineBreak,
contentAfter: '<p><span class="a"><b>ab<br>[]cd</b></span></p>',
});
});
});
});
describe("Selection not collapsed", () => {
test("should delete the first half of a paragraph, then insert a <br>", async () => {
// Forward selection
await testEditor({
contentBefore: "<p>[ab]cd</p>",
stepFunction: insertLineBreak,
contentAfter: "<p><br>[]cd</p>",
});
// Backward selection
await testEditor({
contentBefore: "<p>]ab[cd</p>",
stepFunction: insertLineBreak,
contentAfter: "<p><br>[]cd</p>",
});
});
test("should delete part of a paragraph, then insert a <br>", async () => {
// Forward selection
await testEditor({
contentBefore: "<p>a[bc]d</p>",
stepFunction: insertLineBreak,
contentAfter: "<p>a<br>[]d</p>",
});
// Backward selection
await testEditor({
contentBefore: "<p>a]bc[d</p>",
stepFunction: insertLineBreak,
contentAfter: "<p>a<br>[]d</p>",
});
});
test("should delete the last half of a paragraph, then insert a line break (2 <br>)", async () => {
// Forward selection
await testEditor({
contentBefore: "<p>ab[cd]</p>",
stepFunction: insertLineBreak,
// the second <br> is needed to make the first one
// visible.
contentAfter: "<p>ab<br>[]<br></p>",
});
// Backward selection
await testEditor({
contentBefore: "<p>ab]cd[</p>",
stepFunction: insertLineBreak,
// the second <br> is needed to make the first one
// visible.
contentAfter: "<p>ab<br>[]<br></p>",
});
});
test("should delete all contents of a paragraph, then insert a line break", async () => {
// Forward selection
await testEditor({
contentBefore: "<p>[abcd]</p>",
stepFunction: insertLineBreak,
contentAfter: "<p><br>[]<br></p>",
});
// Backward selection
await testEditor({
contentBefore: "<p>]abcd[</p>",
stepFunction: insertLineBreak,
contentAfter: "<p><br>[]<br></p>",
});
});
});
describe("table", () => {
test("should remove all contents of an anchor td and insert a line break on forward selection", async () => {
// Forward selection
await testEditor({
contentBefore: `
<table>
<tbody>
<tr>
<td><p>[abc</p><p>def</p></td>
<td><p>abcd</p></td>
<td><p>ab]</p></td>
</tr>
<tr>
<td><p><br></p></td>
<td><p><br></p></td>
<td><p><br></p></td>
</tr>
</tbody>
</table>`,
stepFunction: insertLineBreak,
contentAfter: `
<table>
<tbody>
<tr>
<td><p><br>[]<br></p></td>
<td><p>abcd</p></td>
<td><p>ab</p></td>
</tr>
<tr>
<td><p><br></p></td>
<td><p><br></p></td>
<td><p><br></p></td>
</tr>
</tbody>
</table>`,
});
});
test("should remove all contents of an anchor td and insert a line break on backward selection", async () => {
// Backward selection
await testEditor({
contentBefore: `
<table>
<tbody>
<tr>
<td><p>]ab</p></td>
<td><p>abcd</p></td>
<td><p>abc</p><p>def[</p></td>
</tr>
<tr>
<td><p><br></p></td>
<td><p><br></p></td>
<td><p><br></p></td>
</tr>
</tbody>
</table>`,
stepFunction: insertLineBreak,
contentAfter: `
<table>
<tbody>
<tr>
<td><p>ab</p></td>
<td><p>abcd</p></td>
<td><p><br>[]<br></p></td>
</tr>
<tr>
<td><p><br></p></td>
<td><p><br></p></td>
<td><p><br></p></td>
</tr>
</tbody>
</table>`,
});
});
});

View file

@ -0,0 +1,815 @@
import { beforeEach, describe, test } from "@odoo/hoot";
import { animationFrame, waitFor } from "@odoo/hoot-dom";
import { tick } from "@odoo/hoot-mock";
import { testEditor } from "../_helpers/editor";
import { insertText, splitBlock } from "../_helpers/user_actions";
import { unformat } from "../_helpers/format";
import { EMBEDDED_COMPONENT_PLUGINS, MAIN_PLUGINS } from "@html_editor/plugin_sets";
import { QWebPlugin } from "@html_editor/others/qweb_plugin";
import { findInSelection } from "@html_editor/utils/selection";
import {
compareHighlightedContent,
highlightedPre,
patchPrism,
} from "../_helpers/syntax_highlighting";
import { MAIN_EMBEDDINGS } from "@html_editor/others/embedded_components/embedding_sets";
describe("Selection collapsed", () => {
describe("Basic", () => {
test("should duplicate an empty paragraph", async () => {
await testEditor({
contentBefore: "<p>[]<br></p>",
stepFunction: splitBlock,
contentAfter: "<p><br></p><p>[]<br></p>",
});
// TODO this cannot actually be tested currently as a
// backspace/delete in that case is not even detected
// (no input event to rollback)
// await testEditor({
// contentBefore: '<p>[<br>]</p>',
// stepFunction: splitBlock,
// contentAfter: '<p><br></p><p>[]<br></p>',
// });
await testEditor({
contentBefore: "<p><br>[]</p>",
stepFunction: splitBlock,
contentAfter: "<p><br></p><p>[]<br></p>",
});
});
test("should insert an empty paragraph before a paragraph", async () => {
await testEditor({
contentBefore: "<p>[]abc</p>",
stepFunction: splitBlock,
contentAfter: "<p><br></p><p>[]abc</p>",
});
await testEditor({
contentBefore: "<p>[] abc</p>",
stepFunction: splitBlock,
// JW cAfter: '<p><br></p><p>[]abc</p>',
contentAfter: "<p><br></p><p>[] abc</p>",
});
});
test("should split a paragraph in two", async () => {
await testEditor({
contentBefore: "<p>ab[]cd</p>",
stepFunction: splitBlock,
contentAfter: "<p>ab</p><p>[]cd</p>",
});
await testEditor({
contentBefore: "<p>ab []cd</p>",
stepFunction: splitBlock,
// The space is converted to a non-breaking
// space so it is visible.
contentAfter: "<p>ab&nbsp;</p><p>[]cd</p>",
});
await testEditor({
contentBefore: "<p>ab[] cd</p>",
stepFunction: splitBlock,
// The space is converted to a non-breaking
// space so it is visible.
contentAfter: "<p>ab</p><p>[]&nbsp;cd</p>",
});
});
test("should insert an empty paragraph after a paragraph", async () => {
await testEditor({
contentBefore: "<p>abc[]</p>",
stepFunction: splitBlock,
contentAfter: "<p>abc</p><p>[]<br></p>",
});
await testEditor({
contentBefore: "<p>abc[] </p>",
stepFunction: splitBlock,
contentAfter: "<p>abc</p><p>[]<br></p>",
});
});
test("should split block without afecting the uploaded document link", async () => {
await testEditor({
contentBefore: `<p>abc<a href="#" title="document" data-mimetype="application/pdf" class="o_image"></a>[]def</p>`,
stepFunction: splitBlock,
contentAfter: `<p>abc<a href="#" title="document" data-mimetype="application/pdf" class="o_image"></a></p><p>[]def</p>`,
});
});
test("should split block without afecting the uploaded document link (2)", async () => {
await testEditor({
contentBefore: `<p>abc<a href="#" title="document" data-mimetype="application/pdf" class="o_image"></a>[]</p>`,
stepFunction: splitBlock,
contentAfter: `<p>abc<a href="#" title="document" data-mimetype="application/pdf" class="o_image"></a></p><p>[]<br></p>`,
});
});
test("should not split block with conditional template", async () => {
await testEditor({
contentBefore: unformat(`
<h1 t-if="true">
<t t-out="Hello"></t>
[]<t t-out="World"></t>
</h1>
`),
stepFunction: splitBlock,
contentAfter: unformat(`
<h1 t-if="true">
<t t-out="Hello"></t>
<br>
[]<t t-out="World"></t>
</h1>
`),
config: { Plugins: [...MAIN_PLUGINS, QWebPlugin] },
});
});
});
describe("Pre", () => {
describe("with syntax highlighting", () => {
const configWithEmbeddings = {
Plugins: [...MAIN_PLUGINS, ...EMBEDDED_COMPONENT_PLUGINS],
resources: { embedded_components: MAIN_EMBEDDINGS },
};
const testEnterInCodeBlock = (selectionStart) => async (editor) => {
// Set the given selection in the textarea.
const textarea = editor.editable.querySelector("textarea");
textarea.focus();
textarea.setSelectionRange(selectionStart, selectionStart, "forward");
// Trigger native paragraph break.
await editor.document.execCommand("insertParagraph", false, null);
// Wait for the input event to resolve so the content is
// highlighted and the focus is in the textarea.
await animationFrame();
};
beforeEach(patchPrism);
test("should insert a line break within the pre", async () => {
await testEditor({
compareFunction: compareHighlightedContent,
contentBefore: "<pre>abcd</pre>",
contentBeforeEdit: highlightedPre({ value: "abcd" }),
stepFunction: testEnterInCodeBlock(2), // "ab[]cd"
contentAfterEdit: highlightedPre({
value: "ab\ncd",
textareaRange: 3, // "ab\n[]cd"
}),
contentAfter: `<pre data-language-id="plaintext">ab<br>cd</pre>[]`,
config: configWithEmbeddings,
});
});
test("should insert a new line at the end of the pre", async () => {
await testEditor({
compareFunction: compareHighlightedContent,
contentBefore: "<pre>abc</pre>",
contentBeforeEdit: highlightedPre({ value: "abc" }),
stepFunction: testEnterInCodeBlock(3), // "abc[]"
contentAfterEdit: highlightedPre({
value: "abc\n",
preHtml: "abc<br><br>",
textareaRange: 4, // "abc\n[]"
}),
contentAfter: `<pre data-language-id="plaintext">abc<br><br></pre>[]`,
config: configWithEmbeddings,
});
});
});
describe("without syntax highlighting", () => {
test("should insert a line break within the pre", async () => {
await testEditor({
contentBefore: "<pre>ab[]cd</pre>",
stepFunction: splitBlock,
contentAfter: "<pre>ab<br>[]cd</pre>",
});
});
test("should insert a line break within the pre containing inline element", async () => {
await testEditor({
contentBefore: "<pre>a<strong>b[]c</strong>d</pre>",
stepFunction: splitBlock,
contentAfter: "<pre>a<strong>b<br>[]c</strong>d</pre>",
});
});
test("should insert a line break within the pre containing inline elementd", async () => {
await testEditor({
contentBefore: "<pre><em>a<strong>b[]c</strong>d</em></pre>",
stepFunction: splitBlock,
contentAfter: "<pre><em>a<strong>b<br>[]c</strong>d</em></pre>",
});
});
test("should insert a new paragraph after the pre", async () => {
await testEditor({
contentBefore: "<pre>abc[]</pre>",
stepFunction: splitBlock,
contentAfter: "<pre>abc</pre><p>[]<br></p>",
});
});
test("should insert a new paragraph after the pre containing inline element", async () => {
await testEditor({
contentBefore: "<pre>ab<strong>c[]</strong></pre>",
stepFunction: splitBlock,
contentAfter: "<pre>ab<strong>c</strong></pre><p>[]<br></p>",
});
});
test("should insert a new paragraph after the pre containing inline elements", async () => {
await testEditor({
contentBefore: "<pre><em>ab<strong>c[]</strong></em></pre>",
stepFunction: splitBlock,
contentAfter: "<pre><em>ab<strong>c</strong></em></pre><p>[]<br></p>",
});
});
test("should be able to break out of an empty pre", async () => {
await testEditor({
contentBefore: "<pre>[]<br></pre>",
stepFunction: splitBlock,
contentAfter: "<pre><br></pre><p>[]<br></p>",
});
});
test("should insert a new line within the pre", async () => {
await testEditor({
contentBefore: "<pre><p>abc</p><p>def[]</p></pre>",
stepFunction: splitBlock,
contentAfter: "<pre><p>abc</p><p>def</p><p>[]<br></p></pre>",
});
});
test("should insert a new line after pre", async () => {
await testEditor({
contentBefore: "<pre><p>abc</p><p>def</p><p>[]<br></p></pre>",
stepFunction: splitBlock,
contentAfter: "<pre><p>abc</p><p>def</p></pre><p>[]<br></p>",
});
});
test("should insert a new paragraph after a pre tag with rtl direction", async () => {
await testEditor({
contentBefore: `<pre dir="rtl">ab[]</pre>`,
stepFunction: splitBlock,
contentAfter: `<pre dir="rtl">ab</pre><p dir="rtl">[]<br></p>`,
});
});
test("should insert a new paragraph after a pre tag with rtl direction (2)", async () => {
await testEditor({
contentBefore: `<pre><p dir="rtl">abc</p><p dir="rtl">[]<br></p></pre>`,
stepFunction: splitBlock,
contentAfter: `<pre><p dir="rtl">abc</p></pre><p dir="rtl">[]<br></p>`,
});
});
});
describe("Consecutive", () => {
test("should duplicate an empty paragraph twice", async () => {
await testEditor({
contentBefore: "<p>[]<br></p>",
stepFunction: async (editor) => {
splitBlock(editor);
splitBlock(editor);
},
contentAfter: "<p><br></p><p><br></p><p>[]<br></p>",
});
// TODO this cannot actually be tested currently as a
// backspace/delete in that case is not even detected
// (no input event to rollback)
// await testEditor({
// contentBefore: '<p>[<br>]</p>',
// stepFunction: async (editor) => {
// splitBlock(editor);
// splitBlock(editor);
// },
// contentAfter: '<p><br></p><p><br></p><p>[]<br></p>',
// });
await testEditor({
contentBefore: "<p><br>[]</p>",
stepFunction: async (editor) => {
splitBlock(editor);
splitBlock(editor);
},
contentAfter: "<p><br></p><p><br></p><p>[]<br></p>",
});
});
test("should insert two empty paragraphs before a paragraph", async () => {
await testEditor({
contentBefore: "<p>[]abc</p>",
stepFunction: async (editor) => {
splitBlock(editor);
splitBlock(editor);
},
contentAfter: "<p><br></p><p><br></p><p>[]abc</p>",
});
});
test("should split a paragraph in three", async () => {
await testEditor({
contentBefore: "<p>ab[]cd</p>",
stepFunction: async (editor) => {
splitBlock(editor);
splitBlock(editor);
},
contentAfter: "<p>ab</p><p><br></p><p>[]cd</p>",
});
});
test("should split a paragraph in four", async () => {
await testEditor({
contentBefore: "<p>ab[]cd</p>",
stepFunction: async (editor) => {
splitBlock(editor);
splitBlock(editor);
splitBlock(editor);
},
contentAfter: "<p>ab</p><p><br></p><p><br></p><p>[]cd</p>",
});
});
test("should insert two empty paragraphs after a paragraph", async () => {
await testEditor({
contentBefore: "<p>abc[]</p>",
stepFunction: async (editor) => {
splitBlock(editor);
splitBlock(editor);
},
contentAfter: "<p>abc</p><p><br></p><p>[]<br></p>",
});
});
});
describe("Format", () => {
test("should split a paragraph before a format node", async () => {
await testEditor({
contentBefore: "<p>abc[]<b>def</b></p>",
stepFunction: splitBlock,
contentAfter: "<p>abc</p><p><b>[]def</b></p>",
});
await testEditor({
// That selection is equivalent to []<b>
contentBefore: "<p>abc<b>[]def</b></p>",
stepFunction: splitBlock,
contentAfter: "<p>abc</p><p><b>[]def</b></p>",
});
await testEditor({
contentBefore: "<p>abc <b>[]def</b></p>",
stepFunction: splitBlock,
// The space is converted to a non-breaking
// space so it is visible (because it's after a
// <br>).
contentAfter: "<p>abc&nbsp;</p><p><b>[]def</b></p>",
});
await testEditor({
contentBefore: "<p>abc<b>[] def </b></p>",
stepFunction: splitBlock,
// The space is converted to a non-breaking
// space so it is visible (because it's before a
// <br>).
// JW cAfter: '<p>abc</p><p><b>[]&nbsp;def</b></p>',
contentAfter: "<p>abc</p><p><b>[]&nbsp;def </b></p>",
});
});
test("should split a paragraph after a format node", async () => {
await testEditor({
contentBefore: "<p><b>abc</b>[]def</p>",
stepFunction: splitBlock,
contentAfterEdit: "<p><b>abc</b></p><p>[]def</p>",
contentAfter: "<p><b>abc</b></p><p>[]def</p>",
});
await testEditor({
// That selection is equivalent to </b>[]
contentBefore: "<p><b>abc[]</b>def</p>",
stepFunction: splitBlock,
contentAfterEdit: `<p><b>abc</b></p><p><b data-oe-zws-empty-inline="">[]\u200b</b>def</p>`,
contentAfter: "<p><b>abc</b></p><p>[]def</p>",
});
await testEditor({
contentBefore: "<p><b>abc[]</b> def</p>",
stepFunction: splitBlock,
// The space is converted to a non-breaking
// space so it is visible.
contentAfterEdit: `<p><b>abc</b></p><p><b data-oe-zws-empty-inline="">[]\u200b</b>&nbsp;def</p>`,
contentAfter: "<p><b>abc</b></p><p>[]&nbsp;def</p>",
});
await testEditor({
contentBefore: "<p><b>abc []</b>def</p>",
stepFunction: splitBlock,
// The space is converted to a non-breaking
// space so it is visible (because it's before a
// <br>).
contentAfterEdit: `<p><b>abc&nbsp;</b></p><p><b data-oe-zws-empty-inline="">[]\u200b</b>def</p>`,
contentAfter: "<p><b>abc&nbsp;</b></p><p>[]def</p>",
});
});
test("should split a paragraph at the beginning of a format node", async () => {
await testEditor({
contentBefore: "<p>[]<b>abc</b></p>",
stepFunction: splitBlock,
contentAfterEdit: `<p><b data-oe-zws-empty-inline="">\u200b</b></p><p><b>[]abc</b></p>`,
contentAfter: "<p><br></p><p><b>[]abc</b></p>",
});
await testEditor({
contentBefore: "<p><b>[]abc</b></p>",
stepFunction: splitBlock,
contentAfterEdit: `<p><b data-oe-zws-empty-inline="">\u200b</b></p><p><b>[]abc</b></p>`,
contentAfter: "<p><br></p><p><b>[]abc</b></p>",
});
await testEditor({
contentBefore: "<p><b>[] abc</b></p>",
stepFunction: splitBlock,
contentAfterEdit: `<p><b data-oe-zws-empty-inline="">\u200b</b></p><p><b>[] abc</b></p>`,
// The space should have been parsed away.
// JW cAfter: '<p><br></p><p><b>[]abc</b></p>',
contentAfter: "<p><br></p><p><b>[] abc</b></p>",
});
});
test("should split a paragraph within a format node", async () => {
await testEditor({
contentBefore: "<p><b>ab[]cd</b></p>",
stepFunction: splitBlock,
contentAfter: "<p><b>ab</b></p><p><b>[]cd</b></p>",
});
await testEditor({
contentBefore: "<p><b>ab []cd</b></p>",
stepFunction: splitBlock,
// The space is converted to a non-breaking
// space so it is visible.
contentAfter: "<p><b>ab&nbsp;</b></p><p><b>[]cd</b></p>",
});
await testEditor({
contentBefore: "<p><b>ab[] cd</b></p>",
stepFunction: splitBlock,
// The space is converted to a non-breaking
// space so it is visible.
contentAfter: "<p><b>ab</b></p><p><b>[]&nbsp;cd</b></p>",
});
});
test("should split a paragraph at the end of a format node", async () => {
await testEditor({
contentBefore: "<p><b>abc</b>[]</p>",
stepFunction: splitBlock,
contentAfterEdit: `<p><b>abc</b></p><p o-we-hint-text='Type "/" for commands' class="o-we-hint"><b data-oe-zws-empty-inline="">[]\u200b</b></p>`,
contentAfter: "<p><b>abc</b></p><p>[]<br></p>",
});
await testEditor({
// That selection is equivalent to </b>[]
contentBefore: "<p><b>abc[]</b></p>",
stepFunction: splitBlock,
contentAfterEdit: `<p><b>abc</b></p><p o-we-hint-text='Type "/" for commands' class="o-we-hint"><b data-oe-zws-empty-inline="">[]\u200b</b></p>`,
contentAfter: "<p><b>abc</b></p><p>[]<br></p>",
});
await testEditor({
contentBefore: "<p><b>abc[] </b></p>",
stepFunction: splitBlock,
// The space should have been parsed away.
contentAfterEdit: `<p><b>abc</b></p><p o-we-hint-text='Type "/" for commands' class="o-we-hint"><b data-oe-zws-empty-inline="">[]\u200b</b></p>`,
contentAfter: "<p><b>abc</b></p><p>[]<br></p>",
});
});
async function splitBlockA(editor) {
// splitBlock in an <a> tag will open the linkPopover which will take the focus.
// So we need to wait for it to open and put the selection back into the editor.
splitBlock(editor);
const editableSelection =
editor.shared.selection.getSelectionData().editableSelection;
if (findInSelection(editableSelection, "a:not([href])")) {
await waitFor(".o-we-linkpopover");
}
editor.shared.selection.focusEditable();
await tick();
}
// @todo: re-evaluate this possibly outdated comment:
// skipping these tests cause with the link isolation the cursor can be put
// inside/outside the link so the user can choose where to insert the line break
// see `anchor.nodeName === "A" && brEls.includes(anchor.firstChild)` in line_break_plugin.js
test("should insert line breaks outside the edges of an anchor in unbreakable", async () => {
await testEditor({
contentBefore: `<div class="oe_unbreakable">ab<a href="http://test.test/">[]cd</a></div>`,
stepFunction: splitBlockA,
contentAfter: `<div class="oe_unbreakable">ab<br><a href="http://test.test/">[]cd</a></div>`,
});
await testEditor({
contentBefore: `<div class="oe_unbreakable"><a href="http://test.test/">a[]b</a></div>`,
stepFunction: splitBlockA,
contentAfter: `<div class="oe_unbreakable"><a href="http://test.test/">a<br>[]b</a></div>`,
});
await testEditor({
contentBefore: `<div class="oe_unbreakable"><a href="http://test.test/">ab[]</a></div>`,
stepFunction: splitBlockA,
contentAfter: `<div class="oe_unbreakable"><a href="http://test.test/">ab</a><br><br>[]</div>`,
});
await testEditor({
contentBefore: `<div class="oe_unbreakable"><a href="http://test.test/">ab[]</a>cd</div>`,
stepFunction: splitBlockA,
contentAfter: `<div class="oe_unbreakable"><a href="http://test.test/">ab</a><br>[]cd</div>`,
});
await testEditor({
contentBefore: `<div class="oe_unbreakable"><a href="http://test.test/" style="display: block;">ab[]</a></div>`,
stepFunction: splitBlockA,
contentAfter: `<div class="oe_unbreakable"><a href="http://test.test/" style="display: block;">ab</a>[]<br></div>`,
});
});
test("should insert a paragraph break outside the starting edge of an anchor at start of block", async () => {
await testEditor({
contentBefore: '<p><a href="http://test.test/">[]ab</a></p>',
stepFunction: splitBlockA,
contentAfterEdit:
'<p><br></p><p>\ufeff<a href="http://test.test/" class="o_link_in_selection">\ufeff[]ab\ufeff</a>\ufeff</p>',
contentAfter: '<p><br></p><p><a href="http://test.test/">[]ab</a></p>',
});
});
test("should insert a paragraph break outside the starting edge of an anchor after some text", async () => {
await testEditor({
contentBefore: '<p>ab<a href="http://test.test/">[]cd</a></p>',
stepFunction: splitBlockA,
contentAfterEdit:
'<p>ab</p><p>\ufeff<a href="http://test.test/" class="o_link_in_selection">\ufeff[]cd\ufeff</a>\ufeff</p>',
contentAfter: '<p>ab</p><p><a href="http://test.test/">[]cd</a></p>',
});
});
test("should insert a paragraph break in the middle of an anchor", async () => {
await testEditor({
contentBefore: '<p><a href="http://test.test/">a[]b</a></p>',
stepFunction: splitBlockA,
contentAfterEdit:
'<p>\ufeff<a href="http://test.test/">\ufeffa\ufeff</a>\ufeff</p><p>\ufeff<a href="http://test.test/" class="o_link_in_selection">\ufeff[]b\ufeff</a>\ufeff</p>',
contentAfter:
'<p><a href="http://test.test/">a</a></p><p><a href="http://test.test/">[]b</a></p>',
});
});
test("should insert a paragraph break outside the ending edge of an anchor", async () => {
await testEditor({
contentBefore: '<p><a href="http://test.test/">ab[]</a></p>',
stepFunction: splitBlockA,
contentAfterEdit: `<p>\ufeff<a href="http://test.test/">\ufeffab\ufeff</a>\ufeff</p><p o-we-hint-text='Type "/" for commands' class="o-we-hint">[]<br></p>`,
contentAfter: `<p><a href="http://test.test/">ab</a></p><p>[]<br></p>`,
});
});
test("should insert a paragraph break outside the ending edge of an anchor (2)", async () => {
await testEditor({
contentBefore: '<p><a href="http://test.test/">ab[]</a>cd</p>',
stepFunction: splitBlockA,
contentAfterEdit:
'<p>\ufeff<a href="http://test.test/">\ufeffab\ufeff</a>\ufeff</p><p>[]cd</p>',
contentAfter: '<p><a href="http://test.test/">ab</a></p><p>[]cd</p>',
});
});
});
describe("With attributes", () => {
test("should insert an empty paragraph before a paragraph with a span with a class", async () => {
await testEditor({
contentBefore:
'<p><span class="a">ab</span></p><p><span class="b">[]cd</span></p>',
stepFunction: splitBlock,
contentAfter:
'<p><span class="a">ab</span></p><p><span class="b">\u200b</span><br></p><p><span class="b">[]cd</span></p>',
});
});
test("should split a paragraph with a span with a bold in two", async () => {
await testEditor({
contentBefore: '<p><span class="a"><b>ab[]cd</b></span></p>',
stepFunction: splitBlock,
contentAfter:
'<p><span class="a"><b>ab</b></span></p><p><span class="a"><b>[]cd</b></span></p>',
});
});
test("should split a paragraph at its end, with a paragraph after it, and both have the same class", async () => {
await testEditor({
contentBefore: '<p class="a">a[]</p><p class="a"><br></p>',
stepFunction: splitBlock,
contentAfter: '<p class="a">a</p><p class="a">[]<br></p><p class="a"><br></p>',
});
});
});
describe("POC extra tests", () => {
test("should insert a paragraph after an empty h1", async () => {
await testEditor({
contentBefore: "<h1>[]<br></h1>",
stepFunction: splitBlock,
contentAfter: "<h1><br></h1><p>[]<br></p>",
});
});
test("should insert a paragraph after an empty h1 with styles and a zero-width space", async () => {
await testEditor({
contentBefore:
'<h1><font style="color: red;" data-oe-zws-empty-inline="">[]\u200B</font></h1>',
stepFunction: splitBlock,
contentAfterEdit:
'<h1><font style="color: red;" data-oe-zws-empty-inline="">\u200b</font></h1>' +
`<p o-we-hint-text='Type "/" for commands' class="o-we-hint"><font style="color: red;" data-oe-zws-empty-inline="">[]\u200b</font></p>`,
contentAfter: "<h1><br></h1><p>[]<br></p>",
});
});
test("should insert a new paragraph after an h1 with style", async () => {
await testEditor({
contentBefore: `<h1 style="color: red">ab[]</h1>`,
stepFunction: splitBlock,
contentAfterEdit: `<h1 style="color: red">ab</h1><p o-we-hint-text='Type "/" for commands' class="o-we-hint">[]<br></p>`,
contentAfter: `<h1 style="color: red">ab</h1><p>[]<br></p>`,
});
});
test("should insert a new paragraph after a heading tag with rtl direction", async () => {
await testEditor({
contentBefore: `<h1 dir="rtl">ab[]</h1>`,
stepFunction: splitBlock,
contentAfter: `<h1 dir="rtl">ab</h1><p dir="rtl">[]<br></p>`,
});
});
});
describe("Styles", () => {
test("should split a paragraph at the end of style node", async () => {
await testEditor({
contentBefore: '<p><font style="color: red;">abc[]</font></p>',
stepFunction: splitBlock,
contentAfterEdit: `<p><font style="color: red;">abc</font></p><p o-we-hint-text='Type "/" for commands' class="o-we-hint"><font style="color: red;" data-oe-zws-empty-inline="">[]\u200b</font></p>`,
contentAfter: `<p><font style="color: red;">abc</font></p><p>[]<br></p>`,
});
await testEditor({
contentBefore: '<p><font style="background-color: red;">abc[]</font></p>',
stepFunction: splitBlock,
contentAfterEdit: `<p><font style="background-color: red;">abc</font></p><p o-we-hint-text='Type "/" for commands' class="o-we-hint"><font style="background-color: red;" data-oe-zws-empty-inline="">[]\u200b</font></p>`,
contentAfter: `<p><font style="background-color: red;">abc</font></p><p>[]<br></p>`,
});
await testEditor({
contentBefore: '<p><span style="font-size: 36px;">abc[]</span></p>',
stepFunction: splitBlock,
contentAfterEdit: `<p><span style="font-size: 36px;">abc</span></p><p o-we-hint-text='Type "/" for commands' class="o-we-hint"><span style="font-size: 36px;" data-oe-zws-empty-inline="">[]\u200b</span></p>`,
contentAfter: `<p><span style="font-size: 36px;">abc</span></p><p>[]<br></p>`,
});
});
});
});
});
describe("Selection not collapsed", () => {
test("should delete the first half of a paragraph, then split it", async () => {
// Forward selection
await testEditor({
contentBefore: "<p>[ab]cd</p>",
stepFunction: splitBlock,
contentAfter: "<p><br></p><p>[]cd</p>",
});
// Backward selection
await testEditor({
contentBefore: "<p>]ab[cd</p>",
stepFunction: splitBlock,
contentAfter: "<p><br></p><p>[]cd</p>",
});
});
test("should delete part of a paragraph, then split it", async () => {
// Forward selection
await testEditor({
contentBefore: "<p>a[bc]d</p>",
stepFunction: splitBlock,
contentAfter: "<p>a</p><p>[]d</p>",
});
// Backward selection
await testEditor({
contentBefore: "<p>a]bc[d</p>",
stepFunction: splitBlock,
contentAfter: "<p>a</p><p>[]d</p>",
});
});
test("should delete the last half of a paragraph, then split it", async () => {
// Forward selection
await testEditor({
contentBefore: "<p>ab[cd]</p>",
stepFunction: splitBlock,
contentAfter: "<p>ab</p><p>[]<br></p>",
});
// Backward selection
await testEditor({
contentBefore: "<p>ab]cd[</p>",
stepFunction: splitBlock,
contentAfter: "<p>ab</p><p>[]<br></p>",
});
});
test("should delete all contents of a paragraph, then split it", async () => {
// Forward selection
await testEditor({
contentBefore: "<p>[abcd]</p>",
stepFunction: splitBlock,
contentAfter: "<p><br></p><p>[]<br></p>",
});
// Backward selection
await testEditor({
contentBefore: "<p>]abcd[</p>",
stepFunction: splitBlock,
contentAfter: "<p><br></p><p>[]<br></p>",
});
});
test("should keep the selection at the start of the second text node after paragraph break", async () => {
await testEditor({
contentBefore: "<p>ab<br>[c]de</p>",
stepFunction: async (editor) => {
await insertText(editor, "f");
},
contentAfter: "<p>ab<br>f[]de</p>",
});
});
});
describe("Table", () => {
test("should remove all contents of an anchor td and split paragraph on forward selection", async () => {
// Forward selection
await testEditor({
contentBefore: `
<table>
<tbody>
<tr>
<td><p>[abc</p><p>def</p></td>
<td><p>abcd</p></td>
<td><p>ab]</p></td>
</tr>
<tr>
<td><p><br></p></td>
<td><p><br></p></td>
<td><p><br></p></td>
</tr>
</tbody>
</table>`,
stepFunction: splitBlock,
contentAfter: `
<table>
<tbody>
<tr>
<td><p><br></p><p>[]<br></p></td>
<td><p>abcd</p></td>
<td><p>ab</p></td>
</tr>
<tr>
<td><p><br></p></td>
<td><p><br></p></td>
<td><p><br></p></td>
</tr>
</tbody>
</table>`,
});
});
test("should remove all contents of an anchor td and split paragraph on backward selection", async () => {
// Backward selection
await testEditor({
contentBefore: `
<table>
<tbody>
<tr>
<td><p>]ab</p></td>
<td><p>abcd</p></td>
<td><p>abc</p><p>def[</p></td>
</tr>
<tr>
<td><p><br></p></td>
<td><p><br></p></td>
<td><p><br></p></td>
</tr>
</tbody>
</table>`,
stepFunction: splitBlock,
contentAfter: `
<table>
<tbody>
<tr>
<td><p>ab</p></td>
<td><p>abcd</p></td>
<td><p><br></p><p>[]<br></p></td>
</tr>
<tr>
<td><p><br></p></td>
<td><p><br></p></td>
<td><p><br></p></td>
</tr>
</tbody>
</table>`,
});
});
test("remove selected text and insert paragraph tag within a table cell and enter key is pressed", async () => {
await testEditor({
contentBefore: `
<table>
<tbody>
<tr>
<td><p>[Test</p><p>Test</p><p>Test]</p></td>
<td><p><br></p></td>
</tr>
</tbody>
</table>`,
stepFunction: splitBlock,
contentAfter: `
<table>
<tbody>
<tr>
<td><p><br></p><p>[]<br></p></td>
<td><p><br></p></td>
</tr>
</tbody>
</table>`,
});
});
});

View file

@ -0,0 +1,137 @@
import { describe, expect, test } from "@odoo/hoot";
import { setupEditor, testEditor } from "../_helpers/editor";
import { getContent } from "../_helpers/selection";
import { execCommand } from "../_helpers/userCommands";
import { simulateArrowKeyPress } from "../_helpers/user_actions";
import { animationFrame } from "@odoo/hoot-dom";
async function insertSeparator(editor) {
execCommand(editor, "insertSeparator");
}
describe("insert separator", () => {
test("should insert a separator inside editable with contenteditable set to false", async () => {
await testEditor({
contentBefore: "<p>[]<br></p>",
stepFunction: insertSeparator,
contentAfterEdit: `<hr contenteditable="false"><p o-we-hint-text='Type "/" for commands' class="o-we-hint">[]<br></p>`,
contentAfter: "<hr><p>[]<br></p>",
});
});
test("should insert a separator before current element if empty", async () => {
await testEditor({
contentBefore: "<p>content</p><p>[]<br></p>",
stepFunction: insertSeparator,
contentAfterEdit: `<p>content</p><hr contenteditable="false"><p o-we-hint-text='Type "/" for commands' class="o-we-hint">[]<br></p>`,
contentAfter: "<p>content</p><hr><p>[]<br></p>",
});
});
test("should insert a separator after current element if it contains text", async () => {
await testEditor({
contentBefore: "<p>content</p><p>text[]</p>",
stepFunction: insertSeparator,
contentAfterEdit: `<p>content</p><p>text</p><hr contenteditable="false"><p o-we-hint-text='Type "/" for commands' class="o-we-hint">[]<br></p>`,
contentAfter: "<p>content</p><p>text</p><hr><p>[]<br></p>",
});
});
test("should insert a separator before current empty paragraph related element but remain inside the div", async () => {
await testEditor({
contentBefore: "<div><p>[]<br></p></div>",
stepFunction: insertSeparator,
contentAfter: "<div><hr><p>[]<br></p></div>",
});
});
test("should insert a separator after current paragraph related element containing text but remain inside the div", async () => {
await testEditor({
contentBefore: "<div><p>content[]</p></div>",
stepFunction: insertSeparator,
contentAfter: "<div><p>content</p><hr><p>[]<br></p></div>",
});
});
test("should not insert a separator inside a list", async () => {
await testEditor({
contentBefore: "<ul><li>[]<br></li></ul>",
stepFunction: insertSeparator,
contentAfter: "<ul><li>[]<br></li></ul>",
});
});
test("should insert a separator before a empty p element inside a table cell", async () => {
await testEditor({
contentBefore: "<table><tbody><tr><td><p>[]<br></p></td></tr></tbody></table>",
stepFunction: insertSeparator,
contentAfter: "<table><tbody><tr><td><hr><p>[]<br></p></td></tr></tbody></table>",
});
});
test("should insert a separator after a p element containing text inside a table cell", async () => {
await testEditor({
contentBefore: "<table><tbody><tr><td><p>content[]</p></td></tr></tbody></table>",
stepFunction: insertSeparator,
contentAfter:
"<table><tbody><tr><td><p>content</p><hr><p>[]<br></p></td></tr></tbody></table>",
});
});
test("should insert a seperator before a empty block node", async () => {
await testEditor({
contentBefore: "<div>[]<br></div>",
stepFunction: insertSeparator,
contentAfter: "<hr><div>[]<br></div>",
});
});
test("should insert a seperator after a block node containing text", async () => {
await testEditor({
contentBefore: "<div>content[]</div>",
stepFunction: insertSeparator,
contentAfter: "<div>content</div><hr><p>[]<br></p>",
});
});
test("should set the contenteditable attribute to false on the separator when inserted as a child after normalization", async () => {
const { el, editor } = await setupEditor("<p>[]<br></p>");
const div = editor.document.createElement("div");
const separator = editor.document.createElement("hr");
div.append(separator);
el.append(div);
editor.shared.history.addStep();
expect(getContent(el)).toBe(
`<p o-we-hint-text='Type "/" for commands' class="o-we-hint">[]<br></p><div><hr contenteditable="false"></div>`
);
});
test("should apply custom selection on separator when selected", async () => {
const { el, editor } = await setupEditor("<p>abc</p><p>x[]yz</p>");
await insertSeparator(editor);
expect(getContent(el)).toBe(
`<p>abc</p><p>xyz</p><hr contenteditable="false"><p o-we-hint-text='Type "/" for commands' class="o-we-hint">[]<br></p>`
);
simulateArrowKeyPress(editor, ["Shift", "ArrowUp"]);
await animationFrame();
expect(getContent(el)).toBe(
`<p>abc</p><p>]xyz</p><hr contenteditable="false" class="o_selected_hr"><p>[<br></p>`
);
});
test("should remove custom selection on separator when not selected", async () => {
const { el, editor } = await setupEditor(
'<p>[abc</p><hr contenteditable="false"><p>xyz]</p>'
);
expect(getContent(el)).toBe(
`<p>[abc</p><hr contenteditable="false" class="o_selected_hr"><p>xyz]</p>`
);
simulateArrowKeyPress(editor, ["Shift", "ArrowUp"]);
await animationFrame();
expect(getContent(el)).toBe(`<p>[abc]</p><hr contenteditable="false"><p>xyz</p>`);
});
});

View file

@ -0,0 +1,115 @@
import { describe, expect, test } from "@odoo/hoot";
import { setupEditor, testEditor } from "../_helpers/editor";
import { deleteBackward, insertText } from "../_helpers/user_actions";
import { getContent } from "../_helpers/selection";
import { execCommand } from "../_helpers/userCommands";
import { press } from "@odoo/hoot-dom";
describe("collapsed selection", () => {
test("should insert a char into an empty span without removing the zws", async () => {
await testEditor({
contentBefore: '<p>ab<span class="a">[]\u200B</span>cd</p>',
stepFunction: async (editor) => {
await insertText(editor, "x");
},
contentAfter: '<p>ab<span class="a">x[]\u200B</span>cd</p>',
});
});
test("should insert a char into an empty span surrounded by space without removing the zws", async () => {
await testEditor({
contentBefore: '<p>ab <span class="a">[]\u200B</span> cd</p>',
stepFunction: async (editor) => {
await insertText(editor, "x");
},
contentAfter: '<p>ab <span class="a">x[]\u200B</span> cd</p>',
});
});
test("should insert a char into a data-oe-zws-empty-inline span removing the zws and data-oe-zws-empty-inline", async () => {
await testEditor({
contentBefore: '<p>ab<span data-oe-zws-empty-inline="">[]\u200B</span>cd</p>',
stepFunction: async (editor) => {
await insertText(editor, "x");
},
contentAfter: "<p>abx[]cd</p>",
});
});
test("should insert a char into a data-oe-zws-empty-inline span surrounded by space without removing the zws and data-oe-zws-empty-inline", async () => {
await testEditor({
contentBefore: '<p>ab<span data-oe-zws-empty-inline="">[]\u200B</span>cd</p>',
stepFunction: async (editor) => {
await insertText(editor, "x");
},
contentAfter: "<p>abx[]cd</p>",
});
});
test("should insert text within heading after selecting a heading using ctrl+A", async () => {
await testEditor({
contentBefore: "<h1>abc[]</h1><p>def</p>",
stepFunction: async (editor) => {
await press(["ctrl", "a"]);
await insertText(editor, "x");
},
contentAfter: "<h1>x[]</h1>",
});
});
test("should insert a char into an empty p and remove the br", async () => {
await testEditor({
contentBefore: "<p>[]<br></p>",
stepFunction: async (editor) => {
await insertText(editor, "x");
},
contentAfter: "<p>x[]</p>",
});
});
test("should insert a char into an p with br and remove the unecessary br", async () => {
await testEditor({
contentBefore: "<p>abc<br>[]<br></p>",
stepFunction: async (editor) => {
await insertText(editor, "x");
},
contentAfter: "<p>abc<br>x[]</p>",
});
});
});
describe("not collapsed selection", () => {
test("should insert a character in a fully selected font in a heading, preserving its style", async () => {
await testEditor({
contentBefore:
'<h1><font style="background-color: red;">[abc]</font><br></h1><p>def</p>',
stepFunction: async (editor) => await insertText(editor, "g"),
contentAfter: '<h1><font style="background-color: red;">g[]</font><br></h1><p>def</p>',
});
await testEditor({
contentBefore:
'<h1><font style="background-color: red;">[abc]</font><br></h1><p>def</p>',
stepFunction: async (editor) => {
deleteBackward(editor);
await insertText(editor, "g");
},
contentAfter: '<h1><font style="background-color: red;">g[]</font><br></h1><p>def</p>',
});
});
test("should transform the space node preceded by a styled element to &nbsp;", async () => {
await testEditor({
contentBefore: `<p><strong>ab</strong> [cd]</p>`,
stepFunction: async (editor) => {
await insertText(editor, "x");
},
contentAfter: `<p><strong>ab</strong>&nbsp;x[]</p>`,
});
});
test("should replace text and be a undoable step", async () => {
const { editor, el } = await setupEditor("<p>[abc]def</p>");
await insertText(editor, "x");
expect(getContent(el)).toBe("<p>x[]def</p>");
execCommand(editor, "historyUndo");
expect(getContent(el)).toBe("<p>[abc]def</p>");
});
});