🚀 refactor: toc component with responsive design

- Implement scroll-sync highlight tracking
- Add auto-scroll functionality for active items
- Remove animations during view transitions
This commit is contained in:
radishzzz 2025-03-31 16:05:46 +01:00
parent 4c8dff619e
commit 7c6172e0ae
5 changed files with 449 additions and 309 deletions

View file

@ -6,6 +6,7 @@ import robotsTxt from 'astro-robots-txt'
import { defineConfig } from 'astro/config' import { defineConfig } from 'astro/config'
import rehypeExternalLinks from 'rehype-external-links' import rehypeExternalLinks from 'rehype-external-links'
import rehypeKatex from 'rehype-katex' import rehypeKatex from 'rehype-katex'
import rehypeSlug from 'rehype-slug'
import remarkMath from 'remark-math' import remarkMath from 'remark-math'
import UnoCSS from 'unocss/astro' import UnoCSS from 'unocss/astro'
import { themeConfig } from './src/config' import { themeConfig } from './src/config'
@ -56,6 +57,7 @@ export default defineConfig({
remarkReadingTime, remarkReadingTime,
], ],
rehypePlugins: [ rehypePlugins: [
rehypeSlug,
rehypeKatex, rehypeKatex,
[ [
rehypeExternalLinks, rehypeExternalLinks,

View file

@ -22,7 +22,7 @@
"astro-compress": "^2.3.7", "astro-compress": "^2.3.7",
"astro-og-canvas": "^0.7.0", "astro-og-canvas": "^0.7.0",
"astro-robots-txt": "^1.0.0", "astro-robots-txt": "^1.0.0",
"canvaskit-wasm": "^0.39.1", "canvaskit-wasm": "^0.40.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"overlayscrollbars": "^2.11.1", "overlayscrollbars": "^2.11.1",
"photoswipe": "^5.4.4", "photoswipe": "^5.4.4",
@ -38,9 +38,9 @@
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^22.13.14", "@types/node": "^22.13.14",
"@types/sanitize-html": "^2.15.0", "@types/sanitize-html": "^2.15.0",
"@unocss/eslint-plugin": "66.1.0-beta.7", "@unocss/eslint-plugin": "66.1.0-beta.8",
"@unocss/preset-attributify": "66.1.0-beta.7", "@unocss/preset-attributify": "66.1.0-beta.8",
"@unocss/reset": "66.1.0-beta.7", "@unocss/reset": "66.1.0-beta.8",
"astro-eslint-parser": "^1.2.2", "astro-eslint-parser": "^1.2.2",
"eslint": "^9.23.0", "eslint": "^9.23.0",
"eslint-plugin-astro": "^1.3.1", "eslint-plugin-astro": "^1.3.1",
@ -49,7 +49,7 @@
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"unocss": "66.1.0-beta.7", "unocss": "66.1.0-beta.8",
"unocss-preset-theme": "^0.14.1" "unocss-preset-theme": "^0.14.1"
}, },
"lint-staged": { "lint-staged": {

565
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -93,4 +93,10 @@ document.addEventListener('astro:after-swap', setupScrollbar)
--os-handle-bg-active: oklch(var(--un-preset-theme-colors-secondary) / 0.35); --os-handle-bg-active: oklch(var(--un-preset-theme-colors-secondary) / 0.35);
--os-handle-min-size: 12%; --os-handle-min-size: 12%;
} }
@media (min-width: 1536px) {
#toc-content .os-scrollbar {
--at-apply: 'hidden';
}
}
</style> </style>

View file

@ -20,27 +20,34 @@ const filteredHeadings = headings.filter(heading =>
{filteredHeadings.length > 0 && ( {filteredHeadings.length > 0 && (
<div <div
class="relative mb-6 bg-secondary/5" class="mb-6 bg-secondary/5 2xl:(fixed left-0 top-53 max-w-[min(calc(50vw-38rem),13rem)] border-none bg-transparent)"
border="~ secondary/5 rounded solid" border="~ secondary/5 rounded solid"
transition="~ duration-300 ease-in-out"
> >
{/* Accordion toggle for expandable TOC */}
<input <input
type="checkbox" type="checkbox"
id="toc-toggle" id="toc-toggle"
class="accordion-toggle" class="accordion-toggle"
hidden hidden
/> />
<label <div class="relative h-12 w-full bg-transparent">
for="toc-toggle" <label
class="absolute inset-0 z-99 cursor-pointer" for="toc-toggle"
></label> class="absolute inset-0 flex cursor-pointer items-center 2xl:(static h-max w-max flex c-secondary/60 transition-colors ease-in hover:c-secondary)"
>
<span class="toc-title">
{currentUI.toc}
</span>
{/* TOC title bar */} <svg
<div class="h-12 w-full flex items-center bg-secondary/0"> xmlns="http://www.w3.org/2000/svg"
<span class="toc-title"> viewBox="0 0 24 24"
{currentUI.toc} aria-hidden="true"
</span> fill="currentColor"
class="ml-4 hidden aspect-square w-4.2 2xl:(mt-4 block origin-center active:scale-90)"
>
<path d="m12 1c-6.1 0-11 4.9-11 11s4.9 11 11 11 11-4.9 11-11-4.9-11-11-11m0 20c-5.8 0-10.5-4-10.5-9s4.7-9 10.5-9 10.5 4 10.5 9-4.7 9-10.5 9" />
</svg>
</label>
</div> </div>
{/* Expandable content wrapper */} {/* Expandable content wrapper */}
@ -77,36 +84,154 @@ const filteredHeadings = headings.filter(heading =>
</div> </div>
)} )}
<!-- Override heti default styles --> <!-- Override heti default styles >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> -->
<style> <style>
.toc-title { .toc-title {
--at-apply: 'font-semibold pl-4 px-4 py-3'; --at-apply: 'font-semibold ml-4 select-none 2xl:hidden';
} }
.toc-list { .toc-list {
--at-apply: 'list-none pl-0 space-y-2 mt-1 mb-4'; --at-apply: 'list-none pl-0 space-y-2 mt-1 mb-4 2xl:space-y-1.2';
} }
.toc-link-h2 {
--at-apply: 'text-sm no-underline font-semibold'; .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))';
.toc-link-h3 {
--at-apply: 'text-sm no-underline font-normal';
}
.toc-link-h4 {
--at-apply: 'text-sm no-underline font-normal';
} }
/* Initial collapsed state with zero height grid row */ /* Initial collapsed state with zero height grid row */
.accordion-wrapper { .accordion-wrapper {
--at-apply: 'grid rows-[0fr] duration-300 ease-in-out'; --at-apply: 'grid rows-[0fr] duration-300 ease-in-out';
} }
.accordion-content { .accordion-content {
--at-apply: 'overflow-hidden max-h-66 lg:max-h-82 z-99 pl-4'; --at-apply: 'overflow-hidden max-h-66 lg:max-h-82 2xl:(max-h-[calc(100vh-21.5rem)]) px-4';
} }
/* When toggle is checked, expand the wrapper to show content */ /* When toggle is checked, expand the wrapper to show content */
.accordion-toggle:checked ~ .accordion-wrapper { .accordion-toggle:checked ~ .accordion-wrapper {
grid-template-rows: 1fr; grid-template-rows: 1fr;
} }
.accordion-toggle:checked ~ .accordion-wrapper .accordion-content { .accordion-toggle:checked ~ .accordion-wrapper .accordion-content {
--at-apply: 'overflow-y-auto';
}
@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'; --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';
}
}
</style> </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: '-5% 0% -85% 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>