Blog/Components/Pages/Note.razor.js 17.3 K · 509 lines · raw · history

1 import { h, t, getById, debounce, writeError, writeInfo, resetLog } from "/common.module.js";
2 import lzString from "/lz-string.module.js";
3
4 // Notes from before the editor were plain text. New ones are marked, so an old link still
5 // opens as the text it was instead of being read as markup. ':' is outside lz-string's alphabet.
6 const htmlPrefix = "h:";
7
8 // Everything the toolbar can produce, plus what the browser uses for lines. Anything else is
9 // unwrapped to its contents, and every attribute is dropped: a note arrives in a link someone
10 // else made, so it is never trusted with an onerror or a style.
11 const allowedTags = new Set([
12 "B", "STRONG", "I", "EM", "U", "UL", "OL", "LI", "DIV", "P", "BR",
13 "TABLE", "THEAD", "TBODY", "TFOOT", "TR", "TH", "TD",
14 ]);
15 const droppedTags = new Set(["SCRIPT", "STYLE", "TITLE", "TEXTAREA"]);
16
17 const input = getById("input");
18 const toolbar = getById("toolbar");
19 const tableToggle = getById("table-toggle");
20 const tableTools = getById("table-tools");
21 const buttons = [...toolbar.querySelectorAll("button[data-command]")];
22 const saveButton = getById("save");
23 const savedNotes = getById("saved-notes");
24
25 // Saved notes go in the Storage page's database, in the store the link to it opens, so the two
26 // can't disagree.
27 const database = "mb-storage";
28 const notesStore = decodeURIComponent(new URL(savedNotes.href).hash.substring(1));
29
30 // Every edit is a new URL, so a note's URL can't say which saved entry it is. Its id does: made
31 // on the first save, it is the entry's key, and it travels in the query string beside the note in
32 // the fragment. A note opened from Storage brings it along, and saves back over its own entry.
33 const idParam = "id";
34
35 // No browser has commands for tables (Firefox had some, off by default), so these edit the DOM
36 // themselves. That puts them outside the browser's undo history: Ctrl+Z undoes typing, not a
37 // row that was added or deleted.
38 const tableActions = {
39 rowAbove: cell => focusCell(insertRow(cell.parentElement, "before"), cell.cellIndex),
40 rowBelow: cell => focusCell(insertRow(cell.parentElement, "after"), cell.cellIndex),
41 columnLeft: cell => placeCaret(insertColumn(cell, "before")),
42 columnRight: cell => placeCaret(insertColumn(cell, "after")),
43 deleteRow,
44 deleteColumn,
45 };
46
47 // The hash this page wrote itself, so the hashchange that follows doesn't reload the editor
48 // under the caret.
49 let written = null;
50
51 document.execCommand("defaultParagraphSeparator", false, "div");
52 document.execCommand("styleWithCSS", false, false);
53
54 // mousedown would take focus, and the selection with it, before the click runs the command.
55 toolbar.addEventListener("mousedown", event => {
56 if (event.target.closest("button")) event.preventDefault();
57 });
58 toolbar.addEventListener("click", event => {
59 const button = event.target.closest("button");
60 if (!button) return;
61 input.focus();
62 const { command, table } = button.dataset;
63 if (command) {
64 document.execCommand(command);
65 } else if (table === "toggle") {
66 // A table inside a table is not something this editor makes, so inside one the button
67 // is the way out of it instead.
68 const cell = selectedCell();
69 if (cell) {
70 removeTable(cell.closest("table"));
71 } else {
72 insertTable();
73 }
74 changed();
75 } else if (table) {
76 const cell = selectedCell();
77 if (!cell) return;
78 tableActions[table](cell);
79 changed();
80 }
81 updateToolbar();
82 });
83
84 // Tab nests a list item and Shift+Tab lifts it back out. In a table they move between cells, and
85 // Tab in the last cell adds a row. Anywhere else Tab still leaves the editor, so it isn't a
86 // keyboard trap.
87 input.addEventListener("keydown", event => {
88 if (event.key !== "Tab" || event.ctrlKey || event.altKey || event.metaKey) return;
89 const item = selectedElement()?.closest("li");
90 const cell = selectedCell();
91 if (item && (!cell || cell.contains(item))) {
92 event.preventDefault();
93 document.execCommand(event.shiftKey ? "outdent" : "indent");
94 } else if (cell && moveFromCell(cell, event.shiftKey ? -1 : 1)) {
95 event.preventDefault();
96 }
97 });
98
99 // A paste from another page or a word processor brings its classes and styles along. Keep only
100 // its structure.
101 input.addEventListener("paste", event => {
102 const html = event.clipboardData?.getData("text/html");
103 if (!html) return;
104 event.preventDefault();
105 document.execCommand("insertHTML", false, h("div", clean(parse(html))).innerHTML);
106 });
107
108 input.addEventListener("input", debounce(() => {
109 if (input.textContent === '') {
110 written = '';
111 }
112 else {
113 const html = h("div", clean(input.childNodes)).innerHTML;
114 written = '#' + htmlPrefix + lzString.compressToEncodedURIComponent(html);
115 }
116 window.location.hash = written;
117 // Emptied out, a note is gone. Whatever gets written next is another one.
118 if (written === '') setId(null);
119 resetLog();
120 }, 10));
121
122 document.addEventListener("selectionchange", updateToolbar);
123
124 saveButton.addEventListener("click", async () => {
125 resetLog();
126 try {
127 await save();
128 } catch (e) {
129 writeError(e);
130 }
131 });
132
133 window.addEventListener('hashchange', loadState);
134 loadState();
135
136 function loadState() {
137 if (window.location.hash === written) return;
138 written = window.location.hash;
139
140 const hash = window.location.hash.substring(1);
141 if (hash === '') {
142 input.replaceChildren();
143 return;
144 }
145
146 const isHtml = hash.startsWith(htmlPrefix);
147 const note = lzString.decompressFromEncodedURIComponent(isHtml ? hash.substring(htmlPrefix.length) : hash);
148 if (!note) {
149 //Hash but no content?
150 writeError("Failed to load note from url.");
151 return;
152 }
153 input.replaceChildren(...(isHtml ? clean(parse(note)) : lines(note)));
154 }
155
156 function updateToolbar() {
157 for (const button of buttons) {
158 button.setAttribute("aria-pressed", String(document.queryCommandState(button.dataset.command)));
159 }
160 const inTable = selectedCell() !== null;
161 tableToggle.setAttribute("aria-pressed", String(inTable));
162 tableToggle.title = inTable ? "Delete this table" : "Insert a table";
163 tableTools.hidden = !inTable;
164 }
165
166 /** Save the note after an edit the browser didn't make, and so didn't announce. */
167 function changed() {
168 input.dispatchEvent(new Event("input"));
169 }
170
171 /**
172 * The note is its URL, so saving one is keeping the link, under its first line so it can be told
173 * apart from the others. Saving it again, edited or not, updates the same entry.
174 */
175 async function save() {
176 if (window.location.hash === '') {
177 writeError("There is nothing to save yet.");
178 return;
179 }
180 const id = new URLSearchParams(window.location.search).get(idParam) ?? newId();
181 setId(id);
182 // The shape Storage shows as a link with a label, rather than as the text of the value.
183 const entry = { title: firstLine(), url: window.location.href };
184 const db = await openNotesStore();
185 try {
186 const saved = await done(db.transaction(notesStore).objectStore(notesStore).get(id));
187 const unchanged = saved?.url === entry.url && saved?.title === entry.title;
188 if (!unchanged) {
189 const transaction = db.transaction(notesStore, "readwrite");
190 transaction.objectStore(notesStore).put(entry, id);
191 await done(transaction);
192 }
193 writeInfo([
194 unchanged ? "Already saved in " : saved ? "Updated in " : "Saved in ",
195 h("a", { href: savedNotes.href }, notesStore),
196 ".",
197 ]);
198 } finally {
199 // An open connection would hold up the Storage page making or deleting a store.
200 db.close();
201 }
202 }
203
204 /** @param {string|null} id the note's id, or null for a note that isn't saved */
205 function setId(id) {
206 const url = new URL(window.location.href);
207 if (id === null) {
208 url.searchParams.delete(idParam);
209 } else {
210 url.searchParams.set(idParam, id);
211 }
212 // The whole URL: a bare "?id=" would resolve against <base href="/"> and leave the page.
213 if (url.href !== window.location.href) history.replaceState(history.state, "", url);
214 }
215
216 /** Random rather than counted: a shared link carries its id into someone else's storage. */
217 function newId() {
218 return Array.from(crypto.getRandomValues(new Uint8Array(6)), b => b.toString(16).padStart(2, "0")).join("");
219 }
220
221 /** The note's first line with anything on it: a line, a list item, or a table's first row. */
222 function firstLine() {
223 return input.innerText.split("\n").map(line => line.trim()).find(line => line !== '') ?? "Untitled";
224 }
225
226 /**
227 * The Storage page's database with the notes store in it. A store can only be made while
228 * upgrading, so a missing one takes a second open, a version up.
229 * @returns {Promise<IDBDatabase>}
230 */
231 async function openNotesStore() {
232 const db = await openDatabase();
233 if (db.objectStoreNames.contains(notesStore)) return db;
234 db.close();
235 return openDatabase(db.version + 1, upgrading => {
236 // Another tab may have made it first.
237 if (!upgrading.objectStoreNames.contains(notesStore)) {
238 upgrading.createObjectStore(notesStore, { autoIncrement: true });
239 }
240 });
241 }
242
243 /**
244 * @param {number} [version]
245 * @param {(db: IDBDatabase) => void} [upgrade]
246 * @returns {Promise<IDBDatabase>}
247 */
248 function openDatabase(version, upgrade) {
249 return new Promise((resolve, reject) => {
250 const request = indexedDB.open(database, version);
251 request.onupgradeneeded = () => upgrade?.(request.result);
252 request.onsuccess = () => {
253 request.result.onversionchange = () => request.result.close();
254 resolve(request.result);
255 };
256 request.onerror = () => reject(request.error);
257 request.onblocked = () => writeInfo("Waiting for the Storage page in another tab to let go of the database.");
258 });
259 }
260
261 /**
262 * @template T
263 * @param {IDBRequest<T>|IDBTransaction} pending
264 * @returns {Promise<T|undefined>}
265 */
266 function done(pending) {
267 return new Promise((resolve, reject) => {
268 if (pending instanceof IDBTransaction) {
269 pending.oncomplete = () => resolve(undefined);
270 pending.onabort = () => reject(pending.error);
271 } else {
272 pending.onsuccess = () => resolve(pending.result);
273 }
274 pending.onerror = () => reject(pending.error);
275 });
276 }
277
278 /** @returns {Element|null} */
279 function selectedElement() {
280 const node = document.getSelection()?.anchorNode;
281 return node instanceof Element ? node : node?.parentElement ?? null;
282 }
283
284 /** @returns {HTMLTableCellElement|null} */
285 function selectedCell() {
286 const cell = selectedElement()?.closest("td, th");
287 return cell && input.contains(cell) ? cell : null;
288 }
289
290 /** @param {Node} node */
291 function placeCaret(node) {
292 const range = document.createRange();
293 range.selectNodeContents(node);
294 range.collapse(true);
295 const selection = document.getSelection();
296 selection.removeAllRanges();
297 selection.addRange(range);
298 }
299
300 /**
301 * A new table goes after the line the caret is on rather than splitting it, or takes its place
302 * if that line is empty. A line follows it, or there would be nowhere to type below it.
303 */
304 function insertTable() {
305 const table = h("table", [h("tbody", [newRow(2), newRow(2)])]);
306 const line = currentLine();
307 if (line === null) {
308 input.prepend(table);
309 } else if (line instanceof Element && ["DIV", "P"].includes(line.tagName)
310 && line.textContent === '' && !line.querySelector("table")) {
311 line.replaceWith(table);
312 } else {
313 endOfLine(line).after(table);
314 }
315 if (!table.nextSibling) {
316 table.after(h("div", [h("br")]));
317 }
318 placeCaret(table.rows[0].cells[0]);
319 }
320
321 /**
322 * The editor's top-level node holding the caret, or null when the caret is before all of them.
323 * @returns {ChildNode|null}
324 */
325 function currentLine() {
326 const selection = document.getSelection();
327 let node = selection?.anchorNode;
328 if (!node || !input.contains(node)) return null;
329 if (node === input) return input.childNodes[selection.anchorOffset - 1] ?? null;
330 while (node.parentNode !== input) node = node.parentNode;
331 return node;
332 }
333
334 /**
335 * The first line of a note is bare text and inline elements until the first Enter, so a line
336 * is not always one node: follow it along to its last one.
337 * @param {ChildNode} node
338 */
339 function endOfLine(node) {
340 const isBlock = n => n instanceof Element && ["DIV", "P", "UL", "OL", "TABLE"].includes(n.tagName);
341 while (!isBlock(node) && node.nodeName !== "BR" && node.nextSibling && !isBlock(node.nextSibling)) {
342 node = node.nextSibling;
343 }
344 return node;
345 }
346
347 /** @param {string} tag */
348 function newCell(tag = "td") {
349 return h(tag, [h("br")]);
350 }
351
352 /** @param {number} count */
353 function newRow(count) {
354 return h("tr", Array.from({ length: count }, () => newCell()));
355 }
356
357 /**
358 * As wide as the widest row: a pasted table can be ragged.
359 * @param {HTMLTableElement} table
360 */
361 function columnCount(table) {
362 return Math.max(...[...table.rows].map(row => row.cells.length));
363 }
364
365 /**
366 * @param {HTMLTableRowElement} row
367 * @param {"before"|"after"} where
368 */
369 function insertRow(row, where) {
370 const created = newRow(columnCount(row.closest("table")));
371 row[where](created);
372 return created;
373 }
374
375 /**
376 * @param {HTMLTableCellElement} cell
377 * @param {"before"|"after"} where
378 * @returns {HTMLTableCellElement} the new cell in the same row as `cell`
379 */
380 function insertColumn(cell, where) {
381 const index = cell.cellIndex;
382 for (const row of cell.closest("table").rows) {
383 const reference = row.cells[index];
384 if (reference) {
385 // A header row stays a header row.
386 reference[where](newCell(reference.localName));
387 } else {
388 row.append(newCell());
389 }
390 }
391 return where === "before" ? cell.previousElementSibling : cell.nextElementSibling;
392 }
393
394 /** @param {HTMLTableCellElement} cell */
395 function deleteRow(cell) {
396 const row = cell.parentElement;
397 const table = row.closest("table");
398 const rows = [...table.rows];
399 const neighbour = rows[rows.indexOf(row) + 1] ?? rows[rows.indexOf(row) - 1];
400 row.remove();
401 if (neighbour) {
402 focusCell(neighbour, cell.cellIndex);
403 } else {
404 removeTable(table);
405 }
406 }
407
408 /** @param {HTMLTableCellElement} cell */
409 function deleteColumn(cell) {
410 const index = cell.cellIndex;
411 const row = cell.parentElement;
412 const table = row.closest("table");
413 for (const r of [...table.rows]) {
414 r.cells[index]?.remove();
415 if (r.cells.length === 0) r.remove();
416 }
417 if (table.rows.length === 0) {
418 removeTable(table);
419 } else {
420 focusCell(row.isConnected ? row : table.rows[0], index);
421 }
422 }
423
424 /**
425 * Leaves an empty line where the table was, with the caret on it. That is usually the one
426 * insertTable put after it, so inserting and deleting a table puts the note back as it was.
427 * @param {HTMLTableElement} table
428 */
429 function removeTable(table) {
430 const next = table.nextElementSibling;
431 if (next?.tagName === "DIV" && next.textContent === '' && !next.querySelector("table")) {
432 table.remove();
433 placeCaret(next);
434 } else {
435 const line = h("div", [h("br")]);
436 table.replaceWith(line);
437 placeCaret(line);
438 }
439 }
440
441 /**
442 * @param {HTMLTableRowElement} row
443 * @param {number} index
444 */
445 function focusCell(row, index) {
446 placeCaret(row.cells[Math.min(index, row.cells.length - 1)]);
447 }
448
449 /**
450 * @param {HTMLTableCellElement} cell
451 * @param {1|-1} step
452 * @returns {boolean} false when Shift+Tab is already in the first cell, and should leave the editor
453 */
454 function moveFromCell(cell, step) {
455 const table = cell.closest("table");
456 const cells = [...table.rows].flatMap(row => [...row.cells]);
457 const next = cells[cells.indexOf(cell) + step];
458 if (next) {
459 placeCaret(next);
460 } else if (step > 0) {
461 focusCell(insertRow(cell.parentElement, "after"), 0);
462 changed();
463 } else {
464 return false;
465 }
466 return true;
467 }
468
469 /**
470 * Parse markup without running or loading any of it: a DOMParser document has no scripting and
471 * fetches nothing, so an <img onerror> in it is inert until it's put in the page.
472 * @param {string} html
473 * @returns {NodeListOf<ChildNode>}
474 */
475 function parse(html) {
476 return new DOMParser().parseFromString(html, "text/html").body.childNodes;
477 }
478
479 /**
480 * Rebuild nodes as fresh elements from the allowlist, with no attributes.
481 * @param {NodeListOf<ChildNode>} nodes
482 * @returns {Array<HTMLElement|Text>}
483 */
484 function clean(nodes) {
485 const result = [];
486 for (const node of nodes) {
487 if (node.nodeType === Node.TEXT_NODE) {
488 result.push(t(node.data));
489 } else if (node.nodeType !== Node.ELEMENT_NODE || droppedTags.has(node.tagName)) {
490 // comments, and elements whose text isn't content
491 } else if (allowedTags.has(node.tagName)) {
492 result.push(h(node.tagName.toLowerCase(), clean(node.childNodes)));
493 } else {
494 result.push(...clean(node.childNodes));
495 }
496 }
497 return result;
498 }
499
500 /**
501 * A plain-text note as the editor's own lines. Runs of spaces become the non-breaking ones the
502 * editor itself would have typed, or they would collapse into one.
503 * @param {string} text
504 * @returns {HTMLElement[]}
505 */
506 function lines(text) {
507 return text.split("\n").map(line =>
508 line === '' ? h("div", [h("br")]) : h("div", line.replace(/ (?= )/g, " ")));
509 }