Odoo18-Base/addons/html_editor/static/tests/delete/delete_range.test.js
2025-01-06 10:57:38 +07:00

519 lines
21 KiB
JavaScript

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("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>d]ef</div>",
stepFunction: deleteSelection,
contentAfter: "<p>ab[]</p><div>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>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">
<div class="row">
<div class="col-6">a[bc</div>
<div class="col-6">def</div>
</div>
</div>
<p>gh]i</p>`
),
stepFunction: deleteSelection,
contentAfterEdit: unformat(
`<div class="container o_text_columns">
<div class="row">
<div class="col-6">a[]</div>
<div class="col-6"><br></div>
</div>
</div>
<p>i</p>`
),
contentAfter: unformat(
`<div class="container o_text_columns">
<div class="row">
<div class="col-6">a[]</div>
<div class="col-6"><br></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">
<div class="row">
<div class="col-6">abc</div>
<div class="col-6">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>[]<br></td> <td><br></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>`
),
});
});
});
});