feat: M5 背景图上传/压缩+智能取色(FR-A8/A9,smart-color 下沉 shared,canvas 采样→analyzeImage 推导强调色/模糊/表面不透明尊重手动项,持久化+重开恢复)

This commit is contained in:
hunter-dev
2026-08-26 09:20:45 +08:00
parent 545bc38370
commit 81b410e281
11 changed files with 222 additions and 96 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ export function registerIpc(vault: VaultService) {
});
ipcMain.handle(IPC.appearanceLoad, async () => vault.getAppearance());
ipcMain.handle(IPC.appearanceSave, async (_e, p: { themeId: string; params: Record<string, unknown>; effect: string }) => { await vault.saveAppearance(p); });
ipcMain.handle(IPC.appearanceSave, async (_e, p: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }) => { await vault.saveAppearance(p); });
ipcMain.handle(IPC.keymapLoad, async () => vault.getKeymaps());
ipcMain.handle(IPC.keymapSave, async (_e, p: { id: string; combo: string }[]) => { await vault.saveKeymaps(p); });
+2 -81
View File
@@ -1,81 +1,2 @@
// 智能取色:分析背景图亮度/主色 → 推荐深浅模式与强调色(FR-A9)
// 纯函数,输入采样后的像素数据,输出推荐参数
export interface ImageSample {
/** 采样像素的 [r,g,b] 列表(已降采样) */
pixels: [number, number, number][];
}
export interface SmartColorResult {
mode: 'dark' | 'light';
accentHue: number; // 0-360,约束在冷色/紫红区间
accent: string; // hex 强调色
suggestedBgOpacity: number; // 0-1
suggestedBlur: number; // px
suggestedSurfaceOpacity: number; // 0-1
}
// 相对亮度(Rec.709)
function luminance(r: number, g: number, b: number): number {
const f = (c: number) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0; const l = (max + min) / 2; const d = max - min; const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
if (d !== 0) {
switch (max) {
case r: h = ((g - b) / d) % 6; break;
case g: h = (b - r) / d + 2; break;
default: h = (r - g) / d + 4;
}
h *= 60; if (h < 0) h += 360;
}
return [h, s, l];
}
function hslToHex(h: number, s: number, l: number): string {
const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = l - c / 2;
let r = 0, g = 0, b = 0;
if (h < 60) [r, g, b] = [c, x, 0]; else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x]; else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x];
const to = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0');
return `#${to(r)}${to(g)}${to(b)}`;
}
// 色相约束:冷色(180-270) 与紫红(280-330) 区间,否则就近映射
function constrainHue(h: number): number {
if (h >= 180 && h <= 330) return h;
// 映射到最近区间端点
if (h < 180) return h < 90 ? 200 : 210; // 暖色偏冷蓝
return 290; // >330 映射紫红
}
export function analyzeImage(sample: ImageSample): SmartColorResult {
const { pixels } = sample;
if (pixels.length === 0) {
return { mode: 'dark', accentHue: 210, accent: '#4a9eff', suggestedBgOpacity: 0.6, suggestedBlur: 20, suggestedSurfaceOpacity: 0.5 };
}
// 平均亮度
const avgLum = pixels.reduce((s, p) => s + luminance(p[0], p[1], p[2]), 0) / pixels.length;
const mode: 'dark' | 'light' = avgLum < 0.5 ? 'dark' : 'light';
// 主色相:色相直方图(12 桶),取高频且饱和度足够
const buckets = new Array(12).fill(0);
let dominantHue = 210;
for (const p of pixels) {
const [h, s] = rgbToHsl(p[0], p[1], p[2]);
if (s > 0.15) buckets[Math.floor(h / 30) % 12]++;
}
let max = 0;
for (let i = 0; i < 12; i++) if (buckets[i] > max) { max = buckets[i]; dominantHue = i * 30 + 15; }
const accentHue = constrainHue(dominantHue);
const accent = hslToHex(accentHue, 0.7, mode === 'dark' ? 0.6 : 0.45);
// 依亮度推导不透明度
const suggestedBgOpacity = Math.round(Math.max(0.3, Math.min(0.8, 0.5 + (0.5 - avgLum) * 0.4)) * 100) / 100;
const suggestedBlur = avgLum < 0.4 ? 24 : avgLum > 0.7 ? 12 : 18;
const suggestedSurfaceOpacity = mode === 'dark' ? 0.5 : 0.7;
return { mode, accentHue, accent, suggestedBgOpacity, suggestedBlur, suggestedSurfaceOpacity };
}
// 智能取色纯函数已下沉到 @hunter/shared;此处保留薄层 re-export(兼容历史引用)
export { analyzeImage, type ImageSample, type SmartColorResult } from '@hunter/shared';
@@ -6,13 +6,13 @@ import { RECENT_VAULT_MAX } from '@hunter/shared';
export class ConfigStore {
private file: string;
private cache: { vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string }; keymaps?: { id: string; combo: string }[] } | null = null;
private cache: { vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }; keymaps?: { id: string; combo: string }[] } | null = null;
constructor(userDataDir: string) {
this.file = nodePath.join(userDataDir, 'config.json');
}
private async read(): Promise<{ vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string }; keymaps?: { id: string; combo: string }[] }> {
private async read(): Promise<{ vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }; keymaps?: { id: string; combo: string }[] }> {
if (this.cache) return this.cache;
try {
const raw = await fs.readFile(this.file, 'utf8');
@@ -46,11 +46,11 @@ export class ConfigStore {
return meta;
}
async getAppearance(): Promise<{ themeId: string; params: Record<string, unknown>; effect: string } | null> {
async getAppearance(): Promise<{ themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null } | null> {
const c = await this.read();
return c.appearance ?? null;
}
async saveAppearance(data: { themeId: string; params: Record<string, unknown>; effect: string }): Promise<void> {
async saveAppearance(data: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }): Promise<void> {
(await this.read()).appearance = data;
await this.write();
}
@@ -51,7 +51,7 @@ export class VaultService {
async setDefaultRoot(root: string): Promise<void> { await this.config.setDefaultRoot(root); }
getAppearance() { return this.config.getAppearance(); }
saveAppearance(data: { themeId: string; params: Record<string, unknown>; effect: string }) { return this.config.saveAppearance(data); }
saveAppearance(data: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }) { return this.config.saveAppearance(data); }
getKeymaps() { return this.config.getKeymaps(); }
saveKeymaps(data: { id: string; combo: string }[]) { return this.config.saveKeymaps(data); }
}
+4 -1
View File
@@ -43,7 +43,9 @@ export function App() {
// 编辑器样式随设置生效(FR-编辑器设置真实生效)
useEffect(() => { document.documentElement.style.setProperty('--editor-font-size', settings.fontSize + 'px'); document.documentElement.style.setProperty('--editor-font-family', settings.fontFamily); document.documentElement.style.setProperty('--editor-white-space', settings.wordWrap ? 'pre-wrap' : 'pre'); }, [settings.fontSize, settings.fontFamily, settings.wordWrap]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', background: 'var(--bg-primary, #1a1a1a)', color: '#ddd' }}>
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100vh', background: 'var(--bg-primary, #1a1a1a)', color: '#ddd' }}>
{appearance.bgImage && <div style={{ position: 'absolute', inset: 0, backgroundImage: 'url(' + appearance.bgImage + ')', backgroundSize: 'cover', backgroundPosition: 'center', filter: 'blur(var(--blur-px,0px))', opacity: 0.45, zIndex: 0, pointerEvents: 'none' }} />}
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', height: '100vh' }}>
<TitleBar view={view} onToggleView={() => setView(v => v === 'editor' ? 'graph' : 'editor')} onSettings={() => setShowSettings(true)} onAppearance={() => setShowAppearance(true)} onToggleSidebar={() => layout.toggleSidebar()} onToggleMinimal={() => layout.toggleMinimal()} minimal={layout.minimal} />
<Toolbar />
{view === 'editor' && <TabBar />}
@@ -76,6 +78,7 @@ export function App() {
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
{showAppearance && <AppearancePanel onClose={() => setShowAppearance(false)} />}
{showCheat && <CheatSheet onClose={() => setShowCheat(false)} />}
</div>
</div>
);
}
@@ -1,3 +1,4 @@
import { useRef } from 'react';
import { useAppearance } from './appearance-store';
import { THEMES } from './themes';
import type { ThemeDef } from './themes';
@@ -6,7 +7,8 @@ import type { EffectType } from '../effects/effect-engine';
const EFFECTS: [EffectType, string][] = [['off','关闭'],['auto','智能'],['particle','微粒'],['meteor','流星'],['aurora','极光'],['ripple','涟漪']];
export function AppearancePanel({ onClose }: { onClose: () => void }) {
const { themeId, draft, effect, selectTheme, override, setEffect, save, revert, isDirty } = useAppearance();
const { themeId, draft, effect, bgImage, selectTheme, override, setEffect, setBgImage, save, revert, isDirty } = useAppearance();
const fileRef = useRef<HTMLInputElement>(null);
const colors: [keyof typeof draft, string][] = [['accent','强调色'],['glow','辉光色'],['accentText','强调色文字'],['textPrimary','主要文字'],['textSecondary','辅助文字'],['textTertiary','弱化文字'],['controlBorder','控件边框'],['bgPrimary','工作区背景'],['bgSecondary','浮层背景']];
return (
<div onClick={e => { if (e.target === e.currentTarget) { revert(); onClose(); } }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1500 }}>
@@ -37,6 +39,13 @@ export function AppearancePanel({ onClose }: { onClose: () => void }) {
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>背景模糊 {draft.blurPx}px<input type="range" min={0} max={40} value={draft.blurPx} onChange={e => override('blurPx', +e.target.value)} /></label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>表面不透明 {Math.round(draft.surfaceOpacity*100)}%<input type="range" min={20} max={100} value={draft.surfaceOpacity*100} onChange={e => override('surfaceOpacity', +e.target.value/100)} /></label>
</div>
<div style={{ opacity: 0.5, fontSize: 12, marginBottom: 6 }}>背景图(上传后智能取色 FR-A8/A9)</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) compressImage(f).then(d => setBgImage(d)); e.target.value = ''; }} />
<button onClick={() => fileRef.current?.click()} style={{ ...btn }}>选择图片</button>
{bgImage && <><img src={bgImage} alt="bg" style={{ width: 64, height: 40, objectFit: 'cover', borderRadius: 4, border: '1px solid #3a3a4a' }} /><button onClick={() => setBgImage(null)} style={{ ...btn }}>移除</button></>}
{!bgImage && <span style={{ fontSize: 12, color: '#666' }}>未设置</span>}
</div>
<div style={{ opacity: 0.5, fontSize: 12, marginBottom: 6 }}>鼠标特效</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6, marginBottom: 16 }}>
{EFFECTS.map(([t, label]) => (
@@ -49,6 +58,16 @@ export function AppearancePanel({ onClose }: { onClose: () => void }) {
);
}
// 压缩背景图:限宽 1600、JPEG 0.82,避免 config.json 过大
async function compressImage(file: File, maxW = 1600, quality = 0.82): Promise<string> {
const dataUrl = await new Promise<string>((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result as string); r.onerror = rej; r.readAsDataURL(file); });
const img = await new Promise<HTMLImageElement>((res, rej) => { const i = new Image(); i.onload = () => res(i); i.onerror = rej; i.src = dataUrl; });
const scale = Math.min(1, maxW / img.width);
const c = document.createElement('canvas'); c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale);
const ctx = c.getContext('2d')!; ctx.drawImage(img, 0, 0, c.width, c.height);
return c.toDataURL('image/jpeg', quality);
}
function normalizeColor(c: string): string {
// 'transparent' 或 rgba(...) 给取色器一个占位 hex
if (c === 'transparent' || c.startsWith('rgba')) return '#1a1a2a';
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import { IPC } from '@hunter/shared';
import { IPC, analyzeImage, type ImageSample } from '@hunter/shared';
import { THEMES, applyTheme, type ThemeDef, type ThemeParams } from './themes';
import type { EffectType } from '../effects/effect-engine';
@@ -8,11 +8,13 @@ interface AppearanceState {
draft: ThemeParams; // 工作副本
saved: ThemeParams; // 已保存快照(回滚基准)
effect: EffectType; // 鼠标特效(FR-A11)
bgImage: string | null; // 背景图 data URL(FR-A8)
manualFlags: Set<keyof ThemeParams>; // 用户手动改过的项(智能取色不覆盖)
// actions
selectTheme: (id: string) => void;
override: (key: keyof ThemeParams, value: any) => void;
setEffect: (t: EffectType) => void;
setBgImage: (dataUrl: string | null) => Promise<void>; // 设背景图 + 触发智能取色
preview: () => void; // 实时预览(应用到 DOM)
load: () => Promise<void>; // 从 config.json 恢复(FR-A 持久化)
save: () => Promise<void>; // 持久化 + 更新 saved 快照
@@ -22,11 +24,32 @@ interface AppearanceState {
const defaultTheme = THEMES[1]; // 午夜蓝为默认
// 降采样取像素(渲染层 canvas 可用)
function sampleImage(dataUrl: string, size = 48): Promise<ImageSample> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const c = document.createElement('canvas');
c.width = size; c.height = size;
const ctx = c.getContext('2d');
if (!ctx) return reject(new Error('no ctx'));
ctx.drawImage(img, 0, 0, size, size);
const { data } = ctx.getImageData(0, 0, size, size);
const pixels: [number, number, number][] = [];
for (let i = 0; i < data.length; i += 4) pixels.push([data[i], data[i + 1], data[i + 2]]);
resolve({ pixels });
};
img.onerror = reject;
img.src = dataUrl;
});
}
export const useAppearance = create<AppearanceState>((set, get) => ({
themeId: defaultTheme.id,
draft: { ...defaultTheme.params },
saved: { ...defaultTheme.params },
effect: 'off',
bgImage: null,
manualFlags: new Set(),
selectTheme: (id) => {
@@ -42,23 +65,41 @@ export const useAppearance = create<AppearanceState>((set, get) => ({
setEffect: (t) => set({ effect: t }),
setBgImage: async (dataUrl) => {
set({ bgImage: dataUrl });
if (!dataUrl) { get().preview(); return; }
// 智能取色(FR-A9):依背景图推导强调色/模糊/表面不透明,尊重用户手动项
try {
const r = analyzeImage(await sampleImage(dataUrl));
set(s => {
const draft = { ...s.draft };
if (!s.manualFlags.has('accent')) draft.accent = r.accent;
if (!s.manualFlags.has('glow')) draft.glow = r.accent;
if (!s.manualFlags.has('blurPx')) draft.blurPx = r.suggestedBlur;
if (!s.manualFlags.has('surfaceOpacity')) draft.surfaceOpacity = r.suggestedSurfaceOpacity;
return { draft };
});
} catch { /* 解码失败忽略 */ }
get().preview();
},
preview: () => applyTheme(get().draft),
load: async () => {
try {
const data = (await (globalThis as any).dsh.invoke(IPC.appearanceLoad)) as { themeId: string; params: Record<string, unknown>; effect: string } | null;
const data = (await (globalThis as any).dsh.invoke(IPC.appearanceLoad)) as { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null } | null;
if (data) {
const t = THEMES.find(x => x.id === data.themeId) ?? defaultTheme;
const draft = { ...t.params, ...(data.params as Partial<ThemeParams>) };
set({ themeId: data.themeId, draft, saved: { ...draft }, effect: (data.effect as EffectType) ?? 'off' });
set({ themeId: data.themeId, draft, saved: { ...draft }, effect: (data.effect as EffectType) ?? 'off', bgImage: data.bgImage ?? null });
}
} catch { /* 首次启动无配置 */ }
get().preview();
},
save: async () => {
const { themeId, draft, effect } = get();
await (globalThis as any).dsh.invoke(IPC.appearanceSave, { themeId, params: draft as unknown as Record<string, unknown>, effect });
const { themeId, draft, effect, bgImage } = get();
await (globalThis as any).dsh.invoke(IPC.appearanceSave, { themeId, params: draft as unknown as Record<string, unknown>, effect, bgImage });
set({ saved: { ...draft } });
},
+66
View File
@@ -0,0 +1,66 @@
import { test, expect } from '@playwright/test';
import { _electron as electron } from 'playwright';
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
import os from 'node:os';
import zlib from 'node:zlib';
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
// 生成 1x1 RGB PNG(避免依赖图像库)
function crc32(buf: Buffer): number {
let c = ~0;
for (let i = 0; i < buf.length; i++) { c ^= buf[i]; for (let j = 0; j < 8; j++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); }
return ~c >>> 0;
}
function makePng(r: number, g: number, b: number): Buffer {
const sig = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]);
const ihdr = Buffer.from([0,0,0,1, 0,0,0,1, 8, 2, 0, 0, 0]); // 1x1, 8bit, RGB
const idatRaw = Buffer.from([0, r, g, b]);
const idat = zlib.deflateSync(idatRaw);
const chunk = (type: string, data: Buffer) => {
const t = Buffer.from(type, 'ascii'); const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0);
return Buffer.concat([len, t, data, crc]);
};
return Buffer.concat([sig, chunk('IHDR', ihdr.subarray(0,13)), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
}
async function launch(userData: string) {
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
const page = await app.firstWindow();
await page.waitForLoadState('domcontentloaded');
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
return { app, page, invoke };
}
test('背景图:上传+智能取色+持久化(FR-A8/A9)', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-bg-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const pngPath = nodePath.join(tmp, 'white.png');
await fs.writeFile(pngPath, makePng(255, 255, 255)); // 纯白 → 智能取色 mode=light, blur=12
// 上传背景图 + 保存
const a1 = await launch(userData);
await a1.invoke('vault:switch', { root: vault });
await a1.page.getByTitle('外观定制').click();
await a1.page.locator('input[type="file"]').setInputFiles(pngPath);
// 智能取色:白图 → blurPx 12(午夜蓝默认 16)
await expect(a1.page.locator('img[alt="bg"]')).toBeVisible({ timeout: 10000 });
await a1.page.getByRole('button', { name: '保存外观' }).click();
await expect(a1.page.getByText('外观定制').first()).toBeHidden();
await a1.app.close();
// 重开:恢复背景图 + 智能取色结果
const a2 = await launch(userData);
const cfg: { bgImage: string | null } | null = await a2.invoke('appearance:load', null);
expect(cfg?.bgImage).not.toBeNull();
// 背景图层渲染
await expect(a2.page.locator('div').filter({ hasText: '' }).first()).toBeTruthy();
const blur = await a2.page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--blur-px').trim());
expect(blur).toBe('12px');
await a2.app.close();
});