优化主题切换

This commit is contained in:
lsy 2025-05-05 21:19:23 +08:00
parent afbdc61605
commit 0d114ed070
2 changed files with 185 additions and 98 deletions

View File

@ -153,7 +153,7 @@ const navSelectorClassName = "mr-4";
<!-- 使用自定义主题切换组件 --> <!-- 使用自定义主题切换组件 -->
<div class="flex items-center"> <div class="flex items-center">
<ThemeToggle className="group" /> <ThemeToggle className="group" transitionDuration={700} />
</div> </div>
</div> </div>
@ -320,6 +320,7 @@ const navSelectorClassName = "mr-4";
width={14} width={14}
height={14} height={14}
className="group" className="group"
transitionDuration={700}
/> />
</div> </div>
</div> </div>

View File

@ -6,6 +6,8 @@ interface Props {
className?: string; className?: string;
// 更新主题过渡动画模式配置 // 更新主题过渡动画模式配置
transitionMode?: "expand" | "shrink" | "auto" | "reverse-auto"; transitionMode?: "expand" | "shrink" | "auto" | "reverse-auto";
// 新增自定义主题切换动画时间
transitionDuration?: number;
} }
const { const {
@ -14,6 +16,7 @@ const {
fill = "currentColor", fill = "currentColor",
className = "", className = "",
transitionMode = "auto", // 默认为自动模式 transitionMode = "auto", // 默认为自动模式
transitionDuration = 700, // 默认动画时间(毫秒)
} = Astro.props; } = Astro.props;
--- ---
@ -25,6 +28,7 @@ const {
role="button" role="button"
tabindex="0" tabindex="0"
data-transition-mode={transitionMode} data-transition-mode={transitionMode}
data-transition-duration={transitionDuration}
> >
<!-- 月亮图标 (暗色模式) --> <!-- 月亮图标 (暗色模式) -->
<svg <svg
@ -137,10 +141,18 @@ const {
REVERSE_AUTO: 'reverse-auto' // 反向自动模式 REVERSE_AUTO: 'reverse-auto' // 反向自动模式
}; };
// 动画持续时间配置(毫秒) // 获取用户自定义的动画持续时间
const ANIMATION_DURATION = 700; // 动画持续时间 let ANIMATION_DURATION = 700; // 默认动画持续时间
const customDuration = document.querySelector('#theme-toggle-button')?.dataset?.transitionDuration;
if (customDuration && !isNaN(parseInt(customDuration))) {
ANIMATION_DURATION = parseInt(customDuration);
}
// 动画配置(毫秒)
const ANIMATION_BUFFER = 100; // 动画缓冲时间 const ANIMATION_BUFFER = 100; // 动画缓冲时间
const TOTAL_TRANSITION_TIME = ANIMATION_DURATION + ANIMATION_BUFFER; // 总过渡时间 const TOTAL_TRANSITION_TIME = ANIMATION_DURATION + ANIMATION_BUFFER; // 总过渡时间
// 防抖时间动态计算,始终比动画时间略长一些
const DEBOUNCE_TIME = TOTAL_TRANSITION_TIME + 200; // 防抖时间比总过渡时间多200ms
// 从本地存储获取主题过渡模式 // 从本地存储获取主题过渡模式
function getThemeTransitionMode() { function getThemeTransitionMode() {
@ -802,7 +814,76 @@ const {
return; return;
} }
// 防抖配置 - 使用前面定义的动态计算值
let lastClickTime = 0;
let transitioning = false; let transitioning = false;
// 将transitioning状态暴露给全局便于其他脚本检查
window._themeTransitioning = false;
window._lastThemeToggleClickTime = 0;
// 状态清理函数 - 在页面切换、错误和超时情况下调用
const resetThemeToggleState = () => {
if (transitionTimeout) {
clearTimeout(transitionTimeout);
transitionTimeout = null;
}
transitioning = false;
window._themeTransitioning = false;
// 移除所有残留的波纹效果
document.querySelectorAll(".theme-ripple").forEach(ripple => {
if (ripple.parentNode) {
ripple.parentNode.removeChild(ripple);
}
});
console.debug('主题切换: 状态已重置');
};
// 监听Astro的页面切换事件
document.addEventListener('astro:before-swap', resetThemeToggleState, { once: false });
document.addEventListener('astro:after-swap', resetThemeToggleState, { once: false });
allListeners.push(
{ element: document, eventType: 'astro:before-swap', handler: resetThemeToggleState, options: { once: false } },
{ element: document, eventType: 'astro:after-swap', handler: resetThemeToggleState, options: { once: false } }
);
// 监听视图过渡事件
if (typeof document.startViewTransition === 'function') {
document.addEventListener('view-transition-start', resetThemeToggleState, { once: false });
document.addEventListener('view-transition-end', resetThemeToggleState, { once: false });
allListeners.push(
{ element: document, eventType: 'view-transition-start', handler: resetThemeToggleState, options: { once: false } },
{ element: document, eventType: 'view-transition-end', handler: resetThemeToggleState, options: { once: false } }
);
}
// 全局事件拦截器 - 拦截所有冒泡阶段的点击事件,防止重复触发
const globalClickHandler = (e) => {
// 不处理未冒泡到document的事件
if (!e || !e.target) return;
// 如果点击事件来自于主题切换相关元素,且正在过渡中,拦截所有后续处理
if (window._themeTransitioning &&
(e.target.closest('#theme-toggle-button') ||
e.target.closest('#theme-toggle-container'))) {
console.debug('全局拦截: 主题切换正在进行中,忽略额外点击');
e.stopPropagation();
e.preventDefault();
}
};
// 在document级别添加事件捕获确保所有相关点击都能被拦截
document.addEventListener('click', globalClickHandler, { capture: true });
// 清理时记得移除
allListeners.push({
element: document,
eventType: 'click',
handler: globalClickHandler,
options: { capture: true }
});
// 获取系统首选主题 // 获取系统首选主题
const getSystemTheme = () => { const getSystemTheme = () => {
@ -826,40 +907,65 @@ const {
} }
}; };
// 切换主题 // 防抖函数 - 确保在指定时间内只执行一次
const toggleTheme = (e) => { const isThrottled = () => {
if (transitioning) { const now = Date.now();
if (transitioning || now - lastClickTime < DEBOUNCE_TIME) {
return true;
}
lastClickTime = now;
window._lastThemeToggleClickTime = now;
return false;
};
// 为所有触发点提供统一的处理函数
const handleThemeToggle = (e, targetButton) => {
// 防止事件默认行为和冒泡,无论如何都要先阻止
if (e && typeof e.preventDefault === 'function') {
e.preventDefault();
}
if (e && typeof e.stopPropagation === 'function') {
e.stopPropagation();
}
// 第1层保护全局事件防抖
if (isThrottled()) {
console.debug('主题切换: 防抖拦截,忽略点击');
return; return;
} }
transitioning = true; // 第2层保护transitioning状态检查
if (window._themeTransitioning) {
// 更可靠的点击坐标获取 console.debug('主题切换: 已有过渡进行中,忽略点击');
let clickX, clickY; return;
}
// 第3层保护视图过渡检查
if (window._themeTransition) {
console.debug('主题切换: 视图过渡已存在,忽略点击');
return;
}
// 所有检查通过后,开始处理主题切换
console.debug('主题切换: 开始处理');
// 立即设置transitioning状态阻止后续点击
window._themeTransitioning = true;
try { try {
// 计算点击坐标或使用按钮中心坐标
let clickX, clickY;
if (e instanceof Event && typeof e.clientX === 'number' && typeof e.clientY === 'number') { if (e instanceof Event && typeof e.clientX === 'number' && typeof e.clientY === 'number') {
// 如果是真实事件对象且有有效坐标
clickX = e.clientX; clickX = e.clientX;
clickY = e.clientY; clickY = e.clientY;
} else if (e && typeof e.clientX === 'number' && typeof e.clientY === 'number') { } else if (e && typeof e.clientX === 'number' && typeof e.clientY === 'number') {
// 如果是合成事件对象且有有效坐标
clickX = e.clientX; clickX = e.clientX;
clickY = e.clientY; clickY = e.clientY;
} else { } else {
// 如果无法获取有效坐标,使用屏幕中心点 const rect = targetButton.getBoundingClientRect();
clickX = window.innerWidth / 2; clickX = rect.left + rect.width / 2;
clickY = window.innerHeight / 2; clickY = rect.top + rect.height / 2;
// 尝试从目标元素获取位置
if (e && e.target) {
const button = e.target.closest ? e.target.closest("#theme-toggle-button") : null;
if (button) {
const rect = button.getBoundingClientRect();
clickX = rect.left + rect.width / 2;
clickY = rect.top + rect.height / 2;
}
}
} }
// 确保坐标有效防止NaN或无限值 // 确保坐标有效防止NaN或无限值
@ -867,12 +973,7 @@ const {
clickY = isFinite(clickY) ? clickY : window.innerHeight / 2; clickY = isFinite(clickY) ? clickY : window.innerHeight / 2;
// 在按钮上创建小波纹效果 // 在按钮上创建小波纹效果
if (e && e.target) { createRippleEffect(clickX, clickY, targetButton);
const button = e.target.closest ? e.target.closest("#theme-toggle-button") : null;
if (button) {
createRippleEffect(clickX, clickY, button);
}
}
// 获取当前主题 // 获取当前主题
const currentTheme = document.documentElement.dataset.theme; const currentTheme = document.documentElement.dataset.theme;
@ -881,14 +982,11 @@ const {
// 获取过渡模式 // 获取过渡模式
let transitionMode = TRANSITION_MODES.AUTO; // 默认自动模式 let transitionMode = TRANSITION_MODES.AUTO; // 默认自动模式
if (e && e.target) { if (targetButton && targetButton.dataset && targetButton.dataset.transitionMode) {
const button = e.target.closest ? e.target.closest("#theme-toggle-button") : null; transitionMode = targetButton.dataset.transitionMode;
if (button && button.dataset && button.dataset.transitionMode) { } else {
transitionMode = button.dataset.transitionMode; // 如果无法从按钮获取,从本地存储获取
} else { transitionMode = getThemeTransitionMode();
// 如果无法从按钮获取,从本地存储获取
transitionMode = getThemeTransitionMode();
}
} }
// 在开始新的过渡前先重置transitioning的延时清除 // 在开始新的过渡前先重置transitioning的延时清除
@ -912,6 +1010,7 @@ const {
} }
}; };
// 调用视图过渡
createViewTransition( createViewTransition(
safeThemeChangeCallback, safeThemeChangeCallback,
clickX, clickX,
@ -922,8 +1021,9 @@ const {
).then(() => { ).then(() => {
// 过渡完成后恢复状态 // 过渡完成后恢复状态
setTimeout(() => { setTimeout(() => {
transitioning = false; window._themeTransitioning = false;
}, ANIMATION_BUFFER); // 添加缓冲时间,确保动画完全结束 console.debug('主题切换: 过渡完成,恢复状态');
}, ANIMATION_BUFFER); // 添加缓冲时间
}).catch(error => { }).catch(error => {
// 出现错误时强制执行主题切换以确保功能可用 // 出现错误时强制执行主题切换以确保功能可用
if (error.name !== 'AbortError') { if (error.name !== 'AbortError') {
@ -935,13 +1035,15 @@ const {
} }
setTimeout(() => { setTimeout(() => {
transitioning = false; window._themeTransitioning = false;
console.debug('主题切换: 过渡被取消,恢复状态');
}, ANIMATION_BUFFER); }, ANIMATION_BUFFER);
}); });
// 设置防抖保底 - 防止transition.finished不触发导致状态卡死 // 设置防抖保底 - 防止transition.finished不触发导致状态卡死
transitionTimeout = setTimeout(() => { transitionTimeout = setTimeout(() => {
transitioning = false; window._themeTransitioning = false;
console.debug('主题切换: 过渡超时,强制恢复状态');
}, TOTAL_TRANSITION_TIME + 200); // 总过渡时间加额外缓冲 }, TOTAL_TRANSITION_TIME + 200); // 总过渡时间加额外缓冲
} catch (err) { } catch (err) {
// 即使发生错误,也要确保主题能切换 // 即使发生错误,也要确保主题能切换
@ -967,7 +1069,8 @@ const {
} }
// 确保状态被重置 // 确保状态被重置
transitioning = false; window._themeTransitioning = false;
console.debug('主题切换: 发生错误,恢复状态');
} }
}; };
@ -985,79 +1088,50 @@ const {
// 为每个按钮添加事件 // 为每个按钮添加事件
themeToggleButtons.forEach(button => { themeToggleButtons.forEach(button => {
// 创建点击处理函数
const clickHandler = (e) => {
e.preventDefault();
e.stopPropagation();
toggleTheme(e);
};
// 点击事件 - 使用捕获模式 // 点击事件 - 使用捕获模式
addListener(button, "click", clickHandler, { capture: true }); addListener(button, "click", (e) => handleThemeToggle(e, button), { capture: true });
// 键盘事件 // 键盘事件
const keydownHandler = (e) => { const keydownHandler = (e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
// 为键盘事件创建模拟点击事件,使其中心点位于按钮中央 handleThemeToggle(e, button);
const rect = button.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// 创建模拟事件对象
const simulatedEvent = {
clientX: centerX,
clientY: centerY,
target: button,
preventDefault: () => {},
stopPropagation: () => {}
};
toggleTheme(simulatedEvent);
} }
}; };
addListener(button, "keydown", keydownHandler); addListener(button, "keydown", keydownHandler);
}); });
// 处理移动端主题切换容器 - 重新添加此功能以提供更一致的用户体验 // 处理移动端主题切换容器 - 使用同样的逻辑处理
const themeToggleContainer = document.getElementById("theme-toggle-container"); const themeToggleContainer = document.getElementById("theme-toggle-container");
if (themeToggleContainer) { if (themeToggleContainer) {
const containerClickHandler = (e) => { const containerClickHandler = (e) => {
// 只有当点击不是直接在按钮上时,才托管事件处理 // 如果点击的是按钮内部,让按钮自己处理
if (!e.target.closest("#theme-toggle-button")) { if (e.target.closest("#theme-toggle-button")) {
// 找到容器内的主题切换按钮 return;
const button = themeToggleContainer.querySelector("#theme-toggle-button"); }
if (button) {
// 从点击事件中提取坐标 // 找到容器内的主题切换按钮
const clickX = e.clientX || 0; const button = themeToggleContainer.querySelector("#theme-toggle-button");
const clickY = e.clientY || 0; if (button) {
// 调用统一的处理函数
// 使用更可靠的坐标计算方法 handleThemeToggle(e, button);
const rect = button.getBoundingClientRect();
const centerX = clickX || (rect.left + rect.width / 2);
const centerY = clickY || (rect.top + rect.height / 2);
// 创建一个更可靠的合成事件对象
const simulatedEvent = {
clientX: centerX,
clientY: centerY,
target: button,
preventDefault: () => {},
stopPropagation: () => {}
};
// 阻止事件冒泡
e.preventDefault();
e.stopPropagation();
// 调用主题切换函数
toggleTheme(simulatedEvent);
}
} }
}; };
// 使用capture模式确保事件在捕获阶段就被处理 // 使用capture模式确保事件在捕获阶段就被处理
addListener(themeToggleContainer, "click", containerClickHandler, { capture: true }); addListener(themeToggleContainer, "click", containerClickHandler, { capture: true });
// 为移动端容器添加关闭移动端菜单的功能
const closeMenuHandler = (e) => {
// 检查是否有closeMobileMenu全局函数来自Header.astro
if (typeof window.closeMobileMenu === 'function') {
// 这个函数会在点击主题切换容器后被调用,无论是否真正触发主题切换
window.closeMobileMenu();
}
};
// 使用冒泡阶段,确保在主题切换处理后执行
addListener(themeToggleContainer, "click", closeMenuHandler, { capture: false });
} }
// 初始化主题 // 初始化主题
@ -1071,6 +1145,18 @@ const {
// 设置主题切换功能 // 设置主题切换功能
setupThemeToggle(); setupThemeToggle();
// 设置防止内存泄漏的自动清理 - 最多30秒后自毁
setTimeout(() => {
// 如果页面没有被卸载,检查是否已经清理过
if (document.body && !document._themeToggleCleanedUp) {
// 为避免重复清理,设置一个标记
document._themeToggleCleanedUp = true;
// 执行清理
selfDestruct();
console.debug('主题切换: 自动清理已执行');
}
}, 30000);
} }
// 判断DOM是否已加载 // 判断DOM是否已加载