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
+415
View File
@@ -0,0 +1,415 @@
<script setup>
// Banner editor tab: per-page banner HTML and banner design, previewed into
// the real #page-banner region. Close and tab switching live in EditorShell.
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme'
import { loadPlain, runScripts } from './swapdoc'
const props = defineProps({
pagePath: { type: String, default: '' },
})
// close/path-change are wired by EditorShell; this tab never emits them.
defineEmits(['close', 'pathChange'])
const path = ref('')
const banner = ref('')
const saveError = ref('')
const fileInput = ref(null)
const bannerEl = ref(null)
let ws = null
let pendingSave = null
let reconnectTimer = null
let reconnectDelay = 2000
const MAX_RECONNECT_DELAY = 16000
let everConnected = false
let view = null // CodeMirror for the banner HTML
let syncing = false // set while replacing the document programmatically
// Banner design selector options come from the backend via settings.
const theme = ref('')
const bannerDesign = ref(null)
const bannerDesignFrom = ref(null)
const bannerDesigns = ref([])
// Where an empty banner code field falls back to (null = nowhere: the
// banner is just the design artwork), shown as a caption under the field.
const bannerFrom = ref(null)
// The design that "inherit" resolves to and where it comes from
// (bannerDesignFrom: an ancestor path, "" = the front page, null = the
// active theme's default).
const bannerDesignInherited = ref('')
// One-shot callback run on the next save ack (set by onBannerDesignChange,
// whose re-render must not race the save it triggers).
let refreshOnSave = null
// The inherit option names the design actually in effect and its source.
const inheritLabel = computed(() => {
if (bannerDesignFrom.value === null) {
return `— (from ${theme.value || 'none'})`
}
const where = bannerDesignFrom.value === ''
? 'the front page'
: `/${bannerDesignFrom.value}`
return `— (${bannerDesignInherited.value || 'none'} from ${where})`
})
function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg))
} else {
ensureConnected()
}
}
function ensureConnected() {
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
connect()
}
}
const timers = {}
function debounce(key, fn, ms = 600) {
clearTimeout(timers[key])
timers[key] = setTimeout(fn, ms)
}
function save() {
const msg = { type: 'save', path: normPath(path.value), banner: banner.value }
pendingSave = msg
send(msg)
}
function openPath(p) {
path.value = p
send({ type: 'open', path: p })
}
watch(() => props.pagePath, (p) => { openPath(normPath(p)) })
function updateWindowTitle() {
document.title = `banner: /${path.value} 🖊️`
}
onActivated(() => {
updateWindowTitle()
if (banner.value.trim()) previewBanner()
})
// The shell stays mounted while hidden: when it is re-shown with this tab
// active, restore the window title and banner preview.
function onEditorShown() {
if (document.body.dataset.editorMode !== 'banner') return
updateWindowTitle()
if (banner.value.trim()) previewBanner()
}
async function loadSettings() {
try {
const s = await (await fetch('/_api/settings')).json()
theme.value = s.theme || ''
bannerDesigns.value = s.banner_designs || []
} catch { /* keep default */ }
}
// Re-render the page from the server (after banner design changes), then
// re-overlay the page's own banner code if one is being edited.
async function rerender() {
if (await loadPlain(path.value)) {
if (banner.value.trim()) previewBanner()
}
}
// --- Banner design ---------------------------------------------------------
function onBannerDesignChange() {
const msg = {
type: 'save',
path: normPath(path.value),
banner_design: bannerDesign.value,
}
pendingSave = msg
send(msg)
refreshOnSave = rerender
}
// --- Banner editing ------------------------------------------------------
function setDocument(text) {
syncing = true
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
syncing = false
banner.value = text
}
function previewBanner() {
const el = document.getElementById('page-banner')
if (!el) return
if (banner.value.trim()) {
// Own banner code supplements the design: the inlined design artwork
// (marked with [data-design]) is detached while the author code is
// swapped in (so runScripts never re-runs the design's own scripts),
// then put back first — author code stays last so its styles win.
const artwork = [...el.querySelectorAll('[data-design]')]
for (const a of artwork) a.remove()
el.innerHTML = banner.value
runScripts(el)
el.prepend(...artwork)
} else {
// No banner code of its own: the region must show the inherited/design
// banner — re-render from the server (an empty write here would wipe it).
loadPlain(path.value)
}
}
function onBannerInput() {
previewBanner()
debounce('banner-html', save, 400)
}
function stripBannerMedia(html) {
// A banner has one piece of media: uploading replaces earlier img/video
// tags instead of stacking them. (Other HTML, e.g. canvas+script, stays.)
const doc = new DOMParser().parseFromString(html, 'text/html')
for (const el of doc.querySelectorAll('img, video')) el.remove()
return doc.body.innerHTML.trim()
}
async function uploadBannerMedia(file) {
// Banner media goes to the shared content store, like article images.
if (!file || !/^(image|video)\//.test(file.type)) return
const name = file.name.replace(/[^\w.-]/g, '-')
const res = await fetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
if (!res.ok) return
const { path: stored } = await res.json()
const tag = file.type.startsWith('video/')
? `<video src="${stored}" autoplay muted loop playsinline></video>`
: `<img src="${stored}" alt="">`
const rest = stripBannerMedia(banner.value)
setDocument(rest ? `${tag}\n${rest}` : tag)
previewBanner()
save()
}
function onBannerPaste(ev) {
const file = [...(ev.clipboardData?.files || [])]
.find((f) => /^(image|video)\//.test(f.type))
if (file) {
ev.preventDefault()
uploadBannerMedia(file)
}
}
function onMessage(ev) {
const msg = JSON.parse(ev.data)
if (msg.type === 'doc' && msg.path === path.value) {
setDocument(msg.banner ?? '')
bannerDesign.value = msg.banner_design ?? null
bannerDesignFrom.value = msg.banner_design_from ?? null
bannerDesignInherited.value = msg.banner_design_inherited ?? ''
bannerFrom.value = msg.banner_from ?? null
if (banner.value.trim()) previewBanner()
} else if (msg.type === 'saved') {
saveError.value = ''
pendingSave = null
refreshOnSave?.()
refreshOnSave = null
} else if (msg.type === 'error') {
saveError.value = '⚠️ changes could not be saved'
}
}
function onKeydown(ev) {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
ev.preventDefault()
save()
}
}
function connect() {
ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
)
ws.onmessage = onMessage
ws.onopen = () => {
reconnectDelay = 2000
if (everConnected) {
if (pendingSave) send(pendingSave)
} else {
openPath(normPath(props.pagePath))
}
everConnected = true
}
ws.onclose = () => {
clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
connect()
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}, reconnectDelay)
}
}
onMounted(async () => {
connect()
view = new EditorView({
state: EditorState.create({
doc: '',
extensions: [
basicSetup,
html(),
cmTheme,
cmHighlight,
EditorView.lineWrapping,
EditorView.updateListener.of((u) => {
if (u.docChanged && !syncing) {
banner.value = view.state.doc.toString()
onBannerInput()
}
}),
],
}),
parent: bannerEl.value,
})
addEventListener('keydown', onKeydown)
addEventListener('pagerite:editor-shown', onEditorShown)
await loadSettings()
})
onUnmounted(() => {
clearTimeout(reconnectTimer)
for (const t of Object.values(timers)) clearTimeout(t)
if (ws) {
ws.onclose = null // intentional close, no reconnect
ws.close()
}
view?.destroy()
removeEventListener('keydown', onKeydown)
removeEventListener('pagerite:editor-shown', onEditorShown)
})
</script>
<template>
<div class="banner-editor">
<div v-if="saveError">{{ saveError }}</div>
<section class="block" @paste="onBannerPaste">
<div class="block-head">
<select
v-model="bannerDesign"
class="text-input design-select"
title="Banner design (artwork + its own styles)"
@change="onBannerDesignChange"
>
<option :value="null">{{ inheritLabel }}</option>
<option value="">none</option>
<option v-for="d in bannerDesigns" :key="d" :value="d">{{ d }}</option>
</select>
<button
type="button"
class="icon-btn"
title="upload banner image/video (replaces existing media) — pasting works too"
@click="fileInput.click()"
>🖼</button>
<input
ref="fileInput"
type="file"
accept="image/*,video/*"
hidden
@change="(ev) => { uploadBannerMedia(ev.target.files[0]); ev.target.value = '' }"
/>
</div>
<div v-if="bannerFrom !== null" class="note">
left empty, the banner code is inherited from /{{ bannerFrom }}
</div>
<div ref="bannerEl" class="banner-cm" />
</section>
</div>
</template>
<style scoped>
.banner-editor {
display: flex;
flex-direction: column;
}
.block {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: var(--surface);
flex: 1;
min-height: 0;
}
.block-head {
display: flex;
align-items: center;
gap: 0.6rem;
}
.note {
color: var(--muted);
font-size: 0.8rem;
}
.block-head .icon-btn {
margin-left: auto;
padding: 0 0.2rem;
font-size: 1rem;
background: none;
border: none;
cursor: pointer;
opacity: 0.7;
}
.block-head .icon-btn:hover {
opacity: 1;
}
/* The banner design selector stays compact; the upload button is pushed
right by its auto margin. */
.design-select {
flex: 0 1 auto;
width: auto;
font-size: 0.85rem;
}
.text-input {
flex: 1;
min-width: 4rem;
font: inherit;
font-size: 0.9rem;
padding: 0.2rem 0.5rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 4px;
}
/* CodeMirror window for the banner HTML; fills the tab and scrolls
internally. */
.banner-cm {
flex: 1;
min-height: 0;
border: 1px solid var(--line);
border-radius: 4px;
overflow: hidden;
}
.banner-cm :deep(.cm-editor) {
height: 100%;
font-size: 0.85rem;
}
.banner-cm :deep(.cm-scroller) {
overflow: auto;
}
.banner-cm :deep(.cm-gutters) {
display: none;
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<script setup>
// Tabbed shell for the four admin editors. The individual pens are shorthands
// that open the shell on a given tab; once open, tabs switch instantly without
// closing the panel. Tabs are kept alive so switching preserves state.
import { onMounted, onUnmounted, provide, ref, watch } from 'vue'
import PageEditor from './PageEditor.vue'
import BannerEditor from './BannerEditor.vue'
import SiteEditor from './SiteEditor.vue'
import StructureEditor from './StructureEditor.vue'
const props = defineProps({
pagePath: { type: String, default: '' },
initialMode: { type: String, default: 'page' },
})
const emit = defineEmits(['close'])
const currentPath = ref(props.pagePath)
const activeMode = ref(props.initialMode)
// Tab order: site-wide settings first (site, structure), then — after a
// visual break — the per-page editors (article, banner).
const MODES = [
{ key: 'site', label: 'site', component: SiteEditor },
{ key: 'structure', label: 'structure', component: StructureEditor },
{ key: 'page', label: 'article', component: PageEditor, breakBefore: true },
{ key: 'banner', label: 'banner', component: BannerEditor },
]
function switchMode(mode) {
if (MODES.some((m) => m.key === mode)) activeMode.value = mode
}
function onPathChange(path) {
currentPath.value = path
}
function close() {
emit('close')
}
provide('editorShell', { switchMode })
watch(activeMode, (mode) => {
document.body.dataset.editorMode = mode
}, { immediate: true })
// A pen click while the shell is open (main.js) switches tabs, and also
// retargets the editors when the user fetch-navigated with the shell open.
function onSwitchEvent(ev) {
if (ev.detail?.path != null) currentPath.value = ev.detail.path
if (ev.detail?.mode) switchMode(ev.detail.mode)
}
// Closing the shell hides it but keeps it mounted (main.js); the tabs stay
// cached in KeepAlive the whole time, so no state is ever lost until a real
// page reload. On re-show each active tab re-applies its window title and
// preview via its own pagerite:editor-shown listener.
function onKeydown(ev) {
if (ev.key === 'Escape' && document.body.classList.contains('editing')) close()
}
onMounted(() => {
document.body.dataset.editorMode = activeMode.value
addEventListener('pagerite:switch-editor', onSwitchEvent)
addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
removeEventListener('pagerite:switch-editor', onSwitchEvent)
removeEventListener('keydown', onKeydown)
})
</script>
<template>
<div class="editor-root overlay">
<header class="editor-tabs">
<template v-for="m in MODES" :key="m.key">
<span v-if="m.breakBefore" class="tab-break" />
<button
type="button"
class="tab"
:class="{ active: activeMode === m.key }"
@click="switchMode(m.key)"
>
{{ m.label }}
</button>
</template>
<button type="button" class="close" title="close" @click="close"></button>
</header>
<div class="editor-tab-body">
<KeepAlive>
<component
:is="MODES.find((m) => m.key === activeMode).component"
:key="activeMode"
:page-path="currentPath"
@close="close"
@path-change="onPathChange"
/>
</KeepAlive>
</div>
</div>
</template>
<style scoped>
/* Docked-overlay positioning (sticky, height, slide-in) comes from the
global pagerite.css (.editor-root.overlay); the shell is a flex column so
the tab body fills what the tab bar leaves. */
.editor-root {
display: flex;
flex-direction: column;
}
.editor-tabs {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
flex-shrink: 0;
}
.editor-tabs .tab {
padding: 0.25rem 0.8rem;
font: inherit;
font-size: 0.9rem;
color: var(--muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
}
.editor-tabs .tab:hover {
color: var(--text);
}
/* Visual break between the site-wide tabs and the per-page tabs. */
.editor-tabs .tab-break {
align-self: stretch;
margin: 0.2rem 0.5rem;
border-left: 1px solid var(--line);
}
.editor-tabs .tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.editor-tabs .close {
margin-left: auto;
padding: 0 0.3rem;
background: none;
border: none;
color: var(--muted);
font-size: 1.05rem;
cursor: pointer;
}
.editor-tabs .close:hover {
color: var(--text);
}
.editor-tab-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* The active tab component's root fills the body. */
.editor-tab-body > * {
flex: 1;
min-height: 0;
}
</style>
+271 -50
View File
@@ -4,18 +4,23 @@
// (/_api/ws/editor). Docked left of the article on the page itself.
// 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 scroll drives the document scroll, keeping the
// rendered article at the cursor's position.
import { onMounted, onUnmounted, ref, watch } from 'vue'
// survive a disconnect. Editor scroll drives the article scroll (while
// editing the window scroll is locked and only #main scrolls), keeping the
// rendered article at the cursor's position. 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.
import { onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { markdown } from '@codemirror/lang-markdown'
import { cmHighlight, cmTheme } from './cmtheme'
import { loadPlain } from './swapdoc'
const props = defineProps({
pagePath: { type: String, default: '' },
})
const emit = defineEmits(['close'])
const emit = defineEmits(['close', 'pathChange'])
const path = ref('')
const title = ref('')
@@ -32,7 +37,7 @@ let reconnectTimer = null
let reconnectDelay = 2000
const MAX_RECONNECT_DELAY = 16000
let everConnected = false
let dirty = false
const dirty = ref(false) // unsaved text exists (drives the 💾 button)
let syncingScroll = false
function send(msg) {
@@ -65,16 +70,24 @@ function updateWindowTitle() {
}
watch([title, path], updateWindowTitle)
watch(() => props.pagePath, (p) => { openPath(normPath(p)) })
onActivated(() => {
updateWindowTitle()
// Re-shown with unsaved text: restore the working preview into the
// article (closing discarded it in favour of the server-rendered page).
if (dirty.value) requestRender()
})
function requestRender() {
// No debounce: server-side rendering is fast enough per keystroke.
if (!view) return
dirty = true
dirty.value = true
send({ type: 'render', path: path.value, markdown: view.state.doc.toString() })
}
function save() {
// Path is not editable here (that's the site editor's job); saving
// Path is not editable here (that's the structure tab's job); saving
// never moves the page.
const markdown = view.state.doc.toString()
if (markdown.trim() === '') {
@@ -96,20 +109,20 @@ function save() {
return new Promise((resolve) => { savedResolve = resolve })
}
async function saveAndClose() {
async function saveAndRefresh() {
await save()
// Reload so nav/sidebar changes apply, then the editor is gone.
dirty = false
emit('close')
location.reload()
dirty.value = false
// Refresh the page regions from the server so nav/sidebar changes apply
// (never a reload: the editor keeps its state).
loadPlain(path.value)
}
function close() {
// Reload if the visible page is showing unsaved preview edits.
const stale = dirty
dirty = false
emit('close')
if (stale) location.reload()
// Discard the unsaved preview by re-rendering the page from the server.
// The editor text itself is kept (the shell stays mounted while hidden)
// and can still be saved later.
if (dirty.value) loadPlain(path.value)
}
function insertAtCursor(text) {
@@ -128,6 +141,82 @@ async function uploadImage(file) {
}
}
// --- Format toolbar --------------------------------------------------------
// Small Markdown helpers for the hard-to-remember syntax; each leaves the
// relevant part selected so typing replaces it.
function wrapInline(mark) {
// Toggle: wrapped selection (or wrapping marks around it) is unwrapped.
const { from, to } = view.state.selection.main
const doc = view.state.doc
if (doc.sliceString(Math.max(0, from - mark.length), from) === mark
&& doc.sliceString(to, to + mark.length) === mark) {
view.dispatch({ changes: [
{ from: to, to: to + mark.length },
{ from: from - mark.length, to: from },
] })
} else {
const text = doc.sliceString(from, to)
view.dispatch({
changes: { from, to, insert: mark + text + mark },
selection: { anchor: from + mark.length, head: to + mark.length },
})
}
view.focus()
}
function insertCode() {
// On an empty line with no selection: a fenced code block, cursor inside.
// Otherwise an inline code wrap (toggling).
const { from, to } = view.state.selection.main
const line = view.state.doc.lineAt(from)
if (from === to && !line.text.trim()) {
view.dispatch({
changes: { from: line.from, to: line.to, insert: '```\n\n```' },
selection: { anchor: line.from + 4 },
})
view.focus()
return
}
wrapInline('`')
}
function insertLink() {
// Selected text becomes the link label — or the URL if it looks like one.
const { from, to } = view.state.selection.main
const text = view.state.sliceDoc(from, to)
const isUrl = /^https?:\/\/\S+$/.test(text)
const insert = isUrl ? `[](${text})` : `[${text}]()`
const urlStart = from + insert.length - 1 // inside the parens
view.dispatch({
changes: { from, to, insert },
selection: isUrl ? { anchor: from + 1 } : { anchor: urlStart },
})
view.focus()
}
// Table size picker: a hover grid popup (cols × rows) under the toolbar.
const tablePicker = ref(false)
const tableSize = ref({ cols: 0, rows: 0 })
const TABLE_MAX_COLS = 8
const TABLE_MAX_ROWS = 6
function insertTable(cols, rows) {
// A GFM table on its own blank-separated block, first header cell
// selected.
const { from, to } = view.state.selection.main
const before = from > 0 && view.state.doc.sliceString(from - 1, from) !== '\n' ? '\n\n' : ''
const row = (cells) => `| ${cells.join(' | ')} |`
const table = `${before}${row(Array(cols).fill('column'))}\n`
+ `${row(Array(cols).fill('---'))}\n`
+ `${Array(rows).fill(row(Array(cols).fill(''))).join('\n')}\n`
view.dispatch({
changes: { from, to, insert: table },
selection: { anchor: from + before.length + 2, head: from + before.length + 8 },
})
tablePicker.value = false
view.focus()
}
function openPath(p) {
path.value = p
send({ type: 'open', path: p })
@@ -177,12 +266,13 @@ function onMessage(ev) {
published.value = msg.published
setDocument(msg.markdown)
requestRender()
dirty = false // just loaded from the server, nothing unsaved
dirty.value = false // just loaded from the server, nothing unsaved
} else if (msg.type === 'html' && msg.path === path.value) {
previewIntoArticle(msg.html, msg.has_h1)
} else if (msg.type === 'saved') {
saveError.value = ''
pendingSave = null
dirty.value = false
savedResolve?.()
savedResolve = null
} else if (msg.type === 'error') {
@@ -191,24 +281,45 @@ function onMessage(ev) {
}
function onKeydown(ev) {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
if (!(ev.ctrlKey || ev.metaKey)) return
if (ev.key === 's') {
ev.preventDefault()
save()
return
}
if (ev.key === 'Escape') close()
// Bold/italic only when typing in the CodeMirror editor itself.
if (!view?.hasFocus) return
if (ev.key === 'b') {
ev.preventDefault()
wrapInline('**')
} else if (ev.key === 'i') {
ev.preventDefault()
wrapInline('*')
}
}
// The shell stays mounted while hidden: when it is re-shown with this tab
// active, restore the window title (and the working preview if unsaved
// text exists — closing discarded it in favour of the server render).
function onEditorShown() {
if (document.body.dataset.editorMode !== 'page') return
updateWindowTitle()
if (dirty.value) requestRender()
}
function syncScroll() {
// Editor scroll drives the document: keep the rendered article at the
// same proportional position as the cursor area in the editor.
// Editor scroll drives the article: keep the rendered page at the same
// proportional position as the cursor area in the editor. While editing
// the window scroll is locked and #main is the scrolling element.
if (syncingScroll || !view) return
const main = document.getElementById('main')
if (!main) return
syncingScroll = true
requestAnimationFrame(() => {
const scroller = view.scrollDOM
const max = scroller.scrollHeight - scroller.clientHeight
const pct = max > 0 ? scroller.scrollTop / max : 0
const doc = document.documentElement
window.scrollTo(0, pct * (doc.scrollHeight - innerHeight))
main.scrollTop = pct * (main.scrollHeight - main.clientHeight)
syncingScroll = false
})
}
@@ -277,6 +388,7 @@ onMounted(() => {
path: () => path.value,
}
addEventListener('keydown', onKeydown)
addEventListener('pagerite:editor-shown', onEditorShown)
})
onUnmounted(() => {
@@ -288,13 +400,17 @@ onUnmounted(() => {
view?.destroy()
delete window.__pageritePageEditor
removeEventListener('keydown', onKeydown)
removeEventListener('pagerite:editor-shown', onEditorShown)
})
</script>
<template>
<div class="editor-root overlay">
<div class="page-editor">
<header class="toolbar">
<input v-model="title" placeholder="Title" class="title" @input="requestRender" />
<label class="title-field">
<span class="field-label">title</span>
<input v-model="title" class="title" @input="requestRender" />
</label>
<label><input v-model="published" type="checkbox" /> published</label>
<input
ref="fileInput"
@@ -303,10 +419,41 @@ onUnmounted(() => {
hidden
@change="(ev) => { uploadImage(ev.target.files[0]); ev.target.value = '' }"
/>
<button type="button" @click="fileInput.click()">image</button>
<button type="button" @click="saveAndClose">save</button>
<button type="button" class="close" title="close" @click="close"></button>
<button
type="button"
class="save"
title="save (Ctrl+S)"
:disabled="!dirty"
@click="saveAndRefresh"
>💾</button>
</header>
<div class="format-bar">
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
<button type="button" title="italic" @click="wrapInline('*')"><i>I</i></button>
<button type="button" title="code (empty line: code block)" @click="insertCode"><code>&lt;/&gt;</code></button>
<button type="button" title="link" @click="insertLink">🔗</button>
<button
type="button"
title="table"
:class="{ active: tablePicker }"
@click="tablePicker = !tablePicker"
></button>
<button type="button" title="insert image (upload) — pasting works too" @click="fileInput.click()">🖼</button>
<div v-if="tablePicker" class="table-picker" @mouseleave="tableSize = { cols: 0, rows: 0 }">
<div class="tp-grid" :style="{ gridTemplateColumns: `repeat(${TABLE_MAX_COLS}, 1fr)` }">
<button
v-for="n in TABLE_MAX_COLS * TABLE_MAX_ROWS"
:key="n"
type="button"
class="tp-cell"
:class="{ on: tableSize.cols >= (n - 1) % TABLE_MAX_COLS + 1 && tableSize.rows >= Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
@mouseenter="tableSize = { cols: (n - 1) % TABLE_MAX_COLS + 1, rows: Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
@click="insertTable(tableSize.cols, tableSize.rows)"
/>
</div>
<div class="tp-size">{{ tableSize.cols || '' }} × {{ tableSize.rows || '' }}</div>
</div>
</div>
<div v-if="saveError">{{ saveError }}</div>
<div class="panes">
<div ref="editorEl" class="editor" />
@@ -315,15 +462,11 @@ onUnmounted(() => {
</template>
<style scoped>
.editor-root {
.page-editor {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Docked-overlay positioning lives in the global pagerite.css (.editor-host /
.editor-root.overlay) since the host element is created by main.js. */
.toolbar {
display: flex;
align-items: center;
@@ -333,6 +476,19 @@ onUnmounted(() => {
background: var(--surface);
}
.toolbar .title-field {
flex: 1;
min-width: 4rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.toolbar .field-label {
color: var(--muted);
font-size: 0.85rem;
}
.toolbar .title {
flex: 1;
min-width: 4rem;
@@ -350,28 +506,93 @@ onUnmounted(() => {
white-space: nowrap;
}
.toolbar button {
font: inherit;
padding: 0.25rem 0.8rem;
background: var(--accent2);
color: white;
/* Borderless icon button; greyed out (desaturated) while there is nothing
to save. */
.toolbar button.save {
padding: 0 0.3rem;
font-size: 1rem;
background: none;
border: none;
color: var(--text);
cursor: pointer;
}
/* Disabled = black & white only; an explicit color overrides the browser's
built-in dimmed disabled-button color. */
.toolbar button.save:disabled {
color: var(--text);
filter: saturate(0);
cursor: default;
}
/* Markdown helpers: plain icon buttons under the toolbar. */
.format-bar {
position: relative;
display: flex;
align-items: center;
gap: 0.15rem;
padding: 0.25rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.format-bar button {
min-width: 1.7rem;
padding: 0.15rem 0.3rem;
font: inherit;
font-size: 0.85rem;
color: var(--muted);
background: none;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
/* Window-style close button, top right corner. */
.toolbar .close {
margin-left: auto;
padding: 0 0.3rem;
background: none;
color: var(--muted);
font-size: 1.05rem;
}
.toolbar .close:hover {
.format-bar button:hover,
.format-bar button.active {
color: var(--text);
border-color: var(--line);
}
/* Table size picker: hover grid popup below the format bar; the hovered
cell and everything up-left of it is the table to insert. */
.table-picker {
position: absolute;
top: 100%;
left: 6.5rem;
z-index: 20;
padding: 0.5rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 6px;
box-shadow: 0 4px 16px #0004;
}
.tp-grid {
display: grid;
gap: 2px;
}
.tp-cell {
width: 1.05rem;
height: 1.05rem;
min-width: 0;
padding: 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 2px;
}
.tp-cell.on {
background: var(--accent);
border-color: var(--accent);
}
.tp-size {
margin-top: 0.35rem;
color: var(--muted);
font-size: 0.8rem;
text-align: center;
}
.panes {
@@ -385,7 +606,7 @@ 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 document (syncScroll) instead of double-scrolling. */
drives the article (syncScroll) instead of double-scrolling. */
.editor {
flex: 1;
min-width: 0;
File diff suppressed because it is too large Load Diff
+322
View File
@@ -0,0 +1,322 @@
<script setup>
// Structure tab: the draggable site structure tree. Focusing a page's row
// navigates to it in place (no transitions). Close and tab switching live
// in EditorShell.
//
// The tree comes from the server nested (GET /_api/pages); every node is
// real — a label with a title and slug, with content (landing page) or
// without (category whose URL renders a placeholder page). The front page
// is a top-level row with an empty slug, not the parent of the others.
import { inject, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import StructureTree from './StructureTree.vue'
import { slugify } from './slugify'
import { loadPlain } from './swapdoc'
const props = defineProps({
pagePath: { type: String, default: '' },
})
const emit = defineEmits(['close', 'pathChange'])
const shell = inject('editorShell', null)
const path = ref('')
const saveError = ref('')
const tree = ref([])
function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
}
// Debounce per key: text edits save while typing, without a request per
// keystroke.
const timers = {}
function debounce(key, fn, ms = 600) {
clearTimeout(timers[key])
timers[key] = setTimeout(fn, ms)
}
function updateWindowTitle() {
document.title = 'site structure 🖊️'
}
watch(() => props.pagePath, (p) => { path.value = normPath(p) })
onActivated(updateWindowTitle)
// The shell stays mounted while hidden: when it is re-shown with this tab
// active, restore the window title.
function onEditorShown() {
if (document.body.dataset.editorMode === 'structure') updateWindowTitle()
}
// Tree row focus: switch the edited page and show it, skipping transitions.
async function navigate(p) {
path.value = p
emit('pathChange', p)
await loadPlain(p)
}
// If the currently edited page moved (rename/move of itself or an
// ancestor), follow it to the new path.
function followMove(oldPath, newPath) {
if (path.value === oldPath) navigate(newPath)
else if (oldPath && path.value.startsWith(`${oldPath}/`)) {
navigate(newPath + path.value.slice(oldPath.length))
}
}
// --- New page flow -------------------------------------------------------
// The row at the end of any list adds a *pending* row there: a
// local-only item that can be dragged into place before anything is
// filled in. It is persisted only on commit (✓/Enter), at wherever it
// currently sits.
const pending = ref(null)
function newPage(list) {
if (pending.value) return // one at a time
pending.value = {
slug: '',
path: '',
title: '',
order: 0,
published: true,
has_content: true,
children: [],
pending: true,
}
list.push(pending.value)
}
// Where does the pending row currently sit? -> {parentPath, list, index}.
function locatePending(nodes, parentPath) {
const i = nodes.indexOf(pending.value)
if (i >= 0) return { parentPath, list: nodes, i }
for (const n of nodes) {
const found = locatePending(n.children, n.path)
if (found) return found
}
return null
}
function discardPending() {
const loc = locatePending(tree.value, '')
if (loc) loc.list.splice(loc.i, 1)
pending.value = null
}
async function commitPending() {
const node = pending.value
if (!node) return
// Empty slug: derive one from the title (transliterated to ASCII).
const slug = slugify(node.slug.trim()) || slugify(node.title)
if (!slug) {
return
}
const loc = locatePending(tree.value, '')
const parentPath = loc?.parentPath ?? ''
const newPath = parentPath ? `${parentPath}/${slug}` : slug
const res = await fetch(`/_api/pages/${newPath}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: node.title.trim() || slug,
markdown: '', // empty markdown creates an empty page (never deletes)
published: true,
}),
})
if (!res.ok) {
// Show the server's reason (e.g. a reserved file name); the pending
// row stays so it can be edited and committed again.
saveError.value = `⚠️ ${await errorDetail(res)}`
return
}
// Place it exactly where the row was dropped: a fresh order key halfway
// between its new siblings (the PUT appended it at the end).
if (loc) {
const prev = loc.list[loc.i - 1]
const next = loc.list[loc.i + 1]
const order = prev && next ? (prev.order + next.order) / 2
: prev ? prev.order + 1
: next ? next.order - 1
: 1
await postStructure({ path: newPath, order })
} else {
saveError.value = ''
}
pending.value = null
await refreshPages()
await navigate(newPath)
// Hand over to the page editor tab for the actual writing.
shell?.switchMode('page')
}
// --- Site structure tree (drag-and-drop ordering/moving) ----------------
async function refreshPages() {
try {
tree.value = await (await fetch('/_api/pages')).json()
} catch { /* list stays stale; not fatal */ }
}
// Human-readable reason from a failed API call (FastAPI errors carry a
// JSON {detail}), falling back to a generic message.
async function errorDetail(res) {
const body = await res.json().catch(() => null)
return body?.detail || 'changes could not be saved'
}
async function postStructure(op) {
const res = await fetch('/_api/structure', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(op),
})
if (res.ok) {
saveError.value = ''
loadPlain(path.value) // refresh menus and content from the server
} else {
saveError.value = `⚠️ ${await errorDetail(res)}`
}
await refreshPages()
return res.ok
}
async function onReorder(parentPath, list, evt) {
// vuedraggable already mutated `list`; persist the moved item only: a
// fresh order key halfway between its new siblings (all other items
// keep theirs), plus the new path when the parent changed. The pending
// new-page row is local-only — its position is read at commit time.
const change = evt.moved || evt.added
if (!change) return
const el = change.element
if (el.pending) return
const i = change.newIndex
let prev, next
for (let j = i - 1; j >= 0 && !prev; j--) if (!list[j].pending) prev = list[j]
for (let j = i + 1; j < list.length && !next; j++) if (!list[j].pending) next = list[j]
const order = prev && next ? (prev.order + next.order) / 2
: prev ? prev.order + 1
: next ? next.order - 1
: 1
const newPath = parentPath ? `${parentPath}/${el.slug}` : el.slug
const op = { path: el.path, order }
if (newPath !== el.path) op.move_to = newPath
if (await postStructure(op) && op.move_to) followMove(el.path, op.move_to)
}
// Inline title/slug editing: rows are always editable. Title saves while
// typing (debounced); the slug commits on blur/Enter, since it renames
// the path (moving the whole subtree with it).
function onTitleInput(node, ev) {
const title = ev.target.value.trim()
if (!title || title === node.title) return
debounce(`title:${node.path}`, async () => {
await postStructure({ path: node.path, title })
})
}
// The slug inputs are filtered as you type (StructureTree onSlugInput,
// see slugify.js); the server re-validates and its reason is shown.
async function commitSlug(node, ev) {
const slug = ev.target.value.trim()
if (slug === node.slug) return
const parent = node.path.split('/').slice(0, -1).join('/')
// Empty slug at top level = the front page (path "").
const moveTo = parent ? (slug ? `${parent}/${slug}` : parent) : slug
if (await postStructure({ path: node.path, move_to: moveTo })) {
followMove(node.path, moveTo)
} else {
ev.target.value = node.slug // rename failed: put the old slug back
}
}
// Two-step delete (no dialogs): the first click arms the row's button for
// a few seconds, the second actually deletes.
const arming = ref(null)
let armTimer = null
function armRemove(node) {
if (arming.value === node.path) {
clearTimeout(armTimer)
arming.value = null
removePage(node)
} else {
arming.value = node.path
clearTimeout(armTimer)
armTimer = setTimeout(() => { arming.value = null }, 3000)
}
}
async function removePage(node) {
const res = await fetch(`/_api/pages/${node.path}`, { method: 'DELETE' })
if (res.ok) {
saveError.value = ''
refreshPages()
const p = node.path
if (p === path.value || (p && path.value.startsWith(`${p}/`))) {
// The current page was deleted — or reduced to a category, which now
// renders a placeholder page. Either way, re-render from the server.
if (node.children.length) loadPlain(path.value)
else navigate('')
} else {
loadPlain(path.value) // refresh menus
}
} else {
saveError.value = '⚠️ changes could not be saved'
}
}
provide('structureHandlers', {
current: () => path.value,
open: navigate,
arming: () => arming.value,
armRemove,
reorder: onReorder,
titleInput: onTitleInput,
commitSlug,
commitPending,
discardPending,
newPage,
})
onMounted(() => {
path.value = normPath(props.pagePath)
refreshPages()
addEventListener('pagerite:editor-shown', onEditorShown)
})
onUnmounted(() => {
clearTimeout(armTimer)
for (const t of Object.values(timers)) clearTimeout(t)
removeEventListener('pagerite:editor-shown', onEditorShown)
})
</script>
<template>
<div class="structure-editor">
<div v-if="saveError">{{ saveError }}</div>
<section class="block structure">
<StructureTree :nodes="tree" />
</section>
</div>
</template>
<style scoped>
.structure-editor {
display: flex;
flex-direction: column;
}
.block {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.structure {
flex: 1;
overflow-y: auto;
min-height: 0;
}
</style>
+4 -5
View File
@@ -1,6 +1,6 @@
<script setup>
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
// Nodes come from the server (GET /_api/pages via SiteEditor.vue) as
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
// {slug, path, title, order, published, has_content, children}.
// Every node is real: a label whose title and slug are always editable
// inline — the title saves while typing (and focusing it opens the page),
@@ -19,8 +19,8 @@
// The front page is the root row with an empty slug: renaming it away
// leaves no front page, and giving another top-level row the empty slug
// makes it the front page. Delete is a two-step inline button (no dialog).
// Actions are injected from SiteEditor.vue to avoid per-level event
// forwarding.
// Actions are injected from the parent editor (StructureEditor.vue) to
// avoid per-level event forwarding.
import { inject } from 'vue'
import draggable from 'vuedraggable'
import { slugify } from './slugify'
@@ -104,7 +104,7 @@ function onEnd() {
v-model="element.title"
v-focus
class="edit title-edit"
placeholder="Title"
title="Page title"
@keyup.enter="handlers.commitPending()"
@keyup.esc="handlers.discardPending()"
/>
@@ -126,7 +126,6 @@ function onEnd() {
<input
class="edit title-edit"
:value="element.title"
placeholder="Title"
title="Label in the navigation — saves while typing; click opens the page"
@input="handlers.titleInput(element, $event)"
@focus="handlers.open(element.path)"
+35 -23
View File
@@ -257,9 +257,28 @@ body.editing #sidebar {
display: none;
}
/* While editing, the window itself does not scroll: the editor panel is
exactly the remaining window height, and only the article area (#main)
scrolls. Without the editor, normal full-page scroll applies. */
body.editing {
height: 100vh;
overflow: hidden;
}
body.editing #content {
min-height: 0;
grid-template-rows: 100%;
}
body.editing #main {
height: 100%;
min-height: 0;
overflow-y: auto;
}
/* The editor host lives inside #content: it starts below the banner and
ends above the footer. The panel itself sticks to the viewport while
scrolling (but never taller than the content area). */
ends above the footer. With the page scroll locked while editing, the
panel exactly fills that space — the full available window height. */
.editor-host {
position: absolute;
top: 0;
@@ -269,10 +288,7 @@ body.editing #sidebar {
}
.editor-root.overlay {
position: sticky;
top: 0;
height: 100vh;
max-height: 100%;
height: 100%;
background: var(--bg);
animation: editor-slide-in 0.25s ease;
}
@@ -288,15 +304,20 @@ body.editing #sidebar {
}
}
/* The banner's own pen: opens the site editor (banner + structure).
Qualified with `button` to beat the later .edit-link rule's left offset
(both classes apply to the same element). */
button.banner-edit-link {
/* Banner-area pens (banner editor, site editor) and auth buttons live in a
single flex container pinned to the banner's top-right corner. */
.editor-pens {
position: absolute;
top: 0.6rem;
right: 1.25rem;
left: auto;
right: 0.5rem;
z-index: 10;
display: flex;
align-items: center;
gap: 0.6rem;
}
.editor-pens button {
position: static;
font: inherit;
border: none;
cursor: pointer;
@@ -452,19 +473,10 @@ article h1 .edit-link {
}
/* Login/profile buttons injected by pagerite.js when Paskia SSO is in use.
Both sit in the banner's top-right corner, to the right of the site pen,
and are styled like the edit pens. */
They live inside the .editor-pens flex container in the banner's top-right
corner and inherit its reset; keep only their opacity/text-shadow tweaks. */
button.login-link,
button.profile-link {
position: absolute;
top: 0.6rem;
right: 0.5rem;
z-index: 10;
font: inherit;
border: none;
cursor: pointer;
background: none;
padding: 0;
opacity: 0.7;
text-shadow: 0 0 0.1em black;
}
+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
})
}
+42 -36
View File
@@ -60,29 +60,33 @@ import "overlayscrollbars/overlayscrollbars.css";
function makePen(mode) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = mode === "page" ? "edit-link" : "edit-link banner-edit-link";
btn.title = "edit";
btn.textContent = "🖊️";
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";
btn.textContent = "🖊️";
} else if (mode === "banner") {
btn.className = "edit-link";
btn.title = "edit banner";
btn.textContent = "🖊️";
} else {
btn.className = "edit-link site-edit-link";
btn.title = "site settings";
btn.textContent = "⚙️";
}
return btn;
}
function injectPens() {
const banner = document.getElementById("page-banner");
if (banner && !banner.parentElement.querySelector(".banner-edit-link")) {
banner.after(makePen("site"));
}
function injectPagePen() {
const article = document.querySelector("#main article");
if (article && !article.querySelector("button.edit-link")) {
article.prepend(makePen("page"));
}
}
function addLoginButton(url) {
const banner = document.getElementById("page-banner");
if (!banner || banner.parentElement.querySelector(".login-link")) return;
function makeLoginButton(url) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "login-link";
@@ -97,43 +101,45 @@ import "overlayscrollbars/overlayscrollbars.css";
// Cancelled or error: leave the button in place.
}
});
banner.after(btn);
return btn;
}
function injectProfileButton() {
const banner = document.getElementById("page-banner");
if (!banner || banner.parentElement.querySelector(".profile-link")) return;
function makeProfileButton() {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "profile-link";
btn.title = "profile";
btn.textContent = "👤";
btn.textContent = "🔐";
btn.addEventListener("click", () => {
showAuthIframe("/auth/").catch(() => {});
});
banner.after(btn);
return btn;
}
function renderAuthUi() {
// Editing is open for admins and, as a dev/no-proxy fallback, when no
// Paskia SSO is detected at all.
const canEdit = isAdmin || !ssoAvailable;
const banner = document.getElementById("page-banner");
if (banner) {
for (const el of banner.parentElement.querySelectorAll(
".banner-edit-link, .login-link, .profile-link",
)) {
el.remove();
const old = banner.parentElement.querySelector(".editor-pens");
if (old) old.remove();
const pens = document.createElement("div");
pens.className = "editor-pens";
if (canEdit) {
pens.append(makePen("banner"));
pens.append(makePen("site"));
}
if (isAdmin && ssoAvailable) {
pens.append(makeProfileButton());
} else if (!isAdmin && ssoAvailable && loginIframeUrl) {
pens.append(makeLoginButton(loginIframeUrl));
} else if (!isAdmin && ssoAvailable) {
pens.append(makeProfileButton());
}
banner.after(pens);
}
if (isAdmin) {
injectPens();
if (ssoAvailable) injectProfileButton();
} else if (ssoAvailable && loginIframeUrl) {
addLoginButton(loginIframeUrl);
} else if (ssoAvailable) {
injectProfileButton();
} else {
// No Paskia SSO: dev/no-proxy fallback, leave editing open.
injectPens();
}
if (canEdit) injectPagePen();
}
async function setupAuth() {
@@ -433,10 +439,10 @@ import "overlayscrollbars/overlayscrollbars.css";
addEventListener("click", (ev) => {
if (ev.defaultPrevented || ev.button !== 0
|| ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
// Edit buttons toggle the editor panel docked on this page: load the
// Vue app on demand (with any extra styles) and mount it in place.
// Clicking the pen of the already-open editor closes it; clicking the
// other pen swaps the panel for the other editor type.
// Edit buttons toggle the tabbed editor panel docked on this page: load
// the Vue app on demand (with any extra styles) and mount it in place.
// Clicking the pen of the already-open tab closes the shell; clicking
// another pen switches the shell to that tab.
const editBtn = ev.target.closest("button.edit-link");
if (editBtn && editBtn.dataset.editorSrc) {
ev.preventDefault();
+117
View File
@@ -0,0 +1,117 @@
// Shared in-place page re-rendering for the editor tabs: fetch a page
// without transitions and swap its dynamic regions into the live document.
// Used by BannerEditor (banner design changes), SiteEditor (theme changes)
// and StructureEditor (tree navigation).
export function runScripts(root) {
// Scripts injected via innerHTML do not execute; re-create them.
if (!root) return
for (const old of root.querySelectorAll('script')) {
const s = document.createElement('script')
for (const a of old.attributes) s.setAttribute(a.name, a.value)
s.textContent = old.textContent
old.replaceWith(s)
}
}
function swapRegions(doc) {
for (const id of ['page-banner', 'nav', 'main']) {
const fresh = doc.getElementById(id)
const el = document.getElementById(id)
if (fresh && el) el.replaceWith(document.importNode(fresh, true))
}
// #sidebar is omitted entirely when the section has no sub-navigation,
// so it may be absent on either side: replace, insert, or remove.
const freshSidebar = doc.getElementById('sidebar')
const curSidebar = document.getElementById('sidebar')
if (freshSidebar && curSidebar) {
curSidebar.replaceWith(document.importNode(freshSidebar, true))
} else if (freshSidebar) {
document.getElementById('main')?.before(document.importNode(freshSidebar, true))
} else if (curSidebar) {
curSidebar.remove()
}
// The brand lives in the header, outside the swappable regions, and is
// absent entirely when neither a brand nor custom brand HTML is set. A
// plain link keeps its element (text swap only, preserving the shrink-
// to-fit observers); anything else (custom HTML wrapper) is replaced.
const freshBrand = doc.getElementById('brand')
const curBrand = document.getElementById('brand')
if (freshBrand && curBrand && freshBrand.tagName === 'A' && curBrand.tagName === 'A') {
curBrand.textContent = freshBrand.textContent
} else if (freshBrand && curBrand) {
curBrand.replaceWith(document.importNode(freshBrand, true))
runScripts(document.getElementById('brand'))
} else if (curBrand) {
curBrand.remove()
} else if (freshBrand) {
document.getElementById('nav')?.before(document.importNode(freshBrand, true))
runScripts(document.getElementById('brand'))
}
// Site-wide custom CSS is in <head> and must be swapped too.
const freshUserStyle = doc.getElementById('pagerite-user')
const curUserStyle = document.getElementById('pagerite-user')
if (freshUserStyle && curUserStyle) {
curUserStyle.textContent = freshUserStyle.textContent
} else if (freshUserStyle) {
document.head.appendChild(document.importNode(freshUserStyle, true))
} else if (curUserStyle) {
curUserStyle.remove()
}
// Theme and other public stylesheets live in <head>, rendered with stable
// ids by the backend; sync them positionally so the custom CSS (rendered
// last) always keeps winning by order. Diff-based: unchanged sheets keep
// their elements, so their @keyframes are never torn down (re-creating
// keyframes would replay the editor's slide-in animation).
const freshLinks = [...doc.head.querySelectorAll('link[rel="stylesheet"]')]
const freshIds = new Set(freshLinks.map((l) => l.id))
for (const link of [...document.head.querySelectorAll('link[rel="stylesheet"]')]) {
if (!link.dataset.pagerite && !freshIds.has(link.id)) link.remove()
}
// Insert missing sheets in the fresh document's order, each right after
// its predecessor's element. The first sheet rendered is always the base
// CSS, so its link doubles as the fallback anchor when nothing matched yet
// (e.g. no theme was selected before and the position is otherwise lost).
let anchor = null
for (const link of freshLinks) {
const cur = link.id && document.getElementById(link.id)
if (cur && cur.href === link.href) {
anchor = cur
continue
}
const el = document.importNode(link, true)
// Same id, new URL (theme switch): replace in place, keeping position.
if (cur) cur.replaceWith(el)
else if (anchor) anchor.after(el)
else document.getElementById('pagerite-base')?.after(el) ?? document.head.append(el)
anchor = el
}
// The editor keeps its own title while open; only inherit the server title
// when navigating outside the editor (e.g. fetch-navigation swaps).
if (!document.body.classList.contains('editing')) {
document.title = doc.title
}
}
// Fetch /p, swap its regions into the live page and replaceState to it.
// Returns the final URL (after redirects), or null when the fetch did not
// yield a page. Category and missing URLs render a placeholder 404 page —
// fine to swap in (new pages are created by editing them).
export async function loadPlain(p) {
let doc
let finalUrl = `/${p}`
try {
const res = await fetch(finalUrl)
const type = res.headers.get('content-type') || ''
if (!type.includes('text/html')) return null
if (res.redirected) finalUrl = res.url
doc = new DOMParser().parseFromString(await res.text(), 'text/html')
} catch { return null }
if (!doc.getElementById('main')) return null
swapRegions(doc)
history.replaceState(null, '', finalUrl)
runScripts(document.getElementById('page-banner'))
runScripts(document.getElementById('main'))
dispatchEvent(new CustomEvent('pagerite:preview')) // re-inject + re-tuck the edit pens
return finalUrl
}