newechoes/src/pages/articles/[...id].astro

1179 lines
40 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
import { getCollection, render } from "astro:content";
import { getSpecialPath } from "@/content.config";
import Layout from "@/components/Layout.astro";
import Breadcrumb from "@/components/Breadcrumb.astro";
import { ARTICLE_EXPIRY_CONFIG } from "@/consts";
import "@/styles/articles.css";
// 定义文章类型
interface ArticleEntry {
id: string;
data: {
title: string;
date: Date;
tags?: string[];
summary?: string;
};
}
// 定义标题类型
interface Heading {
depth: number;
slug: string;
text: string;
}
export async function getStaticPaths() {
const articles = await getCollection("articles");
// 为每篇文章生成路由参数
const paths = [];
for (const article of articles) {
// 获取所有可能的路径形式
const possiblePaths = new Set([
article.id, // 只保留原始路径
]);
// 如果是多级目录,检查是否需要特殊处理
if (article.id.includes("/")) {
const parts = article.id.split("/");
const fileName = parts[parts.length - 1];
const dirName = parts[parts.length - 2];
// 只有当文件名与其父目录名相同时才添加特殊路径
if (fileName === dirName) {
possiblePaths.add(getSpecialPath(article.id));
}
}
// 为每个可能的路径生成路由
for (const path of possiblePaths) {
paths.push({
params: { id: path },
props: {
article,
section: article.id.includes("/")
? article.id.split("/").slice(0, -1).join("/")
: "",
originalId: path !== article.id ? article.id : undefined,
},
});
}
}
return paths;
}
// 获取文章内容
const { article, section, originalId } = Astro.props;
// 获取搜索参数
const searchParams = new URLSearchParams(Astro.url.search);
// 如果有原始ID使用它来渲染内容
const articleToRender = originalId ? { ...article, id: originalId } : article;
// 渲染文章内容
const { Content, headings } = await render(articleToRender);
// 获取面包屑路径段
const pathSegments = section ? section.split("/") : [];
// 获取相关文章
const allArticles = await getCollection("articles");
// 1. 尝试通过标签匹配相关文章
let relatedArticles = allArticles
.filter((a: ArticleEntry) => {
const hasCommonTags =
a.id !== article.id &&
a.data.tags &&
article.data.tags &&
a.data.tags.length > 0 &&
article.data.tags.length > 0 &&
a.data.tags.some((tag: string) => article.data.tags?.includes(tag));
return hasCommonTags;
})
.sort(
(a: ArticleEntry, b: ArticleEntry) =>
b.data.date.getTime() - a.data.date.getTime(),
)
.slice(0, 3);
// 跟踪相关文章的匹配方式: "tag", "directory", "latest"
let relatedArticlesMatchType = relatedArticles.length > 0 ? "tag" : "";
// 2. 如果标签匹配没有找到足够的相关文章,尝试根据目录结构匹配
if (relatedArticles.length < 3) {
// 获取当前文章的目录路径
const currentPath = article.id.includes("/")
? article.id.substring(0, article.id.lastIndexOf("/"))
: "";
// 如果有目录路径,查找同目录的其他文章
if (currentPath) {
// 收集同目录下的文章,但排除已经通过标签匹配的和当前文章
const dirRelatedArticles = allArticles
.filter(
(a: ArticleEntry) =>
a.id !== article.id &&
a.id.startsWith(currentPath + "/") &&
!relatedArticles.some((r: ArticleEntry) => r.id === a.id),
)
.sort(
(a: ArticleEntry, b: ArticleEntry) =>
b.data.date.getTime() - a.data.date.getTime(),
)
.slice(0, 3 - relatedArticles.length);
if (dirRelatedArticles.length > 0) {
relatedArticles = [...relatedArticles, ...dirRelatedArticles];
relatedArticlesMatchType =
relatedArticles.length > 0 && !relatedArticlesMatchType
? "directory"
: relatedArticlesMatchType;
}
}
}
// 3. 如果仍然没有找到足够的相关文章,则选择最新的文章(排除当前文章和已选择的文章)
if (relatedArticles.length < 3) {
const latestArticles = allArticles
.filter(
(a: ArticleEntry) =>
a.id !== article.id &&
!relatedArticles.some((r: ArticleEntry) => r.id === a.id),
)
.sort(
(a: ArticleEntry, b: ArticleEntry) =>
b.data.date.getTime() - a.data.date.getTime(),
)
.slice(0, 3 - relatedArticles.length);
if (latestArticles.length > 0) {
relatedArticles = [...relatedArticles, ...latestArticles];
relatedArticlesMatchType =
relatedArticles.length > 0 && !relatedArticlesMatchType
? "latest"
: relatedArticlesMatchType;
}
}
// 准备文章描述
const description =
article.data.summary ||
`${article.data.title} - 发布于 ${article.data.date.toLocaleDateString("zh-CN")}`;
// 处理特殊ID的函数
function getArticleUrl(articleId: string) {
return `/articles/${getSpecialPath(articleId)}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`;
}
// 预先生成目录结构
function generateTableOfContents(headings: Heading[]) {
if (!headings || headings.length === 0) {
return '<p class="text-secondary-500 dark:text-secondary-400 italic">此文章没有目录</p>';
}
// 查找最低级别的标题(数值最小)
const minDepth = Math.min(...headings.map((h) => h.depth));
// 按照标题层级构建嵌套结构
const tocTree: any[] = [];
const levelMap: Record<number, any[]> = {};
headings.forEach((heading) => {
const relativeDepth = heading.depth - minDepth;
// 构建标题项
const headingItem = {
slug: heading.slug,
text: heading.text,
depth: relativeDepth,
children: [],
};
// 更精确地处理嵌套关系
if (relativeDepth === 0) {
// 顶级标题直接加入到树中
tocTree.push(headingItem);
levelMap[0] = tocTree;
} else {
// 查找当前标题的父级
let parentDepth = relativeDepth - 1;
// 向上查找可能的父级
while (parentDepth >= 0 && !levelMap[parentDepth]) {
parentDepth--;
}
if (parentDepth >= 0 && levelMap[parentDepth] && levelMap[parentDepth].length > 0) {
// 找到父层级,将此标题添加到最近的父标题的子标题数组中
const parentItems = levelMap[parentDepth];
const parent = parentItems[parentItems.length - 1];
parent.children.push(headingItem);
// 更新当前深度的映射
if (!levelMap[relativeDepth]) {
levelMap[relativeDepth] = [];
}
levelMap[relativeDepth].push(headingItem);
} else {
// 找不到有效父级,作为顶级标题处理
tocTree.push(headingItem);
levelMap[relativeDepth] = [headingItem];
}
}
});
// 递归生成HTML
function generateTocHTML(items: any[], level = 0) {
if (items.length === 0) return '';
const isTopLevel = level === 0;
let html = `<ul class="space-y-2 toc-list ${isTopLevel ? '' : 'toc-sublist hidden'}" ${level > 0 ? 'aria-expanded="false"' : ''}>`;
items.forEach(item => {
const hasChildren = item.children && item.children.length > 0;
const isHigherLevel = item.depth <= 1; // 只有最高级和次高级标题使用较重的样式
html += `<li class="toc-item" data-depth="${item.depth}">
<div class="toc-item-container">
<a href="#${item.slug}"
class="toc-link block duration-50 ${
isHigherLevel
? "text-secondary-800 dark:text-secondary-200 font-medium"
: "text-secondary-600 dark:text-secondary-400"
}"
style="padding-left: ${item.depth * 0.75}rem;">
${item.text}
</a>
${hasChildren ? `<button class="toc-toggle ml-1 p-1 text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400" aria-expanded="false">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>` : ''}
</div>
${generateTocHTML(item.children, level + 1)}
</li>`;
});
html += '</ul>';
return html;
}
return generateTocHTML(tocTree);
}
// 生成目录HTML
const tableOfContents = generateTableOfContents(headings);
---
<Layout
title={article.data.title}
description={description}
date={article.data.date}
tags={article.data.tags}
skipSrTitle={false}
pageType="article"
>
<div class="max-w-5xl py-8">
<!-- 阅读进度条 -->
<div
class="fixed top-0 left-0 w-full h-1 bg-transparent z-50"
id="progress-container"
>
<div
class="h-full w-0 bg-primary-500 transition-width duration-100"
id="progress-bar"
>
</div>
</div>
<!-- 文章头部 - 包含面包屑,保持不变 -->
<header class="mb-8">
<!-- 导航区域/面包屑 - 不参与视图切换 -->
<div
class="bg-white dark:bg-gray-800 rounded-xl p-4 mb-6 shadow-lg border border-gray-200 dark:border-gray-700 relative z-10"
>
<div
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
>
<div class="w-full overflow-hidden">
<Breadcrumb
pageType="article"
pathSegments={pathSegments}
searchParams={searchParams}
articleTitle={article.data.title}
path={section}
/>
</div>
</div>
</div>
<!-- 文章过期提醒 - 放入article-content容器内 -->
</header>
<!-- 文章内容区域 - 只有这部分参与视图切换 -->
<div id="article-content">
<!-- 文章过期提醒 -->
{
(() => {
const publishDate = article.data.date;
const currentDate = new Date();
const daysDiff = Math.floor(
(currentDate.getTime() - publishDate.getTime()) /
(1000 * 60 * 60 * 24),
);
if (
ARTICLE_EXPIRY_CONFIG.enabled &&
daysDiff > ARTICLE_EXPIRY_CONFIG.expiryDays
) {
return (
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
<div class="flex">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-yellow-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700">
{ARTICLE_EXPIRY_CONFIG.warningMessage}
</p>
</div>
</div>
</div>
);
}
return null;
})()
}
<h1 class="text-3xl font-bold mb-4 text-gray-900 dark:text-gray-100">
{article.data.title}
</h1>
<div
class="flex flex-wrap items-center gap-4 text-sm text-secondary-600 dark:text-secondary-400 mb-4"
>
<time
datetime={article.data.date.toISOString()}
class="flex items-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
></path>
</svg>
{article.data.date.toLocaleDateString("zh-CN")}
</time>
{
section && (
<span class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-1 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
<a
href={`/articles/${section}/`}
class="hover:text-indigo-600 break-all"
>
{section}
</a>
</span>
)
}
</div>
{
article.data.tags && article.data.tags.length > 0 && (
<div class="flex flex-wrap gap-2 mb-6">
{article.data.tags.map((tag: string) => (
<a
href={`/articles?tags=${tag}`}
class="text-xs bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 py-1 px-2 rounded hover:bg-primary-100 dark:hover:bg-primary-800/30"
data-astro-prefetch="hover"
>
#{tag}
</a>
))}
</div>
)
}
<article
class="prose prose-lg dark:prose-invert prose-primary prose-table:rounded-lg prose-table:border-separate prose-table:border-2 prose-thead:bg-primary-50 dark:prose-thead:bg-gray-800 prose-ul:list-disc prose-ol:list-decimal prose-li:my-1 prose-blockquote:border-l-4 prose-blockquote:border-primary-500 prose-blockquote:bg-gray-100 prose-blockquote:dark:bg-gray-800 prose-a:text-primary-600 prose-a:dark:text-primary-400 prose-a:no-underline prose-a:border-b prose-a:border-primary-300 prose-a:hover:border-primary-600 max-w-none mb-12"
>
<Content />
</article>
<!-- 相关文章 -->
{
relatedArticles.length > 0 && (
<aside class="mt-12 pt-8 border-t border-secondary-200 dark:border-gray-700">
<h2 class="text-2xl font-bold mb-6 text-primary-900 dark:text-primary-100">
{relatedArticlesMatchType === "tag"
? "相关文章"
: relatedArticlesMatchType === "directory"
? "同类文章"
: "推荐阅读"}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{relatedArticles.map((relatedArticle: ArticleEntry) => (
<a
href={getArticleUrl(relatedArticle.id)}
class="article-card"
data-astro-prefetch="viewport"
>
<div class="article-card-content">
<div class="article-card-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
/>
</svg>
</div>
<div class="article-card-body">
<h3 class="article-card-title">
{relatedArticle.data.title}
</h3>
<p class="article-card-date">
{relatedArticle.data.date.toLocaleDateString("zh-CN")}
</p>
{relatedArticle.data.summary && (
<p class="article-card-summary">
{relatedArticle.data.summary}
</p>
)}
<div class="article-card-footer">
<time
datetime={relatedArticle.data.date.toISOString()}
class="article-card-date"
>
{relatedArticle.data.date.toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
<span class="article-card-read-more">阅读全文</span>
</div>
</div>
</div>
</a>
))}
</div>
</aside>
)
}
<!-- 返回顶部按钮 -->
<button
id="back-to-top"
class="fixed bottom-8 right-8 w-12 h-12 rounded-full bg-primary-500 dark:bg-primary-600 text-white shadow-md flex items-center justify-center opacity-0 invisible translate-y-5 hover:bg-primary-600 dark:hover:bg-primary-700"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 10l7-7m0 0l7 7m-7-7v18"
></path>
</svg>
</button>
</div>
<!-- 目录 -->
<section
class="hidden 2xl:block"
id="toc-panel"
>
<div>
<div
class="panel-header"
>
<h3>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h7"
></path>
</svg>
文章目录
</h3>
</div>
<div
id="toc-content"
class="scrollbar-thin scrollbar-thumb-primary-200 dark:scrollbar-thumb-primary-800 scrollbar-track-transparent"
set:html={tableOfContents}
>
<!-- 目录内容在服务端生成 -->
</div>
</div>
</section>
</div>
<!-- 文章页面脚本 -->
<script is:inline>
// 文章页面交互脚本 - 自销毁模式
(function () {
// 集中管理所有事件监听器
const allListeners = [];
// 为特殊清理任务准备的数组
const customCleanupTasks = [];
// 单独保存清理事件的监听器引用
const cleanupListeners = [];
// 添加事件监听器并记录,方便后续统一清理
function addListener(element, eventType, handler, options) {
if (!element) {
return null;
}
element.addEventListener(eventType, handler, options);
allListeners.push({ element, eventType, handler, options });
return handler;
}
// 统一的清理函数,执行完整清理并自销毁
function selfDestruct() {
// 1. 先移除普通事件监听器
allListeners.forEach(({ element, eventType, handler, options }) => {
try {
element.removeEventListener(eventType, handler, options);
} catch (err) {
console.error("移除事件监听器出错:", err);
}
});
// 清空监听器数组
allListeners.length = 0;
// 2. 执行特殊清理任务
customCleanupTasks.forEach((task) => {
try {
task();
} catch (err) {
console.error("执行特殊清理任务出错:", err);
}
});
// 清空特殊任务数组
customCleanupTasks.length = 0;
// 3. 最后移除清理事件监听器自身
cleanupListeners.forEach(({ element, eventType, handler, options }) => {
try {
element.removeEventListener(eventType, handler, options);
} catch (err) {
console.error("移除清理监听器出错:", err);
}
});
}
// 注册清理事件,并保存引用
function registerCleanupEvents() {
// 创建一次性事件处理函数
const beforeSwapHandler = () => {
selfDestruct();
};
const beforeUnloadHandler = () => {
selfDestruct();
};
// 添加清理事件监听器并保存引用
document.addEventListener("astro:before-swap", beforeSwapHandler, {
once: true,
});
window.addEventListener("beforeunload", beforeUnloadHandler, {
once: true,
});
// 保存清理事件引用,用于完全销毁
cleanupListeners.push(
{
element: document,
eventType: "astro:before-swap",
handler: beforeSwapHandler,
options: { once: true },
},
{
element: window,
eventType: "beforeunload",
handler: beforeUnloadHandler,
options: { once: true },
},
);
}
// 初始化所有功能
function initializeFeatures() {
// 1. 代码块复制功能
function setupCodeCopy() {
const copyButtons = document.querySelectorAll(".code-block-copy");
if (copyButtons.length === 0) {
return;
}
copyButtons.forEach((button) => {
addListener(button, "click", async () => {
try {
const encodedCode = button.getAttribute("data-code");
if (!encodedCode) return;
const code = atob(encodedCode);
await navigator.clipboard.writeText(code);
const originalHTML = button.innerHTML;
button.classList.add("copied");
button.innerHTML = `
<svg 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" class="w-4 h-4">
<path d="M20 6L9 17l-5-5"></path>
</svg>
已复制
`;
setTimeout(() => {
button.classList.remove("copied");
button.innerHTML = originalHTML;
}, 2000);
} catch (err) {
console.error("复制失败:", err);
button.innerHTML = `
<svg 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" class="w-4 h-4">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
失败
`;
setTimeout(() => {
button.innerHTML = `
<svg 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" class="w-4 h-4">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
复制
`;
}, 2000);
}
});
});
}
// 2. 阅读进度条
function setupProgressBar() {
const progressBar = document.getElementById("progress-bar");
const backToTopButton = document.getElementById("back-to-top");
if (!progressBar) {
return;
}
function updateReadingProgress() {
const scrollTop =
window.scrollY || document.documentElement.scrollTop;
const scrollHeight =
document.documentElement.scrollHeight -
document.documentElement.clientHeight;
const progress = (scrollTop / scrollHeight) * 100;
progressBar.style.width = `${progress}%`;
if (backToTopButton) {
if (scrollTop > 300) {
backToTopButton.classList.add(
"opacity-100",
"visible",
"translate-y-0",
);
backToTopButton.classList.remove(
"opacity-0",
"invisible",
"translate-y-5",
);
} else {
backToTopButton.classList.add(
"opacity-0",
"invisible",
"translate-y-5",
);
backToTopButton.classList.remove(
"opacity-100",
"visible",
"translate-y-0",
);
}
}
}
addListener(window, "scroll", updateReadingProgress);
if (backToTopButton) {
addListener(backToTopButton, "click", () => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
});
}
// 初始更新一次进度条
updateReadingProgress();
}
// 3. 目录交互
function setupTableOfContents() {
const tocContent = document.getElementById("toc-content");
const tocPanel = document.querySelector("#toc-panel");
if (!tocPanel || !tocContent) {
return;
}
// 检查窗口大小调整目录面板显示
function checkTocVisibility() {
if (window.innerWidth < 1536) {
tocPanel.classList.add("hidden");
tocPanel.classList.remove("2xl:block");
} else {
tocPanel.classList.remove("hidden");
tocPanel.classList.add("2xl:block");
}
}
addListener(window, "resize", checkTocVisibility);
checkTocVisibility();
// 处理目录折叠/展开功能
const tocToggles = tocContent.querySelectorAll(".toc-toggle");
tocToggles.forEach((toggle) => {
addListener(toggle, "click", (e) => {
e.preventDefault();
e.stopPropagation();
const expanded = toggle.getAttribute("aria-expanded") === "true";
toggle.setAttribute("aria-expanded", expanded ? "false" : "true");
// 更新图标旋转
const svg = toggle.querySelector("svg");
if (svg) {
svg.style.transform = expanded ? "" : "rotate(-180deg)";
}
// 切换子菜单显示状态
const listItem = toggle.closest(".toc-item");
if (listItem) {
const sublist = listItem.querySelector(".toc-sublist");
if (sublist) {
if (expanded) {
sublist.classList.add("hidden");
sublist.setAttribute("aria-expanded", "false");
} else {
sublist.classList.remove("hidden");
sublist.setAttribute("aria-expanded", "true");
}
}
}
});
});
// 处理目录链接点击跳转
const tocLinks = tocContent.querySelectorAll(".toc-link");
tocLinks.forEach((link) => {
addListener(link, "click", (e) => {
e.preventDefault();
const targetId = link.getAttribute("href")?.substring(1);
if (!targetId) return;
const targetElement = document.getElementById(targetId);
if (targetElement) {
const offset = 100;
const targetPosition =
targetElement.getBoundingClientRect().top +
window.scrollY -
offset;
window.scrollTo({
top: targetPosition,
behavior: "smooth",
});
targetElement.classList.add(
"bg-primary-50",
"dark:bg-primary-900/20",
);
setTimeout(() => {
targetElement.classList.remove(
"bg-primary-50",
"dark:bg-primary-900/20",
);
}, 2000);
}
});
});
// 监听滚动以更新当前活动的目录项
const article = document.querySelector("article");
if (!article) {
return;
}
let ticking = false;
function updateActiveHeading() {
const headings = Array.from(
article.querySelectorAll("h1, h2, h3, h4, h5, h6"),
);
const tocLinks = Array.from(tocContent.querySelectorAll(".toc-link"));
const tocItems = Array.from(tocContent.querySelectorAll(".toc-item"));
// 清除所有活动状态
tocLinks.forEach((link) => {
link.classList.remove(
"text-primary-600",
"dark:text-primary-400",
"font-medium",
);
});
// 找出当前可见的标题
const scrollPosition = window.scrollY + 150;
let currentHeading = null;
for (const heading of headings) {
const headingTop =
heading.getBoundingClientRect().top + window.scrollY;
if (headingTop <= scrollPosition) {
currentHeading = heading;
} else {
break;
}
}
// 记录当前活动的项目和其所有父级
const activeItems = new Set();
// 高亮当前标题对应的目录项并展开父菜单
if (currentHeading) {
const id = currentHeading.getAttribute("id");
if (id) {
const activeLink = tocLinks.find(
(link) => link.getAttribute("href") === `#${id}`,
);
if (activeLink) {
// 高亮当前目录项
activeLink.classList.add(
"text-primary-600",
"dark:text-primary-400",
"font-medium",
);
// 展开当前激活项的所有父菜单并收集到活动项集合中
let parent = activeLink.closest(".toc-item");
while (parent) {
// 添加到活动项目集合
activeItems.add(parent);
const parentSublist = parent.querySelector(".toc-sublist");
const parentToggle = parent.querySelector(".toc-toggle");
if (parentSublist && parentSublist.classList.contains("hidden")) {
parentSublist.classList.remove("hidden");
parentSublist.setAttribute("aria-expanded", "true");
if (parentToggle) {
parentToggle.setAttribute("aria-expanded", "true");
const svg = parentToggle.querySelector("svg");
if (svg) {
svg.style.transform = "rotate(-180deg)";
}
}
}
// 向上查找父级
parent = parent.parentElement?.closest(".toc-item");
}
// 确保当前激活的目录项在可视区域内
const tocContainer = tocContent;
if (tocContainer) {
const linkOffsetTop = activeLink.offsetTop;
const containerScrollTop = tocContainer.scrollTop;
const containerHeight = tocContainer.clientHeight;
// 如果当前项不在视口内,滚动目录
if (
linkOffsetTop < containerScrollTop ||
linkOffsetTop > containerScrollTop + containerHeight
) {
tocContainer.scrollTop =
linkOffsetTop - containerHeight / 2;
}
}
}
}
}
// 关闭不在当前活动路径上的所有子菜单
tocItems.forEach(item => {
// 如果不在活动路径上且有子菜单
if (!activeItems.has(item)) {
const sublist = item.querySelector('.toc-sublist');
const toggle = item.querySelector('.toc-toggle');
if (sublist && !sublist.classList.contains('hidden')) {
sublist.classList.add('hidden');
sublist.setAttribute('aria-expanded', 'false');
if (toggle) {
toggle.setAttribute('aria-expanded', 'false');
const svg = toggle.querySelector('svg');
if (svg) {
svg.style.transform = '';
}
}
}
}
});
}
addListener(window, "scroll", () => {
if (!ticking) {
window.requestAnimationFrame(() => {
updateActiveHeading();
ticking = false;
});
ticking = true;
}
});
// 初始化时收起所有子菜单
const topLevelToggles = tocContent.querySelectorAll(".toc-list > .toc-item > .toc-item-container > .toc-toggle");
topLevelToggles.forEach(toggle => {
toggle.setAttribute("aria-expanded", "false");
const sublist = toggle.closest(".toc-item").querySelector(".toc-sublist");
if (sublist) {
sublist.classList.add("hidden");
sublist.setAttribute("aria-expanded", "false");
}
});
// 初始更新一次活动标题,确保相关父菜单展开
updateActiveHeading();
}
// 4. Mermaid图表渲染
function setupMermaid() {
// 查找所有mermaid代码块
const mermaidBlocks = document.querySelectorAll(
"pre.language-mermaid, pre > code.language-mermaid, .mermaid",
);
if (mermaidBlocks.length === 0) {
return;
}
// 动态加载mermaid库
const script = document.createElement("script");
script.src =
"https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
script.onload = function () {
if (!window.mermaid) {
console.error("Mermaid库加载后window.mermaid不存在");
return;
}
// 初始化mermaid配置
window.mermaid.initialize({
startOnLoad: false,
theme: "default",
securityLevel: "loose",
});
// 将所有mermaid代码块转换为可渲染的格式
mermaidBlocks.forEach((block, index) => {
// 获取mermaid代码
let code = "";
// 检查元素类型并相应处理
if (
block.tagName === "CODE" &&
block.classList.contains("language-mermaid")
) {
// 处理 code.language-mermaid 元素
code = block.textContent || "";
const pre = block.closest("pre");
if (pre) {
// 创建新的div元素替换整个pre
const div = document.createElement("div");
div.className = "mermaid";
div.id = "mermaid-diagram-" + index;
div.textContent = code;
pre.parentNode.replaceChild(div, pre);
}
} else if (
block.tagName === "PRE" &&
block.classList.contains("language-mermaid")
) {
// 处理 pre.language-mermaid 元素
code = block.textContent || "";
const div = document.createElement("div");
div.className = "mermaid";
div.id = "mermaid-diagram-" + index;
div.textContent = code;
block.parentNode.replaceChild(div, block);
} else if (
block.classList.contains("mermaid") &&
block.tagName !== "DIV"
) {
// 如果是其他带mermaid类的元素但不是div转换为div
code = block.textContent || "";
const div = document.createElement("div");
div.className = "mermaid";
div.id = "mermaid-diagram-" + index;
div.textContent = code;
block.parentNode.replaceChild(div, block);
}
});
// 初始化渲染
try {
window.mermaid.run().catch((err) => {
console.error("Mermaid渲染出错:", err);
});
} catch (error) {
console.error("初始化Mermaid渲染失败:", error);
}
};
script.onerror = function () {
console.error("加载Mermaid库失败");
// 显示错误信息
mermaidBlocks.forEach((block) => {
if (block.tagName === "CODE") block = block.closest("pre");
if (block) {
block.innerHTML =
'<div class="mermaid-error-message">无法加载Mermaid图表库</div>';
}
});
};
document.head.appendChild(script);
// 添加Mermaid清理任务
customCleanupTasks.push(() => {
// 移除脚本标签
if (script.parentNode) {
script.parentNode.removeChild(script);
}
// 清除全局mermaid对象
if (window.mermaid) {
try {
// 尝试清理mermaid内部状态
if (typeof window.mermaid.destroy === "function") {
window.mermaid.destroy();
}
window.mermaid = undefined;
} catch (e) {
console.error("清理mermaid对象出错:", e);
}
}
// 移除页面上可能留下的mermaid相关元素
try {
// 移除所有可能的mermaid样式和元素
const mermaidElements = [
"#mermaid-style",
"#mermaid-cloned-styles",
".mermaid-svg-reference",
'style[id^="mermaid-"]',
];
document
.querySelectorAll(mermaidElements.join(", "))
.forEach((el) => {
if (el && el.parentNode) {
el.parentNode.removeChild(el);
}
});
} catch (e) {
console.error("清理Mermaid元素时出错:", e);
}
});
}
// 启动所有功能
setupCodeCopy();
setupProgressBar();
setupTableOfContents();
setupMermaid();
}
// 执行初始化
registerCleanupEvents();
initializeFeatures();
})();
</script>
</Layout>