yggdrasil/src/theme.rs
xfy fbd706b198 style: format files and update about page content
- Update about page content

- Format code in admin pages, tags, and theme modules
2026-06-29 14:31:04 +08:00

287 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 主题(浅色 / 深色)管理。
//!
//! 提供两条初始化路径:
//! - **SSR**:从 HTTP 请求 Cookie 中的 `theme` 字段检测主题,避免首屏闪烁。
//! - **WASM 客户端**:优先读取 `localStorage` 中的持久化主题;不存在时回退到
//! `prefers-color-scheme` 媒体查询;切换时同步更新 DOM class 与 localStorage。
use dioxus::prelude::*;
// InteractionLocation 提供 client_coordinates(),用于读取鼠标点击的视口坐标。
// 该 trait 不在 dioxus::prelude(后者只 re-export events::*),需单独从 dioxus::html 引入。
// 仅 WASM 前端用到(ThemeToggle 的圆形展开动画取点击坐标),服务端构建剥离。
#[cfg(target_arch = "wasm32")]
use dioxus::html::InteractionLocation;
/// localStorage 中存储主题值的键名。
#[cfg(any(target_arch = "wasm32", test))]
const THEME_KEY: &str = "yggdrasil-theme";
/// 应用主题枚举。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Theme {
/// 浅色主题。
Light,
/// 深色主题。
Dark,
}
impl Theme {
/// 切换到相反主题。
pub fn toggle(&self) -> Self {
match self {
Theme::Light => Theme::Dark,
Theme::Dark => Theme::Light,
}
}
}
/// 检测初始主题。
///
/// 在 WASM 客户端优先读取 localStorage回退到系统颜色偏好
/// 在 SSR 阶段解析请求 Cookie否则默认浅色主题。
fn detect_initial_theme() -> Theme {
#[cfg(target_arch = "wasm32")]
{
let window = match web_sys::window() {
Some(w) => w,
None => return Theme::Light,
};
// 优先读取 localStorage 中持久化的主题值。
if let Ok(Some(storage)) = window.local_storage() {
if let Ok(Some(value)) = storage.get_item(THEME_KEY) {
return if value == "dark" {
Theme::Dark
} else {
Theme::Light
};
}
}
// 没有持久化值时,根据系统颜色偏好决定。
if let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") {
if media.matches() {
return Theme::Dark;
}
}
}
#[cfg(feature = "server")]
{
// SSR 路径:从请求 Cookie 中解析 `theme` 字段。
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
if let Some(cookie) = ctx.parts_mut().headers.get("cookie") {
if let Ok(cookie_str) = cookie.to_str() {
// 按 ';' 分割 Cookie 字符串,再按 '=' 分割键值对。
for cookie_pair in cookie_str.split(';') {
let mut parts = cookie_pair.trim().splitn(2, '=');
if let (Some(name), Some(value)) = (parts.next(), parts.next()) {
if name == "theme" && value == "dark" {
return Theme::Dark;
}
}
}
}
}
}
}
Theme::Light
}
/// 提供主题上下文的 Hook。
///
/// 初始化时按 SSR Cookie → WASM localStorage → 系统偏好的顺序检测主题;
/// 主题变化时将值持久化到 localStorage。
///
/// `<html>` 的 `dark` class 不在此处管理。WASM 端由 `ThemeToggle` 的 onclick
/// 通过 `yggdrasil-core.js` 的圆形展开动画在 View Transition 回调里同步 toggle。
/// 初始 class 由 `ThemePreload` 首屏脚本设置,避免闪烁。
pub fn use_theme_provider() -> Signal<Theme> {
let theme = use_signal(detect_initial_theme);
use_effect(move || {
let current = theme();
#[cfg(target_arch = "wasm32")]
{
if let Some(window) = web_sys::window() {
// 将当前主题持久化到 localStorage。
if let Ok(Some(storage)) = window.local_storage() {
let theme_str = match current {
Theme::Dark => "dark",
Theme::Light => "light",
};
let _ = storage.set_item(THEME_KEY, theme_str);
}
}
}
// 避免 unused 警告:非 wasm 构建下 current 未被读取。
let _ = current;
});
use_context_provider(|| theme);
theme
}
/// 读取当前主题 Signal 的 Hook。
///
/// 需在 `use_theme_provider` 之后的组件树中使用。
pub fn use_theme() -> Signal<Theme> {
use_context::<Signal<Theme>>()
}
const THEME_PRELOAD_SCRIPT: &str = r#"
(function() {
try {
var theme = localStorage.getItem('yggdrasil-theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
"#;
/// 首屏主题预加载脚本组件。
///
/// 通过内联脚本在页面渲染前读取 localStorage / 系统偏好并设置 `dark` class
/// 防止主题切换时出现闪烁。
#[component]
pub fn ThemePreload() -> Element {
rsx! {
script { dangerous_inner_html: "{THEME_PRELOAD_SCRIPT}" }
}
}
/// 主题切换按钮组件。
// evt 仅在 wasm32 用于取点击坐标,服务端构建剥离,故允许非 wasm 的 unused_variables。
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
#[component]
pub fn ThemeToggle() -> Element {
// theme 仅在非 wasmserver构建的 theme.set(next) 需要 mutwasm 端该分支
// 被 cfg 剥离,写操作转移到副本 theme_clone故 wasm 下标注 unused_mut。
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut theme = use_theme();
// generation:每次点击递增。延迟回调检查自己的 gen 是否最新,过期则跳过 set。
// 解决连续点击时多个 spawn_local 堆积导致的状态错乱。
// click_gen 仅在 wasm 分支被 set连续点击防抖server 构建剥离该分支,
// 故非 wasm 端标注 unused_mut。
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
let mut click_gen = use_signal(|| 0u32);
rsx! {
button {
class: "theme-toggle p-2 rounded-full cursor-pointer hover:text-paper-accent transition-colors duration-200 text-paper-secondary",
r#type: "button",
aria_label: "切换深色/浅色主题",
onclick: move |evt| {
let next = theme().toggle();
#[cfg(target_arch = "wasm32")]
{
let coords = evt.client_coordinates();
let x = coords.x;
let y = coords.y;
// JS 从 DOM 现状推导目标主题(不传 isDark),避免与 Signal 状态不同步。
let _ = js_sys::eval(
&format!(
"if (window.__startThemeTransition) \
window.__startThemeTransition({x}, {y});", // theme.set 推迟到动画结束:其触发的 Dioxus 微任务重渲染会打断 VT。
x = x,
y = y,
),
);
let gen = click_gen() + 1;
click_gen.set(gen);
let mut theme_clone = theme;
let gen_clone = click_gen;
wasm_bindgen_futures::spawn_local(async move {
crate::utils::time::sleep_ms(450).await;
if gen_clone() == gen {
theme_clone.set(next);
}
});
return;
}
#[cfg(not(target_arch = "wasm32"))]
{
theme.set(next);
}
},
if theme() == Theme::Dark {
svg {
xmlns: "http://www.w3.org/2000/svg",
height: "24px",
view_box: "0 -960 960 960",
width: "24px",
fill: "currentColor",
path { d: "M484-80q-84 0-157.5-32t-128-86.5Q144-253 112-326.5T80-484q0-146 93-257.5T410-880q-18 99 11 193.5T521-521q71 71 165.5 100T880-410q-26 144-138 237T484-80Zm0-80q88 0 163-44t118-121q-86-8-163-43.5T464-465q-61-61-97-138t-43-163q-77 43-120.5 118.5T160-484q0 135 94.5 229.5T484-160Zm-20-305Z" }
}
} else {
svg {
xmlns: "http://www.w3.org/2000/svg",
height: "24px",
view_box: "0 -960 960 960",
width: "24px",
fill: "currentColor",
path { d: "M440-800v-120h80v120h-80Zm0 760v-120h80v120h-80Zm360-400v-80h120v80H800Zm-760 0v-80h120v80H40Zm708-252-56-56 70-72 58 58-72 70ZM198-140l-58-58 72-70 56 56-70 72Zm564 0-70-72 56-56 72 70-58 58ZM212-692l-72-70 58-58 70 72-56 56Zm98 382q-70-70-70-170t70-170q70-70 170-70t170 70q70 70 70 170t-70 170q-70 70-170 70t-170-70Zm283.5-56.5Q640-413 640-480t-46.5-113.5Q547-640 480-640t-113.5 46.5Q320-547 320-480t46.5 113.5Q413-320 480-320t113.5-46.5ZM480-480Z" }
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toggle_switches_light_to_dark() {
assert_eq!(Theme::Light.toggle(), Theme::Dark);
}
#[test]
fn toggle_switches_dark_to_light() {
assert_eq!(Theme::Dark.toggle(), Theme::Light);
}
#[test]
fn toggle_is_an_involution() {
// 连续切换两次应当回到原始主题。
assert_eq!(Theme::Light.toggle().toggle(), Theme::Light);
assert_eq!(Theme::Dark.toggle().toggle(), Theme::Dark);
}
#[test]
fn theme_derives_equality() {
// Theme 派生了 PartialEq相同变体必须相等。
assert_eq!(Theme::Light, Theme::Light);
assert_eq!(Theme::Dark, Theme::Dark);
assert_ne!(Theme::Light, Theme::Dark);
}
#[test]
fn theme_preload_script_adds_dark_class() {
// 预加载脚本必须包含给 documentElement 添加 dark class 的逻辑。
assert!(THEME_PRELOAD_SCRIPT.contains("classList.add('dark')"));
}
#[test]
fn theme_preload_script_reads_local_storage() {
// 预加载脚本必须读取 yggdrasil-theme 键,与 THEME_KEY 保持一致。
assert!(THEME_PRELOAD_SCRIPT.contains("localStorage.getItem('yggdrasil-theme')"));
assert_eq!(THEME_KEY, "yggdrasil-theme");
}
#[test]
fn theme_preload_script_falls_back_to_prefers_color_scheme() {
// 当 localStorage 中无主题时,脚本应回退到系统颜色偏好。
assert!(THEME_PRELOAD_SCRIPT.contains("prefers-color-scheme: dark"));
}
#[test]
fn theme_preload_script_swallows_errors() {
// 预加载脚本必须包裹在 try/catch 中,避免禁用 localStorage 时抛错。
assert!(THEME_PRELOAD_SCRIPT.contains("try"));
assert!(THEME_PRELOAD_SCRIPT.contains("catch"));
}
}