diff --git a/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue index 8fd6e1d..ed4d960 100644 --- a/frontend/src/PageEditor.vue +++ b/frontend/src/PageEditor.vue @@ -6,7 +6,7 @@ // 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 } from 'vue' +import { onMounted, onUnmounted, ref, watch } from 'vue' import { EditorView, basicSetup } from 'codemirror' import { EditorState } from '@codemirror/state' import { markdown } from '@codemirror/lang-markdown' @@ -53,6 +53,19 @@ function normPath(p) { return p.trim().replace(/^\/+|\/+$/g, '') } +function pageLabel() { + return title.value.trim() || ('/' + (path.value || '')) +} + +// Show the article title (or path for a new page) plus the edit pen in the +// window title while editing; the server-rendered public title is restored on +// close. +function updateWindowTitle() { + document.title = `${pageLabel()} ๐Ÿ–Š๏ธ` +} + +watch([title, path], updateWindowTitle) + function requestRender() { // No debounce: server-side rendering is fast enough per keystroke. if (!view) return @@ -228,6 +241,7 @@ function connect() { onMounted(() => { connect() + updateWindowTitle() view = new EditorView({ state: EditorState.create({ diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue index e3fe3ac..6a9483d 100644 --- a/frontend/src/SiteEditor.vue +++ b/frontend/src/SiteEditor.vue @@ -10,7 +10,7 @@ // 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 { computed, onMounted, onUnmounted, provide, ref } from 'vue' +import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue' import StructureTree from './StructureTree.vue' import { EditorView, basicSetup } from 'codemirror' import { EditorState, Compartment } from '@codemirror/state' @@ -47,19 +47,6 @@ let cssSyncing = false // set while replacing the CSS document programmatically const customCss = ref('') const cssEl = ref(null) -// path -> node, for quick lookups (current title, delete checks). -const flatMap = computed(() => { - const map = {} - const walk = (nodes) => { - for (const n of nodes) { - map[n.path] = n - walk(n.children) - } - } - walk(tree.value) - return map -}) - function normPath(p) { return p.trim().replace(/^\/+|\/+$/g, '') } @@ -170,7 +157,11 @@ function swapRegions(doc) { else document.getElementById('pagerite-base')?.after(el) ?? document.head.append(el) anchor = el } - document.title = doc.title + // 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 + } } async function loadPlain(p) { @@ -372,9 +363,10 @@ async function removeFavicon() { } } -function currentTitle() { - return flatMap.value[path.value]?.title - || document.title.replace(/ โ€“ [^โ€“]*$/, '') +// The site editorโ€™s window title shows the site brand (or a generic label) +// plus the edit pen; the current article title is irrelevant here. +function updateEditorTitle() { + document.title = `${brand.value.trim() || 'Pagerite'} ๐Ÿ–Š๏ธ` } function applyBrand(b) { @@ -387,11 +379,10 @@ function applyBrand(b) { document.getElementById('nav')?.before(el) } el.textContent = b - document.title = `${currentTitle()} โ€“ ${b}` } else { if (el) el.remove() - document.title = currentTitle() } + updateEditorTitle() } function onBrandInput() { @@ -399,6 +390,8 @@ function onBrandInput() { debounce('brand', saveBrand) } +watch(brand, updateEditorTitle, { immediate: true }) + async function saveSettings(opts = {}) { const res = await fetch('/_api/settings', { method: 'PUT', diff --git a/frontend/src/main.js b/frontend/src/main.js index f3df462..9c026a8 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -15,9 +15,12 @@ import PageEditor from './PageEditor.vue' import SiteEditor from './SiteEditor.vue' let host = null +let app = null +let savedTitle = null export function openEditor(path, { mode = 'page' } = {}) { closeEditor() + savedTitle = document.title host = document.createElement('div') host.className = 'editor-host' // Docked inside #content: below the banner, next to the article only. @@ -26,10 +29,11 @@ export function openEditor(path, { mode = 'page' } = {}) { // 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. document.body.dataset.editorMode = mode - createApp(mode === 'site' ? SiteEditor : PageEditor, { + app = createApp(mode === 'site' ? SiteEditor : PageEditor, { pagePath: path, onClose: closeEditor, - }).mount(host) + }) + app.mount(host) // The slide-in (editor-slide-in in pagerite.css) is a one-shot open // effect; once finished, drop it so that later stylesheet swaps (theme // change re-creating @keyframes) cannot restart it. @@ -48,6 +52,26 @@ export function closeEditor() { // Slide the panel out in sync with the page shifting back. host.firstElementChild?.classList.add('closing') const old = host + const oldApp = app + const restoreTitle = savedTitle host = null - setTimeout(() => old.remove(), 250) + 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) }