feat: improved ToC highlighting
This commit is contained in:
parent
b93eddea6b
commit
c2fa587935
16 changed files with 636 additions and 115 deletions
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
import { Image } from 'astro:assets'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import Link from '@components/Link.astro'
|
||||
import { Image } from 'astro:assets'
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
|
||||
type Props = {
|
||||
|
@ -11,7 +11,9 @@ type Props = {
|
|||
const { project } = Astro.props
|
||||
---
|
||||
|
||||
<div class="overflow-hidden rounded-xl border transition-colors duration-300 ease-in-out hover:bg-secondary/50">
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border transition-colors duration-300 ease-in-out hover:bg-secondary/50"
|
||||
>
|
||||
<Link href={project.data.link} class="block">
|
||||
<Image
|
||||
src={project.data.image}
|
||||
|
@ -22,12 +24,18 @@ const { project } = Astro.props
|
|||
/>
|
||||
<div class="p-4">
|
||||
<h3 class="mb-2 text-lg font-semibold">{project.data.name}</h3>
|
||||
<p class="mb-4 text-sm text-muted-foreground">{project.data.description}</p>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
{project.data.description}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{project.data.tags.map((tag) => (
|
||||
<Badge variant="secondary" showHash={false}>{tag}</Badge>
|
||||
))}
|
||||
{
|
||||
project.data.tags.map((tag) => (
|
||||
<Badge variant="secondary" showHash={false}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,28 +1,37 @@
|
|||
---
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import TableOfContentsHeading from './TableOfContentsHeading.astro'
|
||||
|
||||
// https://kld.dev/building-table-of-contents/
|
||||
const { headings } = Astro.props
|
||||
const toc = buildToc(headings)
|
||||
|
||||
export interface Heading {
|
||||
depth: number
|
||||
slug: string
|
||||
text: string
|
||||
subheadings: Heading[]
|
||||
}
|
||||
|
||||
function buildToc(headings: Heading[]) {
|
||||
const { headings } = Astro.props
|
||||
const toc = buildToc(headings)
|
||||
|
||||
function buildToc(headings: Heading[]): Heading[] {
|
||||
const toc: Heading[] = []
|
||||
const parentHeadings = new Map()
|
||||
const stack: Heading[] = []
|
||||
|
||||
headings.forEach((h) => {
|
||||
const heading = { ...h, subheadings: [] }
|
||||
parentHeadings.set(heading.depth, heading)
|
||||
if (heading.depth === 2) {
|
||||
|
||||
while (stack.length > 0 && stack[stack.length - 1].depth >= heading.depth) {
|
||||
stack.pop()
|
||||
}
|
||||
|
||||
if (stack.length === 0) {
|
||||
toc.push(heading)
|
||||
} else {
|
||||
parentHeadings.get(heading.depth - 1).subheadings.push(heading)
|
||||
stack[stack.length - 1].subheadings.push(heading)
|
||||
}
|
||||
|
||||
stack.push(heading)
|
||||
})
|
||||
|
||||
return toc
|
||||
}
|
||||
---
|
||||
|
@ -32,17 +41,9 @@ function buildToc(headings: Heading[]) {
|
|||
class="flex cursor-pointer items-center justify-between text-xl font-semibold"
|
||||
>
|
||||
Table of Contents
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="size-5 transition-transform group-open:rotate-180"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<ChevronDown
|
||||
className="size-5 transition-transform group-open:rotate-180"
|
||||
/>
|
||||
</summary>
|
||||
<nav>
|
||||
<ul class="pt-3">
|
||||
|
@ -50,11 +51,15 @@ function buildToc(headings: Heading[]) {
|
|||
</ul>
|
||||
</nav>
|
||||
</details>
|
||||
|
||||
<nav
|
||||
class="overflow-wrap-break-word sticky top-16 hidden h-0 w-[calc(50vw-50%-4rem)] translate-x-[calc(-100%-2em)] text-xs leading-4 xl:block"
|
||||
>
|
||||
<div class="mr-6 flex justify-end">
|
||||
<ul class="max-h-[calc(100vh-8rem)] space-y-2 overflow-y-auto">
|
||||
<ul
|
||||
class="max-h-[calc(100vh-8rem)] space-y-2 overflow-y-auto"
|
||||
id="toc-container"
|
||||
>
|
||||
<li>
|
||||
<h2 class="mb-2 text-lg font-semibold">Table of Contents</h2>
|
||||
</li>
|
||||
|
@ -62,3 +67,32 @@ function buildToc(headings: Heading[]) {
|
|||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
function setupToc() {
|
||||
const observer = new IntersectionObserver((sections) => {
|
||||
sections.forEach((section) => {
|
||||
const heading = section.target.querySelector('h2, h3, h4, h5, h6')
|
||||
if (!heading) return
|
||||
|
||||
const id = heading.getAttribute('id')
|
||||
const link = document.querySelector(
|
||||
`#toc-container li a[href="#${id}"]`,
|
||||
)
|
||||
if (!link) return
|
||||
|
||||
const addRemove = section.intersectionRatio > 0 ? 'add' : 'remove'
|
||||
link.classList[addRemove]('font-semibold')
|
||||
link.classList[addRemove]('text-foreground')
|
||||
})
|
||||
})
|
||||
|
||||
const sections = document.querySelectorAll('.prose section')
|
||||
sections.forEach((section) => {
|
||||
observer.observe(section)
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener('astro:page-load', setupToc)
|
||||
document.addEventListener('astro:after-swap', setupToc)
|
||||
</script>
|
||||
|
|
|
@ -8,7 +8,10 @@ const { heading } = Astro.props
|
|||
<li
|
||||
class="mr-2 list-inside list-disc px-6 py-1.5 text-sm text-foreground/60 xl:list-none xl:p-0"
|
||||
>
|
||||
<Link href={'#' + heading.slug} class="toc-link" data-heading={heading.slug}>
|
||||
<Link
|
||||
href={'#' + heading.slug}
|
||||
class="toc-link transition-colors duration-200"
|
||||
>
|
||||
{heading.text}
|
||||
</Link>
|
||||
{
|
||||
|
@ -21,33 +24,3 @@ const { heading } = Astro.props
|
|||
)
|
||||
}
|
||||
</li>
|
||||
|
||||
<script>
|
||||
function updateActiveHeading() {
|
||||
const headings = document.querySelectorAll('h2, h3, h4, h5, h6')
|
||||
const tocLinks = document.querySelectorAll('.toc-link')
|
||||
|
||||
let currentHeading = ''
|
||||
|
||||
headings.forEach((heading) => {
|
||||
const top = heading.getBoundingClientRect().top
|
||||
if (top < 200) {
|
||||
currentHeading = heading.id
|
||||
}
|
||||
})
|
||||
|
||||
tocLinks.forEach((link) => {
|
||||
const headingSlug = link.getAttribute('data-heading')
|
||||
if (headingSlug === currentHeading) {
|
||||
link.classList.add('underline')
|
||||
link.classList.add('text-foreground')
|
||||
} else {
|
||||
link.classList.remove('underline')
|
||||
link.classList.remove('text-foreground')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', updateActiveHeading)
|
||||
window.addEventListener('load', updateActiveHeading)
|
||||
</script>
|
||||
|
|
|
@ -7,7 +7,7 @@ const Card = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('bg-background rounded-xl border', className)}
|
||||
className={cn('rounded-xl border bg-background', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
|
|
@ -23,13 +23,15 @@ export function ModeToggle() {
|
|||
theme === 'dark' ||
|
||||
(theme === 'system' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
|
||||
document.documentElement.classList.add('disable-transitions')
|
||||
|
||||
document.documentElement.classList[isDark ? 'add' : 'remove']('dark')
|
||||
|
||||
window.getComputedStyle(document.documentElement).getPropertyValue('opacity')
|
||||
|
||||
|
||||
window
|
||||
.getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('opacity')
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.classList.remove('disable-transitions')
|
||||
})
|
||||
|
@ -65,4 +67,4 @@ export function ModeToggle() {
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue