Unify admin editors into a tabbed EditorShell (site, structure, article, banner)

- One mounted shell with four kept-alive tabs; pens open/switch tabs,
  closing only hides the shell so unsaved page-editor state survives
  until a real reload; admin panels never reload the page, regions are
  refreshed in place via the shared swapdoc.js helper
- SiteEditor split: site settings vs. the new StructureEditor tree tab;
  BannerEditor split out of the old site editor
- Article editor format bar (bold/italic/code/link/table/image) with a
  table size picker and Ctrl/Cmd-B/I/S bindings; fenced code block on an
  empty line; icon-only save button disabled (saturate(0)) when clean
- Media uploads use icon buttons; favicon upload by clicking the preview
  tile; placeholders reserved for actual defaults in effect
- Banner/site pens and auth buttons grouped in an .editor-pens container;
  page pen no longer shown to anonymous visitors
- While editing, window scroll is locked, the panel exactly fills the
  available window height, and only #main scrolls; editor scroll drives
  the article
- Banner design inherit label names the design and its true source;
  backend banner_design_source excludes the node's own setting and the
  doc payload carries banner_design_inherited
This commit is contained in:
2026-08-19 00:14:05 +00:00
parent 43b841ebb6
commit 4dac16a9ac
13 changed files with 1673 additions and 1019 deletions
+57 -36
View File
@@ -1,9 +1,14 @@
// Pagerite editor entries. Two separate apps, mounted in their own
// dynamically created host divs inside the static document:
// - PageEditor ("page" mode): pen next to an article heading — Markdown
// editing with the preview rendered into the visible article.
// - SiteEditor ("site" mode): pen on the banner — banner HTML editing
// (previewed into the real banner) and the site structure tree.
// Pagerite editor entry. A single tabbed EditorShell is mounted in a
// dynamically created host div inside the static document. The shell hosts
// four tabs: PageEditor (Markdown + preview), BannerEditor (per-page banner
// HTML/design), SiteEditor (site-wide config), and StructureEditor (the
// site structure tree).
//
// Individual pens are shorthands that open the shell on a particular tab;
// once the shell is open, pens switch tabs instead of closing/remounting.
// Closing hides the shell but keeps the Vue app mounted, so editor state —
// including unsaved page text — survives and editing can continue on the
// next pen click; it is lost only on a real page reload.
if (import.meta.env.DEV) {
// Base styles only; the theme CSS is imported by pagerite.js (which
// always runs first — the editor opens from public pages).
@@ -11,26 +16,36 @@ if (import.meta.env.DEV) {
}
import { createApp } from 'vue'
import PageEditor from './PageEditor.vue'
import SiteEditor from './SiteEditor.vue'
import EditorShell from './EditorShell.vue'
let host = null
let app = null
let savedTitle = null
let visible = false
export function openEditor(path, { mode = 'page' } = {}) {
closeEditor()
if (app) {
// Shell already mounted: re-show it if hidden, switch to the requested
// tab and retarget the editors to the current page (the shell survives
// fetch-navigation).
if (!visible) showEditor()
document.body.dataset.editorMode = mode
dispatchEvent(new CustomEvent('pagerite:switch-editor', { detail: { mode, path } }))
return
}
savedTitle = document.title
host = document.createElement('div')
host.className = 'editor-host'
// Docked inside #content: below the banner, next to the article only.
document.getElementById('content').prepend(host)
document.body.classList.add('editing')
// Which kind of editor is open; pagerite.js uses this to decide
// whether a pen click closes the panel or swaps in the other editor.
// Which tab is active; pagerite.js uses this to decide whether a pen click
// closes the panel or switches tabs.
document.body.dataset.editorMode = mode
app = createApp(mode === 'site' ? SiteEditor : PageEditor, {
visible = true
app = createApp(EditorShell, {
pagePath: path,
initialMode: mode,
onClose: closeEditor,
})
app.mount(host)
@@ -45,33 +60,39 @@ export function openEditor(path, { mode = 'page' } = {}) {
})
}
function showEditor() {
savedTitle = document.title
host.style.display = ''
host.firstElementChild?.classList.remove('closing')
document.body.classList.add('editing')
visible = true
dispatchEvent(new CustomEvent('pagerite:editor-shown'))
}
export function closeEditor() {
if (!host) return
if (!visible) return
visible = false
document.body.classList.remove('editing')
delete document.body.dataset.editorMode
// Slide the panel out in sync with the page shifting back.
// dataset.editorMode is kept while hidden: the tabs use it to tell whether
// a pagerite:editor-shown event targets them.
// Slide the panel out in sync with the page shifting back, then hide it.
host.firstElementChild?.classList.add('closing')
const old = host
const oldApp = app
const h = host
setTimeout(() => { h.style.display = 'none' }, 250)
dispatchEvent(new CustomEvent('pagerite:editor-hidden'))
// Restore the server-rendered title for the current URL. Re-fetching makes
// sure a brand change in the site editor or an in-place navigation leaves
// the correct public title behind.
const restoreTitle = savedTitle
host = null
app = null
savedTitle = null
setTimeout(() => {
oldApp?.unmount()
old.remove()
if (host) return // a new editor has opened; its title is authoritative
// Restore the server-rendered title for the current URL. Re-fetching makes
// sure a brand change in the site editor or an in-place navigation leaves
// the correct public title behind.
fetch(location.pathname)
.then((r) => r.text())
.then((html) => {
const doc = new DOMParser().parseFromString(html, 'text/html')
if (doc.title) document.title = doc.title
})
.catch(() => {
if (restoreTitle != null) document.title = restoreTitle
})
}, 250)
fetch(location.pathname)
.then((r) => r.text())
.then((html) => {
if (visible) return // reopened meanwhile; the editor owns the title
const doc = new DOMParser().parseFromString(html, 'text/html')
if (doc.title) document.title = doc.title
})
.catch(() => {
if (!visible && restoreTitle != null) document.title = restoreTitle
})
}