mirror of
https://github.com/reonokiy/blog.nokiy.net.git
synced 2025-06-16 03:32:51 +02:00
🚀 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:
parent
4c8dff619e
commit
7c6172e0ae
5 changed files with 449 additions and 309 deletions
|
@ -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,
|
||||||
|
|
10
package.json
10
package.json
|
@ -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
565
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue