diff --git a/CLAUDE.md b/CLAUDE.md index d146121..6aa129a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,14 @@ Prefer separate, human-readable `.html` files over inlined HTML strings in Go. E - Do not inline JS in templates or merge unrelated features into one file - `ALT+SHIFT` is the modifier for all keyboard shortcuts — do not introduce others - Editor toolbar buttons use `data-action` + `data-key`; adding `data-key` auto-registers the shortcut +- The editor is a *mode* of a page, not a destination. `history-nav.js` turns any + same-path `?edit` link (and the editor's CANCEL link back out) into + `location.replace`, and SAVE POSTs via fetch and then rewrites the entry with + the saved page. Net effect: an edit session never occupies a history entry of + its own. Links to a *different* page's editor (new page / new child) still push. + The save POST answers `204` + `X-Target` when the request carries + `X-Save-Mode: replace`, because the target may hold a `#section` anchor only + the server can compute and fetch drops fragments from followed redirects. - For mutating modals (anything that POSTs and then navigates), call `closeModal()` and then `postReplace(action, body, target)` from `page/actions.js`. Do NOT use `
.submit()`. Two reasons: 1. The modal must be removed from the DOM before navigation, or the browser's bfcache snapshots it open and back-nav restores the modal. 2. `postReplace` uses `window.location.replace` so the action + result occupy a single history entry. A naive POST → 303 → GET creates two entries, and back-nav lands on a stale pre-mutation snapshot of the same page. diff --git a/assets/editor/main.js b/assets/editor/main.js index 5076e38..484e236 100644 --- a/assets/editor/main.js +++ b/assets/editor/main.js @@ -140,7 +140,46 @@ function syncContent() { hidden.value = view.state.doc.toString(); } - form.addEventListener('submit', syncContent); + + // Save POSTs via fetch and then rewrites the *editor's* history entry with + // the resulting page, so the edit session and its result share a single + // entry (see history-nav.js). A plain form submit would push a second one + // and leave the editor sitting in history behind the saved page. + // + // The server answers 204 + X-Target instead of a 303 because the target may + // carry a #section anchor the client cannot compute, and fetch drops the + // fragment from a followed redirect. + function postSave() { + syncContent(); + var body = new URLSearchParams(new FormData(form)).toString(); + fetch(form.action, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Save-Mode': 'replace', + }, + body: body, + }).then(function (res) { + if (!res.ok) { + return res.text().then(function (msg) { + alert(msg || ('Save failed (' + res.status + ')')); + }); + } + var target = res.headers.get('X-Target') || form.action; + // replaceState + reload rather than location.replace: if target + // differs from the current URL only by fragment the browser would + // skip the re-fetch and show pre-save content. + window.history.replaceState(null, '', target); + window.location.reload(); + }).catch(function () { + alert('Network error — the page was not saved'); + }); + } + + form.addEventListener('submit', function (e) { + e.preventDefault(); + postSave(); + }); // --- Actions --- diff --git a/assets/global-shortcuts.js b/assets/global-shortcuts.js index fb257d5..c5f4c4e 100644 --- a/assets/global-shortcuts.js +++ b/assets/global-shortcuts.js @@ -4,7 +4,8 @@ switch (e.key) { case 'E': e.preventDefault(); - window.location.href = window.location.pathname + '?edit'; + // replace, not assign — same reasoning as history-nav.js. + window.location.replace(window.location.pathname + '?edit'); break; case 'N': e.preventDefault(); diff --git a/assets/history-nav.js b/assets/history-nav.js new file mode 100644 index 0000000..46aaf7e --- /dev/null +++ b/assets/history-nav.js @@ -0,0 +1,33 @@ +// Keeps the page editor out of the browser history. +// +// Opening the editor for the page you are already on is a mode switch, not a +// new destination, so it replaces the current history entry instead of pushing +// one; CANCEL replaces it right back, and SAVE does the same via postSave in +// editor/main.js. Without this, page -> edit -> save leaves +// [prev, page, editor, page'] behind and Back walks through the editor and a +// stale pre-save snapshot of the page before reaching prev. +// +// Links to a *different* page's editor (new page, new child) still push — the +// page you started from has to stay in history. +(function () { + function isEdit(loc) { + return new URLSearchParams(loc.search).has('edit'); + } + + document.addEventListener('click', function (e) { + if (e.defaultPrevented || e.button !== 0) return; + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + var a = e.target.closest ? e.target.closest('a[href]') : null; + if (!a || a.target || a.hasAttribute('download')) return; + + var url = new URL(a.href, window.location.href); + if (url.origin !== window.location.origin) return; + if (url.pathname !== window.location.pathname) return; + // Same page: only editor entry/exit is a mode switch. Plain anchor + // links share the pathname too and must keep their normal behaviour. + if (!isEdit(url) && !isEdit(window.location)) return; + + e.preventDefault(); + window.location.replace(url.href); + }); +}()); diff --git a/assets/layout.html b/assets/layout.html index 10c4d69..4748aca 100644 --- a/assets/layout.html +++ b/assets/layout.html @@ -10,6 +10,7 @@ + diff --git a/main.go b/main.go index 442c373..6f8d96f 100644 --- a/main.go +++ b/main.go @@ -465,6 +465,15 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs } } } + // The editor saves via fetch so the save and its result share one history + // entry (see assets/history-nav.js). Hand it the target instead of a 303: + // the browser would follow the redirect into a second entry, and fetch + // drops the #section fragment from a followed redirect anyway. + if r.Header.Get("X-Save-Mode") == "replace" { + w.Header().Set("X-Target", redirectTarget) + w.WriteHeader(http.StatusNoContent) + return + } http.Redirect(w, r, redirectTarget, http.StatusSeeOther) }