Spaces:
Running
Running
File size: 5,613 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 |
export interface TocItem {
id: string;
text: string;
level: number;
}
export interface UseTableOfContentsOptions {
contentRef: Ref<HTMLElement | null>;
headerOffset?: number;
selectors?: string; // e.g., 'h2, h3, u > strong'
}
const DEFAULT_HEADER_OFFSET = 80;
const DEFAULT_SELECTORS = 'h2, h3, u > strong';
// Simple slugify, might need refinement depending on edge cases
const generateSlug = (text: string): string => {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars except space/hyphen
.trim()
.replace(/\s+/g, '-') // Replace spaces with hyphens
.slice(0, 50); // Limit length
};
export function useTableOfContents({
contentRef,
headerOffset = DEFAULT_HEADER_OFFSET,
selectors = DEFAULT_SELECTORS,
}: UseTableOfContentsOptions) {
const tocItems = ref<TocItem[]>([]);
const activeHeadingId = ref<string | null>(null);
const mobileMenuOpen = ref(false); // Keep mobile state here if tied to TOC display
let observer: IntersectionObserver | null = null;
const generateToc = () => {
if (!contentRef.value) return;
const elements = contentRef.value.querySelectorAll(selectors);
const newTocItems: TocItem[] = [];
const observedElements: Element[] = []; // Keep track of elements to observe
elements.forEach((el, index) => {
let level: number;
const text = el.textContent?.trim() || '';
let targetElement: HTMLElement = el as HTMLElement;
if (el.tagName === 'H2') level = 2;
else if (el.tagName === 'H3') level = 3;
else if (el.tagName === 'STRONG' && el.parentElement?.tagName === 'U') {
level = 5; // Special level for topics
targetElement = el.parentElement; // Target the <u> tag
} else {
return; // Skip unrecognized elements
}
// Ensure unique ID even if slug is identical
const id = `${level === 5 ? 'topic' : 'section'}-${index}-${generateSlug(text)}`;
if (text && targetElement) {
targetElement.id = id; // Assign ID
newTocItems.push({ id, text, level });
observedElements.push(targetElement); // Add element for intersection observer
}
});
tocItems.value = newTocItems;
setupIntersectionObserver(observedElements); // Setup observer after generating TOC
};
const setupIntersectionObserver = (elements: Element[]) => {
// Disconnect previous observer if exists
if (observer) {
observer.disconnect();
}
// Observer options: trigger when heading is near the top of the viewport
const options = {
rootMargin: `-${headerOffset - 1}px 0px -${window.innerHeight - headerOffset - 50}px 0px`, // Adjust bottom margin as needed
threshold: 0, // Trigger as soon as any part enters/leaves the rootMargin
};
observer = new IntersectionObserver(entries => {
// Find the topmost visible entry
let topmostVisibleEntry: IntersectionObserverEntry | null = null;
entries.forEach(entry => {
if (entry.isIntersecting) {
// Prioritize the entry closest to the top boundary defined by rootMargin
if (!topmostVisibleEntry || entry.boundingClientRect.top < topmostVisibleEntry.boundingClientRect.top) {
topmostVisibleEntry = entry;
}
}
});
if (topmostVisibleEntry) {
activeHeadingId.value = (topmostVisibleEntry as IntersectionObserverEntry).target.id;
} else {
// If no entry is intersecting within the top margin, check if we scrolled past the first item
if (tocItems.value.length > 0 && window.scrollY > document.getElementById(tocItems.value[0].id)!.offsetTop) {
// Potentially keep the last active ID, or find the last item scrolled past
// For simplicity, let's just keep the *last* one that *was* active if nothing is currently in the top zone
// activeHeadingId.value remains unchanged unless explicitly cleared or updated
} else {
// Scrolled to the very top above the first item
activeHeadingId.value = null;
}
}
}, options);
elements.forEach(el => observer!.observe(el));
};
// Computed property for the "current section name" shown in mobile/dropdown
const currentSectionName = computed(() => {
if (!activeHeadingId.value) {
return 'on this page'; // Default text
}
const activeItem = tocItems.value.find(item => item.id === activeHeadingId.value);
// Maybe find the parent H2 if the active item is H3/topic? Depends on desired UX.
// For now, just use the active item's text.
return activeItem ? activeItem.text.toLowerCase() : 'on this page';
});
const scrollToSection = (id: string) => {
const el = document.getElementById(id);
if (el) {
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
mobileMenuOpen.value = false; // Close mobile menu on selection
}
};
onMounted(() => {
// Ensure DOM is ready before querying elements
nextTick(() => {
generateToc();
});
});
onUnmounted(() => {
if (observer) {
observer.disconnect();
}
});
// Optional: Watch for content changes if the article content could be dynamic
// watch(contentRef, () => { nextTick(generateToc); });
return {
tocItems,
activeHeadingId,
currentSectionName,
mobileMenuOpen,
generateToc, // Expose if manual regeneration is needed
scrollToSection,
};
}
|