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", "TABLE", "THEAD", "TBODY", "TFOOT", "TR", "TH", "TD", ]); const droppedTags = new Set(["SCRIPT", "STYLE", "TITLE", "TEXTAREA"]); const input = getById("input"); const toolbar = getById("toolbar"); const tableToggle = getById("table-toggle"); const tableTools = getById("table-tools"); const buttons = [...toolbar.querySelectorAll("button[data-command]")]; // No browser has commands for tables (Firefox had some, off by default), so these edit the DOM // themselves. That puts them outside the browser's undo history: Ctrl+Z undoes typing, not a // row that was added or deleted. const tableActions = { rowAbove: cell => focusCell(insertRow(cell.parentElement, "before"), cell.cellIndex), rowBelow: cell => focusCell(insertRow(cell.parentElement, "after"), cell.cellIndex), columnLeft: cell => placeCaret(insertColumn(cell, "before")), columnRight: cell => placeCaret(insertColumn(cell, "after")), deleteRow, deleteColumn, }; // 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"); if (!button) return; input.focus(); const { command, table } = button.dataset; if (command) { document.execCommand(command); } else if (table === "toggle") { // A table inside a table is not something this editor makes, so inside one the button // is the way out of it instead. const cell = selectedCell(); if (cell) { removeTable(cell.closest("table")); } else { insertTable(); } changed(); } else if (table) { const cell = selectedCell(); if (!cell) return; tableActions[table](cell); changed(); } updateToolbar(); }); // Tab nests a list item and Shift+Tab lifts it back out. In a table they move between cells, and // Tab in the last cell adds a row. Anywhere else 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 item = selectedElement()?.closest("li"); const cell = selectedCell(); if (item && (!cell || cell.contains(item))) { event.preventDefault(); document.execCommand(event.shiftKey ? "outdent" : "indent"); } else if (cell && moveFromCell(cell, event.shiftKey ? -1 : 1)) { event.preventDefault(); } }); // 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))); } const inTable = selectedCell() !== null; tableToggle.setAttribute("aria-pressed", String(inTable)); tableToggle.title = inTable ? "Delete this table" : "Insert a table"; tableTools.hidden = !inTable; } /** Save the note after an edit the browser didn't make, and so didn't announce. */ function changed() { input.dispatchEvent(new Event("input")); } /** @returns {Element|null} */ function selectedElement() { const node = document.getSelection()?.anchorNode; return node instanceof Element ? node : node?.parentElement ?? null; } /** @returns {HTMLTableCellElement|null} */ function selectedCell() { const cell = selectedElement()?.closest("td, th"); return cell && input.contains(cell) ? cell : null; } /** @param {Node} node */ function placeCaret(node) { const range = document.createRange(); range.selectNodeContents(node); range.collapse(true); const selection = document.getSelection(); selection.removeAllRanges(); selection.addRange(range); } /** * A new table goes after the line the caret is on rather than splitting it, or takes its place * if that line is empty. A line follows it, or there would be nowhere to type below it. */ function insertTable() { const table = h("table", [h("tbody", [newRow(2), newRow(2)])]); const line = currentLine(); if (line === null) { input.prepend(table); } else if (line instanceof Element && ["DIV", "P"].includes(line.tagName) && line.textContent === '' && !line.querySelector("table")) { line.replaceWith(table); } else { endOfLine(line).after(table); } if (!table.nextSibling) { table.after(h("div", [h("br")])); } placeCaret(table.rows[0].cells[0]); } /** * The editor's top-level node holding the caret, or null when the caret is before all of them. * @returns {ChildNode|null} */ function currentLine() { const selection = document.getSelection(); let node = selection?.anchorNode; if (!node || !input.contains(node)) return null; if (node === input) return input.childNodes[selection.anchorOffset - 1] ?? null; while (node.parentNode !== input) node = node.parentNode; return node; } /** * The first line of a note is bare text and inline elements until the first Enter, so a line * is not always one node: follow it along to its last one. * @param {ChildNode} node */ function endOfLine(node) { const isBlock = n => n instanceof Element && ["DIV", "P", "UL", "OL", "TABLE"].includes(n.tagName); while (!isBlock(node) && node.nodeName !== "BR" && node.nextSibling && !isBlock(node.nextSibling)) { node = node.nextSibling; } return node; } /** @param {string} tag */ function newCell(tag = "td") { return h(tag, [h("br")]); } /** @param {number} count */ function newRow(count) { return h("tr", Array.from({ length: count }, () => newCell())); } /** * As wide as the widest row: a pasted table can be ragged. * @param {HTMLTableElement} table */ function columnCount(table) { return Math.max(...[...table.rows].map(row => row.cells.length)); } /** * @param {HTMLTableRowElement} row * @param {"before"|"after"} where */ function insertRow(row, where) { const created = newRow(columnCount(row.closest("table"))); row[where](created); return created; } /** * @param {HTMLTableCellElement} cell * @param {"before"|"after"} where * @returns {HTMLTableCellElement} the new cell in the same row as `cell` */ function insertColumn(cell, where) { const index = cell.cellIndex; for (const row of cell.closest("table").rows) { const reference = row.cells[index]; if (reference) { // A header row stays a header row. reference[where](newCell(reference.localName)); } else { row.append(newCell()); } } return where === "before" ? cell.previousElementSibling : cell.nextElementSibling; } /** @param {HTMLTableCellElement} cell */ function deleteRow(cell) { const row = cell.parentElement; const table = row.closest("table"); const rows = [...table.rows]; const neighbour = rows[rows.indexOf(row) + 1] ?? rows[rows.indexOf(row) - 1]; row.remove(); if (neighbour) { focusCell(neighbour, cell.cellIndex); } else { removeTable(table); } } /** @param {HTMLTableCellElement} cell */ function deleteColumn(cell) { const index = cell.cellIndex; const row = cell.parentElement; const table = row.closest("table"); for (const r of [...table.rows]) { r.cells[index]?.remove(); if (r.cells.length === 0) r.remove(); } if (table.rows.length === 0) { removeTable(table); } else { focusCell(row.isConnected ? row : table.rows[0], index); } } /** * Leaves an empty line where the table was, with the caret on it. That is usually the one * insertTable put after it, so inserting and deleting a table puts the note back as it was. * @param {HTMLTableElement} table */ function removeTable(table) { const next = table.nextElementSibling; if (next?.tagName === "DIV" && next.textContent === '' && !next.querySelector("table")) { table.remove(); placeCaret(next); } else { const line = h("div", [h("br")]); table.replaceWith(line); placeCaret(line); } } /** * @param {HTMLTableRowElement} row * @param {number} index */ function focusCell(row, index) { placeCaret(row.cells[Math.min(index, row.cells.length - 1)]); } /** * @param {HTMLTableCellElement} cell * @param {1|-1} step * @returns {boolean} false when Shift+Tab is already in the first cell, and should leave the editor */ function moveFromCell(cell, step) { const table = cell.closest("table"); const cells = [...table.rows].flatMap(row => [...row.cells]); const next = cells[cells.indexOf(cell) + step]; if (next) { placeCaret(next); } else if (step > 0) { focusCell(insertRow(cell.parentElement, "after"), 0); changed(); } else { return false; } return true; } /** * 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, " "))); }