@@ -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+
114223const 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) => {
125234const 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
146269onMounted (() => {
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
155287onUnmounted (() => {
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