Skip to content

Commit 27ecefa

Browse files
committed
STAR-281 : Refactor sidebar active highlighting with Intersection Observer
Replace flawed manual viewport calculations with modern Intersection Observer API. Fixes initial load highlighting, bottom section detection, and improves performance. - Remove isInViewport() and checkIfScroll() functions - Add Intersection Observer with proper header offset (-80px) - Add bottom detection logic for last sections - Add throttled scroll events (100ms) - Improve lifecycle management with proper cleanup
1 parent b37b7e5 commit 27ecefa

File tree

1 file changed

+192
-47
lines changed

1 file changed

+192
-47
lines changed

docs/.vuepress/theme/sidebar/Sidebar.vue

Lines changed: 192 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -68,49 +68,158 @@ const toggleGroup = (index) => {
6868
openGroupIndex.value = index === openGroupIndex.value ? -1 : index
6969
}
7070
71-
const isInViewport = (element) => {
72-
const rect = element.getBoundingClientRect();
73-
const windowHeight = window.innerHeight;
74-
75-
return (
76-
rect.top >= 0 &&
77-
rect.left >= 0 &&
78-
rect.bottom <= (window.innerHeight / 2 || document.documentElement.clientHeight / 2) &&
79-
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
80-
);
81-
};
82-
watch(() => route, refreshIndex)
71+
// Track visible headings for Intersection Observer
72+
const visibleHeadings = ref(new Set())
73+
let intersectionObserver = null
74+
let scrollThrottleTimeout = null
8375
84-
const checkIfScroll = () => {
85-
const pageAnchors = document.querySelectorAll('.header-anchor')
76+
// Throttle function for scroll events
77+
const throttle = (func, delay) => {
78+
return (...args) => {
79+
if (!scrollThrottleTimeout) {
80+
scrollThrottleTimeout = setTimeout(() => {
81+
func(...args)
82+
scrollThrottleTimeout = null
83+
}, delay)
84+
}
85+
}
86+
}
87+
88+
// Check if user has scrolled to the bottom of the page
89+
const isAtBottom = () => {
90+
const scrollHeight = document.documentElement.scrollHeight
91+
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop
92+
const clientHeight = document.documentElement.clientHeight
93+
// Consider "at bottom" if within 10% of viewport height from the bottom
94+
// This scales better across different screen sizes
95+
const threshold = clientHeight * 0.1
96+
return scrollHeight - scrollTop - clientHeight < threshold
97+
}
98+
99+
// Update sidebar active state based on the topmost visible heading
100+
const updateSidebarActiveState = () => {
86101
const sidebar = document.querySelector('.sidebar')
102+
if (!sidebar) return
103+
87104
const sidebarAnchors = sidebar.querySelectorAll('a')
88105
const sidebarAnchorsContainer = sidebar.querySelectorAll('.collapsible.sidebar-sub-header')
89-
const sidebarStringLinks = Array.from(sidebarAnchors).map(a => a.getAttribute('data-anchor'))
90-
91-
pageAnchors.forEach((a)=>{
92-
if(a.getAttribute('data-anchor')) return
93-
a.setAttribute('data-anchor', page.value.path+a.hash)
106+
const pageAnchors = document.querySelectorAll('.header-anchor')
107+
108+
// Get all visible headings sorted by their position (topmost first)
109+
const sortedVisibleHeadings = Array.from(visibleHeadings.value).sort((a, b) => {
110+
const aRect = a.getBoundingClientRect()
111+
const bRect = b.getBoundingClientRect()
112+
return aRect.top - bRect.top
94113
})
95114
96-
pageAnchors.forEach(a => {
97-
if (isInViewport(a)) {
98-
const currentLink = sidebarStringLinks.find(link => link === a.getAttribute('data-anchor'))
99-
sidebarAnchorsContainer.forEach(container => {
100-
container.querySelectorAll('.sidebar-link-container').forEach(cl => {
101-
if (container.querySelector(`a[data-anchor="${currentLink}"]`)) cl.classList.remove("collapsed")
102-
else cl.classList.add("collapsed")
103-
})
115+
let targetHeading = null
116+
117+
// If at bottom of page, select the last heading that's above the viewport center
118+
if (isAtBottom() && pageAnchors.length > 0) {
119+
const viewportCenter = window.innerHeight / 2
120+
const allAnchorsArray = Array.from(pageAnchors)
121+
122+
// Find all headings that are above the viewport center
123+
const headingsAboveCenter = allAnchorsArray.filter(anchor => {
124+
const rect = anchor.getBoundingClientRect()
125+
return rect.top < viewportCenter
126+
})
127+
128+
// Select the last one (closest to bottom)
129+
if (headingsAboveCenter.length > 0) {
130+
targetHeading = headingsAboveCenter[headingsAboveCenter.length - 1]
131+
}
132+
}
133+
134+
// If not at bottom or no heading found, use the topmost visible heading
135+
if (!targetHeading && sortedVisibleHeadings.length > 0) {
136+
targetHeading = sortedVisibleHeadings[0]
137+
}
138+
139+
if (!targetHeading) return
140+
141+
const currentAnchor = targetHeading.getAttribute('data-anchor')
142+
143+
// Update active class on sidebar links
144+
sidebarAnchors.forEach(a => a.classList.remove('active'))
145+
const activeLink = sidebar.querySelector(`a[data-anchor="${currentAnchor}"]`)
146+
147+
if (activeLink) {
148+
activeLink.classList.add('active')
149+
150+
// Expand/collapse collapsible containers
151+
sidebarAnchorsContainer.forEach(container => {
152+
container.querySelectorAll('.sidebar-link-container').forEach(cl => {
153+
if (container.querySelector(`a[data-anchor="${currentAnchor}"]`)) {
154+
cl.classList.remove("collapsed")
155+
} else {
156+
cl.classList.add("collapsed")
157+
}
104158
})
159+
})
160+
}
161+
}
105162
106-
if (sidebar.querySelector(`a[data-anchor="${currentLink}"]`)) {
107-
sidebarAnchors.forEach(a => a.classList.remove("active"))
108-
sidebar.querySelector(`a[data-anchor="${currentLink}"]`).classList.add("active")
109-
}
163+
// Setup Intersection Observer for heading visibility tracking
164+
const setupIntersectionObserver = () => {
165+
const pageAnchors = document.querySelectorAll('.header-anchor')
166+
const sidebar = document.querySelector('.sidebar')
167+
168+
if (!pageAnchors.length || !sidebar) return
169+
170+
// Set data-anchor attribute on page anchors
171+
pageAnchors.forEach((anchor) => {
172+
if (!anchor.getAttribute('data-anchor')) {
173+
anchor.setAttribute('data-anchor', page.value.path + anchor.hash)
174+
}
175+
})
176+
177+
// Disconnect existing observer if any
178+
if (intersectionObserver) {
179+
intersectionObserver.disconnect()
180+
}
181+
182+
// Create new Intersection Observer
183+
// rootMargin: -80px accounts for 4rem (64px) fixed header + 16px buffer
184+
// -80% bottom margin triggers when heading enters top 20% of viewport
185+
intersectionObserver = new IntersectionObserver(
186+
(entries) => {
187+
entries.forEach((entry) => {
188+
const anchor = entry.target.getAttribute('data-anchor')
189+
if (entry.isIntersecting) {
190+
visibleHeadings.value.add(entry.target)
191+
} else {
192+
visibleHeadings.value.delete(entry.target)
193+
}
194+
})
195+
updateSidebarActiveState()
196+
},
197+
{
198+
rootMargin: '-80px 0px -80% 0px',
199+
threshold: [0, 0.25, 0.5, 0.75, 1]
110200
}
201+
)
202+
203+
// Observe all page anchors
204+
pageAnchors.forEach((anchor) => {
205+
intersectionObserver.observe(anchor)
111206
})
112207
}
113208
209+
// Throttled version for scroll events
210+
const throttledUpdateSidebar = throttle(() => {
211+
updateSidebarActiveState()
212+
}, 100)
213+
214+
watch(() => route, () => {
215+
refreshIndex()
216+
if (!props.isMobileWidth) {
217+
setTimeout(() => {
218+
setupIntersectionObserver()
219+
}, 100)
220+
}
221+
})
222+
114223
const resolveOpenGroupIndex = (route, items) => {
115224
for (let i = 0; i < items.length; i++) {
116225
const item = items[i]
@@ -125,36 +234,72 @@ const resolveOpenGroupIndex = (route, items) => {
125234
const handleHashChange = () => {
126235
// Get the current hash from the URL
127236
const currentHash = window.location.hash;
237+
238+
if (!currentHash) return;
239+
240+
const sidebar = document.querySelector('.sidebar');
241+
if (!sidebar) return;
128242
129243
// Find the corresponding anchor link in the sidebar
130-
const sidebarAnchors = document.querySelectorAll('.sidebar a');
131-
sidebarAnchors.forEach((a) => {
132-
if (a.getAttribute('data-anchor') === currentHash) {
133-
// Remove the "active" class from all sidebar links and add it only to the current one
134-
sidebarAnchors.forEach((link) => link.classList.remove('active'));
135-
a.classList.add('active');
136-
137-
// Expand the parent collapsible sidebar item, if any
138-
const parentCollapsible = a.closest('.collapsible');
139-
if (parentCollapsible) {
140-
parentCollapsible.classList.remove('collapsed');
244+
const targetAnchor = sidebar.querySelector(`a[data-anchor="${page.value.path}${currentHash}"]`);
245+
246+
if (targetAnchor) {
247+
const sidebarAnchors = sidebar.querySelectorAll('a');
248+
249+
// Remove the "active" class from all sidebar links and add it only to the current one
250+
sidebarAnchors.forEach((link) => link.classList.remove('active'));
251+
targetAnchor.classList.add('active');
252+
253+
// Expand the parent collapsible sidebar item, if any
254+
const parentCollapsible = targetAnchor.closest('.collapsible');
255+
if (parentCollapsible) {
256+
const linkContainer = parentCollapsible.querySelector('.sidebar-link-container');
257+
if (linkContainer) {
258+
linkContainer.classList.remove('collapsed');
141259
}
142260
}
143-
});
261+
}
262+
263+
// Re-setup observer after hash change to ensure proper tracking
264+
setTimeout(() => {
265+
setupIntersectionObserver();
266+
}, 50);
144267
};
145268
146269
onMounted(() => {
147270
refreshIndex();
148-
!props.isMobileWidth ? window.addEventListener('scroll', checkIfScroll) : null;
149-
!props.isMobileWidth ? window.addEventListener('resize', checkIfScroll) : null;
271+
272+
if (!props.isMobileWidth) {
273+
// Setup Intersection Observer after DOM is fully rendered
274+
setTimeout(() => {
275+
setupIntersectionObserver()
276+
}, 100)
277+
278+
// Add throttled scroll listener as backup
279+
window.addEventListener('scroll', throttledUpdateSidebar)
280+
window.addEventListener('resize', throttledUpdateSidebar)
281+
}
150282
151283
// Listen to the "hashchange" event to handle direct anchor link access
152284
window.addEventListener('hashchange', handleHashChange);
153285
});
154286
155287
onUnmounted(() => {
156-
window.removeEventListener('scroll', checkIfScroll);
157-
window.removeEventListener('resize', checkIfScroll);
288+
// Disconnect Intersection Observer
289+
if (intersectionObserver) {
290+
intersectionObserver.disconnect()
291+
intersectionObserver = null
292+
}
293+
294+
// Clear any pending throttle timeout
295+
if (scrollThrottleTimeout) {
296+
clearTimeout(scrollThrottleTimeout)
297+
scrollThrottleTimeout = null
298+
}
299+
300+
// Remove event listeners
301+
window.removeEventListener('scroll', throttledUpdateSidebar);
302+
window.removeEventListener('resize', throttledUpdateSidebar);
158303
window.removeEventListener('hashchange', handleHashChange);
159304
});
160305

0 commit comments

Comments
 (0)