init
This commit is contained in:
591
src/components/widgets/Toc.astro
Normal file
591
src/components/widgets/Toc.astro
Normal file
@@ -0,0 +1,591 @@
|
||||
---
|
||||
interface Heading {
|
||||
depth: number;
|
||||
slug: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface HeadingNode extends Heading {
|
||||
children: HeadingNode[];
|
||||
}
|
||||
|
||||
const { headings = [] } = Astro.props as { headings: Heading[] };
|
||||
|
||||
function buildTree(list: Heading[]): HeadingNode[] {
|
||||
const root: HeadingNode[] = [];
|
||||
const stack: HeadingNode[] = [];
|
||||
list.forEach(h => {
|
||||
const node: HeadingNode = { ...h, children: [] };
|
||||
while (stack.length && stack[stack.length - 1].depth >= h.depth) {
|
||||
stack.pop();
|
||||
}
|
||||
if (stack.length === 0) {
|
||||
root.push(node);
|
||||
} else {
|
||||
stack[stack.length - 1].children.push(node);
|
||||
}
|
||||
stack.push(node);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
const tree = buildTree(headings);
|
||||
---
|
||||
|
||||
<div class="toc-container">
|
||||
<div class="toc-header">
|
||||
<button id="toc-toggle" aria-expanded="false" aria-controls="toc-content">
|
||||
<span class="font-brand text-lg">TOC</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="chevron-down">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toc-title">
|
||||
<span class="toc-title-text font-brand text-base">TOC</span>
|
||||
<button id="toc-toggle-desktop" class="toc-toggle-desktop" aria-expanded="true" aria-controls="toc-content" aria-label="Toggle table of contents">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="chevron-up">
|
||||
<path d="m18 15-6-6-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav id="toc-content" class="toc" aria-label="TOC">
|
||||
<ul>
|
||||
{tree.map(node => (
|
||||
<li class={`toc-item font-brand depth-${node.depth}`}>
|
||||
<a href={`#${node.slug}`} data-depth={node.depth} class="toc-link">{node.text}</a>
|
||||
{node.children.length > 0 && (
|
||||
<ul>
|
||||
{node.children.map((c: HeadingNode) => (
|
||||
<li class={`toc-item font-brand depth-${c.depth}`}>
|
||||
<a href={`#${c.slug}`} data-depth={c.depth} class="toc-link">{c.text}</a>
|
||||
{c.children.length > 0 && (
|
||||
<ul>
|
||||
{c.children.map((cc: HeadingNode) => (
|
||||
<li class={`toc-item font-brand depth-${cc.depth}`}>
|
||||
<a href={`#${cc.slug}`} data-depth={cc.depth} class="toc-link">{cc.text}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toc-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toc {
|
||||
max-height: calc(100vh - 4rem);
|
||||
overflow-y: auto;
|
||||
font-size: 1rem;
|
||||
padding: 0;
|
||||
border-radius: 0.75rem;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
scrollbar-width: thin;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.toc::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.toc::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.toc::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toc::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
:global(.dark) .toc::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
:global(.dark) .toc::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.toc-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--color-neutral-200);
|
||||
}
|
||||
|
||||
:global(.dark) .toc-title {
|
||||
border-bottom-color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.toc-title-text {
|
||||
letter-spacing: 1px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
:global(.dark) .toc-title-text {
|
||||
color: var(--color-text-primary-dark);
|
||||
}
|
||||
|
||||
.toc-toggle-desktop {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-neutral-500);
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
:global(.dark) .toc-toggle-desktop {
|
||||
color: var(--color-text-secondary-dark);
|
||||
}
|
||||
|
||||
.toc-toggle-desktop:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
:global(.dark) .toc-toggle-desktop:hover {
|
||||
color: var(--color-primary-light-dark);
|
||||
background: var(--color-bg-tertiary-dark);
|
||||
}
|
||||
|
||||
.toc-toggle-desktop:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.toc-toggle-desktop svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.toc-toggle-desktop[aria-expanded="false"] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
|
||||
.toc ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
.toc > ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.toc-item a {
|
||||
display: block;
|
||||
color: var(--color-neutral-500);
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0.125rem 0;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-size:1rem;
|
||||
letter-spacing: .5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:global(.dark) .toc-item a {
|
||||
color: var(--color-text-secondary-dark);
|
||||
}
|
||||
|
||||
.toc-item a:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
:global(.dark) .toc-item a:hover {
|
||||
color: var(--color-primary-light-dark);
|
||||
background: var(--color-bg-tertiary-dark);
|
||||
}
|
||||
|
||||
.toc-item a.active {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-neutral-100);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.dark) .toc-item a.active {
|
||||
color: var(--color-primary-light-dark);
|
||||
background: var(--color-bg-tertiary-dark);
|
||||
}
|
||||
|
||||
.toc-item a.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2px;
|
||||
height: 60%;
|
||||
background: var(--color-primary);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
:global(.dark) .toc-item a.active::before {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.depth-1 { font-weight: 600; }
|
||||
.depth-2 { padding-left: 0.5rem; }
|
||||
.depth-3 { padding-left: .2em; font-size: 0.875em; }
|
||||
|
||||
/* 响应式样式 */
|
||||
@media (max-width: 1023px) {
|
||||
.toc-container {
|
||||
position: relative;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.toc-title {
|
||||
display: none;
|
||||
}
|
||||
.toc-header {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#toc-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 0.75rem;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.025em;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
:global(.dark) #toc-toggle {
|
||||
background: var(--color-bg-secondary-dark);
|
||||
border-color: var(--color-neutral-700);
|
||||
color: var(--color-text-primary-dark);
|
||||
}
|
||||
|
||||
#toc-toggle:hover {
|
||||
background: var(--color-neutral-100);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) #toc-toggle:hover {
|
||||
background: var(--color-bg-tertiary-dark);
|
||||
border-color: var(--color-primary-light-dark);
|
||||
}
|
||||
|
||||
#toc-toggle:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.chevron-down {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
|
||||
#toc-toggle[aria-expanded="true"] .chevron-down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toc {
|
||||
display: none;
|
||||
position: static; /* 移动端不使用 sticky */
|
||||
max-height: none;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.toc.expanded {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.toc-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
.toc {
|
||||
width: 100%;
|
||||
}
|
||||
.toc-toggle-desktop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toc.collapsed {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-width: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
.chevron-up {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.toc-toggle-desktop[aria-expanded="true"] .chevron-up {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.toc-toggle-desktop[aria-expanded="false"] .chevron-up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.toc-container {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
#toc-toggle {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
}
|
||||
|
||||
#toc-content::-webkit-scrollbar {
|
||||
width: 6px !important; }
|
||||
|
||||
#toc-content::-webkit-scrollbar-track {
|
||||
background: var(--color-neutral-200) !important;
|
||||
border-radius: 6px !important;
|
||||
-webkit-border-radius: 6px !important;
|
||||
-moz-border-radius: 6px !important;
|
||||
-ms-border-radius: 6px !important;
|
||||
-o-border-radius: 6px !important; }
|
||||
|
||||
#toc-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-neutral-300) !important;
|
||||
border-radius: 6px !important; }
|
||||
#toc-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-primary) !important; }
|
||||
#toc-content::-webkit-scrollbar-thumb:active {
|
||||
background: var(--color-primary-dark) !important; }
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Use IIFE to avoid multiple instance conflicts
|
||||
(() => {
|
||||
// Get the current container of the component
|
||||
const containers = document.querySelectorAll('.toc-container');
|
||||
|
||||
containers.forEach((container) => {
|
||||
const tocToggle = container.querySelector('#toc-toggle');
|
||||
const tocToggleDesktop = container.querySelector('#toc-toggle-desktop');
|
||||
const tocContent = container.querySelector('#toc-content');
|
||||
|
||||
// Handle mobile fold/unfold
|
||||
if (tocToggle && tocContent) {
|
||||
tocToggle.addEventListener('click', () => {
|
||||
const expanded = tocToggle.getAttribute('aria-expanded') === 'true';
|
||||
tocToggle.setAttribute('aria-expanded', (!expanded).toString());
|
||||
tocContent.classList.toggle('expanded');
|
||||
});
|
||||
}
|
||||
|
||||
// Handle desktop fold/unfold
|
||||
if (tocToggleDesktop && tocContent) {
|
||||
tocToggleDesktop.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const expanded = tocToggleDesktop.getAttribute('aria-expanded') === 'true';
|
||||
tocToggleDesktop.setAttribute('aria-expanded', (!expanded).toString());
|
||||
|
||||
if (expanded) {
|
||||
tocContent.classList.add('collapsed');
|
||||
const chevronUp = tocToggleDesktop.querySelector('.chevron-up');
|
||||
if (chevronUp instanceof HTMLElement) {
|
||||
chevronUp.style.transform = 'rotate(180deg)';
|
||||
}
|
||||
} else {
|
||||
tocContent.classList.remove('collapsed');
|
||||
const chevronUp = tocToggleDesktop.querySelector('.chevron-up');
|
||||
if (chevronUp instanceof HTMLElement) {
|
||||
chevronUp.style.transform = 'rotate(0)';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial state - mobile default fold, desktop default display
|
||||
const isMobile = window.innerWidth < 1024;
|
||||
if (tocToggle && tocContent) {
|
||||
if (isMobile) {
|
||||
tocToggle.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
tocToggle.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
// Get all directory links in the current container
|
||||
const tocLinks = container.querySelectorAll('.toc-link');
|
||||
const headingElements = Array.from(document.querySelectorAll('main h1[id], main h2[id], main h3[id]'));
|
||||
|
||||
// Create mapping from ID to link
|
||||
const idToLinkMap = new Map();
|
||||
tocLinks.forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
if (href && href.startsWith('#')) {
|
||||
const id = href.substring(1);
|
||||
idToLinkMap.set(id, link);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle click on directory link
|
||||
tocLinks.forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
tocLinks.forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
|
||||
if (isMobile && tocToggle && tocContent) {
|
||||
tocToggle.setAttribute('aria-expanded', 'false');
|
||||
tocContent.classList.remove('expanded');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Use IntersectionObserver to listen to title elements
|
||||
let activeHeadingId: string | null = null;
|
||||
|
||||
const observerOptions = {
|
||||
rootMargin: '-80px 0px -60% 0px',
|
||||
threshold: [0, 0.1, 0.25, 0.5]
|
||||
};
|
||||
|
||||
const headingObserver = new IntersectionObserver((entries) => {
|
||||
const visibleHeadings = entries
|
||||
.filter(entry => entry.isIntersecting)
|
||||
.map(entry => ({
|
||||
id: entry.target.id,
|
||||
ratio: entry.intersectionRatio,
|
||||
top: entry.boundingClientRect.top
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (Math.abs(a.ratio - b.ratio) > 0.1) return b.ratio - a.ratio;
|
||||
return a.top - b.top;
|
||||
});
|
||||
|
||||
if (visibleHeadings.length > 0) {
|
||||
const topHeadingId = visibleHeadings[0].id;
|
||||
if (topHeadingId !== activeHeadingId) {
|
||||
activeHeadingId = topHeadingId;
|
||||
|
||||
tocLinks.forEach(link => link.classList.remove('active'));
|
||||
|
||||
const activeLink = idToLinkMap.get(topHeadingId);
|
||||
if (activeLink) {
|
||||
activeLink.classList.add('active');
|
||||
|
||||
// Scroll to visible in the directory
|
||||
const tocContainer = tocContent;
|
||||
if (tocContainer && !tocContainer.classList.contains('collapsed')) {
|
||||
const linkRect = activeLink.getBoundingClientRect();
|
||||
const containerRect = tocContainer.getBoundingClientRect();
|
||||
if (linkRect.bottom > containerRect.bottom || linkRect.top < containerRect.top) {
|
||||
activeLink.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, observerOptions);
|
||||
|
||||
headingElements.forEach(heading => {
|
||||
headingObserver.observe(heading);
|
||||
});
|
||||
|
||||
// Initial active state
|
||||
setTimeout(() => {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash && idToLinkMap.has(hash)) {
|
||||
const link = idToLinkMap.get(hash);
|
||||
tocLinks.forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
activeHeadingId = hash;
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleHeadingsInit = headingElements.filter(heading => {
|
||||
const rect = heading.getBoundingClientRect();
|
||||
return rect.top >= 0 && rect.top <= window.innerHeight / 2;
|
||||
});
|
||||
|
||||
if (visibleHeadingsInit.length > 0) {
|
||||
const topHeading = visibleHeadingsInit[0];
|
||||
const link = idToLinkMap.get(topHeading.id);
|
||||
if (link) {
|
||||
tocLinks.forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
activeHeadingId = topHeading.id;
|
||||
}
|
||||
} else if (headingElements.length > 0) {
|
||||
const firstHeading = headingElements[0];
|
||||
const link = idToLinkMap.get(firstHeading.id);
|
||||
if (link) {
|
||||
tocLinks.forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
activeHeadingId = firstHeading.id;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Handle hash change
|
||||
window.addEventListener('hashchange', () => {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash && idToLinkMap.has(hash)) {
|
||||
const link = idToLinkMap.get(hash);
|
||||
tocLinks.forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
activeHeadingId = hash;
|
||||
}
|
||||
});
|
||||
}); // End forEach loop
|
||||
})(); // End IIFE
|
||||
</script>
|
||||
Reference in New Issue
Block a user