You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
182 lines
5.5 KiB
182 lines
5.5 KiB
<script lang="ts"> |
|
import { |
|
TableOfContents, |
|
type TocEntry, |
|
} from "$lib/components/publications/table_of_contents.svelte"; |
|
import { getContext } from "svelte"; |
|
import { |
|
SidebarDropdownWrapper, |
|
SidebarGroup, |
|
SidebarItem, |
|
} from "flowbite-svelte"; |
|
import Self from "./TableOfContents.svelte"; |
|
import { onMount, onDestroy } from "svelte"; |
|
|
|
let { depth, onSectionFocused, onLoadMore, toc } = $props<{ |
|
rootAddress: string; |
|
depth: number; |
|
toc: TableOfContents; |
|
onSectionFocused?: (address: string) => void; |
|
onLoadMore?: () => void; |
|
}>(); |
|
|
|
let entries = $derived.by<TocEntry[]>(() => { |
|
const newEntries = []; |
|
for (const [_, entry] of toc.addressMap) { |
|
if (entry.depth !== depth) { |
|
continue; |
|
} |
|
|
|
newEntries.push(entry); |
|
} |
|
|
|
return newEntries; |
|
}); |
|
|
|
// Track the currently visible section |
|
let currentVisibleSection = $state<string | null>(null); |
|
let observer: IntersectionObserver; |
|
|
|
function setEntryExpanded(address: string, expanded: boolean = false) { |
|
const entry = toc.getEntry(address); |
|
if (!entry) { |
|
return; |
|
} |
|
|
|
toc.expandedMap.set(address, expanded); |
|
entry.resolveChildren(); |
|
} |
|
|
|
function handleSectionClick(address: string) { |
|
// Smooth scroll to the section |
|
const element = document.getElementById(address); |
|
if (element) { |
|
element.scrollIntoView({ |
|
behavior: 'smooth', |
|
block: 'start', |
|
}); |
|
} |
|
|
|
onSectionFocused?.(address); |
|
|
|
// Check if this is the last entry and trigger loading more events |
|
const currentEntries = entries; |
|
const lastEntry = currentEntries[currentEntries.length - 1]; |
|
if (lastEntry && lastEntry.address === address) { |
|
console.debug('[TableOfContents] Last entry clicked, triggering load more'); |
|
onLoadMore?.(); |
|
} |
|
} |
|
|
|
// Check if an entry is currently visible |
|
function isEntryVisible(address: string): boolean { |
|
return currentVisibleSection === address; |
|
} |
|
|
|
// Set up intersection observer to track visible sections |
|
onMount(() => { |
|
observer = new IntersectionObserver( |
|
(entries) => { |
|
// Find the section that is most visible in the viewport |
|
let maxIntersectionRatio = 0; |
|
let mostVisibleSection: string | null = null; |
|
|
|
entries.forEach((entry) => { |
|
if (entry.isIntersecting && entry.intersectionRatio > maxIntersectionRatio) { |
|
maxIntersectionRatio = entry.intersectionRatio; |
|
mostVisibleSection = entry.target.id; |
|
} |
|
}); |
|
|
|
if (mostVisibleSection && mostVisibleSection !== currentVisibleSection) { |
|
currentVisibleSection = mostVisibleSection; |
|
} |
|
}, |
|
{ |
|
threshold: [0, 0.25, 0.5, 0.75, 1], |
|
rootMargin: "-20% 0px -20% 0px", // Consider section visible when it's in the middle 60% of the viewport |
|
} |
|
); |
|
|
|
// Function to observe all section elements |
|
function observeSections() { |
|
const sections = document.querySelectorAll('section[id]'); |
|
sections.forEach((section) => { |
|
observer.observe(section); |
|
}); |
|
} |
|
|
|
// Initial observation |
|
observeSections(); |
|
|
|
// Set up a mutation observer to watch for new sections being added |
|
const mutationObserver = new MutationObserver((mutations) => { |
|
mutations.forEach((mutation) => { |
|
mutation.addedNodes.forEach((node) => { |
|
if (node.nodeType === Node.ELEMENT_NODE) { |
|
const element = node as Element; |
|
// Check if the added node is a section with an id |
|
if (element.tagName === 'SECTION' && element.id) { |
|
observer.observe(element); |
|
} |
|
// Check if the added node contains sections |
|
const sections = element.querySelectorAll?.('section[id]'); |
|
if (sections) { |
|
sections.forEach((section) => { |
|
observer.observe(section); |
|
}); |
|
} |
|
} |
|
}); |
|
}); |
|
}); |
|
|
|
// Start observing the document body for changes |
|
mutationObserver.observe(document.body, { |
|
childList: true, |
|
subtree: true, |
|
}); |
|
|
|
return () => { |
|
observer.disconnect(); |
|
mutationObserver.disconnect(); |
|
}; |
|
}); |
|
|
|
onDestroy(() => { |
|
if (observer) { |
|
observer.disconnect(); |
|
} |
|
}); |
|
</script> |
|
|
|
<!-- TODO: Figure out how to style indentations. --> |
|
<!-- TODO: Make group title fonts the same as entry title fonts. --> |
|
<SidebarGroup> |
|
{#each entries as entry, index} |
|
{@const address = entry.address} |
|
{@const expanded = toc.expandedMap.get(address) ?? false} |
|
{@const isLeaf = toc.leaves.has(address)} |
|
{@const isVisible = isEntryVisible(address)} |
|
{#if isLeaf} |
|
<SidebarItem |
|
label={entry.title} |
|
href={`#${address}`} |
|
spanClass="px-2 text-ellipsis" |
|
class={`${isVisible ? "toc-highlight" : ""} `} |
|
onclick={() => handleSectionClick(address)} |
|
> |
|
<!-- Empty for now - could add icons or labels here in the future --> |
|
</SidebarItem> |
|
{:else} |
|
{@const childDepth = depth + 1} |
|
<SidebarDropdownWrapper |
|
label={entry.title} |
|
btnClass="flex items-center p-2 w-full font-normal text-gray-900 rounded-lg transition duration-75 group hover:bg-primary-50 dark:text-white dark:hover:bg-primary-800 {isVisible ? 'toc-highlight' : ''} " |
|
bind:isOpen={() => expanded, (open) => setEntryExpanded(address, open)} |
|
> |
|
<Self rootAddress={address} depth={childDepth} {toc} {onSectionFocused} {onLoadMore} /> |
|
</SidebarDropdownWrapper> |
|
{/if} |
|
{/each} |
|
</SidebarGroup>
|
|
|