Edit /Note as rich text: bold, italic, underline and lists
- 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 | |
| Blog/Components/Pages/Note.razor.js | +125 -11 | |
| Blog/wwwroot/app.css | +38 -0 |
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(); | |
| 932 | + margin-block-end: var(); | |
| 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(); | |
| 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(); | |
| 945 | + color: var(); | |
| 946 | +} | |
| 947 | + | |
| 948 | +/* Dressed as the textarea it replaced. */ | |
| 949 | +.note-editor { | |
| 950 | + border: var() solid var(); | |
| 951 | + border-radius: var(); | |
| 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 |