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
+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 } });
},