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