Save notes to /Storage, and open a store from the fragment main

A Save button under the /Note editor keeps the note's URL in a "notes" store in the Storage page's IndexedDB, labelled with the note's first line, and a "Saved notes" link goes to Storage#notes. The store name is read from that link, so the two can't disagree, and the store is made on the first save with the version bump IndexedDB requires. Every edit is a new URL, so the URL can't say which entry a note is. The first save gives the note a random id, kept in the query string beside the note in the fragment, and uses it as the entry's key: a note opened from Storage brings the id along and saves back over its own entry. Emptying the editor drops the id, so the next note is a new one. The id is random rather than counted because a shared link carries it into someone else's storage. On /Storage, http(s) values are links, and {title, url} is a link labelled with its title. Nothing else becomes a link, so a javascript: value stays text. Links are one line, cut off. Storage#name opens that store on load, and again on a hashchange. Open stores are now tracked by name, which also fixes the buttons resetting to "Open" whenever the store list was rebuilt while panels stayed open. The page used to ignore a versionchange from another tab and so block it; it now closes and reopens, which is what lets Note make its store while Storage is open. Note closes its connection after every save for the same reason. Verified in the browser: save, update and unchanged re-save, the store made while Storage is open in another tab, Storage#notes opening expanded, editing a note opened from Storage updating its own entry, a cleared note saving as a new one, and a javascript: value staying text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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(--s-1) var(--s0);
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(--background);
959 973 }
960 974
975 +.note-actions {
976 + margin-block-start: var(--s-1);
977 +}
978 +
961 979 /* Dressed as the textarea it replaced. */
962 980 .note-editor {
963 981 border: var(--border-width) solid var(--border);