@@ -68,48 +68,126 @@ const toggleGroup = (index) => {
6868 openGroupIndex .value = index === openGroupIndex .value ? - 1 : index
6969}
7070
71- const isInViewport = (element ) => {
72- const rect = element .getBoundingClientRect ();
73-
74- return (
75- rect .top >= 0 &&
76- rect .left >= 0 &&
77- rect .bottom <= (window .innerHeight / 2 || document .documentElement .clientHeight / 2 ) &&
78- rect .right <= (window .innerWidth || document .documentElement .clientWidth )
79- );
80- };
81- watch (() => route, refreshIndex)
71+ watch (() => route, () => {
72+ refreshIndex ()
73+ // Reinitialize observer when route changes
74+ if (! props .isMobileWidth ) {
75+ setTimeout (() => {
76+ setupIntersectionObserver ()
77+ }, 100 )
78+ }
79+ })
8280
83- const checkIfScroll = () => {
84- const pageAnchors = document .querySelectorAll (' .header-anchor' )
81+ // Track visible headings for Intersection Observer
82+ const visibleHeadings = ref (new Set ())
83+ let intersectionObserver = null
84+ let scrollThrottleTimeout = null
85+
86+ // Throttle function for scroll events
87+ const throttle = (func , delay ) => {
88+ return (... args ) => {
89+ if (! scrollThrottleTimeout) {
90+ scrollThrottleTimeout = setTimeout (() => {
91+ func (... args)
92+ scrollThrottleTimeout = null
93+ }, delay)
94+ }
95+ }
96+ }
97+
98+ // Update sidebar active state based on the topmost visible heading
99+ const updateSidebarActiveState = () => {
85100 const sidebar = document .querySelector (' .sidebar' )
101+ if (! sidebar) return
102+
86103 const sidebarAnchors = sidebar .querySelectorAll (' a' )
87104 const sidebarAnchorsContainer = sidebar .querySelectorAll (' .collapsible.sidebar-sub-header' )
88- const sidebarStringLinks = Array .from (sidebarAnchors).map (a => a .getAttribute (' data-anchor' ))
89-
90- pageAnchors .forEach ((a )=> {
91- if (a .getAttribute (' data-anchor' )) return
92- a .setAttribute (' data-anchor' , page .value .path + a .hash )
105+
106+ // Get all visible headings sorted by their position (topmost first)
107+ const sortedVisibleHeadings = Array .from (visibleHeadings .value ).sort ((a , b ) => {
108+ const aRect = a .getBoundingClientRect ()
109+ const bRect = b .getBoundingClientRect ()
110+ return aRect .top - bRect .top
93111 })
94112
95- pageAnchors .forEach (a => {
96- if (isInViewport (a)) {
97- const currentLink = sidebarStringLinks .find (link => link === a .getAttribute (' data-anchor' ))
98- sidebarAnchorsContainer .forEach (container => {
99- container .querySelectorAll (' .sidebar-link-container' ).forEach (cl => {
100- if (container .querySelector (` a[data-anchor="${ currentLink} "]` )) cl .classList .remove (" collapsed" )
101- else cl .classList .add (" collapsed" )
102- })
113+ // Select the topmost visible heading
114+ const topmostHeading = sortedVisibleHeadings[0 ]
115+
116+ if (! topmostHeading) return
117+
118+ const currentAnchor = topmostHeading .getAttribute (' data-anchor' )
119+
120+ // Update active class on sidebar links
121+ sidebarAnchors .forEach (a => a .classList .remove (' active' ))
122+ const activeLink = sidebar .querySelector (` a[data-anchor="${ currentAnchor} "]` )
123+
124+ if (activeLink) {
125+ activeLink .classList .add (' active' )
126+
127+ // Expand/collapse collapsible containers
128+ sidebarAnchorsContainer .forEach (container => {
129+ container .querySelectorAll (' .sidebar-link-container' ).forEach (cl => {
130+ if (container .querySelector (` a[data-anchor="${ currentAnchor} "]` )) {
131+ cl .classList .remove (" collapsed" )
132+ } else {
133+ cl .classList .add (" collapsed" )
134+ }
103135 })
136+ })
137+ }
138+ }
104139
105- if (sidebar .querySelector (` a[data-anchor="${ currentLink} "]` )) {
106- sidebarAnchors .forEach (a => a .classList .remove (" active" ))
107- sidebar .querySelector (` a[data-anchor="${ currentLink} "]` ).classList .add (" active" )
108- }
140+ // Setup Intersection Observer for heading visibility tracking
141+ const setupIntersectionObserver = () => {
142+ const pageAnchors = document .querySelectorAll (' .header-anchor' )
143+ const sidebar = document .querySelector (' .sidebar' )
144+
145+ if (! pageAnchors .length || ! sidebar) return
146+
147+ // Set data-anchor attribute on page anchors
148+ pageAnchors .forEach ((anchor ) => {
149+ if (! anchor .getAttribute (' data-anchor' )) {
150+ anchor .setAttribute (' data-anchor' , page .value .path + anchor .hash )
151+ }
152+ })
153+
154+ // Disconnect existing observer if any
155+ if (intersectionObserver) {
156+ intersectionObserver .disconnect ()
157+ }
158+
159+ // Create new Intersection Observer
160+ // rootMargin: -80px accounts for 4rem (64px) fixed header + 16px buffer
161+ // -80% bottom margin triggers when heading enters top 20% of viewport
162+ intersectionObserver = new IntersectionObserver (
163+ (entries ) => {
164+ entries .forEach ((entry ) => {
165+ const anchor = entry .target .getAttribute (' data-anchor' )
166+ if (entry .isIntersecting ) {
167+ visibleHeadings .value .add (entry .target )
168+ } else {
169+ visibleHeadings .value .delete (entry .target )
170+ }
171+ })
172+ updateSidebarActiveState ()
173+ },
174+ {
175+ rootMargin: ' -80px 0px -80% 0px' ,
176+ threshold: [0 , 0.25 , 0.5 , 0.75 , 1 ]
109177 }
178+ )
179+
180+ // Observe all page anchors
181+ pageAnchors .forEach ((anchor ) => {
182+ intersectionObserver .observe (anchor)
110183 })
111184}
112185
186+ // Throttled version of setupIntersectionObserver for scroll events
187+ const throttledUpdateSidebar = throttle (() => {
188+ updateSidebarActiveState ()
189+ }, 100 )
190+
113191const resolveOpenGroupIndex = (route , items ) => {
114192 for (let i = 0 ; i < items .length ; i++ ) {
115193 const item = items[i]
@@ -124,36 +202,72 @@ const resolveOpenGroupIndex = (route, items) => {
124202const handleHashChange = () => {
125203 // Get the current hash from the URL
126204 const currentHash = window .location .hash ;
205+
206+ if (! currentHash) return ;
207+
208+ const sidebar = document .querySelector (' .sidebar' );
209+ if (! sidebar) return ;
127210
128211 // Find the corresponding anchor link in the sidebar
129- const sidebarAnchors = document .querySelectorAll (' .sidebar a' );
130- sidebarAnchors .forEach ((a ) => {
131- if (a .getAttribute (' data-anchor' ) === currentHash) {
132- // Remove the "active" class from all sidebar links and add it only to the current one
133- sidebarAnchors .forEach ((link ) => link .classList .remove (' active' ));
134- a .classList .add (' active' );
135-
136- // Expand the parent collapsible sidebar item, if any
137- const parentCollapsible = a .closest (' .collapsible' );
138- if (parentCollapsible) {
139- parentCollapsible .classList .remove (' collapsed' );
212+ const targetAnchor = sidebar .querySelector (` a[data-anchor="${ page .value .path }${ currentHash} "]` );
213+
214+ if (targetAnchor) {
215+ const sidebarAnchors = sidebar .querySelectorAll (' a' );
216+
217+ // Remove the "active" class from all sidebar links and add it only to the current one
218+ sidebarAnchors .forEach ((link ) => link .classList .remove (' active' ));
219+ targetAnchor .classList .add (' active' );
220+
221+ // Expand the parent collapsible sidebar item, if any
222+ const parentCollapsible = targetAnchor .closest (' .collapsible' );
223+ if (parentCollapsible) {
224+ const linkContainer = parentCollapsible .querySelector (' .sidebar-link-container' );
225+ if (linkContainer) {
226+ linkContainer .classList .remove (' collapsed' );
140227 }
141228 }
142- });
229+ }
230+
231+ // Re-setup observer after hash change to ensure proper tracking
232+ setTimeout (() => {
233+ setupIntersectionObserver ();
234+ }, 50 );
143235};
144236
145237onMounted (() => {
146238 refreshIndex ();
147- ! props .isMobileWidth ? window .addEventListener (' scroll' , checkIfScroll) : null ;
148- ! props .isMobileWidth ? window .addEventListener (' resize' , checkIfScroll) : null ;
239+
240+ if (! props .isMobileWidth ) {
241+ // Setup Intersection Observer after DOM is fully rendered
242+ setTimeout (() => {
243+ setupIntersectionObserver ()
244+ }, 100 )
245+
246+ // Add throttled scroll listener as backup
247+ window .addEventListener (' scroll' , throttledUpdateSidebar)
248+ window .addEventListener (' resize' , throttledUpdateSidebar)
249+ }
149250
150251 // Listen to the "hashchange" event to handle direct anchor link access
151252 window .addEventListener (' hashchange' , handleHashChange);
152253});
153254
154255onUnmounted (() => {
155- window .removeEventListener (' scroll' , checkIfScroll);
156- window .removeEventListener (' resize' , checkIfScroll);
256+ // Disconnect Intersection Observer
257+ if (intersectionObserver) {
258+ intersectionObserver .disconnect ()
259+ intersectionObserver = null
260+ }
261+
262+ // Clear any pending throttle timeout
263+ if (scrollThrottleTimeout) {
264+ clearTimeout (scrollThrottleTimeout)
265+ scrollThrottleTimeout = null
266+ }
267+
268+ // Remove event listeners
269+ window .removeEventListener (' scroll' , throttledUpdateSidebar);
270+ window .removeEventListener (' resize' , throttledUpdateSidebar);
157271 window .removeEventListener (' hashchange' , handleHashChange);
158272});
159273
0 commit comments