forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}