修复对swup的正确dom监听和清理
This commit is contained in:
parent
5df41feb02
commit
4bb37c4a4d
@ -81,12 +81,10 @@ export const Countdown: React.FC<CountdownProps> = ({ targetDate, className = ''
|
||||
setTimeLeft(newTimeLeft);
|
||||
|
||||
// 如果已经到期,清除计时器
|
||||
if (newTimeLeft.expired) {
|
||||
if (timerRef.current !== null) {
|
||||
if (newTimeLeft.expired && timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// 清理函数
|
||||
|
@ -286,7 +286,63 @@ const normalizedPath =
|
||||
</header>
|
||||
|
||||
<script>
|
||||
function initHeader() {
|
||||
// Header组件逻辑 - 使用"进入→绑定→退出完全清理"模式
|
||||
(function() {
|
||||
// 生成唯一ID用于日志跟踪
|
||||
const headerId = 'header-' + (new Date().getTime());
|
||||
|
||||
// 存储所有事件监听器,便于统一清理
|
||||
const listeners: Array<{
|
||||
element: EventTarget;
|
||||
eventType: string;
|
||||
handler: EventListenerOrEventListenerObject;
|
||||
}> = [];
|
||||
|
||||
// 记录是否已加载文章数据
|
||||
interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string | Date;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
image?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
let articles: Article[] = [];
|
||||
let isArticlesLoaded = false;
|
||||
|
||||
// 添加事件监听器并记录,方便后续统一清理
|
||||
function addListener<K extends keyof HTMLElementEventMap>(
|
||||
element: EventTarget | null,
|
||||
eventType: string,
|
||||
handler: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
): EventListenerOrEventListenerObject | null {
|
||||
if (!element) return null;
|
||||
|
||||
element.addEventListener(eventType, handler, options);
|
||||
listeners.push({ element, eventType, handler });
|
||||
return handler;
|
||||
}
|
||||
|
||||
// 清理函数 - 移除所有事件监听器
|
||||
function cleanup(): void {
|
||||
// 移除所有监听器
|
||||
listeners.forEach(({ element, eventType, handler }) => {
|
||||
try {
|
||||
element.removeEventListener(eventType, handler);
|
||||
} catch (err) {
|
||||
console.error(`移除Header事件监听器出错:`, err);
|
||||
}
|
||||
});
|
||||
|
||||
// 清空数组
|
||||
listeners.length = 0;
|
||||
}
|
||||
|
||||
// Header和导航高亮逻辑
|
||||
function initHeader(): void {
|
||||
const header = document.getElementById("header-bg");
|
||||
const scrollThreshold = 50;
|
||||
|
||||
@ -294,7 +350,7 @@ const normalizedPath =
|
||||
const navLinks = document.querySelectorAll('.hidden.md\\:flex a[href]');
|
||||
|
||||
// 更新导航高亮状态
|
||||
function updateNavHighlight() {
|
||||
function updateNavHighlight(): void {
|
||||
const currentUrl = window.location.pathname;
|
||||
const normalizedPath =
|
||||
currentUrl === "/"
|
||||
@ -343,7 +399,8 @@ const normalizedPath =
|
||||
});
|
||||
}
|
||||
|
||||
function updateHeaderBackground() {
|
||||
// 处理滚动更新背景
|
||||
function updateHeaderBackground(): void {
|
||||
if (window.scrollY > scrollThreshold) {
|
||||
header?.classList.add("scrolled");
|
||||
} else {
|
||||
@ -356,11 +413,11 @@ const normalizedPath =
|
||||
updateNavHighlight();
|
||||
|
||||
// 添加滚动事件监听
|
||||
window.addEventListener("scroll", updateHeaderBackground);
|
||||
addListener(window, "scroll", updateHeaderBackground);
|
||||
|
||||
// 监听路由变化
|
||||
document.addEventListener('astro:page-load', updateNavHighlight);
|
||||
document.addEventListener('astro:after-swap', updateNavHighlight);
|
||||
addListener(document, 'astro:page-load', updateNavHighlight);
|
||||
addListener(document, 'astro:after-swap', updateNavHighlight);
|
||||
|
||||
// 移动端菜单逻辑
|
||||
const mobileMenuButton = document.getElementById("mobile-menu-button");
|
||||
@ -375,7 +432,7 @@ const normalizedPath =
|
||||
const mobileSearchClose = document.getElementById("mobile-search-close");
|
||||
|
||||
// 关闭移动端菜单的函数
|
||||
function closeMobileMenu() {
|
||||
function closeMobileMenu(): void {
|
||||
if (mobileMenuButton && mobileMenu && menuOpenIcon && menuCloseIcon) {
|
||||
mobileMenuButton.setAttribute("aria-expanded", "false");
|
||||
mobileMenu.classList.add("hidden");
|
||||
@ -385,16 +442,21 @@ const normalizedPath =
|
||||
}
|
||||
|
||||
// 关闭移动端搜索面板的函数
|
||||
function closeMobileSearch() {
|
||||
function closeMobileSearch(): void {
|
||||
if (mobileSearchPanel) {
|
||||
mobileSearchPanel.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
if (mobileMenuButton && mobileMenu && menuOpenIcon && menuCloseIcon) {
|
||||
mobileMenuButton.addEventListener("click", () => {
|
||||
const expanded =
|
||||
mobileMenuButton.getAttribute("aria-expanded") === "true";
|
||||
// 移动端菜单按钮点击事件 - 使用捕获模式确保事件优先处理
|
||||
(mobileMenuButton as HTMLElement).style.pointerEvents = 'auto';
|
||||
|
||||
addListener(mobileMenuButton, "click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const expanded = mobileMenuButton.getAttribute("aria-expanded") === "true";
|
||||
|
||||
// 切换菜单状态
|
||||
mobileMenuButton.setAttribute("aria-expanded", (!expanded).toString());
|
||||
@ -413,18 +475,29 @@ const normalizedPath =
|
||||
// 切换图标
|
||||
menuOpenIcon.classList.toggle("hidden");
|
||||
menuCloseIcon.classList.toggle("hidden");
|
||||
});
|
||||
}, { capture: true });
|
||||
|
||||
// 为移动端导航链接添加点击事件
|
||||
const mobileNavLinks = document.querySelectorAll('#mobile-menu a[href]');
|
||||
mobileNavLinks.forEach(link => {
|
||||
link.addEventListener('click', closeMobileMenu);
|
||||
(link as HTMLElement).style.pointerEvents = 'auto';
|
||||
addListener(link, 'click', (e) => {
|
||||
// 不要阻止默认行为,因为需要跳转
|
||||
e.stopPropagation();
|
||||
closeMobileMenu();
|
||||
}, { capture: true });
|
||||
});
|
||||
}
|
||||
|
||||
// 移动端搜索按钮
|
||||
if (mobileSearchButton && mobileSearchPanel) {
|
||||
mobileSearchButton.addEventListener("click", () => {
|
||||
// 搜索按钮点击事件 - 使用捕获模式确保事件优先处理
|
||||
(mobileSearchButton as HTMLElement).style.pointerEvents = 'auto';
|
||||
|
||||
addListener(mobileSearchButton, "click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 检查搜索面板是否已经打开
|
||||
const isSearchVisible = !mobileSearchPanel.classList.contains("hidden");
|
||||
|
||||
@ -439,22 +512,44 @@ const normalizedPath =
|
||||
mobileSearchPanel.classList.remove("hidden");
|
||||
if (mobileSearch) mobileSearch.focus();
|
||||
}
|
||||
});
|
||||
}, { capture: true });
|
||||
|
||||
// 搜索面板关闭按钮
|
||||
if (mobileSearchClose) {
|
||||
mobileSearchClose.addEventListener("click", () => {
|
||||
(mobileSearchClose as HTMLElement).style.pointerEvents = 'auto';
|
||||
addListener(mobileSearchClose, "click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMobileSearch();
|
||||
});
|
||||
}, { capture: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 处理移动端主题切换容器
|
||||
const themeToggleContainer = document.getElementById('theme-toggle-container');
|
||||
if (themeToggleContainer) {
|
||||
(themeToggleContainer as HTMLElement).style.pointerEvents = 'auto';
|
||||
addListener(themeToggleContainer, 'click', (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// 如果点击的不是主题切换按钮本身,则手动触发主题切换
|
||||
if (target.id !== 'theme-toggle-button' && !target.closest('#theme-toggle-button')) {
|
||||
e.stopPropagation();
|
||||
// 获取容器内的主题切换按钮并模拟点击
|
||||
const toggleButton = themeToggleContainer.querySelector('#theme-toggle-button');
|
||||
if (toggleButton) {
|
||||
(toggleButton as HTMLElement).click();
|
||||
}
|
||||
}
|
||||
}, { capture: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索功能逻辑
|
||||
function initSearch() {
|
||||
function initSearch(): void {
|
||||
// 搜索节流函数
|
||||
function debounce<T extends (...args: any[]) => void>(
|
||||
func: T,
|
||||
wait: number,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
return function(this: any, ...args: Parameters<T>): void {
|
||||
@ -474,22 +569,8 @@ const normalizedPath =
|
||||
const mobileList = document.getElementById("mobile-search-list");
|
||||
const mobileMessage = document.getElementById("mobile-search-message");
|
||||
|
||||
// 文章对象的接口定义
|
||||
interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string | Date;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
image?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
let articles: Article[] = [];
|
||||
let isArticlesLoaded = false;
|
||||
|
||||
// 获取文章数据
|
||||
async function fetchArticles() {
|
||||
async function fetchArticles(): Promise<void> {
|
||||
if (isArticlesLoaded && articles.length > 0) return;
|
||||
|
||||
try {
|
||||
@ -522,8 +603,8 @@ const normalizedPath =
|
||||
function searchArticles(
|
||||
query: string,
|
||||
resultsList: HTMLElement,
|
||||
resultsMessage: HTMLElement,
|
||||
) {
|
||||
resultsMessage: HTMLElement
|
||||
): void {
|
||||
if (!query.trim()) {
|
||||
resultsList.innerHTML = "";
|
||||
resultsMessage.textContent = "输入关键词开始搜索";
|
||||
@ -665,7 +746,7 @@ const normalizedPath =
|
||||
searchArticles(
|
||||
value,
|
||||
desktopList as HTMLElement,
|
||||
desktopMessage as HTMLElement,
|
||||
desktopMessage as HTMLElement
|
||||
);
|
||||
}
|
||||
}, 300);
|
||||
@ -675,19 +756,21 @@ const normalizedPath =
|
||||
searchArticles(
|
||||
value,
|
||||
mobileList as HTMLElement,
|
||||
mobileMessage as HTMLElement,
|
||||
mobileMessage as HTMLElement
|
||||
);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// 桌面端搜索逻辑
|
||||
if (desktopSearch && desktopResults) {
|
||||
desktopSearch.addEventListener("focus", () => {
|
||||
// 聚焦时显示结果
|
||||
addListener(desktopSearch, "focus", () => {
|
||||
desktopResults.classList.remove("hidden");
|
||||
if (!isArticlesLoaded) fetchArticles();
|
||||
});
|
||||
|
||||
desktopSearch.addEventListener("input", (e: Event) => {
|
||||
// 输入时更新搜索结果
|
||||
addListener(desktopSearch, "input", (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target && target.value !== undefined) {
|
||||
debouncedDesktopSearch(target.value);
|
||||
@ -695,7 +778,7 @@ const normalizedPath =
|
||||
});
|
||||
|
||||
// 点击外部关闭结果
|
||||
document.addEventListener("click", (e: MouseEvent) => {
|
||||
const documentClickHandler = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
desktopSearch &&
|
||||
@ -704,19 +787,22 @@ const normalizedPath =
|
||||
) {
|
||||
desktopResults.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
addListener(document, "click", documentClickHandler as EventListener);
|
||||
|
||||
// ESC键关闭结果
|
||||
desktopSearch.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
addListener(desktopSearch, "keydown", ((e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
desktopResults.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
// 移动端搜索逻辑
|
||||
if (mobileSearch && mobileResults) {
|
||||
mobileSearch.addEventListener("input", (e: Event) => {
|
||||
// 输入时更新搜索结果
|
||||
addListener(mobileSearch, "input", (e: Event) => {
|
||||
mobileResults.classList.remove("hidden");
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target && target.value !== undefined) {
|
||||
@ -726,43 +812,55 @@ const normalizedPath =
|
||||
});
|
||||
|
||||
// ESC键关闭搜索面板
|
||||
mobileSearch.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
addListener(mobileSearch, "keydown", ((e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
const mobileSearchPanel = document.getElementById(
|
||||
"mobile-search-panel",
|
||||
);
|
||||
const mobileSearchPanel = document.getElementById("mobile-search-panel");
|
||||
if (mobileSearchPanel) {
|
||||
mobileSearchPanel.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
});
|
||||
}) as EventListener);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化函数
|
||||
function setupHeader() {
|
||||
// 注册清理函数
|
||||
function registerCleanup(): void {
|
||||
// Astro 事件
|
||||
document.addEventListener('astro:before-preparation', cleanup, { once: true });
|
||||
document.addEventListener('astro:before-swap', cleanup, { once: true });
|
||||
|
||||
// Swup 事件
|
||||
document.addEventListener('swup:willReplaceContent', cleanup, { once: true });
|
||||
|
||||
// 页面卸载
|
||||
window.addEventListener('beforeunload', cleanup, { once: true });
|
||||
}
|
||||
|
||||
// 初始化全部功能
|
||||
function setupHeader(): void {
|
||||
// 先清理之前的事件监听器
|
||||
cleanup();
|
||||
|
||||
// 初始化各个组件
|
||||
initHeader();
|
||||
initSearch();
|
||||
registerCleanup();
|
||||
}
|
||||
|
||||
// 在文档加载时初始化一次
|
||||
// 在页面加载时初始化
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", setupHeader);
|
||||
document.addEventListener("DOMContentLoaded", setupHeader, { once: true });
|
||||
} else {
|
||||
setupHeader();
|
||||
// 使用setTimeout确保处于事件队列末尾,避免可能的事件冲突
|
||||
setTimeout(setupHeader, 0);
|
||||
}
|
||||
|
||||
// 支持 Astro 视图转换
|
||||
document.addEventListener("astro:after-swap", setupHeader);
|
||||
// 在页面转换后重新初始化
|
||||
document.addEventListener('astro:after-swap', setupHeader);
|
||||
document.addEventListener('astro:page-load', setupHeader);
|
||||
|
||||
document.addEventListener("astro:page-load", setupHeader);
|
||||
|
||||
// 原事件监听 - 保留以兼容可能的旧版本
|
||||
document.addEventListener("astro:swup:page:view", setupHeader);
|
||||
|
||||
// 清理
|
||||
document.addEventListener("astro:before-swap", () => {
|
||||
// 移除可能的全局事件监听器
|
||||
window.removeEventListener("scroll", () => {});
|
||||
});
|
||||
// Swup页面内容替换后重新初始化
|
||||
document.addEventListener('swup:contentReplaced', setupHeader);
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
@ -55,25 +55,46 @@ const { title = SITE_NAME, description = SITE_DESCRIPTION, date, author, tags, i
|
||||
<meta property="article:tag" content={tag} />
|
||||
))}
|
||||
<script is:inline>
|
||||
// 立即执行主题初始化,并防止变量重复声明
|
||||
// 立即执行主题初始化,采用"无闪烁"加载方式
|
||||
(function() {
|
||||
// 检查变量是否已存在
|
||||
if (typeof window.__themeInitDone === 'undefined') {
|
||||
// 设置标志,表示初始化已完成
|
||||
window.__themeInitDone = true;
|
||||
try {
|
||||
// 获取系统首选主题
|
||||
const getSystemTheme = () => {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
const savedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null;
|
||||
const theme = savedTheme || systemTheme;
|
||||
const storedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null;
|
||||
const systemTheme = getSystemTheme();
|
||||
let theme = 'light'; // 默认浅色主题
|
||||
|
||||
// 立即设置文档主题
|
||||
// 按照逻辑优先级应用主题
|
||||
if (storedTheme) {
|
||||
// 如果有存储的主题设置,则应用它
|
||||
theme = storedTheme;
|
||||
} else if (systemTheme) {
|
||||
// 如果没有存储的设置,检查系统主题
|
||||
theme = systemTheme;
|
||||
}
|
||||
|
||||
// 立即设置文档主题,在DOM渲染前应用,避免闪烁
|
||||
document.documentElement.dataset.theme = theme;
|
||||
|
||||
// 将主题信息存储在全局变量中,以便 React 组件可以立即访问
|
||||
window.__THEME_DATA__ = {
|
||||
currentTheme: theme,
|
||||
systemTheme: systemTheme
|
||||
// 监听系统主题变化(只有当主题设为跟随系统时才响应)
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleMediaChange = (e) => {
|
||||
// 只有当主题设置为跟随系统时才更新主题
|
||||
if (!localStorage.getItem('theme')) {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
document.documentElement.dataset.theme = newTheme;
|
||||
}
|
||||
};
|
||||
|
||||
// 添加系统主题变化监听
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
} catch (error) {
|
||||
// 出错时应用默认浅色主题,确保页面正常显示
|
||||
document.documentElement.dataset.theme = 'light';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
interface MediaGridProps {
|
||||
type: "movie" | "book";
|
||||
@ -21,6 +21,9 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
const itemsPerPage = 15;
|
||||
const mediaListRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollTime = useRef(0);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const scrollDetectorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 使用ref来跟踪关键状态,避免闭包问题
|
||||
const stateRef = useRef({
|
||||
@ -30,8 +33,8 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
error: null as string | null,
|
||||
});
|
||||
|
||||
// 封装fetch函数但不使用useCallback以避免依赖循环
|
||||
const fetchMedia = async (page = 1, append = false) => {
|
||||
// 封装fetch函数使用useCallback避免重新创建
|
||||
const fetchMedia = useCallback(async (page = 1, append = false) => {
|
||||
// 使用ref中的最新状态
|
||||
if (
|
||||
stateRef.current.isLoading ||
|
||||
@ -41,6 +44,14 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 取消之前的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// 创建新的AbortController
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
// 更新状态和ref
|
||||
setIsLoading(true);
|
||||
stateRef.current.isLoading = true;
|
||||
@ -55,6 +66,7 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`,
|
||||
{ signal: abortControllerRef.current.signal }
|
||||
);
|
||||
if (!response.ok) {
|
||||
// 解析响应内容,获取详细错误信息
|
||||
@ -127,6 +139,11 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
stateRef.current.hasMoreContent = newHasMoreContent;
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果是取消的请求,不显示错误
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 只有在非追加模式下才清空已加载的内容
|
||||
if (!append) {
|
||||
setItems([]);
|
||||
@ -136,10 +153,10 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
setIsLoading(false);
|
||||
stateRef.current.isLoading = false;
|
||||
}
|
||||
};
|
||||
}, [type, doubanId]);
|
||||
|
||||
// 处理滚动事件
|
||||
const handleScroll = () => {
|
||||
const handleScroll = useCallback(() => {
|
||||
// 获取关键滚动值
|
||||
const scrollY = window.scrollY;
|
||||
const windowHeight = window.innerHeight;
|
||||
@ -167,7 +184,7 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
if (scrollPosition >= threshold) {
|
||||
fetchMedia(stateRef.current.currentPage + 1, true);
|
||||
}
|
||||
};
|
||||
}, [fetchMedia]);
|
||||
|
||||
// 更新ref值以跟踪状态变化
|
||||
useEffect(() => {
|
||||
@ -186,6 +203,58 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
stateRef.current.error = error;
|
||||
}, [error]);
|
||||
|
||||
// 设置和清理IntersectionObserver
|
||||
const setupIntersectionObserver = useCallback(() => {
|
||||
// 清理旧的Observer
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
|
||||
// 清理旧的检测元素
|
||||
if (scrollDetectorRef.current) {
|
||||
scrollDetectorRef.current.remove();
|
||||
scrollDetectorRef.current = null;
|
||||
}
|
||||
|
||||
// 创建新的IntersectionObserver
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: "300px",
|
||||
threshold: 0.1,
|
||||
};
|
||||
|
||||
observerRef.current = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
if (
|
||||
entry.isIntersecting &&
|
||||
!stateRef.current.isLoading &&
|
||||
stateRef.current.hasMoreContent &&
|
||||
!stateRef.current.error
|
||||
) {
|
||||
fetchMedia(stateRef.current.currentPage + 1, true);
|
||||
}
|
||||
}, observerOptions);
|
||||
|
||||
// 创建并添加检测底部的元素
|
||||
const footer = document.createElement("div");
|
||||
footer.id = "scroll-detector";
|
||||
footer.style.width = "100%";
|
||||
footer.style.height = "10px";
|
||||
scrollDetectorRef.current = footer;
|
||||
|
||||
// 确保mediaListRef有父元素
|
||||
if (mediaListRef.current && mediaListRef.current.parentElement) {
|
||||
// 插入到grid后面而不是内部
|
||||
mediaListRef.current.parentElement.insertBefore(
|
||||
footer,
|
||||
mediaListRef.current.nextSibling,
|
||||
);
|
||||
observerRef.current.observe(footer);
|
||||
}
|
||||
}, [fetchMedia]);
|
||||
|
||||
// 组件初始化和依赖变化时重置
|
||||
useEffect(() => {
|
||||
// 重置状态
|
||||
@ -204,69 +273,50 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
// 清空列表
|
||||
setItems([]);
|
||||
|
||||
// 取消可能存在的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// 加载第一页数据
|
||||
fetchMedia(1, false);
|
||||
|
||||
// 管理滚动事件
|
||||
const scrollListener = handleScroll;
|
||||
// 设置滚动事件监听器
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
// 移除任何现有监听器
|
||||
window.removeEventListener("scroll", scrollListener);
|
||||
|
||||
// 添加滚动事件监听器 - 使用passive: true可提高滚动性能
|
||||
window.addEventListener("scroll", scrollListener, { passive: true });
|
||||
|
||||
// 创建一个IntersectionObserver作为备选检测方案
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: "300px",
|
||||
threshold: 0.1,
|
||||
};
|
||||
|
||||
const intersectionObserver = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
if (
|
||||
entry.isIntersecting &&
|
||||
!stateRef.current.isLoading &&
|
||||
stateRef.current.hasMoreContent &&
|
||||
!stateRef.current.error
|
||||
) {
|
||||
fetchMedia(stateRef.current.currentPage + 1, true);
|
||||
}
|
||||
}, observerOptions);
|
||||
|
||||
// 添加检测底部的元素 - 放在grid容器的后面而不是内部
|
||||
const footer = document.createElement("div");
|
||||
footer.id = "scroll-detector";
|
||||
footer.style.width = "100%";
|
||||
footer.style.height = "10px";
|
||||
|
||||
// 确保mediaListRef有父元素
|
||||
if (mediaListRef.current && mediaListRef.current.parentElement) {
|
||||
// 插入到grid后面而不是内部
|
||||
mediaListRef.current.parentElement.insertBefore(
|
||||
footer,
|
||||
mediaListRef.current.nextSibling,
|
||||
);
|
||||
intersectionObserver.observe(footer);
|
||||
}
|
||||
// 设置IntersectionObserver
|
||||
setupIntersectionObserver();
|
||||
|
||||
// 初始检查一次,以防内容不足一屏
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (stateRef.current.hasMoreContent && !stateRef.current.isLoading) {
|
||||
scrollListener();
|
||||
handleScroll();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
window.removeEventListener("scroll", scrollListener);
|
||||
intersectionObserver.disconnect();
|
||||
document.getElementById("scroll-detector")?.remove();
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
|
||||
// 清理IntersectionObserver
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
|
||||
// 清理scroll detector元素
|
||||
if (scrollDetectorRef.current) {
|
||||
scrollDetectorRef.current.remove();
|
||||
scrollDetectorRef.current = null;
|
||||
}
|
||||
|
||||
// 取消正在进行的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [type, doubanId]); // 只在关键属性变化时执行
|
||||
}, [type, doubanId, handleScroll, fetchMedia, setupIntersectionObserver]);
|
||||
|
||||
// 错误提示组件
|
||||
const ErrorMessage = () => {
|
||||
@ -345,6 +395,7 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
src={item.imageUrl}
|
||||
alt={item.title}
|
||||
className="absolute top-0 left-0 w-full h-full object-cover hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent">
|
||||
<h3 className="font-bold text-white text-sm line-clamp-2">
|
||||
|
@ -47,7 +47,58 @@ const {
|
||||
</button>
|
||||
|
||||
<script>
|
||||
function setupThemeToggle() {
|
||||
// 主题切换逻辑
|
||||
(function() {
|
||||
// 存储所有事件监听器,便于统一清理
|
||||
const listeners: Array<{
|
||||
element: EventTarget;
|
||||
eventType: string;
|
||||
handler: EventListenerOrEventListenerObject;
|
||||
}> = [];
|
||||
|
||||
// 定时器
|
||||
let transitionTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 添加事件监听器并记录,方便后续统一清理
|
||||
function addListener<K extends keyof HTMLElementEventMap>(
|
||||
element: EventTarget | null,
|
||||
eventType: string,
|
||||
handler: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
): EventListenerOrEventListenerObject | null {
|
||||
if (!element) return null;
|
||||
|
||||
element.addEventListener(eventType, handler, options);
|
||||
listeners.push({ element, eventType, handler });
|
||||
return handler;
|
||||
}
|
||||
|
||||
// 清理函数 - 移除所有事件监听器
|
||||
function cleanup(): void {
|
||||
// 移除所有监听器
|
||||
listeners.forEach(({ element, eventType, handler }) => {
|
||||
try {
|
||||
element.removeEventListener(eventType, handler);
|
||||
} catch (err) {
|
||||
console.error(`移除主题切换事件监听器出错:`, err);
|
||||
}
|
||||
});
|
||||
|
||||
// 清空数组
|
||||
listeners.length = 0;
|
||||
|
||||
// 清理任何定时器
|
||||
if (transitionTimeout) {
|
||||
clearTimeout(transitionTimeout);
|
||||
transitionTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化主题切换功能
|
||||
function setupThemeToggle(): void {
|
||||
// 确保当前没有活动的主题切换按钮事件
|
||||
cleanup();
|
||||
|
||||
// 获取所有主题切换按钮
|
||||
const themeToggleButtons = document.querySelectorAll('#theme-toggle-button');
|
||||
|
||||
@ -56,15 +107,29 @@ const {
|
||||
}
|
||||
|
||||
let transitioning = false;
|
||||
let transitionTimeout: number | null = null;
|
||||
|
||||
// 获取系统首选主题
|
||||
const getSystemTheme = () => {
|
||||
const getSystemTheme = (): string => {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
// 初始化主题
|
||||
const initializeTheme = (): void => {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
const systemTheme = getSystemTheme();
|
||||
|
||||
// 按照逻辑优先级应用主题
|
||||
if (storedTheme) {
|
||||
document.documentElement.dataset.theme = storedTheme;
|
||||
} else if (systemTheme) {
|
||||
document.documentElement.dataset.theme = systemTheme;
|
||||
} else {
|
||||
document.documentElement.dataset.theme = 'light';
|
||||
}
|
||||
};
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = () => {
|
||||
const toggleTheme = (): void => {
|
||||
if (transitioning) {
|
||||
return;
|
||||
}
|
||||
@ -79,9 +144,9 @@ const {
|
||||
document.documentElement.dataset.theme = newTheme;
|
||||
|
||||
// 更新本地存储
|
||||
const isSystemTheme = newTheme === getSystemTheme();
|
||||
const systemTheme = getSystemTheme();
|
||||
|
||||
if (isSystemTheme) {
|
||||
if (newTheme === systemTheme) {
|
||||
localStorage.removeItem('theme');
|
||||
} else {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
@ -94,92 +159,92 @@ const {
|
||||
|
||||
transitionTimeout = setTimeout(() => {
|
||||
transitioning = false;
|
||||
}, 300) as unknown as number;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 监听系统主题变化
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleMediaChange = (e: MediaQueryListEvent) => {
|
||||
// 只有当主题设置为跟随系统时才更新主题
|
||||
const handleMediaChange = (e: MediaQueryListEvent): void => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
document.documentElement.dataset.theme = newTheme;
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
// 添加系统主题变化监听
|
||||
addListener(mediaQuery, 'change', handleMediaChange as EventListener);
|
||||
|
||||
// 存储事件处理函数引用,用于清理
|
||||
const clickHandlers = new Map();
|
||||
|
||||
// 为每个按钮添加点击事件
|
||||
// 为每个按钮添加事件
|
||||
themeToggleButtons.forEach(button => {
|
||||
const handler = (e: Event) => {
|
||||
// 阻止事件冒泡
|
||||
(button as HTMLElement).style.pointerEvents = 'auto';
|
||||
|
||||
// 创建点击处理函数
|
||||
const clickHandler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleTheme();
|
||||
};
|
||||
|
||||
clickHandlers.set(button, handler);
|
||||
button.addEventListener('click', handler);
|
||||
// 点击事件 - 使用捕获模式
|
||||
addListener(button, 'click', clickHandler, { capture: true });
|
||||
|
||||
// 恢复键盘事件
|
||||
button.addEventListener('keydown', function(e) {
|
||||
const keyEvent = e as KeyboardEvent;
|
||||
if (keyEvent.key === 'Enter' || keyEvent.key === ' ') {
|
||||
// 键盘事件
|
||||
addListener(button, 'keydown', ((e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleTheme();
|
||||
}
|
||||
});
|
||||
}) as EventListener);
|
||||
});
|
||||
|
||||
// 处理移动端主题切换容器
|
||||
let containerHandler: ((e: Event) => void) | null = null;
|
||||
const themeToggleContainer = document.getElementById('theme-toggle-container');
|
||||
if (themeToggleContainer) {
|
||||
containerHandler = (e) => {
|
||||
addListener(themeToggleContainer, 'click', (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// 如果点击的不是主题切换按钮本身,则手动触发主题切换
|
||||
if (target.id !== 'theme-toggle-button' && !target.closest('#theme-toggle-button')) {
|
||||
e.stopPropagation();
|
||||
toggleTheme();
|
||||
}
|
||||
};
|
||||
themeToggleContainer.addEventListener('click', containerHandler);
|
||||
}
|
||||
|
||||
// 清理
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleMediaChange);
|
||||
|
||||
// 清理按钮事件
|
||||
themeToggleButtons.forEach(button => {
|
||||
const handler = clickHandlers.get(button);
|
||||
if (handler) {
|
||||
button.removeEventListener('click', handler);
|
||||
}
|
||||
});
|
||||
|
||||
// 清理容器事件
|
||||
if (themeToggleContainer && containerHandler) {
|
||||
themeToggleContainer.removeEventListener('click', containerHandler);
|
||||
}
|
||||
|
||||
if (transitionTimeout) {
|
||||
clearTimeout(transitionTimeout);
|
||||
}
|
||||
};
|
||||
// 初始化主题
|
||||
initializeTheme();
|
||||
}
|
||||
|
||||
// 页面加载时初始化
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupThemeToggle);
|
||||
} else {
|
||||
// 注册清理函数
|
||||
function registerCleanup(): void {
|
||||
// Astro 事件
|
||||
document.addEventListener('astro:before-preparation', cleanup, { once: true });
|
||||
document.addEventListener('astro:before-swap', cleanup, { once: true });
|
||||
|
||||
// Swup 事件
|
||||
document.addEventListener('swup:willReplaceContent', cleanup, { once: true });
|
||||
|
||||
// 页面卸载
|
||||
window.addEventListener('beforeunload', cleanup, { once: true });
|
||||
}
|
||||
|
||||
// 初始化函数
|
||||
function init(): void {
|
||||
setupThemeToggle();
|
||||
registerCleanup();
|
||||
}
|
||||
|
||||
// 支持 Astro 视图转换
|
||||
document.addEventListener('astro:after-swap', setupThemeToggle);
|
||||
document.addEventListener('astro:page-load', setupThemeToggle);
|
||||
// 在页面加载后初始化
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
setTimeout(init, 0);
|
||||
}
|
||||
|
||||
// 在页面转换后重新初始化
|
||||
document.addEventListener('astro:after-swap', init);
|
||||
document.addEventListener('astro:page-load', init);
|
||||
|
||||
// Swup页面内容替换后重新初始化
|
||||
document.addEventListener('swup:contentReplaced', init);
|
||||
})();
|
||||
</script>
|
@ -8,7 +8,7 @@ export const NAV_LINKS = [
|
||||
{ href: '/movies', text: '观影' },
|
||||
{ href: '/books', text: '读书' },
|
||||
{ href: '/projects', text: '项目' },
|
||||
{ href: '/other', text: '其他' }
|
||||
{ href: '/other', text: '其他' },
|
||||
];
|
||||
|
||||
export const ICP = '渝ICP备2022009272号';
|
||||
|
@ -15,6 +15,7 @@ tags: []
|
||||
5. **观影记录**:集成豆瓣观影数据
|
||||
6. **读书记录**:集成豆瓣读书数据
|
||||
7. **旅行足迹**:支持展示全球旅行足迹热力图
|
||||
8. **丝滑页面过渡**:使用 Swup 集成实现页面间无缝过渡动画,提供类似 SPA 的浏览体验,保留静态站点的所有优势
|
||||
|
||||
## 基础配置
|
||||
|
||||
|
@ -1,6 +1,10 @@
|
||||
---
|
||||
import Layout from '@/components/Layout.astro';
|
||||
import { SITE_NAME } from '@/consts';
|
||||
|
||||
// 启用静态预渲染
|
||||
export const prerender = true;
|
||||
|
||||
---
|
||||
|
||||
<Layout title={`404 - 页面未找到 | ${SITE_NAME}`}>
|
||||
|
@ -32,7 +32,6 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
if (path) {
|
||||
const normalizedPath = path.toLowerCase();
|
||||
filteredArticles = filteredArticles.filter(article => {
|
||||
const articlePath = article.id.split('/');
|
||||
return article.id.toLowerCase().includes(normalizedPath);
|
||||
});
|
||||
}
|
||||
|
@ -115,7 +115,7 @@ function getArticleUrl(articleId: string) {
|
||||
<!-- 阅读进度条 -->
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-1 bg-transparent z-50"
|
||||
id="progress-container 9"
|
||||
id="progress-container"
|
||||
>
|
||||
<div
|
||||
class="h-full w-0 bg-primary-500 transition-width duration-100"
|
||||
@ -369,13 +369,25 @@ function getArticleUrl(articleId: string) {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
// 阅读进度条
|
||||
<script is:inline>
|
||||
(function() {
|
||||
const listeners = [];
|
||||
|
||||
function addListener(element, eventType, handler, options) {
|
||||
if (!element) return null;
|
||||
|
||||
element.addEventListener(eventType, handler, options);
|
||||
listeners.push({ element, eventType, handler });
|
||||
return handler;
|
||||
}
|
||||
|
||||
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 =
|
||||
@ -383,41 +395,31 @@ function getArticleUrl(articleId: string) {
|
||||
document.documentElement.clientHeight;
|
||||
const progress = (scrollTop / scrollHeight) * 100;
|
||||
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
// 显示/隐藏返回顶部按钮
|
||||
if (backToTopButton) {
|
||||
if (scrollTop > 300) {
|
||||
backToTopButton.classList.add(
|
||||
"opacity-100",
|
||||
"visible",
|
||||
"translate-y-0",
|
||||
"opacity-100", "visible", "translate-y-0"
|
||||
);
|
||||
backToTopButton.classList.remove(
|
||||
"opacity-0",
|
||||
"invisible",
|
||||
"translate-y-5",
|
||||
"opacity-0", "invisible", "translate-y-5"
|
||||
);
|
||||
} else {
|
||||
backToTopButton.classList.add(
|
||||
"opacity-0",
|
||||
"invisible",
|
||||
"translate-y-5",
|
||||
"opacity-0", "invisible", "translate-y-5"
|
||||
);
|
||||
backToTopButton.classList.remove(
|
||||
"opacity-100",
|
||||
"visible",
|
||||
"translate-y-0",
|
||||
"opacity-100", "visible", "translate-y-0"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回顶部功能
|
||||
addListener(window, "scroll", updateReadingProgress);
|
||||
|
||||
if (backToTopButton) {
|
||||
backToTopButton.addEventListener("click", () => {
|
||||
addListener(backToTopButton, "click", () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
@ -425,24 +427,16 @@ function getArticleUrl(articleId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// 监听滚动事件
|
||||
window.addEventListener("scroll", updateReadingProgress);
|
||||
|
||||
// 初始化
|
||||
updateReadingProgress();
|
||||
}
|
||||
|
||||
// 目录功能
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
function setupTableOfContents() {
|
||||
const tocContent = document.getElementById("toc-content");
|
||||
const tocPanel = document.querySelector(
|
||||
'[class*="2xl:block"][class*="fixed"]',
|
||||
);
|
||||
const tocPanel = document.querySelector('[class*="2xl:block"][class*="fixed"]');
|
||||
|
||||
if (!tocPanel || !tocContent) return;
|
||||
|
||||
// 检查是否有足够空间显示目录
|
||||
function checkTocVisibility() {
|
||||
if (!tocPanel) return;
|
||||
|
||||
// 如果窗口宽度小于1536px (2xl breakpoint),隐藏目录
|
||||
if (window.innerWidth < 1536) {
|
||||
tocPanel.classList.add("hidden");
|
||||
tocPanel.classList.remove("2xl:block");
|
||||
@ -452,69 +446,52 @@ function getArticleUrl(articleId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener("resize", checkTocVisibility);
|
||||
addListener(window, "resize", checkTocVisibility);
|
||||
|
||||
// 初始检查
|
||||
checkTocVisibility();
|
||||
|
||||
// 生成目录内容
|
||||
function generateTableOfContents() {
|
||||
// 获取文章中的所有标题元素
|
||||
const article = document.querySelector("article");
|
||||
if (!article || !tocContent) {
|
||||
console.error("找不到文章内容或目录容器");
|
||||
if (tocContent) {
|
||||
tocContent.innerHTML =
|
||||
'<p class="text-secondary-500 dark:text-secondary-400 italic">无法生成目录</p>';
|
||||
}
|
||||
if (!article) {
|
||||
tocContent.innerHTML = '<p class="text-secondary-500 dark:text-secondary-400 italic">无法生成目录</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const headings = article.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
||||
if (headings.length === 0) {
|
||||
tocContent.innerHTML =
|
||||
'<p class="text-secondary-500 dark:text-secondary-400 italic">此文章没有目录</p>';
|
||||
tocContent.innerHTML = '<p class="text-secondary-500 dark:text-secondary-400 italic">此文章没有目录</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建目录列表
|
||||
const tocList = document.createElement("ul");
|
||||
tocList.className = "space-y-2";
|
||||
|
||||
// 为每个标题创建目录项
|
||||
headings.forEach((heading, index) => {
|
||||
// 为每个标题添加ID,如果没有的话
|
||||
if (!heading.id) {
|
||||
heading.id = `heading-${index}`;
|
||||
}
|
||||
|
||||
// 创建目录项
|
||||
const listItem = document.createElement("li");
|
||||
|
||||
// 根据标题级别设置缩进
|
||||
const headingLevel = parseInt(heading.tagName.substring(1));
|
||||
const indent = (headingLevel - 1) * 0.75; // 每级缩进0.75rem
|
||||
const indent = (headingLevel - 1) * 0.75;
|
||||
|
||||
// 创建链接
|
||||
const link = document.createElement("a");
|
||||
link.href = `#${heading.id}`;
|
||||
link.className = `block hover:text-primary-600 dark:hover:text-primary-400 duration-50 ${headingLevel > 2 ? "text-secondary-600 dark:text-secondary-400" : "text-secondary-800 dark:text-secondary-200 font-medium"}`;
|
||||
link.className = `block hover:text-primary-600 dark:hover:text-primary-400 duration-50 ${
|
||||
headingLevel > 2
|
||||
? "text-secondary-600 dark:text-secondary-400"
|
||||
: "text-secondary-800 dark:text-secondary-200 font-medium"
|
||||
}`;
|
||||
link.style.paddingLeft = `${indent}rem`;
|
||||
link.textContent = heading.textContent;
|
||||
|
||||
// 点击链接时滚动到目标位置
|
||||
link.addEventListener("click", (e) => {
|
||||
// 平滑滚动到目标位置
|
||||
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 offset = 100;
|
||||
const targetPosition =
|
||||
targetElement.getBoundingClientRect().top +
|
||||
window.scrollY -
|
||||
@ -525,15 +502,12 @@ function getArticleUrl(articleId: string) {
|
||||
behavior: "smooth",
|
||||
});
|
||||
|
||||
// 高亮目标标题
|
||||
targetElement.classList.add(
|
||||
"bg-primary-50",
|
||||
"dark:bg-primary-900/20",
|
||||
"bg-primary-50", "dark:bg-primary-900/20"
|
||||
);
|
||||
setTimeout(() => {
|
||||
targetElement.classList.remove(
|
||||
"bg-primary-50",
|
||||
"dark:bg-primary-900/20",
|
||||
"bg-primary-50", "dark:bg-primary-900/20"
|
||||
);
|
||||
}, 2000);
|
||||
}
|
||||
@ -543,45 +517,26 @@ function getArticleUrl(articleId: string) {
|
||||
tocList.appendChild(listItem);
|
||||
});
|
||||
|
||||
// 将目录添加到面板
|
||||
tocContent.innerHTML = "";
|
||||
tocContent.appendChild(tocList);
|
||||
}
|
||||
|
||||
// 页面加载时生成目录
|
||||
try {
|
||||
generateTableOfContents();
|
||||
let ticking = false;
|
||||
|
||||
// 添加滚动监听,更新目录高亮
|
||||
function updateActiveHeading() {
|
||||
const article = document.querySelector("article");
|
||||
if (!article || !tocContent) return;
|
||||
|
||||
const headings = Array.from(
|
||||
article.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
||||
);
|
||||
if (headings.length === 0) return;
|
||||
|
||||
// 获取所有目录链接
|
||||
const currentHeadings = Array.from(article.querySelectorAll("h1, h2, h3, h4, h5, h6"));
|
||||
const tocLinks = Array.from(tocContent.querySelectorAll("a"));
|
||||
if (tocLinks.length === 0) return;
|
||||
|
||||
// 移除所有活跃状态
|
||||
tocLinks.forEach((link) => {
|
||||
tocLinks.forEach(link => {
|
||||
link.classList.remove(
|
||||
"text-primary-600",
|
||||
"dark:text-primary-400",
|
||||
"font-medium",
|
||||
"text-primary-600", "dark:text-primary-400", "font-medium"
|
||||
);
|
||||
});
|
||||
|
||||
// 找到当前视口中最靠近顶部的标题
|
||||
const scrollPosition = window.scrollY + 150;
|
||||
let currentHeading = null;
|
||||
const scrollPosition = window.scrollY + 150; // 添加一些偏移量
|
||||
|
||||
for (const heading of headings) {
|
||||
const headingTop =
|
||||
heading.getBoundingClientRect().top + window.scrollY;
|
||||
for (const heading of currentHeadings) {
|
||||
const headingTop = heading.getBoundingClientRect().top + window.scrollY;
|
||||
if (headingTop <= scrollPosition) {
|
||||
currentHeading = heading;
|
||||
} else {
|
||||
@ -589,24 +544,19 @@ function getArticleUrl(articleId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到当前标题,高亮对应的目录项
|
||||
if (currentHeading) {
|
||||
const activeLink = tocLinks.find(
|
||||
(link) => link.getAttribute("href") === `#${currentHeading.id}`,
|
||||
link => link.getAttribute("href") === `#${currentHeading.id}`
|
||||
);
|
||||
if (activeLink) {
|
||||
activeLink.classList.add(
|
||||
"text-primary-600",
|
||||
"dark:text-primary-400",
|
||||
"font-medium",
|
||||
"text-primary-600", "dark:text-primary-400", "font-medium"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听滚动事件,使用节流函数优化性能
|
||||
let ticking = false;
|
||||
window.addEventListener("scroll", () => {
|
||||
addListener(window, "scroll", () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
updateActiveHeading();
|
||||
@ -616,48 +566,31 @@ function getArticleUrl(articleId: string) {
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化高亮
|
||||
updateActiveHeading();
|
||||
} catch (error) {
|
||||
console.error("生成目录时发生错误:", error);
|
||||
if (tocContent) {
|
||||
tocContent.innerHTML =
|
||||
'<p class="text-secondary-500 dark:text-secondary-400 italic">生成目录时发生错误</p>';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 代码块增强功能
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// 处理所有代码块
|
||||
function setupCodeBlocks() {
|
||||
const codeBlocks = document.querySelectorAll("pre");
|
||||
if (!codeBlocks.length) return;
|
||||
|
||||
codeBlocks.forEach((pre) => {
|
||||
// 获取代码语言
|
||||
codeBlocks.forEach(pre => {
|
||||
const code = pre.querySelector("code");
|
||||
if (!code) return;
|
||||
if (!code || pre.querySelector('.code-header')) return;
|
||||
|
||||
// 从类名中提取语言
|
||||
const className = code.className;
|
||||
const languageMatch = className.match(/language-(\w+)/);
|
||||
const language = languageMatch ? languageMatch[1] : "text";
|
||||
|
||||
// 创建顶部栏
|
||||
const header = document.createElement("div");
|
||||
header.className =
|
||||
"code-header flex justify-between items-center text-xs px-4 py-2 bg-secondary-800 dark:bg-dark-card text-secondary-300 dark:text-secondary-400 rounded-t-lg";
|
||||
header.className = "code-header flex justify-between items-center text-xs px-4 py-2 bg-secondary-800 dark:bg-dark-card text-secondary-300 dark:text-secondary-400 rounded-t-lg";
|
||||
|
||||
// 创建语言标签
|
||||
const languageLabel = document.createElement("span");
|
||||
languageLabel.className = "code-language font-mono";
|
||||
languageLabel.textContent = language;
|
||||
|
||||
// 创建复制按钮
|
||||
const copyButton = document.createElement("button");
|
||||
copyButton.className =
|
||||
"code-copy-button flex items-center gap-1 hover:text-white dark:hover:text-primary-400 transition-colors";
|
||||
copyButton.className = "code-copy-button flex items-center gap-1 hover:text-white dark:hover:text-primary-400 transition-colors";
|
||||
|
||||
// 创建SVG图标和文本
|
||||
const copyIcon = `<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>`;
|
||||
const successIcon = `<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>`;
|
||||
const errorIcon = `<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="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>`;
|
||||
@ -666,33 +599,23 @@ function getArticleUrl(articleId: string) {
|
||||
copyButton.setAttribute("aria-label", "复制代码");
|
||||
copyButton.setAttribute("title", "复制代码到剪贴板");
|
||||
|
||||
// 添加复制功能
|
||||
copyButton.addEventListener("click", (e) => {
|
||||
addListener(copyButton, "click", (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 获取代码文本
|
||||
const codeText = code.textContent || "";
|
||||
|
||||
// 复制到剪贴板
|
||||
navigator.clipboard
|
||||
.writeText(codeText)
|
||||
navigator.clipboard.writeText(code.textContent || "")
|
||||
.then(() => {
|
||||
// 复制成功,更改按钮文本
|
||||
copyButton.innerHTML = `${successIcon}<span>已复制</span>`;
|
||||
copyButton.classList.add("text-green-400");
|
||||
|
||||
// 2秒后恢复按钮文本
|
||||
setTimeout(() => {
|
||||
copyButton.innerHTML = `${copyIcon}<span>复制</span>`;
|
||||
copyButton.classList.remove("text-green-400");
|
||||
}, 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
// 复制失败,更改按钮文本
|
||||
copyButton.innerHTML = `${errorIcon}<span>失败</span>`;
|
||||
copyButton.classList.add("text-red-400");
|
||||
|
||||
// 2秒后恢复按钮文本
|
||||
setTimeout(() => {
|
||||
copyButton.innerHTML = `${copyIcon}<span>复制</span>`;
|
||||
copyButton.classList.remove("text-red-400");
|
||||
@ -700,17 +623,48 @@ function getArticleUrl(articleId: string) {
|
||||
});
|
||||
});
|
||||
|
||||
// 将语言标签和复制按钮添加到顶部栏
|
||||
header.appendChild(languageLabel);
|
||||
header.appendChild(copyButton);
|
||||
|
||||
// 将顶部栏插入到代码块的最前面
|
||||
pre.insertBefore(header, pre.firstChild);
|
||||
|
||||
// 调整代码块样式
|
||||
pre.classList.add("rounded-b-lg", "mt-0");
|
||||
pre.style.marginTop = "0";
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
listeners.forEach(({ element, eventType, handler }) => {
|
||||
try {
|
||||
element.removeEventListener(eventType, handler);
|
||||
} catch (err) {}
|
||||
});
|
||||
|
||||
listeners.length = 0;
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!document.querySelector("article")) return;
|
||||
|
||||
setupProgressBar();
|
||||
setupTableOfContents();
|
||||
setupCodeBlocks();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
function registerCleanup() {
|
||||
document.addEventListener("astro:before-preparation", cleanup, { once: true });
|
||||
document.addEventListener("astro:before-swap", cleanup, { once: true });
|
||||
document.addEventListener("swup:willReplaceContent", cleanup, { once: true });
|
||||
window.addEventListener("beforeunload", cleanup, { once: true });
|
||||
}
|
||||
|
||||
registerCleanup();
|
||||
})();
|
||||
</script>
|
||||
|
||||
</Layout>
|
||||
|
@ -1,6 +1,9 @@
|
||||
---
|
||||
import ArticlesPage, { getStaticPaths as getOriginalPaths } from './index.astro';
|
||||
|
||||
// 启用静态预渲染
|
||||
export const prerender = true;
|
||||
|
||||
// 重新导出 getStaticPaths,处理所有路径模式
|
||||
export async function getStaticPaths() {
|
||||
const paths = await getOriginalPaths();
|
||||
@ -98,12 +101,6 @@ const mergedProps = {
|
||||
view
|
||||
};
|
||||
|
||||
// 生成页面标题
|
||||
let pageTitle = path ? path : '文章列表';
|
||||
if (tag) {
|
||||
pageTitle = `标签: ${tag}`;
|
||||
}
|
||||
---
|
||||
|
||||
<h1 class="sr-only">{pageTitle}</h1>
|
||||
<ArticlesPage {...mergedProps} />
|
Loading…
Reference in New Issue
Block a user