优化主题切换
This commit is contained in:
parent
afbdc61605
commit
0d114ed070
@ -153,7 +153,7 @@ const navSelectorClassName = "mr-4";
|
||||
|
||||
<!-- 使用自定义主题切换组件 -->
|
||||
<div class="flex items-center">
|
||||
<ThemeToggle className="group" />
|
||||
<ThemeToggle className="group" transitionDuration={700} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -320,6 +320,7 @@ const navSelectorClassName = "mr-4";
|
||||
width={14}
|
||||
height={14}
|
||||
className="group"
|
||||
transitionDuration={700}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -6,6 +6,8 @@ interface Props {
|
||||
className?: string;
|
||||
// 更新主题过渡动画模式配置
|
||||
transitionMode?: "expand" | "shrink" | "auto" | "reverse-auto";
|
||||
// 新增自定义主题切换动画时间
|
||||
transitionDuration?: number;
|
||||
}
|
||||
|
||||
const {
|
||||
@ -14,6 +16,7 @@ const {
|
||||
fill = "currentColor",
|
||||
className = "",
|
||||
transitionMode = "auto", // 默认为自动模式
|
||||
transitionDuration = 700, // 默认动画时间(毫秒)
|
||||
} = Astro.props;
|
||||
|
||||
---
|
||||
@ -25,6 +28,7 @@ const {
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-transition-mode={transitionMode}
|
||||
data-transition-duration={transitionDuration}
|
||||
>
|
||||
<!-- 月亮图标 (暗色模式) -->
|
||||
<svg
|
||||
@ -137,10 +141,18 @@ const {
|
||||
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 TOTAL_TRANSITION_TIME = ANIMATION_DURATION + ANIMATION_BUFFER; // 总过渡时间
|
||||
// 防抖时间动态计算,始终比动画时间略长一些
|
||||
const DEBOUNCE_TIME = TOTAL_TRANSITION_TIME + 200; // 防抖时间比总过渡时间多200ms
|
||||
|
||||
// 从本地存储获取主题过渡模式
|
||||
function getThemeTransitionMode() {
|
||||
@ -802,7 +814,76 @@ const {
|
||||
return;
|
||||
}
|
||||
|
||||
// 防抖配置 - 使用前面定义的动态计算值
|
||||
let lastClickTime = 0;
|
||||
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 = () => {
|
||||
@ -826,40 +907,65 @@ const {
|
||||
}
|
||||
};
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = (e) => {
|
||||
if (transitioning) {
|
||||
// 防抖函数 - 确保在指定时间内只执行一次
|
||||
const isThrottled = () => {
|
||||
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;
|
||||
}
|
||||
|
||||
transitioning = true;
|
||||
|
||||
// 更可靠的点击坐标获取
|
||||
let clickX, clickY;
|
||||
// 第2层保护:transitioning状态检查
|
||||
if (window._themeTransitioning) {
|
||||
console.debug('主题切换: 已有过渡进行中,忽略点击');
|
||||
return;
|
||||
}
|
||||
|
||||
// 第3层保护:视图过渡检查
|
||||
if (window._themeTransition) {
|
||||
console.debug('主题切换: 视图过渡已存在,忽略点击');
|
||||
return;
|
||||
}
|
||||
|
||||
// 所有检查通过后,开始处理主题切换
|
||||
console.debug('主题切换: 开始处理');
|
||||
|
||||
// 立即设置transitioning状态,阻止后续点击
|
||||
window._themeTransitioning = true;
|
||||
|
||||
try {
|
||||
// 计算点击坐标或使用按钮中心坐标
|
||||
let clickX, clickY;
|
||||
|
||||
if (e instanceof Event && typeof e.clientX === 'number' && typeof e.clientY === 'number') {
|
||||
// 如果是真实事件对象且有有效坐标
|
||||
clickX = e.clientX;
|
||||
clickY = e.clientY;
|
||||
} else if (e && typeof e.clientX === 'number' && typeof e.clientY === 'number') {
|
||||
// 如果是合成事件对象且有有效坐标
|
||||
clickX = e.clientX;
|
||||
clickY = e.clientY;
|
||||
} else {
|
||||
// 如果无法获取有效坐标,使用屏幕中心点
|
||||
clickX = window.innerWidth / 2;
|
||||
clickY = window.innerHeight / 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;
|
||||
}
|
||||
}
|
||||
const rect = targetButton.getBoundingClientRect();
|
||||
clickX = rect.left + rect.width / 2;
|
||||
clickY = rect.top + rect.height / 2;
|
||||
}
|
||||
|
||||
// 确保坐标有效(防止NaN或无限值)
|
||||
@ -867,12 +973,7 @@ const {
|
||||
clickY = isFinite(clickY) ? clickY : window.innerHeight / 2;
|
||||
|
||||
// 在按钮上创建小波纹效果
|
||||
if (e && e.target) {
|
||||
const button = e.target.closest ? e.target.closest("#theme-toggle-button") : null;
|
||||
if (button) {
|
||||
createRippleEffect(clickX, clickY, button);
|
||||
}
|
||||
}
|
||||
createRippleEffect(clickX, clickY, targetButton);
|
||||
|
||||
// 获取当前主题
|
||||
const currentTheme = document.documentElement.dataset.theme;
|
||||
@ -881,14 +982,11 @@ const {
|
||||
// 获取过渡模式
|
||||
let transitionMode = TRANSITION_MODES.AUTO; // 默认自动模式
|
||||
|
||||
if (e && e.target) {
|
||||
const button = e.target.closest ? e.target.closest("#theme-toggle-button") : null;
|
||||
if (button && button.dataset && button.dataset.transitionMode) {
|
||||
transitionMode = button.dataset.transitionMode;
|
||||
} else {
|
||||
// 如果无法从按钮获取,从本地存储获取
|
||||
transitionMode = getThemeTransitionMode();
|
||||
}
|
||||
if (targetButton && targetButton.dataset && targetButton.dataset.transitionMode) {
|
||||
transitionMode = targetButton.dataset.transitionMode;
|
||||
} else {
|
||||
// 如果无法从按钮获取,从本地存储获取
|
||||
transitionMode = getThemeTransitionMode();
|
||||
}
|
||||
|
||||
// 在开始新的过渡前,先重置transitioning的延时清除
|
||||
@ -912,6 +1010,7 @@ const {
|
||||
}
|
||||
};
|
||||
|
||||
// 调用视图过渡
|
||||
createViewTransition(
|
||||
safeThemeChangeCallback,
|
||||
clickX,
|
||||
@ -922,8 +1021,9 @@ const {
|
||||
).then(() => {
|
||||
// 过渡完成后恢复状态
|
||||
setTimeout(() => {
|
||||
transitioning = false;
|
||||
}, ANIMATION_BUFFER); // 添加缓冲时间,确保动画完全结束
|
||||
window._themeTransitioning = false;
|
||||
console.debug('主题切换: 过渡完成,恢复状态');
|
||||
}, ANIMATION_BUFFER); // 添加缓冲时间
|
||||
}).catch(error => {
|
||||
// 出现错误时强制执行主题切换以确保功能可用
|
||||
if (error.name !== 'AbortError') {
|
||||
@ -935,13 +1035,15 @@ const {
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
transitioning = false;
|
||||
window._themeTransitioning = false;
|
||||
console.debug('主题切换: 过渡被取消,恢复状态');
|
||||
}, ANIMATION_BUFFER);
|
||||
});
|
||||
|
||||
// 设置防抖保底 - 防止transition.finished不触发导致状态卡死
|
||||
transitionTimeout = setTimeout(() => {
|
||||
transitioning = false;
|
||||
window._themeTransitioning = false;
|
||||
console.debug('主题切换: 过渡超时,强制恢复状态');
|
||||
}, TOTAL_TRANSITION_TIME + 200); // 总过渡时间加额外缓冲
|
||||
} catch (err) {
|
||||
// 即使发生错误,也要确保主题能切换
|
||||
@ -967,7 +1069,8 @@ const {
|
||||
}
|
||||
|
||||
// 确保状态被重置
|
||||
transitioning = false;
|
||||
window._themeTransitioning = false;
|
||||
console.debug('主题切换: 发生错误,恢复状态');
|
||||
}
|
||||
};
|
||||
|
||||
@ -985,79 +1088,50 @@ const {
|
||||
|
||||
// 为每个按钮添加事件
|
||||
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) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
// 为键盘事件创建模拟点击事件,使其中心点位于按钮中央
|
||||
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);
|
||||
handleThemeToggle(e, button);
|
||||
}
|
||||
};
|
||||
addListener(button, "keydown", keydownHandler);
|
||||
});
|
||||
|
||||
// 处理移动端主题切换容器 - 重新添加此功能以提供更一致的用户体验
|
||||
// 处理移动端主题切换容器 - 使用同样的逻辑处理
|
||||
const themeToggleContainer = document.getElementById("theme-toggle-container");
|
||||
if (themeToggleContainer) {
|
||||
const containerClickHandler = (e) => {
|
||||
// 只有当点击不是直接在按钮上时,才托管事件处理
|
||||
if (!e.target.closest("#theme-toggle-button")) {
|
||||
// 找到容器内的主题切换按钮
|
||||
const button = themeToggleContainer.querySelector("#theme-toggle-button");
|
||||
if (button) {
|
||||
// 从点击事件中提取坐标
|
||||
const clickX = e.clientX || 0;
|
||||
const clickY = e.clientY || 0;
|
||||
|
||||
// 使用更可靠的坐标计算方法
|
||||
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);
|
||||
}
|
||||
// 如果点击的是按钮内部,让按钮自己处理
|
||||
if (e.target.closest("#theme-toggle-button")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 找到容器内的主题切换按钮
|
||||
const button = themeToggleContainer.querySelector("#theme-toggle-button");
|
||||
if (button) {
|
||||
// 调用统一的处理函数
|
||||
handleThemeToggle(e, button);
|
||||
}
|
||||
};
|
||||
|
||||
// 使用capture模式确保事件在捕获阶段就被处理
|
||||
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();
|
||||
|
||||
// 设置防止内存泄漏的自动清理 - 最多30秒后自毁
|
||||
setTimeout(() => {
|
||||
// 如果页面没有被卸载,检查是否已经清理过
|
||||
if (document.body && !document._themeToggleCleanedUp) {
|
||||
// 为避免重复清理,设置一个标记
|
||||
document._themeToggleCleanedUp = true;
|
||||
// 执行清理
|
||||
selfDestruct();
|
||||
console.debug('主题切换: 自动清理已执行');
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// 判断DOM是否已加载
|
||||
|
Loading…
Reference in New Issue
Block a user