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

View file

@ -22,7 +22,7 @@
"astro-compress": "^2.3.7",
"astro-og-canvas": "^0.7.0",
"astro-robots-txt": "^1.0.0",
"canvaskit-wasm": "^0.39.1",
"canvaskit-wasm": "^0.40.0",
"markdown-it": "^14.1.0",
"overlayscrollbars": "^2.11.1",
"photoswipe": "^5.4.4",
@ -38,9 +38,9 @@
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.13.14",
"@types/sanitize-html": "^2.15.0",
"@unocss/eslint-plugin": "66.1.0-beta.7",
"@unocss/preset-attributify": "66.1.0-beta.7",
"@unocss/reset": "66.1.0-beta.7",
"@unocss/eslint-plugin": "66.1.0-beta.8",
"@unocss/preset-attributify": "66.1.0-beta.8",
"@unocss/reset": "66.1.0-beta.8",
"astro-eslint-parser": "^1.2.2",
"eslint": "^9.23.0",
"eslint-plugin-astro": "^1.3.1",
@ -49,7 +49,7 @@
"reading-time": "^1.5.0",
"sharp": "^0.33.5",
"typescript": "~5.8.2",
"unocss": "66.1.0-beta.7",
"unocss": "66.1.0-beta.8",
"unocss-preset-theme": "^0.14.1"
},
"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-min-size: 12%;
}
@media (min-width: 1536px) {
#toc-content .os-scrollbar {
--at-apply: 'hidden';
}
}
</style>

View file

@ -20,27 +20,34 @@ const filteredHeadings = headings.filter(heading =>
{filteredHeadings.length > 0 && (
<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"
transition="~ duration-300 ease-in-out"
>
{/* Accordion toggle for expandable TOC */}
<input
type="checkbox"
id="toc-toggle"
class="accordion-toggle"
hidden
/>
<label
for="toc-toggle"
class="absolute inset-0 z-99 cursor-pointer"
></label>
<div class="relative h-12 w-full bg-transparent">
<label
for="toc-toggle"
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 */}
<div class="h-12 w-full flex items-center bg-secondary/0">
<span class="toc-title">
{currentUI.toc}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
aria-hidden="true"
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>
{/* Expandable content wrapper */}
@ -77,36 +84,154 @@ const filteredHeadings = headings.filter(heading =>
</div>
)}
<!-- Override heti default styles -->
<!-- Override heti default styles >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> -->
<style>
.toc-title {
--at-apply: 'font-semibold pl-4 px-4 py-3';
--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';
--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-h3 {
--at-apply: 'text-sm no-underline font-normal';
}
.toc-link-h4 {
--at-apply: 'text-sm no-underline font-normal';
.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: '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 */
.accordion-toggle:checked ~ .accordion-wrapper {
grid-template-rows: 1fr;
}
.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';
}
.accordion-toggle:checked ~ .accordion-wrapper .accordion-content {
--at-apply: 'overflow-hidden';
}
.toc-link-active {
--at-apply: 'c-secondary font-medium';
}
}
</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>