Merge branch 'dev' into v4

This commit is contained in:
neerajadhav 2025-07-07 19:08:35 +05:30
commit 9322c199da
3 changed files with 219 additions and 23 deletions

View File

@ -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
// 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) {
// 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")
}
}
}
}
@ -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
}
@ -96,7 +130,7 @@ async function mouseEnterHandler(
const targetID = `popover-internal-${el.id}`
el.id = targetID
})
const elts = [...html.getElementsByClassName("popover-hint")]
const elts = Array.from(html.getElementsByClassName("popover-hint"))
if (elts.length === 0) return
elts.forEach((elt) => popoverInner.appendChild(elt))
@ -111,23 +145,34 @@ 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.addEventListener("mouseleave", clearActivePopoverAndHighlights)
// Use type assertion to avoid TypeScript error when checking individual files
if (typeof (window as any).addCleanup === 'function') {
(window as any).addCleanup(() => {
link.removeEventListener("mouseenter", mouseEnterHandler)
link.removeEventListener("mouseleave", clearActivePopover)
link.removeEventListener("mouseleave", clearActivePopoverAndHighlights)
})
}
}
})

View File

@ -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
}

View File

@ -14,9 +14,14 @@ export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb:
}
outsideContainer?.addEventListener("click", click)
window.addCleanup(() => outsideContainer?.removeEventListener("click", click))
// Use type assertion to avoid TypeScript error when checking individual files
if (typeof (window as any).addCleanup === 'function') {
(window as any).addCleanup(() => outsideContainer?.removeEventListener("click", click))
}
document.addEventListener("keydown", esc)
window.addCleanup(() => document.removeEventListener("keydown", esc))
if (typeof (window as any).addCleanup === 'function') {
(window as any).addCleanup(() => document.removeEventListener("keydown", esc))
}
}
export function removeAllChildren(node: HTMLElement) {
@ -44,3 +49,144 @@ 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()
// Also clear any highlights that might not be tracked (backup cleanup)
// This catches highlights in popovers or other edge cases
const allHighlightedElements = document.querySelectorAll('[style*="background-color"]')
allHighlightedElements.forEach((el) => {
const element = el as HTMLElement
if (element.style.backgroundColor.includes('var(--highlight') ||
element.style.backgroundColor.includes('#ffeb3b')) {
element.style.backgroundColor = ''
element.style.transition = ''
}
})
}
/**
* 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
activeHighlights.forEach((highlight) => {
if (highlight.element === el) {
clearTimeout(highlight.timeoutId)
activeHighlights.delete(highlight)
}
})
// 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 timeoutId = window.setTimeout(() => {
el.style.backgroundColor = originalBackground
// Remove transition after background fades back
setTimeout(() => {
el.style.transition = originalTransition
// Remove from active highlights
activeHighlights.forEach((highlight) => {
if (highlight.element === el) {
activeHighlights.delete(highlight)
}
})
}, 300)
}, duration)
// Track this highlight
activeHighlights.add({
element: el,
originalBackground,
originalTransition,
timeoutId
})
}
/**
* 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'
) {
// Use requestAnimationFrame to ensure content is rendered before scrolling
requestAnimationFrame(() => {
const targetPosition = target.offsetTop - buffer
container.scroll({
top: Math.max(0, targetPosition),
behavior
})
// Add highlight effect if requested
if (highlight) {
// Small delay to ensure scroll completes before highlighting
setTimeout(() => {
highlightElement(target)
}, 50)
}
})
}