forked from
quillmatiq.com/augment
Fork of Chiri for Astro for my blog
1---
2import { themeConfig } from '@/config'
3import type { TOCProps } from '@/types'
4
5const { 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 href={`#${item.id}`} class="toc-link" title={item.text} data-text={item.text}>
23 {item.text}
24 </a>
25 </li>
26 ))
27 }
28 </ul>
29 </nav>
30</div>
31
32<script
33 is:inline
34 define:vars={{
35 contentWidth: themeConfig.general.contentWidth,
36 centeredLayout: themeConfig.general.centeredLayout,
37 toc: themeConfig.post.toc
38 }}
39>
40 ;(function () {
41 // Core state
42 const state = {
43 container: null,
44 links: null,
45 headings: null,
46 titleLink: null,
47 headingMap: new Map(),
48 positions: [],
49 scrollTimeout: null,
50 hasContent: false
51 }
52
53 // Initialize DOM elements
54 function initElements() {
55 state.container = document.querySelector('.toc-container')
56 if (!state.container) return false
57
58 state.links = document.querySelectorAll('.toc-link')
59 state.headings = document.querySelectorAll('h1, h2, h3')
60 state.titleLink = document.querySelector('.toc-link.toc-title')
61
62 // Build heading map
63 state.headingMap.clear()
64 state.links.forEach((link) => {
65 const href = link.getAttribute('href')
66 if (href?.startsWith('#')) {
67 state.headingMap.set(href.substring(1), link)
68 }
69 })
70
71 return true
72 }
73
74 // Check if content exists
75 function checkContent() {
76 if (!state.container || !state.links) return
77
78 const tocItems = Array.from(state.links).filter(
79 (link) => !link.classList.contains('toc-title')
80 )
81 state.hasContent = tocItems.length > 0
82
83 if (!state.hasContent) {
84 state.container.style.display = 'none'
85 }
86 }
87
88 // Cache heading positions
89 function cachePositions() {
90 if (!state.headings?.length) return
91
92 const scrollTop = window.pageYOffset
93 state.positions = Array.from(state.headings)
94 .filter((_, index) => !(index === 0 && state.headings[0].tagName === 'H1'))
95 .map((heading) => ({
96 id: heading.id,
97 offsetTop: heading.getBoundingClientRect().top + scrollTop
98 }))
99 }
100
101 // Adjust TOC position
102 function adjustPosition() {
103 if (!state.container || !centeredLayout || !state.hasContent) {
104 if (state.container) state.container.style.display = 'none'
105 return
106 }
107
108 const pageWidth = window.innerWidth
109 const contentWidthValue = Math.max(parseFloat(contentWidth), 25)
110 const margin = (pageWidth - contentWidthValue * 16) / 2
111 const baseMinSpace = 11 * 16 // Base minimum space needed
112 const minSpace = toc ? baseMinSpace + 52 : baseMinSpace + 12
113
114 if (margin >= minSpace) {
115 state.container.style.display = 'block'
116 state.container.classList.add('fixed-position')
117 const leftPosition = toc ? margin - 176 - 40 : margin - 176
118 state.container.style.left = `${leftPosition}px`
119 } else {
120 state.container.style.display = 'none'
121 state.container.classList.remove('fixed-position')
122 state.container.style.left = ''
123 }
124 }
125
126 // Handle click events
127 function handleClick(e) {
128 const link = e.target.closest('.toc-link')
129 if (!link) return
130
131 e.preventDefault()
132
133 if (link.classList.contains('toc-title')) {
134 window.scrollTo({ top: 0, behavior: 'smooth' })
135 history.pushState(null, null, '#')
136 } else {
137 const href = link.getAttribute('href')
138 if (href?.startsWith('#')) {
139 const target = document.getElementById(href.substring(1))
140 if (target) {
141 const rect = target.getBoundingClientRect()
142 const scrollTop = window.pageYOffset || document.documentElement.scrollTop
143 const offset = rect.top + scrollTop - 96
144 window.scrollTo({ top: offset, behavior: 'smooth' })
145 history.pushState(null, null, href)
146 }
147 }
148 }
149 }
150
151 // Update active state
152 function updateActive() {
153 if (!state.links?.length || !state.positions.length) return
154
155 const scrollTop = window.pageYOffset + 100
156 let currentActive = null
157
158 // Find current active heading
159 for (let i = state.positions.length - 1; i >= 0; i--) {
160 if (scrollTop >= state.positions[i].offsetTop) {
161 currentActive = state.positions[i].id
162 break
163 }
164 }
165
166 // Update active state
167 state.links.forEach((link) => link.classList.remove('active'))
168
169 if (currentActive && state.headingMap.has(currentActive)) {
170 state.headingMap.get(currentActive).classList.add('active')
171 } else if (state.titleLink) {
172 state.titleLink.classList.add('active')
173 }
174 }
175
176 // Initialize
177 function init(retryCount = 0) {
178 const maxRetries = 5
179
180 if (initElements()) {
181 checkContent()
182 adjustPosition()
183 cachePositions()
184
185 if (state.container) {
186 state.container.removeEventListener('click', handleClick)
187 state.container.addEventListener('click', handleClick)
188 }
189
190 updateActive()
191 } else if (retryCount < maxRetries) {
192 setTimeout(() => init(retryCount + 1), 100)
193 }
194 }
195
196 // Event handlers
197 function handleScroll() {
198 if (state.scrollTimeout) {
199 cancelAnimationFrame(state.scrollTimeout)
200 }
201 state.scrollTimeout = requestAnimationFrame(updateActive)
202 }
203
204 function handleResize() {
205 adjustPosition()
206 requestAnimationFrame(cachePositions)
207 }
208
209 // Cleanup
210 function cleanup() {
211 if (state.scrollTimeout) {
212 cancelAnimationFrame(state.scrollTimeout)
213 state.scrollTimeout = null
214 }
215
216 Object.assign(state, {
217 container: null,
218 links: null,
219 headings: null,
220 titleLink: null,
221 headingMap: new Map(),
222 positions: [],
223 hasContent: false
224 })
225 }
226
227 // Event listeners
228 document.addEventListener('astro:page-load', () => {
229 cleanup()
230 init()
231 })
232
233 document.addEventListener('astro:after-swap', () => {
234 cleanup()
235 init()
236 })
237
238 // Fallback for when Astro transitions are disabled
239 document.addEventListener('DOMContentLoaded', () => {
240 if (!state.container || !state.hasContent) {
241 init()
242 }
243 })
244
245 window.addEventListener('resize', handleResize)
246 window.addEventListener('scroll', handleScroll)
247 })()
248</script>
249
250<style is:inline>
251 .toc-container {
252 width: 12rem;
253 position: relative;
254 left: -0.175em;
255 opacity: 0;
256 transition: opacity 0.2s ease-out;
257 display: none;
258 }
259
260 .toc-container.fixed-position {
261 opacity: 1;
262 position: fixed;
263 top: 12rem;
264 margin-top: 0;
265 padding-left: 1rem;
266 z-index: 10;
267 left: auto;
268 }
269
270 .toc-nav {
271 font-family: var(--sans);
272 }
273
274 .toc-list,
275 .toc-list li,
276 .toc-item {
277 list-style: none;
278 margin: 0;
279 padding: 0;
280 }
281
282 .prose .toc-container .toc-list {
283 margin-left: 0 !important;
284 padding-left: 0 !important;
285 }
286
287 .prose .toc-container .toc-list li {
288 margin: 0 !important;
289 padding: 0 !important;
290 }
291
292 .toc-item::before,
293 .toc-item::marker {
294 display: none;
295 }
296
297 .toc-link {
298 display: block;
299 color: transparent;
300 text-decoration: none;
301 position: relative;
302 padding-left: 0;
303 height: 1.125rem;
304 width: 100%;
305 min-height: 1rem;
306 font-size: 0;
307 line-height: 1.125rem;
308 text-indent: 2rem;
309 overflow: hidden;
310 white-space: nowrap;
311 text-overflow: ellipsis;
312 transition:
313 color 0.2s ease-out,
314 font-size 0.2s ease-out,
315 text-indent 0.2s ease-out;
316 cursor: pointer;
317 }
318
319 .toc-link::after {
320 content: attr(data-text);
321 position: absolute;
322 left: -0.5rem;
323 top: 0;
324 font-family: var(--sans);
325 font-size: var(--font-size-s);
326 letter-spacing: var(--spacing-m);
327 line-height: 1.125rem;
328 color: var(--text-primary);
329 opacity: 0;
330 transition:
331 opacity 0.2s ease-out,
332 left 0.2s ease-out;
333 pointer-events: none;
334 overflow: hidden;
335 white-space: nowrap;
336 text-overflow: ellipsis;
337 max-width: 100%;
338 }
339
340 .toc-link:hover::after {
341 opacity: 1;
342 left: -0.75rem;
343 }
344
345 .toc-level-0 .toc-link:hover::after {
346 opacity: 0;
347 }
348
349 .toc-level-1 .toc-link:hover::before,
350 .toc-level-2 .toc-link:hover::before,
351 .toc-level-3 .toc-link:hover::before {
352 width: 0.75rem;
353 transition: width 0.1s ease-out;
354 }
355
356 .toc-link.active {
357 color: var(--text-primary);
358 }
359
360 /* Horizontal line indicators */
361 .toc-level-0 .toc-link::before,
362 .toc-level-1 .toc-link::before,
363 .toc-level-2 .toc-link::before,
364 .toc-level-3 .toc-link::before {
365 content: '';
366 position: absolute;
367 left: 0;
368 top: 50%;
369 width: 1.75rem;
370 height: 1px;
371 background-color: var(--text-tertiary);
372 transform: translateY(-50%);
373 opacity: 0.4;
374 transition: all 0.1s ease-out;
375 }
376
377 .toc-link:hover::before,
378 .toc-link.active::before {
379 opacity: 0.8;
380 background-color: var(--text-primary);
381 }
382
383 /* Hide on mobile */
384 @media (max-width: 768px) {
385 .toc-container {
386 display: none !important;
387 }
388 }
389</style>