Blog/Components/Pages/Note.razor.js 5.1 K · 140 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(["B", "STRONG", "I", "EM", "U", "UL", "OL", "LI", "DIV", "P", "BR"]);
12 const droppedTags = new Set(["SCRIPT", "STYLE", "TITLE", "TEXTAREA"]);
13
14 const input = getById("input");
15 const toolbar = getById("toolbar");
16 const buttons = [...toolbar.querySelectorAll("button[data-command]")];
17
18 // The hash this page wrote itself, so the hashchange that follows doesn't reload the editor
19 // under the caret.
20 let written = null;
21
22 document.execCommand("defaultParagraphSeparator", false, "div");
23 document.execCommand("styleWithCSS", false, false);
24
25 // mousedown would take focus, and the selection with it, before the click runs the command.
26 toolbar.addEventListener("mousedown", event => {
27 if (event.target.closest("button")) event.preventDefault();
28 });
29 toolbar.addEventListener("click", event => {
30 const button = event.target.closest("button[data-command]");
31 if (!button) return;
32 input.focus();
33 document.execCommand(button.dataset.command);
34 updateToolbar();
35 });
36
37 // Tab nests a list item and Shift+Tab lifts it back out. Outside a list Tab still leaves the
38 // editor, so it isn't a keyboard trap.
39 input.addEventListener("keydown", event => {
40 if (event.key !== "Tab" || event.ctrlKey || event.altKey || event.metaKey) return;
41 const node = document.getSelection()?.anchorNode;
42 const element = node instanceof Element ? node : node?.parentElement;
43 if (!element?.closest("li")) return;
44 event.preventDefault();
45 document.execCommand(event.shiftKey ? "outdent" : "indent");
46 });
47
48 // A paste from another page or a word processor brings its classes and styles along. Keep only
49 // its structure.
50 input.addEventListener("paste", event => {
51 const html = event.clipboardData?.getData("text/html");
52 if (!html) return;
53 event.preventDefault();
54 document.execCommand("insertHTML", false, h("div", clean(parse(html))).innerHTML);
55 });
56
57 input.addEventListener("input", debounce(() => {
58 if (input.textContent === '') {
59 written = '';
60 }
61 else {
62 const html = h("div", clean(input.childNodes)).innerHTML;
63 written = '#' + htmlPrefix + lzString.compressToEncodedURIComponent(html);
64 }
65 window.location.hash = written;
66 resetLog();
67 }, 10));
68
69 document.addEventListener("selectionchange", updateToolbar);
70
71 window.addEventListener('hashchange', loadState);
72 loadState();
73
74 function loadState() {
75 if (window.location.hash === written) return;
76 written = window.location.hash;
77
78 const hash = window.location.hash.substring(1);
79 if (hash === '') {
80 input.replaceChildren();
81 return;
82 }
83
84 const isHtml = hash.startsWith(htmlPrefix);
85 const note = lzString.decompressFromEncodedURIComponent(isHtml ? hash.substring(htmlPrefix.length) : hash);
86 if (!note) {
87 //Hash but no content?
88 writeError("Failed to load note from url.");
89 return;
90 }
91 input.replaceChildren(...(isHtml ? clean(parse(note)) : lines(note)));
92 }
93
94 function updateToolbar() {
95 for (const button of buttons) {
96 button.setAttribute("aria-pressed", String(document.queryCommandState(button.dataset.command)));
97 }
98 }
99
100 /**
101 * Parse markup without running or loading any of it: a DOMParser document has no scripting and
102 * fetches nothing, so an <img onerror> in it is inert until it's put in the page.
103 * @param {string} html
104 * @returns {NodeListOf<ChildNode>}
105 */
106 function parse(html) {
107 return new DOMParser().parseFromString(html, "text/html").body.childNodes;
108 }
109
110 /**
111 * Rebuild nodes as fresh elements from the allowlist, with no attributes.
112 * @param {NodeListOf<ChildNode>} nodes
113 * @returns {Array<HTMLElement|Text>}
114 */
115 function clean(nodes) {
116 const result = [];
117 for (const node of nodes) {
118 if (node.nodeType === Node.TEXT_NODE) {
119 result.push(t(node.data));
120 } else if (node.nodeType !== Node.ELEMENT_NODE || droppedTags.has(node.tagName)) {
121 // comments, and elements whose text isn't content
122 } else if (allowedTags.has(node.tagName)) {
123 result.push(h(node.tagName.toLowerCase(), clean(node.childNodes)));
124 } else {
125 result.push(...clean(node.childNodes));
126 }
127 }
128 return result;
129 }
130
131 /**
132 * A plain-text note as the editor's own lines. Runs of spaces become the non-breaking ones the
133 * editor itself would have typed, or they would collapse into one.
134 * @param {string} text
135 * @returns {HTMLElement[]}
136 */
137 function lines(text) {
138 return text.split("\n").map(line =>
139 line === '' ? h("div", [h("br")]) : h("div", line.replace(/ (?= )/g, " ")));
140 }