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:
2026-08-28 17:42:35 +00:00
parent 6910ccad73
commit f962d70e3b
7 changed files with 238 additions and 58 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ msgspec Structs for the kanta database. See `docs/content-model.md` for the full
## `markdown.py` ## `markdown.py`
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three in-body h1/h2 headings, each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `<img>` HTML stay plain. A `{dates}` line expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`; left literal in previews of unsaved pages). markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside `::: aside`, never participate), each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; anchored headings also carry `data-line` with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `<img>` HTML stay plain. A `{dates}` line expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`; left literal in previews of unsaved pages).
`render()` returns a `Rendered(html, multicol)`: the article content segmented for the column layout (there is no wrapper div — segments and bare blocks are direct `<article>` children) — h1/h2 headings, `.wide` blocks and margin-breakout blocks (`.margin`, `::: aside`) stand bare, the runs between them become `<div class="colseg">` (plus `.cols` on segments with enough text, `::: nocols` opting out), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation). `render()` returns a `Rendered(html, multicol)`: the article content segmented for the column layout (there is no wrapper div — segments and bare blocks are direct `<article>` children) — h1/h2 headings, `.wide` blocks and margin-breakout blocks (`.margin`, `::: aside`) stand bare, the runs between them become `<div class="colseg">` (plus `.cols` on segments with enough text, `::: nocols` opting out), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation).
+1 -1
View File
@@ -46,7 +46,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
- Editing happens **in place**, in two modes opened by two pens: - Editing happens **in place**, in two modes opened by two pens:
- **Page mode** — the 🖊️ next to a page's heading (including 404s, which is how new pages start) opens a CodeMirror Markdown editor docked to the left of the article: the panel is fixed to the viewport's left edge (its top tracks the banner's bottom until the banner scrolls away), the content shifts right and the sidebar hides while editing. Preview renders server-side per keystroke (no debouncing) and swaps the whole visible article content in one go (the edit pen and category cards survive the swap). - **Page mode** — the 🖊️ next to a page's heading (including 404s, which is how new pages start) opens a CodeMirror Markdown editor docked to the left of the article: the panel is fixed to the viewport's left edge (its top tracks the banner's bottom until the banner scrolls away), the content shifts right and the sidebar hides while editing. Preview renders server-side per keystroke (no debouncing) and swaps the whole visible article content in one go (the edit pen and category cards survive the swap).
- **Site mode** — the 🖊 on the banner opens a panel with the site **brand** (applied to the header live), a **theme** selector (swapping the theme stylesheet in place), **font** picks (heading/body/brand — stored as plain `:root` rows inside the custom CSS, referencing the base stylesheet's per-family font variables), a **site-wide custom CSS** field (injected into `<style id="pagerite-user">` in the live page head and swapped during fetch-navigation), the page's **banner design** selector (inherit / none / any design found on disk, inherited by children), the page's **banner HTML** field (supplementing the design, previewed into the real banner region, so you see exactly which banner you're editing) and the **structure tree**. Everything saves immediately as you edit — no save button, no edit mode. - **Site mode** — the at the top right (after the 📊 analytics link, before login) opens a panel with the site **brand** (applied to the header live), a **theme** selector (swapping the theme stylesheet in place), **font** picks (heading/body/brand — stored as plain `:root` rows inside the custom CSS, referencing the base stylesheet's per-family font variables), a **site-wide custom CSS** field (injected into `<style id="pagerite-user">` in the live page head and swapped during fetch-navigation), the page's **banner design** selector (inherit / none / any design found on disk, inherited by children), the page's **banner HTML** field (supplementing the design, previewed into the real banner region, so you see exactly which banner you're editing) and the **structure tree**. Everything saves immediately as you edit — no save button, no edit mode.
- Clicking a pen again closes the editor (without saving; a dirty preview reloads the page). The pens are `<button>`s wired up by `pagerite.js` — editing is an action, not a navigation. The editor's WebSocket **reconnects automatically** with local text and pending saves preserved. (All users are trusted authors for now; access control later with SSO.) - Clicking a pen again closes the editor (without saving; a dirty preview reloads the page). The pens are `<button>`s wired up by `pagerite.js` — editing is an action, not a navigation. The editor's WebSocket **reconnects automatically** with local text and pending saves preserved. (All users are trusted authors for now; access control later with SSO.)
- **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published controls. Images can be pasted straight into the editor or chosen via a file input: they upload to the content store (`PUT /_api/files/...`) and insert `![alt](/_f/hash.ext)` at the cursor. - **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published controls. Images can be pasted straight into the editor or chosen via a file input: they upload to the content store (`PUT /_api/files/...`) and insert `![alt](/_f/hash.ext)` at the cursor.
- The **structure panel** (vue-draggable tree of the whole site, in site mode) covers page management: reorder any menu level, drag across sections, add, delete (two clicks: the button arms, then deletes — no dialogs). Every node is a real label — content-less category rows offer a to give them a landing page. Deleting a category removes only its landing page (the label and its subpages stay). Every non-empty list ends with a row that starts a new page as a local-only tree row at that level; the row can be dragged into place before its title and slug are filled in and is persisted only on commit. While dragging, these rows double as "end of this list" drop targets; dropping ON the lower part of a row makes the page that row's first child (even a leaf's, creating a sublist), while a row's exposed top edge inserts a sibling before it. A dragged row's indentation previews the target list's depth. Rows are always editable: titles save while typing, slug edits commit on blur/Enter since they rename the path (moving the whole subtree). The front page is the root row with an empty slug — renaming it away leaves no front page ("/" redirects to the first nav item), and giving another top-level row the empty slug makes it the front page. - The **structure panel** (vue-draggable tree of the whole site, in site mode) covers page management: reorder any menu level, drag across sections, add, delete (two clicks: the button arms, then deletes — no dialogs). Every node is a real label — content-less category rows offer a to give them a landing page. Deleting a category removes only its landing page (the label and its subpages stay). Every non-empty list ends with a row that starts a new page as a local-only tree row at that level; the row can be dragged into place before its title and slug are filled in and is persisted only on commit. While dragging, these rows double as "end of this list" drop targets; dropping ON the lower part of a row makes the page that row's first child (even a leaf's, creating a sublist), while a row's exposed top edge inserts a sibling before it. A dragged row's indentation previews the target list's depth. Rows are always editable: titles save while typing, slug edits commit on blur/Enter since they rename the path (moving the whole subtree). The front page is the root row with an empty slug — renaming it away leaves no front page ("/" redirects to the first nav item), and giving another top-level row the empty slug makes it the front page.
+2 -2
View File
@@ -6,12 +6,12 @@ The Vue editor is a single tabbed `EditorShell.vue` mounted in a host div create
The shell hosts four kept-alive tabs (ordered site-wide first — site, structure — then, after a visual break, the per-page tabs — article, banner): The shell hosts four kept-alive tabs (ordered site-wide first — site, structure — then, after a visual break, the per-page tabs — article, banner):
- `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked proportionally both ways, applied instantly (the window keeps scrolling normally while any editor is open — the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); a format bar offers Markdown helpers — bold/italic/code/link/table/image upload, with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path. - `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked piecewise-linearly, keyed on the section anchors' `data-line` (markdown source line the backend stamps on top-level anchored h1/h2s): the page follows the cursor (fractional, wrap-aware, anchored at a fixed window height), the editor follows page scroll with a progress-based viewport anchor, applied instantly (the window keeps scrolling normally while any editor is open — the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); anchored h2s carry their own edit pens that open the editor scrolled to that section; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload, with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path.
- `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`. - `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`.
- `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into `<head id="pagerite-user">`. - `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into `<head id="pagerite-user">`.
- `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row. - `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row.
Media uploads everywhere use the image icon buttons (pasting into the editor works too). The article, banner and site-settings pens are shorthands that open the shell on the matching tab; once open, clicking a pen switches tabs (and retargets the editors to the current page) instead of closing/remounting. The close button in the tab bar closes the shell (Escape too); tabs have no close buttons of their own. Closing only HIDES the shell — the Vue app stays mounted, so page-editor state (unsaved text included) survives until a real page reload; saving there is explicit (Ctrl+S) and refreshes the page regions in place. Admin panels never reload the page. Media uploads everywhere use the image icon buttons (pasting into the editor works too). The article, banner and site-settings pens are shorthands that open the shell on the matching tab; once open, clicking a pen switches tabs (and retargets the editors to the current page) instead of closing/remounting. The close button in the tab bar closes the shell (Escape too); tabs have no close buttons of their own. Closing only HIDES the shell — the Vue app stays mounted, so page-editor state (unsaved text included) survives until a real page reload; the editor always follows the URL, so fetch-navigating with the shell open (or before re-opening it) retargets it to the new page — unsaved text is stashed per path for the session and restored when returning, cleared on save. Saving there is explicit (Ctrl+S) and refreshes the page regions in place. Admin panels never reload the page.
In-place page re-rendering shared by the banner/site/structure tabs lives in `swapdoc.js` (`runScripts`/`loadPlain`: fetch a page, swap the dynamic regions, replaceState). It also exports `dropPageCache`, which the editor tabs call after any save that can alter the rendered HTML of other pages (theme, headings, structure, banners, site brand/CSS, favicon). Dropping the cache while editing avoids re-fetching every page immediately; the public runtime re-preloads visible links once the editor panel closes. In-place page re-rendering shared by the banner/site/structure tabs lives in `swapdoc.js` (`runScripts`/`loadPlain`: fetch a page, swap the dynamic regions, replaceState). It also exports `dropPageCache`, which the editor tabs call after any save that can alter the rendered HTML of other pages (theme, headings, structure, banners, site brand/CSS, favicon). Dropping the cache while editing avoids re-fetching every page immediately; the public runtime re-preloads visible links once the editor panel closes.
+163 -31
View File
@@ -5,11 +5,14 @@
// The socket connects when the editor is opened and reconnects with // The socket connects when the editor is opened and reconnects with
// exponential backoff after a failure; unsaved text and pending saves // exponential backoff after a failure; unsaved text and pending saves
// survive a disconnect. Editor and article (window) scrolls are linked // 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 // Saving (💾 / Ctrl+S) is explicit
// and refreshes the page regions in place — never a reload — so the editor // 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 // state (unsaved text included) also survives closing the shell. The editor
// only on a real page reload. // 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 { onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror' import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
@@ -95,6 +98,7 @@ function save() {
// editor; the save APIs (REST PUT / WS save) never delete on empty. // editor; the save APIs (REST PUT / WS save) never delete on empty.
return fetch(`/_api/pages/${path.value}`, { method: 'DELETE' }).then((res) => { return fetch(`/_api/pages/${path.value}`, { method: 'DELETE' }).then((res) => {
saveError.value = res.ok ? '' : '⚠️ changes could not be saved' saveError.value = res.ok ? '' : '⚠️ changes could not be saved'
if (res.ok) unsavedStash.delete(path.value)
}) })
} }
const msg = { const msg = {
@@ -219,7 +223,16 @@ function insertTable(cols, rows) {
view.focus() 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) { function openPath(p) {
if (dirty.value && path.value && p !== path.value) {
unsavedStash.set(path.value, view.state.doc.toString())
}
path.value = p path.value = p
send({ type: 'open', path: p }) send({ type: 'open', path: p })
} }
@@ -265,15 +278,21 @@ function onMessage(ev) {
if (msg.type === 'doc' && msg.path === path.value) { if (msg.type === 'doc' && msg.path === path.value) {
title.value = msg.title title.value = msg.title
published.value = msg.published 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() 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) { } else if (msg.type === 'html' && msg.path === path.value) {
previewIntoArticle(msg.html, msg.multicol) previewIntoArticle(msg.html, msg.multicol)
} else if (msg.type === 'saved') { } else if (msg.type === 'saved') {
saveError.value = '' saveError.value = ''
pendingSave = null pendingSave = null
dirty.value = false dirty.value = false
unsavedStash.delete(path.value)
savedResolve?.() savedResolve?.()
savedResolve = null savedResolve = null
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
@@ -305,31 +324,104 @@ function onKeydown(ev) {
function onEditorShown() { function onEditorShown() {
if (document.body.dataset.editorMode !== 'page') return if (document.body.dataset.editorMode !== 'page') return
updateWindowTitle() 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 // Piecewise-linear scroll sync between the CodeMirror scroller and the
// and the window (the article's scroller, also while editing). Both // window (the article's scroller, also while editing), keyed on the
// directions apply instantly (never smooth — a smooth window scroll feeds // section anchors: the backend tags anchored h1/h2 headings with
// its intermediate positions back into the editor and fights the user's // data-line (markdown source line), so each heading pairs a document
// scrolling) and coalesce to one update per frame. Loops are broken two // position with a page position, and positions interpolate linearly
// ways: a driver flag held until one frame AFTER the write (the scroll // between neighbouring headings. Endpoints are the article top (line 1)
// event a programmatic write dispatches arrives asynchronously — clearing // and the document bottom (last line).
// 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 // Editor → page follows the CURSOR, not the editor viewport: the cursor's
// whenever layout shifted the proportional targets mid-scroll), and a 1px // fractional line (soft-wrap included, so moving inside a wrapped
// tolerance so residual rounding is a no-op. When the panel's height // paragraph tracks smoothly) maps to its page position, shown at a fixed
// changes mid-scroll (its top tracks the banner), the page is the driver: // anchor height in the window — the cursor on the last line lands at the
// the editor is re-matched to the page's position, never vice versa. // 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() { function syncWindowToEditor() {
if (syncingScroll || !view) return if (syncingScroll || !view) return
syncingScroll = true syncingScroll = true
requestAnimationFrame(() => { requestAnimationFrame(() => {
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 scroller = view.scrollDOM
const max = scroller.scrollHeight - scroller.clientHeight const block = view.lineBlockAt(pos)
const pct = max > 0 ? scroller.scrollTop / max : 0 const docY = coords.top - scroller.getBoundingClientRect().top + scroller.scrollTop
const y = pct * Math.max(0, document.documentElement.scrollHeight - innerHeight) const frac = block.height > 0
if (Math.abs(scrollY - y) > 1) scrollTo({ top: y, behavior: 'instant' }) ? 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 }) requestAnimationFrame(() => { syncingScroll = false })
}) })
} }
@@ -338,15 +430,47 @@ function syncEditorToWindow() {
if (syncingScroll || !view) return if (syncingScroll || !view) return
syncingScroll = true syncingScroll = true
requestAnimationFrame(() => { requestAnimationFrame(() => {
const scroller = view.scrollDOM 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 pageMax = Math.max(0, document.documentElement.scrollHeight - innerHeight)
const pct = pageMax > 0 ? scrollY / pageMax : 0 const a = pageMax > 0 ? scrollY / pageMax : 0
const top = pct * Math.max(0, scroller.scrollHeight - scroller.clientHeight) const line = interp(pts, scrollY + a * innerHeight, 1, 0)
if (Math.abs(scroller.scrollTop - top) > 1) scroller.scrollTop = top 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 }) 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() { function connect() {
ws = new WebSocket( ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`, `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
@@ -386,7 +510,11 @@ onMounted(() => {
cmTheme, cmTheme,
cmHighlight, cmHighlight,
EditorView.lineWrapping, // Markdown lines are long: soft-wrap them 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({ EditorView.domEventHandlers({
paste(ev) { paste(ev) {
// Paste an image straight into the article: upload + insert // Paste an image straight into the article: upload + insert
@@ -402,9 +530,10 @@ onMounted(() => {
}), }),
parent: editorEl.value, parent: editorEl.value,
}) })
view.scrollDOM.addEventListener('scroll', syncWindowToEditor)
// Page → editor: window scroll (and resizes, e.g. the panel growing when // 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 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('scroll', syncEditorToWindow, { passive: true })
addEventListener('resize', syncEditorToWindow) addEventListener('resize', syncEditorToWindow)
// Opening the editor means you want to write: start focused. // Opening the editor means you want to write: start focused.
@@ -416,6 +545,8 @@ onMounted(() => {
} }
addEventListener('keydown', onKeydown) addEventListener('keydown', onKeydown)
addEventListener('pagerite:editor-shown', onEditorShown) addEventListener('pagerite:editor-shown', onEditorShown)
// A section pen clicked while the page editor is already open.
addEventListener('pagerite:edit-section', consumePendingLine)
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -430,6 +561,7 @@ onUnmounted(() => {
removeEventListener('resize', syncEditorToWindow) removeEventListener('resize', syncEditorToWindow)
removeEventListener('keydown', onKeydown) removeEventListener('keydown', onKeydown)
removeEventListener('pagerite:editor-shown', onEditorShown) removeEventListener('pagerite:editor-shown', onEditorShown)
removeEventListener('pagerite:edit-section', consumePendingLine)
}) })
</script> </script>
@@ -634,8 +766,8 @@ onUnmounted(() => {
} }
/* CodeMirror sits inside a bordered box, like a dialog's input area, with /* 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 a slight margin to the panel edges. Wheel scroll stays in the editor
drives the article (syncWindowToEditor) instead of double-scrolling. */ (overscroll-behavior) instead of double-scrolling the page. */
.editor { .editor {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
+10
View File
@@ -667,6 +667,16 @@ article h1 .edit-link {
margin-left: 0.4rem; 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 { .edit-link:hover {
opacity: 1; opacity: 1;
} }
+40 -16
View File
@@ -69,19 +69,22 @@ import "overlayscrollbars/overlayscrollbars.css";
return map; return map;
})(); })();
function makePen(mode) { function makePen(mode, line) {
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.dataset.editorSrc = editorMeta.src; btn.dataset.editorSrc = editorMeta.src;
btn.dataset.editorCss = editorMeta.css || ""; btn.dataset.editorCss = editorMeta.css || "";
btn.dataset.editorMode = mode; btn.dataset.editorMode = mode;
if (mode === "page") { if (line != null) {
btn.className = "edit-link"; // Section pen on an anchored h2: opens the page editor at the
btn.title = "edit page"; // section's markdown source line (data-line, from the backend).
btn.className = "edit-link edit-section";
btn.title = "edit section";
btn.textContent = "🖊️"; btn.textContent = "🖊️";
} else if (mode === "banner") { btn.dataset.editorLine = line;
btn.className = "edit-link"; } else if (mode === "page") {
btn.title = "edit banner"; btn.className = "edit-link edit-page";
btn.title = "edit page";
btn.textContent = "🖊️"; btn.textContent = "🖊️";
} else { } else {
btn.className = "edit-link site-edit-link"; btn.className = "edit-link site-edit-link";
@@ -93,9 +96,17 @@ import "overlayscrollbars/overlayscrollbars.css";
function injectPagePen() { function injectPagePen() {
const article = document.querySelector("#main article"); 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")); 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) { function makeAuthLink(admin) {
@@ -131,8 +142,6 @@ import "overlayscrollbars/overlayscrollbars.css";
const pens = document.createElement("div"); const pens = document.createElement("div");
pens.className = "editor-pens"; pens.className = "editor-pens";
if (canEdit && !onAnalytics) { if (canEdit && !onAnalytics) {
pens.append(makePen("banner"));
pens.append(makePen("site"));
// Analytics viewer is now a normal page at /_a. // Analytics viewer is now a normal page at /_a.
const a = document.createElement("a"); const a = document.createElement("a");
a.className = "edit-link analytics-link"; a.className = "edit-link analytics-link";
@@ -140,6 +149,7 @@ import "overlayscrollbars/overlayscrollbars.css";
a.title = "analytics"; a.title = "analytics";
a.textContent = "📊"; a.textContent = "📊";
pens.append(a); pens.append(a);
pens.append(makePen("site"));
} }
if (ssoAvailable) pens.append(makeAuthLink(isAdmin)); if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
banner.after(pens); 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 // 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 // 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() { function placeEditPen() {
const article = document.querySelector("#main article"); const article = document.querySelector("#main article");
const btn = article?.querySelector("button.edit-link"); const btn = article?.querySelector("button.edit-page");
// First visible h1: the title h1 may be display:none when the const h1 = article?.querySelector("h1");
// markdown owns its heading (editor preview state).
const h1 = [...(article?.querySelectorAll("h1") || [])]
.find((h) => h.offsetParent !== null);
if (btn && h1 && btn.parentElement !== h1) h1.append(btn); if (btn && h1 && btn.parentElement !== h1) h1.append(btn);
} }
@@ -690,6 +697,13 @@ import "overlayscrollbars/overlayscrollbars.css";
} }
currentPath = new URL(finalUrl, location.href).pathname; currentPath = new URL(finalUrl, location.href).pathname;
if (push) history.pushState(null, "", finalUrl); 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 // Cross-page anchor links scroll to the section after the swap (the
// browser only does this itself on full page loads). // browser only does this itself on full page loads).
const hash = new URL(finalUrl, location.href).hash; const hash = new URL(finalUrl, location.href).hash;
@@ -710,9 +724,19 @@ import "overlayscrollbars/overlayscrollbars.css";
if (editBtn && editBtn.dataset.editorSrc) { if (editBtn && editBtn.dataset.editorSrc) {
ev.preventDefault(); ev.preventDefault();
const mode = editBtn.dataset.editorMode || "page"; 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") if (document.body.classList.contains("editing")
&& document.body.dataset.editorMode === mode) { && document.body.dataset.editorMode === mode) {
if (line != null) {
dispatchEvent(new CustomEvent("pagerite:edit-section"));
} else {
editorModule?.then((m) => m.closeEditor()); editorModule?.then((m) => m.closeEditor());
}
return; return;
} }
for (const css of (editBtn.dataset.editorCss || "").split(",")) { for (const css of (editBtn.dataset.editorCss || "").split(",")) {
+18 -4
View File
@@ -340,9 +340,12 @@ def _heading_ids(state) -> None:
author-set `{#id}` always wins; auto ids slugify the heading text author-set `{#id}` always wins; auto ids slugify the heading text
(python-slugify, mirroring the editor's slugify.js) and dedupe with (python-slugify, mirroring the editor's slugify.js) and dedupe with
-2/-3 suffixes per render. Headings that already contain a link are -2/-3 suffixes per render. Headings that already contain a link are
left unwrapped. Deeper headings (h3+) are never navigable. ``data-line`` records the heading's markdown source line (0-based, after
undoing the render(title=...) injection offset via ``env``) — the page
editor uses it for section pens and piecewise-linear scroll sync.
""" """
tokens = state.tokens tokens = state.tokens
line_offset = state.env.get("line_offset", 0)
def wrap(i: int, token, href: str) -> None: def wrap(i: int, token, href: str) -> None:
inline = tokens[i + 1] inline = tokens[i + 1]
@@ -353,8 +356,14 @@ def _heading_ids(state) -> None:
inline.children = [anchor, *inline.children, Token("link_close", "a", -1)] inline.children = [anchor, *inline.children, Token("link_close", "a", -1)]
# The first in-body h1 is the title: href="" self-link, never an id. # The first in-body h1 is the title: href="" self-link, never an id.
# Only TOP-LEVEL headings participate — h1/h2 nested in ::: containers
# or asides (level > 0) get no anchors, data-lines or pens.
first_h1 = next( first_h1 = next(
(i for i, t in enumerate(tokens) if t.type == "heading_open" and t.tag == "h1"), (
i
for i, t in enumerate(tokens)
if t.type == "heading_open" and t.tag == "h1" and t.level == 0
),
None, None,
) )
if first_h1 is not None: if first_h1 is not None:
@@ -363,7 +372,7 @@ def _heading_ids(state) -> None:
heads = [ heads = [
(i, token) (i, token)
for i, token in enumerate(tokens) for i, token in enumerate(tokens)
if token.type == "heading_open" and token.tag in ("h1", "h2") and i != first_h1 if token.type == "heading_open" and token.tag in ("h1", "h2") and token.level == 0 and i != first_h1
] ]
if len(heads) < ANCHOR_MIN_HEADINGS: if len(heads) < ANCHOR_MIN_HEADINGS:
return return
@@ -383,6 +392,8 @@ def _heading_ids(state) -> None:
n += 1 n += 1
token.attrSet("id", hid) token.attrSet("id", hid)
seen.add(hid) seen.add(hid)
if token.map:
token.attrSet("data-line", str(max(0, token.map[0] - line_offset)))
wrap(i, token, f"#{hid}") wrap(i, token, f"#{hid}")
@@ -517,9 +528,12 @@ def render(
e.g. the editor preview). Position is the author's choice — typically e.g. the editor preview). Position is the author's choice — typically
right after the article's h1. right after the article's h1.
""" """
env = {"page_path": page_path} env = {"page_path": page_path, "line_offset": 0}
if title and not has_h1(text): if title and not has_h1(text):
text = f"# {title}\n\n{text}" text = f"# {title}\n\n{text}"
# The injected title shifts source lines by two; _heading_ids
# subtracts this from its data-line attributes.
env["line_offset"] = 2
blocks = _top_level_blocks(md.parse(text, env)) blocks = _top_level_blocks(md.parse(text, env))
# Group consecutive non-boundary blocks into segments (is_segment, # Group consecutive non-boundary blocks into segments (is_segment,
# flat tokens); boundary blocks stand on their own between them. # flat tokens); boundary blocks stand on their own between them.