Spaces:
Running
Running
<template> | |
<nav ref="tocContainer" aria-label="Table of contents" class="toc-container"> | |
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300 mb-3">On this page</h2> | |
<ul class="space-y-2 border-l border-gray-200 dark:border-gray-700"> | |
<!-- Loop through structured TOC items --> | |
<li v-for="item in structuredItems" :key="item.id" class="ml-0"> | |
<!-- Section Title (H2/H3) - Using Headless UI Disclosure --> | |
<Disclosure v-if="item.isSection" v-slot="{ open }" :default-open="expandedSections[item.id]" as="div"> | |
<DisclosureButton | |
:id="`toc-section-${item.id}`" | |
class="flex items-center hover:cursor-pointer justify-between w-full text-left text-sm transition-colors duration-150 focus:outline-none py-2 px-1 rounded hover:bg-gray-50 dark:hover:bg-gray-800" | |
:class="[ | |
'hover:text-gray-900 dark:hover:text-gray-100', | |
item.level === 2 ? 'pl-2' : 'pl-4', | |
isSectionActive(item) | |
? 'font-semibold text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-800/50' | |
: 'text-gray-500 dark:text-gray-400', | |
]" | |
@click="toggleSection(item.id)" | |
> | |
<span class="break-words pr-2">{{ item.text }}</span> | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
viewBox="0 0 20 20" | |
fill="currentColor" | |
class="w-4 h-4 text-gray-500 dark:text-gray-400 transform transition-transform duration-300 ease-in-out" | |
:class="{ 'rotate-90': open }" | |
> | |
<path | |
fill-rule="evenodd" | |
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" | |
clip-rule="evenodd" | |
/> | |
</svg> | |
</DisclosureButton> | |
<Transition | |
enter-active-class="transition-all duration-300 ease-out" | |
leave-active-class="transition-all duration-200 ease-in" | |
enter-from-class="opacity-0 -translate-y-4" | |
enter-to-class="opacity-100 translate-y-0" | |
leave-from-class="opacity-100 translate-y-0" | |
leave-to-class="opacity-0 -translate-y-4" | |
> | |
<DisclosurePanel | |
class="mt-1 space-y-1 active:border-none active:outline-none border-l border-gray-200 dark:border-gray-700 ml-1 pl-3 overflow-hidden" | |
> | |
<ul> | |
<li v-for="topic in item.topics" :key="topic.id" class="ml-0 mb-1"> | |
<NuxtLink | |
:id="`toc-item-${topic.id}`" | |
:to="`#${topic.id}`" | |
class="block text-sm hover:underline transition-colors duration-150 break-words py-1 px-1 rounded" | |
:class="[ | |
'hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800/50', | |
activeHeadingId === topic.id | |
? 'font-semibold text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-800/50' | |
: 'text-gray-500 dark:text-gray-400', | |
'pl-2', | |
]" | |
@click.prevent="$emit('navigate', topic.id)" | |
> | |
{{ topic.text }} | |
</NuxtLink> | |
</li> | |
</ul> | |
</DisclosurePanel> | |
</Transition> | |
</Disclosure> | |
</li> | |
</ul> | |
</nav> | |
</template> | |
<script setup lang="ts"> | |
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'; | |
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'; | |
interface TocItemBase { | |
id: string; | |
text: string; | |
level: number; // Original heading level (e.g., 2 for H2, 5 for Topic) | |
} | |
interface TocSection extends TocItemBase { | |
isSection: true; | |
topics: TocTopic[]; | |
} | |
interface TocTopic extends TocItemBase { | |
isSection: false; | |
} | |
type StructuredTocItem = TocSection | TocTopic; // Union type for structured items | |
// Props definition (now expects the structured items) | |
const props = defineProps<{ | |
items: TocItemBase[]; // Still receive the flat list initially | |
activeHeadingId?: string | null; // Add prop for active heading | |
}>(); | |
// Emits definition for navigation | |
const emit = defineEmits<{ | |
(e: 'navigate' | 'activeHeadingChange', id: string): void; | |
}>(); | |
// Refs | |
const tocContainer = ref<HTMLElement | null>(null); | |
const isManualScroll = ref(false); | |
// --- State for Expand/Collapse --- | |
const expandedSections = ref<Record<string, boolean>>({}); | |
const activeHeadingId = ref<string | null>(props.activeHeadingId || null); | |
// Function to check if a section is active (either directly or because one of its children is active) | |
const isSectionActive = (section: TocSection): boolean => { | |
if (!activeHeadingId.value) return false; | |
// Section itself is active | |
if (section.id === activeHeadingId.value) return true; | |
// One of section's topics is active | |
return section.topics.some(topic => topic.id === activeHeadingId.value); | |
}; | |
// Function to toggle section open/closed | |
const toggleSection = (sectionId: string) => { | |
expandedSections.value[sectionId] = !expandedSections.value[sectionId]; | |
// If opening a section, immediately scroll it into view | |
if (expandedSections.value[sectionId]) { | |
scrollActiveTocItemIntoView(sectionId, true); | |
} | |
}; | |
// Find section containing a heading ID | |
const findSectionContainingHeading = (headingId: string): TocSection | undefined => { | |
return structuredItems.value.find( | |
item => item.isSection && (item.id === headingId || item.topics.some(topic => topic.id === headingId)) | |
) as TocSection | undefined; | |
}; | |
// Scroll TOC item into view if needed | |
const scrollActiveTocItemIntoView = (headingId: string, isImmediate = false) => { | |
if (!tocContainer.value || isManualScroll.value) return; | |
// Let the section open first (if needed) | |
nextTick(() => { | |
// First try to find the active topic | |
let elementId = `toc-item-${headingId}`; | |
let element = document.getElementById(elementId); | |
// If not found, it might be a section header | |
if (!element) { | |
const section = findSectionContainingHeading(headingId); | |
if (section) { | |
elementId = `toc-section-${section.id}`; | |
element = document.getElementById(elementId); | |
} | |
} | |
if (element) { | |
// Check if element is outside view | |
const container = tocContainer.value; | |
if (!container) return; | |
const containerRect = container.getBoundingClientRect(); | |
const elementRect = element.getBoundingClientRect(); | |
const isInView = elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom; | |
// Only scroll if element is not in view | |
if (!isInView || isImmediate) { | |
isManualScroll.value = true; // Prevent observer triggers during programmatic scroll | |
// Calculate position to center the element if possible | |
const scrollTop = | |
element.offsetTop - container.offsetTop - container.clientHeight / 2 + element.clientHeight / 2; | |
container.scrollTo({ | |
top: Math.max(0, scrollTop), | |
behavior: isImmediate ? 'auto' : 'smooth', | |
}); | |
// Reset the manual scroll flag after animation completes | |
setTimeout(() => { | |
isManualScroll.value = false; | |
}, 300); // Adjust timing based on your scroll animation duration | |
} | |
} | |
}); | |
}; | |
// Update active heading and open corresponding section | |
const setActiveHeading = (headingId: string) => { | |
if (headingId === activeHeadingId.value) return; | |
activeHeadingId.value = headingId; | |
emit('activeHeadingChange', headingId); | |
const sectionItem = findSectionContainingHeading(headingId); | |
if (sectionItem) { | |
// Close all sections first | |
Object.keys(expandedSections.value).forEach(id => { | |
expandedSections.value[id] = false; | |
}); | |
// Then open just the active one | |
expandedSections.value[sectionItem.id] = true; | |
// Scroll the active item into view | |
scrollActiveTocItemIntoView(headingId); | |
} | |
}; | |
// Watch for prop changes to active heading from parent | |
watch( | |
() => props.activeHeadingId, | |
newId => { | |
if (newId) setActiveHeading(newId); | |
}, | |
{ immediate: true } | |
); | |
// --- Structure the TOC items --- | |
const structuredItems = computed((): StructuredTocItem[] => { | |
const structured: StructuredTocItem[] = []; | |
let currentSection: TocSection | null = null; | |
props.items.forEach(item => { | |
// Assuming H2/H3 are section headers, and level 5+ are topics | |
if (item.level <= 3) { | |
// Adjust level threshold if needed (e.g., <= 4 for H4 sections) | |
currentSection = { ...item, isSection: true, topics: [] }; | |
structured.push(currentSection); | |
// Initialize expanded state (default to collapsed) | |
if (expandedSections.value[item.id] === undefined) { | |
expandedSections.value[item.id] = false; | |
} | |
} else if (currentSection) { | |
// If it's a topic (level > 3) and we have a current section, add it | |
currentSection.topics.push({ ...item, isSection: false }); | |
} | |
// Ignore topics that don't appear under a section header (optional) | |
}); | |
return structured; | |
}); | |
// --- Active Heading Highlighting based on scroll position --- | |
let observer: IntersectionObserver | null = null; | |
const observedElements = ref<Map<string, HTMLElement>>(new Map()); | |
const observerCallback: IntersectionObserverCallback = entries => { | |
if (isManualScroll.value) return; // Skip if currently programmatically scrolling | |
// Find the entry highest up in the viewport that is intersecting | |
let topIntersectingEntry: IntersectionObserverEntry | null = null; | |
entries.forEach(entry => { | |
if (entry.isIntersecting) { | |
if (!topIntersectingEntry || entry.boundingClientRect.top < topIntersectingEntry.boundingClientRect.top) { | |
topIntersectingEntry = entry; | |
} | |
} | |
}); | |
if (topIntersectingEntry) { | |
const newActiveId = (topIntersectingEntry as IntersectionObserverEntry).target.id; | |
setActiveHeading(newActiveId); | |
} | |
}; | |
onMounted(() => { | |
// Setup the observer on mount to ensure DOM is ready | |
setTimeout(setupObserver, 200); // Short delay to ensure DOM is ready | |
}); | |
const setupObserver = () => { | |
if (!('IntersectionObserver' in window)) return; | |
observer?.disconnect(); // Disconnect previous observer if any | |
observedElements.value.clear(); | |
observer = new IntersectionObserver(observerCallback, { | |
rootMargin: '0px 0px -70% 0px', // Trigger when heading is in top 30% of viewport | |
threshold: 0, | |
}); | |
// Find and observe all headings in the document | |
structuredItems.value.forEach(item => { | |
if (item.isSection) { | |
const el = document.getElementById(item.id); | |
if (el) { | |
observer?.observe(el); | |
observedElements.value.set(item.id, el); | |
} | |
item.topics.forEach(topic => { | |
const topicEl = document.getElementById(topic.id); | |
if (topicEl) { | |
observer?.observe(topicEl); | |
observedElements.value.set(topic.id, topicEl); | |
} | |
}); | |
} | |
}); | |
}; | |
// Watch for changes in items to re-setup the observer | |
watch( | |
() => props.items, | |
() => { | |
// Need a small delay to ensure all DOM elements are available | |
setTimeout(setupObserver, 200); | |
}, | |
{ immediate: true, deep: true } | |
); | |
// Clean up observer on component unmount | |
onUnmounted(() => { | |
observer?.disconnect(); | |
}); | |
</script> | |
<style scoped> | |
.toc-container { | |
max-height: calc(100vh - 8rem); | |
overflow-y: auto; | |
scrollbar-width: thin; | |
position: relative; | |
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */ | |
} | |
/* Subtle scrollbar styling */ | |
.toc-container::-webkit-scrollbar { | |
width: 5px; | |
} | |
.toc-container::-webkit-scrollbar-track { | |
background: transparent; | |
} | |
.toc-container::-webkit-scrollbar-thumb { | |
background-color: rgba(156, 163, 175, 0.3); | |
border-radius: 3px; | |
} | |
.dark .toc-container::-webkit-scrollbar-thumb { | |
background-color: rgba(75, 85, 99, 0.5); | |
} | |
/* Smooth height transition for accordion */ | |
.slide-enter-active, | |
.slide-leave-active { | |
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
max-height: 300px; | |
overflow: hidden; | |
} | |
.slide-enter-from, | |
.slide-leave-to { | |
max-height: 0; | |
opacity: 0; | |
} | |
</style> | |