Files
pagerite/frontend/src/PageEditor.vue
T
LeoVasanko 840f15ee59 Use location.replace when the standalone page editor saves and closes
Avoids leaving the /_/admin shell in the browser history stack.
2026-08-16 19:10:52 +00:00

436 lines
12 KiB
Vue

<script setup>
// Page editor: CodeMirror for Markdown, live server-rendered preview
// applied straight into the visible article, saving over one WebSocket
// (/_/api/ws/editor). Docked left of the article on the page itself
// (main.js openEditor) or standalone at /_/admin with its own preview pane.
// 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 { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { markdown } from '@codemirror/lang-markdown'
import { cmHighlight, cmTheme } from './cmtheme'
const props = defineProps({
pagePath: { type: String, default: '' },
standalone: { type: Boolean, default: false },
})
const emit = defineEmits(['close'])
const path = ref('')
const title = ref('')
const published = ref(true)
const saveError = ref('')
const previewHtml = ref('')
const previewHasH1 = ref(false)
const editorEl = ref(null)
const previewEl = ref(null)
const fileInput = ref(null)
let ws = null
let view = null
let savedResolve = null
let pendingSave = null
let reconnectTimer = null
let reconnectDelay = 2000
const MAX_RECONNECT_DELAY = 16000
let everConnected = false
let dirty = false
let syncingScroll = false
function currentPath() {
return location.hash.replace(/^#\/?/, '').replace(/\/$/, '')
}
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()
}
}
function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
}
function requestRender() {
// No debounce: server-side rendering is fast enough per keystroke.
if (!view) return
dirty = 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
// never moves the page.
const msg = {
type: 'save',
path: path.value,
title: title.value,
markdown: view.state.doc.toString(),
published: published.value,
}
pendingSave = msg
send(msg)
return new Promise((resolve) => { savedResolve = resolve })
}
async function saveAndClose() {
await save()
// Reload so nav/sidebar changes apply, then the editor is gone.
// In standalone mode replace the current history entry so the admin
// shell does not remain in the back-button stack.
if (props.standalone) location.replace(`/${path.value}`)
else { dirty = false; emit('close'); location.reload() }
}
function close() {
// Reload if the visible page is showing unsaved preview edits.
const stale = dirty
dirty = false
emit('close')
if (stale && !props.standalone) location.reload()
}
function insertAtCursor(text) {
view.dispatch(view.state.replaceSelection(text))
view.focus()
}
async function uploadImage(file) {
if (!file) return
const name = file.name.replace(/[^\w.-]/g, '-')
const res = await fetch(`/_/api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
if (res.ok) {
const { path: stored } = await res.json()
const alt = name.replace(/\.[^.]+$/, '')
insertAtCursor(`![${alt}](${stored})`)
}
}
function openPath(p) {
path.value = p
send({ type: 'open', path: p })
}
function setDocument(text, preserveSelection = false) {
const tr = { changes: { from: 0, to: view.state.doc.length, insert: text } }
if (preserveSelection) tr.selection = view.state.selection
view.dispatch(tr)
}
function onHashChange() {
const p = currentPath()
if (p !== path.value) openPath(p)
}
function runScripts(root) {
// Scripts injected via innerHTML do not execute; re-create them.
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 previewIntoArticle(html, hasH1) {
// Docked mode previews into the article on the page itself;
// standalone mode has its own preview pane. When the markdown owns its
// h1, the title-derived h1 is hidden (matching server-side rendering).
previewHasH1.value = hasH1
if (props.standalone) {
previewHtml.value = html
nextTick(() => { if (previewEl.value) runScripts(previewEl.value) })
return
}
const article = document.querySelector('#main article')
if (!article) return
const h1 = article.querySelector('h1')
const body = article.querySelector('.body')
// The edit pen may be tucked inside an h1 (title or markdown-owned);
// detach it before textContent/innerHTML wipes destroy the element.
// pagerite.js re-places it into the first visible h1 on pagerite:preview.
const pen = article.querySelector('button.edit-link')
if (pen && (h1?.contains(pen) || body?.contains(pen))) article.prepend(pen)
if (h1) {
h1.style.display = hasH1 ? 'none' : ''
h1.textContent = title.value
}
if (body) {
body.innerHTML = html
runScripts(body)
dispatchEvent(new CustomEvent('pagerite:preview'))
}
}
function onMessage(ev) {
const msg = JSON.parse(ev.data)
if (msg.type === 'doc' && msg.path === path.value) {
title.value = msg.title
published.value = msg.published
setDocument(msg.markdown)
requestRender()
dirty = 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
savedResolve?.()
savedResolve = 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()
}
if (ev.key === 'Escape') close()
}
function syncScroll() {
// Editor scroll drives the document: keep the rendered article at the
// same proportional position as the cursor area in the editor.
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
if (props.standalone) {
const pv = previewEl.value
if (pv) pv.scrollTop = pct * (pv.scrollHeight - pv.clientHeight)
} else {
const doc = document.documentElement
window.scrollTo(0, pct * (doc.scrollHeight - innerHeight))
}
syncingScroll = false
})
}
function connect() {
ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_/api/ws/editor`,
)
ws.onmessage = onMessage
ws.onopen = () => {
reconnectDelay = 2000
if (everConnected) {
// Reconnected: local text is authoritative — don't re-open (that
// would clobber the editor), just resync preview and pending saves.
requestRender()
if (pendingSave) send(pendingSave)
} else {
openPath(props.standalone ? currentPath() : normPath(props.pagePath))
}
everConnected = true
}
ws.onclose = () => {
clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
connect()
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}, reconnectDelay)
}
}
onMounted(() => {
connect()
view = new EditorView({
state: EditorState.create({
doc: '',
extensions: [
basicSetup,
markdown(),
cmTheme,
cmHighlight,
EditorView.lineWrapping, // Markdown lines are long: soft-wrap them
EditorView.updateListener.of((u) => { if (u.docChanged) requestRender() }),
EditorView.domEventHandlers({
paste(ev) {
// Paste an image straight into the article: upload + insert
const file = [...(ev.clipboardData?.files || [])]
.find((f) => f.type.startsWith('image/'))
if (file) {
ev.preventDefault()
uploadImage(file)
}
},
}),
],
}),
parent: editorEl.value,
})
view.scrollDOM.addEventListener('scroll', syncScroll)
window.__pageritePageEditor = {
getMarkdown: () => view.state.doc.toString(),
setMarkdown: (text) => setDocument(text, true),
path: () => path.value,
}
if (props.standalone) addEventListener('hashchange', onHashChange)
addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
clearTimeout(reconnectTimer)
if (ws) {
ws.onclose = null // intentional close, no reconnect
ws.close()
}
view?.destroy()
delete window.__pageritePageEditor
if (props.standalone) removeEventListener('hashchange', onHashChange)
removeEventListener('keydown', onKeydown)
})
</script>
<template>
<div class="editor-root" :class="{ overlay: !standalone }">
<header class="toolbar">
<input v-model="title" placeholder="Title" class="title" @input="requestRender" />
<label><input v-model="published" type="checkbox" /> published</label>
<input
ref="fileInput"
type="file"
accept="image/*"
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 v-if="!standalone" type="button" class="close" title="close" @click="close"></button>
</header>
<div v-if="saveError">{{ saveError }}</div>
<div class="panes">
<div ref="editorEl" class="editor" />
<div v-if="standalone" ref="previewEl" class="preview">
<article>
<h1 v-if="!previewHasH1">{{ title }}</h1>
<!-- server-rendered markdown preview -->
<div class="body" v-html="previewHtml" />
</article>
</div>
</div>
</div>
</template>
<style scoped>
.editor-root {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Docked-overlay positioning lives in the global style.css (.editor-host /
.editor-root.overlay) since the host element is created by main.js. */
.toolbar {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.toolbar .title {
flex: 1;
min-width: 4rem;
font: inherit;
padding: 0.2rem 0.5rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 4px;
}
.toolbar label {
color: var(--muted);
font-size: 0.85rem;
white-space: nowrap;
}
.toolbar button {
font: inherit;
padding: 0.25rem 0.8rem;
background: var(--accent2);
color: white;
border: none;
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 {
color: var(--text);
}
.panes {
flex: 1;
display: flex;
min-height: 0;
padding: 0.6rem 1rem;
gap: 1rem;
background: var(--surface); /* dialog body, same as the toolbar */
}
/* 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. */
.editor {
flex: 1;
min-width: 0;
overflow: hidden;
overscroll-behavior: contain;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--bg);
}
.editor :deep(.cm-editor) {
height: 100%;
}
/* No line numbers / gutter chrome. */
.editor :deep(.cm-gutters) {
display: none;
}
.preview {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 1rem 1.5rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.preview article {
max-width: 44rem;
margin: 0 auto;
}
</style>