jekyll-theme-zer0 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -10
- data/README.md +7 -7
- data/_data/authors.yml +2 -2
- data/_data/features.yml +676 -0
- data/_data/navigation/README.md +54 -0
- data/_data/navigation/about.yml +7 -5
- data/_data/navigation/docs.yml +77 -31
- data/_data/navigation/home.yml +4 -3
- data/_data/navigation/main.yml +16 -7
- data/_data/navigation/quickstart.yml +4 -2
- data/_includes/components/js-cdn.html +2 -2
- data/_includes/landing/landing-install-cards.html +8 -8
- data/_includes/landing/landing-quick-links.html +4 -4
- data/_includes/navigation/breadcrumbs.html +29 -6
- data/_includes/navigation/nav-tree.html +181 -0
- data/_includes/navigation/navbar.html +262 -9
- data/_includes/navigation/sidebar-left.html +22 -23
- data/_layouts/default.html +1 -1
- data/_layouts/landing.html +1 -1
- data/_layouts/notebook.html +4 -4
- data/_layouts/root.html +2 -2
- data/_sass/core/_nav-tree.scss +145 -0
- data/_sass/custom.scss +3 -0
- data/assets/js/modules/navigation/config.js +149 -0
- data/assets/js/modules/navigation/focus.js +189 -0
- data/assets/js/modules/navigation/gestures.js +179 -0
- data/assets/js/modules/navigation/index.js +227 -0
- data/assets/js/modules/navigation/keyboard.js +237 -0
- data/assets/js/modules/navigation/scroll-spy.js +219 -0
- data/assets/js/modules/navigation/sidebar-state.js +267 -0
- data/assets/js/modules/navigation/smooth-scroll.js +153 -0
- data/scripts/migrate-nav-modes.sh +146 -0
- metadata +15 -3
- data/assets/js/sidebar.js +0 -511
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ===================================================================
|
|
3
|
+
* NAVIGATION - Main Entry Point
|
|
4
|
+
* ===================================================================
|
|
5
|
+
*
|
|
6
|
+
* File: index.js
|
|
7
|
+
* Path: assets/js/modules/navigation/index.js
|
|
8
|
+
* Purpose: Main orchestrator for all navigation modules
|
|
9
|
+
*
|
|
10
|
+
* This module imports and initializes all navigation-related modules:
|
|
11
|
+
* - ScrollSpy: Highlights current section in TOC
|
|
12
|
+
* - SmoothScroll: Smooth anchor navigation
|
|
13
|
+
* - KeyboardShortcuts: Keyboard navigation
|
|
14
|
+
* - SwipeGestures: Mobile gesture support
|
|
15
|
+
* - FocusManager: Accessibility focus management
|
|
16
|
+
* - SidebarState: Persistence of sidebar state
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* <script type="module" src="/assets/js/modules/navigation/index.js"></script>
|
|
20
|
+
*
|
|
21
|
+
* Or import programmatically:
|
|
22
|
+
* import { Navigation } from './modules/navigation/index.js';
|
|
23
|
+
* const nav = new Navigation();
|
|
24
|
+
*
|
|
25
|
+
* ===================================================================
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { config } from './config.js';
|
|
29
|
+
import { ScrollSpy } from './scroll-spy.js';
|
|
30
|
+
import { SmoothScroll } from './smooth-scroll.js';
|
|
31
|
+
import { KeyboardShortcuts } from './keyboard.js';
|
|
32
|
+
import { SwipeGestures } from './gestures.js';
|
|
33
|
+
import { FocusManager } from './focus.js';
|
|
34
|
+
import { SidebarState } from './sidebar-state.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Navigation Controller - Orchestrates all navigation modules
|
|
38
|
+
*/
|
|
39
|
+
export class Navigation {
|
|
40
|
+
constructor() {
|
|
41
|
+
this.modules = {};
|
|
42
|
+
this._initialized = false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Initialize all navigation modules
|
|
47
|
+
* @returns {Navigation} this instance for chaining
|
|
48
|
+
*/
|
|
49
|
+
init() {
|
|
50
|
+
if (this._initialized) {
|
|
51
|
+
console.warn('Navigation: Already initialized');
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// Initialize sidebar state first (other modules may depend on it)
|
|
57
|
+
this.modules.state = new SidebarState();
|
|
58
|
+
|
|
59
|
+
// Initialize TOC-related modules only if TOC exists
|
|
60
|
+
const toc = document.querySelector(config.selectors.toc);
|
|
61
|
+
if (toc) {
|
|
62
|
+
this.modules.scrollSpy = new ScrollSpy();
|
|
63
|
+
this.modules.smoothScroll = new SmoothScroll();
|
|
64
|
+
} else {
|
|
65
|
+
console.log('Navigation: No TOC found, skipping scroll spy and smooth scroll');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Initialize keyboard shortcuts
|
|
69
|
+
this.modules.keyboard = new KeyboardShortcuts();
|
|
70
|
+
|
|
71
|
+
// Initialize gesture support
|
|
72
|
+
this.modules.gestures = new SwipeGestures();
|
|
73
|
+
|
|
74
|
+
// Initialize focus management
|
|
75
|
+
this.modules.focus = new FocusManager();
|
|
76
|
+
|
|
77
|
+
this._initialized = true;
|
|
78
|
+
|
|
79
|
+
// Dispatch ready event
|
|
80
|
+
document.dispatchEvent(new CustomEvent('navigation:ready', {
|
|
81
|
+
detail: {
|
|
82
|
+
modules: Object.keys(this.modules)
|
|
83
|
+
}
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
console.log('Navigation: Successfully initialized modules:', Object.keys(this.modules));
|
|
87
|
+
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('Navigation: Initialization error', error);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get a specific module instance
|
|
97
|
+
* @param {string} name - Module name
|
|
98
|
+
* @returns {Object|undefined}
|
|
99
|
+
*/
|
|
100
|
+
getModule(name) {
|
|
101
|
+
return this.modules[name];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get configuration
|
|
106
|
+
* @returns {Object}
|
|
107
|
+
*/
|
|
108
|
+
getConfig() {
|
|
109
|
+
return config;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Scroll to a specific element
|
|
114
|
+
* @param {string|Element} target - Element ID or Element
|
|
115
|
+
* @param {number} [offset] - Optional scroll offset
|
|
116
|
+
*/
|
|
117
|
+
scrollTo(target, offset) {
|
|
118
|
+
if (!this.modules.smoothScroll) return;
|
|
119
|
+
|
|
120
|
+
if (typeof target === 'string') {
|
|
121
|
+
this.modules.smoothScroll.scrollToId(target, offset);
|
|
122
|
+
} else if (target instanceof Element) {
|
|
123
|
+
this.modules.smoothScroll.scrollToElement(target, offset);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Expand sidebar tree to show a specific item
|
|
129
|
+
* @param {string} targetId - ID of element to reveal
|
|
130
|
+
*/
|
|
131
|
+
expandTo(targetId) {
|
|
132
|
+
if (this.modules.state) {
|
|
133
|
+
this.modules.state.expandPathTo(targetId);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Expand all sidebar tree nodes
|
|
139
|
+
*/
|
|
140
|
+
expandAll() {
|
|
141
|
+
if (this.modules.state) {
|
|
142
|
+
this.modules.state.expandAll();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Collapse all sidebar tree nodes
|
|
148
|
+
*/
|
|
149
|
+
collapseAll() {
|
|
150
|
+
if (this.modules.state) {
|
|
151
|
+
this.modules.state.collapseAll();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get keyboard shortcuts for help display
|
|
157
|
+
* @returns {Object|null}
|
|
158
|
+
*/
|
|
159
|
+
getShortcuts() {
|
|
160
|
+
if (this.modules.keyboard && this.modules.keyboard.getShortcuts) {
|
|
161
|
+
return this.modules.keyboard.getShortcuts();
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Destroy all modules and cleanup
|
|
168
|
+
*/
|
|
169
|
+
destroy() {
|
|
170
|
+
Object.values(this.modules).forEach(module => {
|
|
171
|
+
if (module && typeof module.destroy === 'function') {
|
|
172
|
+
module.destroy();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
this.modules = {};
|
|
177
|
+
this._initialized = false;
|
|
178
|
+
|
|
179
|
+
document.dispatchEvent(new CustomEvent('navigation:destroyed'));
|
|
180
|
+
console.log('Navigation: Destroyed all modules');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ===================================================================
|
|
185
|
+
// AUTO-INITIALIZATION
|
|
186
|
+
// ===================================================================
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Create and initialize navigation when DOM is ready
|
|
190
|
+
*/
|
|
191
|
+
function initNavigation() {
|
|
192
|
+
// Create global instance
|
|
193
|
+
window.zer0Navigation = new Navigation();
|
|
194
|
+
|
|
195
|
+
// Check if Bootstrap is available
|
|
196
|
+
if (typeof bootstrap === 'undefined') {
|
|
197
|
+
console.warn('Navigation: Bootstrap not found, waiting for load event');
|
|
198
|
+
window.addEventListener('load', () => {
|
|
199
|
+
window.zer0Navigation.init();
|
|
200
|
+
});
|
|
201
|
+
} else {
|
|
202
|
+
window.zer0Navigation.init();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Wait for DOM to be ready
|
|
207
|
+
if (document.readyState === 'loading') {
|
|
208
|
+
document.addEventListener('DOMContentLoaded', initNavigation);
|
|
209
|
+
} else {
|
|
210
|
+
// DOM already loaded
|
|
211
|
+
initNavigation();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ===================================================================
|
|
215
|
+
// EXPORTS
|
|
216
|
+
// ===================================================================
|
|
217
|
+
|
|
218
|
+
// Export individual modules for advanced usage
|
|
219
|
+
export { config } from './config.js';
|
|
220
|
+
export { ScrollSpy } from './scroll-spy.js';
|
|
221
|
+
export { SmoothScroll } from './smooth-scroll.js';
|
|
222
|
+
export { KeyboardShortcuts } from './keyboard.js';
|
|
223
|
+
export { SwipeGestures } from './gestures.js';
|
|
224
|
+
export { FocusManager } from './focus.js';
|
|
225
|
+
export { SidebarState } from './sidebar-state.js';
|
|
226
|
+
|
|
227
|
+
export default Navigation;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ===================================================================
|
|
3
|
+
* KEYBOARD SHORTCUTS - Navigation Enhancements
|
|
4
|
+
* ===================================================================
|
|
5
|
+
*
|
|
6
|
+
* File: keyboard.js
|
|
7
|
+
* Path: assets/js/modules/navigation/keyboard.js
|
|
8
|
+
* Purpose: Keyboard shortcuts for navigation and accessibility
|
|
9
|
+
*
|
|
10
|
+
* Default Shortcuts:
|
|
11
|
+
* - [ : Navigate to previous section
|
|
12
|
+
* - ] : Navigate to next section
|
|
13
|
+
* - / : Focus search (when implemented)
|
|
14
|
+
* - b : Toggle left sidebar
|
|
15
|
+
* - t : Toggle TOC sidebar
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* import { KeyboardShortcuts } from './keyboard.js';
|
|
19
|
+
* const shortcuts = new KeyboardShortcuts();
|
|
20
|
+
*
|
|
21
|
+
* ===================================================================
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { config, isBelowBreakpoint } from './config.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get all elements safely
|
|
28
|
+
* @param {string} selector - CSS selector
|
|
29
|
+
* @returns {NodeList}
|
|
30
|
+
*/
|
|
31
|
+
function getElements(selector) {
|
|
32
|
+
try {
|
|
33
|
+
return document.querySelectorAll(selector);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class KeyboardShortcuts {
|
|
40
|
+
constructor() {
|
|
41
|
+
if (!config.keyboard.enabled) {
|
|
42
|
+
console.log('KeyboardShortcuts: Disabled via config');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.tocLinks = Array.from(getElements(config.selectors.tocLinks));
|
|
47
|
+
this.currentIndex = -1;
|
|
48
|
+
this._boundHandler = this._handleKeydown.bind(this);
|
|
49
|
+
|
|
50
|
+
this._init();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Initialize keyboard event listeners
|
|
55
|
+
* @private
|
|
56
|
+
*/
|
|
57
|
+
_init() {
|
|
58
|
+
document.addEventListener('keydown', this._boundHandler);
|
|
59
|
+
console.log('KeyboardShortcuts: Initialized');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Handle keydown events
|
|
64
|
+
* @private
|
|
65
|
+
* @param {KeyboardEvent} event
|
|
66
|
+
*/
|
|
67
|
+
_handleKeydown(event) {
|
|
68
|
+
// Ignore if user is typing in an input
|
|
69
|
+
if (event.target.matches('input, textarea, select, [contenteditable="true"]')) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { keys } = config.keyboard;
|
|
74
|
+
|
|
75
|
+
switch(event.key) {
|
|
76
|
+
case keys.previousSection:
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
this._navigatePrevious();
|
|
79
|
+
break;
|
|
80
|
+
|
|
81
|
+
case keys.nextSection:
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
this._navigateNext();
|
|
84
|
+
break;
|
|
85
|
+
|
|
86
|
+
case keys.search:
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
this._focusSearch();
|
|
89
|
+
break;
|
|
90
|
+
|
|
91
|
+
case keys.toggleSidebar:
|
|
92
|
+
// Only handle if not typing
|
|
93
|
+
if (!event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
this._toggleSidebar();
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
case keys.toggleToc:
|
|
100
|
+
// Only handle if not typing
|
|
101
|
+
if (!event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
this._toggleToc();
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Navigate to previous section
|
|
111
|
+
* @private
|
|
112
|
+
*/
|
|
113
|
+
_navigatePrevious() {
|
|
114
|
+
this._updateCurrentIndex();
|
|
115
|
+
|
|
116
|
+
if (this.currentIndex > 0) {
|
|
117
|
+
this.currentIndex--;
|
|
118
|
+
this._navigateToIndex(this.currentIndex);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Navigate to next section
|
|
124
|
+
* @private
|
|
125
|
+
*/
|
|
126
|
+
_navigateNext() {
|
|
127
|
+
this._updateCurrentIndex();
|
|
128
|
+
|
|
129
|
+
if (this.currentIndex < this.tocLinks.length - 1) {
|
|
130
|
+
this.currentIndex++;
|
|
131
|
+
this._navigateToIndex(this.currentIndex);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Update current index based on active link
|
|
137
|
+
* @private
|
|
138
|
+
*/
|
|
139
|
+
_updateCurrentIndex() {
|
|
140
|
+
const activeLink = document.querySelector(`${config.selectors.tocLinks}.active`);
|
|
141
|
+
if (activeLink) {
|
|
142
|
+
this.currentIndex = this.tocLinks.indexOf(activeLink);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Navigate to a specific index
|
|
148
|
+
* @private
|
|
149
|
+
* @param {number} index
|
|
150
|
+
*/
|
|
151
|
+
_navigateToIndex(index) {
|
|
152
|
+
const link = this.tocLinks[index];
|
|
153
|
+
if (link) {
|
|
154
|
+
link.click();
|
|
155
|
+
|
|
156
|
+
// Dispatch event for tracking
|
|
157
|
+
document.dispatchEvent(new CustomEvent('navigation:keyboardNav', {
|
|
158
|
+
detail: {
|
|
159
|
+
direction: index > this.currentIndex ? 'next' : 'previous',
|
|
160
|
+
index: index
|
|
161
|
+
}
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Focus search input
|
|
168
|
+
* @private
|
|
169
|
+
*/
|
|
170
|
+
_focusSearch() {
|
|
171
|
+
const searchInput = document.querySelector('#search-input, [data-search-input]');
|
|
172
|
+
if (searchInput) {
|
|
173
|
+
searchInput.focus();
|
|
174
|
+
} else {
|
|
175
|
+
console.log('KeyboardShortcuts: Search not yet implemented');
|
|
176
|
+
// Dispatch event so other modules can handle
|
|
177
|
+
document.dispatchEvent(new CustomEvent('navigation:searchRequest'));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Toggle left sidebar
|
|
183
|
+
* @private
|
|
184
|
+
*/
|
|
185
|
+
_toggleSidebar() {
|
|
186
|
+
const sidebar = document.querySelector(config.selectors.leftSidebar);
|
|
187
|
+
if (sidebar && typeof bootstrap !== 'undefined') {
|
|
188
|
+
const bsOffcanvas = bootstrap.Offcanvas.getOrCreateInstance(sidebar);
|
|
189
|
+
bsOffcanvas.toggle();
|
|
190
|
+
|
|
191
|
+
document.dispatchEvent(new CustomEvent('navigation:sidebarToggle', {
|
|
192
|
+
detail: { sidebar: 'left' }
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Toggle TOC sidebar
|
|
199
|
+
* @private
|
|
200
|
+
*/
|
|
201
|
+
_toggleToc() {
|
|
202
|
+
const toc = document.querySelector(config.selectors.rightSidebar);
|
|
203
|
+
if (toc && typeof bootstrap !== 'undefined') {
|
|
204
|
+
const bsOffcanvas = bootstrap.Offcanvas.getOrCreateInstance(toc);
|
|
205
|
+
bsOffcanvas.toggle();
|
|
206
|
+
|
|
207
|
+
document.dispatchEvent(new CustomEvent('navigation:sidebarToggle', {
|
|
208
|
+
detail: { sidebar: 'toc' }
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get available shortcuts for help display
|
|
215
|
+
* @returns {Object} Map of key to description
|
|
216
|
+
*/
|
|
217
|
+
getShortcuts() {
|
|
218
|
+
const { keys } = config.keyboard;
|
|
219
|
+
return {
|
|
220
|
+
[keys.previousSection]: 'Previous section',
|
|
221
|
+
[keys.nextSection]: 'Next section',
|
|
222
|
+
[keys.search]: 'Focus search',
|
|
223
|
+
[keys.toggleSidebar]: 'Toggle sidebar',
|
|
224
|
+
[keys.toggleToc]: 'Toggle table of contents'
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Cleanup event listeners
|
|
230
|
+
*/
|
|
231
|
+
destroy() {
|
|
232
|
+
document.removeEventListener('keydown', this._boundHandler);
|
|
233
|
+
console.log('KeyboardShortcuts: Destroyed');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export default KeyboardShortcuts;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ===================================================================
|
|
3
|
+
* SCROLL SPY - Intersection Observer Implementation
|
|
4
|
+
* ===================================================================
|
|
5
|
+
*
|
|
6
|
+
* File: scroll-spy.js
|
|
7
|
+
* Path: assets/js/modules/navigation/scroll-spy.js
|
|
8
|
+
* Purpose: Track visible sections and highlight corresponding TOC links
|
|
9
|
+
*
|
|
10
|
+
* Features:
|
|
11
|
+
* - Intersection Observer-based (better performance than scroll events)
|
|
12
|
+
* - Active link highlighting with smooth transitions
|
|
13
|
+
* - Auto-scroll TOC to keep active link visible
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* import { ScrollSpy } from './scroll-spy.js';
|
|
17
|
+
* const scrollSpy = new ScrollSpy();
|
|
18
|
+
*
|
|
19
|
+
* ===================================================================
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { config } from './config.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get element safely with error handling
|
|
26
|
+
* @param {string} selector - CSS selector
|
|
27
|
+
* @returns {Element|null}
|
|
28
|
+
*/
|
|
29
|
+
function getElement(selector) {
|
|
30
|
+
try {
|
|
31
|
+
return document.querySelector(selector);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.warn(`ScrollSpy: Element not found - ${selector}`);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get all elements safely with error handling
|
|
40
|
+
* @param {string} selector - CSS selector
|
|
41
|
+
* @returns {NodeList}
|
|
42
|
+
*/
|
|
43
|
+
function getElements(selector) {
|
|
44
|
+
try {
|
|
45
|
+
return document.querySelectorAll(selector);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.warn(`ScrollSpy: Elements not found - ${selector}`);
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ScrollSpy {
|
|
53
|
+
constructor() {
|
|
54
|
+
this.tocLinks = getElements(config.selectors.tocLinks);
|
|
55
|
+
this.headings = this._getHeadings();
|
|
56
|
+
this.currentActive = null;
|
|
57
|
+
this.observer = null;
|
|
58
|
+
|
|
59
|
+
if (this.headings.length === 0 || this.tocLinks.length === 0) {
|
|
60
|
+
console.log('ScrollSpy: No TOC or headings found, skipping initialization');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this._init();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get all headings that have corresponding TOC links
|
|
69
|
+
* @private
|
|
70
|
+
* @returns {Array<{element: Element, link: Element, id: string}>}
|
|
71
|
+
*/
|
|
72
|
+
_getHeadings() {
|
|
73
|
+
const headings = [];
|
|
74
|
+
this.tocLinks.forEach(link => {
|
|
75
|
+
const href = link.getAttribute('href');
|
|
76
|
+
if (href && href.startsWith('#')) {
|
|
77
|
+
const id = href.substring(1);
|
|
78
|
+
const heading = document.getElementById(id);
|
|
79
|
+
if (heading) {
|
|
80
|
+
headings.push({
|
|
81
|
+
element: heading,
|
|
82
|
+
link: link,
|
|
83
|
+
id: id
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return headings;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Initialize Intersection Observer
|
|
93
|
+
* @private
|
|
94
|
+
*/
|
|
95
|
+
_init() {
|
|
96
|
+
const observerOptions = {
|
|
97
|
+
root: null,
|
|
98
|
+
rootMargin: config.scrollSpy.rootMargin,
|
|
99
|
+
threshold: config.scrollSpy.threshold
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
this.observer = new IntersectionObserver(
|
|
103
|
+
entries => this._handleIntersection(entries),
|
|
104
|
+
observerOptions
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Observe all headings
|
|
108
|
+
this.headings.forEach(heading => {
|
|
109
|
+
this.observer.observe(heading.element);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
console.log(`ScrollSpy: Observing ${this.headings.length} headings`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Handle intersection events
|
|
117
|
+
* @private
|
|
118
|
+
* @param {IntersectionObserverEntry[]} entries
|
|
119
|
+
*/
|
|
120
|
+
_handleIntersection(entries) {
|
|
121
|
+
// Find the most visible heading
|
|
122
|
+
let mostVisible = null;
|
|
123
|
+
let maxRatio = 0;
|
|
124
|
+
|
|
125
|
+
entries.forEach(entry => {
|
|
126
|
+
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
|
|
127
|
+
maxRatio = entry.intersectionRatio;
|
|
128
|
+
mostVisible = this.headings.find(h => h.element === entry.target);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// If we found a visible heading, activate it
|
|
133
|
+
if (mostVisible) {
|
|
134
|
+
this._setActiveLink(mostVisible.link);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Set active link with visual feedback
|
|
140
|
+
* @private
|
|
141
|
+
* @param {Element} link
|
|
142
|
+
*/
|
|
143
|
+
_setActiveLink(link) {
|
|
144
|
+
if (this.currentActive === link) return;
|
|
145
|
+
|
|
146
|
+
// Remove previous active state
|
|
147
|
+
this.tocLinks.forEach(l => l.classList.remove('active'));
|
|
148
|
+
|
|
149
|
+
// Add active state
|
|
150
|
+
if (link) {
|
|
151
|
+
link.classList.add('active');
|
|
152
|
+
this.currentActive = link;
|
|
153
|
+
|
|
154
|
+
// Scroll TOC to show active link (if needed)
|
|
155
|
+
this._scrollTocToActiveLink(link);
|
|
156
|
+
|
|
157
|
+
// Dispatch custom event for other modules
|
|
158
|
+
document.dispatchEvent(new CustomEvent('navigation:sectionChange', {
|
|
159
|
+
detail: {
|
|
160
|
+
link: link,
|
|
161
|
+
href: link.getAttribute('href')
|
|
162
|
+
}
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Scroll TOC container to show active link
|
|
169
|
+
* @private
|
|
170
|
+
* @param {Element} link
|
|
171
|
+
*/
|
|
172
|
+
_scrollTocToActiveLink(link) {
|
|
173
|
+
const tocContainer = getElement(config.selectors.tocContainer);
|
|
174
|
+
if (!tocContainer) return;
|
|
175
|
+
|
|
176
|
+
const linkRect = link.getBoundingClientRect();
|
|
177
|
+
const containerRect = tocContainer.getBoundingClientRect();
|
|
178
|
+
|
|
179
|
+
// Check if link is out of view
|
|
180
|
+
if (linkRect.top < containerRect.top || linkRect.bottom > containerRect.bottom) {
|
|
181
|
+
link.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Manually set active section by ID
|
|
187
|
+
* @param {string} id - Heading ID to activate
|
|
188
|
+
*/
|
|
189
|
+
setActiveById(id) {
|
|
190
|
+
const heading = this.headings.find(h => h.id === id);
|
|
191
|
+
if (heading) {
|
|
192
|
+
this._setActiveLink(heading.link);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get current active heading
|
|
198
|
+
* @returns {{element: Element, link: Element, id: string}|null}
|
|
199
|
+
*/
|
|
200
|
+
getActive() {
|
|
201
|
+
if (!this.currentActive) return null;
|
|
202
|
+
return this.headings.find(h => h.link === this.currentActive) || null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Cleanup observer
|
|
207
|
+
*/
|
|
208
|
+
destroy() {
|
|
209
|
+
if (this.observer) {
|
|
210
|
+
this.observer.disconnect();
|
|
211
|
+
this.observer = null;
|
|
212
|
+
}
|
|
213
|
+
this.tocLinks.forEach(l => l.classList.remove('active'));
|
|
214
|
+
this.currentActive = null;
|
|
215
|
+
console.log('ScrollSpy: Destroyed');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export default ScrollSpy;
|