From 4677a7c532f1b10db90e7e0090d0afdb8f78dce0 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Wed, 29 Jul 2026 17:44:02 -0400 Subject: [PATCH] refactor: various UI fixes --- backend/api/schemas/site.ts | 3 - backend/locales/en.json | 6 +- backend/models/sites.ts | 2 - frontend/src/assets/icons.generated.js | 4 +- frontend/src/components/EditorMarkdown.vue | 27 +- frontend/src/components/EditorWysiwyg.vue | 3 + frontend/src/components/FileManager.vue | 22 ++ .../src/components/FolderRenameDialog.vue | 5 +- frontend/src/components/HeaderNav.vue | 2 +- frontend/src/components/HeaderSearch.vue | 16 +- frontend/src/components/IconPickerDialog.vue | 57 +-- frontend/src/components/NavEditOverlay.vue | 34 +- frontend/src/components/NavSidebar.vue | 3 + frontend/src/components/PageActionsCol.vue | 4 + frontend/src/components/PageHeader.vue | 29 +- .../components/PageReasonForChangeDialog.vue | 26 +- frontend/src/components/PageSourceOverlay.vue | 3 +- frontend/src/components/PageTags.vue | 78 ++-- frontend/src/components/PageToc.vue | 339 ++++++++++++++++++ frontend/src/components/shared/WLayout.vue | 26 +- .../src/components/shared/WPageScroller.vue | 54 ++- frontend/src/components/shared/WSelect.vue | 77 +++- frontend/src/components/shared/WTab.vue | 23 +- frontend/src/components/shared/WTabs.vue | 15 +- frontend/src/helpers/toc.js | 40 +++ frontend/src/layouts/AdminLayout.vue | 8 + frontend/src/layouts/MainLayout.vue | 48 ++- frontend/src/pages/AdminTheme.vue | 15 - frontend/src/pages/Index.vue | 162 +++++---- frontend/src/renderers/markdown.js | 32 +- frontend/src/stores/page.js | 71 +++- frontend/src/stores/site.js | 1 - 32 files changed, 984 insertions(+), 251 deletions(-) create mode 100644 frontend/src/components/PageToc.vue create mode 100644 frontend/src/helpers/toc.js diff --git a/backend/api/schemas/site.ts b/backend/api/schemas/site.ts index 39674bfa9..548906038 100644 --- a/backend/api/schemas/site.ts +++ b/backend/api/schemas/site.ts @@ -309,9 +309,6 @@ export async function registerSchemas(app: FastifyInstance): Promise { type: 'string', enum: ['off', 'left', 'right'] }, - showSharingMenu: { - type: 'boolean' - }, showPrintBtn: { type: 'boolean' }, diff --git a/backend/locales/en.json b/backend/locales/en.json index d5d399187..a5aab34e1 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1013,8 +1013,6 @@ "admin.theme.secondaryColorHint": "The alternate color for secondary action buttons and for some other elements.", "admin.theme.showPrintBtn": "Show Print Button", "admin.theme.showPrintBtnHint": "Should the print button be displayed on all pages. Note that this doesn't prevent the user from printing the page using the system dialog.", - "admin.theme.showSharingMenu": "Show Sharing Menu", - "admin.theme.showSharingMenuHint": "Should the sharing menu be displayed on all pages.", "admin.theme.sidebarColor": "Sidebar Color", "admin.theme.sidebarColorHint": "The background color for the side navigation menu on content pages. Does not apply to the administration area.", "admin.theme.sidebarPosition": "Sidebar Position", @@ -1361,6 +1359,7 @@ "common.actions.duplicate": "Duplicate", "common.actions.edit": "Edit", "common.actions.exit": "Exit", + "common.actions.exitEdit": "Exit Edit", "common.actions.fetch": "Fetch", "common.actions.filter": "Filter", "common.actions.generate": "Generate", @@ -1497,6 +1496,7 @@ "common.notfound.title": "Not Found", "common.outdatedBrowserWarning": "Your browser is outdated. Upgrade to a {modernBrowser}.", "common.page.bookmark": "Bookmark", + "common.page.contents": "Contents", "common.page.delete": "Delete Page", "common.page.deleteSubtitle": "The page can be restored from the administration area.", "common.page.deleteTitle": "Are you sure you want to delete page {title}?", @@ -1508,6 +1508,7 @@ "common.page.printFormat": "Print Format", "common.page.private": "Private", "common.page.published": "Published", + "common.page.ratePage": "Rate this page", "common.page.returnNormalView": "Return to Normal View", "common.page.share": "Share", "common.page.tags": "Tags", @@ -1738,6 +1739,7 @@ "editor.reasonForChange.reasonMissing": "A reason is missing.", "editor.reasonForChange.required": "You must provide a reason for this change. Enter a small description of what changed.", "editor.reasonForChange.title": "Reason For Change", + "editor.renderFailed": "The preview could not be rendered. The last successful render is kept.", "editor.renderPreview": "Render Preview", "editor.save.createSuccess": "Page created successfully.", "editor.save.error": "An error occurred while creating the page", diff --git a/backend/models/sites.ts b/backend/models/sites.ts index ffcdc474d..0e12d6329 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -135,7 +135,6 @@ class Sites { contentWidth: 'full', sidebarPosition: 'left', tocPosition: 'right', - showSharingMenu: true, showPrintBtn: true, baseFont: 'roboto', contentFont: 'roboto' @@ -360,7 +359,6 @@ class Sites { contentWidth: 'full', sidebarPosition: 'left', tocPosition: 'right', - showSharingMenu: true, showPrintBtn: true, baseFont: 'roboto', contentFont: 'roboto' diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index d549ffc6d..d716a7b5e 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -5,7 +5,7 @@ never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or removing an icon; `check-icons.mjs` fails the build if this drifts. - 242 icons. + 240 icons. */ export const BUNDLED_ICONS = { "la:angle-double-right": {"body":"","width":32,"height":32}, @@ -26,7 +26,6 @@ export const BUNDLED_ICONS = { "la:broadcast-tower": {"body":"","width":32,"height":32}, "la:broom": {"body":"","width":32,"height":32}, "la:calendar": {"body":"","width":32,"height":32}, - "la:caret-right": {"body":"","width":32,"height":32}, "la:caret-square-right": {"body":"","width":32,"height":32}, "la:chalkboard": {"body":"","width":32,"height":32}, "la:chart-area": {"body":"","width":32,"height":32}, @@ -155,7 +154,6 @@ export const BUNDLED_ICONS = { "mdi:check": {"body":"","width":24,"height":24}, "mdi:check-circle": {"body":"","width":24,"height":24}, "mdi:checkbox-blank-outline": {"body":"","width":24,"height":24}, - "mdi:checkbox-marked": {"body":"","width":24,"height":24}, "mdi:checkbox-outline": {"body":"","width":24,"height":24}, "mdi:chevron-down": {"body":"","width":24,"height":24}, "mdi:chevron-left": {"body":"","width":24,"height":24}, diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue index 9176d4ce5..d8a862f4f 100644 --- a/frontend/src/components/EditorMarkdown.vue +++ b/frontend/src/components/EditorMarkdown.vue @@ -487,8 +487,28 @@ async function toggleMarkup({ start, end }) { } function processContent(newContent) { + /* + A render that throws must not become a render that is empty. + + `pageSave` sends whatever is in the store, and the server replaces the stored HTML with it -- so + patching a failed render in blanks the published page, and patching nothing keeps the last good + one. Loud rather than silent, because the preview is then showing something other than the source. + */ + let html + try { + html = md.render(newContent) + } catch (err) { + console.error(err) + notify({ + type: 'negative', + message: t('editor.renderFailed'), + caption: err.message + }) + return + } + pageStore.$patch({ - render: md.render(newContent) + render: html }) nextTick(() => { for (const block of editorPreviewContainerRef.value.querySelectorAll(':not(:defined)')) { @@ -655,7 +675,10 @@ onMounted(async () => { lastChangeTimestamp: Temporal.Now.instant() }) pageStore.$patch({ - content: editor.getValue() + content: editor.getValue(), + // -> What the author has typed IS the source, whatever the load did or did not deliver; see + // the guard in `pageSave` + contentLoaded: true }) processContent(pageStore.content) }, 500) diff --git a/frontend/src/components/EditorWysiwyg.vue b/frontend/src/components/EditorWysiwyg.vue index 279e0a038..395ff803d 100644 --- a/frontend/src/components/EditorWysiwyg.vue +++ b/frontend/src/components/EditorWysiwyg.vue @@ -708,6 +708,9 @@ function init() { }) pageStore.$patch({ content: JSON.stringify(editor.getJSON()), + // -> What the author has typed IS the source, whatever the load did or did not deliver; see + // the guard in `pageSave` + contentLoaded: true, render: editor.getHTML() }) } diff --git a/frontend/src/components/FileManager.vue b/frontend/src/components/FileManager.vue index bc470154b..52db0ea57 100644 --- a/frontend/src/components/FileManager.vue +++ b/frontend/src/components/FileManager.vue @@ -406,6 +406,7 @@ import { computed, defineAsyncComponent, nextTick, + onBeforeUnmount, onMounted, reactive, ref, @@ -491,6 +492,7 @@ const barStyle = { // REFS const fileIpt = ref(null) +const searchField = ref(null) const treeComp = ref(null) // COMPUTED @@ -1142,9 +1144,25 @@ function delItem(item) { } } +/** + * Ctrl+K reaches THIS search field while the overlay is up. + * + * HeaderSearch owns the same shortcut and steps aside for an overlay (see the note there), so the two + * never both answer it. Bound and unbound with the component, which only exists while the overlay is + * open -- the listener's lifetime is the window in which it should win. + */ +function handleKeyPress(ev) { + if (ev.ctrlKey && ev.key === 'k') { + ev.preventDefault() + searchField.value?.focus() + } +} + // MOUNTED onMounted(async () => { + window.addEventListener('keydown', handleKeyPress) + const pathParts = pageStore.path.split('/') const parentPath = pathParts.slice(0, -1).join('/') @@ -1173,6 +1191,10 @@ onMounted(async () => { state.currentFolderId = currentNode[0] } }) + +onBeforeUnmount(() => { + window.removeEventListener('keydown', handleKeyPress) +}) diff --git a/frontend/src/components/PageActionsCol.vue b/frontend/src/components/PageActionsCol.vue index 2e2ca9949..5629c3f0d 100644 --- a/frontend/src/components/PageActionsCol.vue +++ b/frontend/src/components/PageActionsCol.vue @@ -356,6 +356,10 @@ function notImplemented() { } } + /* -> Taller than the shell only on a very short window, and then it scrolls rather than clipping */ + overflow-y: auto; + scrollbar-width: none; + &-mode { writing-mode: vertical-rl; text-orientation: mixed; diff --git a/frontend/src/components/PageHeader.vue b/frontend/src/components/PageHeader.vue index 0bbcf9634..f0b950eec 100644 --- a/frontend/src/components/PageHeader.vue +++ b/frontend/src/components/PageHeader.vue @@ -88,7 +88,7 @@ {{ t(`editor.settings`) }} + + - diff --git a/frontend/src/components/PageReasonForChangeDialog.vue b/frontend/src/components/PageReasonForChangeDialog.vue index 161bbcfde..3286ecde0 100644 --- a/frontend/src/components/PageReasonForChangeDialog.vue +++ b/frontend/src/components/PageReasonForChangeDialog.vue @@ -5,7 +5,9 @@ {{ t(`editor.reasonForChange.title`) }} - + +
{{ t(`editor.reasonForChange.required`) }}
@@ -15,14 +17,14 @@ + lazy-rules="ondemand" /> @@ -51,7 +53,7 @@ import { useI18n } from 'vue-i18n' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' -import { reactive, ref } from 'vue' +import { computed, reactive, ref } from 'vue' // PROPS @@ -69,7 +71,9 @@ defineEmits([...dialogComponentEmits]) // DIALOG -const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent() +const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({ + autofocus: () => iptReason.value +}) // I18N @@ -85,10 +89,20 @@ const state = reactive({ // REFS const reasonForm = ref(null) +const iptReason = ref(null) // VALIDATION RULES -const reasonValidation = [(val) => val.length > 0 || t('editor.reasonForChange.reasonMissing')] +/* + No rule at all when the reason is optional, rather than a rule the field is exempt from. + + WForm validates every registered field on submit and only emits `submit` if they all pass, so an + unconditional rule made Enter on an empty field report a missing reason and swallow the submit -- + even though `commit()` itself only validates when the reason is required. +*/ +const reasonValidation = computed(() => + props.required ? [(val) => val.length > 0 || t('editor.reasonForChange.reasonMissing')] : [] +) // METHODS diff --git a/frontend/src/components/PageSourceOverlay.vue b/frontend/src/components/PageSourceOverlay.vue index eb239221f..e7ac3b519 100644 --- a/frontend/src/components/PageSourceOverlay.vue +++ b/frontend/src/components/PageSourceOverlay.vue @@ -29,7 +29,8 @@ :horizontal-thumb-style="{ height: `5px` }" style="width: 100%; height: calc(100vh - 100px);">
{{ state.notice }}
-

+          
+          

         
       
     
diff --git a/frontend/src/components/PageTags.vue b/frontend/src/components/PageTags.vue
index c3684308d..1dd3bc17b 100644
--- a/frontend/src/components/PageTags.vue
+++ b/frontend/src/components/PageTags.vue
@@ -15,38 +15,27 @@
         {{tag}}
       
     
+    
     
-      
-    
+      :loading="state.loading" />
   
 
 
@@ -83,8 +72,8 @@ const { t } = useI18n()
 // DATA
 
 const state = reactive({
+  /** Every tag on the site, as suggestions. WSelect narrows these against what is typed. */
   tags: [],
-  filteredTags: [],
   loading: false
 })
 
@@ -129,34 +118,31 @@ watch(
 
 // METHODS
 
-function filterTags(val, update) {
-  update(() => {
-    if (val === '') {
-      state.filteredTags = state.tags
-    } else {
-      const tagSearch = val.toLowerCase()
-      state.filteredTags = state.tags.filter((v) => v.toLowerCase().indexOf(tagSearch) >= 0)
-    }
-  })
-}
+/**
+ * Add whatever was typed, as one tag or as several.
+ *
+ * A comma or a semicolon separates tags, so a list can be pasted in one go. Each new one joins the
+ * suggestions too, so re-typing it offers a match rather than looking unknown.
+ */
+function createTag(val) {
+  const tags = val
+    .split(/[,;]+/)
+    .map((v) => v.trim())
+    .filter(Boolean)
+  if (tags.length === 0) {
+    return
+  }
 
-function createTag(val, done) {
-  if (val) {
-    const currentTags = pageStore.tags.slice()
-    for (const tag of val
-      .split(/[,;]+/)
-      .map((v) => v.trim())
-      .filter((v) => v)) {
-      if (!state.tags.includes(tag)) {
-        state.tags.push(tag)
-      }
-      if (!currentTags.includes(tag)) {
-        currentTags.push(tag)
-      }
+  const nextSelection = pageStore.tags.slice()
+  for (const tag of tags) {
+    if (!state.tags.includes(tag)) {
+      state.tags.push(tag)
+    }
+    if (!nextSelection.includes(tag)) {
+      nextSelection.push(tag)
     }
-    done('')
-    pageStore.tags = currentTags
   }
+  pageStore.tags = nextSelection
 }
 
 function removeTag(tag) {
diff --git a/frontend/src/components/PageToc.vue b/frontend/src/components/PageToc.vue
new file mode 100644
index 000000000..a8429368c
--- /dev/null
+++ b/frontend/src/components/PageToc.vue
@@ -0,0 +1,339 @@
+
+
+
+
+
diff --git a/frontend/src/components/shared/WLayout.vue b/frontend/src/components/shared/WLayout.vue
index 58b6d39d2..cc6870809 100644
--- a/frontend/src/components/shared/WLayout.vue
+++ b/frontend/src/components/shared/WLayout.vue
@@ -45,8 +45,18 @@ defineProps({
   min-height: 0;
 }
 
+/*
+  An app shell: the layout is exactly the viewport, and what scrolls is the page cell inside it.
+
+  `min-height: 100vh` here instead meant the whole document scrolled, which pushed the header, the
+  drawers and the footer off the top of the screen and left everything anchored to the bottom of a
+  column -- the sidebar's own action bar, the page's action rail -- sitting at the end of the article
+  rather than at the bottom of the window. Those things belong to the shell, so the shell has to be
+  what stays still.
+*/
 .w-layout--page {
-  min-height: 100vh;
+  height: 100vh;
+  overflow: hidden;
 }
 
 .w-layout--container {
@@ -55,16 +65,20 @@ defineProps({
 }
 
 /*
-  A container layout is bounded by its parent (a dialog), so anything taller than that box has to
-  scroll SOMEWHERE. The layout engine this replaces wrapped its whole tree in a scrolling div; here
-  the page cell scrolls instead, which keeps the header and the drawer in place rather than sliding
-  them away -- the behaviour an overlay with a sidebar wants.
+  Either way the layout is bounded -- by the viewport, or by the dialog holding it -- so anything
+  taller than that box has to scroll SOMEWHERE. The layout engine this replaces wrapped its whole
+  tree in a scrolling div; here the page cell scrolls instead, which keeps the header and the drawers
+  in place rather than sliding them away.
 
   `min-height: 0` is what makes it work at all: a grid item's automatic minimum size is its content,
   so without this the `1fr` row grows to fit and there is nothing left to scroll. Reached with
   `:deep()` because the page cell is a child COMPONENT and this rule has to cross that boundary.
+
+  A page that manages its own scrolling -- the page view scrolls its article column, so that the
+  contents sidebar beside it stays put -- simply never overflows this box, and no second scrollbar
+  appears.
 */
-.w-layout--container :deep(> .w-page-container) {
+.w-layout :deep(> .w-page-container) {
   min-height: 0;
   overflow: auto;
 }
diff --git a/frontend/src/components/shared/WPageScroller.vue b/frontend/src/components/shared/WPageScroller.vue
index 07c855126..427dc749b 100644
--- a/frontend/src/components/shared/WPageScroller.vue
+++ b/frontend/src/components/shared/WPageScroller.vue
@@ -17,9 +17,9 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
  * Floating "back to top" affordance that appears once the page has been scrolled past
  * `scrollOffset`, and smooth-scrolls to the top when clicked.
  *
- * Simplification: only the bottom-right placement is offered, which is the only one the app used.
- * Scrolling uses the platform's own smooth behaviour rather than the hand-rolled easing the
- * previous component shipped, and honours `prefers-reduced-motion` for free.
+ * Sits in the bottom-right corner unless `anchorX` puts it somewhere else horizontally. Scrolling
+ * uses the platform's own smooth behaviour rather than the hand-rolled easing the previous component
+ * shipped, and honours `prefers-reduced-motion` for free.
  */
 const props = defineProps({
   /** Show once the window has scrolled this many pixels. */
@@ -27,35 +27,65 @@ const props = defineProps({
     type: Number,
     default: 1000
   },
-  /** `[x, y]` distance from the viewport corner, in px. */
+  /** `[x, y]` distance from the viewport corner, in px. `x` is unused when `anchorX` is set. */
   offset: {
     type: Array,
     default: () => [18, 18]
+  },
+  /**
+   * Any CSS length, which becomes the x of the button's CENTRE measured from the left of the
+   * viewport — so it can straddle an edge rather than clear it. Null keeps it in the corner.
+   */
+  anchorX: {
+    type: String,
+    default: null
+  },
+  /**
+   * Selector for the element that scrolls, when it is not the window.
+   *
+   * The shell is the viewport, so a page view scrolls its own article column rather than the
+   * document; this button lives in the shell, outside that column, and so cannot find it by looking
+   * upwards. Resolved on each use rather than held, because the element belongs to the routed page
+   * and is replaced whenever that changes. Falls back to the window when there is no match.
+   */
+  target: {
+    type: String,
+    default: null
   }
 })
 
 const visible = ref(false)
 
-const anchorStyle = computed(() => ({
-  right: `${props.offset[0]}px`,
-  bottom: `${props.offset[1]}px`
-}))
+const anchorStyle = computed(() => {
+  const bottom = `${props.offset[1]}px`
+  // -> `translateX(-50%)` is what makes `anchorX` a centre rather than a left edge
+  return props.anchorX
+    ? { bottom, left: props.anchorX, transform: 'translateX(-50%)' }
+    : { bottom, right: `${props.offset[0]}px` }
+})
+
+/** The scrolling element, or null when it is the window. */
+function scroller() {
+  return props.target ? document.querySelector(props.target) : null
+}
 
 function onScroll() {
-  visible.value = window.scrollY > props.scrollOffset
+  const el = scroller()
+  visible.value = (el ? el.scrollTop : window.scrollY) > props.scrollOffset
 }
 
 function scrollToTop() {
   // -> `smooth` is ignored when the user has asked for reduced motion, which is the behaviour we want
-  window.scrollTo({ top: 0, behavior: 'smooth' })
+  ;(scroller() ?? window).scrollTo({ top: 0, behavior: 'smooth' })
 }
 
 onMounted(() => {
-  window.addEventListener('scroll', onScroll, { passive: true })
+  // -> `capture`, because a scroll event on an element does not bubble to the window
+  window.addEventListener('scroll', onScroll, { capture: true, passive: true })
   onScroll()
 })
 
-onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
+onBeforeUnmount(() => window.removeEventListener('scroll', onScroll, { capture: true }))
 
 
 
diff --git a/frontend/src/renderers/markdown.js b/frontend/src/renderers/markdown.js
index 9bd8ddc5a..09024be54 100644
--- a/frontend/src/renderers/markdown.js
+++ b/frontend/src/renderers/markdown.js
@@ -22,7 +22,7 @@ import katexHelper from './modules/katex'
 
 import hljs from 'highlight.js'
 
-import { escape, findLast, times } from 'lodash-es'
+import { escape } from 'es-toolkit/string'
 
 const quoteStyles = {
   chinese: '””‘’',
@@ -53,15 +53,31 @@ export class MarkdownRenderer {
         } else if (['mermaid', 'plantuml'].includes(lang)) {
           return `
${escape(str)}
` } else { - const highlighted = lang - ? hljs.highlight(str, { language: lang, ignoreIllegals: true }) - : { value: str } - const lineCount = highlighted.value.match(/\n/g).length + /* + `getLanguage` first, because `hljs.highlight` THROWS on a language it does not know -- + `ignoreIllegals` only forgives illegal syntax within a language it does. markdown-it takes + the first word of a fence's info string as the language name, so a fence whose code starts + on the opening line (``` `match` is null, not empty, when the code is a single line with no trailing newline + const lineCount = (highlighted.value.match(/\n/g) ?? []).length const lineNums = lineCount > 1 - ? `` + ? `` : '' - return `
${highlighted.value}${lineNums}
` + // -> `lang` is escaped too: it is whatever the author typed after the backticks, and a quote + // in it would otherwise close the attribute and inject markup into the preview + return `
${highlighted.value}${lineNums}
` } } }) @@ -196,6 +212,6 @@ export class MarkdownRenderer { } getClosestPreviewLine(line) { - return findLast(this.linesMap, (n) => n <= line) + return this.linesMap.findLast((n) => n <= line) } } diff --git a/frontend/src/stores/page.js b/frontend/src/stores/page.js index b86497429..a972ce42c 100644 --- a/frontend/src/stores/page.js +++ b/frontend/src/stores/page.js @@ -24,6 +24,14 @@ export const usePageStore = defineStore('page', { authorName: '', commentsCount: 0, content: '', + /** + * Whether `content` above is this page's actual source, rather than just the state it starts in. + * + * The API leaves `content` out of a page unless an editor asked for it and the session may see it, + * so an empty string in this store means either "the page is empty" or "nobody fetched it" — and + * `pageSave` must not write the second one over a page that has content. See the guard there. + */ + contentLoaded: false, createdAt: '', description: '', editor: '', @@ -100,6 +108,9 @@ export const usePageStore = defineStore('page', { // Update page store this.$patch({ ...pageData, + // -> The field is present exactly when the source came with the page, which is what makes + // the copy in this store safe to save; a view-mode load leaves the previous one in place + contentLoaded: Object.hasOwn(pageData, 'content'), relations: pageData.relations.map((r) => pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target']) ), @@ -206,6 +217,8 @@ export const usePageStore = defineStore('page', { relations: [], tags: [], content: content ?? '', + // -> A page being created has no stored source to lose: whatever it starts with IS the source + contentLoaded: true, render: '', isBrowsable: true, isSearchable: true, @@ -255,7 +268,20 @@ export const usePageStore = defineStore('page', { loadArgs.id = this.id } - await this.pageLoad(loadArgs) + /* + Edits made OUTSIDE the editor have to survive opening it. + + The page properties panel writes straight to this store, and the header then offers to save + them — so a page can arrive here with a changed title and an unchanged everything else. A full + load would replace every field with what is stored and reset the change timestamps, throwing + those edits away without a word. The source is the only thing missing in that state, so the + source is the only thing fetched. + */ + if (editorStore.hasPendingChanges) { + await this.pageLoadSource() + } else { + await this.pageLoad(loadArgs) + } if (!editorStore.configIsLoaded) { await editorStore.fetchConfigs() @@ -267,6 +293,32 @@ export const usePageStore = defineStore('page', { editor: this.editor }) }, + /** + * PAGE - LOAD SOURCE ONLY + * + * Fetches the source and nothing else, for opening the editor on a page whose other fields have + * already been edited elsewhere. Deliberately touches neither the rest of the page nor the editor's + * change timestamps: what is pending stays pending, and stays saveable. + */ + async pageLoadSource() { + const siteStore = useSiteStore() + try { + const pageData = await API_CLIENT.get(`sites/${siteStore.id}/pages/${this.id}`, { + searchParams: { withContent: true } + }).json() + // -> Absent rather than empty means the server withheld it; see `contentLoaded` + if (!Object.hasOwn(pageData ?? {}, 'content')) { + throw new Error('ERR_PAGE_SOURCE_UNAVAILABLE') + } + this.$patch({ + content: pageData.content, + contentLoaded: true + }) + } catch (err) { + console.warn(err) + throw err + } + }, /** * PAGE - MOVE */ @@ -338,6 +390,23 @@ export const usePageStore = defineStore('page', { ]) } + /* + Never save a source this store never received. + + An editor that came up empty because the source was withheld — an expired session, a failed + load — is indistinguishable from an empty page by the time the payload is built, and sending + the empty string replaces the stored HTML's source with nothing. Dropping the key instead + leaves it exactly as it was: `updatePage` only writes `content` when it is not `undefined`. + + Typing into an editor sets the flag, so deliberately clearing a page still works — that empty + string came from the author, not from a load that never happened. A page being created always + has it set, which is also why this cannot leave the POST short of a required field. + */ + if (!this.contentLoaded) { + delete body.content + console.warn('Page source was never loaded; saving without touching the stored content.') + } + let pageData if (editorStore.mode === 'create') { const resp = unwrap( diff --git a/frontend/src/stores/site.js b/frontend/src/stores/site.js index 4f252816b..769411cbb 100644 --- a/frontend/src/stores/site.js +++ b/frontend/src/stores/site.js @@ -98,7 +98,6 @@ export const useSiteStore = defineStore('site', { codeBlocksTheme: '', sidebarPosition: 'left', tocPosition: 'right', - showSharingMenu: true, showPrintBtn: true }, sideDialogShown: false,