import { create } from 'zustand'; /** * 自定义背景。 * * 三件事分开表达,因为它们可以组合: * - `kind` 背景来源(不设 / 预设渐变 / 自定义图片) * - `dim` 压暗强度 —— 背景越花,正文越需要一层遮罩才读得动 * - `blur` 模糊强度 —— 图片作背景时通常要虚化,否则细节会跟正文抢注意力 * * # 为什么背景不放进主题 store * * 主题(light/dark/system)是**必须全局一致**的语义:同一个界面里不能一半深色 * 一半浅色。背景是**纯装饰偏好**,可以随时关掉而不影响任何功能,而且它的取值 * 空间(预设 id / 图片数据 / 两个数值)与主题毫无关系。塞在一起会让主题 store * 承担两种生命周期的状态,也会让「跟随系统」的实现被背景字段淹没。 * * # 为什么图片要压缩后再存 * * 存 localStorage。一张手机直出照片 4–8MB,而 localStorage 配额通常只有 5MB: * 写失败会抛异常,用户看到的是「选了图片但没反应」。所以在**存入之前**先等比 * 缩到 MAX_EDGE 并转 JPEG,超限则明确拒绝并告知,而不是静默失败。 * (不使用 IndexedDB:它的异步/事务模型会把这个纯展示功能复杂化,而压缩后 * 的尺寸已经足够小。) */ export type BackgroundKind = 'none' | 'preset' | 'image'; export interface BackgroundState { kind: BackgroundKind; /** 预设 id(如 'aurora')。仅 kind === 'preset' 时有效。 */ presetId: string; /** 压缩后的 data URL。仅 kind === 'image' 时有效。 */ imageDataUrl: string; /** 压暗强度 0–80(百分比)。 */ dim: number; /** 模糊强度 0–24(px)。 */ blur: number; } export const STORAGE_KEY = 'agentmail.background'; /** 预设清单。**渐变的实际色值定义在 index.css**,这里只有 id 与显示名。 */ export const PRESETS: { id: string; label: string }[] = [ { id: 'aurora', label: '极光' }, { id: 'dusk', label: '暮色' }, { id: 'mint', label: '薄荷' }, { id: 'sand', label: '沙丘' }, { id: 'ink', label: '墨色' }, { id: 'mesh', label: '网格' } ]; export const DEFAULT_BACKGROUND: BackgroundState = { kind: 'none', presetId: 'aurora', imageDataUrl: '', dim: 24, blur: 8 }; /** 图片最长边。超过就等比缩小 —— 背景是满屏铺开的,再大也看不出来。 */ export const MAX_EDGE = 2560; /** 压缩后 data URL 的长度上限(约 2.4MB 文本),留足 localStorage 余量。 */ export const MAX_DATA_URL_BYTES = 2_400_000; export function clampDim(v: number): number { if (!Number.isFinite(v)) return DEFAULT_BACKGROUND.dim; return Math.min(80, Math.max(0, Math.round(v))); } export function clampBlur(v: number): number { if (!Number.isFinite(v)) return DEFAULT_BACKGROUND.blur; return Math.min(24, Math.max(0, Math.round(v))); } /** 归一化磁盘上可能存在的脏数据(旧版本、手改 localStorage、字段缺失)。 */ export function normalizeBackground(raw: unknown): BackgroundState { const o = (typeof raw === 'object' && raw !== null ? raw : {}) as Partial; const kind: BackgroundKind = o.kind === 'preset' || o.kind === 'image' || o.kind === 'none' ? o.kind : 'none'; const presetId = PRESETS.some(p => p.id === o.presetId) ? String(o.presetId) : DEFAULT_BACKGROUND.presetId; const imageDataUrl = typeof o.imageDataUrl === 'string' && o.imageDataUrl.startsWith('data:image/') ? o.imageDataUrl : ''; return { // 选了 image 却没有可用图片(被清理/写坏)→ 退回不设,而不是留一个空壳状态 kind: kind === 'image' && !imageDataUrl ? 'none' : kind, presetId, imageDataUrl, dim: clampDim(o.dim ?? DEFAULT_BACKGROUND.dim), blur: clampBlur(o.blur ?? DEFAULT_BACKGROUND.blur) }; } export function readStored(): BackgroundState { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return DEFAULT_BACKGROUND; return normalizeBackground(JSON.parse(raw)); } catch { // 隐私模式或脏 JSON:退回默认背景,页面照常可用 return DEFAULT_BACKGROUND; } } /** * 把背景写进 DOM。 * * 用 CSS 变量 + 一个 `data-bg` 标记,而不是给每个组件加 class: * 全站有 27 个组件,逐个改不现实,漏一处就是「一块不透明卡片浮在背景上」。 * 变量定义在 index.css,玻璃化处理也集中在那里。 */ export function applyBackground(state: BackgroundState = readStored()): void { if (typeof document === 'undefined') return; const root = document.documentElement; const active = state.kind !== 'none' && (state.kind === 'preset' || !!state.imageDataUrl); root.dataset.bg = active ? 'on' : 'off'; root.style.setProperty('--bg-dim', `${clampDim(state.dim)}%`); root.style.setProperty('--bg-blur', `${clampBlur(state.blur)}px`); if (!active) { root.style.removeProperty('--bg-image'); root.classList.remove(...PRESETS.map(p => `bg-preset-${p.id}`)); return; } root.classList.remove(...PRESETS.map(p => `bg-preset-${p.id}`)); if (state.kind === 'image') { root.style.setProperty('--bg-image', `url("${state.imageDataUrl}")`); } else { // 预设的渐变由 class 提供,避免把色值写进 JS(写死十六进制就绕过了主题变量, // 深色模式下会原样落下浅色渐变 —— 与组件里写死颜色是同一类错误)。 root.style.removeProperty('--bg-image'); root.classList.add(`bg-preset-${state.presetId}`); } } interface BackgroundStore extends BackgroundState { setKind: (kind: BackgroundKind) => void; setPreset: (presetId: string) => void; setImage: (dataUrl: string) => void; setDim: (v: number) => void; setBlur: (v: number) => void; reset: () => void; } function persist(state: BackgroundState) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch { // 配额满:本次会话仍生效,只是下次打开会退回默认值。 // 不抛给调用方 —— 背景是装饰,不该让「换背景失败」打断任何操作。 } } export const useBackgroundStore = create((set, get) => { /** 统一的提交口:先落 DOM,再持久化,最后更新 state。 */ const commit = (patch: Partial) => { const next: BackgroundState = normalizeBackground({ ...get(), ...patch }); applyBackground(next); persist(next); set(next); }; return { ...readStored(), setKind: kind => commit({ kind }), setPreset: presetId => commit({ presetId, kind: 'preset' }), setImage: imageDataUrl => commit({ imageDataUrl, kind: 'image' }), setDim: dim => commit({ dim: clampDim(dim) }), setBlur: blur => commit({ blur: clampBlur(blur) }), reset: () => commit({ ...DEFAULT_BACKGROUND }) }; }); /** * 把 disk 上的背景在首屏套用一次。 * * 不做「内联脚本防闪屏」:背景是装饰层,晚一帧出现只是不够顺滑, * 不会像主题那样闪出刺眼的白底(主题已有 index.html 的内联脚本)。 */ export function initBackground(): void { applyBackground(readStored()); } /** * 读取用户选择的图片并压缩成可存储的 data URL。 * * 失败一律返回带原因的 `{ ok: false }` 而不是抛异常 —— 调用方需要在界面上 * 说明「为什么没换成」,静默失败会让人以为按钮坏了。 */ export async function prepareImage( file: File ): Promise<{ ok: true; dataUrl: string } | { ok: false; reason: string }> { if (!file.type.startsWith('image/')) { return { ok: false, reason: '请选择图片文件' }; } // 已压缩过的上限:单张原始文件超过 20MB 就不必读了,解码本身会卡住主线程 if (file.size > 20 * 1024 * 1024) { return { ok: false, reason: '图片过大(超过 20MB),请先裁剪' }; } if (typeof document === 'undefined') { return { ok: false, reason: '当前环境不支持图片处理' }; } try { const bitmap = await loadImage(file); const { canvas, width, height } = drawScaled(bitmap, MAX_EDGE); const dataUrl = canvas.toDataURL('image/jpeg', 0.85); if (dataUrl.length > MAX_DATA_URL_BYTES) { // 缩小一档再试一次。直接拒绝会让「一张 4K 照片」这种完全正常的需求不可用。 const smaller = drawScaled(bitmap, Math.round(MAX_EDGE / 2)); const retry = smaller.canvas.toDataURL('image/jpeg', 0.78); if (retry.length > MAX_DATA_URL_BYTES) { return { ok: false, reason: '图片压缩后仍过大,请换一张更小的图片' }; } return { ok: true, dataUrl: retry }; } void width; void height; return { ok: true, dataUrl }; } catch { return { ok: false, reason: '图片读取失败,请换一张试试' }; } } function loadImage(file: File): Promise { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('decode failed')); }; img.src = url; }); } /** 等比缩放到最长边不超过 maxEdge,并画出到 canvas。 */ function drawScaled(img: HTMLImageElement, maxEdge: number) { const w = img.naturalWidth || img.width; const h = img.naturalHeight || img.height; const scale = Math.min(1, maxEdge / Math.max(w, h || 1)); const width = Math.max(1, Math.round(w * scale)); const height = Math.max(1, Math.round(h * scale)); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (ctx) { ctx.drawImage(img, 0, 0, width, height); } return { canvas, width, height }; }