blog/src/components/Widgets/TOC.astro

237 lines
6.5 KiB
Text

---
import type { MarkdownHeading } from 'astro'
import TocIcon from '@/assets/icons/toc-icon.svg'
import { ui } from '@/i18n/ui'
import { getPageInfo } from '@/utils/page'
interface Props {
headings: MarkdownHeading[]
}
const { currentLang } = getPageInfo(Astro.url.pathname)
const currentUI = ui[currentLang as keyof typeof ui]
const { headings = [] } = Astro.props
const filteredHeadings = headings.filter(heading =>
heading.depth >= 2
&& heading.depth <= 4,
)
---
{filteredHeadings.length > 0 && (
<div class="mb-4 uno-round-border bg-secondary/5 2xl:(fixed left-0 top-43.5 max-w-[min(calc(50vw-38rem),13rem)] border-none bg-transparent)">
<input
type="checkbox"
id="toc-toggle"
class="accordion-toggle"
hidden
/>
<div class="relative h-12 w-full bg-transparent">
<label
for="toc-toggle"
class="absolute inset-0 flex cursor-pointer items-center 2xl:(static flex c-secondary/40 transition-colors ease-out hover:c-secondary/80)"
>
<span class="toc-title">
{currentUI.toc}
</span>
<TocIcon
aria-hidden="true"
class="ml-2 hidden aspect-square w-4.2 2xl:(mt-4 block origin-center active:scale-90)"
fill="currentColor"
/>
</label>
</div>
{/* Expandable content wrapper */}
<div class="accordion-wrapper">
<nav
id="toc-content"
class="accordion-content"
aria-label="Table of Contents"
>
<ul class="toc-list">
{filteredHeadings.map(heading => (
<li
class:list={{
'ml-0': heading.depth === 2,
'ml-4': heading.depth === 3,
'ml-8': heading.depth === 4,
}}
>
<a
href={`#${heading.slug}`}
class:list={[
{ 'toc-link-h2': heading.depth === 2 },
{ 'toc-link-h3': heading.depth === 3 },
{ 'toc-link-h4': heading.depth === 4 },
]}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
</div>
</div>
)}
<!-- Override heti default styles >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> -->
<style>
.toc-title {
--at-apply: 'font-semibold ml-4 select-none 2xl:hidden';
}
.toc-list {
--at-apply: 'list-none pl-0 space-y-2 mt-1 mb-4 2xl:space-y-1.2';
}
.toc-link-h2, .toc-link-h3, .toc-link-h4 {
--at-apply: 'text-sm no-underline font-normal text-balance select-none 2xl:(text-3.2 c-secondary/60 transition-colors ease-in hover:(c-secondary font-medium))';
}
/* Initial collapsed state with zero height grid row */
.accordion-wrapper {
--at-apply: 'grid rows-[0fr] duration-300 ease-in-out';
}
.accordion-content {
--at-apply: 'max-h-66 overflow-hidden pl-4 pr-6 2xl:(max-h-[calc(100vh-21.5rem)] pl-2)';
}
/* When toggle is checked, expand the wrapper to show content */
.accordion-toggle:checked ~ .accordion-wrapper {
grid-template-rows: 1fr;
}
.accordion-toggle:checked ~ .accordion-wrapper .accordion-content {
--at-apply: 'overflow-y-auto';
}
#toc-content::-webkit-scrollbar {
--at-apply: 'w-1.25 lg:w-1.5';
}
#toc-content::-webkit-scrollbar-thumb {
--at-apply: 'rounded-full bg-secondary/15';
}
#toc-content::-webkit-scrollbar-thumb:hover {
--at-apply: 'bg-secondary/25';
}
@media (min-width: 1536px) {
.accordion-wrapper {
grid-template-rows: 1fr;
}
.accordion-toggle:checked ~ .accordion-wrapper {
grid-template-rows: 0fr;
}
.accordion-content {
--at-apply: 'overflow-y-auto';
}
.accordion-toggle:checked ~ .accordion-wrapper .accordion-content {
--at-apply: 'overflow-hidden';
}
.toc-link-active {
--at-apply: 'c-secondary font-medium';
}
#toc-content {
--at-apply: 'scrollbar-hidden';
}
#toc-content::-webkit-scrollbar {
display: none;
}
}
</style>
<!-- TOC Highlight Script >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> -->
<script>
function setupTOCHighlight() {
const tocContent = document.getElementById('toc-content')
if (!tocContent)
return
const tocLinks = tocContent.querySelectorAll('a')
if (tocLinks.length === 0)
return
// Check if in large screen (2xl) mode
let is2xl = window.innerWidth >= 1536
// Build mapping from heading IDs to TOC links
const headingMap = new Map<string, HTMLAnchorElement>()
tocLinks.forEach((link) => {
const id = link.getAttribute('href')?.substring(1)
if (id)
headingMap.set(id, link as HTMLAnchorElement)
})
let currentActiveLink: HTMLAnchorElement | null = null
function highlightLink(link: HTMLAnchorElement) {
if (link === currentActiveLink)
return
if (currentActiveLink) {
currentActiveLink.classList.remove('toc-link-active')
}
link.classList.add('toc-link-active')
currentActiveLink = link
// Only scroll the TOC in large screen (2xl) mode
if (is2xl) {
scrollLinkToCenter(link)
}
}
// Scroll the link to center position
function scrollLinkToCenter(link: HTMLAnchorElement) {
link.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
// Intersection observer callback
const intersectionCallback = (entries: IntersectionObserverEntry[]) => {
const visibleHeading = entries.find(entry => entry.isIntersecting)?.target
if (visibleHeading && visibleHeading.id) {
const link = headingMap.get(visibleHeading.id)
if (link)
highlightLink(link)
}
}
// Create the intersection observer
const headingObserver = new IntersectionObserver(intersectionCallback, {
rootMargin: '0% 0% -66% 0%',
threshold: [0.4],
})
// Observe all heading elements
document.querySelectorAll('h2, h3, h4').forEach((heading) => {
if (heading.id && heading.id !== 'footnotes') {
headingObserver.observe(heading)
}
})
// Highlight the first TOC item by default
if (tocLinks.length > 0) {
highlightLink(tocLinks[0] as HTMLAnchorElement)
}
// Listen for window resize events
window.addEventListener('resize', () => {
const newIs2xl = window.innerWidth >= 1536
if (is2xl !== newIs2xl) {
// Update screen size flag
is2xl = newIs2xl
if (currentActiveLink && is2xl) {
scrollLinkToCenter(currentActiveLink)
}
}
})
}
setupTOCHighlight()
document.addEventListener('astro:after-swap', setupTOCHighlight)
</script>