feat: M5 背景图上传/压缩+智能取色(FR-A8/A9,smart-color 下沉 shared,canvas 采样→analyzeImage 推导强调色/模糊/表面不透明尊重手动项,持久化+重开恢复)
This commit is contained in:
@@ -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 } });
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user