mirror of
https://github.com/KazooTTT/kazoottt-blog.git
synced 2025-06-23 10:41:31 +08:00
feat: 优化体验
This commit is contained in:
@ -13,12 +13,12 @@ const { headings } = Astro.props
|
|||||||
const toc = generateToc(headings)
|
const toc = generateToc(headings)
|
||||||
---
|
---
|
||||||
|
|
||||||
<aside class='sticky top-20 order-2 -me-28 hidden basis-60 lg:flex lg:flex-col'>
|
<aside class='sticky top-20 order-2 -me-28 hidden h-[calc(100vh-6rem)] basis-60 lg:flex lg:flex-col'>
|
||||||
{
|
{
|
||||||
toc.length > 0 && (
|
toc.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<h2 class='font-semibold'>TABLE OF CONTENTS</h2>
|
<h2 class='mb-4 font-semibold'>TABLE OF CONTENTS</h2>
|
||||||
<ul class='text-card-foreground'>
|
<ul class='text-card-foreground overflow-y-auto pr-4 max-h-[calc(100vh-10rem)]'>
|
||||||
{toc.map((heading) => (
|
{toc.map((heading) => (
|
||||||
<TOCHeading heading={heading} />
|
<TOCHeading heading={heading} />
|
||||||
))}
|
))}
|
||||||
|
@ -10,16 +10,45 @@ const {
|
|||||||
} = Astro.props
|
} = Astro.props
|
||||||
---
|
---
|
||||||
|
|
||||||
<li class={`${depth > 2 ? 'ms-2' : ''}`}>
|
<li>
|
||||||
|
<div class='flex items-center gap-1'>
|
||||||
|
{
|
||||||
|
subheadings.length > 0 && (
|
||||||
|
<button
|
||||||
|
class='collapse-button flex h-4 w-4 items-center justify-center rounded-sm hover:bg-accent/50'
|
||||||
|
aria-label='Toggle section'
|
||||||
|
data-slug={slug}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class='h-3 w-3 transform transition-transform duration-200'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
stroke-width='2'
|
||||||
|
stroke-linecap='round'
|
||||||
|
stroke-linejoin='round'
|
||||||
|
>
|
||||||
|
<polyline points='6 9 12 15 18 9' />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
<a
|
<a
|
||||||
aria-label={`Scroll to section: ${text}`}
|
aria-label={`Scroll to section: ${text}`}
|
||||||
class={`block line-clamp-2 ${depth <= 2 ? 'mt-2' : 'mt-1 text-sm'} text-foreground/75 transition-all hover:text-foreground toc-link`}
|
class={`line-clamp-2 hover:text-foreground toc-link flex-1
|
||||||
|
${depth === 1 ? 'text-base font-semibold' : depth === 2 ? 'text-base' : 'text-sm'}
|
||||||
|
text-foreground/75 transition-all`}
|
||||||
href={`#${slug}`}
|
href={`#${slug}`}
|
||||||
data-slug={slug}>{text}</a
|
data-slug={slug}>{text}</a
|
||||||
>
|
>
|
||||||
|
</div>
|
||||||
{
|
{
|
||||||
!!subheadings.length && (
|
!!subheadings.length && (
|
||||||
<ul>
|
<ul
|
||||||
|
class='toc-list ms-6 overflow-hidden transition-all duration-300 ease-in-out'
|
||||||
|
style='max-height: none;'
|
||||||
|
>
|
||||||
{subheadings.map((subheading) => (
|
{subheadings.map((subheading) => (
|
||||||
<Astro.self heading={subheading} />
|
<Astro.self heading={subheading} />
|
||||||
))}
|
))}
|
||||||
@ -30,6 +59,32 @@ const {
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Collapse/Expand functionality
|
||||||
|
const buttons = document.querySelectorAll('.collapse-button')
|
||||||
|
|
||||||
|
buttons.forEach((button) => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const btn = e.currentTarget as HTMLElement
|
||||||
|
const li = btn.closest('li')
|
||||||
|
const ul = li?.querySelector('.toc-list') as HTMLElement
|
||||||
|
const svg = btn.querySelector('svg')
|
||||||
|
|
||||||
|
if (ul && svg) {
|
||||||
|
if (ul.style.maxHeight === '0px') {
|
||||||
|
// Expand
|
||||||
|
ul.style.maxHeight = ul.scrollHeight + 'px'
|
||||||
|
svg.style.transform = ''
|
||||||
|
} else {
|
||||||
|
// Collapse
|
||||||
|
ul.style.maxHeight = '0px'
|
||||||
|
svg.style.transform = 'rotate(-180deg)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Smooth scrolling with offset
|
||||||
const tocLinks = document.querySelectorAll('.toc-link')
|
const tocLinks = document.querySelectorAll('.toc-link')
|
||||||
tocLinks.forEach((link) => {
|
tocLinks.forEach((link) => {
|
||||||
link.addEventListener('click', (event) => {
|
link.addEventListener('click', (event) => {
|
||||||
@ -38,10 +93,60 @@ const {
|
|||||||
if (slug) {
|
if (slug) {
|
||||||
const element = document.getElementById(slug)
|
const element = document.getElementById(slug)
|
||||||
if (element) {
|
if (element) {
|
||||||
element.scrollIntoView({ behavior: 'smooth' })
|
const offset = 80 // Adjust this value based on header height
|
||||||
|
const elementPosition = element.getBoundingClientRect().top
|
||||||
|
const offsetPosition = elementPosition + window.pageYOffset - offset
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: offsetPosition,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Active menu highlighting
|
||||||
|
const observerOptions = {
|
||||||
|
rootMargin: '-80px 0px -40% 0px',
|
||||||
|
threshold: 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
const headings = Array.from(
|
||||||
|
document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]')
|
||||||
|
)
|
||||||
|
const headingElements = new Map()
|
||||||
|
|
||||||
|
headings.forEach((heading) => {
|
||||||
|
const id = heading.getAttribute('id')!
|
||||||
|
const tocLink = document.querySelector(`.toc-link[data-slug="${id}"]`)
|
||||||
|
if (tocLink) {
|
||||||
|
headingElements.set(heading, tocLink)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const tocLink = headingElements.get(entry.target)
|
||||||
|
if (tocLink) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
// Remove active class from all links
|
||||||
|
document.querySelectorAll('.toc-link').forEach((link) => {
|
||||||
|
link.classList.remove('active', 'text-foreground')
|
||||||
|
})
|
||||||
|
// Add active class to current link
|
||||||
|
tocLink.classList.add('active', 'text-foreground')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, observerOptions)
|
||||||
|
|
||||||
|
headings.forEach((heading) => observer.observe(heading))
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toc-link.active {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -46,7 +46,9 @@ const { headings } = await post.render()
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id='blog-gallery'
|
id='blog-gallery'
|
||||||
class='prose prose-base prose-zinc mt-12 text-muted-foreground dark:prose-invert prose-headings:font-medium prose-headings:text-foreground prose-headings:before:absolute prose-headings:before:-ms-4 prose-th:before:content-none'
|
class='prose prose-base prose-zinc mt-12 text-muted-foreground dark:prose-invert prose-headings:font-medium prose-headings:text-foreground prose-headings:before:absolute prose-headings:before:-ms-4 prose-code:bg-green-200
|
||||||
|
prose-th:before:content-none
|
||||||
|
prose-img:shadow'
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
@ -77,7 +79,7 @@ const { headings } = await post.render()
|
|||||||
id='image-modal'
|
id='image-modal'
|
||||||
class='pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 opacity-0 backdrop-blur-md transition-opacity duration-300 ease-in-out'
|
class='pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 opacity-0 backdrop-blur-md transition-opacity duration-300 ease-in-out'
|
||||||
>
|
>
|
||||||
<div class='relative flex h-full w-full items-center justify-center p-4'>
|
<div class='relative flex h-full w-full items-center justify-center p-4' id='modal-container'>
|
||||||
<img
|
<img
|
||||||
id='modal-image'
|
id='modal-image'
|
||||||
class='h-auto max-h-full w-auto max-w-full object-contain transition-transform duration-300 ease-in-out'
|
class='h-auto max-h-full w-auto max-w-full object-contain transition-transform duration-300 ease-in-out'
|
||||||
@ -125,13 +127,14 @@ const { headings } = await post.render()
|
|||||||
// Image preview functionality
|
// Image preview functionality
|
||||||
const imageModal = document.getElementById('image-modal')
|
const imageModal = document.getElementById('image-modal')
|
||||||
const modalImage = document.getElementById('modal-image') as HTMLImageElement
|
const modalImage = document.getElementById('modal-image') as HTMLImageElement
|
||||||
|
const modalContainer = document.getElementById('modal-container')
|
||||||
|
|
||||||
function openModal() {
|
function openModal() {
|
||||||
if (imageModal) {
|
if (imageModal) {
|
||||||
imageModal.classList.remove('opacity-0', 'pointer-events-none')
|
imageModal.classList.remove('opacity-0', 'pointer-events-none')
|
||||||
modalImage.style.transform = 'scale(1)'
|
modalImage.style.transform = 'scale(1)'
|
||||||
isZoomed = false
|
isZoomed = false
|
||||||
document.body.style.overflow = 'hidden' // 禁用滚动
|
document.body.style.overflow = 'hidden'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,7 +145,7 @@ const { headings } = await post.render()
|
|||||||
modalImage.alt = ''
|
modalImage.alt = ''
|
||||||
modalImage.style.transform = 'scale(1)'
|
modalImage.style.transform = 'scale(1)'
|
||||||
isZoomed = false
|
isZoomed = false
|
||||||
document.body.style.overflow = '' // 恢复滚动
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,32 +159,34 @@ const { headings } = await post.render()
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 处理点击事件
|
||||||
if (imageModal) {
|
if (imageModal) {
|
||||||
imageModal.addEventListener('click', (e) => {
|
imageModal.addEventListener('click', (e) => {
|
||||||
if (e.target === imageModal) {
|
const clickedElement = e.target as HTMLElement
|
||||||
closeModal()
|
// 如果点击的是 modal 背景或 modal-container(不是图片和关闭按钮)
|
||||||
}
|
if (clickedElement === imageModal || clickedElement === modalContainer) {
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (imageModal) {
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && !imageModal.classList.contains('opacity-0')) {
|
|
||||||
closeModal()
|
closeModal()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关闭按钮事件
|
||||||
const closeButton = document.getElementById('close-modal')
|
const closeButton = document.getElementById('close-modal')
|
||||||
|
|
||||||
if (closeButton) {
|
if (closeButton) {
|
||||||
closeButton.addEventListener('click', closeModal)
|
closeButton.addEventListener('click', closeModal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加图片缩放功能
|
// ESC 键关闭
|
||||||
let isZoomed = false
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && imageModal && !imageModal.classList.contains('opacity-0')) {
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
modalImage.addEventListener('click', () => {
|
// 图片缩放功能
|
||||||
|
let isZoomed = false
|
||||||
|
modalImage.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation() // 阻止事件冒泡到 modal
|
||||||
if (isZoomed) {
|
if (isZoomed) {
|
||||||
modalImage.style.transform = 'scale(1)'
|
modalImage.style.transform = 'scale(1)'
|
||||||
modalImage.classList.remove('zoomed')
|
modalImage.classList.remove('zoomed')
|
||||||
|
@ -14,27 +14,43 @@ function diveChildren(item: TocItem, depth: number): Array<TocItem> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function generateToc(headings: ReadonlyArray<MarkdownHeading>) {
|
export function generateToc(headings: ReadonlyArray<MarkdownHeading>) {
|
||||||
// this ignores/filters out h1 element(s)
|
|
||||||
const bodyHeadings = [...headings.filter(({ depth }) => depth > 1)]
|
|
||||||
const toc: Array<TocItem> = []
|
const toc: Array<TocItem> = []
|
||||||
|
|
||||||
bodyHeadings.forEach((h) => {
|
headings.forEach((h) => {
|
||||||
const heading: TocItem = { ...h, subheadings: [] }
|
const heading: TocItem = { ...h, subheadings: [] }
|
||||||
|
|
||||||
// add h2 elements into the top level
|
if (heading.depth === 1) {
|
||||||
if (heading.depth === 2) {
|
|
||||||
toc.push(heading)
|
toc.push(heading)
|
||||||
|
} else if (heading.depth === 2) {
|
||||||
|
const lastH1 = toc[toc.length - 1]
|
||||||
|
if (lastH1 && lastH1.depth === 1) {
|
||||||
|
lastH1.subheadings.push(heading)
|
||||||
} else {
|
} else {
|
||||||
const lastItemInToc = toc[toc.length - 1]!
|
toc.push(heading)
|
||||||
if (heading.depth < lastItemInToc.depth) {
|
}
|
||||||
throw new Error(`Orphan heading found: ${heading.text}.`)
|
} else {
|
||||||
|
const lastItem = toc[toc.length - 1]
|
||||||
|
if (!lastItem) {
|
||||||
|
toc.push(heading)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// higher depth
|
if (lastItem.depth === 1 && lastItem.subheadings.length > 0) {
|
||||||
// push into children, or children's children
|
const lastH2 = lastItem.subheadings[lastItem.subheadings.length - 1]
|
||||||
const gap = heading.depth - lastItemInToc.depth
|
if (heading.depth < lastH2.depth) {
|
||||||
const target = diveChildren(lastItemInToc, gap)
|
throw new Error(`Orphan heading found: ${heading.text}.`)
|
||||||
|
}
|
||||||
|
const gap = heading.depth - lastH2.depth
|
||||||
|
const target = diveChildren(lastH2, gap)
|
||||||
target.push(heading)
|
target.push(heading)
|
||||||
|
} else {
|
||||||
|
if (heading.depth < lastItem.depth) {
|
||||||
|
throw new Error(`Orphan heading found: ${heading.text}.`)
|
||||||
|
}
|
||||||
|
const gap = heading.depth - lastItem.depth
|
||||||
|
const target = diveChildren(lastItem, gap)
|
||||||
|
target.push(heading)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return toc
|
return toc
|
||||||
|
Reference in New Issue
Block a user