592 lines
16 KiB
Plaintext
592 lines
16 KiB
Plaintext
---
|
|
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>
|