Skip to content

Commit 5a9131c

Browse files
committed
Add URL hash navigation for direct section linking
- URL updates when scrolling (replaceState) or clicking ToC items (pushState) - Loading page with hash scrolls to target section once discovered - Instantly scrolls past hero/intro when loading with hash for faster access - Browser back/forward navigation works with hash changes - Section IDs are now stable (no counter suffix) for reliable deep links
1 parent 560b3b4 commit 5a9131c

1 file changed

Lines changed: 88 additions & 26 deletions

File tree

src/components/TableOfContents.jsx

Lines changed: 88 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useRef } from 'react';
1+
import { useState, useEffect, useRef, useCallback } from 'react';
22
import { cn } from '../styles/classNames';
33
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
44
import { faInfo } from '@fortawesome/free-solid-svg-icons';
@@ -10,6 +10,10 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
1010
const [showSidebarToggle, setShowSidebarToggle] = useState(false);
1111
const [showSidebarTooltip, setShowSidebarTooltip] = useState(false);
1212
const tooltipTimeoutRef = useRef(null);
13+
const [pendingHash, setPendingHash] = useState(() => {
14+
return window.location.hash ? window.location.hash.slice(1) : null;
15+
});
16+
const isScrollingRef = useRef(false);
1317

1418
// Cleanup tooltip timeout on unmount
1519
useEffect(() => {
@@ -20,6 +24,21 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
2024
};
2125
}, []);
2226

27+
// When loading with a hash, immediately scroll past hero/intro to reduce wait time
28+
useEffect(() => {
29+
if (pendingHash) {
30+
// Find the dashboard section start (after intro) and scroll there instantly
31+
const dashboardStart = document.getElementById('introduction-section');
32+
if (dashboardStart) {
33+
// Instant scroll to just past the intro, then smooth scroll to target will happen later
34+
window.scrollTo({
35+
top: dashboardStart.offsetTop + dashboardStart.offsetHeight,
36+
behavior: 'instant'
37+
});
38+
}
39+
}
40+
}, []); // Only run once on mount
41+
2342
// Extract headings from chart components and direct h3 headings
2443
useEffect(() => {
2544
if (!containerRef?.current) return;
@@ -30,7 +49,6 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
3049
// Use a Map to track unique headings by text content
3150
const uniqueHeadings = new Map();
3251
const headingsList = [];
33-
let sectionCounter = 0;
3452

3553
// Find ALL h3 elements in document order
3654
const allHeadings = container.querySelectorAll('h3');
@@ -54,11 +72,10 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
5472

5573
// Only add if we haven't seen this text before
5674
if (!uniqueHeadings.has(text)) {
57-
// Create a stable ID if it doesn't exist, based on cleaned text
75+
// Create a stable ID if it doesn't exist, based on cleaned text (no counter for stable URLs)
5876
if (!heading.id) {
5977
const cleanText = text.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
60-
heading.id = `section-${cleanText}-${sectionCounter}`;
61-
sectionCounter++;
78+
heading.id = `section-${cleanText}`;
6279
}
6380

6481
const chartContainer = heading.closest('[data-chart-id]');
@@ -98,6 +115,44 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
98115
return () => observer.disconnect();
99116
}, [containerRef]);
100117

118+
// Scroll to a section by ID
119+
const scrollToSection = useCallback((sectionId, updateHistory = true) => {
120+
const element = document.getElementById(sectionId);
121+
if (element) {
122+
// Try to find the associated chart container for better scrolling
123+
let targetElement = element;
124+
const chartContainer = element.closest('[data-chart-id]');
125+
if (chartContainer) {
126+
targetElement = chartContainer;
127+
}
128+
129+
// Get the element's position relative to the viewport
130+
const elementRect = targetElement.getBoundingClientRect();
131+
132+
// Calculate scroll position (current scroll + element position - offset for sticky header)
133+
const headerOffset = 60; // Account for sticky header (48px) + small buffer
134+
const targetScrollPosition = window.scrollY + elementRect.top - headerOffset;
135+
136+
// Mark that we're programmatically scrolling to prevent URL spam
137+
isScrollingRef.current = true;
138+
139+
// Update URL hash (use pushState for clicks so back button works)
140+
if (updateHistory) {
141+
window.history.pushState(null, '', `#${sectionId}`);
142+
}
143+
144+
window.scrollTo({
145+
top: targetScrollPosition,
146+
behavior: 'smooth'
147+
});
148+
149+
// Reset scrolling flag after animation completes
150+
setTimeout(() => {
151+
isScrollingRef.current = false;
152+
}, 500);
153+
}
154+
}, []);
155+
101156
// Simple scroll-based active section tracking
102157
useEffect(() => {
103158
if (!containerRef?.current || sections.length === 0) return;
@@ -143,6 +198,10 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
143198
// Clear active section found
144199
if (activeId !== activeSection) {
145200
setActiveSection(activeId);
201+
// Update URL hash without triggering scroll (only when not programmatically scrolling)
202+
if (!isScrollingRef.current) {
203+
window.history.replaceState(null, '', `#${activeId}`);
204+
}
146205
}
147206
} else if (!activeSection && fallbackId) {
148207
// Only use fallback if we don't have any active section yet (initial load)
@@ -166,29 +225,32 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
166225
};
167226
}, [containerRef, sections, activeSection]);
168227

169-
const scrollToSection = (sectionId) => {
170-
const element = document.getElementById(sectionId);
171-
if (element) {
172-
// Try to find the associated chart container for better scrolling
173-
let targetElement = element;
174-
const chartContainer = element.closest('[data-chart-id]');
175-
if (chartContainer) {
176-
targetElement = chartContainer;
177-
}
178-
179-
// Get the element's position relative to the viewport
180-
const elementRect = targetElement.getBoundingClientRect();
228+
// Handle initial page load with hash - wait for section to be discovered
229+
useEffect(() => {
230+
if (!pendingHash || sections.length === 0) return;
231+
232+
const targetSection = sections.find(s => s.id === pendingHash);
233+
if (targetSection) {
234+
// Small delay to ensure layout is stable after charts load
235+
setTimeout(() => {
236+
scrollToSection(pendingHash, false);
237+
setPendingHash(null);
238+
}, 100);
239+
}
240+
}, [sections, pendingHash, scrollToSection]);
181241

182-
// Calculate scroll position (current scroll + element position - offset for sticky header)
183-
const headerOffset = 60; // Account for sticky header (48px) + small buffer
184-
const targetScrollPosition = window.scrollY + elementRect.top - headerOffset;
242+
// Handle browser back/forward navigation
243+
useEffect(() => {
244+
const handlePopState = () => {
245+
const hash = window.location.hash.slice(1);
246+
if (hash) {
247+
scrollToSection(hash, false);
248+
}
249+
};
185250

186-
window.scrollTo({
187-
top: targetScrollPosition,
188-
behavior: 'smooth'
189-
});
190-
}
191-
};
251+
window.addEventListener('popstate', handlePopState);
252+
return () => window.removeEventListener('popstate', handlePopState);
253+
}, [scrollToSection]);
192254

193255
// Filter sections based on search query
194256
const filteredSections = sections.filter(section =>

0 commit comments

Comments
 (0)