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