From 81b410e281d0c7bd5cda255f061d2e8fccfdeb12 Mon Sep 17 00:00:00 2001 From: hunter-dev Date: Wed, 26 Aug 2026 09:20:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M5=20=E8=83=8C=E6=99=AF=E5=9B=BE?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0/=E5=8E=8B=E7=BC=A9+=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E5=8F=96=E8=89=B2=EF=BC=88FR-A8/A9=EF=BC=8Csmart-color=20?= =?UTF-8?q?=E4=B8=8B=E6=B2=89=20shared=EF=BC=8Ccanvas=20=E9=87=87=E6=A0=B7?= =?UTF-8?q?=E2=86=92analyzeImage=20=E6=8E=A8=E5=AF=BC=E5=BC=BA=E8=B0=83?= =?UTF-8?q?=E8=89=B2/=E6=A8=A1=E7=B3=8A/=E8=A1=A8=E9=9D=A2=E4=B8=8D?= =?UTF-8?q?=E9=80=8F=E6=98=8E=E5=B0=8A=E9=87=8D=E6=89=8B=E5=8A=A8=E9=A1=B9?= =?UTF-8?q?=EF=BC=8C=E6=8C=81=E4=B9=85=E5=8C=96+=E9=87=8D=E5=BC=80?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/main/ipc/handlers.ts | 2 +- apps/desktop/src/main/safe/smart-color.ts | 83 +------------------ .../desktop/src/main/services/config-store.ts | 8 +- .../src/main/services/vault-service.ts | 2 +- apps/desktop/src/renderer/App.tsx | 5 +- .../features/appearance/AppearancePanel.tsx | 21 ++++- .../features/appearance/appearance-store.ts | 51 ++++++++++-- apps/desktop/tests/e2e/background.spec.ts | 66 +++++++++++++++ packages/shared/src/index.ts | 1 + packages/shared/src/types/ipc.ts | 4 +- packages/shared/src/utils/smart-color.ts | 75 +++++++++++++++++ 11 files changed, 222 insertions(+), 96 deletions(-) create mode 100644 apps/desktop/tests/e2e/background.spec.ts create mode 100644 packages/shared/src/utils/smart-color.ts diff --git a/apps/desktop/src/main/ipc/handlers.ts b/apps/desktop/src/main/ipc/handlers.ts index 69a4c46..ee98d13 100644 --- a/apps/desktop/src/main/ipc/handlers.ts +++ b/apps/desktop/src/main/ipc/handlers.ts @@ -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; effect: string }) => { await vault.saveAppearance(p); }); + ipcMain.handle(IPC.appearanceSave, async (_e, p: { themeId: string; params: Record; 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); }); diff --git a/apps/desktop/src/main/safe/smart-color.ts b/apps/desktop/src/main/safe/smart-color.ts index f9084df..3d0d708 100644 --- a/apps/desktop/src/main/safe/smart-color.ts +++ b/apps/desktop/src/main/safe/smart-color.ts @@ -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'; diff --git a/apps/desktop/src/main/services/config-store.ts b/apps/desktop/src/main/services/config-store.ts index 7aef784..9c77acd 100644 --- a/apps/desktop/src/main/services/config-store.ts +++ b/apps/desktop/src/main/services/config-store.ts @@ -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; effect: string }; keymaps?: { id: string; combo: string }[] } | null = null; + private cache: { vault?: VaultMeta; appearance?: { themeId: string; params: Record; 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; effect: string }; keymaps?: { id: string; combo: string }[] }> { + private async read(): Promise<{ vault?: VaultMeta; appearance?: { themeId: string; params: Record; 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; effect: string } | null> { + async getAppearance(): Promise<{ themeId: string; params: Record; effect: string; bgImage: string | null } | null> { const c = await this.read(); return c.appearance ?? null; } - async saveAppearance(data: { themeId: string; params: Record; effect: string }): Promise { + async saveAppearance(data: { themeId: string; params: Record; effect: string; bgImage: string | null }): Promise { (await this.read()).appearance = data; await this.write(); } diff --git a/apps/desktop/src/main/services/vault-service.ts b/apps/desktop/src/main/services/vault-service.ts index 76d63a6..8eb757c 100644 --- a/apps/desktop/src/main/services/vault-service.ts +++ b/apps/desktop/src/main/services/vault-service.ts @@ -51,7 +51,7 @@ export class VaultService { async setDefaultRoot(root: string): Promise { await this.config.setDefaultRoot(root); } getAppearance() { return this.config.getAppearance(); } - saveAppearance(data: { themeId: string; params: Record; effect: string }) { return this.config.saveAppearance(data); } + saveAppearance(data: { themeId: string; params: Record; 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); } } \ No newline at end of file diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 6ede068..d1a6389 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -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 ( -
+
+ {appearance.bgImage &&
} +
setView(v => v === 'editor' ? 'graph' : 'editor')} onSettings={() => setShowSettings(true)} onAppearance={() => setShowAppearance(true)} onToggleSidebar={() => layout.toggleSidebar()} onToggleMinimal={() => layout.toggleMinimal()} minimal={layout.minimal} /> {view === 'editor' && } @@ -76,6 +78,7 @@ export function App() { {showSettings && setShowSettings(false)} />} {showAppearance && setShowAppearance(false)} />} {showCheat && setShowCheat(false)} />} +
); } diff --git a/apps/desktop/src/renderer/features/appearance/AppearancePanel.tsx b/apps/desktop/src/renderer/features/appearance/AppearancePanel.tsx index 1b75bc4..62c567f 100644 --- a/apps/desktop/src/renderer/features/appearance/AppearancePanel.tsx +++ b/apps/desktop/src/renderer/features/appearance/AppearancePanel.tsx @@ -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(null); const colors: [keyof typeof draft, string][] = [['accent','强调色'],['glow','辉光色'],['accentText','强调色文字'],['textPrimary','主要文字'],['textSecondary','辅助文字'],['textTertiary','弱化文字'],['controlBorder','控件边框'],['bgPrimary','工作区背景'],['bgSecondary','浮层背景']]; return (
{ 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 }) {
+
背景图(上传后智能取色 FR-A8/A9)
+
+ { const f = e.target.files?.[0]; if (f) compressImage(f).then(d => setBgImage(d)); e.target.value = ''; }} /> + + {bgImage && <>bg} + {!bgImage && 未设置} +
鼠标特效
{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 { + const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result as string); r.onerror = rej; r.readAsDataURL(file); }); + const img = await new Promise((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'; diff --git a/apps/desktop/src/renderer/features/appearance/appearance-store.ts b/apps/desktop/src/renderer/features/appearance/appearance-store.ts index b6301bd..7bbe26a 100644 --- a/apps/desktop/src/renderer/features/appearance/appearance-store.ts +++ b/apps/desktop/src/renderer/features/appearance/appearance-store.ts @@ -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; // 用户手动改过的项(智能取色不覆盖) // actions selectTheme: (id: string) => void; override: (key: keyof ThemeParams, value: any) => void; setEffect: (t: EffectType) => void; + setBgImage: (dataUrl: string | null) => Promise; // 设背景图 + 触发智能取色 preview: () => void; // 实时预览(应用到 DOM) load: () => Promise; // 从 config.json 恢复(FR-A 持久化) save: () => Promise; // 持久化 + 更新 saved 快照 @@ -22,11 +24,32 @@ interface AppearanceState { const defaultTheme = THEMES[1]; // 午夜蓝为默认 +// 降采样取像素(渲染层 canvas 可用) +function sampleImage(dataUrl: string, size = 48): Promise { + 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((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((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; effect: string } | null; + const data = (await (globalThis as any).dsh.invoke(IPC.appearanceLoad)) as { themeId: string; params: Record; 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) }; - 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, effect }); + const { themeId, draft, effect, bgImage } = get(); + await (globalThis as any).dsh.invoke(IPC.appearanceSave, { themeId, params: draft as unknown as Record, effect, bgImage }); set({ saved: { ...draft } }); }, diff --git a/apps/desktop/tests/e2e/background.spec.ts b/apps/desktop/tests/e2e/background.spec.ts new file mode 100644 index 0000000..ac688d1 --- /dev/null +++ b/apps/desktop/tests/e2e/background.spec.ts @@ -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(); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d55df3a..6a3920d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,3 +5,4 @@ export * from './types/doc.js'; export * from './types/search.js'; export * from './types/graph.js'; export * from './constants.js'; +export * from './utils/smart-color.js'; diff --git a/packages/shared/src/types/ipc.ts b/packages/shared/src/types/ipc.ts index afd4363..6e947c7 100644 --- a/packages/shared/src/types/ipc.ts +++ b/packages/shared/src/types/ipc.ts @@ -58,8 +58,8 @@ export interface IpcMap { [IPC.winClose]: { args: void; result: void }; [IPC.copyMd]: { args: { path: string }; result: string }; [IPC.printPdf]: { args: void; result: void }; - [IPC.appearanceLoad]: { args: void; result: { themeId: string; params: Record; effect: string } | null }; - [IPC.appearanceSave]: { args: { themeId: string; params: Record; effect: string }; result: void }; + [IPC.appearanceLoad]: { args: void; result: { themeId: string; params: Record; effect: string; bgImage: string | null } | null }; + [IPC.appearanceSave]: { args: { themeId: string; params: Record; effect: string; bgImage: string | null }; result: void }; [IPC.keymapLoad]: { args: void; result: { id: string; combo: string }[] | null }; [IPC.keymapSave]: { args: { id: string; combo: string }[]; result: void }; } diff --git a/packages/shared/src/utils/smart-color.ts b/packages/shared/src/utils/smart-color.ts new file mode 100644 index 0000000..d88dcf3 --- /dev/null +++ b/packages/shared/src/utils/smart-color.ts @@ -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 }; +}