mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-19 10:54:06 -06:00
Merge 1c67a752e3 into 86a30ad150
This commit is contained in:
commit
29f717d933
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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<Response> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user