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:
+1
-1
@@ -18,7 +18,7 @@ msgspec Structs for the kanta database. See `docs/content-model.md` for the full
|
||||
|
||||
## `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).
|
||||
|
||||
|
||||
@@ -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:
|
||||
- **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.)
|
||||
- **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 `` 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.
|
||||
|
||||
+2
-2
@@ -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):
|
||||
|
||||
- `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`.
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+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(",")) {
|
||||
|
||||
+18
-4
@@ -340,9 +340,12 @@ def _heading_ids(state) -> None:
|
||||
author-set `{#id}` always wins; auto ids slugify the heading text
|
||||
(python-slugify, mirroring the editor's slugify.js) and dedupe with
|
||||
-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
|
||||
line_offset = state.env.get("line_offset", 0)
|
||||
|
||||
def wrap(i: int, token, href: str) -> None:
|
||||
inline = tokens[i + 1]
|
||||
@@ -353,8 +356,14 @@ def _heading_ids(state) -> None:
|
||||
inline.children = [anchor, *inline.children, Token("link_close", "a", -1)]
|
||||
|
||||
# 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(
|
||||
(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,
|
||||
)
|
||||
if first_h1 is not None:
|
||||
@@ -363,7 +372,7 @@ def _heading_ids(state) -> None:
|
||||
heads = [
|
||||
(i, token)
|
||||
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:
|
||||
return
|
||||
@@ -383,6 +392,8 @@ def _heading_ids(state) -> None:
|
||||
n += 1
|
||||
token.attrSet("id", hid)
|
||||
seen.add(hid)
|
||||
if token.map:
|
||||
token.attrSet("data-line", str(max(0, token.map[0] - line_offset)))
|
||||
wrap(i, token, f"#{hid}")
|
||||
|
||||
|
||||
@@ -517,9 +528,12 @@ def render(
|
||||
e.g. the editor preview). Position is the author's choice — typically
|
||||
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):
|
||||
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))
|
||||
# Group consecutive non-boundary blocks into segments (is_segment,
|
||||
# flat tokens); boundary blocks stand on their own between them.
|
||||
|
||||
Reference in New Issue
Block a user