[READ-ONLY] a fast, modern browser for the npm registry
at main 111 lines 3.2 kB view raw
1import type { TocItem } from '#shared/types/readme' 2import type { Ref } from 'vue' 3 4/** 5 * Composable for tracking the currently visible heading in a TOC. 6 * Uses IntersectionObserver to detect which heading is at the top of the viewport. 7 * 8 * @param toc - Reactive array of TOC items 9 * @returns Object containing activeId 10 * @public 11 */ 12export function useActiveTocItem(toc: Ref<TocItem[]>) { 13 const activeId = shallowRef<string | null>(null) 14 15 // Only run observer logic on client 16 if (import.meta.server) { 17 return { activeId } 18 } 19 20 let observer: IntersectionObserver | null = null 21 const headingElements = new Map<string, Element>() 22 23 const setupObserver = () => { 24 // Clean up previous observer 25 if (observer) { 26 observer.disconnect() 27 } 28 headingElements.clear() 29 30 // Find all heading elements that match TOC IDs 31 const ids = toc.value.map(item => item.id) 32 if (ids.length === 0) return 33 34 for (const id of ids) { 35 const el = document.getElementById(id) 36 if (el) { 37 headingElements.set(id, el) 38 } 39 } 40 41 if (headingElements.size === 0) return 42 43 // Create observer that triggers when headings cross the top 20% of viewport 44 observer = new IntersectionObserver( 45 entries => { 46 // Get all visible headings sorted by their position 47 const visibleHeadings: { id: string; top: number }[] = [] 48 49 for (const entry of entries) { 50 if (entry.isIntersecting) { 51 visibleHeadings.push({ 52 id: entry.target.id, 53 top: entry.boundingClientRect.top, 54 }) 55 } 56 } 57 58 // If there are visible headings, pick the one closest to the top 59 if (visibleHeadings.length > 0) { 60 visibleHeadings.sort((a, b) => a.top - b.top) 61 activeId.value = visibleHeadings[0]?.id ?? null 62 } else { 63 // No headings visible in intersection zone - find the one just above viewport 64 const headingsWithPosition: { id: string; top: number }[] = [] 65 for (const [id, el] of headingElements) { 66 const rect = el.getBoundingClientRect() 67 headingsWithPosition.push({ id, top: rect.top }) 68 } 69 70 // Find the heading that's closest to (but above) the viewport top 71 const aboveViewport = headingsWithPosition 72 .filter(h => h.top < 100) // Allow some buffer 73 .sort((a, b) => b.top - a.top) // Sort descending (closest to top first) 74 75 if (aboveViewport.length > 0) { 76 activeId.value = aboveViewport[0]?.id ?? null 77 } 78 } 79 }, 80 { 81 rootMargin: '-80px 0px -70% 0px', // Trigger in top ~30% of viewport (accounting for header) 82 threshold: 0, 83 }, 84 ) 85 86 // Observe all heading elements 87 for (const el of headingElements.values()) { 88 observer.observe(el) 89 } 90 } 91 92 // Set up observer when TOC changes 93 watch( 94 toc, 95 () => { 96 // Use nextTick to ensure DOM is updated 97 nextTick(setupObserver) 98 }, 99 { immediate: true }, 100 ) 101 102 // Clean up on unmount 103 onUnmounted(() => { 104 if (observer) { 105 observer.disconnect() 106 observer = null 107 } 108 }) 109 110 return { activeId } 111}