Skip to content

Commit 5194823

Browse files
committed
feat(website): add scroll-aware table of contents and sidebar navigation
- Add TableOfContents component with scroll-based section highlighting - Track visible sections with bg-base-300 background - Highlight current item (based on URL hash) with menu-active class - Auto-scroll TOC and sidebar to show active item on page load - Add TocTree component for recursive TOC rendering
1 parent 7c45c1c commit 5194823

File tree

3 files changed

+387
-1
lines changed

3 files changed

+387
-1
lines changed

website/app/components/DocsSidebar.vue

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
const $emit = defineEmits(['close'])
33
const route = useRoute()
4+
const sidebarRef = ref<HTMLElement | null>(null)
45
56
// Determine which menu to load based on the current route
67
function getMenuName(path: string) {
@@ -39,10 +40,25 @@ const currentMenu = computed(() => {
3940
return guidesMenu.value
4041
}
4142
})
43+
44+
// Scroll sidebar to show the active menu item
45+
onMounted(() => {
46+
setTimeout(() => {
47+
const sidebar = sidebarRef.value
48+
const activeItem = sidebar?.querySelector('.menu-active') as HTMLElement | null
49+
if (sidebar && activeItem) {
50+
const containerRect = sidebar.getBoundingClientRect()
51+
const itemRect = activeItem.getBoundingClientRect()
52+
const scrollTop =
53+
itemRect.top - containerRect.top + sidebar.scrollTop - containerRect.height / 2 + itemRect.height / 2
54+
sidebar.scrollTo({ top: scrollTop, behavior: 'smooth' })
55+
}
56+
}, 500)
57+
})
4258
</script>
4359

4460
<template>
45-
<div class="relative menu w-60 bg-base-200 text-base-content h-full overflow-y-auto">
61+
<div ref="sidebarRef" class="relative menu w-60 bg-base-200 text-base-content h-full overflow-y-auto">
4662
<div class="relative z-10">
4763
<Flex justify-end class="absolute right-2 lg:hidden z-20 top-4">
4864
<Button square ghost @click="$emit('close')">
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
<script setup lang="ts">
2+
const pageRoute = useRoute()
3+
4+
// Interface for our TOC structure
5+
interface TocLink {
6+
id: string
7+
text: string
8+
level: number
9+
children: TocLink[]
10+
}
11+
12+
// Create a reactive reference for our TOC links
13+
const tocLinks = ref<TocLink[]>([])
14+
const pageTitle = ref('Table of Contents')
15+
const activeHeadingIds = ref<string[]>([])
16+
const tocContainerRef = ref<HTMLElement | null>(null)
17+
18+
// Current heading is based on the route hash
19+
const currentHeadingId = computed(() => {
20+
const hash = pageRoute.hash
21+
return hash ? hash.slice(1) : null // Remove the leading #
22+
})
23+
24+
// Function to scan the page for headings and build the TOC
25+
function scanHeadings() {
26+
// Wait for the DOM to be ready
27+
if (typeof document === 'undefined') {
28+
return
29+
}
30+
31+
// Find all headings (h1-h6) with IDs
32+
const headings = document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]')
33+
const links: TocLink[] = []
34+
35+
// Get the page title from the first h1
36+
const h1 = document.querySelector('h1')
37+
if (h1) {
38+
pageTitle.value = h1.textContent || 'Table of Contents'
39+
}
40+
41+
// If no headings found, it might mean content hasn't loaded yet
42+
// But also check if we have at least an h1 to ensure page content exists
43+
if (headings.length === 0 && !h1) {
44+
return
45+
}
46+
47+
// Process each heading
48+
headings.forEach((heading) => {
49+
// Skip the first h1 as it's the page title
50+
if (heading.tagName === 'H1' && heading === h1) {
51+
return
52+
}
53+
54+
const id = heading.id
55+
const level = Number.parseInt(heading.tagName.substring(1), 10)
56+
57+
// Create a TOC link
58+
const link: TocLink = { id, text: heading.textContent || '', level, children: [] }
59+
links.push(link)
60+
})
61+
62+
// Build a robust hierarchical structure in a single pass
63+
const hierarchicalLinks: TocLink[] = []
64+
const parents: TocLink[] = []
65+
66+
for (const link of links) {
67+
// Always ensure children is initialized
68+
if (!('children' in link) || !Array.isArray(link.children)) {
69+
;(link as TocLink).children = []
70+
}
71+
// Find the last parent of lower level
72+
while (parents.length > 0 && parents[parents.length - 1]!.level >= link.level) {
73+
parents.pop()
74+
}
75+
if (parents.length === 0) {
76+
hierarchicalLinks.push(link)
77+
} else {
78+
parents[parents.length - 1]!.children.push(link)
79+
}
80+
parents.push(link)
81+
}
82+
tocLinks.value = hierarchicalLinks
83+
84+
// Set up intersection observer after headings are scanned
85+
setupScrollObserver()
86+
}
87+
88+
// Scroll handler for section-based highlighting
89+
let scrollHandler: (() => void) | null = null
90+
let headingPositions: { id: string; top: number }[] = []
91+
92+
function setupScrollObserver() {
93+
// Clean up existing scroll handler
94+
if (scrollHandler) {
95+
window.removeEventListener('scroll', scrollHandler)
96+
scrollHandler = null
97+
}
98+
99+
const headings = Array.from(document.querySelectorAll('h2[id], h3[id], h4[id], h5[id], h6[id]'))
100+
if (headings.length === 0) return
101+
102+
// Cache heading positions (recalculate on resize)
103+
function updateHeadingPositions() {
104+
headingPositions = headings.map((heading) => ({
105+
id: heading.id,
106+
top: heading.getBoundingClientRect().top + window.scrollY
107+
}))
108+
}
109+
110+
updateHeadingPositions()
111+
window.addEventListener('resize', updateHeadingPositions)
112+
113+
// Calculate which sections are visible based on scroll position
114+
scrollHandler = () => {
115+
const viewportTop = window.scrollY + 100 // Account for fixed header
116+
const viewportBottom = window.scrollY + window.innerHeight
117+
118+
const visibleIds: string[] = []
119+
120+
for (let i = 0; i < headingPositions.length; i++) {
121+
const current = headingPositions[i]!
122+
const next = headingPositions[i + 1]
123+
124+
// Section extends from this heading to the next heading (or end of document)
125+
const sectionTop = current.top
126+
const sectionBottom = next ? next.top : document.body.scrollHeight
127+
128+
// Check if this section overlaps with the viewport
129+
const isVisible = sectionTop < viewportBottom && sectionBottom > viewportTop
130+
131+
if (isVisible) {
132+
visibleIds.push(current.id)
133+
}
134+
}
135+
136+
activeHeadingIds.value = visibleIds
137+
}
138+
139+
window.addEventListener('scroll', scrollHandler, { passive: true })
140+
// Initial calculation
141+
scrollHandler()
142+
}
143+
144+
// Check if we're in the browser
145+
let observer: MutationObserver | null = null
146+
147+
onMounted(() => {
148+
// Initial scan with a small delay to ensure content is rendered
149+
setTimeout(() => {
150+
scanHeadings()
151+
}, 100)
152+
153+
// Scroll the TOC to show the current item if there's a hash in the URL
154+
setTimeout(() => {
155+
if (pageRoute.hash) {
156+
const tocContainer = (tocContainerRef.value as any)?.$el || tocContainerRef.value
157+
const activeItem = tocContainer?.querySelector('.menu-active') as HTMLElement | null
158+
if (tocContainer && activeItem) {
159+
const containerRect = tocContainer.getBoundingClientRect()
160+
const itemRect = activeItem.getBoundingClientRect()
161+
const scrollTop =
162+
itemRect.top -
163+
containerRect.top +
164+
tocContainer.scrollTop -
165+
containerRect.height / 2 +
166+
itemRect.height / 2
167+
tocContainer.scrollTo({ top: scrollTop, behavior: 'smooth' })
168+
}
169+
}
170+
}, 500)
171+
172+
// Re-scan when route changes (for SPA navigation)
173+
watch(
174+
() => pageRoute.path,
175+
() => {
176+
// Clear current TOC immediately when route changes
177+
tocLinks.value = []
178+
pageTitle.value = 'Table of Contents'
179+
activeHeadingIds.value = []
180+
181+
// Use multiple strategies to ensure content is fully loaded
182+
nextTick(() => {
183+
// First attempt after nextTick
184+
scanHeadings()
185+
186+
// Second attempt with a delay to handle slow rendering
187+
setTimeout(() => {
188+
scanHeadings()
189+
}, 200)
190+
191+
// Third attempt with a longer delay as fallback
192+
setTimeout(() => {
193+
scanHeadings()
194+
}, 500)
195+
})
196+
}
197+
)
198+
199+
// Also watch for hash changes to update active states
200+
watch(
201+
() => pageRoute.hash,
202+
() => {
203+
// No need to rescan, just update active states
204+
}
205+
)
206+
207+
// Watch for DOM mutations affecting headings
208+
observer = new MutationObserver((mutations) => {
209+
let shouldRescan = false
210+
for (const mutation of mutations) {
211+
if (
212+
Array.from(mutation.addedNodes).some(isHeadingNode) ||
213+
Array.from(mutation.removedNodes).some(isHeadingNode) ||
214+
(mutation.type === 'attributes' && isHeadingNode(mutation.target))
215+
) {
216+
shouldRescan = true
217+
break
218+
}
219+
}
220+
if (shouldRescan) {
221+
// Add a small delay to allow DOM to stabilize
222+
setTimeout(() => {
223+
scanHeadings()
224+
}, 50)
225+
}
226+
})
227+
observer.observe(document.body, {
228+
childList: true,
229+
subtree: true,
230+
attributes: true,
231+
attributeFilter: ['id']
232+
})
233+
})
234+
235+
onBeforeUnmount(() => {
236+
if (observer) {
237+
observer.disconnect()
238+
observer = null
239+
}
240+
if (scrollHandler) {
241+
window.removeEventListener('scroll', scrollHandler)
242+
scrollHandler = null
243+
}
244+
})
245+
246+
function isHeadingNode(node: Node | EventTarget): boolean {
247+
if (!(node instanceof HTMLElement)) {
248+
return false
249+
}
250+
return /^H[1-6]$/.test(node.tagName)
251+
}
252+
253+
function checkActive(id: string) {
254+
return activeHeadingIds.value.includes(id) ? 'active' : null
255+
}
256+
257+
function toTop() {
258+
window.scrollTo({
259+
top: 0,
260+
behavior: 'smooth'
261+
})
262+
activeHeadingIds.value = []
263+
setTimeout(() => {
264+
window.location.hash = ''
265+
}, 600)
266+
}
267+
268+
function toBottom() {
269+
window.scrollTo({
270+
top: document.body.scrollHeight,
271+
behavior: 'smooth'
272+
})
273+
}
274+
275+
const router = useRouter()
276+
277+
function scrollToHeading(id: string) {
278+
const el = document.getElementById(id)
279+
if (el) {
280+
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
281+
// Update the hash using router to trigger reactivity
282+
router.replace({ hash: `#${id}` })
283+
}
284+
}
285+
</script>
286+
287+
<template>
288+
<Flex ref="tocContainerRef" col class="max-h-[calc(100vh-5rem)] overflow-y-auto">
289+
<Menu v-if="tocLinks.length" class="md:menu-sm xl:menu-md w-full">
290+
<MenuItem class="text-base-content cursor-pointer">
291+
<a
292+
class="flex flex-row items-center justify-between font-semibold bg-neutral/30"
293+
:class="activeHeadingIds.length === 0 ? 'active' : ''"
294+
@click="toTop"
295+
>
296+
{{ pageTitle }}
297+
<Icon name="feather:arrow-up" class="text-base" />
298+
</a>
299+
</MenuItem>
300+
301+
<TocTree
302+
:links="tocLinks"
303+
:check-active="checkActive"
304+
:scroll-to-heading="scrollToHeading"
305+
:current-id="currentHeadingId"
306+
/>
307+
308+
<MenuItem>
309+
<a class="flex flex-row items-center justify-between hover:bg-accent/25 mt-6" @click="toBottom">
310+
scroll to bottom
311+
<Icon name="feather:arrow-down" class="text-base" />
312+
</a>
313+
</MenuItem>
314+
</Menu>
315+
</Flex>
316+
</template>

0 commit comments

Comments
 (0)