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