fix: content scrolling + browser page title issues

scarlett
NGPixel 1 month ago
parent e0fd96a347
commit 8cd2f0de43
No known key found for this signature in database

@ -1260,14 +1260,18 @@
}
}
/* -> Where a footnote link has just landed, so the reader can see which one they were sent to */
.footnote-item:target,
h1:target,
h2:target,
h3:target,
h4:target,
h5:target,
h6:target {
/*
Where a footnote link has just landed, so the reader can see which one they were sent to: one item
among a list of near-identical ones, where arriving says nothing about which is which.
Keyed off the class `helpers/anchors.js` leaves on it rather than `:target`, which stopped matching
once an in-content fragment link began updating the URL with `router.push` -- a pushed hash sets no
target element. Keep the name in step with `LANDED_CLASS` there.
Headings are deliberately not styled: a heading is what the reader asked for and it announces itself
by sitting at the top of the column, so the wash only added noise to every jump to a section.
*/
.footnote-item.is-anchor-landed {
/* Drawn outside the box so it cannot shift the text it highlights */
box-shadow: 0 0 0 0.4em var(--content-mark);
background-color: var(--content-mark);

@ -29,8 +29,27 @@ const SETTLE_MS = 1200
/** How far the heading may sit from where it was aimed before it is worth correcting, in pixels. */
const DRIFT_TOLERANCE = 4
/**
* Left on whatever a fragment link landed on, for content styling to mark a footnote does, since it
* is one item among a list of near-identical ones and being sent to it says nothing about which.
*
* `:target` used to do this and cannot any more: an in-content fragment link is followed with
* `router.push`, and a pushed hash does not set the document's target element. Doing it here instead
* covers arriving with a `#fragment` in the URL by the same path, which `:target` handled differently
* from a click. Styled in `_page-contents.scss` the two have to be kept in step.
*/
export const LANDED_CLASS = 'is-anchor-landed'
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
/** Mark where the reader has just been sent, and only there — the way `:target` behaved. */
function markLanded(el) {
for (const previous of document.querySelectorAll(`.${LANDED_CLASS}`)) {
previous.classList.remove(LANDED_CLASS)
}
el.classList.add(LANDED_CLASS)
}
/** The heading a `#slug` refers to, or null. */
export function anchorTarget(hash) {
const id = decodeURIComponent(String(hash ?? '').replace(/^#/, ''))
@ -137,6 +156,7 @@ export function scrollToAnchor(hash, { smooth = false } = {}) {
if (!isVisible(target)) {
return false
}
markLanded(target)
scrollTo(target, smooth)
return true
}
@ -171,6 +191,7 @@ export async function scrollToAnchorWhenReady(hash, { timeout = 5000 } = {}) {
if (!target || !isVisible(target)) {
return
}
markLanded(target)
const scroller = scrollerOf(target)
await whenStill(target, scroller, deadline)

@ -184,8 +184,7 @@ export function enhanceRenderedContent(root) {
* - another origin, or a scheme that is not http(s) `mailto:`, `tel:`, a download link
* - anything asking for a new context: `target`, `download`, `rel="external"`
* - a path the server owns rather than the router
* - a bare fragment on the page already open, which the browser scrolls to and which fires the
* `hashchange` the page view already listens for
* - a fragment on the page already open, which is `sameDocumentHash`'s business instead
*
* @param {object} link The anchor's own properties: `href` is the resolved absolute URL.
* @param {Location|{origin: string, pathname: string}} current Where the reader is now.
@ -210,11 +209,43 @@ export function routableHref({ href, target, download, rel } = {}, current) {
if (isServerPath(url.pathname)) {
return null
}
// -> Same page, different fragment: the browser scrolls and announces it, and the router would do
// neither
// -> Same page, different fragment: nothing to route to, and `sameDocumentHash` handles the scroll
if (url.pathname === current.pathname && url.hash) {
return null
}
return `${url.pathname}${url.search}${url.hash}`
}
/**
* The fragment of a link that points at a heading on the page already open, if that is what it is.
*
* The counterpart to `routableHref`, which declines these: there is no page to load, only a place on
* this one to travel to. Left to the browser it is an instant jump, where every other way of reaching
* a heading in this app animates the contents list does, and so does arriving with a `#heading` in
* the URL.
*
* Declined on the same grounds as a routable link, so a fragment link asking for a new tab, or
* carrying `download` / `rel="external"`, is still the browser's to handle.
*
* @param {object} link The anchor's own properties: `href` is the resolved absolute URL.
* @param {Location|{origin: string, pathname: string}} current Where the reader is now.
* @returns {string|null} The `#fragment` to travel to, or null when this is not such a link.
*/
export function sameDocumentHash({ href, target, download, rel } = {}, current) {
if (!href || (target && target !== '_self') || download || /\bexternal\b/.test(rel ?? '')) {
return null
}
let url
try {
url = new URL(href)
} catch {
return null
}
if (url.origin !== current.origin || !url.hash || url.pathname !== current.pathname) {
return null
}
return url.hash
}

@ -497,8 +497,13 @@ const { t } = useI18n()
// META
useMeta({
titleTemplate: (title) => `${title} - ${t('admin.adminArea')} - Wiki.js`
// -> The site's own name rather than the literal `Wiki.js`, as the page view does. A getter, so the
// template is recomputed when the site config arrives -- see the note in `MainLayout`.
useMeta(() => {
const siteTitle = siteStore.title
return {
titleTemplate: (title) => `${title} - ${t('admin.adminArea')} - ${siteTitle}`
}
})
// DATA

@ -36,6 +36,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useMeta } from '@/composables/meta'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import HeaderNav from '@/components/HeaderNav.vue'
@ -51,6 +52,7 @@ import MainOverlayDialog from '@/components/MainOverlayDialog.vue'
// STORES
const siteStore = useSiteStore()
const userStore = useUserStore()
// ROUTER
@ -64,8 +66,13 @@ const { t } = useI18n()
// META
useMeta({
titleTemplate: (title) => `${title} - ${t('inbox.title')} - Wiki.js`
// -> The site's own name rather than the literal `Wiki.js`, as the page view does. A getter, so the
// template is recomputed when the site config arrives -- see the note in `MainLayout`.
useMeta(() => {
const siteTitle = siteStore.title
return {
titleTemplate: (title) => `${title} - ${t('inbox.title')} - ${siteTitle}`
}
})
// DATA

@ -156,8 +156,18 @@ const { t } = useI18n()
// META
useMeta({
titleTemplate: (title) => `${title} - ${siteStore.title}`
/*
A getter that READS the site title, so `watchEffect` has something to track: the site config is
fetched, so a template closing over `siteStore.title` and registered once would keep whatever the
store held at mount. The page title alone no longer forces a recompute either, now that a page with
no title of its own -- the welcome screen, a path with no page -- has to fall back to the site name
rather than leaving the tab reading " - Site".
*/
useMeta(() => {
const siteTitle = siteStore.title
return {
titleTemplate: (title) => (title ? `${title} - ${siteTitle}` : siteTitle)
}
})
// REFS

@ -86,8 +86,13 @@ const { t } = useI18n()
// META
useMeta({
titleTemplate: (title) => `${title} - ${t('profile.title')} - Wiki.js`
// -> The site's own name rather than the literal `Wiki.js`, as the page view does. A getter, so the
// template is recomputed when the site config arrives -- see the note in `MainLayout`.
useMeta(() => {
const siteTitle = siteStore.title
return {
titleTemplate: (title) => `${title} - ${t('profile.title')} - ${siteTitle}`
}
})
// DATA

@ -104,7 +104,7 @@
that is not there at all, has no target to have been given yet.
-->
<page-redirect v-else-if="pageStore.editor === `redirect`" />
<w-scroll-area class="page-container-scrl" v-else style="height: 100%">
<w-scroll-area class="page-container-scrl" ref="pageScroller" v-else style="height: 100%">
<div class="page-container-body p-4">
<!--
Delegated rather than bound per link: the anchors are written by `v-html`, so there is
@ -292,8 +292,8 @@ import { dialog } from '@/composables/dialog'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { scrollToAnchorWhenReady } from '@/helpers/anchors'
import { enhanceRenderedContent, routableHref } from '@/helpers/renderedContent'
import { scrollToAnchor, scrollToAnchorWhenReady } from '@/helpers/anchors'
import { enhanceRenderedContent, routableHref, sameDocumentHash } from '@/helpers/renderedContent'
import { flattenToc } from '@/helpers/toc'
import { useCommonStore } from '@/stores/common'
@ -352,9 +352,15 @@ const dark = useDark()
// META
useMeta({
/*
A getter, not a plain object: the page's title is not known when this runs. The view is mounted for
the path, and the title arrives with the page a moment later -- read once, it was always the empty
string, so the tab showed nothing but the site name the template appends. It has to keep up with
every navigation after that too, since the view is reused rather than remounted.
*/
useMeta(() => ({
title: pageStore.title
})
}))
// DATA
@ -369,6 +375,8 @@ const state = reactive({
currentRating: 3
})
const pageContents = ref(null)
/** The article column, which is what scrolls -- see `scrollPageToTop`. */
const pageScroller = ref(null)
// COMPUTED
@ -581,6 +589,7 @@ watch(
}
// -> Load Page
scrollPageToTop()
try {
await pageStore.pageLoad({ path: newValue })
if (editorStore.isActive) {
@ -658,6 +667,22 @@ watch(
* which ones are ours; anything it declines is left to the browser, including a click asking for a
* new tab.
*/
/**
* Back to the top of the article on arriving at another page.
*
* The article column scrolls, not the window -- the shell around it holds still -- so the router's own
* `scrollBehavior` has nothing to do: it scrolls the document, which never moved. Left alone, a reader
* following a link from halfway down one page arrives halfway down the next.
*
* Called before the content is swapped rather than after, so the jump happens on the page being left
* instead of showing the new one at the old offset for a frame. A `#heading` in the URL still wins:
* `scrollToAnchorWhenReady` runs once the render has settled, and travelling to it from the top is
* what it is written to do.
*/
function scrollPageToTop() {
pageScroller.value?.$el?.scrollTo({ top: 0, left: 0 })
}
/**
* What a relation button links to, as props for `WBtn`.
*
@ -710,6 +735,25 @@ function onContentClick(ev) {
if (!anchor) {
return
}
/*
A heading on this same page: travelled to rather than jumped at, which is how the contents list
and an arriving `#heading` already reach one. Through the helper, so a heading inside a closed tab
is revealed first, and only claimed once it says it found somewhere to go -- a fragment naming
nothing in the render is left to the browser, as it was.
The URL still follows, so the address bar can be copied and Back returns to the section before.
`router.push` rather than assigning `location.hash`, which would jump the page as well -- and since
a pushed hash sets no target element, marking where the reader landed is the helper's job (see
`LANDED_CLASS`) rather than `:target`'s.
*/
const hash = sameDocumentHash(anchor, window.location)
if (hash) {
if (scrollToAnchor(hash, { smooth: true })) {
ev.preventDefault()
router.push({ path: route.path, query: route.query, hash })
}
return
}
const target = routableHref(anchor, window.location)
if (!target) {
return

@ -249,8 +249,22 @@ const { t } = useI18n()
// META
useMeta({
titleTemplate: (title) => `${title} - ${t('profile.title')} - Wiki.js`
/*
Both halves, because `/_search` is mounted on its own with no layout above it to supply either. Only
the template was registered, and a template with no title leaves `document.title` alone: the tab read
whatever was there already, which on a fresh load is the shell's own `Wiki.js`.
The name is this page's own, where the template said `profile.title` and announced a page of search
results as somebody's profile. Nothing sits between it and the site name, so nothing is inserted there.
A getter for the site title, as everywhere else -- see the note in `MainLayout`.
*/
useMeta(() => {
const siteTitle = siteStore.title
return {
title: t('search.results'),
titleTemplate: (title) => `${title} - ${siteTitle}`
}
})
// DATA

Loading…
Cancel
Save