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,108 @@
import { test } from "@odoo/hoot";
import { press } from "@odoo/hoot-dom";
import { testEditor } from "../_helpers/editor";
import { unformat } from "../_helpers/format";
import { mockUserAgent } from "@odoo/hoot-mock";
const ctrlShiftBackspace = () => press(["Ctrl", "Shift", "Backspace"]);
test("should do nothing on ctrl+shift+backspace", async () => {
await testEditor({
contentBefore: "<p>[]<br></p>",
stepFunction: ctrlShiftBackspace,
contentAfter: "<p>[]<br></p>",
});
});
test("should delete to start of paragraph with ctrl+shift+backspace", async () => {
await testEditor({
contentBefore: "<p>abc def[]</p>",
stepFunction: ctrlShiftBackspace,
contentAfter: "<p>[]<br></p>",
});
});
test("should delete to start of paragraph with ctrl+shift+backspace (2)", async () => {
await testEditor({
contentBefore: "<p>abc def[] ghi</p>",
stepFunction: ctrlShiftBackspace,
contentAfter: "<p>[]&nbsp;ghi</p>",
});
});
test.tags("focus required");
test("should delete to start of paragraph with ctrl+shift+backspace (3)", async () => {
await testEditor({
contentBefore: "<p>first paragraph</p><p>abc def[]</p>",
stepFunction: ctrlShiftBackspace,
contentAfter: "<p>first paragraph</p><p>[]<br></p>",
});
});
test("should delete text between line-break and cursor", async () => {
await testEditor({
contentBefore: "<p>text<br>abc def []ghi</p>",
stepFunction: ctrlShiftBackspace,
contentAfter: "<p>text<br>[]ghi</p>",
});
});
test("should delete text between line-break and cursor (2)", async () => {
await testEditor({
contentBefore: "<p>text<br>abc def ghi[]</p>",
stepFunction: ctrlShiftBackspace,
contentAfter: "<p>text<br>[]<br></p>",
});
});
test("should delete text between cursor and previous line-break", async () => {
await testEditor({
contentBefore: "<p>text<br>more text<br>abc def []ghi</p>",
stepFunction: ctrlShiftBackspace,
contentAfter: "<p>text<br>more text<br>[]ghi</p>",
});
});
test("should not remove an unremovable element on CTRL+SHIFT+BACKSPACE", async () => {
await testEditor({
contentBefore: unformat(`
<p>abc</p>
<p class="oe_unremovable">[]<br></p>`),
stepFunction: ctrlShiftBackspace,
contentAfter: unformat(`
<p>abc</p>
<p class="oe_unremovable">[]<br></p>`),
});
});
test("should not merge an unbreakable element on CTRL+SHIFT+BACKSPACE", async () => {
await testEditor({
contentBefore: unformat(`
<div class="oe_unbreakable">abc</div>
<p>[]def</p>`),
stepFunction: ctrlShiftBackspace,
contentAfter: unformat(`
<div class="oe_unbreakable">abc</div>
<p>[]def</p>`),
});
});
test("should not merge an unbreakable element on CTRL+SHIFT+BACKSPACE (2)", async () => {
await testEditor({
contentBefore: unformat(`
<p>abc</p>
<div class="oe_unbreakable">[]def</div>`),
stepFunction: ctrlShiftBackspace,
contentAfter: unformat(`
<p>abc</p>
<div class="oe_unbreakable">[]def</div>`),
});
});
test("Should delete last line on MacOS", async () => {
mockUserAgent("mac");
await testEditor({
contentBefore: `<p>hello world, How Are you ?[]</p>`,
stepFunction: () => press(["Meta", "Backspace"]),
contentAfter: `<p>[]<br></p>`,
});
});

View file

@ -0,0 +1,120 @@
import { test } from "@odoo/hoot";
import { press } from "@odoo/hoot-dom";
import { testEditor } from "../_helpers/editor";
import { unformat } from "../_helpers/format";
import { mockUserAgent } from "@odoo/hoot-mock";
// CTRL+BACKSPACE
test("should not remove the last p with ctrl+backspace", async () => {
await testEditor({
contentBefore: unformat(`<p>[]<br></p>`),
stepFunction: () => press(["Ctrl", "Backspace"]),
contentAfter: unformat(`<p>[]<br></p>`),
});
});
test("should not remove the last p enclosed in a contenteditable=false with ctrl+backspace", async () => {
await testEditor({
contentBefore: unformat(`
<p>text</p>
<div contenteditable="false"><div contenteditable="true">
<p>[]<br></p>
</div></div>`),
stepFunction: () => press(["Ctrl", "Backspace"]),
contentAfter: unformat(`
<p>text</p>
<div contenteditable="false"><div contenteditable="true">
<p>[]<br></p>
</div></div>`),
});
});
test("should add a <p><br></p> element when deleting the last child of the editable with ctrl+backspace", async () => {
await testEditor({
contentBefore: unformat(`
<blockquote>
[]<br>
</blockquote>`),
stepFunction: () => press(["Ctrl", "Backspace"]),
contentAfter: unformat(`<p>[]<br></p>`),
});
});
test("should add a <p><br></p> element when deleting the last child of an element with ctrl+backspace", async () => {
await testEditor({
contentBefore: unformat(`
<div contenteditable="false"><div contenteditable="true">
<blockquote>
[]<br>
</blockquote>
</div></div>`),
stepFunction: () => press(["Ctrl", "Backspace"]),
contentAfter: unformat(`
<div contenteditable="false"><div contenteditable="true">
<p>[]<br></p>
</div></div>`),
});
});
test("should not remove an unremovable element on CTRL+BACKSPACE", async () => {
await testEditor({
contentBefore: unformat(`
<div contenteditable="false"><div contenteditable="true">
<blockquote class="oe_unremovable">
[]<br>
</blockquote>
</div></div>`),
stepFunction: () => press(["Ctrl", "Backspace"]),
contentAfter: unformat(`
<div contenteditable="false"><div contenteditable="true">
<blockquote class="oe_unremovable">
[]<br>
</blockquote>
</div></div>`),
});
});
test("should not remove an unremovable element on CTRL+BACKSPACE (2)", async () => {
await testEditor({
contentBefore: unformat(`
<p>abc</p>
<p class="oe_unremovable">[]<br></p>`),
stepFunction: () => press(["Ctrl", "Backspace"]),
contentAfter: unformat(`
<p>abc</p>
<p class="oe_unremovable">[]<br></p>`),
});
});
test("should not merge an unbreakable element on CTRL+BACKSPACE", async () => {
await testEditor({
contentBefore: unformat(`
<div class="oe_unbreakable">abc</div>
<p>[]def</p>`),
stepFunction: () => press(["Ctrl", "Backspace"]),
contentAfter: unformat(`
<div class="oe_unbreakable">abc</div>
<p>[]def</p>`),
});
});
test("should not merge an unbreakable element on CTRL+BACKSPACE (2)", async () => {
await testEditor({
contentBefore: unformat(`
<p>abc</p>
<div class="oe_unbreakable">[]def</div>`),
stepFunction: () => press(["Ctrl", "Backspace"]),
contentAfter: unformat(`
<p>abc</p>
<div class="oe_unbreakable">[]def</div>`),
});
});
test("Should delete last word on MacOS", async () => {
mockUserAgent("mac");
await testEditor({
contentBefore: `<p>hello world[]</p>`,
stepFunction: () => press(["Alt", "Backspace"]),
contentAfter: `<p>hello&nbsp;[]</p>`,
});
});

View file

@ -0,0 +1,541 @@
import { describe, expect, test } from "@odoo/hoot";
import { setupEditor, testEditor } from "../_helpers/editor";
import { unformat } from "../_helpers/format";
import { CORE_PLUGINS } from "@html_editor/plugin_sets";
import { getContent, setSelection } from "../_helpers/selection";
async function testCoreEditor(testConfig) {
return testEditor({ ...testConfig, config: { Plugins: CORE_PLUGINS } });
}
// Tests the deleteRange shared method.
async function deleteRange(editor) {
// Avoid SelectionPlugin methods to avoid normalization. The goal is to
// simulate the range passed as argument to the deleteRange method.
const selection = editor.document.getSelection();
let range = selection.getRangeAt(0);
range = editor.shared.delete.deleteRange(range);
const { startContainer, startOffset, endContainer, endOffset } = range;
selection.setBaseAndExtent(startContainer, startOffset, endContainer, endOffset);
}
// Tests the DELETE_SELECTION command.
async function deleteSelection(editor) {
editor.shared.delete.deleteSelection();
}
describe("deleteRange method", () => {
describe("Basic", () => {
test("should delete a range inside a text node in a paragraph", async () => {
await testCoreEditor({
contentBefore: "<p>a[bc]d</p>",
stepFunction: deleteRange,
contentAfterEdit: "<p>a[]d</p>",
});
});
test("should delete a range across different nodes in a paragraph", async () => {
await testCoreEditor({
contentBefore: "<p>a[b<i>cd</i>ef<strong>gh</strong>i]j</p>",
stepFunction: deleteRange,
contentAfterEdit: "<p>a[]j</p>",
});
});
});
describe("Inside inline", () => {
test("should delete a range inside an inline element", async () => {
await testCoreEditor({
contentBefore: "<p><strong>a[bc]d</strong></p>",
stepFunction: deleteRange,
contentAfterEdit: "<p><strong>a[]d</strong></p>",
});
});
test("should delete a range inside an inline element and fill empty inline", async () => {
await testCoreEditor({
contentBefore: "<p><strong>[abcd]</strong></p>",
stepFunction: deleteRange,
contentAfterEdit:
'<p><strong data-oe-zws-empty-inline="">[]\u200b</strong><br></p>',
});
});
});
describe("Across inlines", () => {
test("delete across two inlines (no merge)", async () => {
await testCoreEditor({
contentBefore: "<p><i>a[bc</i>de<i>fg]h</i></p>",
stepFunction: deleteRange,
contentAfterEdit: "<p><i>a[]</i><i>h</i></p>",
});
});
test("delete across two inlines, start one left empty (should fill empty inline) ", async () => {
await testCoreEditor({
contentBefore: "<p><i>[abc</i>de<i>fg]h</i></p>",
stepFunction: deleteRange,
contentAfterEdit: '<p><i data-oe-zws-empty-inline="">[]\u200b</i><i>h</i></p>',
});
});
test("delete across two inlines, end one left empty (should fill empty inline) ", async () => {
await testCoreEditor({
contentBefore: "<p><i>a[bc</i>de<i>fgh]</i></p>",
stepFunction: deleteRange,
contentAfterEdit: '<p><i>a[]</i><i data-oe-zws-empty-inline="">\u200b</i></p>',
});
});
test("delete across two inlines, both left empty (should fill both)", async () => {
await testCoreEditor({
contentBefore: "<p><i>[abc</i>de<i>fgh]</i>jkl</p>",
stepFunction: deleteRange,
contentAfterEdit:
'<p><i data-oe-zws-empty-inline="">[]\u200b</i><i data-oe-zws-empty-inline="">\u200b</i>jkl</p>',
});
});
test("delete across two inlines, both left empty, block left shrunk (should fill inlines and block", async () => {
await testCoreEditor({
contentBefore: "<p><i>[abc</i>de<i>fgh]</i></p>",
stepFunction: deleteRange,
contentAfterEdit:
'<p><i data-oe-zws-empty-inline="">[]\u200b</i><i data-oe-zws-empty-inline="">\u200b</i><br></p>',
});
});
});
describe("Inside block", () => {
test("should delete a range inside a text node in a paragraph and fill shrunk block", async () => {
await testCoreEditor({
contentBefore: "<p>[abcd]</p>",
stepFunction: deleteRange,
contentAfterEdit: "<p>[]<br></p>",
});
});
});
describe("Across blocks", () => {
test("should merge paragraphs", async () => {
await testEditor({
contentBefore: "<p>ab[c</p><p>d]ef</p>",
stepFunction: deleteRange,
contentAfter: "<p>ab[]ef</p>",
});
});
test("should merge right block's content into left block", async () => {
await testEditor({
contentBefore: "<h1>ab[c</h1><p>d]ef</p>",
stepFunction: deleteRange,
contentAfter: "<h1>ab[]ef</h1>",
});
});
test("should merge right block's content into fully selected left block", async () => {
// As opposed to the DELETE_SELECTION command, in which fully selected block on the left is removed.
// See "should remove fully selected left block and keep second block"
await testEditor({
contentBefore: "<h1>[abc</h1><p>d]ef</p>",
stepFunction: deleteRange,
contentAfter: "<h1>[]ef</h1>",
});
});
test("should merge right block's content into left block and fill shrunk block", async () => {
await testEditor({
contentBefore: "<h1>[abc</h1><p>def]</p>",
stepFunction: deleteRange,
contentAfter: "<h1>[]<br></h1>",
});
});
test("should not merge paragraph with paragraph before it", async () => {
await testEditor({
contentBefore: "<div><p>abc</p>[<p>]def</p></div>",
stepFunction: deleteRange,
contentAfter: "<div><p>abc</p>[]<p>def</p></div>",
});
});
test("should merge paragraph with paragraph before it", async () => {
await testEditor({
contentBefore: "<div><p>abc[</p><p>]def</p></div>",
stepFunction: deleteRange,
contentAfter: "<div><p>abc[]def</p></div>",
});
});
});
describe("Block + inline", () => {
test("should merge paragraph with inline content after it", async () => {
await testEditor({
contentBefore: "<div><p>ab[c</p>d]ef</div>",
stepFunction: deleteRange,
contentAfter: "<div><p>ab[]ef</p></div>",
});
});
test("should merge paragraph with inline content after it (2)", async () => {
// This is the kind of range passed to deleteRange on `...</p>[]def...` + deleteBackward
await testEditor({
contentBefore: "<div><p>abc[</p>]def</div>",
stepFunction: deleteRange,
contentAfter: "<div><p>abc[]def</p></div>",
});
});
});
describe("Inline + block", () => {
test("should merge paragraph with inline content before it (remove paragraph)", async () => {
await testEditor({
contentBefore: "<div>ab[c<p>d]ef</p></div>",
stepFunction: deleteRange,
contentAfter: "<div>ab[]ef</div>",
});
});
test("should merge paragraph with inline content before it", async () => {
await testEditor({
contentBefore: "<div>ab[c<p>d]ef</p><p>ghi</p></div>",
stepFunction: deleteRange,
contentAfter: "<div>ab[]ef<p>ghi</p></div>",
});
});
test("should merge paragraph with inline content before it (remove paragraph) (2)", async () => {
await testEditor({
contentBefore: "<div>abc[<p>]def</p></div>",
stepFunction: deleteRange,
contentAfter: "<div>abc[]def</div>",
});
});
test("should merge paragraph with inline content before it and insert a line-break after it", async () => {
await testEditor({
contentBefore: "<div>ab[c<p>d]ef</p>ghi</div>",
stepFunction: deleteRange,
contentAfter: "<div>ab[]ef<br>ghi</div>",
});
});
test("should merge nested paragraph with inline content before it and insert a line-break after it", async () => {
await testEditor({
contentBefore: `<div>ab[c<custom-block style="display: block;"><p>d]ef</p></custom-block>ghi</div>`,
stepFunction: deleteRange,
contentAfter: "<div>ab[]ef<br>ghi</div>",
});
});
});
describe("Fake line breaks", () => {
test("should not crash if cursor is inside a fake BR", async () => {
// The goal of this tests is to make sure deleteRange does not rely
// on selection normaliztion. It should not assume that the cursor
// is never inside a BR.
const contentBefore = unformat(
`<table><tbody>
<tr><td><br></td><td><br></td></tr>
<tr><td><br></td><td><br></td></tr>
</tbody></table>`
);
const { editor, el } = await setupEditor(contentBefore);
// Place the cursor inside the BR.
setSelection({
anchorNode: el,
anchorOffset: 0,
focusNode: el.querySelector("tr:nth-child(2) td br"),
focusOffset: 0,
});
/* [<table><tbody>
<tr><td><br></td><td><br></td></tr>
<tr><td><]br></td><td><br></td></tr>
</tbody></table>
*/
deleteRange(editor);
const contentAfter = unformat(
`[<table><tbody>
<tr><td><br></td><td><br></td></tr>
<tr><td>]<br></td><td><br></td></tr>
</tbody></table>`
);
expect(getContent(el)).toBe(contentAfter);
});
});
describe("Fill shrunk blocks", () => {
test("should not fill a HR with BR", async () => {
const { editor, el } = await setupEditor("<hr><p>abc[</p><p>]def</p>");
deleteRange(editor);
const hr = el.firstElementChild;
expect(hr.childNodes.length).toBe(0);
});
});
describe("Delete Columns", () => {
test("should delete columns when all selected", async () => {
await testEditor({
contentBefore: `[<div class="container o_text_columns"><div class="row"><div class="col-4"><p>a</p></div><div class="col-4"><p>b</p></div><div class="col-4"><p>c</p></div></div></div>]`,
stepFunction: deleteRange,
contentAfter: `[]<p><br></p>`,
});
});
test("should delete columns when all selected along with text from an outer node", async () => {
await testEditor({
contentBefore: `<p>a[b</p><div class="container o_text_columns"><div class="row"><div class="col-4"><p><br></p></div><div class="col-4"><p><br></p></div><div class="col-4"><p>c</p></div></div></div>]`,
stepFunction: deleteRange,
contentAfter: `<p>a[]</p>`,
});
});
test("should delete all columns when all selected within a text", async () => {
await testEditor({
contentBefore: `<p>a[b</p><div class="container o_text_columns"><div class="row"><div class="col-4"><p><br></p></div><div class="col-4"><p><br></p></div><div class="col-4"><p><br></p></div></div></div><p>a]b</p>`,
stepFunction: deleteRange,
contentAfter: `<p>a[]b</p>`,
});
});
});
});
describe("deleteSelection", () => {
describe("Merge blocks", () => {
test("should remove fully selected left block and keep second block", async () => {
// As opposed to the deleteRange method.
// This is done by expanding the range to fully include the left
// block before calling deleteRange. See `includeEndOrStartBlock` method.
// <h1>[abc</h1><p>d]ef</p> -> [<h1>abc</h1><p>d]ef</p> -> deleteRange
await testEditor({
contentBefore: "<h1>[abc</h1><p>d]ef</p>",
stepFunction: deleteSelection,
contentAfter: "<p>[]ef</p>",
});
});
test("should keep left block if both have been emptied", async () => {
await testEditor({
contentBefore: "<h1>[abc</h1><p>def]</p>",
stepFunction: deleteSelection,
contentAfter: "<h1>[]<br></h1>",
});
});
});
describe("Unmergeables", () => {
test("should not merge paragraph with unmeargeble block", async () => {
await testEditor({
contentBefore: `<p>ab[c</p><div class="oe_unbreakable">d]ef</div>`,
stepFunction: deleteSelection,
contentAfter: `<p>ab[]</p><div class="oe_unbreakable">ef</div>`,
});
});
test("should remove unmergeable block that has been emptied", async () => {
// `includeEndOrStartBlock` fully includes the right block.
// <p>ab[c</p><div>def]</div> -> <p>ab[c</p><div>def</div>] -> deleteRange
await testEditor({
contentBefore: `<p>ab[c</p><div class="oe_unbreakable">def]</div>`,
stepFunction: deleteSelection,
contentAfter: "<p>ab[]</p>",
});
});
});
describe("Unremovables", () => {
test("should not remove unremovable node, but clear its content", async () => {
await testEditor({
contentBefore: `<p>a[bc</p><div class="oe_unremovable">def</div><p>gh]i</p>`,
stepFunction: deleteSelection,
contentAfter: `<p>a[]</p><div class="oe_unremovable"><br></div><p>i</p>`,
});
});
test("should move the unremovable up the tree", async () => {
await testEditor({
contentBefore: `<p>a[bc</p><div><div class="oe_unremovable">def</div></div><p>gh]i</p>`,
stepFunction: deleteSelection,
contentAfter: `<p>a[]</p><div class="oe_unremovable"><br></div><p>i</p>`,
});
});
test("should preserve parent-child relations between unremovables", async () => {
await testEditor({
contentBefore: unformat(
`<p>a[bc</p>
<div>
<div class="oe_unremovable">
<div class="oe_unremovable">jkl</div>
<p>mno</p>
</div>
</div>
<p>gh]i</p>`
),
stepFunction: deleteSelection,
contentAfter: unformat(
`<p>a[]</p>
<div class="oe_unremovable">
<div class="oe_unremovable"><br></div>
</div>
<p>i</p>`
),
});
});
test("should preserve parent-child relations between unremovables (2)", async () => {
await testEditor({
contentBefore: unformat(
`<p>a[bc</p>
<div class="oe_unremovable">xyz</div>
<div>
<div class="oe_unremovable">
<div>
<div class="oe_unremovable">jkl</div>
</div>
<p>mno</p>
<div class="oe_unremovable">mno</div>
</div>
</div>
<p>gh]i</p>`
),
stepFunction: deleteSelection,
contentAfter: unformat(
`<p>a[]</p>
<div class="oe_unremovable"><br></div>
<div class="oe_unremovable">
<div class="oe_unremovable"><br></div>
<div class="oe_unremovable"><br></div>
</div>
<p>i</p>`
),
});
});
});
describe("Conditional unremovables", () => {
describe("Bootstrap columns", () => {
test("should not remove bootstrap columns, but clear its content", async () => {
await testEditor({
contentBefore: unformat(
`<div class="container o_text_columns o-contenteditable-false">
<div class="row">
<div class="col-6 o-contenteditable-true">a[bc</div>
<div class="col-6 o-contenteditable-true">def</div>
</div>
</div>
<p>gh]i</p>`
),
stepFunction: deleteSelection,
contentAfterEdit: unformat(
`<div class="container o_text_columns o-contenteditable-false" contenteditable="false">
<div class="row">
<div class="col-6 o-contenteditable-true" contenteditable="true">a[]</div>
<div class="col-6 o-contenteditable-true" contenteditable="true"><p o-we-hint-text="Empty column" class="o-we-hint"><br></p></div>
</div>
</div>
<p>i</p>`
),
contentAfter: unformat(
`<div class="container o_text_columns o-contenteditable-false">
<div class="row">
<div class="col-6 o-contenteditable-true">a[]</div>
<div class="col-6 o-contenteditable-true"><p><br></p></div>
</div>
</div>
<p>i</p>`
),
});
});
test("should remove bootstrap columns", async () => {
await testEditor({
contentBefore: unformat(
`<p>x[yz</p>
<div class="container o_text_columns o-contenteditable-false">
<div class="row">
<div class="col-6 o-contenteditable-true">abc</div>
<div class="col-6 o-contenteditable-true">def</div>
</div>
</div>
<p>gh]i</p>`
),
stepFunction: deleteSelection,
contentAfter: "<p>x[]i</p>",
});
});
});
describe("Table cells", () => {
test("should not remove table cell, but clear its content", async () => {
// Actually this is handled by the table plugin, and does not
// involve the unremovable mechanism.
await testEditor({
contentBefore: unformat(
`<table><tbody>
<tr>
<td>[a</td> <td>b]</td> <td>c</td>
</tr>
<tr>
<td>d</td> <td>e</td> <td>f</td>
</tr>
</tbody></table>`
),
stepFunction: deleteSelection,
contentAfter: unformat(
`<table><tbody>
<tr>
<td><p>[]<br></p></td> <td><p><br></p></td> <td>c</td>
</tr>
<tr>
<td>d</td> <td>e</td> <td>f</td>
</tr>
</tbody></table>`
),
});
});
test("should remove table", async () => {
await testEditor({
contentBefore: unformat(
`<p>a[bc</p>
<table><tbody>
<tr>
<td><p>abc</p></td><td><p>def</p></td>
</tr>
</tbody></table>
<p>gh]i</p>`
),
stepFunction: deleteSelection,
contentAfter: "<p>a[]i</p>",
});
});
});
});
describe("Allowed content mismatch on blocks merge", () => {
test("should not add H1 (flow content) to P (allows phrasing content only)", async () => {
await testEditor({
contentBefore: unformat(
`<p>a[bc</p>
<ul>
<li>
<h1>def</h1>]
<h1>ghi</h1>
</li>
</ul>`
),
stepFunction: deleteSelection,
contentAfter: unformat(
`<p>a[]</p>
<ul>
<li>
<h1>ghi</h1>
</li>
</ul>`
),
});
});
test("should add P (flow content) to LI (allows flow content) ", async () => {
await testEditor({
contentBefore: unformat(
`<ul>
<li>
<h1>abc</h1>
[<h1>def</h1>
</li>
<li>
<p>ghi</p>]
<p>jkl</p>
</li>
</ul>`
),
stepFunction: deleteSelection,
contentAfter: unformat(
`<ul>
<li>
<h1>abc</h1>
<p>[]jkl</p>
</li>
</ul>`
),
});
});
});
});

View file

@ -0,0 +1,238 @@
import { describe, expect, test } from "@odoo/hoot";
import { setupEditor } from "../_helpers/editor";
import { getContent, setSelection } from "../_helpers/selection";
import { unformat } from "../_helpers/format";
import { FilePlugin } from "@html_editor/main/media/file_plugin";
import { CORE_PLUGINS } from "@html_editor/plugin_sets";
function findAdjacentPosition(editor, direction) {
const deletePlugin = editor.plugins.find((p) => p.constructor.id === "delete");
const selection = editor.document.getSelection();
const { anchorNode, anchorOffset } = selection;
return deletePlugin.findAdjacentPosition(anchorNode, anchorOffset, direction);
}
function assertAdjacentPositions(editor, previous, next) {
let [node, offset] = findAdjacentPosition(editor, "forward");
setSelection({ anchorNode: node, anchorOffset: offset });
expect(getContent(editor.editable)).toBe(next);
[node, offset] = findAdjacentPosition(editor, "backward");
setSelection({ anchorNode: node, anchorOffset: offset });
expect(getContent(editor.editable)).toBe(previous);
}
describe("findAdjacentPosition method", () => {
describe("Basic", () => {
test("should find adjacent character", async () => {
const previous = "<p>a[]bcd</p>";
const next = "<p>ab[]cd</p>";
const { editor } = await setupEditor(previous);
assertAdjacentPositions(editor, previous, next);
});
test("should find adjacent character (2)", async () => {
const previous = "<p>[]abcd</p>";
const next = "<p>a[]bcd</p>";
const { editor } = await setupEditor(previous);
assertAdjacentPositions(editor, previous, next);
});
test("should find adjacent character in different text node", async () => {
const previous = "<p>a[]bcd</p>";
const next = "<p>ab[]cd</p>";
const { editor, el } = await setupEditor(previous);
// Split text node between 'a' and 'b'
const textNode = el.firstChild.firstChild;
textNode.splitText(1);
setSelection({ anchorNode: textNode, anchorOffset: 1 });
assertAdjacentPositions(editor, previous, next);
});
test("should find first position after paragraph break", async () => {
const previous = "<p>ab[]</p><p>cd</p>";
const next = "<p>ab</p><p>[]cd</p>";
const { editor } = await setupEditor(previous);
assertAdjacentPositions(editor, previous, next);
});
test("should not find anything before the first position", async () => {
const { editor } = await setupEditor("<p>[]abc</p>");
const [node, offset] = findAdjacentPosition(editor, "backward");
expect(node).toBe(null);
expect(offset).toBe(null);
});
test("should not find anything after the last position", async () => {
const { editor } = await setupEditor("<p>abc[]</p>");
const [node, offset] = findAdjacentPosition(editor, "forward");
expect(node).toBe(null);
expect(offset).toBe(null);
});
test("should skip invisible character", async () => {
const { editor, el } = await setupEditor("<p>d[]\u200bef</p>");
const [node, offset] = findAdjacentPosition(editor, "forward");
setSelection({ anchorNode: node, anchorOffset: offset });
expect(getContent(el)).toBe("<p>d\u200be[]f</p>");
// @todo: non-reversible operation (e.g. backward results in
// <p>d\u200b[]ef</p>). Should it be?
});
test("should skip invisible character (2)", async () => {
const { editor, el } = await setupEditor("<p>d\u200b[]ef</p>");
const [node, offset] = findAdjacentPosition(editor, "backward");
setSelection({ anchorNode: node, anchorOffset: offset });
expect(getContent(el)).toBe("<p>[]d\u200bef</p>");
});
});
describe("Contenteditable=false elements", () => {
describe("Inlines", () => {
test("Should find position after the span", async () => {
const previous = '<p>a[]<span contenteditable="false">b</span>c</p>';
const next = '<p>a<span contenteditable="false">b</span>[]c</p>';
const { editor } = await setupEditor(previous);
assertAdjacentPositions(editor, previous, next);
});
test("Should find position after paragraph break", async () => {
const previous = '<div><p>a[]</p><span contenteditable="false">b</span></div>';
const next = '<div><p>a</p>[]<span contenteditable="false">b</span></div>';
const { editor } = await setupEditor(previous);
assertAdjacentPositions(editor, previous, next);
});
test("Should find position before filebox", async () => {
const content = `<div>\ufeff<span contenteditable="false" class="o_file_box"></span>\ufeff[]</div>`;
const { editor, el } = await setupEditor(content, {
config: { Plugins: [...CORE_PLUGINS, FilePlugin] },
});
const [node, offset] = findAdjacentPosition(editor, "backward");
setSelection({ anchorNode: node, anchorOffset: offset });
expect(getContent(el)).toBe(
`<div class="o-paragraph">\ufeff[]<span contenteditable="false" class="o_file_box"></span>\ufeff<br></div>`
);
});
});
describe("Blocks", () => {
test("Should find position after the div", async () => {
const { editor, el } = await setupEditor(
'<p>a[]</p><div contenteditable="false">b</div><p>c</p>'
);
const [node, offset] = findAdjacentPosition(editor, "forward");
setSelection({ anchorNode: node, anchorOffset: offset });
expect(getContent(el)).toBe(
// This position is not reachable with the keyboard, but
// it's the desirable one to compose a range for deletion,
// allowing to remove the div with deleteForward without
// afecting the paragraph after it.
'<p>a</p><div contenteditable="false">b</div>[]<p>c</p>'
);
});
test("Should find position before the div", async () => {
const { editor, el } = await setupEditor(
'<p>a</p><div contenteditable="false">b</div><p>[]c</p>'
);
const [node, offset] = findAdjacentPosition(editor, "backward");
setSelection({ anchorNode: node, anchorOffset: offset });
expect(getContent(el)).toBe(
// This position is not reachable with the keyboard, but
// it's the desirable one to compose a range for deletion,
// allowing to remove the div with deleteBackward without
// afecting the paragraph before it.
'<p>a</p>[]<div contenteditable="false">b</div><p>c</p>'
);
});
});
});
describe("Different editable zones", () => {
test("should find adjacent character", async () => {
const previous = unformat(`
<div contenteditable="false">
<p>abc</p>
<p contenteditable="true">[]def</p>
</div>
<p>fgh</p>
`);
const next = unformat(`
<div contenteditable="false">
<p>abc</p>
<p contenteditable="true">d[]ef</p>
</div>
<p>fgh</p>
`);
const { editor } = await setupEditor(previous);
assertAdjacentPositions(editor, previous, next);
});
test("should not find anything outside the closest editable root", async () => {
const { editor } = await setupEditor(
unformat(`
<div contenteditable="false">
<p>abc</p>
<p contenteditable="true">[]def</p>
</div>
<p>fgh</p>
`)
);
const [node, offset] = findAdjacentPosition(editor, "backward");
expect(node).toBe(null);
expect(offset).toBe(null);
});
test("should not find anything outside the closest editable root (2)", async () => {
const { editor } = await setupEditor(
unformat(`
<div contenteditable="false">
<p>abc</p>
<p contenteditable="true">def[]</p>
</div>
<p>fgh</p>
`)
);
const [node, offset] = findAdjacentPosition(editor, "forward");
expect(node).toBe(null);
expect(offset).toBe(null);
});
test("Should find position before the div", async () => {
const { editor, el } = await setupEditor(
unformat(`
<div contenteditable="false">
<p>abc</p>
<p contenteditable="true">def</p>
</div>
<p>[]fgh</p>
`)
);
const [node, offset] = findAdjacentPosition(editor, "backward");
setSelection({ anchorNode: node, anchorOffset: offset });
expect(getContent(el)).toBe(
// This position is not reachable with the keyboard, but
// it's the desirable one to compose a range for deletion,
// allowing to remove the div with deleteBackward
unformat(`
[]<div contenteditable="false">
<p>abc</p>
<p contenteditable="true">def</p>
</div>
<p>fgh</p>
`)
);
});
test("Should find position after the div", async () => {
const { editor, el } = await setupEditor(
unformat(`
<p>fgh[]</p>
<div contenteditable="false">
<p>abc</p>
<p contenteditable="true">def</p>
</div>
`)
);
const [node, offset] = findAdjacentPosition(editor, "forward");
setSelection({ anchorNode: node, anchorOffset: offset });
expect(getContent(el)).toBe(
// This position is not reachable with the keyboard, but
// it's the desirable one to compose a range for deletion,
// allowing to remove the div with deleteForward
unformat(`
<p>fgh</p>
<div contenteditable="false">
<p>abc</p>
<p contenteditable="true">def</p>
</div>[]
`)
);
});
});
});

View file

@ -0,0 +1,264 @@
import { describe, test } from "@odoo/hoot";
import { testEditor } from "../_helpers/editor";
import { unformat } from "../_helpers/format";
import { deleteBackward } from "../_helpers/user_actions";
describe("Adjust base container on delete", () => {
test("should remove empty o-paragraph block", async () => {
await testEditor({
contentBefore: unformat(`
<div>
<div class="o-paragraph">[]<br></div>
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<div class="o-paragraph o-we-hint" o-we-hint-text='Type "/" for commands'>[]<br></div>
`),
});
});
test("should not remove empty o-paragraph block", async () => {
await testEditor({
contentBefore: unformat(`
<div>
<div class="o-paragraph">[]<br></div>
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<div>
<div class="o-paragraph o-we-hint" o-we-hint-text='Type "/" for commands'>[]<br></div>
</div>
`),
config: { cleanEmptyStructuralContainers: false },
});
});
test("should remove empty o-paragraph block after text", async () => {
await testEditor({
contentBefore: unformat(`
<div>
abc<div class="o-paragraph">[]<br></div>
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<div class="o-paragraph">abc[]</div>
`),
});
});
test("should remove div with selected text", async () => {
await testEditor({
contentBefore: unformat(`
<div>
<div>[abc]</div>
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<div class="o-paragraph o-we-hint" o-we-hint-text='Type "/" for commands'>[]<br></div>
`),
});
});
test("should remove empty nested paragraph", async () => {
await testEditor({
contentBefore: unformat(`
<div>
<div>
<p>[]<br></p>
</div>
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<div><div class="o-paragraph o-we-hint" o-we-hint-text='Type "/" for commands'>[]<br></div></div>
`),
});
});
test("should not remove standalone paragraph", async () => {
await testEditor({
contentBefore: unformat(`
<p>[]<br></p>
`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<p o-we-hint-text='Type "/" for commands' class="o-we-hint">[]<br></p>
`),
});
});
test("should not remove unremovable div with selected text", async () => {
await testEditor({
contentBefore: unformat(`
<div class="oe_unremovable">
[abc]
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<div class="oe_unremovable">[]<br></div>
`),
});
});
test("should keep paragraph inside unremovable div", async () => {
await testEditor({
contentBefore: unformat(`
<div class="oe_unremovable">
<p>[abc]</p>
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<div class="oe_unremovable">
<p o-we-hint-text='Type "/" for commands' class="o-we-hint">[]<br></p>
</div>
`),
});
});
test("should keep block inside non-editable parent", async () => {
await testEditor({
contentBefore: unformat(`
<div contenteditable="false">
<div contenteditable="true">[abc]</div>
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
// The P is added by the deletion, not by `cleanEmptyStructuralContainers`.
contentAfterEdit: unformat(`
<div contenteditable="false">
<div contenteditable="true">
<p o-we-hint-text='Type "/" for commands' class="o-we-hint">[]<br></p>
</div>
</div>
`),
});
});
test("should keep o-paragraph inside non-editable parent", async () => {
await testEditor({
contentBefore: unformat(`
<div contenteditable="false">
<div contenteditable="true">
<div class="o-paragraph">[]<br></div>
</div>
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<div contenteditable="false">
<div contenteditable="true">
<div class="o-paragraph o-we-hint" o-we-hint-text='Type "/" for commands'>[]<br></div>
</div>
</div>
`),
});
});
test("should remove nested o-paragraph with inner paragraph", async () => {
await testEditor({
contentBefore: unformat(`
<div>
<div class="o-paragraph">
<p class="inner-paragraph">[]<br></p>
</div>
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<div>
<div class="o-paragraph o-we-hint" o-we-hint-text='Type "/" for commands'>[]</div>
</div>
`),
});
});
test("should remove first of multiple consecutive empty blocks", async () => {
await testEditor({
contentBefore: unformat(`
<div>
<div>[]<br></div>
<div><br></div>
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<div>
<div class="o-paragraph o-we-hint" o-we-hint-text='Type "/" for commands'>[]<br></div>
</div>
`),
});
});
test("should not remove block with cursor at beginning of content", async () => {
await testEditor({
contentBefore: unformat(`
<div>
<div>[]text</div>
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<div>
<div class="o-paragraph">[]text</div>
</div>
`),
});
});
test("should merge blocks when range spans multiple o-paragraphs", async () => {
await testEditor({
contentBefore: unformat(`
<div>
<div class="o-paragraph">[abc</div>
<div class="o-paragraph">def]</div>
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<div class="o-paragraph o-we-hint" o-we-hint-text='Type "/" for commands'>[]<br></div>
`),
});
});
test("should remove o-paragraph inside nested non-editable structure", async () => {
await testEditor({
contentBefore: unformat(`
<div contenteditable="false">
<div contenteditable="true">
<div class="o-paragraph">[]<br></div>
</div>
</div>`),
stepFunction: async (editor) => {
deleteBackward(editor);
},
contentAfterEdit: unformat(`
<div contenteditable="false">
<div contenteditable="true">
<div class="o-paragraph o-we-hint" o-we-hint-text='Type "/" for commands'>[]<br></div>
</div>
</div>
`),
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,107 @@
import { test } from "@odoo/hoot";
import { press } from "@odoo/hoot-dom";
import { testEditor } from "../_helpers/editor";
import { unformat } from "../_helpers/format";
import { mockUserAgent } from "@odoo/hoot-mock";
const ctrlShiftDelete = () => press(["Ctrl", "Shift", "Delete"]);
test("should do nothing on ctrl+shift+delete", async () => {
await testEditor({
contentBefore: "<p>[]<br></p>",
stepFunction: ctrlShiftDelete,
contentAfter: "<p>[]<br></p>",
});
});
test("should delete to end of paragraph with ctrl+shift+delete", async () => {
await testEditor({
contentBefore: "<p>[]abc def</p>",
stepFunction: ctrlShiftDelete,
contentAfter: "<p>[]<br></p>",
});
});
test("should delete to end of paragraph with ctrl+shift+delete (2)", async () => {
await testEditor({
contentBefore: "<p>abc []def ghi</p>",
stepFunction: ctrlShiftDelete,
contentAfter: "<p>abc&nbsp;[]</p>",
});
});
test("should delete to end of paragraph with ctrl+shift+delete (3)", async () => {
await testEditor({
contentBefore: "<p>[]abc def</p><p>second paragraph</p>",
stepFunction: ctrlShiftDelete,
contentAfter: "<p>[]<br></p><p>second paragraph</p>",
});
});
test("should delete text between line-break and cursor", async () => {
await testEditor({
contentBefore: "<p>abc[] def ghi<br>text</p>",
stepFunction: ctrlShiftDelete,
contentAfter: "<p>abc[]<br>text</p>",
});
});
test("should delete text between line-break and cursor (2)", async () => {
await testEditor({
contentBefore: "<p>[]abc def ghi<br>text</p>",
stepFunction: ctrlShiftDelete,
contentAfter: "<p>[]<br>text</p>",
});
});
test("should delete text between cursor and next line-break", async () => {
await testEditor({
contentBefore: "<p>abc[] def ghi<br>text<br>more text</p>",
stepFunction: ctrlShiftDelete,
contentAfter: "<p>abc[]<br>text<br>more text</p>",
});
});
test("should not remove an unremovable element on CTRL+SHIFT+DELETE", async () => {
await testEditor({
contentBefore: unformat(`
<p class="oe_unremovable">[]<br></p>
<p>abc</p>`),
stepFunction: ctrlShiftDelete,
contentAfter: unformat(`
<p class="oe_unremovable">[]<br></p>
<p>abc</p>`),
});
});
test("should not merge an unbreakable element on CTRL+SHIFT+DELETE", async () => {
await testEditor({
contentBefore: unformat(`
<div class="oe_unbreakable">abc[]</div>
<p>def</p>`),
stepFunction: ctrlShiftDelete,
contentAfter: unformat(`
<div class="oe_unbreakable">abc[]</div>
<p>def</p>`),
});
});
test("should not merge an unbreakable element on CTRL+SHIFT+DELETE (2)", async () => {
await testEditor({
contentBefore: unformat(`
<p>abc[]</p>
<div class="oe_unbreakable">def</div>`),
stepFunction: ctrlShiftDelete,
contentAfter: unformat(`
<p>abc[]</p>
<div class="oe_unbreakable">def</div>`),
});
});
test("Should delete last line on MacOS", async () => {
mockUserAgent("mac");
await testEditor({
contentBefore: `<p>[]hello world, How Are you ?</p>`,
stepFunction: () => press(["Meta", "Delete"]),
contentAfter: `<p>[]<br></p>`,
});
});

View file

@ -0,0 +1,50 @@
import { test } from "@odoo/hoot";
import { press } from "@odoo/hoot-dom";
import { testEditor } from "../_helpers/editor";
import { unformat } from "../_helpers/format";
import { mockUserAgent } from "@odoo/hoot-mock";
test("should not remove an unremovable element on CTRL+DELETE", async () => {
await testEditor({
contentBefore: unformat(`
<p class="oe_unremovable">[]<br></p>
<p>abc</p>`),
stepFunction: () => press(["Ctrl", "Delete"]),
contentAfter: unformat(`
<p class="oe_unremovable">[]<br></p>
<p>abc</p>`),
});
});
test("should not merge an unbreakable element on CTRL+DELETE", async () => {
await testEditor({
contentBefore: unformat(`
<div class="oe_unbreakable">abc[]</div>
<p>def</p>`),
stepFunction: () => press(["Ctrl", "Delete"]),
contentAfter: unformat(`
<div class="oe_unbreakable">abc[]</div>
<p>def</p>`),
});
});
test("should not merge an unbreakable element on CTRL+DELETE (2)", async () => {
await testEditor({
contentBefore: unformat(`
<p>abc[]</p>
<div class="oe_unbreakable">def</div>`),
stepFunction: () => press(["Ctrl", "Delete"]),
contentAfter: unformat(`
<p>abc[]</p>
<div class="oe_unbreakable">def</div>`),
});
});
test("Should delete last word on MacOS", async () => {
mockUserAgent("mac");
await testEditor({
contentBefore: `<p>hello[] world</p>`,
stepFunction: () => press(["Alt", "Delete"]),
contentAfter: `<p>hello[]</p>`,
});
});

View file

@ -0,0 +1,61 @@
import { describe, expect, test } from "@odoo/hoot";
import { setupEditor } from "../_helpers/editor";
import { press } from "@odoo/hoot-dom";
import { getContent } from "../_helpers/selection";
import { execCommand } from "../_helpers/userCommands";
describe("delete backward", () => {
test("should register a history step (collapsed selection)", async () => {
const { el, editor } = await setupEditor("<p>ab[]cd</p>");
await press("Backspace");
expect(getContent(el)).toBe("<p>a[]cd</p>");
execCommand(editor, "historyUndo");
expect(getContent(el)).toBe("<p>ab[]cd</p>");
execCommand(editor, "historyRedo");
expect(getContent(el)).toBe("<p>a[]cd</p>");
});
test("should register a history step (non-collapsed selection)", async () => {
const { el, editor } = await setupEditor("<p>ab[cd]ef</p>");
await press("Backspace");
expect(getContent(el)).toBe("<p>ab[]ef</p>");
execCommand(editor, "historyUndo");
expect(getContent(el)).toBe("<p>ab[cd]ef</p>");
execCommand(editor, "historyRedo");
expect(getContent(el)).toBe("<p>ab[]ef</p>");
});
});
describe("delete forward", () => {
test("should register a history step (collapsed selection)", async () => {
const { el, editor } = await setupEditor("<p>ab[]cd</p>");
await press("Delete");
expect(getContent(el)).toBe("<p>ab[]d</p>");
execCommand(editor, "historyUndo");
expect(getContent(el)).toBe("<p>ab[]cd</p>");
execCommand(editor, "historyRedo");
expect(getContent(el)).toBe("<p>ab[]d</p>");
});
test("should register a history step (non-collapsed selection)", async () => {
const { el, editor } = await setupEditor("<p>ab[cd]ef</p>");
await press("Delete");
expect(getContent(el)).toBe("<p>ab[]ef</p>");
execCommand(editor, "historyUndo");
expect(getContent(el)).toBe("<p>ab[cd]ef</p>");
execCommand(editor, "historyRedo");
expect(getContent(el)).toBe("<p>ab[]ef</p>");
});
});