Spaces:
Running
Running
File size: 12,415 Bytes
1b44660 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 |
<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>
|