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">