diff --git a/frontend/src/css/_page-contents.scss b/frontend/src/css/_page-contents.scss index d40530ef8..2abaae8ce 100644 --- a/frontend/src/css/_page-contents.scss +++ b/frontend/src/css/_page-contents.scss @@ -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); diff --git a/frontend/src/helpers/anchors.js b/frontend/src/helpers/anchors.js index 1c95966cf..652c92420 100644 --- a/frontend/src/helpers/anchors.js +++ b/frontend/src/helpers/anchors.js @@ -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) diff --git a/frontend/src/helpers/renderedContent.js b/frontend/src/helpers/renderedContent.js index ed3bb7271..c6b4688fa 100644 --- a/frontend/src/helpers/renderedContent.js +++ b/frontend/src/helpers/renderedContent.js @@ -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 +} diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 9920b20b5..aedbec39f 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -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 diff --git a/frontend/src/layouts/InboxLayout.vue b/frontend/src/layouts/InboxLayout.vue index 4e217f365..c197db3be 100644 --- a/frontend/src/layouts/InboxLayout.vue +++ b/frontend/src/layouts/InboxLayout.vue @@ -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 diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 3857bf598..35b952923 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -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 diff --git a/frontend/src/layouts/ProfileLayout.vue b/frontend/src/layouts/ProfileLayout.vue index 1131d5846..d3e00ef5b 100644 --- a/frontend/src/layouts/ProfileLayout.vue +++ b/frontend/src/layouts/ProfileLayout.vue @@ -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 diff --git a/frontend/src/pages/Index.vue b/frontend/src/pages/Index.vue index 04f4312f5..ac67f46a7 100644 --- a/frontend/src/pages/Index.vue +++ b/frontend/src/pages/Index.vue @@ -104,7 +104,7 @@ that is not there at all, has no target to have been given yet. --> - +