// 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("

Hello

"); 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, "

Hello

"); }); 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('

a

loose text'); assert.equal(env.textarea.value, "

a

\n

loose text

"); 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, "


"); 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, "

Some words

"); assert.equal(env.textarea.value, "

Some words

"); assert.equal(inputs, 1); type(env, "

Some words

"); 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 text

para

more"); assert.equal(env.textarea.value, "

loose text

\n

para

\n

more

"); }); test("deleting everything starts again with a plain paragraph", () => { const env = editorWindow("

bold

"); type(env, "


", "deleteContentBackward"); assert.equal(env.box.innerHTML, "


"); }); test("setContent and getContent", () => { const env = editorWindow(); env.editor.setContent("

new story

"); assert.equal(env.editor.getContent(), "

new story

"); assert.equal(env.textarea.value, "

new story

"); }); test("the form sends the editor's content", () => { const env = editorWindow("

a

"); env.box.innerHTML = "

changed without an input event

"; env.textarea.form.dispatchEvent(new env.window.Event("submit", { cancelable: true })); assert.equal(env.textarea.value, "

changed without an input event

"); }); test("bold and italic use the browser's commands", () => { const env = editorWindow("

word

"); 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("

one

two

"); 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, '

one

\n

two

'); button(env.document, "Align right").click(); assert.equal(env.textarea.value, '

one

\n

two

'); button(env.document, "Align right").click(); assert.equal(env.textarea.value, "

one

\n

two

"); }); test("an old align attribute is replaced", () => { const env = editorWindow('

x

'); select(env.window, env.box.querySelector("p").firstChild, 0); button(env.document, "Justify").click(); assert.equal(env.textarea.value, '

x

'); }); test("indent steps by 30 pixels and never goes below zero", () => { const env = editorWindow("

x

"); 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, '

x

'); button(env.document, "Decrease indent").click(); assert.equal(env.textarea.value, '

x

'); button(env.document, "Decrease indent").click(); button(env.document, "Decrease indent").click(); assert.equal(env.textarea.value, "

x

"); }); test("indent in a list uses the browser's command", () => { const env = editorWindow(""); 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("

one

two

three

"); 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, "
\n

one

\n

two

\n
\n

three

"); select(env.window, ps[0].firstChild, 1); button(env.document, "Blockquote").click(); assert.equal(env.textarea.value, "

one

\n

two

\n

three

"); }); test("the link dialog adds a link", () => { const env = editorWindow("

go here

"); 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, '

go there

'); }); test("the link dialog makes email addresses into mailto links", () => { const env = editorWindow("

x

"); 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, '

xed@example.org

'); }); test("the link dialog refuses unsafe links", () => { const env = editorWindow("

x

"); 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, "

x

"); }); test("the link dialog edits and removes a link", () => { const env = editorWindow('

old

'); 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, '

old

'); 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, "

old

"); }); test("Escape and Cancel close a dialog without a change", () => { const env = editorWindow("

x

"); 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, "

x

"); }); test("a click beside the link dialog does not close it", () => { const env = editorWindow("

x

"); 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('

a link b

'); select(env.window, env.box.querySelector("a").firstChild, 2); button(env.document, "Remove link").click(); assert.equal(env.textarea.value, "

a link b

"); }); test("the special character window inserts a character", () => { const env = editorWindow("

x

"); 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("

x

"); 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("

x

"); 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("

a

"); 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 = '

edited

'; 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, "

edited

"); }); test("setContent leaves source mode", () => { const env = editorWindow("

a

"); button(env.document, "Source code").click(); env.editor.setContent("

b

"); assert.equal(env.box.hidden, false); assert.equal(env.editor.getContent(), "

b

"); }); test("undo and redo, with the keys", () => { const env = editorWindow("

x

"); select(env.window, env.box.querySelector("p").firstChild, 0); button(env.document, "Align center").click(); assert.equal(env.textarea.value, '

x

'); assert.ok(key(env.window, env.box, "z", { ctrlKey: true }).defaultPrevented); assert.equal(env.textarea.value, "

x

"); key(env.window, env.box, "y", { ctrlKey: true }); assert.equal(env.textarea.value, '

x

'); key(env.window, env.box, "z", { metaKey: true }); key(env.window, env.box, "Z", { ctrlKey: true, shiftKey: true }); assert.equal(env.textarea.value, '

x

'); }); test("undo keeps a run of typing as one step", () => { const env = editorWindow("

a

"); type(env, "

ab

"); type(env, "

abc

"); key(env.window, env.box, "z", { ctrlKey: true }); assert.equal(env.textarea.value, "

a

"); }); test("undo from the browser's history input", () => { const env = editorWindow("

a

"); type(env, "

b

"); 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, "

a

"); }); test("the shortcut keys run the toolbar commands", () => { const env = editorWindow("

x

"); 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("

x

"); 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("

x

"); const event = paste(env, { "text/html": '

Bold

' }); assert.ok(event.defaultPrevented); assert.deepEqual(env.window.commands.at(-1), ["insertHTML", "

Bold

"]); }); test("pasted text keeps its paragraphs and line breaks", () => { const env = editorWindow("

x

"); 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"]]); });