1 ---
2 import { SITE } from '@/config'
3 import type { TOCProps } from '@/types'
4
5 const { toc = [] }: TOCProps = Astro.props
6 ---
7
8 <div class="toc-container" id="toc">
9 <nav class="toc-nav">
10 <ul class="toc-list" id="toc-list">
11 <!-- Back to top link -->
12 <li class="toc-item toc-level-0">
13 <a href="#" class="toc-link toc-title" title="Back to top" data-text="Back to top">
14 Back to top
15 </a>
16 </li>
17
18 <!-- TOC items -->
19 {
20 toc.map((item) => (
21 <li class={`toc-item toc-level-${item.level}`}>
22 <a
23 href={`#${item.id}`}
24 class="toc-link"
25 title={item.text}
26 data-text={item.text}
27 data-heading-id={item.id}
28 >
29 {item.text}
30 </a>
31 </li>
32 ))
33 }
34 </ul>
35 </nav>
36 </div>
37
38 <script
39 is:inline
40 define:vars={{
41 contentWidth: SITE.contentWidth,
42 centeredLayout: SITE.centeredLayout,
43 toc: SITE.toc
44 }}
45 >
46 ;(function () {
47 let isInitialized = false
48
49 // TOC positioning logic (similar to BackButton)
50 function adjustTOCPosition() {
51 const tocContainer = document.querySelector('.toc-container')
52 if (!tocContainer) return
53
54 // If not using centered layout, hide TOC
55 if (!centeredLayout) {
56 tocContainer.style.display = 'none'
57 return
58 }
59
60 // Calculate available margin space for positioning
61 const pageWidth = window.innerWidth
62 const contentWidthValue = parseFloat(contentWidth)
63 const margin = (pageWidth - contentWidthValue * 16) / 2
64 const baseMinSpace = 11 * 16 // Base minimum space needed
65 // If toc is enabled, need additional 2.5rem (40px) space
66 const minSpace = toc ? baseMinSpace + 40 : baseMinSpace
67
68 // Show and position TOC fixed on the left if there's enough space
69 if (margin >= minSpace) {
70 tocContainer.style.display = 'block'
71 tocContainer.classList.add('fixed-position')
72 const basePosition = margin - baseMinSpace
73 // If toc is enabled, move 2.5rem (40px) further left
74 const leftPosition = toc ? basePosition - 40 : basePosition
75 tocContainer.style.left = `${leftPosition}px`
76 } else {
77 tocContainer.style.display = 'none'
78 tocContainer.classList.remove('fixed-position')
79 tocContainer.style.left = ''
80 }
81 }
82
83 // Hide TOC if there are no headings (only title)
84 function checkTOCVisibility() {
85 const tocContainer = document.querySelector('.toc-container')
86 const tocItems = document.querySelectorAll('.toc-item:not(.toc-level-0)')
87
88 if (tocContainer && tocItems.length === 0) {
89 tocContainer.style.display = 'none'
90 }
91 }
92
93 // Add click handlers to TOC links using event delegation
94 function addTOCEventListeners() {
95 const tocContainer = document.querySelector('.toc-container')
96 if (!tocContainer) return
97
98 // Use event delegation for better performance and reliability
99 tocContainer.addEventListener('click', function (e) {
100 const link = e.target.closest('.toc-link')
101 if (!link) return
102
103 e.preventDefault()
104
105 if (link.classList.contains('toc-title')) {
106 // Back to top
107 window.scrollTo({ top: 0, behavior: 'smooth' })
108 history.pushState(null, null, '#')
109 } else {
110 // TOC item
111 const href = link.getAttribute('href')
112 if (href && href.startsWith('#')) {
113 const targetId = href.substring(1)
114 const target = document.getElementById(targetId)
115 if (target) {
116 const rect = target.getBoundingClientRect()
117 const scrollTop = window.pageYOffset || document.documentElement.scrollTop
118 const offset = rect.top + scrollTop - 96 // 6rem = 96px
119 window.scrollTo({ top: offset, behavior: 'smooth' })
120 history.pushState(null, null, href)
121 }
122 }
123 }
124 })
125 }
126
127 // Update active TOC item based on scroll position
128 function updateActiveTOCItem() {
129 const tocLinks = document.querySelectorAll('.toc-link')
130 const headings = document.querySelectorAll('h1, h2, h3')
131
132 let currentActive = null
133 const scrollTop = window.pageYOffset + 100 // Offset for better detection
134
135 // Check headings
136 headings.forEach((heading, index) => {
137 if (index === 0 && heading.tagName === 'H1') return
138
139 const rect = heading.getBoundingClientRect()
140 const offsetTop = rect.top + window.pageYOffset
141
142 if (scrollTop >= offsetTop) {
143 currentActive = heading.id
144 }
145 })
146
147 tocLinks.forEach((link) => {
148 link.classList.remove('active')
149 if (link.getAttribute('href') === `#${currentActive}`) {
150 link.classList.add('active')
151 }
152 })
153
154 // Always highlight the first item ("Back to top") unless another TOC item is active
155 if (!currentActive) {
156 const titleLink = document.querySelector('.toc-link.toc-title')
157 if (titleLink) {
158 titleLink.classList.add('active')
159 }
160 }
161 }
162
163 function initTOC() {
164 if (isInitialized) return
165
166 adjustTOCPosition()
167 checkTOCVisibility()
168 addTOCEventListeners()
169 updateActiveTOCItem() // Initial active state
170
171 isInitialized = true
172 }
173
174 // Initialize on page load
175 function handlePageLoad() {
176 initTOC()
177 }
178
179 // Listen for various page load events
180 document.addEventListener('astro:page-load', handlePageLoad)
181 document.addEventListener('DOMContentLoaded', handlePageLoad)
182
183 // Also initialize immediately if DOM is already loaded
184 if (document.readyState === 'loading') {
185 document.addEventListener('DOMContentLoaded', handlePageLoad)
186 } else {
187 handlePageLoad()
188 }
189
190 // Re-initialize on resize and scroll
191 window.addEventListener('resize', adjustTOCPosition)
192 window.addEventListener('scroll', updateActiveTOCItem)
193 })()
194 </script>
195
196 <style is:global>
197 .toc-container {
198 width: 12rem;
199 position: relative;
200 left: -0.175em;
201 }
202
203 .toc-nav {
204 font-family: var(--font-serif);
205 font-size: var(--font-size-s);
206 line-height: 1.5;
207 }
208
209 .toc-list {
210 list-style: none;
211 list-style-type: none;
212 margin: 0 !important;
213 padding: 0 !important;
214 }
215
216 .toc-list li {
217 margin: 0 !important;
218 padding: 0 !important;
219 }
220
221 .toc-item {
222 list-style: none;
223 list-style-type: none;
224 }
225
226 .toc-item::before {
227 display: none;
228 }
229
230 .toc-item::marker {
231 display: none;
232 }
233
234 .toc-link {
235 display: block;
236 color: transparent;
237 text-decoration: none;
238 position: relative;
239 padding-left: 0;
240 height: 1.125rem;
241 width: 100%;
242 min-height: 1rem;
243 font-size: 0;
244 line-height: 1.125rem;
245 text-indent: 2rem;
246 overflow: hidden;
247 white-space: nowrap;
248 text-overflow: ellipsis;
249 transition:
250 color 0.2s ease-out,
251 font-size 0.2s ease-out,
252 text-indent 0.2s ease-out;
253 cursor: pointer;
254 }
255
256 .toc-link::after {
257 content: attr(data-text);
258 position: absolute;
259 left: -0.5rem;
260 top: 0;
261 font-family: var(--font-sans);
262 font-size: var(--font-size-s);
263 letter-spacing: var(--spacing-m);
264 line-height: 1.125rem;
265 color: var(--text-primary);
266 opacity: 0;
267 transition:
268 opacity 0.2s ease-out,
269 left 0.2s ease-out;
270 pointer-events: none;
271 overflow: hidden;
272 white-space: nowrap;
273 text-overflow: ellipsis;
274 max-width: 100%;
275 }
276
277 .toc-link:hover::after {
278 opacity: 1;
279 left: -0.75rem;
280 }
281
282 .toc-level-0 .toc-link:hover::after {
283 opacity: 0;
284 }
285
286 .toc-level-1 .toc-link:hover::before,
287 .toc-level-2 .toc-link:hover::before,
288 .toc-level-3 .toc-link:hover::before {
289 width: 0.75rem;
290 transition: width 0.1s ease-out;
291 }
292
293 .toc-link.active {
294 color: var(--text-primary);
295 }
296
297 /* Horizontal line indicators */
298 .toc-level-0 .toc-link::before,
299 .toc-level-1 .toc-link::before,
300 .toc-level-2 .toc-link::before,
301 .toc-level-3 .toc-link::before {
302 content: '';
303 position: absolute;
304 left: 0;
305 top: 50%;
306 width: 2.5rem;
307 height: 1px;
308 background-color: var(--text-tertiary);
309 transform: translateY(-50%);
310 opacity: 0.4;
311 transition: all 0.1s ease-out;
312 }
313
314 .toc-link:hover::before,
315 .toc-link.active::before {
316 opacity: 0.8;
317 background-color: var(--text-primary);
318 }
319
320 .toc-container.fixed-position {
321 position: fixed;
322 top: 12rem; /* Position below BackButton */
323 margin-top: 0;
324 padding-left: 1rem;
325 z-index: 10;
326 }
327
328 /* Hide on mobile and when space is limited */
329 @media (max-width: 768px) {
330 .toc-container {
331 display: none !important;
332 }
333 }
334 </style>