mirror of
https://github.com/LiuYuYang01/ThriveX-Blog.git
synced 2026-09-03 06:24:24 +08:00
feat: 重构主题切换逻辑以提升用户体验
1. 引入requestThemeTransition函数,替代setIsDark方法,优化主题切换的实现。 2. 更新多个组件(FloatingBlock、Header、Tools)以使用新的主题切换逻辑,确保一致性。 3. 在RootLayoutContent中添加ThemeTransition组件,增强主题切换的可视化效果。
This commit is contained in:
@@ -11,6 +11,7 @@ import Search from '../Search';
|
||||
import Rss from '../Tools/components/Rss';
|
||||
import { LuMoonStar } from 'react-icons/lu';
|
||||
import { FaRegSun } from 'react-icons/fa';
|
||||
import { requestThemeTransition } from '@/utils/themeTransition';
|
||||
|
||||
const floatingBtnClass =
|
||||
'transition-none! active:scale-100! hover:scale-105 hover:text-primary';
|
||||
@@ -22,7 +23,7 @@ const FloatingBlock = () => {
|
||||
const [isDragging, setIsDragging] = useState(false); // 拖拽状态
|
||||
const constraintsRef = useRef(null); // 拖拽约束参考
|
||||
const { web } = useAppConfig();
|
||||
const { isDark, setIsDark } = useConfigStore();
|
||||
const { isDark } = useConfigStore();
|
||||
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
|
||||
const { isOpen: isRssOpen, onOpen: onRssOpen, onClose: onRssClose } = useDisclosure();
|
||||
|
||||
@@ -54,7 +55,7 @@ const FloatingBlock = () => {
|
||||
|
||||
// 主题切换功能
|
||||
const onToggleTheme = () => {
|
||||
setIsDark(!isDark);
|
||||
requestThemeTransition(!isDark);
|
||||
};
|
||||
|
||||
const actionItems = [
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getCateNavHref, getCateNavRel, getCateNavTarget } from '@/utils/cateNav
|
||||
|
||||
import { useConfigStore } from '@/stores';
|
||||
import useMounted from '@/hooks/useMounted';
|
||||
import { requestThemeTransition } from '@/utils/themeTransition';
|
||||
|
||||
const getSubmenuGridClass = (count: number) => {
|
||||
if (count <= 3) return 'grid-cols-1 w-max min-w-[160px]';
|
||||
@@ -58,7 +59,7 @@ const Submenu = ({ items, showIcon }: { items: Cate[]; showIcon?: boolean }) =>
|
||||
export default ({ theme }: { theme: Theme }) => {
|
||||
const patchName = usePathname();
|
||||
|
||||
const { isDark, setIsDark } = useConfigStore();
|
||||
const { isDark } = useConfigStore();
|
||||
const mounted = useMounted();
|
||||
|
||||
// 这些路径段不需要改变导航样式
|
||||
@@ -85,7 +86,7 @@ export default ({ theme }: { theme: Theme }) => {
|
||||
// 监听系统主题变化
|
||||
const mediaQuery = matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', (e: MediaQueryListEvent) => {
|
||||
setIsDark(e.matches);
|
||||
requestThemeTransition(e.matches);
|
||||
});
|
||||
|
||||
getCateList();
|
||||
@@ -101,15 +102,8 @@ export default ({ theme }: { theme: Theme }) => {
|
||||
|
||||
// 手动切换主题
|
||||
const toTheme = () => {
|
||||
setIsDark(!isDark);
|
||||
requestThemeTransition(!isDark);
|
||||
};
|
||||
// 判断当前主题
|
||||
useEffect(() => {
|
||||
const html = document.querySelector('html');
|
||||
if (html && html.classList) {
|
||||
html?.classList?.toggle('dark', isDark);
|
||||
}
|
||||
}, [isDark]);
|
||||
|
||||
// 是否打开侧边栏导航
|
||||
const [isOpenSidebarNav, setIsOpenSidebarNav] = useState(false);
|
||||
|
||||
@@ -7,6 +7,7 @@ import Footer from '@/components/Footer';
|
||||
import RouteChangeHandler from '@/components/RouteChangeHandler';
|
||||
import BaiduStatis from '@/components/BaiduStatis';
|
||||
import FloatingBlock from '@/components/FloatingBlock';
|
||||
import ThemeTransition from '@/components/ThemeTransition';
|
||||
import AppConfigProvider from '@/components/AppConfigProvider';
|
||||
import { getAppConfigCacheAPI } from '@/lib/config';
|
||||
|
||||
@@ -36,6 +37,7 @@ export default async function RootLayoutContent({ children }: Props) {
|
||||
|
||||
<Footer />
|
||||
<FloatingBlock />
|
||||
<ThemeTransition />
|
||||
</AppConfigProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
21
src/components/ThemeTransition/index.scss
Normal file
21
src/components/ThemeTransition/index.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
.theme-transition {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99999;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: auto;
|
||||
display: block;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
html.theme-switching #root {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html.theme-switching #root *,
|
||||
html.theme-switching #root *::before,
|
||||
html.theme-switching #root *::after {
|
||||
transition: none !important;
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
320
src/components/ThemeTransition/index.tsx
Normal file
320
src/components/ThemeTransition/index.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useConfigStore } from '@/stores';
|
||||
import { subscribeThemeTransition } from '@/utils/themeTransition';
|
||||
import './index.scss';
|
||||
|
||||
const DURATION_MS = 1000;
|
||||
const EXIT_MS = 260;
|
||||
|
||||
type Direction = 'to-dark' | 'to-light';
|
||||
|
||||
function applyThemeClass(dark: boolean) {
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
}
|
||||
|
||||
function setSwitching(on: boolean) {
|
||||
document.documentElement.classList.toggle('theme-switching', on);
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function easeInOutCubic(t: number) {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
/** t: 0 左下 → 0.5 顶心 → 1 右下 */
|
||||
function arcPos(t: number, w: number, h: number) {
|
||||
return {
|
||||
x: w * t,
|
||||
y: h * 0.18 + (t - 0.5) * (t - 0.5) * h * 2.4,
|
||||
};
|
||||
}
|
||||
|
||||
/** 绘制昼夜天空渐变,t: 0 白天 → 1 黑夜 */
|
||||
function drawSky(ctx: CanvasRenderingContext2D, w: number, h: number, t: number) {
|
||||
const day = [
|
||||
[110, 175, 230],
|
||||
[160, 210, 245],
|
||||
[210, 232, 250],
|
||||
[255, 232, 200],
|
||||
] as const;
|
||||
const night = [
|
||||
[8, 12, 28],
|
||||
[14, 20, 42],
|
||||
[24, 30, 58],
|
||||
[42, 28, 62],
|
||||
] as const;
|
||||
|
||||
const g = ctx.createLinearGradient(0, 0, 0, h);
|
||||
const stops = [0, 0.35, 0.7, 1];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const r = (day[i][0] + (night[i][0] - day[i][0]) * t) | 0;
|
||||
const gCh = (day[i][1] + (night[i][1] - day[i][1]) * t) | 0;
|
||||
const b = (day[i][2] + (night[i][2] - day[i][2]) * t) | 0;
|
||||
g.addColorStop(stops[i], `rgb(${r},${gCh},${b})`);
|
||||
}
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
/** 预绘太阳:仅球体,无光晕 */
|
||||
function createSunSprite(size: number) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = size;
|
||||
c.height = size;
|
||||
const ctx = c.getContext('2d')!;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const bodyR = size * 0.32;
|
||||
|
||||
const body = ctx.createRadialGradient(cx - bodyR * 0.25, cy - bodyR * 0.3, bodyR * 0.05, cx, cy, bodyR);
|
||||
body.addColorStop(0, '#fffef5');
|
||||
body.addColorStop(0.25, '#fff1a8');
|
||||
body.addColorStop(0.65, '#ffd25a');
|
||||
body.addColorStop(1, '#ffa824');
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, bodyR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = body;
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx - bodyR * 0.28, cy - bodyR * 0.3, bodyR * 0.18, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.55)';
|
||||
ctx.fill();
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
/** 预绘月亮:球体 + 陨石坑,无光晕 */
|
||||
function createMoonSprite(size: number) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = size;
|
||||
c.height = size;
|
||||
const ctx = c.getContext('2d')!;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const bodyR = size * 0.32;
|
||||
|
||||
const body = ctx.createRadialGradient(cx - bodyR * 0.35, cy - bodyR * 0.35, bodyR * 0.12, cx, cy, bodyR);
|
||||
body.addColorStop(0, '#f8fafc');
|
||||
body.addColorStop(0.55, '#dbe4f0');
|
||||
body.addColorStop(1, '#9aabc0');
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, bodyR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = body;
|
||||
ctx.fill();
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, bodyR, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
|
||||
const shade = ctx.createRadialGradient(cx + bodyR * 0.55, cy, bodyR * 0.2, cx + bodyR * 0.2, cy, bodyR * 1.1);
|
||||
shade.addColorStop(0, 'rgba(80, 100, 130, 0.35)');
|
||||
shade.addColorStop(1, 'rgba(80, 100, 130, 0)');
|
||||
ctx.fillStyle = shade;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
const craters: [number, number, number][] = [
|
||||
[-0.15, -0.2, 0.14],
|
||||
[0.2, -0.05, 0.1],
|
||||
[-0.05, 0.25, 0.12],
|
||||
[0.28, 0.22, 0.08],
|
||||
[-0.32, 0.08, 0.07],
|
||||
];
|
||||
for (const [ox, oy, sr] of craters) {
|
||||
const x = cx + ox * bodyR;
|
||||
const y = cy + oy * bodyR;
|
||||
const cr = sr * bodyR;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, cr, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(120, 140, 165, 0.35)';
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(x - cr * 0.25, y - cr * 0.25, cr * 0.55, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
function drawSprite(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
sprite: HTMLCanvasElement,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
alpha: number,
|
||||
) {
|
||||
if (alpha <= 0.01) return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.drawImage(sprite, x - size / 2, y - size / 2, size, size);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export default function ThemeTransition() {
|
||||
const setIsDark = useConfigStore((s) => s.setIsDark);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const runningRef = useRef(false);
|
||||
const directionRef = useRef<Direction>('to-dark');
|
||||
const targetDarkRef = useRef(false);
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
applyThemeClass(useConfigStore.getState().isDark);
|
||||
};
|
||||
|
||||
if (useConfigStore.persist.hasHydrated()) {
|
||||
sync();
|
||||
return;
|
||||
}
|
||||
|
||||
return useConfigStore.persist.onFinishHydration(sync);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeThemeTransition((nextDark) => {
|
||||
if (runningRef.current) return;
|
||||
if (useConfigStore.getState().isDark === nextDark) return;
|
||||
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setIsDark(nextDark);
|
||||
applyThemeClass(nextDark);
|
||||
return;
|
||||
}
|
||||
|
||||
runningRef.current = true;
|
||||
targetDarkRef.current = nextDark;
|
||||
directionRef.current = nextDark ? 'to-dark' : 'to-light';
|
||||
setSwitching(true);
|
||||
setActive(true);
|
||||
});
|
||||
}, [setIsDark]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: false, desynchronized: true });
|
||||
if (!ctx) return;
|
||||
|
||||
const toDark = directionRef.current === 'to-dark';
|
||||
const targetDark = targetDarkRef.current;
|
||||
let themeApplied = false;
|
||||
let raf = 0;
|
||||
let stopped = false;
|
||||
|
||||
const starX: number[] = [];
|
||||
const starY: number[] = [];
|
||||
const starS: number[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
starX.push(Math.random());
|
||||
starY.push(Math.random() * 0.5);
|
||||
starS.push(Math.random() > 0.7 ? 2.5 : 1.5);
|
||||
}
|
||||
|
||||
const spriteSize = 256;
|
||||
const sunSprite = createSunSprite(spriteSize);
|
||||
const moonSprite = createMoonSprite(spriteSize);
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
resize();
|
||||
|
||||
const start = performance.now();
|
||||
const total = DURATION_MS + EXIT_MS;
|
||||
|
||||
const applyTheme = () => {
|
||||
if (themeApplied) return;
|
||||
themeApplied = true;
|
||||
setIsDark(targetDark);
|
||||
applyThemeClass(targetDark);
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
applyTheme();
|
||||
runningRef.current = false;
|
||||
setSwitching(false);
|
||||
setActive(false);
|
||||
};
|
||||
|
||||
const frame = (now: number) => {
|
||||
if (stopped) return;
|
||||
|
||||
const elapsed = now - start;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const size = Math.min(w * 0.18, 120);
|
||||
|
||||
if (!themeApplied && elapsed >= 50) applyTheme();
|
||||
|
||||
const moveT = Math.min(1, elapsed / DURATION_MS);
|
||||
const e = easeInOutCubic(moveT);
|
||||
const exitT = elapsed <= DURATION_MS ? 0 : Math.min(1, (elapsed - DURATION_MS) / EXIT_MS);
|
||||
canvas.style.opacity = exitT > 0 ? String(1 - exitT) : '1';
|
||||
|
||||
drawSky(ctx, w, h, toDark ? e : 1 - e);
|
||||
|
||||
const starA = toDark ? e : 1 - e;
|
||||
if (starA > 0.02) {
|
||||
ctx.globalAlpha = starA;
|
||||
ctx.fillStyle = '#fff';
|
||||
for (let i = 0; i < starX.length; i++) {
|
||||
ctx.fillRect(starX[i] * w, starY[i] * h, starS[i], starS[i]);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
const setPos = arcPos(lerp(0.5, 1, e), w, h);
|
||||
const risePos = arcPos(lerp(0, 0.5, e), w, h);
|
||||
const setA = 1 - e * 0.92;
|
||||
const riseA = Math.min(1, e * 1.25);
|
||||
|
||||
if (toDark) {
|
||||
drawSprite(ctx, sunSprite, setPos.x, setPos.y, size, setA);
|
||||
drawSprite(ctx, moonSprite, risePos.x, risePos.y, size * 0.9, riseA);
|
||||
} else {
|
||||
drawSprite(ctx, moonSprite, setPos.x, setPos.y, size * 0.9, setA);
|
||||
drawSprite(ctx, sunSprite, risePos.x, risePos.y, size, riseA);
|
||||
}
|
||||
|
||||
if (elapsed < total) {
|
||||
raf = requestAnimationFrame(frame);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
drawSky(ctx, canvas.width, canvas.height, toDark ? 0 : 1);
|
||||
canvas.style.opacity = '1';
|
||||
raf = requestAnimationFrame(frame);
|
||||
|
||||
const onResize = () => resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
runningRef.current = false;
|
||||
setSwitching(false);
|
||||
applyThemeClass(useConfigStore.getState().isDark);
|
||||
};
|
||||
}, [active, setIsDark]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return <canvas ref={canvasRef} className="theme-transition" aria-hidden="true" />;
|
||||
}
|
||||
@@ -11,12 +11,13 @@ import { useAppConfig } from '@/components/AppConfigProvider';
|
||||
import { useConfigStore } from '@/stores';
|
||||
import Search from '../Search';
|
||||
import Rss from './components/Rss';
|
||||
import { requestThemeTransition } from '@/utils/themeTransition';
|
||||
|
||||
const itemSty = 'p-2 hover:bg-[#edf5ff] dark:hover:bg-[#4e5969] cursor-pointer ';
|
||||
|
||||
export default () => {
|
||||
const { web } = useAppConfig();
|
||||
const { isDark, setIsDark } = useConfigStore();
|
||||
const { isDark } = useConfigStore();
|
||||
const { isOpen: isSwiper, onOpen: onSwiperOpen, onClose: onSwiperClose } = useDisclosure();
|
||||
const { isOpen: isRssOpen, onOpen: onRssOpen, onClose: onRssClose } = useDisclosure();
|
||||
const onReturnTop = () => {
|
||||
@@ -26,7 +27,7 @@ export default () => {
|
||||
return (
|
||||
<>
|
||||
<div className="z-999 overflow-hidden fixed top-[70%] right-[3%] flex flex-col w-12 bg-white dark:bg-black-b border dark:border-[#4e5969] rounded-md divide-y dark:divide-[#4e5969] ">
|
||||
{isDark ? <Image src={sun.src} alt="太阳" width={46} height={46} className={itemSty} onClick={() => setIsDark(false)} /> : <Image src={moon.src} alt="月亮" width={46} height={46} className={itemSty} onClick={() => setIsDark(true)} />}
|
||||
{isDark ? <Image src={sun.src} alt="太阳" width={46} height={46} className={itemSty} onClick={() => requestThemeTransition(false)} /> : <Image src={moon.src} alt="月亮" width={46} height={46} className={itemSty} onClick={() => requestThemeTransition(true)} />}
|
||||
|
||||
<Image src={search.src} alt="搜索" width={46} height={46} className={itemSty} onClick={onSwiperOpen} />
|
||||
<Image src={rss.src} alt="订阅" width={46} height={46} className={itemSty} onClick={onRssOpen} />
|
||||
|
||||
19
src/utils/themeTransition.ts
Normal file
19
src/utils/themeTransition.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
type ThemeListener = (nextDark: boolean) => void;
|
||||
|
||||
let listener: ThemeListener | null = null;
|
||||
|
||||
export function subscribeThemeTransition(fn: ThemeListener) {
|
||||
listener = fn;
|
||||
return () => {
|
||||
if (listener === fn) listener = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 由开关调用:先播过渡动画,再真正切换主题 */
|
||||
export function requestThemeTransition(nextDark: boolean) {
|
||||
if (listener) {
|
||||
listener(nextDark);
|
||||
return;
|
||||
}
|
||||
document.documentElement.classList.toggle('dark', nextDark);
|
||||
}
|
||||
Reference in New Issue
Block a user