diff --git a/quartz/components/scripts/popover.inline.ts b/quartz/components/scripts/popover.inline.ts index 989af7ee8..7f1a6aeb0 100644 --- a/quartz/components/scripts/popover.inline.ts +++ b/quartz/components/scripts/popover.inline.ts @@ -1,6 +1,7 @@ +import { clearAllHighlights, fetchCanonical, scrollInContainerToElement } from "./util" import { computePosition, flip, inline, shift } from "@floating-ui/dom" + import { normalizeRelativeURLs } from "../../util/path" -import { fetchCanonical } from "./util" const p = new DOMParser() let activeAnchor: HTMLAnchorElement | null = null @@ -14,6 +15,9 @@ async function mouseEnterHandler( return } + // Clear any existing highlights immediately when entering a new link + clearAllHighlights() + async function setPosition(popoverElement: HTMLElement) { const { x, y } = await computePosition(link, popoverElement, { strategy: "fixed", @@ -24,17 +28,47 @@ async function mouseEnterHandler( }) } - function showPopover(popoverElement: HTMLElement) { + function showPopover(popoverElement: HTMLElement, targetHash: string = "") { clearActivePopover() popoverElement.classList.add("active-popover") setPosition(popoverElement as HTMLElement) - if (hash !== "") { - 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" }) + // Always scroll to hash anchor if present, regardless of popover state + if (targetHash !== "") { + const popoverInner = popoverElement.querySelector(".popover-inner") as HTMLElement | null + if (popoverInner) { + const hashWithoutPound = targetHash.slice(1) + const targetAnchor = `popover-internal-${hashWithoutPound}` + + // Try to find the element by ID first + let heading = popoverInner.querySelector(`#${targetAnchor}`) as HTMLElement | null + + // If not found by ID, try to find by text content (fallback for headings) + if (!heading) { + const headings = popoverInner.querySelectorAll("h1, h2, h3, h4, h5, h6") + for (const h of headings) { + const headingElement = h as HTMLElement + const headingText = headingElement.textContent?.trim().toLowerCase() || "" + const targetText = hashWithoutPound.toLowerCase().replace(/-/g, " ") + + // More strict matching: avoid matching very short headings unless they're exact matches + const isExactMatch = headingText === targetText + const isSubstringMatch = + headingText.length >= 3 && + (headingText.includes(targetText) || + (targetText.length >= 3 && targetText.includes(headingText))) + + if (isExactMatch || isSubstringMatch) { + heading = headingElement + break + } + } + } + + if (heading) { + // Use utility function to scroll with buffer and highlight + scrollInContainerToElement(popoverInner, heading, 20, true, "instant") + } } } } @@ -48,7 +82,7 @@ async function mouseEnterHandler( // dont refetch if there's already a popover if (!!document.getElementById(popoverId)) { - showPopover(prevPopoverElement as HTMLElement) + showPopover(prevPopoverElement as HTMLElement, hash) return } @@ -111,23 +145,33 @@ async function mouseEnterHandler( return } - showPopover(popoverElement) + showPopover(popoverElement, hash) } function clearActivePopover() { activeAnchor = null const allPopoverElements = document.querySelectorAll(".popover") allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover")) + // Clear any remaining highlights when closing popovers + clearAllHighlights() +} + +function clearActivePopoverAndHighlights() { + clearActivePopover() + // Also clear highlights immediately when mouse leaves + clearAllHighlights() } document.addEventListener("nav", () => { - const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[] + const links = Array.from(document.querySelectorAll("a.internal")) as HTMLAnchorElement[] for (const link of links) { link.addEventListener("mouseenter", mouseEnterHandler) - link.addEventListener("mouseleave", clearActivePopover) - window.addCleanup(() => { - link.removeEventListener("mouseenter", mouseEnterHandler) - link.removeEventListener("mouseleave", clearActivePopover) - }) + link.addEventListener("mouseleave", clearActivePopoverAndHighlights) + if (typeof window.addCleanup === "function") { + window.addCleanup(() => { + link.removeEventListener("mouseenter", mouseEnterHandler) + link.removeEventListener("mouseleave", clearActivePopoverAndHighlights) + }) + } } }) diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts index 22fcd72b4..997fe38ca 100644 --- a/quartz/components/scripts/spa.inline.ts +++ b/quartz/components/scripts/spa.inline.ts @@ -1,6 +1,6 @@ import micromorph from "micromorph" import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path" -import { fetchCanonical } from "./util" +import { fetchCanonical, scrollToElementWithBuffer } from "./util" // adapted from `micromorph` // https://github.com/natemoo-re/micromorph @@ -108,7 +108,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 +157,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..cf7a6a6ec 100644 --- a/quartz/components/scripts/util.ts +++ b/quartz/components/scripts/util.ts @@ -14,9 +14,13 @@ export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb: } outsideContainer?.addEventListener("click", click) - window.addCleanup(() => outsideContainer?.removeEventListener("click", click)) + if (typeof window.addCleanup === "function") { + window.addCleanup(() => outsideContainer?.removeEventListener("click", click)) + } document.addEventListener("keydown", esc) - window.addCleanup(() => document.removeEventListener("keydown", esc)) + if (typeof window.addCleanup === "function") { + window.addCleanup(() => document.removeEventListener("keydown", esc)) + } } export function removeAllChildren(node: HTMLElement) { @@ -44,3 +48,128 @@ export async function fetchCanonical(url: URL): Promise { const [_, redirect] = text.match(canonicalRegex) ?? [] return redirect ? fetch(`${new URL(redirect, url)}`) : res } + +// Keep track of active highlights to clean them up +let activeHighlights: Set<{ + element: HTMLElement + originalBackground: string + originalTransition: string + timeoutId: number +}> = new Set() + +/** + * Clears all active highlights immediately + */ +export function clearAllHighlights() { + // Clear tracked highlights + activeHighlights.forEach(({ element, originalBackground, originalTransition, timeoutId }) => { + clearTimeout(timeoutId) + element.style.backgroundColor = originalBackground + element.style.transition = originalTransition + }) + activeHighlights.clear() +} + +/** + * 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)", +) { + // Clear any existing highlight on this element + const existingHighlight = Array.from(activeHighlights).find((h) => h.element === el) + if (existingHighlight) { + clearTimeout(existingHighlight.timeoutId) + activeHighlights.delete(existingHighlight) + } + + // 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 + + // Set up cleanup + const highlight = { + element: el, + originalBackground, + originalTransition, + timeoutId: 0, + } + + highlight.timeoutId = window.setTimeout(() => { + el.style.backgroundColor = originalBackground + el.style.transition = originalTransition + activeHighlights.delete(highlight) + }, duration) + + // Track this highlight + activeHighlights.add(highlight) +} + +/** + * 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", +) { + // Scroll immediately, since content should already be rendered in a static site + const targetPosition = target.offsetTop - buffer + container.scroll({ + top: Math.max(0, targetPosition), + behavior, + }) + + // Add highlight effect if requested + if (highlight) { + highlightElement(target) + } +}