From c803941cddbd6a0589637b5daacb381bdf56c7c8 Mon Sep 17 00:00:00 2001 From: neerajadhav Date: Mon, 7 Jul 2025 18:29:27 +0530 Subject: [PATCH] feat(navigation): improve hash anchor scrolling with header-aware positioning and visual feedback - Add scrollToElementWithBuffer and highlightElement utility functions to util.ts - Implement header-aware scroll positioning to prevent content hiding behind fixed headers - Add temporary highlight effect for scrolled-to elements to improve user experience - Refactor SPA and popover scripts to use shared utility functions (DRY principle) - Support configurable buffer space and highlight duration for different use cases Fixes content being obscured by page header when navigating to hash anchors. Enhances UX with visual feedback showing which heading was targeted. --- quartz/components/scripts/popover.inline.ts | 7 +- quartz/components/scripts/spa.inline.ts | 13 ++-- quartz/components/scripts/util.ts | 80 +++++++++++++++++++++ 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/quartz/components/scripts/popover.inline.ts b/quartz/components/scripts/popover.inline.ts index 989af7ee8..3348e0249 100644 --- a/quartz/components/scripts/popover.inline.ts +++ b/quartz/components/scripts/popover.inline.ts @@ -1,6 +1,7 @@ import { computePosition, flip, inline, shift } from "@floating-ui/dom" +import { fetchCanonical, scrollInContainerToElement } from "./util" + import { normalizeRelativeURLs } from "../../util/path" -import { fetchCanonical } from "./util" const p = new DOMParser() let activeAnchor: HTMLAnchorElement | null = null @@ -33,8 +34,8 @@ async function mouseEnterHandler( const targetAnchor = `#popover-internal-${hash.slice(1)}` const heading = popoverInner.querySelector(targetAnchor) as HTMLElement | null if (heading) { - // leave ~12px of buffer when scrolling to a heading - popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" }) + // Use utility function to scroll with buffer and highlight + scrollInContainerToElement(popoverInner, heading, 20, true, "instant") } } } diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts index 22fcd72b4..1459c0f01 100644 --- a/quartz/components/scripts/spa.inline.ts +++ b/quartz/components/scripts/spa.inline.ts @@ -1,6 +1,7 @@ -import micromorph from "micromorph" import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path" -import { fetchCanonical } from "./util" +import { fetchCanonical, scrollToElementWithBuffer } from "./util" + +import micromorph from "micromorph" // adapted from `micromorph` // https://github.com/natemoo-re/micromorph @@ -108,7 +109,9 @@ async function _navigate(url: URL, isBack: boolean = false) { if (!isBack) { if (url.hash) { const el = document.getElementById(decodeURIComponent(url.hash.substring(1))) - el?.scrollIntoView() + if (el) { + scrollToElementWithBuffer(el) + } } else { window.scrollTo({ top: 0 }) } @@ -155,7 +158,9 @@ function createRouter() { if (isSamePage(url) && url.hash) { const el = document.getElementById(decodeURIComponent(url.hash.substring(1))) - el?.scrollIntoView() + if (el) { + scrollToElementWithBuffer(el) + } history.pushState({}, "", url) return } diff --git a/quartz/components/scripts/util.ts b/quartz/components/scripts/util.ts index f71790104..b76469020 100644 --- a/quartz/components/scripts/util.ts +++ b/quartz/components/scripts/util.ts @@ -44,3 +44,83 @@ export async function fetchCanonical(url: URL): Promise { const [_, redirect] = text.match(canonicalRegex) ?? [] return redirect ? fetch(`${new URL(redirect, url)}`) : res } + +/** + * Highlights an element with a temporary background color effect + * @param el - The element to highlight + * @param duration - How long to show the highlight in milliseconds (default: 2000) + * @param color - The highlight color (default: uses CSS variable --highlight) + */ +export function highlightElement(el: HTMLElement, duration: number = 2000, color: string = 'var(--highlight, #ffeb3b40)') { + // Store original styles + const originalBackground = el.style.backgroundColor + const originalTransition = el.style.transition + + // Apply highlight styles + el.style.transition = 'background-color 0.3s ease' + el.style.backgroundColor = color + + // Remove highlight after specified duration + setTimeout(() => { + el.style.backgroundColor = originalBackground + // Remove transition after background fades back + setTimeout(() => { + el.style.transition = originalTransition + }, 300) + }, duration) +} + +/** + * Scrolls to an element with buffer space from the page header + * @param el - The element to scroll to + * @param buffer - Additional buffer space in pixels (default: 20) + * @param highlight - Whether to highlight the element after scrolling (default: true) + */ +export function scrollToElementWithBuffer(el: HTMLElement, buffer: number = 20, highlight: boolean = true) { + // Get the height of the header to calculate buffer + const header = document.querySelector('.page-header') as HTMLElement + const headerHeight = header ? header.offsetHeight : 0 + const totalOffset = headerHeight + buffer + + // Calculate the target position + const elementTop = el.offsetTop + const targetPosition = elementTop - totalOffset + + // Scroll to the calculated position + window.scrollTo({ + top: Math.max(0, targetPosition), + behavior: 'smooth' + }) + + // Add highlight effect if requested + if (highlight) { + highlightElement(el) + } +} + +/** + * Scrolls within a container element to a target element with buffer + * @param container - The container element to scroll within + * @param target - The target element to scroll to + * @param buffer - Buffer space in pixels (default: 20) + * @param highlight - Whether to highlight the element after scrolling (default: true) + * @param behavior - Scroll behavior (default: 'instant') + */ +export function scrollInContainerToElement( + container: HTMLElement, + target: HTMLElement, + buffer: number = 20, + highlight: boolean = true, + behavior: ScrollBehavior = 'instant' +) { + const targetPosition = target.offsetTop - buffer + container.scroll({ + top: Math.max(0, targetPosition), + behavior + }) + + // Add highlight effect if requested + if (highlight) { + highlightElement(target) + } +}