feat: M5 背景图上传/压缩+智能取色(FR-A8/A9,smart-color 下沉 shared,canvas 采样→analyzeImage 推导强调色/模糊/表面不透明尊重手动项,持久化+重开恢复)
This commit is contained in:
@@ -51,7 +51,7 @@ export function registerIpc(vault: VaultService) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC.appearanceLoad, async () => vault.getAppearance());
|
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.keymapLoad, async () => vault.getKeymaps());
|
||||||
ipcMain.handle(IPC.keymapSave, async (_e, p: { id: string; combo: string }[]) => { await vault.saveKeymaps(p); });
|
ipcMain.handle(IPC.keymapSave, async (_e, p: { id: string; combo: string }[]) => { await vault.saveKeymaps(p); });
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +1,2 @@
|
|||||||
// 智能取色:分析背景图亮度/主色 → 推荐深浅模式与强调色(FR-A9)
|
// 智能取色纯函数已下沉到 @hunter/shared;此处保留薄层 re-export(兼容历史引用)
|
||||||
// 纯函数,输入采样后的像素数据,输出推荐参数
|
export { analyzeImage, type ImageSample, type SmartColorResult } from '@hunter/shared';
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import { RECENT_VAULT_MAX } from '@hunter/shared';
|
|||||||
|
|
||||||
export class ConfigStore {
|
export class ConfigStore {
|
||||||
private file: string;
|
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) {
|
constructor(userDataDir: string) {
|
||||||
this.file = nodePath.join(userDataDir, 'config.json');
|
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;
|
if (this.cache) return this.cache;
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(this.file, 'utf8');
|
const raw = await fs.readFile(this.file, 'utf8');
|
||||||
@@ -46,11 +46,11 @@ export class ConfigStore {
|
|||||||
return meta;
|
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();
|
const c = await this.read();
|
||||||
return c.appearance ?? null;
|
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.read()).appearance = data;
|
||||||
await this.write();
|
await this.write();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export class VaultService {
|
|||||||
|
|
||||||
async setDefaultRoot(root: string): Promise<void> { await this.config.setDefaultRoot(root); }
|
async setDefaultRoot(root: string): Promise<void> { await this.config.setDefaultRoot(root); }
|
||||||
getAppearance() { return this.config.getAppearance(); }
|
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(); }
|
getKeymaps() { return this.config.getKeymaps(); }
|
||||||
saveKeymaps(data: { id: string; combo: string }[]) { return this.config.saveKeymaps(data); }
|
saveKeymaps(data: { id: string; combo: string }[]) { return this.config.saveKeymaps(data); }
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,9 @@ export function App() {
|
|||||||
// 编辑器样式随设置生效(FR-编辑器设置真实生效)
|
// 编辑器样式随设置生效(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]);
|
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 (
|
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} />
|
<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 />
|
<Toolbar />
|
||||||
{view === 'editor' && <TabBar />}
|
{view === 'editor' && <TabBar />}
|
||||||
@@ -76,6 +78,7 @@ export function App() {
|
|||||||
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
|
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
|
||||||
{showAppearance && <AppearancePanel onClose={() => setShowAppearance(false)} />}
|
{showAppearance && <AppearancePanel onClose={() => setShowAppearance(false)} />}
|
||||||
{showCheat && <CheatSheet onClose={() => setShowCheat(false)} />}
|
{showCheat && <CheatSheet onClose={() => setShowCheat(false)} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
import { useAppearance } from './appearance-store';
|
import { useAppearance } from './appearance-store';
|
||||||
import { THEMES } from './themes';
|
import { THEMES } from './themes';
|
||||||
import type { ThemeDef } 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','涟漪']];
|
const EFFECTS: [EffectType, string][] = [['off','关闭'],['auto','智能'],['particle','微粒'],['meteor','流星'],['aurora','极光'],['ripple','涟漪']];
|
||||||
|
|
||||||
export function AppearancePanel({ onClose }: { onClose: () => void }) {
|
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','浮层背景']];
|
const colors: [keyof typeof draft, string][] = [['accent','强调色'],['glow','辉光色'],['accentText','强调色文字'],['textPrimary','主要文字'],['textSecondary','辅助文字'],['textTertiary','弱化文字'],['controlBorder','控件边框'],['bgPrimary','工作区背景'],['bgSecondary','浮层背景']];
|
||||||
return (
|
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 }}>
|
<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 }}>背景模糊 {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>
|
<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>
|
||||||
|
<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={{ opacity: 0.5, fontSize: 12, marginBottom: 6 }}>鼠标特效</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6, marginBottom: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6, marginBottom: 16 }}>
|
||||||
{EFFECTS.map(([t, label]) => (
|
{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 {
|
function normalizeColor(c: string): string {
|
||||||
// 'transparent' 或 rgba(...) 给取色器一个占位 hex
|
// 'transparent' 或 rgba(...) 给取色器一个占位 hex
|
||||||
if (c === 'transparent' || c.startsWith('rgba')) return '#1a1a2a';
|
if (c === 'transparent' || c.startsWith('rgba')) return '#1a1a2a';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
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 { THEMES, applyTheme, type ThemeDef, type ThemeParams } from './themes';
|
||||||
import type { EffectType } from '../effects/effect-engine';
|
import type { EffectType } from '../effects/effect-engine';
|
||||||
|
|
||||||
@@ -8,11 +8,13 @@ interface AppearanceState {
|
|||||||
draft: ThemeParams; // 工作副本
|
draft: ThemeParams; // 工作副本
|
||||||
saved: ThemeParams; // 已保存快照(回滚基准)
|
saved: ThemeParams; // 已保存快照(回滚基准)
|
||||||
effect: EffectType; // 鼠标特效(FR-A11)
|
effect: EffectType; // 鼠标特效(FR-A11)
|
||||||
|
bgImage: string | null; // 背景图 data URL(FR-A8)
|
||||||
manualFlags: Set<keyof ThemeParams>; // 用户手动改过的项(智能取色不覆盖)
|
manualFlags: Set<keyof ThemeParams>; // 用户手动改过的项(智能取色不覆盖)
|
||||||
// actions
|
// actions
|
||||||
selectTheme: (id: string) => void;
|
selectTheme: (id: string) => void;
|
||||||
override: (key: keyof ThemeParams, value: any) => void;
|
override: (key: keyof ThemeParams, value: any) => void;
|
||||||
setEffect: (t: EffectType) => void;
|
setEffect: (t: EffectType) => void;
|
||||||
|
setBgImage: (dataUrl: string | null) => Promise<void>; // 设背景图 + 触发智能取色
|
||||||
preview: () => void; // 实时预览(应用到 DOM)
|
preview: () => void; // 实时预览(应用到 DOM)
|
||||||
load: () => Promise<void>; // 从 config.json 恢复(FR-A 持久化)
|
load: () => Promise<void>; // 从 config.json 恢复(FR-A 持久化)
|
||||||
save: () => Promise<void>; // 持久化 + 更新 saved 快照
|
save: () => Promise<void>; // 持久化 + 更新 saved 快照
|
||||||
@@ -22,11 +24,32 @@ interface AppearanceState {
|
|||||||
|
|
||||||
const defaultTheme = THEMES[1]; // 午夜蓝为默认
|
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) => ({
|
export const useAppearance = create<AppearanceState>((set, get) => ({
|
||||||
themeId: defaultTheme.id,
|
themeId: defaultTheme.id,
|
||||||
draft: { ...defaultTheme.params },
|
draft: { ...defaultTheme.params },
|
||||||
saved: { ...defaultTheme.params },
|
saved: { ...defaultTheme.params },
|
||||||
effect: 'off',
|
effect: 'off',
|
||||||
|
bgImage: null,
|
||||||
manualFlags: new Set(),
|
manualFlags: new Set(),
|
||||||
|
|
||||||
selectTheme: (id) => {
|
selectTheme: (id) => {
|
||||||
@@ -42,23 +65,41 @@ export const useAppearance = create<AppearanceState>((set, get) => ({
|
|||||||
|
|
||||||
setEffect: (t) => set({ effect: t }),
|
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),
|
preview: () => applyTheme(get().draft),
|
||||||
|
|
||||||
load: async () => {
|
load: async () => {
|
||||||
try {
|
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) {
|
if (data) {
|
||||||
const t = THEMES.find(x => x.id === data.themeId) ?? defaultTheme;
|
const t = THEMES.find(x => x.id === data.themeId) ?? defaultTheme;
|
||||||
const draft = { ...t.params, ...(data.params as Partial<ThemeParams>) };
|
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 { /* 首次启动无配置 */ }
|
} catch { /* 首次启动无配置 */ }
|
||||||
get().preview();
|
get().preview();
|
||||||
},
|
},
|
||||||
|
|
||||||
save: async () => {
|
save: async () => {
|
||||||
const { themeId, draft, effect } = get();
|
const { themeId, draft, effect, bgImage } = get();
|
||||||
await (globalThis as any).dsh.invoke(IPC.appearanceSave, { themeId, params: draft as unknown as Record<string, unknown>, effect });
|
await (globalThis as any).dsh.invoke(IPC.appearanceSave, { themeId, params: draft as unknown as Record<string, unknown>, effect, bgImage });
|
||||||
set({ saved: { ...draft } });
|
set({ saved: { ...draft } });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -5,3 +5,4 @@ export * from './types/doc.js';
|
|||||||
export * from './types/search.js';
|
export * from './types/search.js';
|
||||||
export * from './types/graph.js';
|
export * from './types/graph.js';
|
||||||
export * from './constants.js';
|
export * from './constants.js';
|
||||||
|
export * from './utils/smart-color.js';
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ export interface IpcMap {
|
|||||||
[IPC.winClose]: { args: void; result: void };
|
[IPC.winClose]: { args: void; result: void };
|
||||||
[IPC.copyMd]: { args: { path: string }; result: string };
|
[IPC.copyMd]: { args: { path: string }; result: string };
|
||||||
[IPC.printPdf]: { args: void; result: void };
|
[IPC.printPdf]: { args: void; result: void };
|
||||||
[IPC.appearanceLoad]: { args: void; result: { themeId: string; params: Record<string, unknown>; effect: string } | null };
|
[IPC.appearanceLoad]: { args: void; result: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null } | null };
|
||||||
[IPC.appearanceSave]: { args: { themeId: string; params: Record<string, unknown>; effect: string }; result: void };
|
[IPC.appearanceSave]: { args: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }; result: void };
|
||||||
[IPC.keymapLoad]: { args: void; result: { id: string; combo: string }[] | null };
|
[IPC.keymapLoad]: { args: void; result: { id: string; combo: string }[] | null };
|
||||||
[IPC.keymapSave]: { args: { id: string; combo: string }[]; result: void };
|
[IPC.keymapSave]: { args: { id: string; combo: string }[]; result: void };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// 智能取色:分析背景图亮度/主色 → 推荐深浅模式与强调色(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
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function constrainHue(h: number): number {
|
||||||
|
if (h >= 180 && h <= 330) return h;
|
||||||
|
if (h < 180) return h < 90 ? 200 : 210;
|
||||||
|
return 290;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user