import { h, t, getById, debounce, writeError, resetLog } from "/common.module.js";
import lzString from "/lz-string.module.js";
// Notes from before the editor were plain text. New ones are marked, so an old link still
// opens as the text it was instead of being read as markup. ':' is outside lz-string's alphabet.
const htmlPrefix = "h:";
// Everything the toolbar can produce, plus what the browser uses for lines. Anything else is
// unwrapped to its contents, and every attribute is dropped: a note arrives in a link someone
// else made, so it is never trusted with an onerror or a style.
const allowedTags = new Set(["B", "STRONG", "I", "EM", "U", "UL", "OL", "LI", "DIV", "P", "BR"]);
const droppedTags = new Set(["SCRIPT", "STYLE", "TITLE", "TEXTAREA"]);
const input = getById("input");
const toolbar = getById("toolbar");
const buttons = [...toolbar.querySelectorAll("button[data-command]")];
// The hash this page wrote itself, so the hashchange that follows doesn't reload the editor
// under the caret.
let written = null;
document.execCommand("defaultParagraphSeparator", false, "div");
document.execCommand("styleWithCSS", false, false);
// mousedown would take focus, and the selection with it, before the click runs the command.
toolbar.addEventListener("mousedown", event => {
if (event.target.closest("button")) event.preventDefault();
});
toolbar.addEventListener("click", event => {
const button = event.target.closest("button[data-command]");
if (!button) return;
input.focus();
document.execCommand(button.dataset.command);
updateToolbar();
});
// Tab nests a list item and Shift+Tab lifts it back out. Outside a list Tab still leaves the
// editor, so it isn't a keyboard trap.
input.addEventListener("keydown", event => {
if (event.key !== "Tab" || event.ctrlKey || event.altKey || event.metaKey) return;
const node = document.getSelection()?.anchorNode;
const element = node instanceof Element ? node : node?.parentElement;
if (!element?.closest("li")) return;
event.preventDefault();
document.execCommand(event.shiftKey ? "outdent" : "indent");
});
// A paste from another page or a word processor brings its classes and styles along. Keep only
// its structure.
input.addEventListener("paste", event => {
const html = event.clipboardData?.getData("text/html");
if (!html) return;
event.preventDefault();
document.execCommand("insertHTML", false, h("div", clean(parse(html))).innerHTML);
});
input.addEventListener("input", debounce(() => {
if (input.textContent === '') {
written = '';
}
else {
const html = h("div", clean(input.childNodes)).innerHTML;
written = '#' + htmlPrefix + lzString.compressToEncodedURIComponent(html);
}
window.location.hash = written;
resetLog();
}, 10));
document.addEventListener("selectionchange", updateToolbar);
window.addEventListener('hashchange', loadState);
loadState();
function loadState() {
if (window.location.hash === written) return;
written = window.location.hash;
const hash = window.location.hash.substring(1);
if (hash === '') {
input.replaceChildren();
return;
}
const isHtml = hash.startsWith(htmlPrefix);
const note = lzString.decompressFromEncodedURIComponent(isHtml ? hash.substring(htmlPrefix.length) : hash);
if (!note) {
//Hash but no content?
writeError("Failed to load note from url.");
return;
}
input.replaceChildren(...(isHtml ? clean(parse(note)) : lines(note)));
}
function updateToolbar() {
for (const button of buttons) {
button.setAttribute("aria-pressed", String(document.queryCommandState(button.dataset.command)));
}
}
/**
* Parse markup without running or loading any of it: a DOMParser document has no scripting and
* fetches nothing, so an
in it is inert until it's put in the page.
* @param {string} html
* @returns {NodeListOf}
*/
function parse(html) {
return new DOMParser().parseFromString(html, "text/html").body.childNodes;
}
/**
* Rebuild nodes as fresh elements from the allowlist, with no attributes.
* @param {NodeListOf} nodes
* @returns {Array}
*/
function clean(nodes) {
const result = [];
for (const node of nodes) {
if (node.nodeType === Node.TEXT_NODE) {
result.push(t(node.data));
} else if (node.nodeType !== Node.ELEMENT_NODE || droppedTags.has(node.tagName)) {
// comments, and elements whose text isn't content
} else if (allowedTags.has(node.tagName)) {
result.push(h(node.tagName.toLowerCase(), clean(node.childNodes)));
} else {
result.push(...clean(node.childNodes));
}
}
return result;
}
/**
* A plain-text note as the editor's own lines. Runs of spaces become the non-breaking ones the
* editor itself would have typed, or they would collapse into one.
* @param {string} text
* @returns {HTMLElement[]}
*/
function lines(text) {
return text.split("\n").map(line =>
line === '' ? h("div", [h("br")]) : h("div", line.replace(/ (?= )/g, " ")));
}