Fork of Chiri for Astro for my blog
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 93856307e5f4eeb75dad33c7e3281050e4e8d7b9 385 lines 9.7 kB view raw
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>