Section edit pens, cursor-driven piecewise scroll sync, URL-following editor.
Anchored h1/h2s carry data-line (markdown source line, top-level headings only) and h2s get dimmed section pens that open the page editor at that section. Editor scroll sync is now piecewise-linear keyed on those anchors: the page follows the cursor (fractional, wrap-aware, fixed window anchor; editor scroll no longer drives it), the editor follows page scroll with a progress-based viewport anchor, so document ends line up exactly in both directions. The editor always follows the URL — fetch-navigation retargets it — with unsaved text stashed per path for the session and restored on return. Banner pen removed (the tab stays in the shell); top-right order is now analytics, site settings, login/logout.
This commit is contained in:
+165
-33
@@ -5,11 +5,14 @@
|
||||
// The socket connects when the editor is opened and reconnects with
|
||||
// exponential backoff after a failure; unsaved text and pending saves
|
||||
// survive a disconnect. Editor and article (window) scrolls are linked
|
||||
// proportionally both ways (syncWindowToEditor / syncEditorToWindow).
|
||||
// piecewise-linearly both ways, keyed on the section anchors' data-line
|
||||
// (syncWindowToEditor / syncEditorToWindow).
|
||||
// Saving (💾 / Ctrl+S) is explicit
|
||||
// and refreshes the page regions in place — never a reload — so the editor
|
||||
// state (unsaved text included) also survives closing the shell; it is lost
|
||||
// only on a real page reload.
|
||||
// state (unsaved text included) also survives closing the shell. The editor
|
||||
// always follows the URL: navigating away retargets it to the new page,
|
||||
// stashing unsaved text per path (unsavedStash) so returning to the page
|
||||
// restores the working draft; stashes clear on save and on real reload.
|
||||
import { onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
@@ -95,6 +98,7 @@ function save() {
|
||||
// editor; the save APIs (REST PUT / WS save) never delete on empty.
|
||||
return fetch(`/_api/pages/${path.value}`, { method: 'DELETE' }).then((res) => {
|
||||
saveError.value = res.ok ? '' : '⚠️ changes could not be saved'
|
||||
if (res.ok) unsavedStash.delete(path.value)
|
||||
})
|
||||
}
|
||||
const msg = {
|
||||
@@ -219,7 +223,16 @@ function insertTable(cols, rows) {
|
||||
view.focus()
|
||||
}
|
||||
|
||||
// Unsaved edits survive navigation within the session: leaving a page
|
||||
// stashes its working text here, returning restores it (the server doc
|
||||
// still arrives, for title/published and as the base underneath).
|
||||
// Entries clear on save and on real reload (the shell is in-memory only).
|
||||
const unsavedStash = new Map()
|
||||
|
||||
function openPath(p) {
|
||||
if (dirty.value && path.value && p !== path.value) {
|
||||
unsavedStash.set(path.value, view.state.doc.toString())
|
||||
}
|
||||
path.value = p
|
||||
send({ type: 'open', path: p })
|
||||
}
|
||||
@@ -265,15 +278,21 @@ function onMessage(ev) {
|
||||
if (msg.type === 'doc' && msg.path === path.value) {
|
||||
title.value = msg.title
|
||||
published.value = msg.published
|
||||
setDocument(msg.markdown)
|
||||
// Restore stashed unsaved edits over the server doc when returning
|
||||
// to a page left dirty.
|
||||
const stashed = unsavedStash.get(msg.path)
|
||||
setDocument(stashed ?? msg.markdown)
|
||||
dirty.value = stashed != null
|
||||
requestRender()
|
||||
dirty.value = false // just loaded from the server, nothing unsaved
|
||||
// A section pen's target line survives the open/path-switch here.
|
||||
consumePendingLine()
|
||||
} else if (msg.type === 'html' && msg.path === path.value) {
|
||||
previewIntoArticle(msg.html, msg.multicol)
|
||||
} else if (msg.type === 'saved') {
|
||||
saveError.value = ''
|
||||
pendingSave = null
|
||||
dirty.value = false
|
||||
unsavedStash.delete(path.value)
|
||||
savedResolve?.()
|
||||
savedResolve = null
|
||||
} else if (msg.type === 'error') {
|
||||
@@ -305,31 +324,104 @@ function onKeydown(ev) {
|
||||
function onEditorShown() {
|
||||
if (document.body.dataset.editorMode !== 'page') return
|
||||
updateWindowTitle()
|
||||
if (dirty.value) requestRender()
|
||||
// Always follow the URL: if the user navigated while the editor was
|
||||
// hidden or on another tab, retarget (discarding unsaved text — its
|
||||
// preview page is gone); otherwise restore the working preview.
|
||||
const p = normPath(props.pagePath)
|
||||
if (p !== path.value) openPath(p)
|
||||
else if (dirty.value) requestRender()
|
||||
consumePendingLine()
|
||||
}
|
||||
|
||||
// Bidirectional proportional scroll sync between the CodeMirror scroller
|
||||
// and the window (the article's scroller, also while editing). Both
|
||||
// directions apply instantly (never smooth — a smooth window scroll feeds
|
||||
// its intermediate positions back into the editor and fights the user's
|
||||
// scrolling) and coalesce to one update per frame. Loops are broken two
|
||||
// ways: a driver flag held until one frame AFTER the write (the scroll
|
||||
// event a programmatic write dispatches arrives asynchronously — clearing
|
||||
// the flag in the writing frame would let the echo through and the two
|
||||
// directions would chase each other, which showed up as random jumping
|
||||
// whenever layout shifted the proportional targets mid-scroll), and a 1px
|
||||
// tolerance so residual rounding is a no-op. When the panel's height
|
||||
// changes mid-scroll (its top tracks the banner), the page is the driver:
|
||||
// the editor is re-matched to the page's position, never vice versa.
|
||||
// Piecewise-linear scroll sync between the CodeMirror scroller and the
|
||||
// window (the article's scroller, also while editing), keyed on the
|
||||
// section anchors: the backend tags anchored h1/h2 headings with
|
||||
// data-line (markdown source line), so each heading pairs a document
|
||||
// position with a page position, and positions interpolate linearly
|
||||
// between neighbouring headings. Endpoints are the article top (line 1)
|
||||
// and the document bottom (last line).
|
||||
//
|
||||
// Editor → page follows the CURSOR, not the editor viewport: the cursor's
|
||||
// fractional line (soft-wrap included, so moving inside a wrapped
|
||||
// paragraph tracks smoothly) maps to its page position, shown at a fixed
|
||||
// anchor height in the window — the cursor on the last line lands at the
|
||||
// end of the page, no ramping needed. Only cursor/selection changes drive
|
||||
// this direction: editor wheel-scrolling repositions the text, not the
|
||||
// page, which removes the scroll→scroll echo entirely.
|
||||
// Page → editor anchors a viewport fraction that grows with page progress
|
||||
// (0 = heading at viewport top when the page is at the top, 1 = viewport
|
||||
// bottom at the page's end), so both document ends line up exactly.
|
||||
//
|
||||
// Both directions apply instantly (never smooth — a smooth window scroll
|
||||
// feeds its intermediate positions back into the editor and fights the
|
||||
// user's scrolling) and coalesce to one update per frame. Loops are
|
||||
// broken two ways: a driver flag held until one frame AFTER the write
|
||||
// (the scroll event a programmatic write dispatches arrives
|
||||
// asynchronously — clearing the flag in the writing frame would let the
|
||||
// echo through and the two directions would chase each other, which
|
||||
// showed up as random jumping whenever layout shifted the targets
|
||||
// mid-scroll), and a 1px tolerance so residual rounding is a no-op. When
|
||||
// the panel's height changes mid-scroll (its top tracks the banner), the
|
||||
// page is the driver: the editor is re-matched to the page's position,
|
||||
// never vice versa.
|
||||
|
||||
// [markdown line (1-based), window Y] control points, ascending in both.
|
||||
function syncPoints() {
|
||||
const article = document.querySelector('#main article')
|
||||
if (!article || !view) return null
|
||||
const pts = [[1, article.getBoundingClientRect().top + scrollY]]
|
||||
for (const h of article.querySelectorAll('[data-line]')) {
|
||||
pts.push([+h.dataset.line + 1, h.getBoundingClientRect().top + scrollY])
|
||||
}
|
||||
pts.push([view.state.doc.lines, document.documentElement.scrollHeight])
|
||||
return pts.sort((a, b) => a[0] - b[0])
|
||||
}
|
||||
|
||||
// Piecewise-linear map of v from column `from` to column `to`, clamped to
|
||||
// the segment ends.
|
||||
function interp(pts, v, from, to) {
|
||||
let i = 1
|
||||
while (i < pts.length - 1 && pts[i][from] < v) i++
|
||||
const [a0, b0] = [pts[i - 1][from], pts[i - 1][to]]
|
||||
const [a1, b1] = [pts[i][from], pts[i][to]]
|
||||
const t = a1 > a0 ? (v - a0) / (a1 - a0) : 0
|
||||
return b0 + Math.max(0, Math.min(1, t)) * (b1 - b0)
|
||||
}
|
||||
|
||||
// Editor scroller top showing the fractional markdown line.
|
||||
function editorTopFor(line) {
|
||||
const scroller = view.scrollDOM
|
||||
const max = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
|
||||
if (line >= view.state.doc.lines) return max
|
||||
const n = Math.max(1, Math.floor(line))
|
||||
const block = view.lineBlockAt(view.state.doc.line(n).from)
|
||||
return Math.min(max, block.top + (line - n) * block.height)
|
||||
}
|
||||
|
||||
//: Window height fraction where the cursor's page position is shown.
|
||||
const CURSOR_ANCHOR = 1 / 3
|
||||
|
||||
function syncWindowToEditor() {
|
||||
if (syncingScroll || !view) return
|
||||
syncingScroll = true
|
||||
requestAnimationFrame(() => {
|
||||
const scroller = view.scrollDOM
|
||||
const max = scroller.scrollHeight - scroller.clientHeight
|
||||
const pct = max > 0 ? scroller.scrollTop / max : 0
|
||||
const y = pct * Math.max(0, document.documentElement.scrollHeight - innerHeight)
|
||||
if (Math.abs(scrollY - y) > 1) scrollTo({ top: y, behavior: 'instant' })
|
||||
const pts = syncPoints()
|
||||
if (pts) {
|
||||
// The cursor's page position, shown at a fixed window height.
|
||||
const pos = view.state.selection.main.head
|
||||
const coords = view.coordsAtPos(pos)
|
||||
if (coords) {
|
||||
const scroller = view.scrollDOM
|
||||
const block = view.lineBlockAt(pos)
|
||||
const docY = coords.top - scroller.getBoundingClientRect().top + scroller.scrollTop
|
||||
const frac = block.height > 0
|
||||
? Math.max(0, Math.min(1, (docY - block.top) / block.height))
|
||||
: 0
|
||||
const line = view.state.doc.lineAt(pos).number + frac
|
||||
const y = interp(pts, line, 0, 1) - CURSOR_ANCHOR * innerHeight
|
||||
if (Math.abs(scrollY - y) > 1) scrollTo({ top: Math.max(0, y), behavior: 'instant' })
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(() => { syncingScroll = false })
|
||||
})
|
||||
}
|
||||
@@ -338,15 +430,47 @@ function syncEditorToWindow() {
|
||||
if (syncingScroll || !view) return
|
||||
syncingScroll = true
|
||||
requestAnimationFrame(() => {
|
||||
const scroller = view.scrollDOM
|
||||
const pageMax = Math.max(0, document.documentElement.scrollHeight - innerHeight)
|
||||
const pct = pageMax > 0 ? scrollY / pageMax : 0
|
||||
const top = pct * Math.max(0, scroller.scrollHeight - scroller.clientHeight)
|
||||
if (Math.abs(scroller.scrollTop - top) > 1) scroller.scrollTop = top
|
||||
const pts = syncPoints()
|
||||
if (pts) {
|
||||
// Anchor fraction grows with page progress: the mapped line sits at
|
||||
// the viewport top when the page is at its top, at the bottom when
|
||||
// scrolled all the way down.
|
||||
const pageMax = Math.max(0, document.documentElement.scrollHeight - innerHeight)
|
||||
const a = pageMax > 0 ? scrollY / pageMax : 0
|
||||
const line = interp(pts, scrollY + a * innerHeight, 1, 0)
|
||||
const scroller = view.scrollDOM
|
||||
const top = editorTopFor(line) - a * scroller.clientHeight
|
||||
const max = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
|
||||
const clamped = Math.max(0, Math.min(max, top))
|
||||
if (Math.abs(scroller.scrollTop - clamped) > 1) scroller.scrollTop = clamped
|
||||
}
|
||||
requestAnimationFrame(() => { syncingScroll = false })
|
||||
})
|
||||
}
|
||||
|
||||
// Jump both views to a markdown source line (0-based, as carried by the
|
||||
// section pens' data-line / window.__pageriteEditLine).
|
||||
function scrollToSourceLine(line) {
|
||||
if (!view || line == null) return
|
||||
const n = Math.max(1, Math.min(line + 1, view.state.doc.lines))
|
||||
const pos = view.state.doc.line(n).from
|
||||
view.dispatch({
|
||||
selection: { anchor: pos },
|
||||
effects: EditorView.scrollIntoView(pos, { y: 'start', yMargin: 8 }),
|
||||
})
|
||||
const h = document.querySelector(`#main article [data-line="${line}"]`)
|
||||
if (h) scrollTo({ top: h.getBoundingClientRect().top + scrollY, behavior: 'instant' })
|
||||
}
|
||||
|
||||
// A section pen carries its line in window.__pageriteEditLine; consume it
|
||||
// once the document is here (fresh open, path switch, re-shown shell).
|
||||
function consumePendingLine() {
|
||||
const line = window.__pageriteEditLine
|
||||
if (line == null) return
|
||||
delete window.__pageriteEditLine
|
||||
scrollToSourceLine(line)
|
||||
}
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(
|
||||
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
|
||||
@@ -386,7 +510,11 @@ onMounted(() => {
|
||||
cmTheme,
|
||||
cmHighlight,
|
||||
EditorView.lineWrapping, // Markdown lines are long: soft-wrap them
|
||||
EditorView.updateListener.of((u) => { if (u.docChanged) requestRender() }),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) requestRender()
|
||||
// Cursor moves (typing included) drive the page scroll sync.
|
||||
if (u.selectionSet) syncWindowToEditor()
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
paste(ev) {
|
||||
// Paste an image straight into the article: upload + insert
|
||||
@@ -402,9 +530,10 @@ onMounted(() => {
|
||||
}),
|
||||
parent: editorEl.value,
|
||||
})
|
||||
view.scrollDOM.addEventListener('scroll', syncWindowToEditor)
|
||||
// Page → editor: window scroll (and resizes, e.g. the panel growing when
|
||||
// the banner scrolls away) re-match the editor to the page's position.
|
||||
// The other direction is cursor-driven (updateListener above), never
|
||||
// scroll-driven — an editor scroll moves text, not the page.
|
||||
addEventListener('scroll', syncEditorToWindow, { passive: true })
|
||||
addEventListener('resize', syncEditorToWindow)
|
||||
// Opening the editor means you want to write: start focused.
|
||||
@@ -416,6 +545,8 @@ onMounted(() => {
|
||||
}
|
||||
addEventListener('keydown', onKeydown)
|
||||
addEventListener('pagerite:editor-shown', onEditorShown)
|
||||
// A section pen clicked while the page editor is already open.
|
||||
addEventListener('pagerite:edit-section', consumePendingLine)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -430,6 +561,7 @@ onUnmounted(() => {
|
||||
removeEventListener('resize', syncEditorToWindow)
|
||||
removeEventListener('keydown', onKeydown)
|
||||
removeEventListener('pagerite:editor-shown', onEditorShown)
|
||||
removeEventListener('pagerite:edit-section', consumePendingLine)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -634,8 +766,8 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
/* CodeMirror sits inside a bordered box, like a dialog's input area, with
|
||||
a slight margin to the panel edges. Wheel scroll stays in the editor and
|
||||
drives the article (syncWindowToEditor) instead of double-scrolling. */
|
||||
a slight margin to the panel edges. Wheel scroll stays in the editor
|
||||
(overscroll-behavior) instead of double-scrolling the page. */
|
||||
.editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -667,6 +667,16 @@ article h1 .edit-link {
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
/* Section pens sit at the end of anchored h2s, dimmer than the page pen
|
||||
until hovered. */
|
||||
article h2 .edit-section {
|
||||
position: static;
|
||||
font-size: 0.85rem;
|
||||
vertical-align: 0.35em;
|
||||
margin-left: 0.4rem;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.edit-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
+41
-17
@@ -69,19 +69,22 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
return map;
|
||||
})();
|
||||
|
||||
function makePen(mode) {
|
||||
function makePen(mode, line) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.dataset.editorSrc = editorMeta.src;
|
||||
btn.dataset.editorCss = editorMeta.css || "";
|
||||
btn.dataset.editorMode = mode;
|
||||
if (mode === "page") {
|
||||
btn.className = "edit-link";
|
||||
btn.title = "edit page";
|
||||
if (line != null) {
|
||||
// Section pen on an anchored h2: opens the page editor at the
|
||||
// section's markdown source line (data-line, from the backend).
|
||||
btn.className = "edit-link edit-section";
|
||||
btn.title = "edit section";
|
||||
btn.textContent = "🖊️";
|
||||
} else if (mode === "banner") {
|
||||
btn.className = "edit-link";
|
||||
btn.title = "edit banner";
|
||||
btn.dataset.editorLine = line;
|
||||
} else if (mode === "page") {
|
||||
btn.className = "edit-link edit-page";
|
||||
btn.title = "edit page";
|
||||
btn.textContent = "🖊️";
|
||||
} else {
|
||||
btn.className = "edit-link site-edit-link";
|
||||
@@ -93,9 +96,17 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
|
||||
function injectPagePen() {
|
||||
const article = document.querySelector("#main article");
|
||||
if (article && !article.querySelector("button.edit-link")) {
|
||||
if (!article) return;
|
||||
if (!article.querySelector("button.edit-page")) {
|
||||
article.prepend(makePen("page"));
|
||||
}
|
||||
// Section pens on the anchored h2s (long articles only — the backend
|
||||
// adds data-line to those headings), for editor access mid-document.
|
||||
for (const h2 of article.querySelectorAll("h2[data-line]")) {
|
||||
if (!h2.querySelector("button.edit-section")) {
|
||||
h2.append(makePen("page", h2.dataset.line));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeAuthLink(admin) {
|
||||
@@ -131,8 +142,6 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
const pens = document.createElement("div");
|
||||
pens.className = "editor-pens";
|
||||
if (canEdit && !onAnalytics) {
|
||||
pens.append(makePen("banner"));
|
||||
pens.append(makePen("site"));
|
||||
// Analytics viewer is now a normal page at /_a.
|
||||
const a = document.createElement("a");
|
||||
a.className = "edit-link analytics-link";
|
||||
@@ -140,6 +149,7 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
a.title = "analytics";
|
||||
a.textContent = "📊";
|
||||
pens.append(a);
|
||||
pens.append(makePen("site"));
|
||||
}
|
||||
if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
|
||||
banner.after(pens);
|
||||
@@ -260,14 +270,11 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
|
||||
// Tuck the article edit pen at the end of the first h1 (which may come
|
||||
// from the markdown itself). Re-runs when the editor replaces the
|
||||
// previewed body, since that wipes elements inside it.
|
||||
// previewed article, since that wipes elements inside it.
|
||||
function placeEditPen() {
|
||||
const article = document.querySelector("#main article");
|
||||
const btn = article?.querySelector("button.edit-link");
|
||||
// First visible h1: the title h1 may be display:none when the
|
||||
// markdown owns its heading (editor preview state).
|
||||
const h1 = [...(article?.querySelectorAll("h1") || [])]
|
||||
.find((h) => h.offsetParent !== null);
|
||||
const btn = article?.querySelector("button.edit-page");
|
||||
const h1 = article?.querySelector("h1");
|
||||
if (btn && h1 && btn.parentElement !== h1) h1.append(btn);
|
||||
}
|
||||
|
||||
@@ -690,6 +697,13 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
}
|
||||
currentPath = new URL(finalUrl, location.href).pathname;
|
||||
if (push) history.pushState(null, "", finalUrl);
|
||||
// The open editor follows the URL: retarget the per-page tabs to the
|
||||
// navigated-to page (unsaved text of the previous page is discarded —
|
||||
// the article it previewed into is gone).
|
||||
if (document.body.classList.contains("editing")) {
|
||||
const p = currentPath.replace(/^\/+|\/+$/g, "");
|
||||
dispatchEvent(new CustomEvent("pagerite:switch-editor", { detail: { path: p } }));
|
||||
}
|
||||
// Cross-page anchor links scroll to the section after the swap (the
|
||||
// browser only does this itself on full page loads).
|
||||
const hash = new URL(finalUrl, location.href).hash;
|
||||
@@ -710,9 +724,19 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
if (editBtn && editBtn.dataset.editorSrc) {
|
||||
ev.preventDefault();
|
||||
const mode = editBtn.dataset.editorMode || "page";
|
||||
const line = editBtn.dataset.editorLine;
|
||||
// A section pen remembers its markdown source line: the page editor
|
||||
// opens (or jumps, when already open) scrolled to that section.
|
||||
// Clicking the plain page pen of the open tab closes the shell.
|
||||
if (line != null) window.__pageriteEditLine = +line;
|
||||
else delete window.__pageriteEditLine;
|
||||
if (document.body.classList.contains("editing")
|
||||
&& document.body.dataset.editorMode === mode) {
|
||||
editorModule?.then((m) => m.closeEditor());
|
||||
if (line != null) {
|
||||
dispatchEvent(new CustomEvent("pagerite:edit-section"));
|
||||
} else {
|
||||
editorModule?.then((m) => m.closeEditor());
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const css of (editBtn.dataset.editorCss || "").split(",")) {
|
||||
|
||||
Reference in New Issue
Block a user