Blog/Components/Pages/Note.razor.js 12.4 K · 379 lines · raw · history

1 import { h, t, getById, debounce, writeError, 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
23 // No browser has commands for tables (Firefox had some, off by default), so these edit the DOM
24 // themselves. That puts them outside the browser's undo history: Ctrl+Z undoes typing, not a
25 // row that was added or deleted.
26 const tableActions = {
27 rowAbove: cell => focusCell(insertRow(cell.parentElement, "before"), cell.cellIndex),
28 rowBelow: cell => focusCell(insertRow(cell.parentElement, "after"), cell.cellIndex),
29 columnLeft: cell => placeCaret(insertColumn(cell, "before")),
30 columnRight: cell => placeCaret(insertColumn(cell, "after")),
31 deleteRow,
32 deleteColumn,
33 };
34
35 // The hash this page wrote itself, so the hashchange that follows doesn't reload the editor
36 // under the caret.
37 let written = null;
38
39 document.execCommand("defaultParagraphSeparator", false, "div");
40 document.execCommand("styleWithCSS", false, false);
41
42 // mousedown would take focus, and the selection with it, before the click runs the command.
43 toolbar.addEventListener("mousedown", event => {
44 if (event.target.closest("button")) event.preventDefault();
45 });
46 toolbar.addEventListener("click", event => {
47 const button = event.target.closest("button");
48 if (!button) return;
49 input.focus();
50 const { command, table } = button.dataset;
51 if (command) {
52 document.execCommand(command);
53 } else if (table === "toggle") {
54 // A table inside a table is not something this editor makes, so inside one the button
55 // is the way out of it instead.
56 const cell = selectedCell();
57 if (cell) {
58 removeTable(cell.closest("table"));
59 } else {
60 insertTable();
61 }
62 changed();
63 } else if (table) {
64 const cell = selectedCell();
65 if (!cell) return;
66 tableActions[table](cell);
67 changed();
68 }
69 updateToolbar();
70 });
71
72 // Tab nests a list item and Shift+Tab lifts it back out. In a table they move between cells, and
73 // Tab in the last cell adds a row. Anywhere else Tab still leaves the editor, so it isn't a
74 // keyboard trap.
75 input.addEventListener("keydown", event => {
76 if (event.key !== "Tab" || event.ctrlKey || event.altKey || event.metaKey) return;
77 const item = selectedElement()?.closest("li");
78 const cell = selectedCell();
79 if (item && (!cell || cell.contains(item))) {
80 event.preventDefault();
81 document.execCommand(event.shiftKey ? "outdent" : "indent");
82 } else if (cell && moveFromCell(cell, event.shiftKey ? -1 : 1)) {
83 event.preventDefault();
84 }
85 });
86
87 // A paste from another page or a word processor brings its classes and styles along. Keep only
88 // its structure.
89 input.addEventListener("paste", event => {
90 const html = event.clipboardData?.getData("text/html");
91 if (!html) return;
92 event.preventDefault();
93 document.execCommand("insertHTML", false, h("div", clean(parse(html))).innerHTML);
94 });
95
96 input.addEventListener("input", debounce(() => {
97 if (input.textContent === '') {
98 written = '';
99 }
100 else {
101 const html = h("div", clean(input.childNodes)).innerHTML;
102 written = '#' + htmlPrefix + lzString.compressToEncodedURIComponent(html);
103 }
104 window.location.hash = written;
105 resetLog();
106 }, 10));
107
108 document.addEventListener("selectionchange", updateToolbar);
109
110 window.addEventListener('hashchange', loadState);
111 loadState();
112
113 function loadState() {
114 if (window.location.hash === written) return;
115 written = window.location.hash;
116
117 const hash = window.location.hash.substring(1);
118 if (hash === '') {
119 input.replaceChildren();
120 return;
121 }
122
123 const isHtml = hash.startsWith(htmlPrefix);
124 const note = lzString.decompressFromEncodedURIComponent(isHtml ? hash.substring(htmlPrefix.length) : hash);
125 if (!note) {
126 //Hash but no content?
127 writeError("Failed to load note from url.");
128 return;
129 }
130 input.replaceChildren(...(isHtml ? clean(parse(note)) : lines(note)));
131 }
132
133 function updateToolbar() {
134 for (const button of buttons) {
135 button.setAttribute("aria-pressed", String(document.queryCommandState(button.dataset.command)));
136 }
137 const inTable = selectedCell() !== null;
138 tableToggle.setAttribute("aria-pressed", String(inTable));
139 tableToggle.title = inTable ? "Delete this table" : "Insert a table";
140 tableTools.hidden = !inTable;
141 }
142
143 /** Save the note after an edit the browser didn't make, and so didn't announce. */
144 function changed() {
145 input.dispatchEvent(new Event("input"));
146 }
147
148 /** @returns {Element|null} */
149 function selectedElement() {
150 const node = document.getSelection()?.anchorNode;
151 return node instanceof Element ? node : node?.parentElement ?? null;
152 }
153
154 /** @returns {HTMLTableCellElement|null} */
155 function selectedCell() {
156 const cell = selectedElement()?.closest("td, th");
157 return cell && input.contains(cell) ? cell : null;
158 }
159
160 /** @param {Node} node */
161 function placeCaret(node) {
162 const range = document.createRange();
163 range.selectNodeContents(node);
164 range.collapse(true);
165 const selection = document.getSelection();
166 selection.removeAllRanges();
167 selection.addRange(range);
168 }
169
170 /**
171 * A new table goes after the line the caret is on rather than splitting it, or takes its place
172 * if that line is empty. A line follows it, or there would be nowhere to type below it.
173 */
174 function insertTable() {
175 const table = h("table", [h("tbody", [newRow(2), newRow(2)])]);
176 const line = currentLine();
177 if (line === null) {
178 input.prepend(table);
179 } else if (line instanceof Element && ["DIV", "P"].includes(line.tagName)
180 && line.textContent === '' && !line.querySelector("table")) {
181 line.replaceWith(table);
182 } else {
183 endOfLine(line).after(table);
184 }
185 if (!table.nextSibling) {
186 table.after(h("div", [h("br")]));
187 }
188 placeCaret(table.rows[0].cells[0]);
189 }
190
191 /**
192 * The editor's top-level node holding the caret, or null when the caret is before all of them.
193 * @returns {ChildNode|null}
194 */
195 function currentLine() {
196 const selection = document.getSelection();
197 let node = selection?.anchorNode;
198 if (!node || !input.contains(node)) return null;
199 if (node === input) return input.childNodes[selection.anchorOffset - 1] ?? null;
200 while (node.parentNode !== input) node = node.parentNode;
201 return node;
202 }
203
204 /**
205 * The first line of a note is bare text and inline elements until the first Enter, so a line
206 * is not always one node: follow it along to its last one.
207 * @param {ChildNode} node
208 */
209 function endOfLine(node) {
210 const isBlock = n => n instanceof Element && ["DIV", "P", "UL", "OL", "TABLE"].includes(n.tagName);
211 while (!isBlock(node) && node.nodeName !== "BR" && node.nextSibling && !isBlock(node.nextSibling)) {
212 node = node.nextSibling;
213 }
214 return node;
215 }
216
217 /** @param {string} tag */
218 function newCell(tag = "td") {
219 return h(tag, [h("br")]);
220 }
221
222 /** @param {number} count */
223 function newRow(count) {
224 return h("tr", Array.from({ length: count }, () => newCell()));
225 }
226
227 /**
228 * As wide as the widest row: a pasted table can be ragged.
229 * @param {HTMLTableElement} table
230 */
231 function columnCount(table) {
232 return Math.max(...[...table.rows].map(row => row.cells.length));
233 }
234
235 /**
236 * @param {HTMLTableRowElement} row
237 * @param {"before"|"after"} where
238 */
239 function insertRow(row, where) {
240 const created = newRow(columnCount(row.closest("table")));
241 row[where](created);
242 return created;
243 }
244
245 /**
246 * @param {HTMLTableCellElement} cell
247 * @param {"before"|"after"} where
248 * @returns {HTMLTableCellElement} the new cell in the same row as `cell`
249 */
250 function insertColumn(cell, where) {
251 const index = cell.cellIndex;
252 for (const row of cell.closest("table").rows) {
253 const reference = row.cells[index];
254 if (reference) {
255 // A header row stays a header row.
256 reference[where](newCell(reference.localName));
257 } else {
258 row.append(newCell());
259 }
260 }
261 return where === "before" ? cell.previousElementSibling : cell.nextElementSibling;
262 }
263
264 /** @param {HTMLTableCellElement} cell */
265 function deleteRow(cell) {
266 const row = cell.parentElement;
267 const table = row.closest("table");
268 const rows = [...table.rows];
269 const neighbour = rows[rows.indexOf(row) + 1] ?? rows[rows.indexOf(row) - 1];
270 row.remove();
271 if (neighbour) {
272 focusCell(neighbour, cell.cellIndex);
273 } else {
274 removeTable(table);
275 }
276 }
277
278 /** @param {HTMLTableCellElement} cell */
279 function deleteColumn(cell) {
280 const index = cell.cellIndex;
281 const row = cell.parentElement;
282 const table = row.closest("table");
283 for (const r of [...table.rows]) {
284 r.cells[index]?.remove();
285 if (r.cells.length === 0) r.remove();
286 }
287 if (table.rows.length === 0) {
288 removeTable(table);
289 } else {
290 focusCell(row.isConnected ? row : table.rows[0], index);
291 }
292 }
293
294 /**
295 * Leaves an empty line where the table was, with the caret on it. That is usually the one
296 * insertTable put after it, so inserting and deleting a table puts the note back as it was.
297 * @param {HTMLTableElement} table
298 */
299 function removeTable(table) {
300 const next = table.nextElementSibling;
301 if (next?.tagName === "DIV" && next.textContent === '' && !next.querySelector("table")) {
302 table.remove();
303 placeCaret(next);
304 } else {
305 const line = h("div", [h("br")]);
306 table.replaceWith(line);
307 placeCaret(line);
308 }
309 }
310
311 /**
312 * @param {HTMLTableRowElement} row
313 * @param {number} index
314 */
315 function focusCell(row, index) {
316 placeCaret(row.cells[Math.min(index, row.cells.length - 1)]);
317 }
318
319 /**
320 * @param {HTMLTableCellElement} cell
321 * @param {1|-1} step
322 * @returns {boolean} false when Shift+Tab is already in the first cell, and should leave the editor
323 */
324 function moveFromCell(cell, step) {
325 const table = cell.closest("table");
326 const cells = [...table.rows].flatMap(row => [...row.cells]);
327 const next = cells[cells.indexOf(cell) + step];
328 if (next) {
329 placeCaret(next);
330 } else if (step > 0) {
331 focusCell(insertRow(cell.parentElement, "after"), 0);
332 changed();
333 } else {
334 return false;
335 }
336 return true;
337 }
338
339 /**
340 * Parse markup without running or loading any of it: a DOMParser document has no scripting and
341 * fetches nothing, so an <img onerror> in it is inert until it's put in the page.
342 * @param {string} html
343 * @returns {NodeListOf<ChildNode>}
344 */
345 function parse(html) {
346 return new DOMParser().parseFromString(html, "text/html").body.childNodes;
347 }
348
349 /**
350 * Rebuild nodes as fresh elements from the allowlist, with no attributes.
351 * @param {NodeListOf<ChildNode>} nodes
352 * @returns {Array<HTMLElement|Text>}
353 */
354 function clean(nodes) {
355 const result = [];
356 for (const node of nodes) {
357 if (node.nodeType === Node.TEXT_NODE) {
358 result.push(t(node.data));
359 } else if (node.nodeType !== Node.ELEMENT_NODE || droppedTags.has(node.tagName)) {
360 // comments, and elements whose text isn't content
361 } else if (allowedTags.has(node.tagName)) {
362 result.push(h(node.tagName.toLowerCase(), clean(node.childNodes)));
363 } else {
364 result.push(...clean(node.childNodes));
365 }
366 }
367 return result;
368 }
369
370 /**
371 * A plain-text note as the editor's own lines. Runs of spaces become the non-breaking ones the
372 * editor itself would have typed, or they would collapse into one.
373 * @param {string} text
374 * @returns {HTMLElement[]}
375 */
376 function lines(text) {
377 return text.split("\n").map(line =>
378 line === '' ? h("div", [h("br")]) : h("div", line.replace(/ (?= )/g, " ")));
379 }