meridian-ml-service / apps /frontend /src /components /BriefTableOfContents.vue
yunlonggong's picture
Initial project upload
1b44660
<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>