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