feat: add toc component; fix footnote and back button positions

the3ash committed Jul 11, 2025 at 13:09 UTC 5075638dac5285c407afa8b3a8933d5a744c96d6
6 files changed +325 -6
src/components/features/FootnoteScroll.astro
+15 -5
@@ -1,5 +1,5 @@
1 <script>
2 - document.addEventListener('astro:page-load', () => {
2 + function bindFootnoteEvents() {
3 const footnoteLinks = document.querySelectorAll('[data-footnote-ref], [data-footnote-backref]')
4
5 footnoteLinks.forEach((link) => {
@@ -11,13 +11,23 @@
11 const target = document.querySelector(href)
12 if (!target) return
13
14 - // Calculate scroll position with offset (6 * 16px = 96px from top)
15 - const targetPosition = target.getBoundingClientRect().top + window.scrollY - 6 * 16
14 + // Use fixed offset of 128px
15 + const offset = 128
16 +
17 + const targetPosition = target.getBoundingClientRect().top + window.scrollY - offset
18 +
19 window.scrollTo({
17 - top: targetPosition,
18 - behavior: 'smooth'
20 + top: targetPosition
21 })
22 })
23 })
24 + }
25 +
26 + document.addEventListener('astro:page-load', () => {
27 + bindFootnoteEvents()
28 + })
29 +
30 + document.addEventListener('DOMContentLoaded', () => {
31 + bindFootnoteEvents()
32 })
33 </script>
src/components/ui/BackButton.astro
+1 -1
@@ -35,7 +35,7 @@ import { SITE } from '@/config'
35 const pageWidth = window.innerWidth
36 const contentWidthValue = parseFloat(contentWidth)
37 const margin = (pageWidth - contentWidthValue * 16) / 2
38 - const minSpace = 9 * 16 // Minimum space needed
38 + const minSpace = 11 * 16 // Minimum space needed
39
40 // Position button fixed on the left if there's enough space
41 if (margin >= minSpace) {
src/components/ui/TableOfContents.astro new
+304
@@ -0,0 +1,304 @@
1 +---
2 +import { SITE } from '@/config'
3 +---
4 +
5 +<div class="toc-container" id="toc">
6 + <nav class="toc-nav">
7 + <ul class="toc-list" id="toc-list"></ul>
8 + </nav>
9 +</div>
10 +
11 +<script
12 + is:inline
13 + define:vars={{ contentWidth: SITE.contentWidth, centeredLayout: SITE.centeredLayout }}
14 +>
15 + ;(function () {
16 + // TOC positioning logic (similar to BackButton)
17 + function adjustTOCPosition() {
18 + const toc = document.querySelector('.toc-container')
19 + if (!toc) return
20 +
21 + // If not using centered layout, hide TOC
22 + if (!centeredLayout) {
23 + toc.style.display = 'none'
24 + return
25 + }
26 +
27 + // Calculate available margin space for positioning
28 + const pageWidth = window.innerWidth
29 + const contentWidthValue = parseFloat(contentWidth)
30 + const margin = (pageWidth - contentWidthValue * 16) / 2
31 + const minSpace = 11 * 16 // Minimum space needed
32 +
33 + // Show and position TOC fixed on the left if there's enough space
34 + if (margin >= minSpace) {
35 + toc.style.display = 'block'
36 + toc.classList.add('fixed-position')
37 + toc.style.left = `${margin - minSpace}px`
38 + } else {
39 + toc.style.display = 'none'
40 + toc.classList.remove('fixed-position')
41 + toc.style.left = ''
42 + }
43 + }
44 +
45 + // Extract headings and paragraphs and create TOC
46 + function createTOC() {
47 + const tocList = document.getElementById('toc-list')
48 + if (!tocList) return
49 +
50 + const tocItems = []
51 +
52 + // Get all content elements in document order
53 + const allElements = Array.from(document.querySelectorAll('h1, h2, h3'))
54 +
55 + // Process elements in document order
56 + allElements.forEach((element, index) => {
57 + if (element.tagName === 'H1' || element.tagName === 'H2' || element.tagName === 'H3') {
58 + // Skip the main title (first h1)
59 + if (index === 0 && element.tagName === 'H1') return
60 +
61 + const level = parseInt(element.tagName.charAt(1))
62 + if (level > 3) return // Only include h1, h2, h3
63 +
64 + const text = element.textContent || ''
65 + const id = element.id || `heading-${index}`
66 + element.id = id
67 +
68 + tocItems.push({
69 + level,
70 + text,
71 + id,
72 + element
73 + })
74 + }
75 + })
76 +
77 + // Clear existing TOC
78 + tocList.innerHTML = ''
79 +
80 + // Add title link at the top
81 + const titleLi = document.createElement('li')
82 + titleLi.className = 'toc-item toc-level-0'
83 +
84 + const titleLink = document.createElement('a')
85 + titleLink.href = '#'
86 + titleLink.className = 'toc-link toc-title'
87 + titleLink.setAttribute('title', 'Back to top')
88 + titleLink.setAttribute('data-text', 'Back to top')
89 +
90 + // Add click handler to scroll to top
91 + titleLink.addEventListener('click', (e) => {
92 + e.preventDefault()
93 + window.scrollTo({ top: 0, behavior: 'smooth' })
94 + // Update URL without page jump
95 + history.pushState(null, null, '#')
96 + })
97 +
98 + titleLi.appendChild(titleLink)
99 + tocList.appendChild(titleLi)
100 +
101 + // Create TOC items
102 + tocItems.forEach((item) => {
103 + const li = document.createElement('li')
104 + li.className = `toc-item toc-level-${item.level}`
105 +
106 + const link = document.createElement('a')
107 + link.href = `#${item.id}`
108 + link.className = 'toc-link'
109 + link.setAttribute('title', item.text) // Add title for accessibility
110 + link.setAttribute('data-text', item.text) // Add data-text for CSS content
111 + link.textContent = item.text // Add text content for hover display
112 +
113 + link.addEventListener('click', (e) => {
114 + e.preventDefault()
115 + const target = document.getElementById(item.id)
116 + if (target) {
117 + const rect = target.getBoundingClientRect()
118 + const scrollTop = window.pageYOffset || document.documentElement.scrollTop
119 + const offset = rect.top + scrollTop - 96 // 6rem = 96px
120 + window.scrollTo({ top: offset, behavior: 'smooth' })
121 + history.pushState(null, null, `#${item.id}`)
122 + }
123 + })
124 +
125 + li.appendChild(link)
126 + tocList.appendChild(li)
127 + })
128 +
129 + updateActiveTOCItem()
130 + }
131 +
132 + // Update active TOC item based on scroll position
133 + function updateActiveTOCItem() {
134 + const tocLinks = document.querySelectorAll('.toc-link')
135 + const headings = document.querySelectorAll('h1, h2, h3')
136 +
137 + let currentActive = null
138 + const scrollTop = window.pageYOffset + 100 // Offset for better detection
139 +
140 + // Check headings
141 + headings.forEach((heading, index) => {
142 + if (index === 0 && heading.tagName === 'H1') return
143 +
144 + const rect = heading.getBoundingClientRect()
145 + const offsetTop = rect.top + window.pageYOffset
146 +
147 + if (scrollTop >= offsetTop) {
148 + currentActive = heading.id
149 + }
150 + })
151 +
152 + tocLinks.forEach((link) => {
153 + link.classList.remove('active')
154 + if (link.getAttribute('href') === `#${currentActive}`) {
155 + link.classList.add('active')
156 + }
157 + })
158 +
159 + // If no active item found and we're at the top, activate the title link
160 + if (!currentActive && window.pageYOffset < 200) {
161 + const titleLink = document.querySelector('.toc-link.toc-title')
162 + if (titleLink) {
163 + titleLink.classList.add('active')
164 + }
165 + }
166 + }
167 +
168 + function initTOC() {
169 + adjustTOCPosition()
170 + createTOC()
171 + }
172 +
173 + document.addEventListener('astro:page-load', () => {
174 + initTOC()
175 + })
176 +
177 + document.addEventListener('DOMContentLoaded', () => {
178 + initTOC()
179 + })
180 +
181 + window.addEventListener('resize', adjustTOCPosition)
182 + window.addEventListener('scroll', updateActiveTOCItem)
183 + })()
184 +</script>
185 +
186 +<style is:global>
187 + .toc-container {
188 + width: 128px;
189 + position: relative;
190 + left: -0.175em;
191 + }
192 +
193 + .toc-nav {
194 + font-family: var(--font-serif);
195 + font-size: var(--font-size-s);
196 + line-height: 1.5;
197 + }
198 +
199 + .toc-list {
200 + list-style: none;
201 + list-style-type: none;
202 + margin: 0 !important;
203 + padding: 0 !important;
204 + }
205 +
206 + .toc-list li {
207 + margin: 0 !important;
208 + padding: 0 !important;
209 + }
210 +
211 + .toc-item {
212 + list-style: none;
213 + list-style-type: none;
214 + }
215 +
216 + .toc-item::before {
217 + display: none;
218 + }
219 +
220 + .toc-item::marker {
221 + display: none;
222 + }
223 +
224 + .toc-link {
225 + display: block;
226 + color: transparent;
227 + text-decoration: none;
228 + transition: all 0.2s ease-out;
229 + position: relative;
230 + padding-left: 0;
231 + height: 1.125rem;
232 + width: 100%;
233 + min-height: 1rem;
234 + font-size: 0;
235 + line-height: 1.125rem;
236 + text-indent: 2rem;
237 + overflow: hidden;
238 + white-space: nowrap;
239 + text-overflow: ellipsis;
240 + }
241 +
242 + .toc-link:hover {
243 + font-family: var(--font-sans);
244 + font-size: var(--font-size-s);
245 + letter-spacing: var(--spacing-m);
246 + text-indent: 1.125rem;
247 + text-decoration: none;
248 + }
249 +
250 + .toc-level-1 .toc-link:hover::before,
251 + .toc-level-2 .toc-link:hover::before,
252 + .toc-level-3 .toc-link:hover::before {
253 + width: 0.75rem;
254 + }
255 +
256 + .toc-level-0 .toc-link:hover::after,
257 + .toc-level-1 .toc-link:hover::after,
258 + .toc-level-2 .toc-link:hover::after,
259 + .toc-level-3 .toc-link:hover::after {
260 + opacity: 1;
261 + }
262 +
263 + .toc-link.active {
264 + color: var(--text-primary);
265 + }
266 +
267 + /* Horizontal line indicators */
268 + .toc-level-0 .toc-link::before,
269 + .toc-level-1 .toc-link::before,
270 + .toc-level-2 .toc-link::before,
271 + .toc-level-3 .toc-link::before {
272 + content: '';
273 + position: absolute;
274 + left: 0;
275 + top: 50%;
276 + width: 2.5rem;
277 + height: 1px;
278 + background-color: var(--text-tertiary);
279 + transform: translateY(-50%);
280 + opacity: 0.4;
281 + }
282 +
283 + .toc-link:hover::before,
284 + .toc-link.active::before {
285 + opacity: 0.8;
286 + background-color: var(--text-primary);
287 + }
288 +
289 + /* Fixed positioning */
290 + .toc-container.fixed-position {
291 + position: fixed;
292 + top: 12rem; /* Position below BackButton */
293 + margin-top: 0;
294 + padding-left: 1rem;
295 + z-index: 10;
296 + }
297 +
298 + /* Hide on mobile and when space is limited */
299 + @media (max-width: 768px) {
300 + .toc-container {
301 + display: none !important;
302 + }
303 + }
304 +</style>
src/config.ts
+1
@@ -21,6 +21,7 @@ export const SITE = {
21
22 // POST SETTINGS ///////////////////////////////////////////////////////////////////////////////////////
23 readingTime: false, // Show reading time in posts
24 + toc: true, // Show table of contents
25 imageViewer: true, // Enable image viewer
26 copyCode: false // Enable copy button in code blocks
27 }
src/content/posts/theme-guide.md
+2
@@ -53,6 +53,8 @@ export const SITE = {
53
54 // Show reading time in posts
55 readingTime: false,
56 + // Show table of contents
57 + toc: true,
58 // Enable image viewer
59 imageViewer: true,
60 // Enable copy button in code blocks
src/layouts/PostLayout.astro
+2
@@ -6,6 +6,7 @@ import FootnoteScroll from '@/components/features/FootnoteScroll.astro'
6 import BaseHead from '@/components/layout/BaseHead.astro'
7 import Footer from '@/components/layout/Footer.astro'
8 import BackButton from '@/components/ui/BackButton.astro'
9 +import TableOfContents from '@/components/ui/TableOfContents.astro'
10 import GradientMask from '@/components/ui/GradientMask.astro'
11 import ImageViewer from '@/components/ui/ImageViewer.astro'
12 import GitHubCard from '@/components/ui/GitHubCard.astro'
@@ -34,6 +35,7 @@ const { title, pubDate, readingTime } = Astro.props
35 <div class="prose">
36 <GradientMask />
37 <BackButton />
38 + {SITE.toc && <TableOfContents />}
39 <div class="title">
40 <h1>{title}</h1>
41 <div class="date">