Save notes to /Storage, and open a store from the fragment main
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-09-26 19:30 UTC
- commit
- e20f986af55fd1019540103abed1d65bab776929
- parent
- 3811e52013
- tree
- browse at this commit
4 files changed +211 -15
Blog/Components/Pages/Note.razor +4 -0
| @@ -21,5 +21,9 @@ | ||
| 21 | 21 | </div> |
| 22 | 22 | <div id="input" class="editor-tall note-editor" contenteditable="true" |
| 23 | 23 | role="textbox" aria-multiline="true" aria-label="Note" autofocus></div> |
| 24 | + <div class="flex-row note-actions"> | |
| 25 | + <button type="button" id="save" title="Keep a link to this note on the Storage page">Save</button> | |
| 26 | + <a id="saved-notes" href="Storage#notes">Saved notes</a> | |
| 27 | + </div> | |
| 24 | 28 | <Log/> |
| 25 | 29 | </main> |
Blog/Components/Pages/Note.razor.js +131 -1
| @@ -1,4 +1,4 @@ | ||
| 1 | -import { h, t, getById, debounce, writeError, resetLog } from "/common.module.js"; | |
| 1 | +import { h, t, getById, debounce, writeError, writeInfo, resetLog } from "/common.module.js"; | |
| 2 | 2 | import lzString from "/lz-string.module.js"; |
| 3 | 3 | |
| 4 | 4 | // Notes from before the editor were plain text. New ones are marked, so an old link still |
| @@ -19,6 +19,18 @@ const toolbar = getById("toolbar"); | ||
| 19 | 19 | const tableToggle = getById("table-toggle"); |
| 20 | 20 | const tableTools = getById("table-tools"); |
| 21 | 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"; | |
| 22 | 34 | |
| 23 | 35 | // No browser has commands for tables (Firefox had some, off by default), so these edit the DOM |
| 24 | 36 | // themselves. That puts them outside the browser's undo history: Ctrl+Z undoes typing, not a |
| @@ -102,11 +114,22 @@ input.addEventListener("input", debounce(() => { | ||
| 102 | 114 | written = '#' + htmlPrefix + lzString.compressToEncodedURIComponent(html); |
| 103 | 115 | } |
| 104 | 116 | window.location.hash = written; |
| 117 | + // Emptied out, a note is gone. Whatever gets written next is another one. | |
| 118 | + if (written === '') setId(null); | |
| 105 | 119 | resetLog(); |
| 106 | 120 | }, 10)); |
| 107 | 121 | |
| 108 | 122 | document.addEventListener("selectionchange", updateToolbar); |
| 109 | 123 | |
| 124 | +saveButton.addEventListener("click", async () => { | |
| 125 | + resetLog(); | |
| 126 | + try { | |
| 127 | + await save(); | |
| 128 | + } catch (e) { | |
| 129 | + writeError(e); | |
| 130 | + } | |
| 131 | +}); | |
| 132 | + | |
| 110 | 133 | window.addEventListener('hashchange', loadState); |
| 111 | 134 | loadState(); |
| 112 | 135 | |
| @@ -145,6 +168,113 @@ function changed() { | ||
| 145 | 168 | input.dispatchEvent(new Event("input")); |
| 146 | 169 | } |
| 147 | 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 | + | |
| 148 | 278 | /** @returns {Element|null} */ |
| 149 | 279 | function selectedElement() { |
| 150 | 280 | const node = document.getSelection()?.anchorNode; |
Blog/Components/Pages/Storage.razor.js +58 -14
| @@ -3,6 +3,16 @@ import {getById, writeError, writeDebug, writeInfo, h } from "/common.module.js" | ||
| 3 | 3 | /** @member {IDBDatabase} db */ |
| 4 | 4 | let db; |
| 5 | 5 | |
| 6 | +/** | |
| 7 | + * The stores open on the page, by name, each with what closes it again. | |
| 8 | + * @type {Map<string, () => void>} | |
| 9 | + */ | |
| 10 | +const opened = new Map(); | |
| 11 | + | |
| 12 | +// The store named in the fragment is opened once the store list is known. Only then: showStores | |
| 13 | +// runs again whenever the database changes, and must not reopen a store someone just closed. | |
| 14 | +let openFromHash = true; | |
| 15 | + | |
| 6 | 16 | function InitDB(upgradeCallback) { |
| 7 | 17 | writeDebug(`db = ${db?.version}`) |
| 8 | 18 | if (db) { |
| @@ -28,7 +38,12 @@ function InitDB(upgradeCallback) { | ||
| 28 | 38 | writeDebug(`db = ${db.version}`) |
| 29 | 39 | writeDebug("Database opened successfully."); |
| 30 | 40 | db.onversionchange = () => { |
| 31 | - writeInfo(`Version changed in another tab, closing database.`); | |
| 41 | + // Another tab is making or deleting a store, such as Note saving its first note. Let | |
| 42 | + // it, and open whatever version it leaves behind. | |
| 43 | + writeInfo(`Version changed in another tab, reopening database.`); | |
| 44 | + db.close(); | |
| 45 | + db = null; | |
| 46 | + InitDB(null); | |
| 32 | 47 | } |
| 33 | 48 | showStores(); |
| 34 | 49 | }; |
| @@ -44,6 +59,12 @@ try { | ||
| 44 | 59 | db = null; |
| 45 | 60 | InitDB(null); |
| 46 | 61 | |
| 62 | + // A link to another store from this page changes only the fragment, which reloads nothing. | |
| 63 | + window.addEventListener('hashchange', () => { | |
| 64 | + openFromHash = true; | |
| 65 | + if (db) showStores(); | |
| 66 | + }); | |
| 67 | + | |
| 47 | 68 | const newStoreButton = getById('newStoreButton'); |
| 48 | 69 | newStoreButton.addEventListener('click', async () => { |
| 49 | 70 | const newStoreName = await getInput("New store", "Store name"); |
| @@ -69,33 +90,39 @@ function showStores() { | ||
| 69 | 90 | |
| 70 | 91 | let storeNames = db.objectStoreNames; |
| 71 | 92 | |
| 93 | + if (openFromHash) { | |
| 94 | + openFromHash = false; | |
| 95 | + const storeName = decodeURIComponent(window.location.hash.substring(1)); | |
| 96 | + if (storeNames.contains(storeName) && !opened.has(storeName)) { | |
| 97 | + openStore(storeName); | |
| 98 | + } | |
| 99 | + } | |
| 100 | + | |
| 72 | 101 | for (const storeName of storeNames) { |
| 73 | 102 | const deleteButton = h("button", { |
| 74 | 103 | onClick: async () => { |
| 75 | 104 | if (await askForConfirmation(`Are you sure you want to delete store '${storeName}'?`)) { |
| 105 | + opened.get(storeName)?.(); | |
| 76 | 106 | InitDB((event) => { |
| 77 | 107 | writeDebug(`Deleting store: ${storeName}, version: ${event.newVersion}`); |
| 78 | 108 | event.target.result.deleteObjectStore(storeName); |
| 79 | 109 | writeDebug("Deleted store: " + storeName); |
| 80 | 110 | }); |
| 81 | - | |
| 82 | - if (openButton.closeCallback) openButton.closeCallback(); | |
| 83 | 111 | } |
| 84 | 112 | } |
| 85 | 113 | }, "Delete"); |
| 86 | 114 | |
| 87 | 115 | const openButton = h("button", { |
| 88 | 116 | onClick: () => { |
| 89 | - if (openButton.textContent === "Open") { | |
| 90 | - openButton.closeCallback = openStore(storeName, deleteButton); | |
| 91 | - openButton.textContent = "Close"; | |
| 92 | - } else { | |
| 93 | - openButton.closeCallback(); | |
| 117 | + if (opened.has(storeName)) { | |
| 118 | + opened.get(storeName)(); | |
| 94 | 119 | openButton.textContent = "Open"; |
| 95 | - openButton.closeCallback = null; | |
| 120 | + } else { | |
| 121 | + openStore(storeName); | |
| 122 | + openButton.textContent = "Close"; | |
| 96 | 123 | } |
| 97 | 124 | } |
| 98 | - }, "Open"); | |
| 125 | + }, opened.has(storeName) ? "Close" : "Open"); | |
| 99 | 126 | |
| 100 | 127 | const storeElement = h("span", storeName); |
| 101 | 128 | storeList.appendChild(storeElement); |
| @@ -109,9 +136,10 @@ function openStore(storeName) { | ||
| 109 | 136 | |
| 110 | 137 | const addButton = h("button", "Add"); |
| 111 | 138 | const storeElement = h("fieldset", {class: "panel"}, [h("legend", [storeName + " ", addButton])]); |
| 112 | - const closeStore = () => { | |
| 139 | + opened.set(storeName, () => { | |
| 113 | 140 | openedStores.removeChild(storeElement); |
| 114 | - }; | |
| 141 | + opened.delete(storeName); | |
| 142 | + }); | |
| 115 | 143 | |
| 116 | 144 | openedStores.appendChild(storeElement); |
| 117 | 145 | const elementList = h("div", {class:"grid-stores"}); |
| @@ -157,7 +185,7 @@ function openStore(storeName) { | ||
| 157 | 185 | } |
| 158 | 186 | }, "Delete"); |
| 159 | 187 | |
| 160 | - newChildren.push(h("span", value)); | |
| 188 | + newChildren.push(showValue(value)); | |
| 161 | 189 | newChildren.push(editItemButton); |
| 162 | 190 | newChildren.push(deleteItemButton); |
| 163 | 191 | |
| @@ -166,6 +194,7 @@ function openStore(storeName) { | ||
| 166 | 194 | } |
| 167 | 195 | |
| 168 | 196 | showItems(); |
| 197 | + storeElement.scrollIntoView({block: "nearest"}); | |
| 169 | 198 | |
| 170 | 199 | addButton.addEventListener('click', async () => { |
| 171 | 200 | const value = await getInput("New value", "value"); |
| @@ -178,8 +207,23 @@ function openStore(storeName) { | ||
| 178 | 207 | store.add(value); |
| 179 | 208 | } |
| 180 | 209 | }) |
| 210 | +} | |
| 181 | 211 | |
| 182 | - return closeStore; | |
| 212 | +/** | |
| 213 | + * A web address is a link to it, and `{title, url}` (what Note saves) is a link labelled with | |
| 214 | + * its title. Anything else is its text. Only http(s): a value can be anything that was typed | |
| 215 | + * into it, and a javascript: link would run it. | |
| 216 | + * @param {unknown} value | |
| 217 | + * @returns {HTMLElement} | |
| 218 | + */ | |
| 219 | +function showValue(value) { | |
| 220 | + const isLabelled = typeof value?.url === "string" && typeof value?.title === "string"; | |
| 221 | + const text = isLabelled ? value.url : String(value); | |
| 222 | + const url = URL.canParse(text) ? new URL(text) : null; | |
| 223 | + if (url && (url.protocol === "https:" || url.protocol === "http:")) { | |
| 224 | + return h("a", {href: url.href, class: "store-link"}, isLabelled ? value.title : text); | |
| 225 | + } | |
| 226 | + return h("span", text); | |
| 183 | 227 | } |
| 184 | 228 | |
| 185 | 229 | /** |
Blog/wwwroot/app.css +18 -0
| @@ -722,6 +722,20 @@ progress { | ||
| 722 | 722 | gap: var() var(); |
| 723 | 723 | } |
| 724 | 724 | |
| 725 | +/* A link is one line, cut off: a saved note's first line can be a paragraph, and a bare URL can | |
| 726 | + be several kilobytes of base64. A fieldset is at least as wide as its content | |
| 727 | + unless told otherwise, which would push the page sideways instead. */ | |
| 728 | +fieldset:has(> .grid-stores) { | |
| 729 | + min-inline-size: 0; | |
| 730 | +} | |
| 731 | + | |
| 732 | +.store-link { | |
| 733 | + min-inline-size: 0; | |
| 734 | + overflow: hidden; | |
| 735 | + text-overflow: ellipsis; | |
| 736 | + white-space: nowrap; | |
| 737 | +} | |
| 738 | + | |
| 725 | 739 | /* -- Warframe drops ------------------------------------------------------- */ |
| 726 | 740 | |
| 727 | 741 | /* Chances are compared down the column, so they align right and shrink to fit. */ |
| @@ -958,6 +972,10 @@ progress { | ||
| 958 | 972 | color: var(); |
| 959 | 973 | } |
| 960 | 974 | |
| 975 | +.note-actions { | |
| 976 | + margin-block-start: var(); | |
| 977 | +} | |
| 978 | + | |
| 961 | 979 | /* Dressed as the textarea it replaced. */ |
| 962 | 980 | .note-editor { |
| 963 | 981 | border: var() solid var(); |