Edit /Note as rich text: bold, italic, underline and lists

The textarea becomes a contenteditable div with a toolbar driven by execCommand - deprecated, but still the only built-in way to do this and supported everywhere. Ctrl/Cmd+B/I/U come from the browser, the buttons show whether the text under the caret has their style, and Tab and Shift-Tab nest and lift list items. Outside a list Tab still leaves the editor, so it is not a keyboard trap. The note still lives in the URL fragment through lz-string, now as HTML behind an "h:" marker. A fragment without it is a note from before this and opens as the plain text it was, one line per line, so a "<" in an old note is not read as markup. ':' is outside lz-string's alphabet, so no old fragment can start with the marker. A note arrives in a link someone else made, so its markup is never put in the page as is. It is parsed with DOMParser, which runs and loads nothing, and rebuilt from an allowlist of tags with every attribute dropped. Pastes go through the same rebuild, and so does the note on its way into the URL, because Chrome adds inline styles of its own when it inserts a paste. The page wrote its own hash on every edit and reloaded on every hashchange. That was harmless for a textarea, but replacing the editor contents moves the caret, so the page now ignores the change it made itself. Verified in the browser: formatting and nested lists survive a reload, an old plain-text fragment opens intact, and an <img onerror> and a <script> in the fragment or a paste are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-09-25 15:35 UTC
commit
fc31017b1120ad2b3558bedbe26496ca8c945dc8
parent
5759452c97
tree
browse at this commit

3 files changed +172 -12

Blog/Components/Pages/Note.razor +9 -1

@@ -3,6 +3,14 @@
3 3 <script type="module" src="@Assets["Components/Pages/Note.razor.js"]"></script>
4 4
5 5 <main>
6 - <textarea id="input" class="editor-tall" autofocus></textarea>
6 + <div id="toolbar" class="note-toolbar" role="toolbar" aria-label="Formatting" aria-controls="input">
7 + <button type="button" data-command="bold" aria-pressed="false" title="Bold (Ctrl+B)"><b>B</b></button>
8 + <button type="button" data-command="italic" aria-pressed="false" title="Italic (Ctrl+I)"><i>I</i></button>
9 + <button type="button" data-command="underline" aria-pressed="false" title="Underline (Ctrl+U)"><u>U</u></button>
10 + <button type="button" data-command="insertUnorderedList" aria-pressed="false" title="Bulleted list">• list</button>
11 + <button type="button" data-command="insertOrderedList" aria-pressed="false" title="Numbered list">1. list</button>
12 + </div>
13 + <div id="input" class="editor-tall note-editor" contenteditable="true"
14 + role="textbox" aria-multiline="true" aria-label="Note" autofocus></div>
7 15 <Log/>
8 16 </main>

Blog/Components/Pages/Note.razor.js +125 -11

@@ -1,26 +1,140 @@
1 -import { getById, debounce, writeError, resetLog } from "/common.module.js";
1 +import { h, t, getById, debounce, writeError, resetLog } from "/common.module.js";
2 2 import lzString from "/lz-string.module.js";
3 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 +
4 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 +
5 57 input.addEventListener("input", debounce(() => {
6 - if (input.value === '') {
7 - window.location.hash = ''
58 + if (input.textContent === '') {
59 + written = '';
8 60 }
9 61 else {
10 - window.location.hash = '#' + lzString.compressToEncodedURIComponent(input.value);
62 + const html = h("div", clean(input.childNodes)).innerHTML;
63 + written = '#' + htmlPrefix + lzString.compressToEncodedURIComponent(html);
11 64 }
65 + window.location.hash = written;
12 66 resetLog();
13 -}, 10))
67 +}, 10));
68 +
69 +document.addEventListener("selectionchange", updateToolbar);
14 70
15 71 window.addEventListener('hashchange', loadState);
16 72 loadState();
17 73
18 74 function loadState() {
19 - if (window.location.hash !== '') {
20 - input.value = lzString.decompressFromEncodedURIComponent(window.location.hash.substring(1));
21 - if (input.value === '') {
22 - //Hash but no content?
23 - writeError("Failed to load note from url.")
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));
24 126 }
25 127 }
26 -}
No newline at end of file
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 +}

Blog/wwwroot/app.css +38 -0

@@ -923,6 +923,44 @@ progress {
923 923 }
924 924
925 925
926 +/* -- Note (rich text editor) ---------------------------------------------- */
927 +
928 +.note-toolbar {
929 + display: flex;
930 + flex-wrap: wrap;
931 + gap: var(--s-1);
932 + margin-block-end: var(--s-1);
933 +}
934 +
935 +/* One letter or two words each: the 8ch every other button gets would make this a row of gaps. */
936 +.note-toolbar button {
937 + min-inline-size: 4ch;
938 + padding-inline: var(--s-1);
939 +}
940 +
941 +/* On for the text under the caret: drawn the way a hovered button is, since that is the only
942 + emphasis a button here has. */
943 +.note-toolbar button[aria-pressed="true"] {
944 + background-color: var(--color);
945 + color: var(--background);
946 +}
947 +
948 +/* Dressed as the textarea it replaced. */
949 +.note-editor {
950 + border: var(--border-width) solid var(--border);
951 + border-radius: var(--radius);
952 + padding: 0.1em 0.5em;
953 + overflow-y: auto;
954 + overflow-wrap: break-word;
955 + cursor: text;
956 +}
957 +
958 +/* A nested list is one more line of its parent, not a new paragraph. */
959 +.note-editor :is(ul, ol) :is(ul, ol) {
960 + margin-block: 0;
961 +}
962 +
963 +
926 964 /* -- Scroll shortcuts ------------------------------------------------------ */
927 965
928 966 /* Docked to the viewport edge rather than the page flow, so it stays