2025-04-25 14:52:42 +08:00
|
|
|
|
---
|
|
|
|
|
interface Props {
|
|
|
|
|
height?: number;
|
|
|
|
|
width?: number;
|
|
|
|
|
fill?: string;
|
|
|
|
|
className?: string;
|
|
|
|
|
// 更新主题过渡动画模式配置
|
|
|
|
|
transitionMode?: "expand" | "shrink" | "auto" | "reverse-auto";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
height = 16,
|
|
|
|
|
width = 16,
|
|
|
|
|
fill = "currentColor",
|
|
|
|
|
className = "",
|
|
|
|
|
transitionMode = "auto", // 默认为自动模式
|
|
|
|
|
} = Astro.props;
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
id="theme-toggle-button"
|
|
|
|
|
class={`inline-flex items-center justify-center h-8 w-8 cursor-pointer rounded-md hover:bg-gray-100 dark:hover:bg-gray-700/50 text-secondary-600 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400 ${className} overflow-hidden relative`}
|
|
|
|
|
aria-label="切换主题"
|
|
|
|
|
role="button"
|
|
|
|
|
tabindex="0"
|
|
|
|
|
data-transition-mode={transitionMode}
|
|
|
|
|
>
|
|
|
|
|
<!-- 月亮图标 (暗色模式) -->
|
|
|
|
|
<svg
|
|
|
|
|
id="dark-icon"
|
|
|
|
|
style={`height: ${height}px; width: ${width}px;`}
|
|
|
|
|
fill={fill}
|
|
|
|
|
viewBox="0 0 16 16"
|
|
|
|
|
class="hover:scale-110 hidden dark:block relative z-10"
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
>
|
|
|
|
|
<path
|
|
|
|
|
d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"
|
|
|
|
|
></path>
|
|
|
|
|
</svg>
|
|
|
|
|
|
|
|
|
|
<!-- 太阳图标 (亮色模式) -->
|
|
|
|
|
<svg
|
|
|
|
|
id="light-icon"
|
|
|
|
|
style={`height: ${height}px; width: ${width}px;`}
|
|
|
|
|
fill={fill}
|
|
|
|
|
viewBox="0 0 16 16"
|
|
|
|
|
class="hover:scale-110 block dark:hidden relative z-10"
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
>
|
|
|
|
|
<path
|
|
|
|
|
d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"
|
|
|
|
|
></path>
|
|
|
|
|
</svg>
|
|
|
|
|
|
|
|
|
|
<!-- 波纹效果容器 -->
|
|
|
|
|
<span id="ripple-container" class="absolute inset-0 pointer-events-none z-0"></span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<style is:global>
|
|
|
|
|
/* 波纹效果相关样式 */
|
|
|
|
|
@keyframes ripple-effect {
|
|
|
|
|
from {
|
|
|
|
|
transform: scale(0);
|
|
|
|
|
opacity: 0.8;
|
|
|
|
|
}
|
|
|
|
|
to {
|
|
|
|
|
transform: scale(10);
|
|
|
|
|
opacity: 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.theme-ripple {
|
|
|
|
|
position: absolute;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
background-color: rgba(var(--theme-ripple-color, 100, 100, 100), 0.15);
|
|
|
|
|
width: 10px;
|
|
|
|
|
height: 10px;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
transform-origin: center;
|
|
|
|
|
animation: ripple-effect 800ms ease-out forwards;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 暗色模式下使用不同颜色变量 */
|
|
|
|
|
.dark .theme-ripple {
|
|
|
|
|
background-color: rgba(var(--theme-ripple-color, 200, 200, 200), 0.15);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* View Transitions 样式控制 */
|
|
|
|
|
::view-transition-old(root),
|
|
|
|
|
::view-transition-new(root) {
|
|
|
|
|
animation: none !important;
|
|
|
|
|
mix-blend-mode: normal !important;
|
|
|
|
|
isolation: auto !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 新增特殊模式样式 */
|
|
|
|
|
html.theme-transition-active {
|
|
|
|
|
transition: none !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
::view-transition-old(root) {
|
|
|
|
|
z-index: 999 !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
::view-transition-new(root) {
|
|
|
|
|
z-index: 1000 !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 设置主题容器在移动设备上的样式 */
|
|
|
|
|
#theme-toggle-container {
|
|
|
|
|
position: relative;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
<script is:inline>
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 主题切换逻辑 - 自销毁模式
|
|
|
|
|
(function() {
|
|
|
|
|
|
|
|
|
|
// 集中管理所有事件监听器
|
|
|
|
|
const allListeners = [];
|
|
|
|
|
|
|
|
|
|
// 单独保存清理事件的监听器引用
|
|
|
|
|
const cleanupListeners = [];
|
2025-04-25 14:52:42 +08:00
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 定时器引用
|
|
|
|
|
let transitionTimeout = null;
|
2025-04-25 14:52:42 +08:00
|
|
|
|
let rippleTimeout = null;
|
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 主题过渡模式
|
2025-04-25 14:52:42 +08:00
|
|
|
|
const TRANSITION_MODES = {
|
|
|
|
|
EXPAND: 'expand', // 扩散模式
|
|
|
|
|
SHRINK: 'shrink', // 收缩模式
|
|
|
|
|
AUTO: 'auto', // 自动模式(根据切换方向选择)
|
|
|
|
|
REVERSE_AUTO: 'reverse-auto' // 反向自动模式
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 动画持续时间配置(毫秒)
|
|
|
|
|
const ANIMATION_DURATION = 700; // 动画持续时间
|
|
|
|
|
const ANIMATION_BUFFER = 100; // 动画缓冲时间
|
|
|
|
|
const TOTAL_TRANSITION_TIME = ANIMATION_DURATION + ANIMATION_BUFFER; // 总过渡时间
|
|
|
|
|
|
|
|
|
|
// 从本地存储获取主题过渡模式
|
2025-04-25 14:52:42 +08:00
|
|
|
|
function getThemeTransitionMode() {
|
|
|
|
|
const savedMode = localStorage.getItem('theme-transition-mode');
|
|
|
|
|
return Object.values(TRANSITION_MODES).includes(savedMode)
|
|
|
|
|
? savedMode
|
|
|
|
|
: TRANSITION_MODES.AUTO;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 添加事件监听器并记录,方便后续统一清理
|
|
|
|
|
function addListener(element, eventType, handler, options) {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
if (!element) {
|
|
|
|
|
console.warn(`主题切换尝试为不存在的元素添加事件:`, eventType);
|
|
|
|
|
return null;
|
2025-04-25 14:52:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
element.addEventListener(eventType, handler, options);
|
2025-05-05 19:40:03 +08:00
|
|
|
|
allListeners.push({ element, eventType, handler, options });
|
2025-04-25 14:52:42 +08:00
|
|
|
|
return handler;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 统一的清理函数,执行完整清理并自销毁
|
|
|
|
|
function selfDestruct() {
|
|
|
|
|
// 0. 取消正在进行的transition
|
|
|
|
|
if (window._themeTransition && typeof window._themeTransition.skipTransition === 'function') {
|
2025-04-25 14:52:42 +08:00
|
|
|
|
try {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
window._themeTransition.skipTransition();
|
|
|
|
|
console.debug(`主题切换清理阶段取消transition成功`);
|
2025-04-25 14:52:42 +08:00
|
|
|
|
} catch (err) {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 降级为debug日志
|
|
|
|
|
if (err.name === 'AbortError') {
|
|
|
|
|
console.debug(`主题切换清理阶段: 过渡已被跳过,这是正常现象`);
|
|
|
|
|
} else {
|
|
|
|
|
console.debug(`主题切换清理阶段取消transition出错:`, err);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
window._themeTransition = null;
|
2025-04-25 14:52:42 +08:00
|
|
|
|
}
|
2025-05-05 19:40:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 1. 清理所有计时器
|
2025-04-25 14:52:42 +08:00
|
|
|
|
if (transitionTimeout) {
|
|
|
|
|
clearTimeout(transitionTimeout);
|
|
|
|
|
transitionTimeout = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (rippleTimeout) {
|
|
|
|
|
clearTimeout(rippleTimeout);
|
|
|
|
|
rippleTimeout = null;
|
|
|
|
|
}
|
2025-05-05 19:40:03 +08:00
|
|
|
|
|
|
|
|
|
// 2. 清理临时样式元素
|
|
|
|
|
const tempStyle = document.getElementById('theme-transition-temp-style');
|
|
|
|
|
if (tempStyle) {
|
|
|
|
|
tempStyle.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. 移除过渡动画标记
|
|
|
|
|
document.documentElement.classList.remove('theme-transition-active');
|
|
|
|
|
|
|
|
|
|
// 4. 移除所有波纹效果元素
|
|
|
|
|
document.querySelectorAll(".theme-ripple").forEach(ripple => {
|
|
|
|
|
if (ripple.parentNode) {
|
|
|
|
|
ripple.parentNode.removeChild(ripple);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 5. 移除普通事件监听器
|
|
|
|
|
allListeners.forEach(({ element, eventType, handler, options }) => {
|
|
|
|
|
try {
|
|
|
|
|
element.removeEventListener(eventType, handler, options);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`主题切换移除事件监听器出错:`, err);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 清空监听器数组
|
|
|
|
|
allListeners.length = 0;
|
|
|
|
|
|
|
|
|
|
// 6. 最后移除清理事件监听器自身
|
|
|
|
|
cleanupListeners.forEach(({ element, eventType, handler, options }) => {
|
|
|
|
|
try {
|
|
|
|
|
element.removeEventListener(eventType, handler, options);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`主题切换移除清理监听器出错:`, err);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 清空清理监听器数组
|
|
|
|
|
cleanupListeners.length = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 注册清理事件,并保存引用
|
|
|
|
|
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 } }
|
|
|
|
|
);
|
2025-04-25 14:52:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 创建波纹动画元素
|
|
|
|
|
function createRippleEffect(x, y, element) {
|
|
|
|
|
// 清理旧的波纹元素
|
|
|
|
|
const container = element.querySelector("#ripple-container") || element;
|
|
|
|
|
const oldRipples = container.querySelectorAll(".theme-ripple");
|
|
|
|
|
oldRipples.forEach(ripple => ripple.remove());
|
|
|
|
|
|
|
|
|
|
// 创建新的波纹元素
|
|
|
|
|
const ripple = document.createElement("span");
|
|
|
|
|
ripple.classList.add("theme-ripple");
|
|
|
|
|
|
|
|
|
|
// 设置波纹位置
|
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
|
const relativeX = x - rect.left;
|
|
|
|
|
const relativeY = y - rect.top;
|
|
|
|
|
|
|
|
|
|
ripple.style.left = `${relativeX}px`;
|
|
|
|
|
ripple.style.top = `${relativeY}px`;
|
|
|
|
|
|
|
|
|
|
// 添加波纹到容器
|
|
|
|
|
container.appendChild(ripple);
|
|
|
|
|
|
|
|
|
|
// 自动清理波纹元素
|
|
|
|
|
rippleTimeout = setTimeout(() => {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
if (ripple.parentNode) {
|
|
|
|
|
ripple.parentNode.removeChild(ripple);
|
|
|
|
|
}
|
2025-04-25 14:52:42 +08:00
|
|
|
|
}, 1000);
|
|
|
|
|
|
|
|
|
|
return ripple;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 确定应该使用的动画类型
|
|
|
|
|
function determineAnimationType(transitionMode, fromTheme, toTheme) {
|
|
|
|
|
// 如果是固定模式,直接返回
|
|
|
|
|
if (transitionMode === TRANSITION_MODES.EXPAND ||
|
|
|
|
|
transitionMode === TRANSITION_MODES.SHRINK) {
|
|
|
|
|
return transitionMode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 如果是自动模式,根据切换方向决定
|
|
|
|
|
if (transitionMode === TRANSITION_MODES.AUTO) {
|
|
|
|
|
// 默认自动模式:亮色->暗色用扩散,暗色->亮色用收缩
|
|
|
|
|
return (fromTheme === 'light' && toTheme === 'dark')
|
|
|
|
|
? TRANSITION_MODES.EXPAND
|
|
|
|
|
: TRANSITION_MODES.SHRINK;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 如果是反向自动模式,反向选择
|
|
|
|
|
if (transitionMode === TRANSITION_MODES.REVERSE_AUTO) {
|
|
|
|
|
// 反向自动模式:亮色->暗色用收缩,暗色->亮色用扩散
|
|
|
|
|
return (fromTheme === 'light' && toTheme === 'dark')
|
|
|
|
|
? TRANSITION_MODES.SHRINK
|
|
|
|
|
: TRANSITION_MODES.EXPAND;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 默认返回扩散模式
|
|
|
|
|
return TRANSITION_MODES.EXPAND;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 使用View Transitions API创建全屏过渡效果
|
|
|
|
|
function createViewTransition(callback, x, y, fromTheme, toTheme, transitionMode) {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 如果已有正在进行的过渡,先取消它
|
|
|
|
|
if (window._themeTransition) {
|
|
|
|
|
try {
|
|
|
|
|
if (typeof window._themeTransition.skipTransition === 'function') {
|
|
|
|
|
window._themeTransition.skipTransition();
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// 降级为debug日志,不需要显示警告
|
|
|
|
|
console.debug(`主题切换取消先前过渡失败:`, e);
|
|
|
|
|
} finally {
|
|
|
|
|
// 无论成功与否,都清除引用
|
|
|
|
|
window._themeTransition = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-25 14:52:42 +08:00
|
|
|
|
// 检查浏览器是否支持View Transitions API
|
|
|
|
|
if (!document.startViewTransition) {
|
|
|
|
|
// 尝试使用简单的回退动画
|
|
|
|
|
try {
|
|
|
|
|
// 创建一个圆形蒙版元素
|
|
|
|
|
const mask = document.createElement('div');
|
|
|
|
|
mask.style.position = 'fixed';
|
|
|
|
|
mask.style.zIndex = '9999';
|
|
|
|
|
mask.style.top = '0';
|
|
|
|
|
mask.style.left = '0';
|
|
|
|
|
mask.style.width = '100vw';
|
|
|
|
|
mask.style.height = '100vh';
|
|
|
|
|
mask.style.pointerEvents = 'none';
|
|
|
|
|
|
|
|
|
|
// 设置当前主题的背景颜色
|
|
|
|
|
if (fromTheme === 'dark') {
|
|
|
|
|
mask.style.backgroundColor = '#1a1a1a'; // 暗色主题背景色
|
|
|
|
|
} else {
|
|
|
|
|
mask.style.backgroundColor = '#ffffff'; // 亮色主题背景色
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 创建圆形过渡裁剪区域
|
|
|
|
|
const clipType = determineAnimationType(transitionMode, fromTheme, toTheme);
|
|
|
|
|
|
|
|
|
|
if (clipType === TRANSITION_MODES.EXPAND) {
|
|
|
|
|
// 扩散效果 - 从点击位置向外扩散
|
|
|
|
|
mask.style.clipPath = `circle(0px at ${x}px ${y}px)`;
|
|
|
|
|
document.body.appendChild(mask);
|
|
|
|
|
|
|
|
|
|
// 先执行回调改变主题
|
|
|
|
|
callback();
|
|
|
|
|
|
|
|
|
|
// 然后执行动画
|
|
|
|
|
setTimeout(() => {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
mask.style.transition = `clip-path ${ANIMATION_DURATION/1000}s ease-out`;
|
2025-04-25 14:52:42 +08:00
|
|
|
|
mask.style.clipPath = `circle(150vmax at ${x}px ${y}px)`;
|
|
|
|
|
|
|
|
|
|
// 动画结束后删除遮罩
|
|
|
|
|
setTimeout(() => {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
if (mask.parentNode) {
|
|
|
|
|
mask.parentNode.removeChild(mask);
|
|
|
|
|
}
|
|
|
|
|
}, ANIMATION_DURATION);
|
2025-04-25 14:52:42 +08:00
|
|
|
|
}, 20);
|
|
|
|
|
} else {
|
|
|
|
|
// 收缩效果 - 从全屏向点击位置收缩
|
|
|
|
|
mask.style.clipPath = `circle(150vmax at ${x}px ${y}px)`;
|
|
|
|
|
document.body.appendChild(mask);
|
|
|
|
|
|
|
|
|
|
// 添加过渡样式
|
2025-05-05 19:40:03 +08:00
|
|
|
|
mask.style.transition = `clip-path ${ANIMATION_DURATION/1000}s ease-in`;
|
2025-04-25 14:52:42 +08:00
|
|
|
|
|
|
|
|
|
// 强制回流
|
|
|
|
|
void mask.offsetWidth;
|
|
|
|
|
|
|
|
|
|
// 设置目标状态
|
|
|
|
|
mask.style.clipPath = `circle(0px at ${x}px ${y}px)`;
|
|
|
|
|
|
|
|
|
|
// 等待动画结束后切换主题并移除遮罩
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
callback();
|
2025-05-05 19:40:03 +08:00
|
|
|
|
if (mask.parentNode) {
|
|
|
|
|
mask.parentNode.removeChild(mask);
|
|
|
|
|
}
|
|
|
|
|
}, ANIMATION_DURATION);
|
2025-04-25 14:52:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
return new Promise(resolve => setTimeout(resolve, TOTAL_TRANSITION_TIME));
|
2025-04-25 14:52:42 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
// 如果回退方案也失败,直接执行回调
|
|
|
|
|
callback();
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 清除状态标记,为下一次过渡做准备
|
|
|
|
|
document.documentElement.classList.remove('theme-transition-active');
|
|
|
|
|
|
|
|
|
|
// 清除可能存在的临时样式
|
|
|
|
|
const oldStyle = document.getElementById('theme-transition-temp-style');
|
|
|
|
|
if (oldStyle) {
|
|
|
|
|
oldStyle.remove();
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-25 14:52:42 +08:00
|
|
|
|
// 计算从点击位置到页面四个角的最大距离
|
|
|
|
|
const w = window.innerWidth;
|
|
|
|
|
const h = window.innerHeight;
|
|
|
|
|
|
|
|
|
|
// 计算最大半径,确保覆盖整个屏幕
|
|
|
|
|
const maxDistance = Math.max(
|
|
|
|
|
Math.hypot(x, y), // 左上角
|
|
|
|
|
Math.hypot(w - x, y), // 右上角
|
|
|
|
|
Math.hypot(x, h - y), // 左下角
|
|
|
|
|
Math.hypot(w - x, h - y) // 右下角
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 设置CSS变量用于波纹颜色
|
|
|
|
|
document.documentElement.style.setProperty(
|
|
|
|
|
'--theme-ripple-color',
|
|
|
|
|
toTheme === 'dark' ? '230, 230, 230' : '20, 20, 20'
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 添加主题过渡标记类
|
|
|
|
|
document.documentElement.classList.add('theme-transition-active');
|
|
|
|
|
|
|
|
|
|
// 确定动画类型
|
|
|
|
|
const animationType = determineAnimationType(transitionMode, fromTheme, toTheme);
|
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 创建一个简单回调函数,避免复杂操作导致transition跳过
|
|
|
|
|
const safeCallback = () => {
|
|
|
|
|
try {
|
|
|
|
|
// 更新HTML属性
|
|
|
|
|
document.documentElement.dataset.theme = toTheme;
|
|
|
|
|
|
|
|
|
|
// 更新本地存储
|
|
|
|
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
|
|
|
if (toTheme === systemTheme) {
|
|
|
|
|
localStorage.removeItem("theme");
|
|
|
|
|
} else {
|
|
|
|
|
localStorage.setItem("theme", toTheme);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 最后执行用户回调
|
|
|
|
|
callback();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`主题切换 回调执行出错:`, err);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-04-25 14:52:42 +08:00
|
|
|
|
// 启动视图过渡
|
2025-05-05 19:40:03 +08:00
|
|
|
|
const transition = document.startViewTransition(safeCallback);
|
|
|
|
|
|
|
|
|
|
// 存储transition引用以便清理
|
|
|
|
|
window._themeTransition = transition;
|
2025-04-25 14:52:42 +08:00
|
|
|
|
|
|
|
|
|
// 生成动画需要的SVG资源
|
|
|
|
|
const gradientOffset = 0.75;
|
|
|
|
|
const maskSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><defs><radialGradient id="toggle-theme-gradient"><stop offset="${gradientOffset}"/><stop offset="1" stop-opacity="0"/></radialGradient></defs><circle cx="4" cy="4" r="4" fill="url(#toggle-theme-gradient)"/></svg>`;
|
|
|
|
|
const maskUrl = `data:image/svg+xml;base64,${btoa(maskSvg)}`;
|
|
|
|
|
|
|
|
|
|
// 计算动画需要多大才能覆盖整个屏幕
|
|
|
|
|
const maxRadius = Math.ceil(maxDistance / gradientOffset);
|
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 准备应用自定义动画的操作
|
|
|
|
|
const applyCustomAnimation = async () => {
|
|
|
|
|
try {
|
|
|
|
|
// 使用race和超时Promise,改进超时策略
|
|
|
|
|
const readyPromise = Promise.race([
|
|
|
|
|
transition.ready,
|
|
|
|
|
new Promise((_, reject) => {
|
|
|
|
|
// 增加超时时间到50ms,提高收缩模式的成功率
|
|
|
|
|
setTimeout(() => reject(new Error('Ready timeout')), 50);
|
|
|
|
|
})
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 等待过渡准备完成
|
|
|
|
|
await readyPromise;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// 超时处理 - 只记录警告日志,继续执行动画
|
|
|
|
|
if (err.message === 'Ready timeout') {
|
|
|
|
|
console.debug(`主题切换过渡准备超时,使用备用策略`);
|
|
|
|
|
// 收缩模式时此处超时更常见,立即使用备用方案
|
|
|
|
|
if (animationType === TRANSITION_MODES.SHRINK) {
|
|
|
|
|
applyFallbackAnimation();
|
|
|
|
|
return; // 跳过后续代码,使用备用方案
|
|
|
|
|
}
|
|
|
|
|
} else if (err.name === 'AbortError') {
|
|
|
|
|
// 如果是AbortError,我们也立即使用备用方案
|
|
|
|
|
applyFallbackAnimation();
|
|
|
|
|
return; // 跳过后续代码,使用备用方案
|
|
|
|
|
} else {
|
|
|
|
|
console.debug(`主题切换过渡准备错误:`, err);
|
2025-04-25 14:52:42 +08:00
|
|
|
|
}
|
2025-05-05 19:40:03 +08:00
|
|
|
|
}
|
2025-04-25 14:52:42 +08:00
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 应用基础样式到document
|
|
|
|
|
const style = document.createElement('style');
|
|
|
|
|
style.id = 'theme-transition-temp-style';
|
2025-04-25 14:52:42 +08:00
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
if (animationType === TRANSITION_MODES.EXPAND) {
|
|
|
|
|
// 扩散效果 - 新主题从点击位置向外扩散
|
2025-04-25 14:52:42 +08:00
|
|
|
|
style.textContent = `
|
|
|
|
|
::view-transition-new(root) {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
animation: none !important;
|
|
|
|
|
-webkit-mask-image: url('${maskUrl}') !important;
|
|
|
|
|
mask-image: url('${maskUrl}') !important;
|
|
|
|
|
-webkit-mask-repeat: no-repeat !important;
|
|
|
|
|
mask-repeat: no-repeat !important;
|
|
|
|
|
-webkit-mask-position: ${x}px ${y}px !important;
|
|
|
|
|
mask-position: ${x}px ${y}px !important;
|
|
|
|
|
-webkit-mask-size: 0 !important;
|
|
|
|
|
mask-size: 0 !important;
|
|
|
|
|
z-index: 1000 !important;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
document.head.appendChild(style);
|
|
|
|
|
|
|
|
|
|
// 强制重新计算样式
|
|
|
|
|
window.getComputedStyle(document.documentElement).getPropertyValue('--force-reflow');
|
|
|
|
|
|
|
|
|
|
// 立即设置最终状态
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
if (style.parentNode) {
|
|
|
|
|
style.textContent = `
|
|
|
|
|
::view-transition-new(root) {
|
|
|
|
|
animation: none !important;
|
|
|
|
|
-webkit-mask-image: url('${maskUrl}') !important;
|
|
|
|
|
mask-image: url('${maskUrl}') !important;
|
|
|
|
|
-webkit-mask-repeat: no-repeat !important;
|
|
|
|
|
mask-repeat: no-repeat !important;
|
|
|
|
|
-webkit-mask-position: ${x - maxRadius}px ${y - maxRadius}px !important;
|
|
|
|
|
mask-position: ${x - maxRadius}px ${y - maxRadius}px !important;
|
|
|
|
|
-webkit-mask-size: ${maxRadius * 2}px !important;
|
|
|
|
|
mask-size: ${maxRadius * 2}px !important;
|
|
|
|
|
z-index: 1000 !important;
|
|
|
|
|
transition: -webkit-mask-position ${ANIMATION_DURATION/1000}s ease-out, -webkit-mask-size ${ANIMATION_DURATION/1000}s ease-out,
|
|
|
|
|
mask-position ${ANIMATION_DURATION/1000}s ease-out, mask-size ${ANIMATION_DURATION/1000}s ease-out !important;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 清理临时样式
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (document.getElementById('theme-transition-temp-style')) {
|
|
|
|
|
document.getElementById('theme-transition-temp-style').remove();
|
|
|
|
|
}
|
|
|
|
|
}, TOTAL_TRANSITION_TIME);
|
|
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
// 改进收缩效果实现
|
|
|
|
|
style.textContent = `
|
|
|
|
|
::view-transition-old(root) {
|
2025-04-25 14:52:42 +08:00
|
|
|
|
animation: none !important;
|
|
|
|
|
-webkit-mask-image: url('${maskUrl}') !important;
|
|
|
|
|
mask-image: url('${maskUrl}') !important;
|
|
|
|
|
-webkit-mask-repeat: no-repeat !important;
|
|
|
|
|
mask-repeat: no-repeat !important;
|
|
|
|
|
-webkit-mask-position: ${x - maxRadius}px ${y - maxRadius}px !important;
|
|
|
|
|
mask-position: ${x - maxRadius}px ${y - maxRadius}px !important;
|
|
|
|
|
-webkit-mask-size: ${maxRadius * 2}px !important;
|
|
|
|
|
mask-size: ${maxRadius * 2}px !important;
|
2025-05-05 19:40:03 +08:00
|
|
|
|
z-index: 999 !important;
|
|
|
|
|
}
|
|
|
|
|
::view-transition-new(root) {
|
|
|
|
|
z-index: 998 !important;
|
2025-04-25 14:52:42 +08:00
|
|
|
|
}
|
|
|
|
|
`;
|
2025-05-05 19:40:03 +08:00
|
|
|
|
document.head.appendChild(style);
|
|
|
|
|
|
|
|
|
|
// 强制重新计算样式
|
|
|
|
|
window.getComputedStyle(document.documentElement).getPropertyValue('--force-reflow');
|
|
|
|
|
|
|
|
|
|
// 使用双重requestAnimationFrame确保样式先被应用
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
if (style.parentNode) {
|
|
|
|
|
style.textContent = `
|
|
|
|
|
::view-transition-old(root) {
|
|
|
|
|
animation: none !important;
|
|
|
|
|
-webkit-mask-image: url('${maskUrl}') !important;
|
|
|
|
|
mask-image: url('${maskUrl}') !important;
|
|
|
|
|
-webkit-mask-repeat: no-repeat !important;
|
|
|
|
|
mask-repeat: no-repeat !important;
|
|
|
|
|
-webkit-mask-position: ${x}px ${y}px !important;
|
|
|
|
|
mask-position: ${x}px ${y}px !important;
|
|
|
|
|
-webkit-mask-size: 0 !important;
|
|
|
|
|
mask-size: 0 !important;
|
|
|
|
|
z-index: 999 !important;
|
|
|
|
|
transition: -webkit-mask-position ${ANIMATION_DURATION/1000}s ease-in, -webkit-mask-size ${ANIMATION_DURATION/1000}s ease-in,
|
|
|
|
|
mask-position ${ANIMATION_DURATION/1000}s ease-in, mask-size ${ANIMATION_DURATION/1000}s ease-in !important;
|
|
|
|
|
}
|
|
|
|
|
::view-transition-new(root) {
|
|
|
|
|
z-index: 998 !important;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-04-25 14:52:42 +08:00
|
|
|
|
|
|
|
|
|
// 清理临时样式
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (document.getElementById('theme-transition-temp-style')) {
|
|
|
|
|
document.getElementById('theme-transition-temp-style').remove();
|
|
|
|
|
}
|
2025-05-05 19:40:03 +08:00
|
|
|
|
}, TOTAL_TRANSITION_TIME);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// AbortError是正常的,我们完全可以忽略它,但仍保留调试日志
|
|
|
|
|
if (error.name === 'AbortError') {
|
|
|
|
|
// 降级为debug级别日志,不要吓到用户
|
|
|
|
|
console.debug(`主题切换过渡被跳过,这是正常现象`);
|
|
|
|
|
// 使用备用动画方案
|
|
|
|
|
applyFallbackAnimation();
|
|
|
|
|
} else if (error.message === 'Ready timeout') {
|
|
|
|
|
// 超时是我们主动抛出的,只需要降级处理
|
|
|
|
|
console.debug(`主题切换过渡准备超时,继续执行动画`);
|
|
|
|
|
|
|
|
|
|
// 即使ready超时,我们仍然可以应用样式
|
|
|
|
|
// 这里可以直接调用备用方案应用动画
|
|
|
|
|
applyFallbackAnimation();
|
|
|
|
|
} else {
|
|
|
|
|
// 只有真正意外的错误才输出error级别日志
|
|
|
|
|
console.error(`主题切换应用自定义动画出错:`, error);
|
|
|
|
|
// 尝试使用备用方案
|
|
|
|
|
applyFallbackAnimation();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 添加备用动画方案
|
|
|
|
|
const applyFallbackAnimation = () => {
|
|
|
|
|
try {
|
|
|
|
|
// 创建并应用样式但不等待transition.ready
|
|
|
|
|
const style = document.createElement('style');
|
|
|
|
|
style.id = 'theme-transition-temp-style';
|
2025-04-25 14:52:42 +08:00
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
if (animationType === TRANSITION_MODES.EXPAND) {
|
|
|
|
|
// 扩散效果样式 - 保持不变
|
2025-04-25 14:52:42 +08:00
|
|
|
|
style.textContent = `
|
2025-05-05 19:40:03 +08:00
|
|
|
|
::view-transition-new(root) {
|
2025-04-25 14:52:42 +08:00
|
|
|
|
animation: none !important;
|
|
|
|
|
-webkit-mask-image: url('${maskUrl}') !important;
|
|
|
|
|
mask-image: url('${maskUrl}') !important;
|
|
|
|
|
-webkit-mask-repeat: no-repeat !important;
|
|
|
|
|
mask-repeat: no-repeat !important;
|
|
|
|
|
-webkit-mask-position: ${x}px ${y}px !important;
|
|
|
|
|
mask-position: ${x}px ${y}px !important;
|
|
|
|
|
-webkit-mask-size: 0 !important;
|
|
|
|
|
mask-size: 0 !important;
|
2025-05-05 19:40:03 +08:00
|
|
|
|
z-index: 1000 !important;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
document.head.appendChild(style);
|
|
|
|
|
|
|
|
|
|
// 强制重新计算样式
|
|
|
|
|
window.getComputedStyle(document.documentElement).getPropertyValue('--force-reflow');
|
|
|
|
|
|
|
|
|
|
// 使用双重requestAnimationFrame确保样式先被应用
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
if (style.parentNode) {
|
|
|
|
|
style.textContent = `
|
|
|
|
|
::view-transition-new(root) {
|
|
|
|
|
animation: none !important;
|
|
|
|
|
-webkit-mask-image: url('${maskUrl}') !important;
|
|
|
|
|
mask-image: url('${maskUrl}') !important;
|
|
|
|
|
-webkit-mask-repeat: no-repeat !important;
|
|
|
|
|
mask-repeat: no-repeat !important;
|
|
|
|
|
-webkit-mask-position: ${x - maxRadius}px ${y - maxRadius}px !important;
|
|
|
|
|
mask-position: ${x - maxRadius}px ${y - maxRadius}px !important;
|
|
|
|
|
-webkit-mask-size: ${maxRadius * 2}px !important;
|
|
|
|
|
mask-size: ${maxRadius * 2}px !important;
|
|
|
|
|
z-index: 1000 !important;
|
|
|
|
|
transition: -webkit-mask-position ${ANIMATION_DURATION/1000}s ease-out, -webkit-mask-size ${ANIMATION_DURATION/1000}s ease-out,
|
|
|
|
|
mask-position ${ANIMATION_DURATION/1000}s ease-out, mask-size ${ANIMATION_DURATION/1000}s ease-out !important;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// 改进收缩效果实现
|
|
|
|
|
style.textContent = `
|
|
|
|
|
::view-transition-old(root) {
|
|
|
|
|
animation: none !important;
|
|
|
|
|
-webkit-mask-image: url('${maskUrl}') !important;
|
|
|
|
|
mask-image: url('${maskUrl}') !important;
|
|
|
|
|
-webkit-mask-repeat: no-repeat !important;
|
|
|
|
|
mask-repeat: no-repeat !important;
|
|
|
|
|
-webkit-mask-position: ${x - maxRadius}px ${y - maxRadius}px !important;
|
|
|
|
|
mask-position: ${x - maxRadius}px ${y - maxRadius}px !important;
|
|
|
|
|
-webkit-mask-size: ${maxRadius * 2}px !important;
|
|
|
|
|
mask-size: ${maxRadius * 2}px !important;
|
2025-04-25 14:52:42 +08:00
|
|
|
|
z-index: 999 !important;
|
|
|
|
|
}
|
|
|
|
|
::view-transition-new(root) {
|
|
|
|
|
z-index: 998 !important;
|
|
|
|
|
}
|
|
|
|
|
`;
|
2025-05-05 19:40:03 +08:00
|
|
|
|
document.head.appendChild(style);
|
|
|
|
|
|
|
|
|
|
// 强制重新计算样式
|
|
|
|
|
window.getComputedStyle(document.documentElement).getPropertyValue('--force-reflow');
|
|
|
|
|
|
|
|
|
|
// 使用双重requestAnimationFrame确保样式先被应用
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
if (style.parentNode) {
|
|
|
|
|
style.textContent = `
|
|
|
|
|
::view-transition-old(root) {
|
|
|
|
|
animation: none !important;
|
|
|
|
|
-webkit-mask-image: url('${maskUrl}') !important;
|
|
|
|
|
mask-image: url('${maskUrl}') !important;
|
|
|
|
|
-webkit-mask-repeat: no-repeat !important;
|
|
|
|
|
mask-repeat: no-repeat !important;
|
|
|
|
|
-webkit-mask-position: ${x}px ${y}px !important;
|
|
|
|
|
mask-position: ${x}px ${y}px !important;
|
|
|
|
|
-webkit-mask-size: 0 !important;
|
|
|
|
|
mask-size: 0 !important;
|
|
|
|
|
z-index: 999 !important;
|
|
|
|
|
transition: -webkit-mask-position ${ANIMATION_DURATION/1000}s ease-in, -webkit-mask-size ${ANIMATION_DURATION/1000}s ease-in,
|
|
|
|
|
mask-position ${ANIMATION_DURATION/1000}s ease-in, mask-size ${ANIMATION_DURATION/1000}s ease-in !important;
|
|
|
|
|
}
|
|
|
|
|
::view-transition-new(root) {
|
|
|
|
|
z-index: 998 !important;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-04-25 14:52:42 +08:00
|
|
|
|
|
|
|
|
|
// 清理临时样式
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (document.getElementById('theme-transition-temp-style')) {
|
|
|
|
|
document.getElementById('theme-transition-temp-style').remove();
|
|
|
|
|
}
|
2025-05-05 19:40:03 +08:00
|
|
|
|
}, TOTAL_TRANSITION_TIME);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.debug(`主题切换应用备用动画方案出错:`, err);
|
2025-04-25 14:52:42 +08:00
|
|
|
|
}
|
2025-05-05 19:40:03 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 非阻塞执行自定义动画应用
|
|
|
|
|
applyCustomAnimation().catch(error => {
|
|
|
|
|
console.error(`主题切换应用动画过程出错:`, error);
|
2025-04-25 14:52:42 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 返回过渡完成的Promise
|
2025-05-05 19:40:03 +08:00
|
|
|
|
return transition.finished
|
|
|
|
|
.then(() => {
|
|
|
|
|
// 移除主题过渡标记类
|
|
|
|
|
document.documentElement.classList.remove('theme-transition-active');
|
|
|
|
|
window._themeTransition = null;
|
|
|
|
|
})
|
|
|
|
|
.catch(error => {
|
|
|
|
|
if (error.name === 'AbortError') {
|
|
|
|
|
console.debug(`主题切换过渡被中断: 这是正常现象,可能是新的过渡开始或页面导航变化`);
|
|
|
|
|
} else {
|
|
|
|
|
console.error(`主题切换过渡动画错误:`, error);
|
|
|
|
|
}
|
|
|
|
|
// 确保标记类被移除
|
|
|
|
|
document.documentElement.classList.remove('theme-transition-active');
|
|
|
|
|
|
|
|
|
|
// 清除引用
|
|
|
|
|
window._themeTransition = null;
|
|
|
|
|
});
|
2025-04-25 14:52:42 +08:00
|
|
|
|
} catch (error) {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
console.error(`主题切换错误:`, error);
|
2025-04-25 14:52:42 +08:00
|
|
|
|
// 在出错时也要执行回调
|
|
|
|
|
callback();
|
|
|
|
|
// 确保标记类被移除
|
|
|
|
|
document.documentElement.classList.remove('theme-transition-active');
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 设置主题切换功能
|
2025-04-25 14:52:42 +08:00
|
|
|
|
function setupThemeToggle() {
|
|
|
|
|
// 获取所有主题切换按钮
|
2025-05-05 19:40:03 +08:00
|
|
|
|
const themeToggleButtons = document.querySelectorAll("#theme-toggle-button");
|
2025-04-25 14:52:42 +08:00
|
|
|
|
|
|
|
|
|
if (!themeToggleButtons.length) {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
console.warn(`主题切换未找到主题切换按钮`);
|
2025-04-25 14:52:42 +08:00
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let transitioning = false;
|
|
|
|
|
|
|
|
|
|
// 获取系统首选主题
|
|
|
|
|
const getSystemTheme = () => {
|
|
|
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
|
|
|
? "dark"
|
|
|
|
|
: "light";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 初始化主题
|
|
|
|
|
const initializeTheme = () => {
|
|
|
|
|
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 = (e) => {
|
|
|
|
|
if (transitioning) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
transitioning = true;
|
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 更可靠的点击坐标获取
|
|
|
|
|
let clickX, clickY;
|
2025-04-25 14:52:42 +08:00
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
try {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 确保坐标有效(防止NaN或无限值)
|
|
|
|
|
clickX = isFinite(clickX) ? clickX : window.innerWidth / 2;
|
|
|
|
|
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);
|
|
|
|
|
}
|
2025-04-25 14:52:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 获取当前主题
|
|
|
|
|
const currentTheme = document.documentElement.dataset.theme;
|
|
|
|
|
const newTheme = currentTheme === "light" ? "dark" : "light";
|
|
|
|
|
|
|
|
|
|
// 获取过渡模式
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 在开始新的过渡前,先重置transitioning的延时清除
|
|
|
|
|
if (transitionTimeout) {
|
|
|
|
|
clearTimeout(transitionTimeout);
|
|
|
|
|
transitionTimeout = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 创建一个安全的主题切换回调
|
|
|
|
|
const safeThemeChangeCallback = () => {
|
|
|
|
|
try {
|
|
|
|
|
// 直接在这里执行主题切换的核心逻辑
|
|
|
|
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
|
|
|
if (newTheme === systemTheme) {
|
|
|
|
|
localStorage.removeItem("theme");
|
|
|
|
|
} else {
|
|
|
|
|
localStorage.setItem("theme", newTheme);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`主题切换回调执行出错:`, err);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
createViewTransition(
|
|
|
|
|
safeThemeChangeCallback,
|
|
|
|
|
clickX,
|
|
|
|
|
clickY,
|
|
|
|
|
currentTheme,
|
|
|
|
|
newTheme,
|
|
|
|
|
transitionMode
|
|
|
|
|
).then(() => {
|
|
|
|
|
// 过渡完成后恢复状态
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
transitioning = false;
|
|
|
|
|
}, ANIMATION_BUFFER); // 添加缓冲时间,确保动画完全结束
|
|
|
|
|
}).catch(error => {
|
|
|
|
|
// 出现错误时强制执行主题切换以确保功能可用
|
|
|
|
|
if (error.name !== 'AbortError') {
|
|
|
|
|
console.error(`主题切换过渡动画错误:`, error);
|
|
|
|
|
// 如果不是因为过渡被跳过而是真正的错误,直接执行主题切换
|
|
|
|
|
safeThemeChangeCallback();
|
|
|
|
|
} else {
|
|
|
|
|
console.debug(`主题切换过渡被跳过,这是正常现象`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
transitioning = false;
|
|
|
|
|
}, ANIMATION_BUFFER);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 设置防抖保底 - 防止transition.finished不触发导致状态卡死
|
|
|
|
|
transitionTimeout = setTimeout(() => {
|
|
|
|
|
transitioning = false;
|
|
|
|
|
}, TOTAL_TRANSITION_TIME + 200); // 总过渡时间加额外缓冲
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// 即使发生错误,也要确保主题能切换
|
|
|
|
|
console.error(`主题切换处理过程出错:`, err);
|
|
|
|
|
|
|
|
|
|
// 尝试直接切换主题
|
|
|
|
|
try {
|
|
|
|
|
const currentTheme = document.documentElement.dataset.theme || 'light';
|
|
|
|
|
const newTheme = currentTheme === "light" ? "dark" : "light";
|
|
|
|
|
|
|
|
|
|
// 更新HTML属性
|
2025-04-25 14:52:42 +08:00
|
|
|
|
document.documentElement.dataset.theme = newTheme;
|
|
|
|
|
|
|
|
|
|
// 更新本地存储
|
2025-05-05 19:40:03 +08:00
|
|
|
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
2025-04-25 14:52:42 +08:00
|
|
|
|
if (newTheme === systemTheme) {
|
|
|
|
|
localStorage.removeItem("theme");
|
|
|
|
|
} else {
|
|
|
|
|
localStorage.setItem("theme", newTheme);
|
|
|
|
|
}
|
2025-05-05 19:40:03 +08:00
|
|
|
|
} catch (fallbackErr) {
|
|
|
|
|
console.error(`主题切换降级处理失败:`, fallbackErr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 确保状态被重置
|
2025-04-25 14:52:42 +08:00
|
|
|
|
transitioning = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 监听系统主题变化
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 添加系统主题变化监听
|
|
|
|
|
addListener(mediaQuery, "change", handleMediaChange);
|
|
|
|
|
|
|
|
|
|
// 为每个按钮添加事件
|
2025-05-05 19:40:03 +08:00
|
|
|
|
themeToggleButtons.forEach(button => {
|
2025-04-25 14:52:42 +08:00
|
|
|
|
// 创建点击处理函数
|
|
|
|
|
const clickHandler = (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
toggleTheme(e);
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 点击事件 - 使用捕获模式
|
2025-04-25 14:52:42 +08:00
|
|
|
|
addListener(button, "click", clickHandler, { 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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
addListener(button, "keydown", keydownHandler);
|
|
|
|
|
});
|
2025-05-05 19:40:03 +08:00
|
|
|
|
|
|
|
|
|
// 处理移动端主题切换容器 - 重新添加此功能以提供更一致的用户体验
|
|
|
|
|
const themeToggleContainer = document.getElementById("theme-toggle-container");
|
2025-04-25 14:52:42 +08:00
|
|
|
|
if (themeToggleContainer) {
|
|
|
|
|
const containerClickHandler = (e) => {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 只有当点击不是直接在按钮上时,才托管事件处理
|
|
|
|
|
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);
|
|
|
|
|
}
|
2025-04-25 14:52:42 +08:00
|
|
|
|
}
|
|
|
|
|
};
|
2025-05-05 19:40:03 +08:00
|
|
|
|
|
|
|
|
|
// 使用capture模式确保事件在捕获阶段就被处理
|
|
|
|
|
addListener(themeToggleContainer, "click", containerClickHandler, { capture: true });
|
2025-04-25 14:52:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 初始化主题
|
|
|
|
|
initializeTheme();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 初始化函数
|
|
|
|
|
function init() {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 注册清理事件
|
|
|
|
|
registerCleanupEvents();
|
|
|
|
|
|
|
|
|
|
// 设置主题切换功能
|
2025-04-25 14:52:42 +08:00
|
|
|
|
setupThemeToggle();
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-05 19:40:03 +08:00
|
|
|
|
// 判断DOM是否已加载
|
2025-04-25 14:52:42 +08:00
|
|
|
|
if (document.readyState === "loading") {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
document.addEventListener("DOMContentLoaded", init, { once: true });
|
2025-04-25 14:52:42 +08:00
|
|
|
|
} else {
|
2025-05-05 19:40:03 +08:00
|
|
|
|
init();
|
2025-04-25 14:52:42 +08:00
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
</script>
|