Files
xxx/tests/js/editor.test.js
T

488 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// XxxEditor.attach(): the toolbar, the dialogs, source mode, undo, paste and the keys
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { editorWindow, select, selectText, button, key } = require("./helpers");
// Type into the editor: replace its HTML and send the input event
function type(env, html, inputType = "insertText") {
env.box.dispatchEvent(new env.window.InputEvent("beforeinput", { inputType, bubbles: true, cancelable: true }));
env.box.innerHTML = html;
env.box.dispatchEvent(new env.window.InputEvent("input", { inputType, bubbles: true }));
}
function paste(env, data) {
const event = new env.window.Event("paste", { bubbles: true, cancelable: true });
event.clipboardData = { getData: (type) => data[type] || "" };
env.box.dispatchEvent(event);
return event;
}
test("the editor takes the place of the textarea in the form", () => {
const env = editorWindow("<p>Hello</p>");
const wrapper = env.document.querySelector("#textparent > .xed");
assert.ok(wrapper);
assert.equal(env.textarea.parentNode, wrapper);
assert.equal(env.textarea.hidden, true);
assert.equal(env.textarea.style.display, "none");
assert.equal(env.textarea.form.id, "form");
assert.equal(env.box.contentEditable, "true");
assert.equal(env.box.getAttribute("aria-label"), "Story:");
assert.equal(env.box.innerHTML, "<p>Hello</p>");
});
test("the toolbar has the TinyMCE buttons, in order", () => {
const env = editorWindow();
const labels = Array.from(env.document.querySelectorAll(".xed-btn"), (b) => b.getAttribute("aria-label"));
assert.deepEqual(labels, ["Bold", "Italic", "Align left", "Align center", "Align right", "Justify", "Blockquote",
"Decrease indent", "Increase indent", "Insert/edit link", "Remove link", "Special character", "Source code"]);
assert.equal(button(env.document, "Bold").title, "Bold (Ctrl+B)");
assert.equal(button(env.document, "Insert/edit link").title, "Insert/edit link (Ctrl+K)");
assert.equal(env.document.querySelectorAll(".xed-group").length, 6);
for (const b of env.document.querySelectorAll(".xed-btn")) {
assert.equal(b.type, "button");
assert.ok(b.querySelector("svg[aria-hidden=true] path"));
}
});
test("the story in the textarea is cleaned when the editor starts", () => {
const env = editorWindow('<p onclick="x()">a</p>loose <b>text</b>');
assert.equal(env.textarea.value, "<p>a</p>\n<p>loose <strong>text</strong></p>");
assert.equal(env.editor.getContent(), env.textarea.value);
});
test("an empty story starts with an empty paragraph", () => {
const env = editorWindow("");
assert.equal(env.box.innerHTML, "<p><br></p>");
assert.equal(env.textarea.value, "");
});
test("typing updates the textarea and tells the word counter", () => {
const env = editorWindow();
let inputs = 0;
env.textarea.addEventListener("input", () => inputs++);
type(env, "<p>Some words</p>");
assert.equal(env.textarea.value, "<p>Some words</p>");
assert.equal(inputs, 1);
type(env, "<p>Some words</p>");
assert.equal(inputs, 1, "no event when nothing changed");
});
test("text typed outside a paragraph is put in one", () => {
const env = editorWindow();
type(env, "loose <em>text</em><p>para</p>more");
assert.equal(env.textarea.value, "<p>loose <em>text</em></p>\n<p>para</p>\n<p>more</p>");
});
test("deleting everything starts again with a plain paragraph", () => {
const env = editorWindow("<p><strong>bold</strong></p>");
type(env, "<p><strong><br></strong></p>", "deleteContentBackward");
assert.equal(env.box.innerHTML, "<p><br></p>");
});
test("setContent and getContent", () => {
const env = editorWindow();
env.editor.setContent("<p>new <i>story</i></p>");
assert.equal(env.editor.getContent(), "<p>new <em>story</em></p>");
assert.equal(env.textarea.value, "<p>new <em>story</em></p>");
});
test("the form sends the editor's content", () => {
const env = editorWindow("<p>a</p>");
env.box.innerHTML = "<p>changed without an input event</p>";
env.textarea.form.dispatchEvent(new env.window.Event("submit", { cancelable: true }));
assert.equal(env.textarea.value, "<p>changed without an input event</p>");
});
test("bold and italic use the browser's commands", () => {
const env = editorWindow("<p>word</p>");
selectText(env.window, env.box.querySelector("p"));
button(env.document, "Bold").click();
button(env.document, "Italic").click();
assert.deepEqual(env.window.commands.filter((c) => c[0] !== "styleWithCSS" && c[0] !== "defaultParagraphSeparator"),
[["bold", null], ["italic", null]]);
});
test("the alignment buttons set and clear text-align", () => {
const env = editorWindow("<p>one</p><p>two</p>");
const [one, two] = env.box.querySelectorAll("p");
select(env.window, one.firstChild, 0, two.firstChild, 3);
button(env.document, "Align center").click();
assert.equal(env.textarea.value, '<p style="text-align: center;">one</p>\n<p style="text-align: center;">two</p>');
button(env.document, "Align right").click();
assert.equal(env.textarea.value, '<p style="text-align: right;">one</p>\n<p style="text-align: right;">two</p>');
button(env.document, "Align right").click();
assert.equal(env.textarea.value, "<p>one</p>\n<p>two</p>");
});
test("an old align attribute is replaced", () => {
const env = editorWindow('<p align="right">x</p>');
select(env.window, env.box.querySelector("p").firstChild, 0);
button(env.document, "Justify").click();
assert.equal(env.textarea.value, '<p style="text-align: justify;">x</p>');
});
test("indent steps by 30 pixels and never goes below zero", () => {
const env = editorWindow("<p>x</p>");
select(env.window, env.box.querySelector("p").firstChild, 0);
button(env.document, "Increase indent").click();
button(env.document, "Increase indent").click();
assert.equal(env.textarea.value, '<p style="padding-left: 60px;">x</p>');
button(env.document, "Decrease indent").click();
assert.equal(env.textarea.value, '<p style="padding-left: 30px;">x</p>');
button(env.document, "Decrease indent").click();
button(env.document, "Decrease indent").click();
assert.equal(env.textarea.value, "<p>x</p>");
});
test("indent in a list uses the browser's command", () => {
const env = editorWindow("<ul><li>a</li><li>b</li></ul>");
select(env.window, env.box.querySelectorAll("li")[1].firstChild, 0);
button(env.document, "Increase indent").click();
assert.deepEqual(env.window.commands.at(-1), ["indent", null]);
});
test("blockquote wraps the selected paragraphs, and unwraps them", () => {
const env = editorWindow("<p>one</p><p>two</p><p>three</p>");
const ps = env.box.querySelectorAll("p");
select(env.window, ps[0].firstChild, 1, ps[1].firstChild, 1);
button(env.document, "Blockquote").click();
assert.equal(env.textarea.value, "<blockquote>\n<p>one</p>\n<p>two</p>\n</blockquote>\n<p>three</p>");
select(env.window, ps[0].firstChild, 1);
button(env.document, "Blockquote").click();
assert.equal(env.textarea.value, "<p>one</p>\n<p>two</p>\n<p>three</p>");
});
test("the link dialog adds a link", () => {
const env = editorWindow("<p>go here</p>");
const text = env.box.querySelector("p").firstChild;
select(env.window, text, 3, text, 7);
button(env.document, "Insert/edit link").click();
const dialog = env.document.querySelector(".xed-window[role=dialog]");
assert.ok(dialog);
assert.equal(dialog.querySelector(".xed-title").textContent, "Insert link");
const inputs = dialog.querySelectorAll("input");
assert.equal(inputs.length, 3);
assert.equal(inputs[1].value, "here");
assert.equal(env.document.activeElement, inputs[0]);
inputs[0].value = "www.example.org";
inputs[1].value = "there";
inputs[2].value = "A title";
dialog.querySelector(".xed-primary").click();
assert.equal(env.document.querySelector(".xed-window"), null);
assert.equal(env.textarea.value, '<p>go <a href="http://www.example.org" title="A title">there</a></p>');
});
test("the link dialog makes email addresses into mailto links", () => {
const env = editorWindow("<p>x</p>");
select(env.window, env.box.querySelector("p").firstChild, 1);
button(env.document, "Insert/edit link").click();
const inputs = env.document.querySelectorAll(".xed-window input");
inputs[0].value = "ed@example.org";
key(env.window, inputs[0], "Enter");
assert.equal(env.textarea.value, '<p>x<a href="mailto:ed@example.org">ed@example.org</a></p>');
});
test("the link dialog refuses unsafe links", () => {
const env = editorWindow("<p>x</p>");
select(env.window, env.box.querySelector("p").firstChild, 1);
button(env.document, "Insert/edit link").click();
env.document.querySelector(".xed-window input").value = "javascript:alert(1)";
env.document.querySelector(".xed-primary").click();
assert.equal(env.textarea.value, "<p>x</p>");
});
test("the link dialog edits and removes a link", () => {
const env = editorWindow('<p><a href="https://old.example/">old</a></p>');
select(env.window, env.box.querySelector("a").firstChild, 1);
button(env.document, "Insert/edit link").click();
let inputs = env.document.querySelectorAll(".xed-window input");
assert.equal(inputs[0].value, "https://old.example/");
inputs[0].value = "https://new.example/";
env.document.querySelector(".xed-primary").click();
assert.equal(env.textarea.value, '<p><a href="https://new.example/">old</a></p>');
select(env.window, env.box.querySelector("a").firstChild, 1);
button(env.document, "Insert/edit link").click();
env.document.querySelector(".xed-window input").value = "";
env.document.querySelector(".xed-primary").click();
assert.equal(env.textarea.value, "<p>old</p>");
});
test("Escape and Cancel close a dialog without a change", () => {
const env = editorWindow("<p>x</p>");
select(env.window, env.box.querySelector("p").firstChild, 1);
button(env.document, "Insert/edit link").click();
key(env.window, env.document.querySelector(".xed-window input"), "Escape");
assert.equal(env.document.querySelector(".xed-window"), null);
assert.equal(env.document.querySelector(".xed-modal-block"), null);
button(env.document, "Insert/edit link").click();
env.document.querySelector(".xed-window input").value = "https://example.org/";
Array.from(env.document.querySelectorAll(".xed-button")).find((b) => b.textContent === "Cancel").click();
assert.equal(env.document.querySelector(".xed-window"), null);
assert.equal(env.textarea.value, "<p>x</p>");
});
test("a click beside the link dialog does not close it", () => {
const env = editorWindow("<p>x</p>");
button(env.document, "Insert/edit link").click();
env.document.querySelector(".xed-modal-block").click();
assert.ok(env.document.querySelector(".xed-window"));
});
test("remove link unwraps the link at the caret", () => {
const env = editorWindow('<p>a <a href="https://example.org/">link</a> b</p>');
select(env.window, env.box.querySelector("a").firstChild, 2);
button(env.document, "Remove link").click();
assert.equal(env.textarea.value, "<p>a link b</p>");
});
test("the special character window inserts a character", () => {
const env = editorWindow("<p>x</p>");
select(env.window, env.box.querySelector("p").firstChild, 1);
button(env.document, "Special character").click();
const cells = env.document.querySelectorAll(".xed-charmap td");
assert.equal(cells.length, 250);
assert.equal(env.document.querySelectorAll(".xed-charmap tr").length, 10);
const euro = Array.from(cells).find((c) => c.title === "euro sign");
euro.dispatchEvent(new env.window.MouseEvent("mouseover"));
assert.equal(env.document.querySelector(".xed-charmap-char").textContent, "€");
assert.equal(env.document.querySelector(".xed-charmap-name").textContent, "euro sign");
euro.click();
assert.equal(env.document.querySelector(".xed-window"), null);
assert.deepEqual(env.window.commands.at(-1), ["insertText", "€"]);
});
test("the special character window answers the keyboard", () => {
const env = editorWindow("<p>x</p>");
button(env.document, "Special character").click();
const cell = env.document.querySelector(".xed-charmap td");
assert.equal(cell.tabIndex, 0);
key(env.window, cell, "Enter");
assert.deepEqual(env.window.commands.at(-1), ["insertText", " "]);
});
test("a click outside the special character window closes it", () => {
const env = editorWindow("<p>x</p>");
button(env.document, "Special character").click();
const backdrop = env.document.querySelector(".xed-modal-block");
assert.ok(backdrop.classList.contains("xed-modal-light"));
backdrop.click();
assert.equal(env.document.querySelector(".xed-window"), null);
assert.equal(env.document.querySelector(".xed-modal-block"), null);
assert.equal(env.window.commands.filter((c) => c[0] === "insertText").length, 0);
});
test("the source button swaps the editor for the textarea and back", () => {
const env = editorWindow("<p>a</p>");
const code = button(env.document, "Source code");
code.click();
assert.equal(env.textarea.hidden, false);
assert.equal(env.textarea.style.display, "");
assert.equal(env.box.hidden, true);
assert.equal(code.getAttribute("aria-pressed"), "true");
assert.ok(code.classList.contains("xed-active"));
for (const b of env.document.querySelectorAll(".xed-btn"))
assert.equal(b.disabled, b !== code);
assert.equal(env.document.querySelector(".xed-window"), null, "no source window");
env.textarea.value = '<p onclick="x()">edited</p><script>bad()</script>';
assert.equal(env.editor.getContent(), env.textarea.value, "the textarea is the story in source mode");
code.click();
assert.equal(env.box.hidden, false);
assert.equal(env.textarea.hidden, true);
assert.equal(code.getAttribute("aria-pressed"), "false");
assert.ok(Array.from(env.document.querySelectorAll(".xed-btn")).every((b) => !b.disabled));
assert.equal(env.textarea.value, "<p>edited</p>");
});
test("submitting in source mode sends the source edits", (t) => {
const env = editorWindow("<p>Old story</p>");
t.after(() => env.window.close());
button(env.document, "Source code").click();
const source = "<p>Source edit</p>\n<p>Second paragraph.</p>";
env.textarea.value = source;
env.textarea.form.dispatchEvent(new env.window.Event("submit", { cancelable: true }));
assert.equal(env.textarea.value, source);
assert.equal(env.editor.getContent(), source);
assert.equal(new env.window.FormData(env.textarea.form).get("story"), source);
});
test("setContent leaves source mode", () => {
const env = editorWindow("<p>a</p>");
button(env.document, "Source code").click();
env.editor.setContent("<p>b</p>");
assert.equal(env.box.hidden, false);
assert.equal(env.editor.getContent(), "<p>b</p>");
});
test("undo and redo, with the keys", () => {
const env = editorWindow("<p>x</p>");
select(env.window, env.box.querySelector("p").firstChild, 0);
button(env.document, "Align center").click();
assert.equal(env.textarea.value, '<p style="text-align: center;">x</p>');
assert.ok(key(env.window, env.box, "z", { ctrlKey: true }).defaultPrevented);
assert.equal(env.textarea.value, "<p>x</p>");
key(env.window, env.box, "y", { ctrlKey: true });
assert.equal(env.textarea.value, '<p style="text-align: center;">x</p>');
key(env.window, env.box, "z", { metaKey: true });
key(env.window, env.box, "Z", { ctrlKey: true, shiftKey: true });
assert.equal(env.textarea.value, '<p style="text-align: center;">x</p>');
});
test("undo keeps a run of typing as one step", () => {
const env = editorWindow("<p>a</p>");
type(env, "<p>ab</p>");
type(env, "<p>abc</p>");
key(env.window, env.box, "z", { ctrlKey: true });
assert.equal(env.textarea.value, "<p>a</p>");
});
for (const pause of [999, 1000, 1001]) {
test(`undo groups typing only when the pause is below one second (${pause} ms)`, (t) => {
const env = editorWindow("<p>a</p>");
t.after(() => env.window.close());
let now = 10000;
t.mock.method(env.window.Date, "now", () => now);
type(env, "<p>ab</p>");
now += pause;
type(env, "<p>abc</p>");
key(env.window, env.box, "z", { ctrlKey: true });
assert.equal(env.textarea.value, pause < 1000 ? "<p>a</p>" : "<p>ab</p>");
key(env.window, env.box, "y", { ctrlKey: true });
assert.equal(env.textarea.value, "<p>abc</p>");
});
}
test("a change of input type starts a separate undo step", (t) => {
const env = editorWindow("<p>a</p>");
t.after(() => env.window.close());
t.mock.method(env.window.Date, "now", () => 10000);
type(env, "<p>abc</p>", "insertText");
type(env, "<p>ab</p>", "deleteContentBackward");
key(env.window, env.box, "z", { ctrlKey: true });
assert.equal(env.textarea.value, "<p>abc</p>");
key(env.window, env.box, "z", { ctrlKey: true });
assert.equal(env.textarea.value, "<p>a</p>");
});
test("a new edit after undo clears the old redo history", (t) => {
const env = editorWindow("<p>a</p>");
t.after(() => env.window.close());
let now = 10000;
t.mock.method(env.window.Date, "now", () => now);
type(env, "<p>ab</p>");
now += 1000;
type(env, "<p>abc</p>");
key(env.window, env.box, "z", { ctrlKey: true });
assert.equal(env.textarea.value, "<p>ab</p>");
type(env, "<p>abX</p>");
key(env.window, env.box, "y", { ctrlKey: true });
assert.equal(env.textarea.value, "<p>abX</p>");
key(env.window, env.box, "z", { ctrlKey: true });
assert.equal(env.textarea.value, "<p>ab</p>");
key(env.window, env.box, "y", { ctrlKey: true });
assert.equal(env.textarea.value, "<p>abX</p>");
});
test("undo keeps the latest 100 steps and redo restores them", (t) => {
const env = editorWindow("<p>0</p>");
t.after(() => env.window.close());
let now = 10000;
t.mock.method(env.window.Date, "now", () => now);
for (let i = 1; i <= 105; i++) {
now += 1000;
type(env, `<p>${i}</p>`);
}
for (let i = 104; i >= 5; i--) {
key(env.window, env.box, "z", { ctrlKey: true });
assert.equal(env.textarea.value, `<p>${i}</p>`);
}
key(env.window, env.box, "z", { ctrlKey: true });
assert.equal(env.textarea.value, "<p>5</p>", "the oldest five steps are discarded");
for (let i = 6; i <= 105; i++) {
key(env.window, env.box, "y", { ctrlKey: true });
assert.equal(env.textarea.value, `<p>${i}</p>`);
}
key(env.window, env.box, "y", { ctrlKey: true });
assert.equal(env.textarea.value, "<p>105</p>");
});
test("undo restores a selection across nodes and redo restores the caret", (t) => {
const original = "<p>alpha<em>beta</em></p>";
const env = editorWindow(original);
t.after(() => env.window.close());
select(env.window, env.box.querySelector("p").firstChild, 2, env.box.querySelector("em").firstChild, 3);
type(env, "<p>altered</p>");
select(env.window, env.box.querySelector("p").firstChild, 4);
key(env.window, env.box, "z", { ctrlKey: true });
assert.equal(env.textarea.value, original);
let range = env.window.getSelection().getRangeAt(0);
assert.equal(range.startContainer, env.box.querySelector("p").firstChild);
assert.equal(range.startOffset, 2);
assert.equal(range.endContainer, env.box.querySelector("em").firstChild);
assert.equal(range.endOffset, 3);
assert.equal(range.toString(), "phabet");
key(env.window, env.box, "y", { ctrlKey: true });
assert.equal(env.textarea.value, "<p>altered</p>");
range = env.window.getSelection().getRangeAt(0);
assert.equal(range.startContainer, env.box.querySelector("p").firstChild);
assert.equal(range.startOffset, 4);
assert.equal(range.collapsed, true);
});
test("redo from the browser's history input", (t) => {
const env = editorWindow("<p>a</p>");
t.after(() => env.window.close());
type(env, "<p>b</p>");
key(env.window, env.box, "z", { ctrlKey: true });
assert.equal(env.textarea.value, "<p>a</p>");
const event = new env.window.InputEvent("beforeinput", { inputType: "historyRedo", bubbles: true, cancelable: true });
env.box.dispatchEvent(event);
assert.ok(event.defaultPrevented);
assert.equal(env.textarea.value, "<p>b</p>");
});
test("undo from the browser's history input", () => {
const env = editorWindow("<p>a</p>");
type(env, "<p>b</p>");
const event = new env.window.InputEvent("beforeinput", { inputType: "historyUndo", bubbles: true, cancelable: true });
env.box.dispatchEvent(event);
assert.ok(event.defaultPrevented);
assert.equal(env.textarea.value, "<p>a</p>");
});
test("the shortcut keys run the toolbar commands", () => {
const env = editorWindow("<p>x</p>");
selectText(env.window, env.box.querySelector("p"));
assert.ok(key(env.window, env.box, "b", { ctrlKey: true }).defaultPrevented);
assert.deepEqual(env.window.commands.at(-1), ["bold", null]);
key(env.window, env.box, "i", { metaKey: true });
assert.deepEqual(env.window.commands.at(-1), ["italic", null]);
key(env.window, env.box, "k", { ctrlKey: true });
assert.ok(env.document.querySelector(".xed-window"));
});
test("other keys are left to the browser", () => {
const env = editorWindow("<p>x</p>");
assert.equal(key(env.window, env.box, "b").defaultPrevented, false);
assert.equal(key(env.window, env.box, "b", { ctrlKey: true, altKey: true }).defaultPrevented, false);
assert.equal(key(env.window, env.box, "c", { ctrlKey: true }).defaultPrevented, false);
});
test("pasted HTML is cleaned first", () => {
const env = editorWindow("<p>x</p>");
const event = paste(env, { "text/html": '<p style="color: red" class="MsoNormal"><b>Bold</b><script>x()</script></p>' });
assert.ok(event.defaultPrevented);
assert.deepEqual(env.window.commands.at(-1), ["insertHTML", "<p><strong>Bold</strong></p>"]);
});
test("pasted text keeps its paragraphs and line breaks", () => {
const env = editorWindow("<p>x</p>");
env.window.commands.length = 0;
paste(env, { "text/plain": "one\r\ntwo\n\nthree" });
assert.deepEqual(env.window.commands, [["insertText", "one"], ["insertLineBreak", null], ["insertText", "two"],
["insertParagraph", null], ["insertText", "three"]]);
});