import { h, t, getById, debounce, writeError, writeInfo, 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]")];
const saveButton = getById("save");
const savedNotes = getById("saved-notes");
// Saved notes go in the Storage page's database, in the store the link to it opens, so the two
// can't disagree.
const database = "mb-storage";
const notesStore = decodeURIComponent(new URL(savedNotes.href).hash.substring(1));
// Every edit is a new URL, so a note's URL can't say which saved entry it is. Its id does: made
// on the first save, it is the entry's key, and it travels in the query string beside the note in
// the fragment. A note opened from Storage brings it along, and saves back over its own entry.
const idParam = "id";
// 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;
// Emptied out, a note is gone. Whatever gets written next is another one.
if (written === '') setId(null);
resetLog();
}, 10));
document.addEventListener("selectionchange", updateToolbar);
saveButton.addEventListener("click", async () => {
resetLog();
try {
await save();
} catch (e) {
writeError(e);
}
});
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"));
}
/**
* The note is its URL, so saving one is keeping the link, under its first line so it can be told
* apart from the others. Saving it again, edited or not, updates the same entry.
*/
async function save() {
if (window.location.hash === '') {
writeError("There is nothing to save yet.");
return;
}
const id = new URLSearchParams(window.location.search).get(idParam) ?? newId();
setId(id);
// The shape Storage shows as a link with a label, rather than as the text of the value.
const entry = { title: firstLine(), url: window.location.href };
const db = await openNotesStore();
try {
const saved = await done(db.transaction(notesStore).objectStore(notesStore).get(id));
const unchanged = saved?.url === entry.url && saved?.title === entry.title;
if (!unchanged) {
const transaction = db.transaction(notesStore, "readwrite");
transaction.objectStore(notesStore).put(entry, id);
await done(transaction);
}
writeInfo([
unchanged ? "Already saved in " : saved ? "Updated in " : "Saved in ",
h("a", { href: savedNotes.href }, notesStore),
".",
]);
} finally {
// An open connection would hold up the Storage page making or deleting a store.
db.close();
}
}
/** @param {string|null} id the note's id, or null for a note that isn't saved */
function setId(id) {
const url = new URL(window.location.href);
if (id === null) {
url.searchParams.delete(idParam);
} else {
url.searchParams.set(idParam, id);
}
// The whole URL: a bare "?id=" would resolve against and leave the page.
if (url.href !== window.location.href) history.replaceState(history.state, "", url);
}
/** Random rather than counted: a shared link carries its id into someone else's storage. */
function newId() {
return Array.from(crypto.getRandomValues(new Uint8Array(6)), b => b.toString(16).padStart(2, "0")).join("");
}
/** The note's first line with anything on it: a line, a list item, or a table's first row. */
function firstLine() {
return input.innerText.split("\n").map(line => line.trim()).find(line => line !== '') ?? "Untitled";
}
/**
* The Storage page's database with the notes store in it. A store can only be made while
* upgrading, so a missing one takes a second open, a version up.
* @returns {Promise}
*/
async function openNotesStore() {
const db = await openDatabase();
if (db.objectStoreNames.contains(notesStore)) return db;
db.close();
return openDatabase(db.version + 1, upgrading => {
// Another tab may have made it first.
if (!upgrading.objectStoreNames.contains(notesStore)) {
upgrading.createObjectStore(notesStore, { autoIncrement: true });
}
});
}
/**
* @param {number} [version]
* @param {(db: IDBDatabase) => void} [upgrade]
* @returns {Promise}
*/
function openDatabase(version, upgrade) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(database, version);
request.onupgradeneeded = () => upgrade?.(request.result);
request.onsuccess = () => {
request.result.onversionchange = () => request.result.close();
resolve(request.result);
};
request.onerror = () => reject(request.error);
request.onblocked = () => writeInfo("Waiting for the Storage page in another tab to let go of the database.");
});
}
/**
* @template T
* @param {IDBRequest|IDBTransaction} pending
* @returns {Promise}
*/
function done(pending) {
return new Promise((resolve, reject) => {
if (pending instanceof IDBTransaction) {
pending.oncomplete = () => resolve(undefined);
pending.onabort = () => reject(pending.error);
} else {
pending.onsuccess = () => resolve(pending.result);
}
pending.onerror = () => reject(pending.error);
});
}
/** @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, " ")));
}