feat(webui): 自定义背景 + 外观现代化;修正实心按钮白字在深色下的对比度
# 自定义背景(新功能)
三选一:不设 / 预设渐变 / 自定义图片,另加压暗与模糊两条滑杆。
**预设的色值全部复用现有调色板变量**,因此自动随主题变化 —— 那一组
(50–300)在深色下本来就是暗的(见 .dark 与 theme.test.mjs 第 19 条),
于是浅色得到柔和 pastel、深色得到低沉暗调,不需要维护两套渐变,也不会
出现「深色模式下原样落下浅色渐变」这类绕过主题变量的错误。
图片路径的关键取舍:
- **先压缩再存**。手机直出照片 4–8MB,而 localStorage 配额约 5MB,直接写会抛
异常,用户看到的是「选了图片没反应」。等比缩到最长边 2560px、转 JPEG;
仍超限则再缩一档;再不行就**明确拒绝并说明原因**(不是静默失败)。
- 失败一律返回 `{ok:false, reason}` 并渲染成 `role="alert"`。
# 背景层为什么不放进主题 store
主题(light/dark/system)是必须全局一致的语义;背景是纯装饰偏好,取值空间
与主题毫无关系。混在一起会让「跟随系统」的实现被背景字段淹没。
# 背景层实现在 CSS,不改 27 个组件
按 Tailwind 生成的实际类名统一接管:背景开启时让出不透明的页面底
(body / bg-gray-50 / bg-slate-100 → 透明),并把卡片(bg-white)与框架
(bg-chrome-800/900)变成半透明 + 背景模糊。
逐个组件加 class 必然漏 —— 漏掉的那块就是一张不透明卡片浮在背景上。
这段 CSS **刻意放在所有 @layer 之外**:它要覆盖的正是 utilities 生成的
`.bg-white`,写进 @layer components 会被 utilities 压过去(静默失效),
而分层 CSS 恒输给未分层 CSS,这是唯一稳定可靠的位置。
**chrome-600/700 刻意保持不透明**:它们不是大面板,而是导航项与 15px 的
计数徽标。真实渲染量得半透明会把徽标上的数字压到 4.46:1,低于 AA 4.5 ——
小控件的可读性优先于装饰效果(已用脚本量出,见下)。
# 「跟随系统」的可见性
三态本来就已实现(system 为默认值 + matchMedia 监听)。这次做的是让它可被
发现与信任:选择器改成分段控件(role=radiogroup + aria-checked),说明文案
写清「跟随系统会随系统的深色开关自动切换」,并保留单选按钮入口的
「当前跟随系统:深色/浅色」提示。
# 外观现代化
- **圆角整体调大一档**(默认 0.25→0.5rem)。原值是几年前的紧凑风格,
在宽屏桌面应用上偏硬。只改比例尺,200 处圆角一次性刷新,不产生
「新组件大圆角、旧组件小圆角」的断层。
- 语义化圆角令牌:`rounded-card` / `rounded-control`(数值档位答的是「多大」,
这两个名字答的是「用在哪」)。
- 自定义滚动条(桌面应用里常驻可见,系统默认样式偏旧)。
- 键盘焦点环(`:focus-visible`,仅键盘导航时出现;可访问性硬要求)。
- 交互元素统一过渡;并尊重 `prefers-reduced-motion`。
# 顺带修正两处真实问题(都由真实渲染量出,不是估算)
1. **实心按钮白字在深色下 4.46:1,低于 AA**。
深色 `--c-on-accent` 是「近白」244 246 250(为了不刺眼),而结构检查第 23
条只拿**浅色**的纯白 255 去算 → 4.83 通过。**测试存在盲区**:
同一个实心底,白字换暗一点点就越过 AA 线。导航未读徽标「12」正是这个组合。
两处都修:把第 23 条改成**两种模式的 on-accent 都算**(闭合盲区),
并把深色 on-accent 抬到 250 250 252(4.65:1,仍非纯白,保留原初衷)。
2. **theme.test.mjs 切颜色块的方式很脆**:它用 `indexOf('.dark')` 切片,于是在
:root 的注释里写一句带点的选择器写法就会把浅色块提前截断(我加注释时
真的踩到了,第 8 条假失败)。更危险的是反向情形:块被截短后变量集合变小,
「覆盖齐全」这类断言可能**真空通过**。改为所有块切分都基于**剥注释后**的文本。
# 测试
- 新增 `test/background.test.mjs`(15 条结构检查):遮罩两主题各一份、
背景层必须负 z-index(0 会盖住界面)、背景开启时必须让出页面底、
玻璃化只在 data-bg=on 下、悬停态一并接管、预设复用调色板变量、
图片上限与失败原因存在、尊重 reduced-motion 等。
- 新增 `test/stores/background.test.ts`(16 条):脏数据归一化(未知预设、
kind=image 却无图、越界数值)、CSS 变量写入与清理成对(残留 --bg-image 会
让「关掉背景」后仍显示旧图)、localStorage 抛异常不打断操作。
- 新增 `test/manual/background-verify.mjs`:连真实 Chromium 验收**渲染结果**
(背景层是否真的可见、玻璃化的计算样式、正文在背景之上是否仍达 WCAG AA、
自动模式在**不刷新**页面时跟随系统切换、显式选择不被系统覆盖)。
它拦住了上面两个真问题,也拦住了我自己两次写错的判据。
# 验证
- typecheck 干净
- 主题 30/30、背景 15/15、vitest 216/216(新增 16)
- 真实渲染验收 23/23(AGENTMAIL_DIST 注入本地构建 + 活 Gateway,未部署即验收)
- 截图对照:浅色/深色 + 极光背景,面板玻璃化与层次均符合预期
This commit is contained in:
@ -4,6 +4,7 @@ import * as api from '../api/client';
|
||||
import { LockIcon, LogoutIcon } from './icons';
|
||||
import KeyPanel from './KeyPanel';
|
||||
import ThemePicker from './ThemePicker';
|
||||
import BackgroundPicker from './BackgroundPicker';
|
||||
|
||||
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
|
||||
export default function AccountPage() {
|
||||
@ -214,8 +215,9 @@ export default function AccountPage() {
|
||||
|
||||
{/* 外观。放在密钥之后、退出之前:它是一个高频且完全可逆的偏好,
|
||||
与「账号自身」的密码/密钥属于不同性质,但同样是「我的设置」。 */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<section className="border-t border-gray-200 pt-6 space-y-6">
|
||||
<ThemePicker />
|
||||
<BackgroundPicker />
|
||||
</section>
|
||||
|
||||
{/* 退出登录。
|
||||
|
||||
241
client/electron/src/components/BackgroundPicker.tsx
Normal file
241
client/electron/src/components/BackgroundPicker.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
DEFAULT_BACKGROUND,
|
||||
MAX_DATA_URL_BYTES,
|
||||
PRESETS,
|
||||
prepareImage,
|
||||
useBackgroundStore,
|
||||
type BackgroundKind
|
||||
} from '../stores/backgroundStore';
|
||||
import { UploadIcon, TrashIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 自定义背景设置。
|
||||
*
|
||||
* 三条设计约束:
|
||||
*
|
||||
* 1. **预览就是真实效果**。预设缩略图直接复用 `.bg-preset-*` 类(与全屏背景
|
||||
* 同一份 CSS),不是另画一张示意图 —— 否则预览与结果必然会漂移。
|
||||
* 2. **不让用户交出控制权**。背景会直接影响正文对比度,所以「压暗」与「模糊」
|
||||
* 都给显式滑杆,而不是自动选一个值。默认值取偏保守的一侧。
|
||||
* 3. **失败要出声**。图片过大/解码失败时给出具体原因;静默失败会让人以为
|
||||
* 按钮坏了(本仓库在权限按钮上踩过同类问题)。
|
||||
*/
|
||||
export default function BackgroundPicker() {
|
||||
const kind = useBackgroundStore(s => s.kind);
|
||||
const presetId = useBackgroundStore(s => s.presetId);
|
||||
const imageDataUrl = useBackgroundStore(s => s.imageDataUrl);
|
||||
const dim = useBackgroundStore(s => s.dim);
|
||||
const blur = useBackgroundStore(s => s.blur);
|
||||
const setKind = useBackgroundStore(s => s.setKind);
|
||||
const setPreset = useBackgroundStore(s => s.setPreset);
|
||||
const setImage = useBackgroundStore(s => s.setImage);
|
||||
const setDim = useBackgroundStore(s => s.setDim);
|
||||
const setBlur = useBackgroundStore(s => s.setBlur);
|
||||
const reset = useBackgroundStore(s => s.reset);
|
||||
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const active = kind !== 'none';
|
||||
|
||||
const pick = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
const result = await prepareImage(file);
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
setImage(result.dataUrl);
|
||||
} else {
|
||||
setError(result.reason);
|
||||
}
|
||||
};
|
||||
|
||||
const optionClass = (on: boolean) =>
|
||||
`px-3 py-2.5 rounded-control border text-xs flex flex-col items-center gap-1.5 transition-colors ${
|
||||
on
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-sm font-medium text-gray-900">背景</h3>
|
||||
{active && (
|
||||
<button
|
||||
onClick={reset}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 inline-flex items-center gap-1"
|
||||
>
|
||||
<TrashIcon className="w-3 h-3" />
|
||||
恢复默认
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
背景会显示在卡片后面,两种主题各有对应色调。
|
||||
</p>
|
||||
|
||||
{/* 三选一:不设 / 预设 / 自定义图片。与主题选择器同一套控件形态,
|
||||
让「外观」这一组看起来是一件事。 */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
{(
|
||||
[
|
||||
{ value: 'none', label: '无' },
|
||||
{ value: 'preset', label: '预设' },
|
||||
{ value: 'image', label: '图片' }
|
||||
] as { value: BackgroundKind; label: string }[]
|
||||
).map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() => {
|
||||
setError('');
|
||||
setKind(o.value);
|
||||
}}
|
||||
className={optionClass(kind === o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{kind === 'preset' && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{PRESETS.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setPreset(p.id)}
|
||||
title={p.label}
|
||||
className={`h-14 rounded-control border overflow-hidden relative transition-transform hover:scale-[1.02] ${
|
||||
presetId === p.id ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{/* 用与全屏背景同一份 CSS 变量渲染缩略图 —— 预览必然等于结果 */}
|
||||
<span
|
||||
className={`bg-preset-${p.id} absolute inset-0 block`}
|
||||
style={{ backgroundImage: 'var(--bg-image)', backgroundSize: 'cover' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="absolute bottom-0 inset-x-0 text-[10px] py-0.5 bg-black/45 text-white">
|
||||
{p.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'image' && (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
void pick(e.target.files?.[0]);
|
||||
// 清空 value:否则连续选同一张图不会再触发 change
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
className="w-full px-3 py-2.5 rounded-control border border-gray-300 bg-white text-xs text-gray-700 hover:bg-gray-50 inline-flex items-center justify-center gap-2 disabled:opacity-60"
|
||||
>
|
||||
<UploadIcon className="w-3.5 h-3.5" />
|
||||
{busy ? '处理中…' : imageDataUrl ? '更换图片' : '选择图片'}
|
||||
</button>
|
||||
{imageDataUrl && (
|
||||
<div className="h-20 rounded-control border border-gray-300 overflow-hidden">
|
||||
<img src={imageDataUrl} alt="背景预览" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-500">
|
||||
图片会等比缩放到最长边 2560px 后保存在本机(上限约 2.4MB)。
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-[11px] text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 只有真的启用了背景才显示这两条 —— 背景为「无」时它们没有任何作用,
|
||||
摆在界面上只会让人疑惑「调了为什么没变化」。 */}
|
||||
{active && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<Slider
|
||||
label="压暗"
|
||||
hint="背景越花,正文越需要一层遮罩才读得动"
|
||||
value={dim}
|
||||
min={0}
|
||||
max={80}
|
||||
suffix="%"
|
||||
onChange={setDim}
|
||||
/>
|
||||
<Slider
|
||||
label="模糊"
|
||||
hint="虚化细节,避免背景与正文抢注意力"
|
||||
value={blur}
|
||||
min={0}
|
||||
max={24}
|
||||
suffix="px"
|
||||
onChange={setBlur}
|
||||
/>
|
||||
{/* 配额保护的下限提示:告诉用户上限是怎么来的,而不是神秘失败 */}
|
||||
<p className="text-[11px] text-gray-500">
|
||||
当前背景数据约 {Math.round((imageDataUrl.length || 1) / 1024)}KB,上限{' '}
|
||||
{Math.round(MAX_DATA_URL_BYTES / 1024)}KB。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 带数值显示的滑杆。input[type=range] 的原生外观各平台差异很大,这里统一掉。 */
|
||||
function Slider({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
suffix,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
suffix: string;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="flex items-center justify-between text-xs text-gray-700">
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="text-gray-500 tabular-nums">
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
className="w-full mt-1 accent-blue-600"
|
||||
aria-label={label}
|
||||
/>
|
||||
<span className="block text-[11px] text-gray-500">{hint}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** 「恢复默认」用到,导出以便测试断言默认值形状。 */
|
||||
export { DEFAULT_BACKGROUND };
|
||||
@ -50,29 +50,45 @@ export default function ThemePicker() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-900">外观</h3>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-sm font-medium text-gray-900">主题</h3>
|
||||
{pref === 'system' && (
|
||||
<span className="text-xs text-gray-500">
|
||||
当前跟随系统:{resolved === 'dark' ? '深色' : '浅色'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
「跟随系统」会随系统的深色开关自动切换,不选它就固定在一种外观。
|
||||
</p>
|
||||
|
||||
{/*
|
||||
分段控件而不是三个独立卡片。
|
||||
|
||||
三选一的语义是「三种互斥的偏好」,分段控件正好表达这个;独立卡片看起来
|
||||
像可多选。选中态用整块底色(而非仅描边),在深色下也比描边更容易辨认。
|
||||
*/}
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="主题偏好"
|
||||
className="inline-flex p-1 rounded-control bg-gray-100 border border-gray-200"
|
||||
>
|
||||
{OPTIONS.map(o => {
|
||||
const active = pref === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => setPref(o.value)}
|
||||
title={o.hint}
|
||||
className={`px-3 py-2.5 rounded border text-xs flex flex-col items-center gap-1.5 transition-colors ${
|
||||
className={`px-3 py-1.5 rounded-md text-xs inline-flex items-center gap-1.5 transition-colors ${
|
||||
active
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
|
||||
? 'bg-white text-gray-900 shadow-sm border border-gray-200'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<o.Icon className="w-4 h-4" />
|
||||
<o.Icon className="w-3.5 h-3.5" />
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
|
||||
@ -197,6 +197,51 @@
|
||||
--s-yellow-700: 161 98 7;
|
||||
--s-yellow-800: 133 77 14;
|
||||
--s-yellow-900: 113 63 18;
|
||||
|
||||
/*
|
||||
* ─── 背景层 ───
|
||||
*
|
||||
* 自定义背景是**装饰层**,它的取值空间与主题色板无关,所以不复用 --c-*:
|
||||
* --c-* 是要被主题反转的语义色,而这里只是「叠在图片/渐变上的一层遮罩」。
|
||||
*
|
||||
* --bg-scrim 是遮罩颜色:浅色下用白(把花哨的图案洗淡),
|
||||
* 深色下用黑(压暗)。两者都在 .dark 里换值。
|
||||
* --bg-dim 与 --bg-blur 由 backgroundStore 在运行时写入,这里是初值。
|
||||
*/
|
||||
--bg-scrim: 255 255 255;
|
||||
--bg-dim: 24%;
|
||||
--bg-blur: 8px;
|
||||
/* 玻璃面板的不透明度:背景越花,面板需要越实才读得动。 */
|
||||
--bg-glass: 0.82;
|
||||
|
||||
/*
|
||||
* ─── 尺寸与动效 ───
|
||||
*
|
||||
* 提取成变量的理由与颜色相同:一处定义、全站一致。写死数值时「圆角"
|
||||
* 会随组件作者的随手一写而漂移(本仓库曾同时存在 4 种圆角),
|
||||
* 而动效时长不一致会让界面显得格崩。
|
||||
*/
|
||||
--radius-card: 0.875rem;
|
||||
--radius-control: 0.5rem;
|
||||
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--dur-fast: 120ms;
|
||||
--dur-base: 180ms;
|
||||
|
||||
/*
|
||||
* 抬高阴影。
|
||||
*
|
||||
* 浅色下靠阴影表达层次;深色下黑色阴影几乎看不见,层次改由更亮的边框
|
||||
* 与表层面亮度差承担 —— 因此深色主题里换一整组值(见下),
|
||||
* 而不是继续加深同一个阴影。
|
||||
*
|
||||
* 注:本注释刻意不写出深色选择器的字面量 —— theme.test.mjs 用
|
||||
* indexOf(选择器) 切分两个颜色块,在 :root 的注释里出现那个字符串
|
||||
* 会把它提前截断(已踩过一次)。该测试现已先剥注释,但这里仍避开。
|
||||
*/
|
||||
--shadow-1: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.1);
|
||||
--shadow-2: 0 2px 4px rgb(15 23 42 / 0.05), 0 4px 12px rgb(15 23 42 / 0.1);
|
||||
--shadow-3: 0 8px 24px rgb(15 23 42 / 0.12), 0 2px 6px rgb(15 23 42 / 0.08);
|
||||
--hairline: 0 0 0;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@ -226,8 +271,15 @@
|
||||
.dark {
|
||||
--c-white: 24 27 33;
|
||||
|
||||
/* 近白而非纯白:深色页面上纯白字偏刺眼。关键是它不跟着 --c-white 变暗。 */
|
||||
--c-on-accent: 244 246 250;
|
||||
/*
|
||||
* 近白而非纯白:深色页面上纯白字偏刺眼。
|
||||
*
|
||||
* 但它同时是**实心彩底按钮上的文字**,而那儿的对比度是硬指标:实测 244
|
||||
* 时「红底白字」只有 4.46:1,低于 WCAG AA 的 4.5(真实渲染量出,见
|
||||
* test/manual/background-verify.mjs)。抬到 250 后为 4.65:1,
|
||||
* 仍不是纯白,观感上仍保留了「不刺眼」的初衷。
|
||||
*/
|
||||
--c-on-accent: 250 250 252;
|
||||
|
||||
--c-gray-50: 17 19 24;
|
||||
--c-gray-100: 32 36 44;
|
||||
@ -254,6 +306,23 @@
|
||||
--c-chrome-800: 30 35 44;
|
||||
--c-chrome-900: 12 14 18;
|
||||
|
||||
/*
|
||||
* 背景遮罩换成黑:浅色下用白把图案洗淡,深色下必须压暗,
|
||||
* 否则一张浅色照片会在深色界面里舗成一块亮斑,正文完全读不动。
|
||||
*/
|
||||
--bg-scrim: 0 0 0;
|
||||
/* 深色下面板要更实:背景亮部与深色卡片对比过强时,文字会显得发灰。 */
|
||||
--bg-glass: 0.86;
|
||||
|
||||
/*
|
||||
* 深色下的层次靠「边框亮于底」而不是阴影。
|
||||
* 保留一行极淡的黑色阴影只为了与浅色统一接管机制,真正的边界感来自 --hairline。
|
||||
*/
|
||||
--shadow-1: 0 1px 2px rgb(0 0 0 / 0.4);
|
||||
--shadow-2: 0 2px 6px rgb(0 0 0 / 0.45);
|
||||
--shadow-3: 0 10px 30px rgb(0 0 0 / 0.55);
|
||||
--hairline: 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
|
||||
/*
|
||||
* ─── 强调色(深色)───
|
||||
*
|
||||
@ -492,3 +561,223 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
* 自定义背景 + 现代化打磨
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* # 为什么这一段放在所有 @layer 之外
|
||||
*
|
||||
* Tailwind 的层序是 base < components < utilities,而这里要覆盖的正是
|
||||
* **utilities 生成的** `.bg-white` / `.bg-gray-50`。写进 @layer components
|
||||
* 会被 utilities 压过去(静默失效,只有肉眼能看出「背景没生效」);
|
||||
* 写进 @layer utilities 则取决于 Tailwind 内部的合并顺序,不可靠。
|
||||
* 未分层的 CSS 在层叠中恒胜过分层 CSS —— 这是唯一稳定可靠的位置。
|
||||
*/
|
||||
|
||||
/* ─── 背景层本体 ─── */
|
||||
|
||||
.app-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/*
|
||||
* 必须是**负** z-index。
|
||||
*
|
||||
* 层叠顺序里 z-index:0/auto 的定位元素画在常规流内容**之上**,用它会把
|
||||
* 整个界面盖住(背板是 fixed 全屏的,连点击区都会挡住 —— 虽然有
|
||||
* pointer-events:none 兵底,但视觉上完全遮住)。负值才画在常规流内容
|
||||
* 之下、同时在父元素背景之上,正是「缓景」该在的位置。
|
||||
*
|
||||
* 它能露出来还依赖另一条:背景开启时把所有不透明的页面底
|
||||
* (bg-gray-50 / bg-slate-100 / body)改成透明,见下面。
|
||||
*/
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
/* 默认不可见;背景开启时才铺开(data-bg 由 backgroundStore 写在 <html> 上)。 */
|
||||
background-image: var(--bg-image, none);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(var(--bg-blur));
|
||||
/* 模糊会把边缘拖进画面,放大 2% 抵消。 */
|
||||
transform: scale(1.02);
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-base) var(--ease-out-soft);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
html[data-bg='on'] .app-backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* 遮罩:把图案压下去,让正文读得动。
|
||||
*
|
||||
* 用单独一层而不是直接调低 background 的透明度:透明度会让背景变淡但仍与
|
||||
* 正文争夺对比度,而遮罩是**在两者之间插一层**,颜色随主题反转(浅色用白、
|
||||
* 深色用黑),因此两种模式下都是「背景退后、内容在前」。
|
||||
*/
|
||||
.app-backdrop::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: rgb(var(--bg-scrim) / var(--bg-dim));
|
||||
}
|
||||
|
||||
/*
|
||||
* ─── 预设渐变 ───
|
||||
*
|
||||
* 全部用现有调色板的**表面段**(50–300)拼出来,因此:
|
||||
* - 无需新增任何写死的十六进制值
|
||||
* - 自动随主题变化 —— 那一段在深色下本来就是暗的(见 index.css 的 .dark
|
||||
* 与 theme.test.mjs 第 19 条),浅色得到柔和pastel、深色得到低沉暗调
|
||||
*/
|
||||
.bg-preset-aurora {
|
||||
--bg-image: radial-gradient(at 18% 22%, rgb(var(--c-blue-200)) 0%, transparent 55%),
|
||||
radial-gradient(at 82% 12%, rgb(var(--c-green-100)) 0%, transparent 50%),
|
||||
radial-gradient(at 68% 82%, rgb(var(--c-blue-100)) 0%, transparent 55%);
|
||||
}
|
||||
.bg-preset-dusk {
|
||||
--bg-image: radial-gradient(at 12% 80%, rgb(var(--c-orange-100)) 0%, transparent 55%),
|
||||
radial-gradient(at 85% 25%, rgb(var(--c-blue-200)) 0%, transparent 55%);
|
||||
}
|
||||
.bg-preset-mint {
|
||||
--bg-image: radial-gradient(at 25% 30%, rgb(var(--c-green-100)) 0%, transparent 55%),
|
||||
radial-gradient(at 78% 70%, rgb(var(--c-blue-100)) 0%, transparent 55%);
|
||||
}
|
||||
.bg-preset-sand {
|
||||
--bg-image: radial-gradient(at 20% 25%, rgb(var(--c-amber-100)) 0%, transparent 60%),
|
||||
radial-gradient(at 80% 75%, rgb(var(--c-orange-100)) 0%, transparent 55%);
|
||||
}
|
||||
.bg-preset-ink {
|
||||
--bg-image: radial-gradient(at 30% 20%, rgb(var(--c-gray-200)) 0%, transparent 60%),
|
||||
linear-gradient(160deg, rgb(var(--c-gray-100)), rgb(var(--c-gray-200)));
|
||||
}
|
||||
.bg-preset-mesh {
|
||||
--bg-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgb(var(--c-gray-200) / 0.55) 0 1px,
|
||||
transparent 1px 28px
|
||||
),
|
||||
repeating-linear-gradient(90deg, rgb(var(--c-gray-200) / 0.55) 0 1px, transparent 1px 28px);
|
||||
}
|
||||
|
||||
/*
|
||||
* ─── 背景开启时的表面处理 ───
|
||||
*
|
||||
* 不改 27 个组件的 class:背景是全局装饰,逐个组件加 class 必然漏(漏掉的那块
|
||||
* 就是一张不透明卡片浮在背景上)。这里按 Tailwind 生成的实际类名统一接管。
|
||||
*
|
||||
* 被接管的是三类:
|
||||
* - 页面底(bg-gray-50 / bg-slate-100)→ 完全透明,让出背景
|
||||
* - 卡片/面板(bg-white) → 半透明 + 背景模糊(玻璃)
|
||||
* - 应用框架(bg-chrome-*) → 半透明,保持「框架比内容沉」
|
||||
*/
|
||||
html[data-bg='on'] body,
|
||||
html[data-bg='on'] .bg-gray-50,
|
||||
html[data-bg='on'] .bg-slate-100 {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
html[data-bg='on'] .bg-white {
|
||||
background-color: rgb(var(--c-white) / var(--bg-glass));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
}
|
||||
|
||||
/* 悬停态也要接管:不接管的话鼠标一进面板就会闪成不透明(明显跳动)。 */
|
||||
html[data-bg='on'] .hover\:bg-gray-50:hover,
|
||||
html[data-bg='on'] .hover\:bg-gray-100:hover {
|
||||
background-color: rgb(var(--c-gray-100) / var(--bg-glass));
|
||||
}
|
||||
|
||||
html[data-bg='on'] .bg-chrome-900 {
|
||||
background-color: rgb(var(--c-chrome-900) / 0.86);
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
}
|
||||
html[data-bg='on'] .bg-chrome-800 {
|
||||
background-color: rgb(var(--c-chrome-800) / 0.82);
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
}
|
||||
/*
|
||||
* chrome-600/700 **刻意保持不透明**。
|
||||
*
|
||||
* 它们不是大面板,而是导航项与 15px 的计数徽标(如「收件 12」)。给它们加
|
||||
* 透明度有两个代价:一是看不出背景(本来就太小),二是**正文对比度被拉低**
|
||||
* ——实测徽标上的数字从原值降到 4.46:1,低于 WCAG AA 的 4.5(真实渲染量得,
|
||||
* 不是估算)。小控件的可读性优先于装饰效果。
|
||||
*/
|
||||
|
||||
/*
|
||||
* ─── 现代化打磨 ───
|
||||
*/
|
||||
|
||||
/* 滚动条:桌面应用里它常驻可见,系统默认样式(尤其窄屏上的粗条)很旧。 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(var(--c-gray-300)) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(var(--c-gray-300));
|
||||
border-radius: 9999px;
|
||||
/* 透明边框 + background-clip:让滑块比轨道窄,不贴边,观感更轻。 */
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(var(--c-gray-400));
|
||||
}
|
||||
.dark * {
|
||||
scrollbar-color: rgb(var(--c-gray-600)) transparent;
|
||||
}
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(var(--c-gray-600));
|
||||
}
|
||||
.dark *::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(var(--c-gray-500));
|
||||
}
|
||||
|
||||
/*
|
||||
* 键盘焦点环。
|
||||
*
|
||||
* 只在**键盘**导航时出现(:focus-visible),鼠标点击不画 —— 常驻焦点框会让
|
||||
* 界面显得脏。这也是可访问性的硬要求:没有可见焦点,键盘用户无法定位。
|
||||
*/
|
||||
:focus-visible {
|
||||
outline: 2px solid rgb(var(--c-blue-500));
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* 交互元素统一过渡;只过渡颜色类属性,避免布局抖动。 */
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
[role='button'] {
|
||||
transition: background-color var(--dur-fast) var(--ease-out-soft),
|
||||
border-color var(--dur-fast) var(--ease-out-soft),
|
||||
color var(--dur-fast) var(--ease-out-soft), box-shadow var(--dur-base) var(--ease-out-soft);
|
||||
}
|
||||
|
||||
/* 尊重系统的「减弱动态效果」:前庭功能障碍者会因动画不适。 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { initTheme } from './stores/themeStore';
|
||||
import { initBackground } from './stores/backgroundStore';
|
||||
import './index.css';
|
||||
|
||||
// 必须在 render 之前:晚一步就会让深色偏好的用户看到一帧白色闪屏。
|
||||
@ -9,6 +10,11 @@ import './index.css';
|
||||
// 这里做的是把 store 状态与 DOM 对齐并订阅系统主题变化。
|
||||
initTheme();
|
||||
|
||||
// 自定义背景同样要在 render 之前套用(读 localStorage + 写 CSS 变量)。
|
||||
// 它只是装饰层,晚一帧不会像主题那样闪出刺眼白底,但提前套用能避免
|
||||
// 「先看到纯色底、再变成背景」的抽动。
|
||||
initBackground();
|
||||
|
||||
/**
|
||||
* 用 Visual Viewport 驱动应用高度。
|
||||
*
|
||||
@ -39,6 +45,15 @@ document.addEventListener('focusout', () => requestAnimationFrame(syncEditingSta
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
{/*
|
||||
背景层挂在 App **之外**。
|
||||
|
||||
挂在 App 里面的话,它只会存在于主界面的那几个分支上 —— 登录页、加载页、
|
||||
初始化向导都各自 return 自己的外层 div。背景是全局装饰,用户不该
|
||||
「一进登录页背景就没了」。它 position:fixed + z-index:-1,与 App 的
|
||||
布局无关,放这里最稳。
|
||||
*/}
|
||||
<div className="app-backdrop" aria-hidden="true" />
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
259
client/electron/src/stores/backgroundStore.ts
Normal file
259
client/electron/src/stores/backgroundStore.ts
Normal file
@ -0,0 +1,259 @@
|
||||
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<BackgroundState>;
|
||||
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<BackgroundStore>((set, get) => {
|
||||
/** 统一的提交口:先落 DOM,再持久化,最后更新 state。 */
|
||||
const commit = (patch: Partial<BackgroundState>) => {
|
||||
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<HTMLImageElement> {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user