综合更新
This commit is contained in:
1 parent
d733c21145
commit
147f3b7045
57 files changed
+3038
-800
No files matched your search
@@ -31,6 +31,10 @@ out
|
||||
release
|
||||
tmp/
|
||||
|
||||
# electron-liquid-glass 是嵌套的独立 git 仓库(发布到 npm,WeFlow 走 registry 依赖),
|
||||
# 不能被外层仓库跟踪(会变成悬空 gitlink)
|
||||
/packages/electron-liquid-glass/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-shm
|
||||
|
||||
+1
-1
@@ -685,7 +685,7 @@ Body 示例:
|
||||
}
|
||||
```
|
||||
|
||||
`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)。
|
||||
`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)、`markdown`(兼容写法:`md`)。
|
||||
|
||||
### 7.6 朋友圈防删开关
|
||||
|
||||
|
||||
+59
-97
@@ -39,6 +39,12 @@ import { bizService } from './services/bizService'
|
||||
import { backupService } from './services/backupService'
|
||||
import { imageDownloadService } from './services/imageDownloadService'
|
||||
|
||||
// 屏幕采集去节流(仅影响通知玻璃的 Chromium 流回退管线;Windows 主路径为
|
||||
// 原生面板渲染,不经过 Chromium 采集):默认桌面采集 CPU 预算限制在 50%,
|
||||
// 会自适应压低采集帧率、显著增加回退路径下折射的跟随延迟。
|
||||
// 回退采集只在通知展示的数秒内运行且分辨率已降为逻辑尺寸,放开预算不影响常态性能
|
||||
app.commandLine.appendSwitch('webrtc-max-cpu-consumption-percentage', '100')
|
||||
|
||||
// 配置自动更新
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
@@ -1268,12 +1274,14 @@ function createSplashWindow(): BrowserWindow {
|
||||
const splashUrl = new URL('splash.html', process.env.VITE_DEV_SERVER_URL)
|
||||
splashUrl.searchParams.set('themeId', splashThemeId)
|
||||
splashUrl.searchParams.set('themeMode', splashThemeMode)
|
||||
splashUrl.searchParams.set('version', app.getVersion())
|
||||
splashWindow.loadURL(splashUrl.toString())
|
||||
} else {
|
||||
splashWindow.loadFile(join(__dirname, '../dist/splash.html'), {
|
||||
query: {
|
||||
themeId: splashThemeId,
|
||||
themeMode: splashThemeMode
|
||||
themeMode: splashThemeMode,
|
||||
version: app.getVersion()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4553,10 +4561,10 @@ app.whenReady().then(async () => {
|
||||
shouldShowMain = onboardingDone
|
||||
|
||||
if (!startInBackground) {
|
||||
// 非静默模式下显示 Splash,提供启动反馈
|
||||
// 非静默模式下显示 Splash,提供启动反馈(主题/版本号通过 URL 参数传入)
|
||||
createSplashWindow()
|
||||
|
||||
// 等待 Splash 页面加载完成后再推送进度
|
||||
// 等待 Splash 页面加载完成,确保后续 executeJavaScript 推送的进度可靠送达
|
||||
if (splashWindow) {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (splashWindow!.webContents.isLoading()) {
|
||||
@@ -4565,13 +4573,9 @@ app.whenReady().then(async () => {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
splashWindow.webContents
|
||||
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const withTimeout = <T>(task: () => Promise<T>, timeoutMs: number): Promise<{ timedOut: boolean; value?: T; error?: string }> => {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
@@ -4597,112 +4601,29 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
}
|
||||
|
||||
updateSplashProgress(5, '正在加载配置...')
|
||||
|
||||
// 将用户主题配置推送给 Splash 窗口
|
||||
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||
const themeId = configService.get('themeId') || 'cloud-dancer'
|
||||
const themeMode = configService.get('theme') || 'system'
|
||||
splashWindow.webContents
|
||||
.executeJavaScript(`applyTheme(${JSON.stringify(themeId)}, ${JSON.stringify(themeMode)})`)
|
||||
.catch(() => {})
|
||||
}
|
||||
await delay(200)
|
||||
|
||||
// 设置资源路径
|
||||
updateSplashProgress(12, '正在初始化...')
|
||||
// 基础初始化均为毫秒级操作,直接顺序执行
|
||||
updateSplashProgress(10, '正在初始化...')
|
||||
const candidateResources = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
const fallbackResources = join(process.cwd(), 'resources')
|
||||
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
|
||||
const userDataPath = app.getPath('userData')
|
||||
await delay(200)
|
||||
|
||||
// 初始化数据库服务
|
||||
updateSplashProgress(20, '正在初始化...')
|
||||
wcdbService.setPaths(resourcesPath, userDataPath)
|
||||
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
||||
await delay(200)
|
||||
|
||||
// 注册 IPC 处理器
|
||||
updateSplashProgress(28, '正在初始化...')
|
||||
registerIpcHandlers()
|
||||
if (configService.get('autoDownloadHighRes')) {
|
||||
const whitelistArr = configService.get('autoDownloadWhitelist') || []
|
||||
const whitelistStr = (Array.isArray(whitelistArr) && whitelistArr.length > 0)
|
||||
? (whitelistArr.join('\0') + '\0\0')
|
||||
: ''
|
||||
imageDownloadService.startAutoDownload(whitelistStr)
|
||||
}
|
||||
chatService.addDbMonitorListener((type, json) => {
|
||||
messagePushService.handleDbMonitorChange(type, json)
|
||||
insightService.handleDbMonitorChange(type, json)
|
||||
})
|
||||
messagePushService.start()
|
||||
insightService.start()
|
||||
groupSummaryService.start()
|
||||
await delay(200)
|
||||
|
||||
// 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等)
|
||||
if (onboardingDone) {
|
||||
updateSplashProgress(34, '正在连接数据库...')
|
||||
const connectWarmup = await withTimeout(() => chatService.connect(), 12000)
|
||||
const connected = !connectWarmup.timedOut && connectWarmup.value?.success === true
|
||||
|
||||
if (!connected) {
|
||||
const reason = connectWarmup.timedOut
|
||||
? connectWarmup.error
|
||||
: (connectWarmup.value?.error || connectWarmup.error || 'unknown')
|
||||
console.warn('[StartupWarmup] 跳过预热,数据库连接失败:', reason)
|
||||
updateSplashProgress(68, '数据库预热已跳过')
|
||||
} else {
|
||||
const preloadUsernames = new Set<string>()
|
||||
|
||||
updateSplashProgress(44, '正在预加载会话...')
|
||||
const sessionsWarmup = await withTimeout(() => chatService.getSessions(), 12000)
|
||||
if (!sessionsWarmup.timedOut && sessionsWarmup.value?.success && Array.isArray(sessionsWarmup.value.sessions)) {
|
||||
for (const session of sessionsWarmup.value.sessions) {
|
||||
const username = String((session as any)?.username || '').trim()
|
||||
if (username) preloadUsernames.add(username)
|
||||
}
|
||||
}
|
||||
|
||||
updateSplashProgress(56, '正在预加载联系人...')
|
||||
const contactsWarmup = await withTimeout(() => chatService.getContacts(), 15000)
|
||||
if (!contactsWarmup.timedOut && contactsWarmup.value?.success && Array.isArray(contactsWarmup.value.contacts)) {
|
||||
for (const contact of contactsWarmup.value.contacts) {
|
||||
const username = String((contact as any)?.username || '').trim()
|
||||
if (username) preloadUsernames.add(username)
|
||||
}
|
||||
}
|
||||
|
||||
updateSplashProgress(63, '正在缓存联系人头像...')
|
||||
const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000)
|
||||
if (avatarWarmupUsernames.length > 0) {
|
||||
await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000)
|
||||
}
|
||||
|
||||
updateSplashProgress(68, '正在初始化消息库索引...')
|
||||
await withTimeout(() => chatService.warmupMessageDbSnapshot(), 10000)
|
||||
|
||||
updateSplashProgress(69, '正在预加载导出统计...')
|
||||
if (avatarWarmupUsernames.length > 0) {
|
||||
await withTimeout(() => chatService.getExportSessionStats(avatarWarmupUsernames, { includeRelations: false }), 8000)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updateSplashProgress(68, '首次启动准备中...')
|
||||
}
|
||||
|
||||
// 创建主窗口(不显示,由启动流程统一控制)
|
||||
updateSplashProgress(70, '正在准备主窗口...')
|
||||
// 提前创建主窗口(隐藏),让渲染进程加载与数据库预热并行进行
|
||||
updateSplashProgress(20, '正在准备主窗口...')
|
||||
ensureWeChatRequestHeaderInterceptor()
|
||||
mainWindow = createWindow({ autoShow: false })
|
||||
|
||||
const resolvedTrayIcon = resolveAppIconPath()
|
||||
|
||||
|
||||
try {
|
||||
tray = new Tray(resolvedTrayIcon)
|
||||
tray.setToolTip('WeFlow')
|
||||
@@ -4746,8 +4667,38 @@ app.whenReady().then(async () => {
|
||||
console.warn('[Tray] Failed to create tray icon:', e)
|
||||
}
|
||||
|
||||
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
|
||||
updateSplashProgress(70, '正在准备主窗口...', true)
|
||||
// 已完成引导时,与渲染进程加载并行预热会话列表和联系人名字/头像缓存
|
||||
if (onboardingDone) {
|
||||
updateSplashProgress(35, '正在连接数据库...')
|
||||
const connectWarmup = await withTimeout(() => chatService.connect(), 12000)
|
||||
const connected = !connectWarmup.timedOut && connectWarmup.value?.success === true
|
||||
|
||||
if (!connected) {
|
||||
const reason = connectWarmup.timedOut
|
||||
? connectWarmup.error
|
||||
: (connectWarmup.value?.error || connectWarmup.error || 'unknown')
|
||||
console.warn('[StartupWarmup] 跳过预热,数据库连接失败:', reason)
|
||||
} else {
|
||||
updateSplashProgress(55, '正在预加载会话...')
|
||||
const preloadUsernames = new Set<string>()
|
||||
const sessionsWarmup = await withTimeout(() => chatService.getSessions(), 12000)
|
||||
if (!sessionsWarmup.timedOut && sessionsWarmup.value?.success && Array.isArray(sessionsWarmup.value.sessions)) {
|
||||
for (const session of sessionsWarmup.value.sessions) {
|
||||
const username = String((session as any)?.username || '').trim()
|
||||
if (username) preloadUsernames.add(username)
|
||||
}
|
||||
}
|
||||
|
||||
updateSplashProgress(75, '正在缓存联系人头像...')
|
||||
const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000)
|
||||
if (avatarWarmupUsernames.length > 0) {
|
||||
await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 等待主窗口加载完成(进度条末端呼吸光点)
|
||||
updateSplashProgress(90, '正在准备主窗口...', true)
|
||||
await new Promise<void>((resolve) => {
|
||||
if (mainWindowReady) {
|
||||
resolve()
|
||||
@@ -4761,7 +4712,6 @@ app.whenReady().then(async () => {
|
||||
|
||||
// 加载完成,收尾
|
||||
updateSplashProgress(100, '启动完成')
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
closeSplash()
|
||||
|
||||
if (!onboardingDone) {
|
||||
@@ -4772,6 +4722,18 @@ app.whenReady().then(async () => {
|
||||
mainWindow?.show()
|
||||
}
|
||||
|
||||
// 依赖数据库的后台服务在窗口显示后再启动,避免与启动预热争抢数据库 worker
|
||||
messagePushService.start()
|
||||
insightService.start()
|
||||
groupSummaryService.start()
|
||||
if (configService.get('autoDownloadHighRes')) {
|
||||
const whitelistArr = configService.get('autoDownloadWhitelist') || []
|
||||
const whitelistStr = (Array.isArray(whitelistArr) && whitelistArr.length > 0)
|
||||
? (whitelistArr.join('\0') + '\0\0')
|
||||
: ''
|
||||
imageDownloadService.startAutoDownload(whitelistStr)
|
||||
}
|
||||
|
||||
// 启动时检测更新(不阻塞启动)
|
||||
checkForUpdatesOnStartup()
|
||||
|
||||
|
||||
+10
-1
@@ -21,10 +21,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
click: (payload: any) => ipcRenderer.send('notification-clicked', payload),
|
||||
ready: () => ipcRenderer.send('notification:ready'),
|
||||
resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
|
||||
// 原生玻璃面板:卡片实测几何上报 / 退场淡出 / 亮度带回调(Windows 原生模式专用)
|
||||
glassRect: (payload: any) => ipcRenderer.send('notification:glassRect', payload),
|
||||
glassHide: () => ipcRenderer.send('notification:glassHide'),
|
||||
onLuma: (callback: (bands: any) => void) => {
|
||||
const listener = (_: any, bands: any) => callback(bands)
|
||||
ipcRenderer.on('notification:luma', listener)
|
||||
return () => ipcRenderer.removeListener('notification:luma', listener)
|
||||
},
|
||||
onShow: (callback: (event: any, data: any) => void) => {
|
||||
ipcRenderer.on('notification:show', callback)
|
||||
return () => ipcRenderer.removeAllListeners('notification:show')
|
||||
}, // 监听原本发送出来的navigate-to-session事件,跳转到具体的会话
|
||||
},
|
||||
// 监听原本发送出来的navigate-to-session事件,跳转到具体的会话
|
||||
onNavigateToSession: (callback: (sessionId: string) => void) => {
|
||||
const listener = (_: any, sessionId: string) => callback(sessionId)
|
||||
ipcRenderer.on('navigate-to-session', listener)
|
||||
|
||||
@@ -449,6 +449,7 @@ class ChatService {
|
||||
private readonly visibilityAnomalyLogBurst = 3
|
||||
private visibilityAnomalyLogState = new Map<string, { windowStart: number; total: number; suppressed: number }>()
|
||||
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
|
||||
private connectInFlight: Promise<{ success: boolean; error?: string }> | null = null
|
||||
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
|
||||
private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>()
|
||||
private readonly contactsMemoryCacheTtlMs = 3 * 60 * 1000
|
||||
@@ -561,9 +562,27 @@ class ChatService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接数据库
|
||||
* 连接数据库(并发调用共享同一次连接过程,避免重复 open)
|
||||
*/
|
||||
async connect(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.connected && wcdbService.isReady()) {
|
||||
return { success: true }
|
||||
}
|
||||
if (this.connectInFlight) {
|
||||
return this.connectInFlight
|
||||
}
|
||||
const promise = this.connectInternal()
|
||||
this.connectInFlight = promise
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
if (this.connectInFlight === promise) {
|
||||
this.connectInFlight = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async connectInternal(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
|
||||
const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
|
||||
|
||||
@@ -5,34 +5,10 @@ import { buildGroupNicknameIdCandidates } from '../../export/contacts/groupNickn
|
||||
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
|
||||
import { formatTimestamp } from '../../export/utils/timestamp';
|
||||
import { parallelLimit } from '../../export/utils/parallelLimit';
|
||||
import { buildMarkdownBlockquote, escapeMarkdownLinkText, escapeMarkdownText, toMarkdownUrl } from '../../export/utils/markdown';
|
||||
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
|
||||
import { wcdbService } from '../../wcdbService';
|
||||
|
||||
const escapeMarkdownText = (value: unknown): string => {
|
||||
return String(value ?? '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/([`*_{}\[\]<>#+\-.!|])/g, '\\$1')
|
||||
}
|
||||
|
||||
const escapeMarkdownLinkText = (value: unknown): string => {
|
||||
return escapeMarkdownText(value).replace(/\r?\n/g, ' ')
|
||||
}
|
||||
|
||||
const toMarkdownUrl = (value: string): string => {
|
||||
return encodeURI(value)
|
||||
.replace(/\(/g, '%28')
|
||||
.replace(/\)/g, '%29')
|
||||
.replace(/</g, '%3C')
|
||||
.replace(/>/g, '%3E')
|
||||
}
|
||||
|
||||
const buildMarkdownBlockquote = (value: string): string => {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map(line => `> ${line ? escapeMarkdownText(line) : ''}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export class MarkdownFormatter {
|
||||
constructor(private exportService: any) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export const escapeMarkdownText = (value: unknown): string => {
|
||||
return String(value ?? '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/([`*_{}\[\]<>#+\-.!|])/g, '\\$1')
|
||||
}
|
||||
|
||||
export const escapeMarkdownLinkText = (value: unknown): string => {
|
||||
return escapeMarkdownText(value).replace(/\r?\n/g, ' ')
|
||||
}
|
||||
|
||||
export const toMarkdownUrl = (value: string): string => {
|
||||
return encodeURI(value)
|
||||
.replace(/\(/g, '%28')
|
||||
.replace(/\)/g, '%29')
|
||||
.replace(/</g, '%3C')
|
||||
.replace(/>/g, '%3E')
|
||||
}
|
||||
|
||||
export const buildMarkdownBlockquote = (value: string): string => {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map(line => `> ${line ? escapeMarkdownText(line) : ''}`)
|
||||
.join('\n')
|
||||
}
|
||||
@@ -1453,9 +1453,9 @@ class HttpService {
|
||||
}
|
||||
|
||||
const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase()
|
||||
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat
|
||||
if (!['json', 'html', 'arkmejson'].includes(format)) {
|
||||
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson')
|
||||
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat === 'md' ? 'markdown' : rawFormat
|
||||
if (!['json', 'html', 'arkmejson', 'markdown'].includes(format)) {
|
||||
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson/markdown')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1466,7 +1466,7 @@ class HttpService {
|
||||
|
||||
const options: {
|
||||
outputDir: string
|
||||
format: 'json' | 'html' | 'arkmejson'
|
||||
format: 'json' | 'html' | 'arkmejson' | 'markdown'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportMedia?: boolean
|
||||
@@ -1477,7 +1477,7 @@ class HttpService {
|
||||
endTime?: number
|
||||
} = {
|
||||
outputDir,
|
||||
format: format as 'json' | 'html' | 'arkmejson',
|
||||
format: format as 'json' | 'html' | 'arkmejson' | 'markdown',
|
||||
usernames,
|
||||
keyword,
|
||||
exportMedia: this.parseBooleanParam(url, ['exportMedia'], false)
|
||||
|
||||
+125
-23
@@ -8,6 +8,7 @@ import { basename, join } from 'path'
|
||||
import crypto from 'crypto'
|
||||
import { WasmService } from './wasmService'
|
||||
import zlib from 'zlib'
|
||||
import { escapeMarkdownLinkText, escapeMarkdownText, toMarkdownUrl } from './export/utils/markdown'
|
||||
|
||||
export interface SnsLivePhoto {
|
||||
url: string
|
||||
@@ -332,6 +333,30 @@ const decodeXmlText = (text: string): string => {
|
||||
.replace(/'/gi, "'")
|
||||
}
|
||||
|
||||
const normalizeSnsLocationText = (value?: string): string => (
|
||||
decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim()
|
||||
)
|
||||
|
||||
/** 解析朋友圈位置的展示文本(POI 优先,附加国家/城市信息) */
|
||||
const resolveSnsLocationText = (location?: SnsLocation): string => {
|
||||
if (!location) return ''
|
||||
const primaryCandidates = [
|
||||
normalizeSnsLocationText(location.poiName),
|
||||
normalizeSnsLocationText(location.poiAddressName),
|
||||
normalizeSnsLocationText(location.label),
|
||||
normalizeSnsLocationText(location.poiAddress)
|
||||
].filter(Boolean)
|
||||
const primary = primaryCandidates[0] || ''
|
||||
const region = [
|
||||
normalizeSnsLocationText(location.country),
|
||||
normalizeSnsLocationText(location.city)
|
||||
].filter(Boolean).join(' ')
|
||||
if (primary && region && !primary.includes(region)) {
|
||||
return `${primary} · ${region}`
|
||||
}
|
||||
return primary || region
|
||||
}
|
||||
|
||||
class SnsService {
|
||||
private configService: ConfigService
|
||||
private contactCache: ContactCacheService
|
||||
@@ -1358,7 +1383,7 @@ class SnsService {
|
||||
*/
|
||||
async exportTimeline(options: {
|
||||
outputDir: string
|
||||
format: 'json' | 'html' | 'arkmejson'
|
||||
format: 'json' | 'html' | 'arkmejson' | 'markdown'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportMedia?: boolean
|
||||
@@ -1735,6 +1760,11 @@ class SnsService {
|
||||
}
|
||||
recordCreatedFileBeforeWrite(outputFilePath)
|
||||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||
} else if (format === 'markdown') {
|
||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.md`)
|
||||
const markdown = this.generateMarkdown(allPosts, { usernames, keyword })
|
||||
recordCreatedFileBeforeWrite(outputFilePath)
|
||||
await writeFile(outputFilePath, markdown, 'utf-8')
|
||||
} else {
|
||||
// HTML 格式
|
||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
||||
@@ -1752,6 +1782,99 @@ class SnsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成朋友圈 Markdown 导出文件
|
||||
*/
|
||||
private generateMarkdown(posts: SnsPost[], filters: { usernames?: string[]; keyword?: string }): string {
|
||||
const formatTime = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
// 逐行转义正文,行尾补两个空格以在渲染时保留换行
|
||||
const formatMultilineText = (text: string): string => text
|
||||
.split(/\r?\n/)
|
||||
.map(line => escapeMarkdownText(line))
|
||||
.join(' \n')
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('# 朋友圈导出')
|
||||
lines.push('')
|
||||
lines.push(`- 动态数量: ${posts.length}`)
|
||||
if (filters.keyword) lines.push(`- 筛选关键词: ${escapeMarkdownText(filters.keyword)}`)
|
||||
if (filters.usernames && filters.usernames.length > 0) lines.push(`- 筛选用户: ${filters.usernames.length} 人`)
|
||||
lines.push(`- 导出时间: ${new Date().toLocaleString('zh-CN')}`)
|
||||
lines.push('- 导出工具: WeFlow')
|
||||
|
||||
for (const post of posts) {
|
||||
lines.push('')
|
||||
lines.push('---')
|
||||
lines.push('')
|
||||
lines.push(`## ${escapeMarkdownText(post.nickname)} · ${formatTime(post.createTime)}`)
|
||||
lines.push('')
|
||||
|
||||
if (post.contentDesc) {
|
||||
lines.push(formatMultilineText(post.contentDesc))
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
const locationText = resolveSnsLocationText(post.location)
|
||||
if (locationText) {
|
||||
lines.push(`📍 ${escapeMarkdownText(locationText)}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
const mediaLines: string[] = []
|
||||
post.media.forEach((m, mi) => {
|
||||
const indexSuffix = post.media.length > 1 ? ` ${mi + 1}` : ''
|
||||
const localPath = (m as any).localPath as string | undefined
|
||||
const isVideo = isVideoUrl(m.url)
|
||||
if (localPath) {
|
||||
mediaLines.push(isVideo
|
||||
? `[视频${indexSuffix}](${toMarkdownUrl(localPath)})`
|
||||
: `})`)
|
||||
} else if (m.url) {
|
||||
mediaLines.push(`[${isVideo ? '视频' : '图片'}${indexSuffix}](${toMarkdownUrl(m.url)})`)
|
||||
}
|
||||
const livePhotoLocalPath = m.livePhoto ? (m.livePhoto as any).localPath as string | undefined : undefined
|
||||
if (livePhotoLocalPath) {
|
||||
mediaLines.push(`[实况视频${indexSuffix}](${toMarkdownUrl(livePhotoLocalPath)})`)
|
||||
}
|
||||
})
|
||||
if (mediaLines.length > 0) {
|
||||
// 行尾两个空格产生硬换行,让每个媒体渲染时各占一行
|
||||
lines.push(mediaLines.join(' \n'))
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (post.linkTitle && post.linkUrl) {
|
||||
lines.push(`🔗 [${escapeMarkdownLinkText(post.linkTitle)}](${toMarkdownUrl(post.linkUrl)})`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (post.likes.length > 0) {
|
||||
lines.push(`♥ ${post.likes.map(l => escapeMarkdownText(l)).join('、')}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (post.comments.length > 0) {
|
||||
lines.push('💬 评论:')
|
||||
lines.push('')
|
||||
for (const c of post.comments) {
|
||||
const ref = c.refNickname ? ` 回复 **${escapeMarkdownText(c.refNickname)}**` : ''
|
||||
lines.push(`- **${escapeMarkdownText(c.nickname)}**${ref}:${escapeMarkdownLinkText(c.content)}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// 去掉每条动态末尾多余的空行,统一由分隔符控制间距
|
||||
while (lines[lines.length - 1] === '') lines.pop()
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成朋友圈 HTML 导出文件
|
||||
*/
|
||||
@@ -1778,27 +1901,6 @@ class SnsService {
|
||||
const ch = name.charAt(0)
|
||||
return escapeHtml(ch || '?')
|
||||
}
|
||||
const normalizeLocationText = (value?: string): string => (
|
||||
decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim()
|
||||
)
|
||||
const resolveLocationText = (location?: SnsLocation): string => {
|
||||
if (!location) return ''
|
||||
const primaryCandidates = [
|
||||
normalizeLocationText(location.poiName),
|
||||
normalizeLocationText(location.poiAddressName),
|
||||
normalizeLocationText(location.label),
|
||||
normalizeLocationText(location.poiAddress)
|
||||
].filter(Boolean)
|
||||
const primary = primaryCandidates[0] || ''
|
||||
const region = [
|
||||
normalizeLocationText(location.country),
|
||||
normalizeLocationText(location.city)
|
||||
].filter(Boolean).join(' ')
|
||||
if (primary && region && !primary.includes(region)) {
|
||||
return `${primary} · ${region}`
|
||||
}
|
||||
return primary || region
|
||||
}
|
||||
|
||||
let filterInfo = ''
|
||||
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
|
||||
@@ -1822,7 +1924,7 @@ class SnsService {
|
||||
const linkHtml = post.linkTitle && post.linkUrl
|
||||
? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a">›</span></a>`
|
||||
: ''
|
||||
const locationText = resolveLocationText(post.location)
|
||||
const locationText = resolveSnsLocationText(post.location)
|
||||
const locationHtml = locationText
|
||||
? `<div class="loc"><span class="loc-i">📍</span><span class="loc-t">${escapeHtml(locationText)}</span></div>`
|
||||
: ''
|
||||
|
||||
+28
-41
@@ -1,7 +1,11 @@
|
||||
import { Notification } from "electron";
|
||||
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
|
||||
import { avatarFileCache } from "./avatarFileCacheService";
|
||||
|
||||
export interface LinuxNotificationData {
|
||||
// 系统通知服务(Linux / macOS):走各自系统的通知中心(Linux 底层为
|
||||
// D-Bus/libnotify,macOS 为通知中心),Windows 使用特制的液态玻璃通知窗口。
|
||||
// 声音、勿扰模式、通知历史均由系统按用户设置管理
|
||||
|
||||
export interface SystemNotificationData {
|
||||
sessionId?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
@@ -14,6 +18,9 @@ export interface LinuxNotificationData {
|
||||
|
||||
type NotificationCallback = (payload: unknown) => void;
|
||||
|
||||
const isSupportedPlatform =
|
||||
process.platform === "linux" || process.platform === "darwin";
|
||||
|
||||
let notificationCallbacks: NotificationCallback[] = [];
|
||||
let notificationCounter = 1;
|
||||
const activeNotifications: Map<number, Notification> = new Map();
|
||||
@@ -39,20 +46,20 @@ function triggerNotificationCallback(payload: unknown): void {
|
||||
try {
|
||||
callback(payload);
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Callback error:", error);
|
||||
console.error("[SystemNotification] Callback error:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function showLinuxNotification(
|
||||
data: LinuxNotificationData,
|
||||
export async function showSystemNotification(
|
||||
data: SystemNotificationData,
|
||||
): Promise<number | null> {
|
||||
if (process.platform !== "linux") {
|
||||
if (!isSupportedPlatform) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
console.warn("[SystemNotification] Notification API is not supported");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -91,12 +98,15 @@ export async function showLinuxNotification(
|
||||
});
|
||||
|
||||
notification.on("failed", (_, error) => {
|
||||
console.error("[LinuxNotification] Notification failed:", error);
|
||||
console.error("[SystemNotification] Notification failed:", error);
|
||||
clearNotificationState(notificationId);
|
||||
});
|
||||
|
||||
// Linux 的部分通知服务不会自动过期,需要手动关闭兜底;
|
||||
// macOS 横幅由系统自动收起并保留在通知中心,手动 close 反而会把它
|
||||
// 从通知中心移除,因此不做超时关闭
|
||||
const expireTimeout = data.expireTimeout ?? 5000;
|
||||
if (expireTimeout > 0) {
|
||||
if (process.platform === "linux" && expireTimeout > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
const currentNotification = activeNotifications.get(notificationId);
|
||||
if (currentNotification) {
|
||||
@@ -109,37 +119,16 @@ export async function showLinuxNotification(
|
||||
notification.show();
|
||||
|
||||
console.log(
|
||||
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}`,
|
||||
`[SystemNotification] Shown notification ${notificationId}: ${data.title}`,
|
||||
);
|
||||
|
||||
return notificationId;
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Failed to show notification:", error);
|
||||
console.error("[SystemNotification] Failed to show notification:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeLinuxNotification(
|
||||
notificationId: number,
|
||||
): Promise<void> {
|
||||
const notification = activeNotifications.get(notificationId);
|
||||
if (!notification) return;
|
||||
notification.close();
|
||||
clearNotificationState(notificationId);
|
||||
}
|
||||
|
||||
export async function getCapabilities(): Promise<string[]> {
|
||||
if (process.platform !== "linux") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ["native-notification", "click"];
|
||||
}
|
||||
|
||||
export function onNotificationAction(callback: NotificationCallback): void {
|
||||
notificationCallbacks.push(callback);
|
||||
}
|
||||
@@ -153,23 +142,21 @@ export function removeNotificationCallback(
|
||||
}
|
||||
}
|
||||
|
||||
export async function initLinuxNotificationService(): Promise<void> {
|
||||
if (process.platform !== "linux") {
|
||||
console.log("[LinuxNotification] Not on Linux, skipping init");
|
||||
export async function initSystemNotificationService(): Promise<void> {
|
||||
if (!isSupportedPlatform) {
|
||||
console.log("[SystemNotification] Platform uses custom window, skipping init");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
console.warn("[SystemNotification] Notification API is not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
const caps = await getCapabilities();
|
||||
console.log("[LinuxNotification] Service initialized with native API:", caps);
|
||||
console.log("[SystemNotification] Service initialized for", process.platform);
|
||||
}
|
||||
|
||||
export async function shutdownLinuxNotificationService(): Promise<void> {
|
||||
// 清理所有活动的通知
|
||||
export async function shutdownSystemNotificationService(): Promise<void> {
|
||||
for (const [id, notification] of activeNotifications) {
|
||||
try {
|
||||
notification.close();
|
||||
@@ -182,5 +169,5 @@ export async function shutdownLinuxNotificationService(): Promise<void> {
|
||||
await avatarFileCache.clearCache();
|
||||
} catch {}
|
||||
|
||||
console.log("[LinuxNotification] Service shutdown complete");
|
||||
console.log("[SystemNotification] Service shutdown complete");
|
||||
}
|
||||
@@ -161,13 +161,18 @@ class VideoService {
|
||||
return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 dbPath 本身是否就是账号目录。
|
||||
* 无论 wxid_ 开头还是自定义微信号,账号目录下必然存在 db_storage 子目录,
|
||||
* 以此为判据。不可用路径子串匹配 wxid(#1128:Windows 用户名与微信号相同时,
|
||||
* `C:\Users\<同名>` 会让子串匹配误判,导致视频目录解析到错误位置)。
|
||||
*/
|
||||
private isAccountDir(dirPath: string): boolean {
|
||||
return existsSync(join(dirPath, 'db_storage'))
|
||||
}
|
||||
|
||||
private resolveVideoBaseDir(dbPath: string, wxid: string): string {
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
const dbPathLower = dbPath.toLowerCase()
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||
if (dbPathContainsWxid) {
|
||||
if (this.isAccountDir(dbPath)) {
|
||||
return join(dbPath, 'msg', 'video')
|
||||
}
|
||||
|
||||
@@ -182,12 +187,7 @@ class VideoService {
|
||||
}
|
||||
|
||||
private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] {
|
||||
const dbPathLower = dbPath.toLowerCase()
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||
|
||||
if (dbPathContainsWxid) {
|
||||
if (this.isAccountDir(dbPath)) {
|
||||
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||
import { BrowserWindow, desktopCapturer, ipcMain, screen } from "electron";
|
||||
import { join } from "path";
|
||||
import { ConfigService } from "../services/config";
|
||||
|
||||
// Linux D-Bus通知服务
|
||||
// 通知交付分流:Windows 走特制液态玻璃通知窗口;
|
||||
// Linux / macOS 走系统通知中心(systemNotificationService)
|
||||
const isLinux = process.platform === "linux";
|
||||
let linuxNotificationService:
|
||||
| typeof import("../services/linuxNotificationService")
|
||||
const isMac = process.platform === "darwin";
|
||||
const usesSystemNotifications = isLinux || isMac;
|
||||
|
||||
// 原生液态玻璃(Windows 专用):DXGI 零拷贝采集 + D3D11 玻璃管线 + DComp 直接上屏,
|
||||
// 感知滞后中位 ~6ms(Chromium 流方案 ~77ms),渲染完全不经过 Electron 进程。
|
||||
// 不可用(系统过旧 / 二进制缺失)时自动回退 Chromium 桌面流管线
|
||||
type NativeGlassModule = typeof import("@hicccc77/electron-liquid-glass");
|
||||
let nativeGlass: NativeGlassModule | null = null;
|
||||
if (!usesSystemNotifications) {
|
||||
try {
|
||||
const mod: NativeGlassModule = require("@hicccc77/electron-liquid-glass");
|
||||
nativeGlass = mod.isSupported() ? mod : null;
|
||||
} catch {
|
||||
nativeGlass = null;
|
||||
}
|
||||
}
|
||||
|
||||
let systemNotificationService:
|
||||
| typeof import("../services/systemNotificationService")
|
||||
| null = null;
|
||||
|
||||
// 用于处理通知点击的回调函数(在Linux上用于导航到会话)
|
||||
// 用于处理通知点击的回调函数(系统通知平台用于导航到会话)
|
||||
let onNotificationNavigate: ((payload: unknown) => void) | null = null;
|
||||
|
||||
export function setNotificationNavigateHandler(
|
||||
@@ -20,19 +38,119 @@ export function setNotificationNavigateHandler(
|
||||
let notificationWindow: BrowserWindow | null = null;
|
||||
let closeTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// 原生玻璃面板:与通知窗口一样常驻复用(创建后隐藏待命),
|
||||
// 展示期跟随渲染层上报的卡片实测矩形(notification:glassRect)
|
||||
let glassPanel: import("@hicccc77/electron-liquid-glass").GlassPanel | null = null;
|
||||
// 面板创建时的 dpr:原生端几何常量按 dpr 换算且仅在创建时设定,
|
||||
// 显示器缩放变化后必须重建面板,否则复用旧 dpr 会算错折射几何
|
||||
let glassPanelDpr = 0;
|
||||
|
||||
function destroyGlassPanel() {
|
||||
if (glassPanel) {
|
||||
try {
|
||||
glassPanel.destroy();
|
||||
} catch {
|
||||
/* 面板已随会话销毁 */
|
||||
}
|
||||
glassPanel = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 视觉参数换算(缺省值与渲染层 GLASS_PARAMS 一致,CSS 值 → 物理像素/比例)
|
||||
function toGlassParams(payload: Record<string, number | undefined>, scale: number) {
|
||||
return {
|
||||
cornerRadius: (payload.cornerRadius ?? 16) * scale,
|
||||
blurSigma: (payload.blurSigma ?? 2) * scale,
|
||||
displacementScale: payload.displacementScale ?? 70,
|
||||
aberrationIntensity: payload.aberrationIntensity ?? 1,
|
||||
saturation: (payload.saturation ?? 140) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
// 创建或复用玻璃面板并同步全部状态(不负责 show/hide)。
|
||||
// 空闲预热与 glassRect 上报共用此路径:预热时占位几何 + 空亮度带,
|
||||
// 首次上报会以实测值幂等覆盖
|
||||
function ensureGlassPanel(
|
||||
bounds: { x: number; y: number; width: number; height: number },
|
||||
params: ReturnType<typeof toGlassParams>,
|
||||
scale: number,
|
||||
bands: Array<{ id: number; x: number; y: number; width: number; height: number }>,
|
||||
) {
|
||||
if (!nativeGlass || !notificationWindow || notificationWindow.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
if (glassPanel && glassPanelDpr !== scale) destroyGlassPanel();
|
||||
if (!glassPanel) {
|
||||
glassPanel = nativeGlass.createPanel({
|
||||
...bounds,
|
||||
...params,
|
||||
dpr: scale,
|
||||
anchorWindow: notificationWindow,
|
||||
lumaBands: bands,
|
||||
onLuma: (bandStats) => {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
notificationWindow.webContents.send("notification:luma", bandStats);
|
||||
}
|
||||
},
|
||||
});
|
||||
glassPanelDpr = scale;
|
||||
} else {
|
||||
glassPanel.setBounds(bounds);
|
||||
glassPanel.setParams(params);
|
||||
glassPanel.setLumaBands(bands);
|
||||
glassPanel.anchor(notificationWindow);
|
||||
}
|
||||
return glassPanel;
|
||||
}
|
||||
|
||||
// —— Chromium 桌面流回退管线(仅原生玻璃不可用时启用:系统过旧 / 二进制缺失)——
|
||||
// 透明窗口内 backdrop-filter 采样不到桌面像素(Chromium 只能采样页面内容),
|
||||
// 回退路径下折射源由渲染层用 getUserMedia 开启的实时桌面视频流提供。
|
||||
// 主进程只负责预热并缓存屏幕采集源 ID:desktopCapturer.getSources 会同步
|
||||
// 初始化 DirectX 采集管线(实测阻塞主线程 300ms+,不要缩略图也一样),
|
||||
// 绝不能出现在通知弹出路径上,否则每条通知都会让整个应用卡一下。
|
||||
// 通知窗口自身已设置 content protection,不会被拍进视频流造成折射回环
|
||||
let cachedSourceId: string | null = null;
|
||||
let sourceIdRefreshing: Promise<void> | null = null;
|
||||
|
||||
function refreshDesktopSourceId(): Promise<void> {
|
||||
if (sourceIdRefreshing) return sourceIdRefreshing;
|
||||
sourceIdRefreshing = (async () => {
|
||||
try {
|
||||
const display = screen.getPrimaryDisplay();
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ["screen"],
|
||||
thumbnailSize: { width: 0, height: 0 },
|
||||
});
|
||||
const source =
|
||||
sources.find((s) => s.display_id === String(display.id)) ?? sources[0];
|
||||
cachedSourceId = source?.id ?? null;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[NotificationWindow] Failed to refresh desktop source id:",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
sourceIdRefreshing = null;
|
||||
}
|
||||
})();
|
||||
return sourceIdRefreshing;
|
||||
}
|
||||
|
||||
export function destroyNotificationWindow() {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
lastNotificationData = null;
|
||||
destroyGlassPanel();
|
||||
|
||||
// Linux:关闭通知服务并清理缓存(fire-and-forget,不阻塞退出)
|
||||
if (isLinux && linuxNotificationService) {
|
||||
linuxNotificationService.shutdownLinuxNotificationService().catch((error) => {
|
||||
console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error);
|
||||
// Linux/macOS:关闭系统通知服务并清理缓存(fire-and-forget,不阻塞退出)
|
||||
if (usesSystemNotifications && systemNotificationService) {
|
||||
systemNotificationService.shutdownSystemNotificationService().catch((error) => {
|
||||
console.warn("[NotificationWindow] Failed to shutdown system notification service:", error);
|
||||
});
|
||||
linuxNotificationService = null;
|
||||
systemNotificationService = null;
|
||||
}
|
||||
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
@@ -50,6 +168,17 @@ export function destroyNotificationWindow() {
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口通过 min/max 锁定尺寸,程序化调整尺寸前需要同步放宽限制。
|
||||
// 尺寸未变化时直接跳过:可见状态下重复 setSize 会让 DWM
|
||||
// 短暂拉伸旧帧缓冲,在通知周围闪出一圈"幽灵轮廓"
|
||||
function applyWindowSize(win: BrowserWindow, width: number, height: number) {
|
||||
const [currentWidth, currentHeight] = win.getSize();
|
||||
if (currentWidth === width && currentHeight === height) return;
|
||||
win.setMinimumSize(width, height);
|
||||
win.setMaximumSize(width, height);
|
||||
win.setSize(width, height);
|
||||
}
|
||||
|
||||
export function createNotificationWindow() {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow;
|
||||
@@ -68,9 +197,12 @@ export function createNotificationWindow() {
|
||||
notificationWindow = new BrowserWindow({
|
||||
width: width,
|
||||
height: height,
|
||||
type: "toolbar", // 有助于在某些操作系统上保持置顶
|
||||
type: "toolbar", // 辅助置顶(仅 Windows 走此窗口)
|
||||
frame: false,
|
||||
// 无边框透明窗口:不会出现 DWM 材质窗口的系统描边,
|
||||
// 玻璃底下的"桌面"由原生面板或渲染层的实时桌面视频流提供
|
||||
transparent: true,
|
||||
hasShadow: false, // 卡片投影由 CSS 提供
|
||||
resizable: false,
|
||||
show: false,
|
||||
alwaysOnTop: true,
|
||||
@@ -85,6 +217,13 @@ export function createNotificationWindow() {
|
||||
},
|
||||
});
|
||||
|
||||
// 把窗口从屏幕采集中排除(WDA_EXCLUDEFROMCAPTURE,Win10 2004+):
|
||||
// 玻璃靠采集屏幕获得折射像素,不排除自身会拍到自己形成无限回环。
|
||||
// 副作用:系统截图/录屏中看不到通知窗口本体
|
||||
notificationWindow.setContentProtection(true);
|
||||
|
||||
applyWindowSize(notificationWindow, width, height);
|
||||
|
||||
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透
|
||||
|
||||
@@ -99,6 +238,12 @@ export function createNotificationWindow() {
|
||||
console.log("[NotificationWindow] Loading URL:", loadUrl);
|
||||
notificationWindow.loadURL(loadUrl);
|
||||
|
||||
// Chromium 会按 file:// 域持久化页面缩放(主窗口与通知窗口同域):
|
||||
// 任何来源的缩放残留都会让通知按错误的逻辑尺寸排版,这里强制钉回 1
|
||||
notificationWindow.webContents.on("did-finish-load", () => {
|
||||
notificationWindow?.webContents.setZoomFactor(1);
|
||||
});
|
||||
|
||||
notificationWindow.on("closed", () => {
|
||||
notificationWindow = null;
|
||||
});
|
||||
@@ -139,9 +284,9 @@ export async function showNotification(data: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// Linux 使用 D-Bus 通知
|
||||
if (isLinux) {
|
||||
await showLinuxNotification(data);
|
||||
// Linux / macOS 走系统通知中心,仅 Windows 使用特制液态玻璃窗口
|
||||
if (usesSystemNotifications) {
|
||||
await showViaSystemNotification(data);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,24 +307,22 @@ export async function showNotification(data: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// 显示Linux通知
|
||||
async function showLinuxNotification(data: any) {
|
||||
if (!linuxNotificationService) {
|
||||
// 经系统通知中心显示(Linux / macOS)
|
||||
async function showViaSystemNotification(data: any) {
|
||||
if (!systemNotificationService) {
|
||||
try {
|
||||
linuxNotificationService =
|
||||
await import("../services/linuxNotificationService");
|
||||
systemNotificationService =
|
||||
await import("../services/systemNotificationService");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[NotificationWindow] Failed to load Linux notification service:",
|
||||
"[NotificationWindow] Failed to load system notification service:",
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { showLinuxNotification: showNotification } = linuxNotificationService;
|
||||
|
||||
const notificationData = {
|
||||
systemNotificationService.showSystemNotification({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
avatarUrl: data.avatarUrl,
|
||||
@@ -188,21 +331,20 @@ async function showLinuxNotification(data: any) {
|
||||
insightRecordId: data.insightRecordId,
|
||||
targetRoute: data.targetRoute,
|
||||
expireTimeout: 5000,
|
||||
};
|
||||
|
||||
showNotification(notificationData);
|
||||
});
|
||||
}
|
||||
|
||||
let lastNotificationData: any = null;
|
||||
|
||||
async function showAndSend(win: BrowserWindow, data: any) {
|
||||
lastNotificationData = data;
|
||||
const config = ConfigService.getInstance();
|
||||
const position = (await config.get("notificationPosition")) || "top-right";
|
||||
|
||||
// 更新位置
|
||||
const { width: screenWidth, height: screenHeight } =
|
||||
screen.getPrimaryDisplay().workAreaSize;
|
||||
// 更新位置:基于工作区完整矩形(含原点偏移)定位。
|
||||
// macOS 菜单栏、Windows 任务栏靠上/靠左时工作区原点不为 (0,0),
|
||||
// 只用 workAreaSize 会把通知压进系统栏下面
|
||||
const display = screen.getPrimaryDisplay();
|
||||
const workArea = display.workArea;
|
||||
const winWidth = position === "top-center" ? 280 : 344;
|
||||
const winHeight = 114;
|
||||
const padding = 20;
|
||||
@@ -212,57 +354,81 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
||||
|
||||
switch (position) {
|
||||
case "top-center":
|
||||
x = (screenWidth - winWidth) / 2;
|
||||
y = padding;
|
||||
x = workArea.x + (workArea.width - winWidth) / 2;
|
||||
y = workArea.y + padding;
|
||||
break;
|
||||
case "top-right":
|
||||
x = screenWidth - winWidth - padding;
|
||||
y = padding;
|
||||
x = workArea.x + workArea.width - winWidth - padding;
|
||||
y = workArea.y + padding;
|
||||
break;
|
||||
case "bottom-right":
|
||||
x = screenWidth - winWidth - padding;
|
||||
y = screenHeight - winHeight - padding;
|
||||
x = workArea.x + workArea.width - winWidth - padding;
|
||||
y = workArea.y + workArea.height - winHeight - padding;
|
||||
break;
|
||||
case "top-left":
|
||||
x = padding;
|
||||
y = padding;
|
||||
x = workArea.x + padding;
|
||||
y = workArea.y + padding;
|
||||
break;
|
||||
case "bottom-left":
|
||||
x = padding;
|
||||
y = screenHeight - winHeight - padding;
|
||||
x = workArea.x + padding;
|
||||
y = workArea.y + workArea.height - winHeight - padding;
|
||||
break;
|
||||
}
|
||||
|
||||
win.setPosition(Math.floor(x), Math.floor(y));
|
||||
win.setSize(winWidth, winHeight); // 确保尺寸
|
||||
const winX = Math.floor(x);
|
||||
const winY = Math.floor(y);
|
||||
|
||||
// 采集源 ID 使用启动时预热的缓存;仅当首条通知早于预热完成时补一次。
|
||||
// 除此之外通知路径不触碰 desktopCapturer,弹出过程主线程零阻塞。
|
||||
// 原生玻璃可用时折射由原生面板提供,渲染层不再开启 Chromium 桌面流
|
||||
if (!nativeGlass && !cachedSourceId) await refreshDesktopSourceId();
|
||||
const payload = {
|
||||
...data,
|
||||
position,
|
||||
backdrop: {
|
||||
native: Boolean(nativeGlass),
|
||||
sourceId: nativeGlass ? null : cachedSourceId,
|
||||
winX,
|
||||
winY,
|
||||
width: display.size.width,
|
||||
height: display.size.height,
|
||||
},
|
||||
};
|
||||
lastNotificationData = payload;
|
||||
|
||||
win.setPosition(winX, winY);
|
||||
// 窗口高度始终沿用渲染层的实测校准值(notification:resize),
|
||||
// 这里只同步宽度;反复重置高度会造成 114→实测高度的弹跳闪烁
|
||||
const [, currentHeight] = win.getSize();
|
||||
applyWindowSize(win, winWidth, currentHeight);
|
||||
|
||||
win.webContents.send("notification:show", payload);
|
||||
|
||||
// 设为可交互
|
||||
win.setIgnoreMouseEvents(false);
|
||||
win.showInactive(); // 显示但不聚焦
|
||||
win.setAlwaysOnTop(true, "screen-saver"); // 最高层级
|
||||
|
||||
win.webContents.send("notification:show", { ...data, position });
|
||||
|
||||
// 自动关闭计时器通常由渲染进程管理
|
||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||
}
|
||||
|
||||
// 注册通知处理
|
||||
export async function registerNotificationHandlers() {
|
||||
// Linux: 初始化D-Bus服务
|
||||
if (isLinux) {
|
||||
// Linux / macOS:初始化系统通知服务
|
||||
if (usesSystemNotifications) {
|
||||
try {
|
||||
const linuxNotificationModule =
|
||||
await import("../services/linuxNotificationService");
|
||||
linuxNotificationService = linuxNotificationModule;
|
||||
const systemNotificationModule =
|
||||
await import("../services/systemNotificationService");
|
||||
systemNotificationService = systemNotificationModule;
|
||||
|
||||
// 初始化服务
|
||||
await linuxNotificationModule.initLinuxNotificationService();
|
||||
await systemNotificationModule.initSystemNotificationService();
|
||||
|
||||
// 在Linux上注册通知点击回调
|
||||
linuxNotificationModule.onNotificationAction((payload: unknown) => {
|
||||
// 注册通知点击回调(导航到会话/洞察)
|
||||
systemNotificationModule.onNotificationAction((payload: unknown) => {
|
||||
console.log(
|
||||
"[NotificationWindow] Linux notification clicked, sessionId:",
|
||||
"[NotificationWindow] System notification clicked, payload:",
|
||||
payload,
|
||||
);
|
||||
// 如果设置了导航处理程序,则使用该处理程序;否则,回退到ipcMain方法。
|
||||
@@ -278,11 +444,11 @@ export async function registerNotificationHandlers() {
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[NotificationWindow] Linux notification service initialized",
|
||||
"[NotificationWindow] System notification service initialized",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[NotificationWindow] Failed to initialize Linux notification service:",
|
||||
"[NotificationWindow] Failed to initialize system notification service:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
@@ -293,20 +459,60 @@ export async function registerNotificationHandlers() {
|
||||
});
|
||||
|
||||
ipcMain.handle("notification:close", () => {
|
||||
if (isLinux && linuxNotificationService) {
|
||||
// 注册通知点击回调函数。Linux通知通过D-Bus自动关闭,但我们可以根据需要进行跟踪
|
||||
if (usesSystemNotifications) {
|
||||
// 系统通知由通知中心管理生命周期,无窗口可关
|
||||
return;
|
||||
}
|
||||
// 窗口即将隐藏,玻璃面板立即消失(渲染层通常已提前发过淡出信号)
|
||||
glassPanel?.hide(0);
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
notificationWindow.hide();
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
});
|
||||
|
||||
// —— 原生玻璃面板生命周期(仅 nativeGlass 可用时渲染层才会发这些消息)——
|
||||
|
||||
// 渲染层在卡片入场动画落定后上报实测几何(窗口本地 CSS 像素)
|
||||
ipcMain.on("notification:glassRect", (_event, payload) => {
|
||||
if (!nativeGlass || !notificationWindow || notificationWindow.isDestroyed()) return;
|
||||
const display = screen.getDisplayMatching(notificationWindow.getBounds());
|
||||
// 优先用渲染层实测的 devicePixelRatio(已含可能的页面缩放),显示器缩放兜底
|
||||
const scale = payload.dpr || display.scaleFactor;
|
||||
const [winX, winY] = notificationWindow.getPosition();
|
||||
const toPhysical = (rect: { x: number; y: number; width: number; height: number }) => ({
|
||||
x: Math.round(rect.x * scale),
|
||||
y: Math.round(rect.y * scale),
|
||||
width: Math.round(rect.width * scale),
|
||||
height: Math.round(rect.height * scale),
|
||||
});
|
||||
const bounds = {
|
||||
x: Math.round((winX + payload.card.x) * scale),
|
||||
y: Math.round((winY + payload.card.y) * scale),
|
||||
width: Math.round(payload.card.width * scale),
|
||||
height: Math.round(payload.card.height * scale),
|
||||
};
|
||||
// 亮度带矩形是卡片本地坐标(带 id:0=整卡 1=标题行 2=正文)
|
||||
const bands = (payload.bands ?? []).map(
|
||||
(band: { id: number; x: number; y: number; width: number; height: number }) => ({
|
||||
id: band.id,
|
||||
...toPhysical(band),
|
||||
}),
|
||||
);
|
||||
|
||||
const panel = ensureGlassPanel(bounds, toGlassParams(payload, scale), scale, bands);
|
||||
panel?.show(120);
|
||||
});
|
||||
|
||||
// 渲染层退场动画开始:面板与卡片的 0.3s 渐隐同步淡出
|
||||
ipcMain.on("notification:glassHide", () => {
|
||||
glassPanel?.hide(240);
|
||||
});
|
||||
|
||||
// Handle renderer ready event (fix race condition)
|
||||
ipcMain.on("notification:ready", (event) => {
|
||||
if (isLinux) {
|
||||
// Linux不需要通知窗口,拦截通知窗口渲染
|
||||
if (usesSystemNotifications) {
|
||||
// 系统通知平台不需要通知窗口,拦截通知窗口渲染
|
||||
return;
|
||||
}
|
||||
console.log("[NotificationWindow] Renderer ready, checking cached data");
|
||||
@@ -323,10 +529,44 @@ export async function registerNotificationHandlers() {
|
||||
}
|
||||
});
|
||||
|
||||
// 启动空闲期预热(系统通知平台不需要):
|
||||
// - 采集源 ID:getSources 初始化采集管线会同步阻塞主线程 300ms+,放到空闲期一次性完成
|
||||
// (原生玻璃可用时不需要 Chromium 采集源,跳过)
|
||||
// - 预创建通知窗口:首条通知免去窗口创建和渲染器冷启动
|
||||
// - 预创建原生玻璃面板(隐藏待命):worker 线程、D3D 设备、着色器编译、
|
||||
// DComp 窗口链全部离开首条通知的可见路径(原生端实测 ~150ms);
|
||||
// 隐藏面板零渲染零采集,常驻开销可忽略
|
||||
if (!usesSystemNotifications) {
|
||||
setTimeout(() => {
|
||||
if (!nativeGlass) refreshDesktopSourceId();
|
||||
const win = createNotificationWindow();
|
||||
if (nativeGlass && win) {
|
||||
const scale = screen.getPrimaryDisplay().scaleFactor;
|
||||
const [winX, winY] = win.getPosition();
|
||||
ensureGlassPanel(
|
||||
{
|
||||
x: Math.round(winX * scale),
|
||||
y: Math.round(winY * scale),
|
||||
width: Math.round(344 * scale),
|
||||
height: Math.round(96 * scale),
|
||||
},
|
||||
toGlassParams({}, scale),
|
||||
scale,
|
||||
[],
|
||||
);
|
||||
}
|
||||
}, 3000);
|
||||
if (!nativeGlass) {
|
||||
// 显示器增删后源 ID 可能失效,重新预热
|
||||
screen.on("display-added", () => refreshDesktopSourceId());
|
||||
screen.on("display-removed", () => refreshDesktopSourceId());
|
||||
}
|
||||
}
|
||||
|
||||
// Handle resize request from renderer
|
||||
ipcMain.on("notification:resize", (event, { width, height }) => {
|
||||
if (isLinux) {
|
||||
// Linux 通知通过D-Bus自动调整大小
|
||||
if (usesSystemNotifications) {
|
||||
// 系统通知由通知中心排版,无需窗口尺寸同步
|
||||
return;
|
||||
}
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
@@ -342,11 +582,8 @@ export async function registerNotificationHandlers() {
|
||||
// We can re-call setPosition or just let it be.
|
||||
// If bottom-right, y needs to prevent overflow.
|
||||
|
||||
// Ideally we get current config position
|
||||
const bounds = notificationWindow.getBounds();
|
||||
// Check if we need to adjust Y?
|
||||
// For now, let's just set the size as requested.
|
||||
notificationWindow.setSize(Math.round(width), Math.round(height));
|
||||
applyWindowSize(notificationWindow, Math.round(width), Math.round(height));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Generated
+35
-3
@@ -9,6 +9,7 @@
|
||||
"version": "5.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@hicccc77/electron-liquid-glass": "^0.3.0",
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
@@ -1050,6 +1051,26 @@
|
||||
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hicccc77/electron-liquid-glass": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hicccc77/electron-liquid-glass/-/electron-liquid-glass-0.3.0.tgz",
|
||||
"integrity": "sha512-8myTqXmQWodYDZX2z9aGsv2Dthbqg3IFIrQiNFYhAATaz1q2FG8FkPSLNNEv7VXbsQpCy8h2Rn8hcA+pjlcpVQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@hicccc77/electron-liquid-glass/node_modules/node-addon-api": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
||||
"integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
@@ -7085,9 +7106,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "4.28.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz",
|
||||
"integrity": "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==",
|
||||
"version": "4.33.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz",
|
||||
"integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7140,6 +7161,17 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/isexe": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
||||
|
||||
+4
-1
@@ -17,12 +17,13 @@
|
||||
"rebuild": "electron-rebuild",
|
||||
"dev": "node scripts/prepare-electron-runtime.cjs && vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"build": "node scripts/clean-dist-electron.cjs && tsc && vite build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "node scripts/prepare-electron-runtime.cjs && vite --mode electron",
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hicccc77/electron-liquid-glass": "^0.3.0",
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
@@ -264,6 +265,7 @@
|
||||
"node_modules/sherpa-onnx-*/*",
|
||||
"node_modules/sherpa-onnx-*/**/*",
|
||||
"node_modules/ffmpeg-static/**/*",
|
||||
"node_modules/@hicccc77/electron-liquid-glass/**/*",
|
||||
"resources/wedecrypt/**/*.node"
|
||||
],
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
@@ -272,6 +274,7 @@
|
||||
"brace-expansion": {
|
||||
".": "^5.0.6"
|
||||
},
|
||||
"node-abi": ">=4.33.0",
|
||||
"exceljs": {
|
||||
"uuid": "11.1.1"
|
||||
},
|
||||
|
||||
@@ -578,6 +578,7 @@
|
||||
(function bootstrapSplash() {
|
||||
var params = new URLSearchParams(window.location.search || "");
|
||||
applyTheme(params.get("themeId") || "cloud-dancer", params.get("themeMode") || "system");
|
||||
setVersion(params.get("version") || "");
|
||||
updateProgress(0, "", false);
|
||||
})();
|
||||
</script>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+55
-39
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from 'react-router-dom'
|
||||
import TitleBar from './components/TitleBar'
|
||||
import Sidebar from './components/Sidebar'
|
||||
@@ -6,17 +6,10 @@ import RouteGuard from './components/RouteGuard'
|
||||
import WelcomePage from './pages/WelcomePage'
|
||||
import HomePage from './pages/HomePage'
|
||||
import ChatPage from './pages/ChatPage'
|
||||
import AnalyticsPage from './pages/AnalyticsPage'
|
||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
|
||||
import AnnualReportPage from './pages/AnnualReportPage'
|
||||
import AnnualReportWindow from './pages/AnnualReportWindow'
|
||||
import DualReportPage from './pages/DualReportPage'
|
||||
import DualReportWindow from './pages/DualReportWindow'
|
||||
import AgreementPage from './pages/AgreementPage'
|
||||
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import ExportPage from './pages/Export/ExportPage'
|
||||
import MyFootprintPage from './pages/MyFootprintPage'
|
||||
import VideoWindow from './pages/VideoWindow'
|
||||
import ImageWindow from './pages/ImageWindow'
|
||||
@@ -43,6 +36,16 @@ import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import WindowCloseDialog from './components/WindowCloseDialog'
|
||||
|
||||
// 重型页面懒加载:echarts 分析页、年度/双人报告(含大字体样式)、导出页模块树,
|
||||
// 从首屏 bundle 中拆出,显著缩短主窗口首次渲染时间
|
||||
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'))
|
||||
const GroupAnalyticsPage = lazy(() => import('./pages/GroupAnalyticsPage'))
|
||||
const AnnualReportPage = lazy(() => import('./pages/AnnualReportPage'))
|
||||
const AnnualReportWindow = lazy(() => import('./pages/AnnualReportWindow'))
|
||||
const DualReportPage = lazy(() => import('./pages/DualReportPage'))
|
||||
const DualReportWindow = lazy(() => import('./pages/DualReportWindow'))
|
||||
const ExportPage = lazy(() => import('./pages/Export/ExportPage'))
|
||||
|
||||
function RouteStateRedirect({ to }: { to: string }) {
|
||||
const location = useLocation()
|
||||
|
||||
@@ -532,12 +535,20 @@ function App() {
|
||||
|
||||
// 独立年度报告全屏窗口
|
||||
if (isAnnualReportWindow) {
|
||||
return <AnnualReportWindow />
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AnnualReportWindow />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立双人报告全屏窗口
|
||||
if (isDualReportWindow) {
|
||||
return <DualReportWindow />
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DualReportWindow />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
@@ -696,38 +707,43 @@ function App() {
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<main className="content">
|
||||
<RouteGuard>
|
||||
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
|
||||
<ExportPage />
|
||||
</div>
|
||||
{/* 独立 Suspense 边界:Export chunk 异步加载时不阻塞首屏路由渲染 */}
|
||||
<Suspense fallback={null}>
|
||||
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
|
||||
<ExportPage />
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
<Routes location={routeLocation}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/account-management" element={<AccountManagementPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Suspense fallback={null}>
|
||||
<Routes location={routeLocation}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/account-management" element={<AccountManagementPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
|
||||
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
|
||||
<Route path="/analytics/private/view" element={<AnalyticsPage />} />
|
||||
<Route path="/analytics/group" element={<GroupAnalyticsPage />} />
|
||||
<Route path="/analytics/view" element={<RouteStateRedirect to="/analytics/private/view" />} />
|
||||
<Route path="/group-analytics" element={<RouteStateRedirect to="/analytics/group" />} />
|
||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||
<Route path="/dual-report" element={<DualReportPage />} />
|
||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||
<Route path="/footprint" element={<MyFootprintPage />} />
|
||||
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
|
||||
<Route path="/analytics/private/view" element={<AnalyticsPage />} />
|
||||
<Route path="/analytics/group" element={<GroupAnalyticsPage />} />
|
||||
<Route path="/analytics/view" element={<RouteStateRedirect to="/analytics/private/view" />} />
|
||||
<Route path="/group-analytics" element={<RouteStateRedirect to="/analytics/group" />} />
|
||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||
<Route path="/dual-report" element={<DualReportPage />} />
|
||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||
<Route path="/footprint" element={<MyFootprintPage />} />
|
||||
|
||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/insight-inbox" element={<InsightInboxPage />} />
|
||||
<Route path="/biz" element={<BizPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/backup" element={<BackupPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/insight-inbox" element={<InsightInboxPage />} />
|
||||
<Route path="/biz" element={<BizPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/backup" element={<BackupPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</RouteGuard>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -12,13 +12,18 @@
|
||||
z-index: 100;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
|
||||
.confirm-dialog {
|
||||
width: 480px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
// 液态玻璃外壳:宽度与入场动画在玻璃根节点上
|
||||
.confirm-dialog-glass {
|
||||
width: min(480px, calc(100% - 48px));
|
||||
animation: slideUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
width: 100%;
|
||||
// 半透明背景透出玻璃折射,阴影由 LiquidGlass 提供
|
||||
background: color-mix(in srgb, var(--bg-primary) 80%, transparent);
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.close-btn {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { X } from 'lucide-react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './ConfirmDialog.scss'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
@@ -14,19 +15,26 @@ export default function ConfirmDialog({ open, title, message, onConfirm, onCance
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||
<div className="confirm-dialog" onClick={e => e.stopPropagation()}>
|
||||
<button className="close-btn" onClick={onCancel}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{title && <div className="dialog-title">{title}</div>}
|
||||
<div className="dialog-content">
|
||||
<p style={{ whiteSpace: 'pre-line' }}>{message}</p>
|
||||
<LiquidGlass
|
||||
className="confirm-dialog-glass"
|
||||
cornerRadius={20}
|
||||
displacementScale={36}
|
||||
aberrationIntensity={1.5}
|
||||
>
|
||||
<div className="confirm-dialog" onClick={e => e.stopPropagation()}>
|
||||
<button className="close-btn" onClick={onCancel}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{title && <div className="dialog-title">{title}</div>}
|
||||
<div className="dialog-content">
|
||||
<p style={{ whiteSpace: 'pre-line' }}>{message}</p>
|
||||
</div>
|
||||
<div className="dialog-actions">
|
||||
<button className="btn-cancel" onClick={onCancel}>取消</button>
|
||||
<button className="btn-confirm" onClick={onConfirm}>开始获取</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dialog-actions">
|
||||
<button className="btn-cancel" onClick={onCancel}>取消</button>
|
||||
<button className="btn-confirm" onClick={onConfirm}>开始获取</button>
|
||||
</div>
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -54,18 +54,31 @@
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: var(--bg-secondary-solid, var(--bg-primary, var(--card-bg)));
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border: 1px solid var(--border-color);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
animation: dropdownFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
// 内容层底色:中心保留半透明底保证日历可读,四周 26px 渐隐露出玻璃的边缘折射带
|
||||
.picker-dropdown-body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background-color: color-mix(in srgb, var(--bg-secondary-solid, var(--bg-primary, var(--card-bg))) 68%, transparent);
|
||||
mask:
|
||||
linear-gradient(to right, transparent, #000 26px, #000 calc(100% - 26px), transparent),
|
||||
linear-gradient(to bottom, transparent, #000 26px, #000 calc(100% - 26px), transparent);
|
||||
mask-composite: intersect;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdownFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Calendar, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './DateRangePicker.scss'
|
||||
|
||||
interface DateRangePickerProps {
|
||||
@@ -198,6 +199,13 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
|
||||
{isOpen && (
|
||||
<div className="picker-dropdown">
|
||||
<LiquidGlass
|
||||
cornerRadius={16}
|
||||
displacementScale={48}
|
||||
aberrationIntensity={1.5}
|
||||
blurAmount={0.25}
|
||||
>
|
||||
<div className="picker-dropdown-body">
|
||||
<div className="quick-options">
|
||||
{QUICK_OPTIONS.map(opt => (
|
||||
<button key={opt.label} className="quick-option" onClick={() => handleQuickOption(opt.days)}>
|
||||
@@ -246,6 +254,8 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -52,10 +52,9 @@ export function GlobalSessionMonitor() {
|
||||
return () => { }
|
||||
}, [])
|
||||
|
||||
// 注意:导出统计的预加载已移除。
|
||||
// 之前在这里预加载所有会话的统计到前端 zustand store,会导致启动时内存从 200MB 飙升到 500+MB。
|
||||
// 后端 main.ts 启动时已经通过 getExportSessionStats 将统计结果缓存在主进程内存中,
|
||||
// 导出页打开时会按需拉取当前可见批次的会话统计,后端缓存命中率很高,响应速度足够快。
|
||||
// 注意:导出统计的预加载已移除(前端全量预载曾导致内存从 200MB 飙升到 500+MB,
|
||||
// 主进程启动预热也因拖慢启动速度被移除)。
|
||||
// 导出页打开时按需拉取可见批次的会话统计,主进程有磁盘缓存兜底,响应速度足够快。
|
||||
|
||||
|
||||
const refreshSessions = async () => {
|
||||
|
||||
@@ -3,20 +3,29 @@
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
width: 312px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: none;
|
||||
background-color: var(--bg-secondary-solid, #ffffff) !important;
|
||||
opacity: 1;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
mix-blend-mode: normal;
|
||||
isolation: isolate;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||
padding: 12px;
|
||||
z-index: 1600;
|
||||
}
|
||||
|
||||
// 内容层底色:中心保留半透明底保证日历可读,四周 26px 渐隐露出玻璃的边缘折射带
|
||||
.jump-date-popover-body {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background-color: color-mix(in srgb, var(--bg-secondary-solid, #ffffff) 68%, transparent);
|
||||
mask:
|
||||
linear-gradient(to right, transparent, #000 26px, #000 calc(100% - 26px), transparent),
|
||||
linear-gradient(to bottom, transparent, #000 26px, #000 calc(100% - 26px), transparent);
|
||||
mask-composite: intersect;
|
||||
}
|
||||
}
|
||||
|
||||
.jump-date-popover .calendar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -49,8 +58,7 @@
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
background-color: var(--bg-secondary-solid, #ffffff) !important;
|
||||
background-color: color-mix(in srgb, var(--bg-secondary-solid, #ffffff) 80%, transparent);
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './JumpToDatePopover.scss'
|
||||
|
||||
interface JumpToDatePopoverProps {
|
||||
@@ -185,6 +186,13 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
|
||||
return (
|
||||
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
||||
<LiquidGlass
|
||||
cornerRadius={14}
|
||||
displacementScale={48}
|
||||
aberrationIntensity={1.5}
|
||||
blurAmount={0.25}
|
||||
>
|
||||
<div className="jump-date-popover-body">
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
className="nav-btn"
|
||||
@@ -293,6 +301,8 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { LensDisplacementMap } from './lensDisplacementMap'
|
||||
|
||||
/**
|
||||
* SVG 滤镜:透镜位移折射 + 边缘色散
|
||||
*
|
||||
* 透镜贴图中心是中性灰(零位移),中心天然保持清晰;RGB 三通道位移量递减,
|
||||
* 在折射最强的边缘自然分离出色散。
|
||||
* 几何约定(Chromium 实测):feImage 的像素几何 (0,0,w,h) 与元素原点精确对齐,
|
||||
* 负坐标/百分比几何解析不可靠;滤镜区域收紧为元素本身,位移不会画出卡片之外。
|
||||
* scale 用 2×maxScale 还原贴图编码的几何位移,displacementScale 以 70 为基准整体缩放
|
||||
*/
|
||||
export default function GlassFilter({ id, map, displacementScale, aberrationIntensity }: {
|
||||
id: string
|
||||
map: LensDisplacementMap
|
||||
displacementScale: number
|
||||
aberrationIntensity: number
|
||||
}) {
|
||||
const scale = 2 * map.maxScale * (displacementScale / 70)
|
||||
return (
|
||||
<svg style={{ position: 'absolute', width: 0, height: 0, pointerEvents: 'none' }} aria-hidden="true">
|
||||
<defs>
|
||||
<filter id={id} x="0%" y="0%" width="100%" height="100%" colorInterpolationFilters="sRGB">
|
||||
<feImage x="0" y="0" width={map.width} height={map.height} result="DISPLACEMENT_MAP" href={map.url} preserveAspectRatio="none" />
|
||||
|
||||
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={scale} xChannelSelector="R" yChannelSelector="B" result="RED_DISPLACED" />
|
||||
<feColorMatrix
|
||||
in="RED_DISPLACED"
|
||||
type="matrix"
|
||||
values="1 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 1 0"
|
||||
result="RED_CHANNEL"
|
||||
/>
|
||||
|
||||
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={scale * (1 - aberrationIntensity * 0.05)} xChannelSelector="R" yChannelSelector="B" result="GREEN_DISPLACED" />
|
||||
<feColorMatrix
|
||||
in="GREEN_DISPLACED"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0
|
||||
0 1 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 1 0"
|
||||
result="GREEN_CHANNEL"
|
||||
/>
|
||||
|
||||
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={scale * (1 - aberrationIntensity * 0.1)} xChannelSelector="R" yChannelSelector="B" result="BLUE_DISPLACED" />
|
||||
<feColorMatrix
|
||||
in="BLUE_DISPLACED"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 1 0 0
|
||||
0 0 0 1 0"
|
||||
result="BLUE_CHANNEL"
|
||||
/>
|
||||
|
||||
{/* screen 混合合并三通道:位移一致的区域无损还原原色 */}
|
||||
<feBlend in="GREEN_CHANNEL" in2="BLUE_CHANNEL" mode="screen" result="GB_COMBINED" />
|
||||
<feBlend in="RED_CHANNEL" in2="GB_COMBINED" mode="screen" result="RGB_COMBINED" />
|
||||
|
||||
<feGaussianBlur in="RGB_COMBINED" stdDeviation={Math.max(0.1, 0.5 - aberrationIntensity * 0.1)} />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// 玻璃背景全局运动估计器:块匹配连续两帧的低分辨率亮度网格,
|
||||
// 得到内容平移速度,用于预测采样(补偿采集链路 ~60ms 延迟,见 glassStreamRenderer)。
|
||||
//
|
||||
// 设计要点:
|
||||
// - 置信门控:只有当位移显著优于零位移(SAD 对比)且方向与历史一致时才更新速度,
|
||||
// 旋转/局部运动/画面突变等不可预测场景自动衰减到零补偿(退回普通滞后表现)
|
||||
// - 亚格点精度:SAD 极值点抛物线插值,配合 EMA 平滑,避免逐帧抖动
|
||||
// - 失败模式安全:估计错误的最坏结果是折射内容短暂偏移后回正,且经过模糊层视觉柔和
|
||||
|
||||
export const MOTION_GRID_W = 56
|
||||
export const MOTION_GRID_H = 24
|
||||
|
||||
const SEARCH_X = 10 // 搜索半径(格):±10 格 ≈ ±76px/帧 ≈ 4500px/s
|
||||
const SEARCH_Y = 6
|
||||
const MAX_VELOCITY = 6 // px/ms 上限(6000px/s,超出视为误匹配)
|
||||
const MAX_OFFSET = 72 // 预测偏移上限(px),防极端外推
|
||||
|
||||
export interface GlassMotionEstimator {
|
||||
/** 输入当前帧的 RGBA 网格与时间戳,返回预测采样偏移目标(px,运动方向) */
|
||||
update: (rgba: Uint8Array, timestampMs: number) => { x: number; y: number }
|
||||
/** 无新帧时的衰减步(内容停止变化后把偏移收回零) */
|
||||
decay: () => { x: number; y: number }
|
||||
}
|
||||
|
||||
/** SAD 极值点抛物线亚格点插值 */
|
||||
function subCell(sadPrev: number, sadBest: number, sadNext: number): number {
|
||||
const denom = sadPrev - 2 * sadBest + sadNext
|
||||
if (denom <= 0) return 0
|
||||
return Math.max(-0.5, Math.min(0.5, (sadPrev - sadNext) / (2 * denom)))
|
||||
}
|
||||
|
||||
export function createGlassMotionEstimator(
|
||||
cellPxX: number,
|
||||
cellPxY: number,
|
||||
predictMs: number
|
||||
): GlassMotionEstimator {
|
||||
const size = MOTION_GRID_W * MOTION_GRID_H
|
||||
let prev: Uint8Array | null = null
|
||||
let curr: Uint8Array = new Uint8Array(size)
|
||||
let prevTs = 0
|
||||
let velX = 0
|
||||
let velY = 0
|
||||
|
||||
const target = () => {
|
||||
// 速度低于 60px/s 视为静止(噪声区),不做补偿
|
||||
const speed = Math.hypot(velX, velY)
|
||||
if (speed < 0.06) return { x: 0, y: 0 }
|
||||
return {
|
||||
x: Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, velX * predictMs)),
|
||||
y: Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, velY * predictMs))
|
||||
}
|
||||
}
|
||||
|
||||
/** 全搜索块匹配:返回最优位移(格)与置信信息 */
|
||||
const search = (prevGrid: Uint8Array) => {
|
||||
let bestDx = 0
|
||||
let bestDy = 0
|
||||
let bestSad = Infinity
|
||||
let zeroSad = 0
|
||||
// 缓存 x 方向 SAD 曲线(bestDy 行),用于亚格点插值
|
||||
const sadRow = new Float32Array(SEARCH_X * 2 + 1)
|
||||
for (let dy = -SEARCH_Y; dy <= SEARCH_Y; dy++) {
|
||||
const y0 = Math.max(0, -dy)
|
||||
const y1 = MOTION_GRID_H - Math.max(0, dy)
|
||||
for (let dx = -SEARCH_X; dx <= SEARCH_X; dx++) {
|
||||
const x0 = Math.max(0, -dx)
|
||||
const x1 = MOTION_GRID_W - Math.max(0, dx)
|
||||
let sad = 0
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const rowC = y * MOTION_GRID_W
|
||||
const rowP = (y + dy) * MOTION_GRID_W + dx
|
||||
for (let x = x0; x < x1; x++) {
|
||||
sad += Math.abs(curr[rowC + x] - prevGrid[rowP + x])
|
||||
}
|
||||
}
|
||||
sad /= (x1 - x0) * (y1 - y0)
|
||||
if (dx === 0 && dy === 0) zeroSad = sad
|
||||
if (sad < bestSad) {
|
||||
bestSad = sad
|
||||
bestDx = dx
|
||||
bestDy = dy
|
||||
}
|
||||
}
|
||||
}
|
||||
// 最优行的 x 邻域 SAD(亚格点用),只在需要时补算
|
||||
const y0 = Math.max(0, -bestDy)
|
||||
const y1 = MOTION_GRID_H - Math.max(0, bestDy)
|
||||
for (let i = 0; i < sadRow.length; i++) {
|
||||
const dx = i - SEARCH_X
|
||||
const x0 = Math.max(0, -dx)
|
||||
const x1 = MOTION_GRID_W - Math.max(0, dx)
|
||||
let sad = 0
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const rowC = y * MOTION_GRID_W
|
||||
const rowP = (y + bestDy) * MOTION_GRID_W + dx
|
||||
for (let x = x0; x < x1; x++) {
|
||||
sad += Math.abs(curr[rowC + x] - prevGrid[rowP + x])
|
||||
}
|
||||
}
|
||||
sadRow[i] = sad / ((x1 - x0) * (y1 - y0))
|
||||
}
|
||||
return { bestDx, bestDy, bestSad, zeroSad, sadRow }
|
||||
}
|
||||
|
||||
return {
|
||||
update(rgba, timestampMs) {
|
||||
// RGBA → 亮度网格,同时统计对比度
|
||||
let min = 255
|
||||
let max = 0
|
||||
const next = prev ?? new Uint8Array(size)
|
||||
for (let i = 0; i < size; i++) {
|
||||
const p = i * 4
|
||||
const luma = (rgba[p] * 54 + rgba[p + 1] * 183 + rgba[p + 2] * 19) >> 8
|
||||
next[i] = luma
|
||||
if (luma < min) min = luma
|
||||
if (luma > max) max = luma
|
||||
}
|
||||
prev = curr
|
||||
curr = next
|
||||
|
||||
const dt = timestampMs - prevTs
|
||||
prevTs = timestampMs
|
||||
// 帧间隔异常(首帧/长暂停/时钟跳变)或画面无结构:不更新速度
|
||||
if (dt < 4 || dt > 90 || max - min < 10) {
|
||||
velX *= 0.55
|
||||
velY *= 0.55
|
||||
return target()
|
||||
}
|
||||
|
||||
const { bestDx, bestDy, bestSad, zeroSad, sadRow } = search(prev)
|
||||
|
||||
if (bestDx === 0 && bestDy === 0) {
|
||||
// 零位移最优 → 置信静止
|
||||
velX *= 0.35
|
||||
velY *= 0.35
|
||||
return target()
|
||||
}
|
||||
|
||||
// 置信条件:位移匹配显著优于不动(否则可能是局部运动/画面突变)
|
||||
if (bestSad >= zeroSad * 0.8) {
|
||||
velX *= 0.55
|
||||
velY *= 0.55
|
||||
return target()
|
||||
}
|
||||
|
||||
const i = bestDx + SEARCH_X
|
||||
const fracX = i > 0 && i < sadRow.length - 1 ? subCell(sadRow[i - 1], sadRow[i], sadRow[i + 1]) : 0
|
||||
// 匹配含义:curr[x] ≈ prev[x + dx],内容位移 = -dx(向右运动时 dx 为负)
|
||||
const instVx = (-(bestDx + fracX) * cellPxX) / dt
|
||||
const instVy = (-bestDy * cellPxY) / dt
|
||||
if (Math.hypot(instVx, instVy) > MAX_VELOCITY) {
|
||||
velX *= 0.55
|
||||
velY *= 0.55
|
||||
return target()
|
||||
}
|
||||
|
||||
if (instVx * velX + instVy * velY < 0) {
|
||||
// 方向反转(如拖拽折返):快速重锚定,幅度打折避免过冲
|
||||
velX = instVx * 0.3
|
||||
velY = instVy * 0.3
|
||||
} else {
|
||||
velX = velX * 0.65 + instVx * 0.35
|
||||
velY = velY * 0.65 + instVy * 0.35
|
||||
}
|
||||
return target()
|
||||
},
|
||||
|
||||
decay() {
|
||||
velX *= 0.5
|
||||
velY *= 0.5
|
||||
return target()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
// 视频流模式的 WebGL 玻璃渲染器:替代「<video> 元素 + CSS blur/saturate + SVG 位移滤镜」管线。
|
||||
//
|
||||
// 性能与延迟设计:
|
||||
// - MediaStreamTrackProcessor 直读采集帧(无 <video> 播出缓冲,省 1~2 帧延迟),
|
||||
// 帧到达立即渲染,latest-wins 丢弃积压帧;无帧到达时零渲染(桌面静止零开销)
|
||||
// - 圆角矩形 SDF 及其梯度在片元着色器内解析式求值,替代 CPU 逐像素生成位移贴图
|
||||
// (lensDisplacementMap)+ feImage + 3×feDisplacementMap + 2×feBlend 的滤镜图
|
||||
// - 模糊降到半分辨率两趟可分离高斯(工作面积仅玻璃+边距),
|
||||
// 位移、色散、饱和度合并进最终一趟片元着色,整帧只有 3 个小渲染目标
|
||||
// - 透镜几何与 lensDisplacementMap/GlassFilter 保持同一套公式(bezel/单峰剖面/色散递减),
|
||||
// 视觉与应用内 SVG 路径一致
|
||||
// - 运动补偿预测采样(timewarp 思路):屏幕采集链路存在 ~60ms 物理延迟
|
||||
// (WGC 帧池 → 跨进程 → 合成上屏,应用层无法消除),对"拖拽/滚动"这类
|
||||
// 均匀平移场景,用块匹配估计背景运动矢量,把采样窗口向运动方向外推一个
|
||||
// 延迟量,折射内容与真实位置的感知滞后可从 ~70ms 压到接近零;
|
||||
// 不可预测运动(旋转/局部变化)由置信门控自动退回普通滞后表现
|
||||
import { createGlassMotionEstimator, MOTION_GRID_W, MOTION_GRID_H } from './glassMotionEstimator'
|
||||
|
||||
const BLUR_MARGIN = 40
|
||||
/** 预测外推时长(ms):按实测采集→上屏延迟标定,故意欠补偿留稳定余量 */
|
||||
const MOTION_PREDICT_MS = 60
|
||||
/** 预测偏移的逐帧平滑系数 */
|
||||
const OFFSET_SMOOTH = 0.45
|
||||
/** 无新帧超过该时长即进入偏移回收(内容停止运动后把预测偏移退回零) */
|
||||
const IDLE_DECAY_DELAY = 90
|
||||
|
||||
export interface GlassStreamRendererOptions {
|
||||
canvas: HTMLCanvasElement
|
||||
/** 渲染器独占的视频轨(调用方负责 clone),dispose 时停止 */
|
||||
track: MediaStreamTrack
|
||||
/** 玻璃 CSS 尺寸与圆角 */
|
||||
width: number
|
||||
height: number
|
||||
cornerRadius: number
|
||||
/** 玻璃左上角的屏幕逻辑坐标(窗口坐标 + 页内布局偏移) */
|
||||
screenX: number
|
||||
screenY: number
|
||||
/** 屏幕逻辑尺寸(视频帧与屏幕的映射基准,与帧实际分辨率无关) */
|
||||
screenW: number
|
||||
screenH: number
|
||||
displacementScale: number
|
||||
aberrationIntensity: number
|
||||
saturation: number
|
||||
/** 高斯模糊标准差(CSS px) */
|
||||
blurSigma: number
|
||||
/** 首帧渲染完成(用于淡入时机) */
|
||||
onFirstFrame?: () => void
|
||||
}
|
||||
|
||||
export interface GlassStreamRenderer {
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
const VERT = `#version 300 es
|
||||
void main() {
|
||||
vec2 pos = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
|
||||
gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
|
||||
}`
|
||||
|
||||
/** 下采样 + 单方向高斯(半分辨率,两趟可分离) */
|
||||
const BLUR_FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
uniform sampler2D uTex;
|
||||
uniform vec2 uSrcOrigin; // 采样区域原点(源纹理 UV)
|
||||
uniform vec2 uSrcSpan; // 采样区域跨度(源纹理 UV)
|
||||
uniform vec2 uOutSize; // 本目标像素尺寸
|
||||
uniform vec2 uDir; // 模糊方向(源纹理 UV 每标准差步长)
|
||||
out vec4 outColor;
|
||||
void main() {
|
||||
vec2 t = gl_FragCoord.xy / uOutSize;
|
||||
vec2 uv = uSrcOrigin + t * uSrcSpan;
|
||||
outColor = texture(uTex, uv) * 0.4026
|
||||
+ (texture(uTex, uv + uDir) + texture(uTex, uv - uDir)) * 0.2442
|
||||
+ (texture(uTex, uv + uDir * 2.0) + texture(uTex, uv - uDir * 2.0)) * 0.0545;
|
||||
}`
|
||||
|
||||
/** 透镜主着色:解析式 SDF 位移 + RGB 色散 + 饱和度 */
|
||||
const LENS_FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
uniform sampler2D uBlurTex;
|
||||
uniform vec2 uGlassSize; // CSS px
|
||||
uniform float uDpr;
|
||||
uniform float uRadius;
|
||||
uniform float uBezel;
|
||||
uniform float uMaxBend;
|
||||
uniform float uDispFactor; // displacementScale / 70
|
||||
uniform float uAberration;
|
||||
uniform float uSaturation;
|
||||
uniform float uMargin; // 模糊纹理相对玻璃的外扩边距(CSS px)
|
||||
out vec4 outColor;
|
||||
|
||||
// 圆角矩形 SDF(p 以玻璃中心为原点)
|
||||
float sdRoundRect(vec2 p, vec2 halfSize, float r) {
|
||||
vec2 q = abs(p) - halfSize + r;
|
||||
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
|
||||
}
|
||||
|
||||
// 平滑 max(q,0):法线方向在角区连续旋转,消除对角线上的方向折痕
|
||||
vec2 softClamp(vec2 q, float soft) {
|
||||
return 0.5 * (q + sqrt(q * q + vec2(soft * soft)));
|
||||
}
|
||||
|
||||
vec2 toBlurUV(vec2 cssPos) {
|
||||
return (cssPos + uMargin) / (uGlassSize + uMargin * 2.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// 画布像素 → 玻璃 CSS 坐标(画布原点左上,gl_FragCoord 原点左下)
|
||||
vec2 css = vec2(gl_FragCoord.x, uGlassSize.y * uDpr - gl_FragCoord.y) / uDpr;
|
||||
vec2 halfSize = uGlassSize * 0.5;
|
||||
vec2 p = css - halfSize;
|
||||
float r = min(uRadius, min(halfSize.x, halfSize.y));
|
||||
|
||||
// 液态弯月面边缘(iOS 边缘处理,与原生面板/lensDisplacementMap 一致):
|
||||
// 边缘带只重新分布"自己的内容"——靠边界处抻胀(拉伸)、往中心方向追赶压缩。
|
||||
// 采样映射 s(d) = -d + bump(t)·bend 两端连续(bump(0)=bump(1)=0):
|
||||
// 内容跨玻璃边无缝衔接(无断裂线)、平滑并入清晰中心。单射性:上升斜率
|
||||
// 通过 maxBend ≤ 0.7/1.5·tp·bezel 封顶(局部拉伸 ≤ ~3.3×),
|
||||
// 镜像"舌头"折叠结构上不可能;下降侧只压缩(斜率 < -1),恒单射
|
||||
float depth = -sdRoundRect(p, halfSize, r);
|
||||
vec2 disp = vec2(0.0);
|
||||
vec2 nrm = vec2(0.0);
|
||||
float bump = 0.0;
|
||||
float edgeW = 0.0;
|
||||
if (depth > 0.0 && depth < uBezel) {
|
||||
vec2 q = abs(p) - halfSize + r;
|
||||
vec2 qs = softClamp(q, max(r * 0.8, 1.0));
|
||||
nrm = (qs / max(length(qs), 1e-4)) * sign(p + vec2(1e-6));
|
||||
float t = depth / uBezel;
|
||||
edgeW = (1.0 - t) * (1.0 - t);
|
||||
// C¹ 鼓包:smoothstep 上升至 t=0.62 峰值,smoothstep 回落到 0
|
||||
float u = clamp(t < 0.62 ? t / 0.62 : (1.0 - t) / 0.38, 0.0, 1.0);
|
||||
bump = u * u * (3.0 - 2.0 * u);
|
||||
disp = nrm * (bump * uMaxBend * min(uDispFactor, 1.0));
|
||||
}
|
||||
|
||||
// 压缩带内做 5 tap 足迹积分(径向 + 切向拉丝):边缘带是 2~3 倍缩小映射,
|
||||
// 点采样会产生锯齿/摩尔纹;径向扩散随压缩强度自适应加宽做抗锯齿。
|
||||
// 色散:与 GlassFilter 相同的 RGB 位移递减(R 全量、G/B 递减)
|
||||
vec3 c;
|
||||
float dispLen2 = dot(disp, disp);
|
||||
if (dispLen2 > 0.25) {
|
||||
vec2 tangent = vec2(nrm.y, -nrm.x) * sqrt(dispLen2);
|
||||
float rs = 0.05 + 0.10 * bump;
|
||||
float radial[5]; radial[0] = 1.0 - 2.0 * rs; radial[1] = 1.0 - rs; radial[2] = 1.0; radial[3] = 1.0 + rs; radial[4] = 1.0 + 2.0 * rs;
|
||||
float lateral[5]; lateral[0] = -0.36; lateral[1] = 0.18; lateral[2] = 0.0; lateral[3] = -0.18; lateral[4] = 0.36;
|
||||
float wts[5]; wts[0] = 0.14; wts[1] = 0.22; wts[2] = 0.28; wts[3] = 0.22; wts[4] = 0.14;
|
||||
c = vec3(0.0);
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
vec2 d = disp * radial[i] + tangent * lateral[i];
|
||||
c += wts[i] * vec3(
|
||||
texture(uBlurTex, toBlurUV(css + d)).r,
|
||||
texture(uBlurTex, toBlurUV(css + d * (1.0 - uAberration * 0.05))).g,
|
||||
texture(uBlurTex, toBlurUV(css + d * (1.0 - uAberration * 0.1))).b);
|
||||
}
|
||||
} else {
|
||||
c = texture(uBlurTex, toBlurUV(css + disp)).rgb;
|
||||
}
|
||||
|
||||
// CSS saturate() 等效矩阵
|
||||
float lum = dot(c, vec3(0.213, 0.715, 0.072));
|
||||
c = mix(vec3(lum), c, uSaturation);
|
||||
|
||||
// 贴边宽幅边缘光(与原生面板一致,光源方向左上):受光侧提亮、背光侧微压暗,
|
||||
// 权重 edgeW 在边界处最大、向内 C¹ 衰减,独立于位移鼓包——iOS 玻璃的
|
||||
// "实体厚度"暗示;细锐的描边环仍由内容层负责
|
||||
float lightDot = dot(nrm, vec2(-0.40, -0.92));
|
||||
c = clamp(c + edgeW * (0.09 * clamp(lightDot, 0.0, 1.0) - 0.04 * clamp(-lightDot, 0.0, 1.0)), 0.0, 1.0);
|
||||
|
||||
outColor = vec4(c, 1.0);
|
||||
}`
|
||||
|
||||
function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
|
||||
const shader = gl.createShader(type)
|
||||
if (!shader) return null
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.warn('[LiquidGlass] shader compile failed:', gl.getShaderInfoLog(shader))
|
||||
gl.deleteShader(shader)
|
||||
return null
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
function link(gl: WebGL2RenderingContext, frag: string): WebGLProgram | null {
|
||||
const vs = compile(gl, gl.VERTEX_SHADER, VERT)
|
||||
const fs = compile(gl, gl.FRAGMENT_SHADER, frag)
|
||||
if (!vs || !fs) return null
|
||||
const program = gl.createProgram()
|
||||
if (!program) return null
|
||||
gl.attachShader(program, vs)
|
||||
gl.attachShader(program, fs)
|
||||
gl.linkProgram(program)
|
||||
gl.deleteShader(vs)
|
||||
gl.deleteShader(fs)
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
console.warn('[LiquidGlass] program link failed:', gl.getProgramInfoLog(program))
|
||||
gl.deleteProgram(program)
|
||||
return null
|
||||
}
|
||||
return program
|
||||
}
|
||||
|
||||
/** 创建流渲染器;WebGL 不可用或轨道无效时返回 null(调用方回退 <video> 管线) */
|
||||
export function createGlassStreamRenderer(options: GlassStreamRendererOptions): GlassStreamRenderer | null {
|
||||
const { canvas, track } = options
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const gl = canvas.getContext('webgl2', {
|
||||
alpha: false,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
stencil: false,
|
||||
// 低延迟提示:帧到达即绘制,desynchronized 允许绕过合成器队列
|
||||
desynchronized: true,
|
||||
powerPreference: 'low-power'
|
||||
}) as WebGL2RenderingContext | null
|
||||
if (!gl) return null
|
||||
|
||||
const blurProgram = link(gl, BLUR_FRAG)
|
||||
const lensProgram = link(gl, LENS_FRAG)
|
||||
if (!blurProgram || !lensProgram) {
|
||||
track.stop()
|
||||
return null
|
||||
}
|
||||
|
||||
canvas.width = Math.max(1, Math.round(options.width * dpr))
|
||||
canvas.height = Math.max(1, Math.round(options.height * dpr))
|
||||
|
||||
// 半分辨率模糊目标(玻璃 + 外扩边距)
|
||||
const blurW = Math.max(1, Math.ceil((options.width + BLUR_MARGIN * 2) / 2))
|
||||
const blurH = Math.max(1, Math.ceil((options.height + BLUR_MARGIN * 2) / 2))
|
||||
|
||||
const screenTex = gl.createTexture()
|
||||
const makeTarget = () => {
|
||||
const tex = gl.createTexture()
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, blurW, blurH, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
const fbo = gl.createFramebuffer()
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0)
|
||||
return { tex, fbo }
|
||||
}
|
||||
const targetA = makeTarget()
|
||||
const targetB = makeTarget()
|
||||
|
||||
// 运动估计降采样目标:56×24 网格(复用模糊程序做加权降采样,uDir=0 时权重和为 1)
|
||||
const motionTex = gl.createTexture()
|
||||
const motionFbo = gl.createFramebuffer()
|
||||
gl.bindTexture(gl.TEXTURE_2D, motionTex)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, MOTION_GRID_W, MOTION_GRID_H, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, motionFbo)
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, motionTex, 0)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
|
||||
|
||||
const blurU = {
|
||||
tex: gl.getUniformLocation(blurProgram, 'uTex'),
|
||||
srcOrigin: gl.getUniformLocation(blurProgram, 'uSrcOrigin'),
|
||||
srcSpan: gl.getUniformLocation(blurProgram, 'uSrcSpan'),
|
||||
outSize: gl.getUniformLocation(blurProgram, 'uOutSize'),
|
||||
dir: gl.getUniformLocation(blurProgram, 'uDir')
|
||||
}
|
||||
const lensU = {
|
||||
blurTex: gl.getUniformLocation(lensProgram, 'uBlurTex'),
|
||||
glassSize: gl.getUniformLocation(lensProgram, 'uGlassSize'),
|
||||
dpr: gl.getUniformLocation(lensProgram, 'uDpr'),
|
||||
radius: gl.getUniformLocation(lensProgram, 'uRadius'),
|
||||
bezel: gl.getUniformLocation(lensProgram, 'uBezel'),
|
||||
maxBend: gl.getUniformLocation(lensProgram, 'uMaxBend'),
|
||||
dispFactor: gl.getUniformLocation(lensProgram, 'uDispFactor'),
|
||||
aberration: gl.getUniformLocation(lensProgram, 'uAberration'),
|
||||
saturation: gl.getUniformLocation(lensProgram, 'uSaturation'),
|
||||
margin: gl.getUniformLocation(lensProgram, 'uMargin')
|
||||
}
|
||||
|
||||
// 与 lensDisplacementMap 相同的几何参数(iOS 式宽幅湿边)。
|
||||
// maxBend = 弯月面鼓包峰值,斜率封顶保证映射单射:
|
||||
// 上升斜率 1.5/tp·maxBend/bezel ≤ 0.7(tp=0.62)⇒ maxBend = 0.289·bezel,
|
||||
// 远小于模糊纹理外扩边距,足迹采样不会越界
|
||||
const halfMin = Math.min(options.width, options.height) / 2
|
||||
const bezel = Math.min(34, halfMin * 0.75)
|
||||
const maxBend = 0.289 * bezel
|
||||
|
||||
let disposed = false
|
||||
let firstFrame = true
|
||||
let texW = 0
|
||||
let texH = 0
|
||||
|
||||
// 玻璃区域(+模糊边距)的屏幕逻辑坐标
|
||||
const regionX = options.screenX - BLUR_MARGIN
|
||||
const regionY = options.screenY - BLUR_MARGIN
|
||||
const regionW = options.width + BLUR_MARGIN * 2
|
||||
const regionH = options.height + BLUR_MARGIN * 2
|
||||
|
||||
// —— 运动补偿状态:估计器输出目标偏移,渲染时平滑逼近 ——
|
||||
const estimator = createGlassMotionEstimator(regionW / MOTION_GRID_W, regionH / MOTION_GRID_H, MOTION_PREDICT_MS)
|
||||
const motionPixels = new Uint8Array(MOTION_GRID_W * MOTION_GRID_H * 4)
|
||||
let offsetX = 0
|
||||
let offsetY = 0
|
||||
let targetOffsetX = 0
|
||||
let targetOffsetY = 0
|
||||
let decayTimer = 0
|
||||
|
||||
// 内容停止运动后采集不再出帧,渲染循环停摆,需要主动把预测偏移收回零,
|
||||
// 否则折射内容会停在外推位置上(错位定格);首步等 IDLE_DECAY_DELAY
|
||||
// 确认无新帧,后续以短步长快速滑回
|
||||
const scheduleDecay = (delay = IDLE_DECAY_DELAY) => {
|
||||
clearTimeout(decayTimer)
|
||||
decayTimer = window.setTimeout(() => {
|
||||
if (disposed) return
|
||||
const t = estimator.decay()
|
||||
targetOffsetX = t.x
|
||||
targetOffsetY = t.y
|
||||
offsetX = offsetX * 0.5 + targetOffsetX * 0.5
|
||||
offsetY = offsetY * 0.5 + targetOffsetY * 0.5
|
||||
drawGlass()
|
||||
if (Math.abs(offsetX) > 0.5 || Math.abs(offsetY) > 0.5) scheduleDecay(40)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const uploadTexture = (source: TexImageSource, w: number, h: number) => {
|
||||
gl.activeTexture(gl.TEXTURE0)
|
||||
gl.bindTexture(gl.TEXTURE_2D, screenTex)
|
||||
if (w !== texW || h !== texH) {
|
||||
texW = w
|
||||
texH = h
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
}
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, source)
|
||||
}
|
||||
|
||||
/** 用当前 screenTex 内容执行模糊 + 透镜两级绘制(可被运动偏移回收重绘复用) */
|
||||
const drawGlass = () => {
|
||||
// 趟1:纹理采样区域 → 半分辨率 + 水平高斯。
|
||||
// 采样原点按运动预测偏移前移:显示"内容此刻应到达的位置"而非采集帧的旧位置
|
||||
gl.activeTexture(gl.TEXTURE0)
|
||||
gl.bindTexture(gl.TEXTURE_2D, screenTex)
|
||||
gl.useProgram(blurProgram)
|
||||
gl.uniform1i(blurU.tex, 0)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, targetA.fbo)
|
||||
gl.viewport(0, 0, blurW, blurH)
|
||||
gl.uniform2f(blurU.srcOrigin, (regionX - offsetX) / options.screenW, (regionY - offsetY) / options.screenH)
|
||||
gl.uniform2f(blurU.srcSpan, regionW / options.screenW, regionH / options.screenH)
|
||||
gl.uniform2f(blurU.outSize, blurW, blurH)
|
||||
gl.uniform2f(blurU.dir, options.blurSigma / options.screenW, 0)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
||||
|
||||
// 趟2:垂直高斯(源为趟1 结果,UV 全幅,步长换算到区域跨度)
|
||||
gl.bindTexture(gl.TEXTURE_2D, targetA.tex)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, targetB.fbo)
|
||||
gl.uniform2f(blurU.srcOrigin, 0, 0)
|
||||
gl.uniform2f(blurU.srcSpan, 1, 1)
|
||||
gl.uniform2f(blurU.dir, 0, options.blurSigma / regionH)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
||||
|
||||
// 趟3:透镜主着色 → 画布
|
||||
gl.useProgram(lensProgram)
|
||||
gl.bindTexture(gl.TEXTURE_2D, targetB.tex)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
|
||||
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||
gl.uniform1i(lensU.blurTex, 0)
|
||||
gl.uniform2f(lensU.glassSize, options.width, options.height)
|
||||
gl.uniform1f(lensU.dpr, dpr)
|
||||
gl.uniform1f(lensU.radius, options.cornerRadius)
|
||||
gl.uniform1f(lensU.bezel, bezel)
|
||||
gl.uniform1f(lensU.maxBend, maxBend)
|
||||
gl.uniform1f(lensU.dispFactor, options.displacementScale / 70)
|
||||
gl.uniform1f(lensU.aberration, options.aberrationIntensity)
|
||||
gl.uniform1f(lensU.saturation, options.saturation / 100)
|
||||
gl.uniform1f(lensU.margin, BLUR_MARGIN)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
||||
// 立即提交而不等下一次合成对齐(desynchronized 画布的低延迟关键)
|
||||
gl.flush()
|
||||
}
|
||||
|
||||
/** 固定区域降采样出 56×24 亮度网格并回读,喂给运动估计器。
|
||||
采样区域不随补偿偏移移动,估计的是真实屏幕运动(避免补偿反馈回路) */
|
||||
const estimateMotion = (timestampMs: number) => {
|
||||
gl.useProgram(blurProgram)
|
||||
gl.uniform1i(blurU.tex, 0)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, motionFbo)
|
||||
gl.viewport(0, 0, MOTION_GRID_W, MOTION_GRID_H)
|
||||
gl.uniform2f(blurU.srcOrigin, regionX / options.screenW, regionY / options.screenH)
|
||||
gl.uniform2f(blurU.srcSpan, regionW / options.screenW, regionH / options.screenH)
|
||||
gl.uniform2f(blurU.outSize, MOTION_GRID_W, MOTION_GRID_H)
|
||||
// uDir=0 时高斯核权重和恰为 1,等价于纯降采样
|
||||
gl.uniform2f(blurU.dir, 0, 0)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
||||
// 5KB 同步回读,实测 <0.5ms;异步 PBO 会引入一帧估计延迟,得不偿失
|
||||
gl.readPixels(0, 0, MOTION_GRID_W, MOTION_GRID_H, gl.RGBA, gl.UNSIGNED_BYTE, motionPixels)
|
||||
const t = estimator.update(motionPixels, timestampMs)
|
||||
targetOffsetX = t.x
|
||||
targetOffsetY = t.y
|
||||
offsetX += (targetOffsetX - offsetX) * OFFSET_SMOOTH
|
||||
offsetY += (targetOffsetY - offsetY) * OFFSET_SMOOTH
|
||||
scheduleDecay()
|
||||
}
|
||||
|
||||
const render = (frame: VideoFrame | HTMLVideoElement) => {
|
||||
if (disposed) return
|
||||
const fw = frame instanceof HTMLVideoElement ? frame.videoWidth : frame.displayWidth
|
||||
const fh = frame instanceof HTMLVideoElement ? frame.videoHeight : frame.displayHeight
|
||||
if (!fw || !fh) return
|
||||
|
||||
// 整帧直传:采集帧是 GPU 背书的,texImage2D 走 GPU-GPU 拷贝;
|
||||
// 实测 visibleRect 裁剪视图反而更慢(触发同步转换路径),不做预裁剪。
|
||||
// UV 按屏幕逻辑坐标映射,帧分辨率高于逻辑尺寸时(Retina/高 DPI)采样自动降分辨率
|
||||
uploadTexture(frame as TexImageSource, fw, fh)
|
||||
estimateMotion(frame instanceof HTMLVideoElement ? performance.now() : frame.timestamp / 1000)
|
||||
drawGlass()
|
||||
|
||||
if (firstFrame) {
|
||||
firstFrame = false
|
||||
options.onFirstFrame?.()
|
||||
}
|
||||
}
|
||||
|
||||
// —— 帧泵:MediaStreamTrackProcessor 为主源(帧到达即渲染,无播出缓冲)。
|
||||
// 同轨再挂一个离屏 <video> 作首帧引子与后备:MSTP 只在画面变化时出帧,
|
||||
// 静态桌面上新挂载的玻璃会一直等不到首帧;video 的 loadeddata 能立刻
|
||||
// 给出当前帧,rVFC 则在 MSTP 不可用/停摆时兜底渲染 ——
|
||||
let lastPumpRender = 0
|
||||
|
||||
const video = document.createElement('video')
|
||||
video.muted = true
|
||||
video.srcObject = new MediaStream([track])
|
||||
const onVideoFrame = () => {
|
||||
if (disposed) return
|
||||
// 主源正常供帧时后备不重复渲染(同帧二次绘制无意义)
|
||||
if (performance.now() - lastPumpRender > 250) render(video)
|
||||
video.requestVideoFrameCallback(onVideoFrame)
|
||||
}
|
||||
video.addEventListener('loadeddata', () => {
|
||||
if (!disposed && firstFrame) render(video)
|
||||
}, { once: true })
|
||||
video.requestVideoFrameCallback(onVideoFrame)
|
||||
video.play().catch(() => { /* 流启动失败保持首帧前状态 */ })
|
||||
|
||||
const ProcessorCtor = (window as unknown as {
|
||||
MediaStreamTrackProcessor?: new (init: { track: MediaStreamTrack }) => { readable: ReadableStream<VideoFrame> }
|
||||
}).MediaStreamTrackProcessor
|
||||
let stopPump = () => { /* MSTP 不可用时仅有 video 后备 */ }
|
||||
|
||||
if (ProcessorCtor) {
|
||||
// MSTP 会独占消费传入轨道,供 video 后备的轨道需要单独 clone
|
||||
const pumpTrack = track.clone()
|
||||
const reader = new ProcessorCtor({ track: pumpTrack }).readable.getReader()
|
||||
const pump = async () => {
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done || disposed) {
|
||||
value?.close()
|
||||
return
|
||||
}
|
||||
lastPumpRender = performance.now()
|
||||
render(value)
|
||||
value.close()
|
||||
}
|
||||
} catch { /* 轨道停止时 read 抛错,正常退出 */ }
|
||||
}
|
||||
void pump()
|
||||
stopPump = () => {
|
||||
void reader.cancel().catch(() => { /* 已关闭 */ })
|
||||
pumpTrack.stop()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
clearTimeout(decayTimer)
|
||||
stopPump()
|
||||
video.srcObject = null
|
||||
track.stop()
|
||||
gl.deleteTexture(screenTex)
|
||||
gl.deleteTexture(targetA.tex)
|
||||
gl.deleteTexture(targetB.tex)
|
||||
gl.deleteTexture(motionTex)
|
||||
gl.deleteFramebuffer(targetA.fbo)
|
||||
gl.deleteFramebuffer(targetB.fbo)
|
||||
gl.deleteFramebuffer(motionFbo)
|
||||
gl.deleteProgram(blurProgram)
|
||||
gl.deleteProgram(lensProgram)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import GlassFilter from './GlassFilter'
|
||||
import { generateLensDisplacementMap } from './lensDisplacementMap'
|
||||
import { createGlassStreamRenderer } from './glassStreamRenderer'
|
||||
import './liquidGlass.scss'
|
||||
|
||||
/**
|
||||
* 液态玻璃容器,移植自 liquid-glass-react v1.1.1(MIT)
|
||||
* https://github.com/rdev/liquid-glass-react
|
||||
*
|
||||
* 相对原库的调整:
|
||||
* - 移除内部 Tailwind 类名,全部改为内联样式(WeFlow 无 Tailwind 运行时)
|
||||
* - 由"固定定位 + 居中平移"的悬浮布局改为常规文档流布局,定位交给调用方
|
||||
* - 移除原库 overLight 的黑色压暗层:Chromium 中与 backdrop-filter 同级的
|
||||
* mix-blend 图层无法参与混合、会按普通合成渲染,不透明黑层因此退化为实心黑块。
|
||||
* 主题适配改由全局兼容层 liquidGlass.scss 的 CSS 变量统一控制
|
||||
* (--liquid-glass-tint / --liquid-glass-shadow),色调层只使用普通 alpha 合成;
|
||||
* 边缘高光/悬停辉光均为低透明度白色渐变,混合失效时也只会退化为普通高光,安全
|
||||
* - 注意:根节点不能设置 isolation / opacity / mask 等会形成 backdrop root 的属性,
|
||||
* 否则内部 backdrop-filter 将采样不到页面内容,玻璃会失去模糊折射
|
||||
* - 鼠标追踪仅在 elasticity > 0 时监听,并用 rAF 节流;远离激活区时不触发重渲染
|
||||
* - 尺寸改用 ResizeObserver 跟踪,内容变化时滤镜区域同步更新
|
||||
* - 位移贴图不用原库静态 PNG(边缘位移带太窄,宽扁卡片上退化成模糊),
|
||||
* 改用 lensDisplacementMap 按实际尺寸/圆角生成的透镜贴图:中心零位移完全清晰、
|
||||
* 边缘全周透镜弯曲;polar/prominent 模式与 Firefox 降级逻辑不保留(固定 Chromium)
|
||||
*/
|
||||
|
||||
/** 鼠标弹性效果的激活半径(距元素边缘的像素距离) */
|
||||
const ACTIVATION_ZONE = 200
|
||||
|
||||
/** 像素源裁剪层向外的出血宽度:容纳模糊核的边缘采样(透镜位移只向内采样,不需要出血) */
|
||||
const PIXEL_SOURCE_BLEED = 32
|
||||
|
||||
interface MouseState {
|
||||
engaged: boolean
|
||||
tx: number
|
||||
ty: number
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
/** 相对元素中心的偏移百分比,用于边缘高光渐变 */
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
const IDLE_MOUSE: MouseState = { engaged: false, tx: 0, ty: 0, scaleX: 1, scaleY: 1, offsetX: 0, offsetY: 0 }
|
||||
|
||||
/** 玻璃的替代背景采样源:屏幕快照/视频流 + 玻璃所在窗口的屏幕坐标 */
|
||||
export interface LiquidGlassBackdropImage {
|
||||
/** 静态快照,视频流启动前的首帧兜底;可为空 */
|
||||
dataUrl?: string | null
|
||||
/** 屏幕的 CSS 像素尺寸(即逻辑分辨率) */
|
||||
width: number
|
||||
height: number
|
||||
/** 玻璃所在窗口在屏幕上的位置 */
|
||||
screenX: number
|
||||
screenY: number
|
||||
}
|
||||
|
||||
export interface LiquidGlassProps {
|
||||
children: ReactNode
|
||||
/** 位移折射强度 */
|
||||
displacementScale?: number
|
||||
/** 磨砂程度(0~1,映射为额外的 backdrop blur 像素) */
|
||||
blurAmount?: number
|
||||
/** 背景饱和度百分比 */
|
||||
saturation?: number
|
||||
/** 边缘色散强度 */
|
||||
aberrationIntensity?: number
|
||||
/** 鼠标弹性系数,默认 0(完全关闭鼠标追踪,需要弹性效果时显式开启) */
|
||||
elasticity?: number
|
||||
cornerRadius?: number
|
||||
className?: string
|
||||
padding?: string
|
||||
style?: CSSProperties
|
||||
/**
|
||||
* 替代背景采样源。Electron 透明窗口内 backdrop-filter 不生效(Chromium 限制),
|
||||
* 传入与桌面逐像素对齐的屏幕快照后,玻璃改用普通 filter 在内部现场加工快照,
|
||||
* 视觉效果与真实 backdrop 采样一致
|
||||
*/
|
||||
backdropImage?: LiquidGlassBackdropImage
|
||||
/** 屏幕实时视频流:就绪后替换静态快照,折射随桌面实时更新 */
|
||||
backdropStream?: MediaStream | null
|
||||
/**
|
||||
* 原生玻璃模式(Windows):模糊/折射/色散由主进程的原生面板在窗口下方渲染
|
||||
* (@hicccc77/electron-liquid-glass,DXGI 零拷贝 + D3D11,感知滞后中位 ~6ms),
|
||||
* 组件内不再渲染折射背景层,只保留色调纱层、内容与边缘高光
|
||||
*/
|
||||
nativeBackdrop?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
/** 沿 offsetParent 链累计布局坐标:不受 transform 动画影响,可稳定对齐快照 */
|
||||
function getLayoutOffset(el: HTMLElement): { x: number; y: number } {
|
||||
let x = 0
|
||||
let y = 0
|
||||
let node: HTMLElement | null = el
|
||||
while (node) {
|
||||
x += node.offsetLeft
|
||||
y += node.offsetTop
|
||||
node = node.offsetParent as HTMLElement | null
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export default function LiquidGlass({
|
||||
children,
|
||||
displacementScale = 70,
|
||||
blurAmount = 0.0625,
|
||||
saturation = 140,
|
||||
aberrationIntensity = 2,
|
||||
elasticity = 0,
|
||||
cornerRadius = 999,
|
||||
className = '',
|
||||
padding,
|
||||
style,
|
||||
backdropImage,
|
||||
backdropStream,
|
||||
nativeBackdrop = false,
|
||||
onClick
|
||||
}: LiquidGlassProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const rawId = useId()
|
||||
// useId 可能包含 ':' 等 CSS url() 不接受的字符,需要清洗后才能用作滤镜 id
|
||||
const filterId = `liquid-glass-${rawId.replace(/[^a-zA-Z0-9_-]/g, '')}`
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [glassSize, setGlassSize] = useState({ width: 270, height: 69 })
|
||||
const [anchor, setAnchor] = useState({ x: 0, y: 0 })
|
||||
const [mouse, setMouse] = useState<MouseState>(IDLE_MOUSE)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
// 视频流出帧后才切换显示,避免黑帧闪烁
|
||||
const [streamLive, setStreamLive] = useState(false)
|
||||
// WebGL 渲染器初始化失败时回退 <video> + SVG 滤镜管线
|
||||
const [glFailed, setGlFailed] = useState(false)
|
||||
const useGlPipeline = Boolean(backdropStream) && !glFailed && !nativeBackdrop
|
||||
|
||||
// 透镜位移贴图按实际尺寸生成(中心零位移、边缘弯曲);
|
||||
// WebGL 流管线在着色器内解析求值同一几何,无需贴图;原生模式折射在原生面板完成
|
||||
const lensMap = useMemo(
|
||||
() => (useGlPipeline || nativeBackdrop ? null : generateLensDisplacementMap(glassSize.width, glassSize.height, cornerRadius)),
|
||||
[useGlPipeline, nativeBackdrop, glassSize.width, glassSize.height, cornerRadius]
|
||||
)
|
||||
// 贴图生成失败时跳过位移滤镜(模糊和材质层仍然生效)
|
||||
const refractionFilter = lensMap?.url ? `url(#${filterId})` : undefined
|
||||
|
||||
// WebGL 流管线:MediaStreamTrackProcessor 直读帧 + 着色器一次完成模糊/位移/色散/饱和,
|
||||
// 相比 <video> + CSS/SVG 滤镜省去播出缓冲与滤镜图逐帧重光栅化(延迟↓、GPU 开销↓)
|
||||
useEffect(() => {
|
||||
if (!useGlPipeline || !backdropImage) return
|
||||
const canvas = canvasRef.current
|
||||
const track = backdropStream?.getVideoTracks()[0]
|
||||
if (!canvas || !track) return
|
||||
|
||||
// clone:同一流可能有多个玻璃实例(新旧通知过渡)与亮度采样共同消费
|
||||
const renderer = createGlassStreamRenderer({
|
||||
canvas,
|
||||
track: track.clone(),
|
||||
width: glassSize.width,
|
||||
height: glassSize.height,
|
||||
cornerRadius,
|
||||
screenX: backdropImage.screenX + anchor.x,
|
||||
screenY: backdropImage.screenY + anchor.y,
|
||||
screenW: backdropImage.width,
|
||||
screenH: backdropImage.height,
|
||||
displacementScale,
|
||||
aberrationIntensity,
|
||||
saturation,
|
||||
// 与回退管线的 blur(${(4 + blurAmount * 32) / 2}px) 一致
|
||||
blurSigma: (4 + blurAmount * 32) / 2,
|
||||
onFirstFrame: () => setStreamLive(true)
|
||||
})
|
||||
if (!renderer) {
|
||||
setGlFailed(true)
|
||||
return
|
||||
}
|
||||
return () => {
|
||||
renderer.dispose()
|
||||
setStreamLive(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
useGlPipeline, backdropStream, glassSize.width, glassSize.height, cornerRadius,
|
||||
backdropImage?.screenX, backdropImage?.screenY, backdropImage?.width, backdropImage?.height,
|
||||
anchor.x, anchor.y, displacementScale, aberrationIntensity, saturation, blurAmount
|
||||
])
|
||||
|
||||
// 回退管线:<video> 承载流,CSS/SVG 滤镜加工
|
||||
useEffect(() => {
|
||||
if (useGlPipeline) return
|
||||
const video = videoRef.current
|
||||
if (!video || !backdropStream) return
|
||||
let cancelled = false
|
||||
video.srcObject = backdropStream
|
||||
// 用首帧回调而非 playing 事件:真正出帧的一刻立即显示,最小化感知延迟
|
||||
video.requestVideoFrameCallback(() => {
|
||||
if (!cancelled) setStreamLive(true)
|
||||
})
|
||||
video.play().catch(() => { /* 流启动失败时保持静态快照/材质层 */ })
|
||||
return () => {
|
||||
cancelled = true
|
||||
video.srcObject = null
|
||||
setStreamLive(false)
|
||||
}
|
||||
}, [useGlPipeline, backdropStream])
|
||||
|
||||
// 跟踪自身尺寸与页面内布局位置,供 SVG 滤镜和快照对齐使用
|
||||
useEffect(() => {
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
const width = el.offsetWidth
|
||||
const height = el.offsetHeight
|
||||
setGlassSize(prev => (prev.width === width && prev.height === height ? prev : { width, height }))
|
||||
const offset = getLayoutOffset(el)
|
||||
setAnchor(prev => (prev.x === offset.x && prev.y === offset.y ? prev : offset))
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// 鼠标接近时的弹性形变(rAF 节流;远离激活区时不重复 setState)
|
||||
useEffect(() => {
|
||||
if (elasticity <= 0) return
|
||||
|
||||
let rafHandle = 0
|
||||
let lastEvent: MouseEvent | null = null
|
||||
|
||||
const compute = () => {
|
||||
rafHandle = 0
|
||||
const el = rootRef.current
|
||||
if (!el || !lastEvent) return
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
const centerY = rect.top + rect.height / 2
|
||||
const deltaX = lastEvent.clientX - centerX
|
||||
const deltaY = lastEvent.clientY - centerY
|
||||
|
||||
const edgeDistance = Math.hypot(
|
||||
Math.max(0, Math.abs(deltaX) - rect.width / 2),
|
||||
Math.max(0, Math.abs(deltaY) - rect.height / 2)
|
||||
)
|
||||
if (edgeDistance > ACTIVATION_ZONE) {
|
||||
setMouse(prev => (prev.engaged ? IDLE_MOUSE : prev))
|
||||
return
|
||||
}
|
||||
|
||||
// 距离边缘越近效果越强
|
||||
const fadeIn = 1 - edgeDistance / ACTIVATION_ZONE
|
||||
const centerDistance = Math.hypot(deltaX, deltaY)
|
||||
const stretch = centerDistance === 0 ? 0 : Math.min(centerDistance / 300, 1) * elasticity * fadeIn
|
||||
const normalX = centerDistance === 0 ? 0 : Math.abs(deltaX / centerDistance)
|
||||
const normalY = centerDistance === 0 ? 0 : Math.abs(deltaY / centerDistance)
|
||||
|
||||
setMouse({
|
||||
engaged: true,
|
||||
tx: deltaX * elasticity * 0.1 * fadeIn,
|
||||
ty: deltaY * elasticity * 0.1 * fadeIn,
|
||||
// 沿鼠标方向拉伸、垂直方向压缩,模拟液体
|
||||
scaleX: Math.max(0.8, 1 + normalX * stretch * 0.3 - normalY * stretch * 0.15),
|
||||
scaleY: Math.max(0.8, 1 + normalY * stretch * 0.3 - normalX * stretch * 0.15),
|
||||
offsetX: (deltaX / rect.width) * 100,
|
||||
offsetY: (deltaY / rect.height) * 100
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
lastEvent = event
|
||||
if (!rafHandle) rafHandle = requestAnimationFrame(compute)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
if (rafHandle) cancelAnimationFrame(rafHandle)
|
||||
}
|
||||
}, [elasticity])
|
||||
|
||||
const clickable = Boolean(onClick)
|
||||
const pressed = isActive && clickable
|
||||
|
||||
let transform: string | undefined
|
||||
if (mouse.engaged) {
|
||||
transform = `translate(${mouse.tx}px, ${mouse.ty}px) ${pressed ? 'scale(0.96)' : `scaleX(${mouse.scaleX}) scaleY(${mouse.scaleY})`}`
|
||||
} else if (pressed) {
|
||||
transform = 'scale(0.96)'
|
||||
}
|
||||
|
||||
const borderGradient = (midAlpha: number, peakAlpha: number) => `linear-gradient(
|
||||
${135 + mouse.offsetX * 1.2}deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, ${midAlpha + Math.abs(mouse.offsetX) * 0.008}) ${Math.max(10, 33 + mouse.offsetY * 0.3)}%,
|
||||
rgba(255, 255, 255, ${peakAlpha + Math.abs(mouse.offsetX) * 0.012}) ${Math.min(90, 66 + mouse.offsetY * 0.4)}%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
)`
|
||||
|
||||
const overlayBase: CSSProperties = {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: cornerRadius,
|
||||
pointerEvents: 'none'
|
||||
}
|
||||
|
||||
// 快照/视频与屏幕逐像素对齐:按"窗口屏幕坐标 + 玻璃布局位置"负偏移,
|
||||
// 再补回裁剪层的出血宽度(像素源挂在 inset:-BLEED 的裁剪层内)
|
||||
const backdropPixelSourceStyle: CSSProperties | undefined = backdropImage
|
||||
? {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: backdropImage.width,
|
||||
height: backdropImage.height,
|
||||
maxWidth: 'none',
|
||||
transform: `translate(${PIXEL_SOURCE_BLEED - (backdropImage.screenX + anchor.x)}px, ${PIXEL_SOURCE_BLEED - (backdropImage.screenY + anchor.y)}px)`,
|
||||
pointerEvents: 'none'
|
||||
}
|
||||
: undefined
|
||||
|
||||
// 1.5px 环形高光:通过 padding + mask 挖空中心实现
|
||||
// 注意不要带深色投影,浅色页面上会形成一圈突兀的黑边
|
||||
const borderLayerBase: CSSProperties = {
|
||||
...overlayBase,
|
||||
padding: 1.5,
|
||||
WebkitMask: 'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)',
|
||||
WebkitMaskComposite: 'xor',
|
||||
maskComposite: 'exclude',
|
||||
boxShadow: '0 0 0 0.5px rgba(255, 255, 255, 0.35) inset, 0 1px 3px rgba(255, 255, 255, 0.2) inset'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`liquid-glass ${className}`.trim()}
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: cornerRadius,
|
||||
cursor: clickable ? 'pointer' : undefined,
|
||||
transform,
|
||||
transition: 'transform 0.2s ease-out',
|
||||
boxShadow: 'var(--liquid-glass-shadow)',
|
||||
...style
|
||||
}}
|
||||
onClick={onClick}
|
||||
onMouseEnter={clickable ? () => setIsHovered(true) : undefined}
|
||||
onMouseLeave={clickable ? () => { setIsHovered(false); setIsActive(false) } : undefined}
|
||||
onMouseDown={clickable ? () => setIsActive(true) : undefined}
|
||||
onMouseUp={clickable ? () => setIsActive(false) : undefined}
|
||||
>
|
||||
{lensMap?.url && (
|
||||
<GlassFilter
|
||||
id={filterId}
|
||||
map={lensMap}
|
||||
displacementScale={displacementScale}
|
||||
aberrationIntensity={aberrationIntensity}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 折射背景层:常规模式用 backdrop-filter 实时采样页面;
|
||||
原生模式(Windows)折射由主进程原生面板在窗口下方渲染,这里完全透明;
|
||||
流模式(透明窗口)优先走 WebGL 管线:采集帧直读进着色器,
|
||||
模糊/透镜位移/色散/饱和一次完成,画布尺寸即玻璃尺寸;
|
||||
WebGL 不可用时回退「<video> + CSS/SVG 滤镜」结构:
|
||||
外层裁剪(圆角)→ 中层挂位移滤镜 → 模糊裁剪层(玻璃+出血)→ 像素源。
|
||||
提供快照时先显示、视频流出帧后无缝接管;无快照时流首帧淡入 */}
|
||||
{nativeBackdrop ? null : useGlPipeline && backdropImage ? (
|
||||
<span style={{ ...overlayBase, overflow: 'hidden' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: streamLive ? 1 : 0,
|
||||
transition: 'opacity 0.12s ease',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : backdropImage ? (
|
||||
<span style={{ ...overlayBase, overflow: 'hidden' }}>
|
||||
<span style={{ position: 'absolute', inset: 0, filter: refractionFilter }}>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: -PIXEL_SOURCE_BLEED,
|
||||
overflow: 'hidden',
|
||||
// 快照模式模糊直接作用于像素源,同参数观感重于 backdrop 采样,减半以保留折射细节
|
||||
filter: `blur(${(4 + blurAmount * 32) / 2}px) saturate(${saturation}%)`
|
||||
}}
|
||||
>
|
||||
{backdropImage.dataUrl && !streamLive && (
|
||||
<img src={backdropImage.dataUrl} alt="" style={backdropPixelSourceStyle} />
|
||||
)}
|
||||
{backdropStream && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
style={{
|
||||
...backdropPixelSourceStyle,
|
||||
// 首帧就绪前保持透明、就绪后快速淡入:无快照兜底时折射内容不突兀也不拖沓
|
||||
opacity: streamLive ? 1 : 0,
|
||||
transition: 'opacity 0.12s ease'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
...overlayBase,
|
||||
overflow: 'hidden',
|
||||
// 与原库一致的轻模糊基线,保证玻璃通透而非磨砂
|
||||
backdropFilter: `blur(${4 + blurAmount * 32}px) saturate(${saturation}%)`,
|
||||
filter: refractionFilter
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 主题色调层:由全局兼容层变量控制,保证深浅模式下内容可读;
|
||||
变量可能被外部按背景高频连续调节(如通知的自适应纱层,采样上限 ~60Hz),
|
||||
短过渡只负责抹平采样步进,不引入可感知的跟随延迟 */}
|
||||
<span style={{ ...overlayBase, background: 'var(--liquid-glass-tint)', transition: 'background 0.08s linear' }} />
|
||||
|
||||
{/* 内容层保持清晰 */}
|
||||
<div style={{ position: 'relative', zIndex: 1, padding }}>{children}</div>
|
||||
|
||||
{/* 边缘高光双层(screen + overlay 混合),强度由兼容层变量整体缩放 */}
|
||||
<span style={{ ...borderLayerBase, zIndex: 2, mixBlendMode: 'screen', opacity: 'calc(0.2 * var(--liquid-glass-ring, 1))', background: borderGradient(0.12, 0.4) }} />
|
||||
<span style={{ ...borderLayerBase, zIndex: 2, mixBlendMode: 'overlay', opacity: 'var(--liquid-glass-ring, 1)', background: borderGradient(0.32, 0.6) }} />
|
||||
|
||||
{/* 可点击时的悬停 / 按下辉光 */}
|
||||
{clickable && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
...overlayBase,
|
||||
zIndex: 2,
|
||||
transition: 'opacity 0.2s ease-out',
|
||||
opacity: isHovered || isActive ? 0.5 : 0,
|
||||
backgroundImage: 'radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%)',
|
||||
mixBlendMode: 'overlay'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
...overlayBase,
|
||||
zIndex: 2,
|
||||
transition: 'opacity 0.2s ease-out',
|
||||
opacity: isActive ? 0.5 : 0,
|
||||
backgroundImage: 'radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 80%)',
|
||||
mixBlendMode: 'overlay'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
...overlayBase,
|
||||
zIndex: 2,
|
||||
transition: 'opacity 0.2s ease-out',
|
||||
opacity: isHovered ? 0.4 : isActive ? 0.8 : 0,
|
||||
backgroundImage: 'radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%)',
|
||||
mixBlendMode: 'overlay'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// 透镜位移贴图生成器(liquid-glass-react shader 模式思路的像素空间重写)
|
||||
//
|
||||
// 原版 shuding 公式在归一化坐标系里求 SDF,对宽扁卡片会把位移全部堆到四角、
|
||||
// 长边中段几乎无效果。这里改为像素空间按真实圆角计算:
|
||||
// 沿整个周边取弯曲带(bezel),带内用透镜剖面把采样点拉向中心,
|
||||
// 中心区域零位移保持完全清晰,边缘全周产生"透过厚玻璃边看背景"的弯曲折射。
|
||||
//
|
||||
// 贴图尺寸=元素尺寸、放置在滤镜坐标 (0,0):Chromium 实测 feImage 像素几何
|
||||
// (0,0,w,h) 与元素原点精确对齐,负坐标/百分比几何解析不可靠
|
||||
|
||||
/** 圆角矩形 SDF:内部为负,边界为 0(像素坐标,原点在矩形中心) */
|
||||
function roundedRectSDF(x: number, y: number, halfW: number, halfH: number, radius: number): number {
|
||||
const qx = Math.abs(x) - halfW + radius
|
||||
const qy = Math.abs(y) - halfH + radius
|
||||
return Math.min(Math.max(qx, qy), 0) + Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) - radius
|
||||
}
|
||||
|
||||
export interface LensDisplacementMap {
|
||||
/** 贴图 dataURL,生成失败时为空串(此时滤镜退化为无位移) */
|
||||
url: string
|
||||
/** 贴图编码的最大位移像素数;SVG 滤镜 scale 取 2×maxScale 时还原几何精确的透镜折射 */
|
||||
maxScale: number
|
||||
/** 贴图(=元素)尺寸,feImage 的像素几何 */
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** 按玻璃尺寸生成透镜位移贴图,R 通道编码 X 位移、G/B 通道编码 Y 位移,0.5 为零位移 */
|
||||
export function generateLensDisplacementMap(width: number, height: number, cornerRadius: number): LensDisplacementMap {
|
||||
const w = Math.max(1, Math.round(width))
|
||||
const h = Math.max(1, Math.round(height))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return { url: '', maxScale: 0, width: w, height: h }
|
||||
|
||||
const halfW = w / 2
|
||||
const halfH = h / 2
|
||||
const radius = Math.min(cornerRadius, halfW, halfH)
|
||||
// 弯曲带只占边缘一圈(真实玻璃只有斜面边缘折射,平板中心不弯曲)。
|
||||
// iOS 式宽幅湿边:带宽最多占短边一半的 75%(小药丸几乎整体是透镜,
|
||||
// 大面板保留清晰中心),不超过则弯曲不会叠进中心
|
||||
const bezel = Math.min(34, Math.min(halfW, halfH) * 0.75)
|
||||
// 弯月面鼓包峰值,斜率封顶保证映射单射(无断裂、无折叠):
|
||||
// 上升斜率 1.5/tp·maxBend/bezel ≤ 0.7(tp=0.62)⇒ maxBend = 0.289·bezel
|
||||
const maxBend = 0.289 * bezel
|
||||
|
||||
let maxScale = 0
|
||||
const rawValues: number[] = []
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
// 以玻璃中心为原点的像素坐标
|
||||
const px = x + 0.5 - halfW
|
||||
const py = y + 0.5 - halfH
|
||||
// 距边界深度(玻璃内部为正)
|
||||
const depth = -roundedRectSDF(px, py, halfW, halfH, radius)
|
||||
let dx = 0
|
||||
let dy = 0
|
||||
if (depth > 0 && depth < bezel) {
|
||||
// 液态弯月面剖面(iOS 边缘处理):位移在边界与带内缘均为 0,
|
||||
// 内容跨玻璃边无缝衔接、平滑并入清晰中心;带内 smoothstep 升降
|
||||
//(峰值在 t=0.62),近边界抻胀、向内压缩回正,斜率封顶无折叠
|
||||
const t = depth / bezel
|
||||
const u = Math.min(1, t < 0.62 ? t / 0.62 : (1 - t) / 0.38)
|
||||
const hump = u * u * (3 - 2 * u)
|
||||
const amount = hump * maxBend
|
||||
// 法线 = 平滑化 max(q,0) 的方向(角区连续旋转,无对角线折痕)
|
||||
const qx = Math.abs(px) - halfW + radius
|
||||
const qy = Math.abs(py) - halfH + radius
|
||||
const soft = Math.max(radius * 0.8, 1)
|
||||
const sx = 0.5 * (qx + Math.hypot(qx, soft))
|
||||
const sy = 0.5 * (qy + Math.hypot(qy, soft))
|
||||
const len = Math.hypot(sx, sy)
|
||||
if (len > 1e-4) {
|
||||
// 采样点沿法线向外:近边界内容被抻胀拽入边缘带(弯月面张力观感)
|
||||
dx = (sx / len) * Math.sign(px || 1) * amount
|
||||
dy = (sy / len) * Math.sign(py || 1) * amount
|
||||
}
|
||||
}
|
||||
maxScale = Math.max(maxScale, Math.abs(dx), Math.abs(dy))
|
||||
rawValues.push(dx, dy)
|
||||
}
|
||||
}
|
||||
maxScale = Math.max(maxScale, 1)
|
||||
|
||||
const imageData = context.createImageData(w, h)
|
||||
const data = imageData.data
|
||||
let rawIndex = 0
|
||||
let pixelIndex = 0
|
||||
const normalize = 2 * maxScale // [-max,max] 映射到 [0,1],不裁剪、无平顶色带
|
||||
for (let i = 0; i < rawValues.length; i += 2) {
|
||||
const r = rawValues[rawIndex++] / normalize + 0.5
|
||||
const g = rawValues[rawIndex++] / normalize + 0.5
|
||||
data[pixelIndex++] = Math.max(0, Math.min(255, Math.round(r * 255))) // R:X 位移
|
||||
data[pixelIndex++] = Math.max(0, Math.min(255, Math.round(g * 255))) // G:Y 位移
|
||||
data[pixelIndex++] = Math.max(0, Math.min(255, Math.round(g * 255))) // B:Y 位移(滤镜取 B 通道)
|
||||
data[pixelIndex++] = 255
|
||||
}
|
||||
context.putImageData(imageData, 0, 0)
|
||||
return { url: canvas.toDataURL(), maxScale, width: w, height: h }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 液态玻璃全局兼容层
|
||||
// 所有 LiquidGlass 实例的主题适配统一由这些变量控制:
|
||||
// - --liquid-glass-tint:玻璃色调层。浅色主题用白色磨砂保证深色文字可读,
|
||||
// 深色主题用暗色磨砂保证浅色文字可读(替代原库有合成缺陷的黑色 mix-blend 压暗层)
|
||||
// - --liquid-glass-shadow:玻璃投影 + 1px 描边,浅色页面上靠它与背景分离
|
||||
// - --liquid-glass-ring:边缘高光环强度系数(0~1)。玻璃直接叠在透明窗口背景上时
|
||||
// 混合层会全强度过曝,需要调低(如独立通知窗口)
|
||||
// 个别表面需要微调时,在其玻璃节点的 class 上覆盖同名变量即可
|
||||
|
||||
:root {
|
||||
--liquid-glass-tint: rgba(255, 255, 255, 0.42);
|
||||
--liquid-glass-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 12px 40px rgba(0, 0, 0, 0.24);
|
||||
--liquid-glass-ring: 1;
|
||||
}
|
||||
|
||||
[data-mode="dark"] {
|
||||
--liquid-glass-tint: rgba(24, 22, 20, 0.4);
|
||||
--liquid-glass-shadow: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: var(--bg-primary);
|
||||
// 保留少量透明度,让 backdrop blur 透出模糊后的应用内容,供解锁卡片折射
|
||||
background-color: color-mix(in srgb, var(--bg-primary) 86%, transparent);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -14,8 +15,6 @@
|
||||
-webkit-app-region: drag;
|
||||
transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
backdrop-filter: blur(25px) saturate(180%);
|
||||
background-color: var(--bg-primary);
|
||||
// 让背景带一点透明度以增强毛玻璃效果
|
||||
opacity: 1;
|
||||
|
||||
&.unlocked {
|
||||
@@ -24,21 +23,25 @@
|
||||
backdrop-filter: blur(0) saturate(100%);
|
||||
transform: scale(1.02);
|
||||
|
||||
.lock-content {
|
||||
.lock-card {
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 液态玻璃卡片外壳:承载出入场动画
|
||||
.lock-card {
|
||||
-webkit-app-region: no-drag;
|
||||
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.lock-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 320px;
|
||||
-webkit-app-region: no-drag;
|
||||
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
.lock-avatar {
|
||||
width: 100px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './LockScreen.scss'
|
||||
|
||||
interface LockScreenProps {
|
||||
@@ -99,7 +100,14 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
||||
|
||||
return (
|
||||
<div className={`lock-screen ${isUnlocked ? 'unlocked' : ''}`}>
|
||||
<div className="lock-content">
|
||||
<div className="lock-card">
|
||||
<LiquidGlass
|
||||
cornerRadius={28}
|
||||
padding="40px 36px"
|
||||
displacementScale={48}
|
||||
aberrationIntensity={1.5}
|
||||
>
|
||||
<div className="lock-content">
|
||||
<div className="lock-avatar">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="User" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
|
||||
@@ -138,6 +146,8 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
||||
</form>
|
||||
|
||||
{error && <div className="lock-error">{error}</div>}
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,166 +1,34 @@
|
||||
// 通知卡片:玻璃视觉全部由 LiquidGlass 兼容层渲染,
|
||||
// 本文件只负责留白、出入场动画与文本排版
|
||||
.notification-toast-container {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
width: 320px;
|
||||
background: var(--bg-secondary);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-light);
|
||||
|
||||
// 浅色模式下使用完全不透明背景,并禁用毛玻璃效果
|
||||
[data-mode="light"] &,
|
||||
:not([data-mode]) & {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
// 四周留白:容纳卡片投影,并透出窗口底层的仿桌面背景
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
pointer-events: none; // Allow clicking through when hidden
|
||||
transform: scale(0.96);
|
||||
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transform: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&.static {
|
||||
position: relative !important;
|
||||
width: calc(100% - 4px) !important; // Leave 2px margin for anti-aliasing saftey
|
||||
height: auto !important; // Fits content
|
||||
min-height: 0;
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
transform: none !important;
|
||||
margin: 2px !important; // 2px centered margin
|
||||
border-radius: 12px !important; // Rounded corners
|
||||
// 玻璃材质与文字颜色均由 useNotificationAdaptiveTheme 按桌面背景的实际对比度
|
||||
// 无级计算后写入 <html> 的 --noti-* 变量(纱层方向/浓度、分区连续文字色、光晕强度);
|
||||
// 此处仅提供首帧采样前的浅色默认值。投影收敛到留白宽度内,避免被窗口矩形裁剪出脏边
|
||||
--liquid-glass-shadow: var(--noti-shadow, 0 0 0 1px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.22));
|
||||
--liquid-glass-tint: var(--noti-tint, rgba(255, 255, 255, 0.1));
|
||||
|
||||
|
||||
// Disable backdrop filter
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
|
||||
// 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <html> 上的主题属性
|
||||
background: #ffffff;
|
||||
color: #3d3d3d;
|
||||
--text-primary: #3d3d3d;
|
||||
--text-secondary: #666666;
|
||||
--text-tertiary: #999999;
|
||||
--border-light: rgba(0, 0, 0, 0.08);
|
||||
|
||||
// 深色模式覆盖
|
||||
[data-mode="dark"] & {
|
||||
background: var(--bg-secondary-solid, #282420);
|
||||
color: var(--text-primary, #F0EEE9);
|
||||
--text-primary: #F0EEE9;
|
||||
--text-secondary: #b3b0aa;
|
||||
--text-tertiary: #807d78;
|
||||
--border-light: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
box-shadow: none !important; // NO SHADOW
|
||||
border: 1px solid var(--border-light);
|
||||
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
padding-right: 32px; // Make space for close button
|
||||
box-sizing: border-box;
|
||||
|
||||
// Force close button to be visible but transparent background
|
||||
.notification-close {
|
||||
opacity: 1 !important;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: transparent !important; // Transparent per user request
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1) !important; // Subtle hover effect
|
||||
}
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
top: 24px; // Match padding
|
||||
right: 40px; // Left of close button (12px + 20px + 8px)
|
||||
}
|
||||
}
|
||||
|
||||
// Position variants
|
||||
&.bottom-right {
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
transform: translate(0, 20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.top-right {
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
transform: translate(0, -20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.bottom-left {
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
transform: translate(0, 20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.top-left {
|
||||
top: 24px;
|
||||
left: 24px;
|
||||
transform: translate(0, -20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.top-center {
|
||||
top: 24px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
|
||||
// 灵动岛样式
|
||||
border-radius: 40px !important;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&.static {
|
||||
border-radius: 40px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
||||
}
|
||||
color: var(--noti-title-color, #2c2c2c);
|
||||
|
||||
.notification-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding-right: 14px; // 给右上角关闭按钮留出空间
|
||||
}
|
||||
|
||||
.notification-avatar {
|
||||
@@ -180,29 +48,34 @@
|
||||
.notification-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
color: var(--noti-title-color, #2c2c2c);
|
||||
text-shadow: var(--noti-title-halo, none);
|
||||
transition: color 0.08s linear, text-shadow 0.08s linear;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%; // 允许缩放
|
||||
flex: 1; // 占据剩余空间
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0; // 关键:允许 flex 子项收缩到内容以下
|
||||
margin-right: 60px; // Make space for absolute time + close button
|
||||
margin-right: 56px; // 给绝对定位的时间 + 关闭按钮留出空间
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
color: var(--noti-title-tertiary, #7a7a7a);
|
||||
transition: color 0.08s linear;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 36px; // Left of close button (8px + 20px + 8px)
|
||||
top: 14px;
|
||||
right: 40px; // 关闭按钮左侧
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
color: var(--noti-body-color, #4d4d4d);
|
||||
text-shadow: var(--noti-body-halo, none);
|
||||
transition: color 0.08s linear, text-shadow 0.08s linear;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -215,8 +88,8 @@
|
||||
|
||||
.notification-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
@@ -225,18 +98,14 @@
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
color: var(--noti-title-tertiary, #7a7a7a);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
z-index: 2;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
color: var(--noti-title-color, #2c2c2c);
|
||||
background: var(--noti-close-hover-bg, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .notification-close {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Avatar } from './Avatar'
|
||||
import LiquidGlass, { type LiquidGlassBackdropImage } from './LiquidGlass'
|
||||
import { renderTextWithEmoji } from '../utils/renderTextWithEmoji'
|
||||
import './NotificationToast.scss'
|
||||
|
||||
export interface NotificationData {
|
||||
@@ -21,22 +22,43 @@ interface NotificationToastProps {
|
||||
onClose: () => void
|
||||
onClick: (data: NotificationData) => void
|
||||
duration?: number
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||
isStatic?: boolean
|
||||
initialVisible?: boolean
|
||||
/** 回退管线的屏幕几何信息(含可选桌面快照),玻璃用它对齐折射采样 */
|
||||
backdropImage?: LiquidGlassBackdropImage
|
||||
/** 回退管线的屏幕实时视频流,就绪后折射随桌面实时更新;原生模式下为 null */
|
||||
backdropStream?: MediaStream | null
|
||||
/** 原生玻璃模式(Windows):折射由主进程原生面板渲染,卡片背景透明 */
|
||||
nativeBackdrop?: boolean
|
||||
/** 退场动画开始的一刻触发(原生模式用来提前淡出原生面板) */
|
||||
onHideStart?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知卡片:始终渲染为全局液态玻璃(LiquidGlass 兼容层),在独立通知窗口内展示。
|
||||
* 折射背景分两路:Windows 原生模式由主进程的原生面板在窗口下方实时渲染
|
||||
* (卡片内完全透明),原生不可用时回退为渲染层自采的桌面视频流
|
||||
*/
|
||||
export function NotificationToast({
|
||||
data,
|
||||
onClose,
|
||||
onClick,
|
||||
duration = 5000,
|
||||
position = 'top-right',
|
||||
isStatic = false,
|
||||
initialVisible = false
|
||||
initialVisible = false,
|
||||
backdropImage,
|
||||
backdropStream,
|
||||
nativeBackdrop = false,
|
||||
onHideStart
|
||||
}: NotificationToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(initialVisible)
|
||||
const [currentData, setCurrentData] = useState<NotificationData | null>(null)
|
||||
const onHideStartRef = useRef(onHideStart)
|
||||
onHideStartRef.current = onHideStart
|
||||
|
||||
// 任何路径(超时/关闭/点击)触发的退场都在动画开始的一刻通知外层
|
||||
const beginHide = () => {
|
||||
setIsVisible(false)
|
||||
onHideStartRef.current?.()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -44,7 +66,7 @@ export function NotificationToast({
|
||||
setIsVisible(true)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
beginHide()
|
||||
// clean up data after animation
|
||||
setTimeout(onClose, 300)
|
||||
}, duration)
|
||||
@@ -53,59 +75,63 @@ export function NotificationToast({
|
||||
} else {
|
||||
setIsVisible(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data, duration, onClose])
|
||||
|
||||
if (!currentData) return null
|
||||
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setIsVisible(false)
|
||||
beginHide()
|
||||
setTimeout(onClose, 300)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
setIsVisible(false)
|
||||
beginHide()
|
||||
setTimeout(() => {
|
||||
onClose()
|
||||
onClick(currentData)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`notification-toast-container ${position} ${isVisible ? 'visible' : ''} ${isStatic ? 'static' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="notification-content">
|
||||
<div className="notification-avatar">
|
||||
<Avatar
|
||||
src={currentData.avatarUrl}
|
||||
name={currentData.title}
|
||||
size={40}
|
||||
/>
|
||||
</div>
|
||||
<div className="notification-text">
|
||||
<div className="notification-header">
|
||||
<span className="notification-title">{currentData.title}</span>
|
||||
<span className="notification-time">
|
||||
{new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
return (
|
||||
<div className={`notification-toast-container ${isVisible ? 'visible' : ''}`}>
|
||||
<LiquidGlass
|
||||
cornerRadius={16}
|
||||
padding="14px 16px"
|
||||
blurAmount={0}
|
||||
saturation={140}
|
||||
displacementScale={70}
|
||||
aberrationIntensity={1}
|
||||
backdropImage={backdropImage}
|
||||
backdropStream={backdropStream}
|
||||
nativeBackdrop={nativeBackdrop}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="notification-content">
|
||||
<div className="notification-avatar">
|
||||
<Avatar
|
||||
src={currentData.avatarUrl}
|
||||
name={currentData.title}
|
||||
size={40}
|
||||
/>
|
||||
</div>
|
||||
<div className="notification-body">
|
||||
{currentData.content}
|
||||
<div className="notification-text">
|
||||
<div className="notification-header">
|
||||
<span className="notification-title">{currentData.title}</span>
|
||||
<span className="notification-time">
|
||||
{new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="notification-body">
|
||||
{renderTextWithEmoji(currentData.content, 17)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="notification-close" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<button className="notification-close" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isStatic) {
|
||||
return content
|
||||
}
|
||||
|
||||
// Portal to document.body to ensure it's on top
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-rea
|
||||
import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
|
||||
import { Avatar } from '../Avatar'
|
||||
import { SnsMediaGrid } from './SnsMediaGrid'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
import { renderTextWithEmoji } from '../../utils/renderTextWithEmoji'
|
||||
|
||||
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
|
||||
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
|
||||
@@ -437,23 +437,6 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
})
|
||||
}
|
||||
|
||||
// 解析微信表情
|
||||
const renderTextWithEmoji = (text: string) => {
|
||||
if (!text) return text
|
||||
const parts = text.split(/\[(.*?)\]/g)
|
||||
return parts.map((part, index) => {
|
||||
if (index % 2 === 1) {
|
||||
// @ts-ignore
|
||||
const path = getEmojiPath(part as any)
|
||||
if (path) {
|
||||
return <img key={index} src={`${import.meta.env.BASE_URL}${path}`} alt={`[${part}]`} className="inline-emoji" style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }} />
|
||||
}
|
||||
return `[${part}]`
|
||||
}
|
||||
return part
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (deleting || dbDeleted) return
|
||||
|
||||
@@ -12,20 +12,23 @@
|
||||
z-index: 9999;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
|
||||
// 液态玻璃外壳:宽度与入场动画在玻璃根节点上
|
||||
.update-dialog-glass {
|
||||
width: min(680px, calc(100vw - 48px));
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.update-dialog {
|
||||
width: 680px;
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
width: 100%;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* Top Section (White/Gradient) */
|
||||
/* Top Section (White/Gradient),半透明以透出玻璃折射 */
|
||||
.dialog-header {
|
||||
background: var(--bg-primary, #ffffff);
|
||||
background: color-mix(in srgb, var(--bg-primary, #ffffff) 78%, transparent);
|
||||
padding: 40px 20px 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -72,9 +75,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Content Section (Light Gray) */
|
||||
/* Content Section (Light Gray),半透明以透出玻璃折射 */
|
||||
.dialog-content {
|
||||
background: var(--bg-tertiary, #f2f2f2);
|
||||
background: color-mix(in srgb, var(--bg-tertiary, #f2f2f2) 72%, transparent);
|
||||
padding: 24px 40px 40px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Quote, X } from 'lucide-react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './UpdateDialog.scss'
|
||||
|
||||
interface UpdateInfo {
|
||||
@@ -70,7 +71,13 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
||||
|
||||
return (
|
||||
<div className="update-dialog-overlay">
|
||||
<div className="update-dialog">
|
||||
<LiquidGlass
|
||||
className="update-dialog-glass"
|
||||
cornerRadius={24}
|
||||
displacementScale={40}
|
||||
aberrationIntensity={1.5}
|
||||
>
|
||||
<div className="update-dialog">
|
||||
{!isDownloading && !isMandatory && (
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={20} />
|
||||
@@ -135,7 +142,8 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,29 +8,17 @@
|
||||
animation: capsuleSlideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
.capsule-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
// 视觉背景(毛玻璃、边框、阴影)由 LiquidGlass 提供,这里只负责内容布局
|
||||
.capsule-content {
|
||||
background: var(--bg-primary);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
padding: 8px 18px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: background 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
@@ -112,11 +100,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// State Modifiers
|
||||
// State Modifiers(改为半透明色调叠加在玻璃之上)
|
||||
&.state-available {
|
||||
.capsule-content {
|
||||
background: var(--primary);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: color-mix(in srgb, var(--primary) 82%, transparent);
|
||||
color: white;
|
||||
|
||||
.icon-wrapper {
|
||||
@@ -139,16 +126,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.state-downloading {
|
||||
.capsule-content {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.state-error {
|
||||
.capsule-content {
|
||||
background: #fff1f0;
|
||||
border-color: #ffa39e;
|
||||
background: color-mix(in srgb, #ff4d4f 14%, transparent);
|
||||
|
||||
.icon-wrapper {
|
||||
color: #ff4d4f;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { Download, X, AlertCircle, Info } from 'lucide-react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './UpdateProgressCapsule.scss'
|
||||
|
||||
const UpdateProgressCapsule: React.FC = () => {
|
||||
@@ -102,15 +103,22 @@ const UpdateProgressCapsule: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={capsuleClass} onClick={() => setShowUpdateDialog(true)}>
|
||||
<div className="capsule-content">
|
||||
{content}
|
||||
{!isDownloading && (
|
||||
<button className="capsule-close" onClick={handleClose}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={capsuleClass}>
|
||||
<LiquidGlass
|
||||
cornerRadius={24}
|
||||
displacementScale={48}
|
||||
aberrationIntensity={1.5}
|
||||
onClick={() => setShowUpdateDialog(true)}
|
||||
>
|
||||
<div className="capsule-content">
|
||||
{content}
|
||||
{!isDownloading && (
|
||||
<button className="capsule-close" onClick={handleClose}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,16 +13,20 @@
|
||||
animation: windowCloseDialogFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.window-close-dialog {
|
||||
// 液态玻璃外壳:宽度与入场动画在玻璃根节点上
|
||||
.window-close-dialog-glass {
|
||||
width: min(560px, 100%);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||
animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.window-close-dialog {
|
||||
width: 100%;
|
||||
border-radius: 24px;
|
||||
// 半透明背景透出玻璃折射,边框与阴影由 LiquidGlass 提供
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 94%, white 6%) 0%, var(--bg-primary) 100%);
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32);
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 72%, transparent) 0%, color-mix(in srgb, var(--bg-primary) 84%, transparent) 100%);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.window-close-dialog-header {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Minimize2, Power, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './WindowCloseDialog.scss'
|
||||
|
||||
interface WindowCloseDialogProps {
|
||||
@@ -39,13 +40,19 @@ export default function WindowCloseDialog({
|
||||
|
||||
return (
|
||||
<div className="window-close-dialog-overlay" onClick={onCancel}>
|
||||
<div
|
||||
className="window-close-dialog"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="window-close-dialog-title"
|
||||
<LiquidGlass
|
||||
className="window-close-dialog-glass"
|
||||
cornerRadius={24}
|
||||
displacementScale={40}
|
||||
aberrationIntensity={1.5}
|
||||
>
|
||||
<div
|
||||
className="window-close-dialog"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="window-close-dialog-title"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="window-close-dialog-close"
|
||||
@@ -118,7 +125,8 @@ export default function WindowCloseDialog({
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-28
@@ -9,7 +9,7 @@ import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batc
|
||||
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
||||
import type { ChatRecordItem, ChatSession, Message } from '../types/models'
|
||||
import type { GroupSummaryRecord, GroupSummaryRecordSummary } from '../types/electron'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
import { renderTextWithEmoji } from '../utils/renderTextWithEmoji'
|
||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
||||
@@ -10985,33 +10985,6 @@ function MessageBubble({
|
||||
return content.replace(/^[a-zA-Z0-9]+@openim:\n?/, '')
|
||||
}, [])
|
||||
|
||||
// 解析混合文本和表情
|
||||
const renderTextWithEmoji = useCallback((text: string) => {
|
||||
if (!text) return text
|
||||
const parts = text.split(/\[(.*?)\]/g)
|
||||
return parts.map((part, index) => {
|
||||
// 奇数索引是捕获组的内容(即括号内的文字)
|
||||
if (index % 2 === 1) {
|
||||
// @ts-ignore
|
||||
const path = getEmojiPath(part as any)
|
||||
if (path) {
|
||||
// path 例如 'assets/face/微笑.png',需要添加 base 前缀
|
||||
return (
|
||||
<img
|
||||
key={index}
|
||||
src={`${import.meta.env.BASE_URL}${path}`}
|
||||
alt={`[${part}]`}
|
||||
className="inline-emoji"
|
||||
style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return `[${part}]`
|
||||
}
|
||||
return part
|
||||
})
|
||||
}, [])
|
||||
|
||||
const cleanedParsedContent = useMemo(
|
||||
() => cleanMessageContent(message.parsedContent || ''),
|
||||
[cleanMessageContent, message.parsedContent]
|
||||
|
||||
@@ -44,7 +44,7 @@ export type TextExportFormat =
|
||||
| 'weclone'
|
||||
| 'sql'
|
||||
|
||||
export type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson'
|
||||
export type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson' | 'markdown'
|
||||
|
||||
export type ContactsSortKey = 'messageCount' | 'latestMessageTime'
|
||||
export type ContactsSortOrder = 'desc' | 'asc'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// 卡片动画可以安全使用 opacity:回退管线的玻璃像素源自含在卡片内(视频流画布),
|
||||
// 原生模式的折射面板在窗口之下、由主进程独立同步淡入淡出,均不受卡片透明度影响
|
||||
@keyframes noti-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.96);
|
||||
transform: translateY(-24px) scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
@@ -22,29 +24,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 旧卡片原地缩小淡出:不做位移,避免从新卡片背后探出边缘
|
||||
@keyframes noti-exit {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
filter: blur(0);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.92) translateY(4px);
|
||||
filter: blur(2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes noti-exit-center {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px) scale(0.7);
|
||||
transform: scale(0.94);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,18 +45,12 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#notification-root {
|
||||
// Ensure the container allows 3D transforms
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
#notification-current {
|
||||
// New notification slides in
|
||||
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
will-change: transform, opacity;
|
||||
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
&.anim-center {
|
||||
animation: noti-enter-center 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
animation: noti-enter-center 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +58,7 @@ body {
|
||||
// Old notification scales out
|
||||
animation: noti-exit 0.35s cubic-bezier(0.33, 1, 0.68, 1) forwards;
|
||||
transform-origin: center top;
|
||||
will-change: transform, opacity, filter;
|
||||
|
||||
// Ensure it stays behind
|
||||
z-index: 0 !important;
|
||||
|
||||
&.anim-center {
|
||||
animation: noti-exit-center 0.5s cubic-bezier(0.33, 1, 0.68, 1) forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,40 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { NotificationToast, type NotificationData } from '../components/NotificationToast'
|
||||
import type { LiquidGlassBackdropImage } from '../components/LiquidGlass'
|
||||
import {
|
||||
useNotificationAdaptiveTheme,
|
||||
useNotificationNativeAdaptiveTheme,
|
||||
getLayoutRect,
|
||||
NATIVE_BAND_IDS
|
||||
} from './useNotificationAdaptiveTheme'
|
||||
import '../components/NotificationToast.scss'
|
||||
import './NotificationWindow.scss'
|
||||
|
||||
/** 与 NotificationToast 传给 LiquidGlass 的参数保持一致(原生面板需要同一套值) */
|
||||
const GLASS_PARAMS = { cornerRadius: 16, blurSigma: 2, displacementScale: 70, aberrationIntensity: 1, saturation: 140 }
|
||||
|
||||
export default function NotificationWindow() {
|
||||
const [notification, setNotification] = useState<NotificationData | null>(null)
|
||||
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
||||
const [position, setPosition] = useState<string>('top-right')
|
||||
// 主进程随通知下发的屏幕几何信息(尺寸 + 窗口坐标)。
|
||||
// 回退管线(原生玻璃不可用时)用它把桌面视频流与屏幕逐像素对齐——
|
||||
// Electron 透明窗口内 backdrop-filter 采不到桌面,渲染层自采视频流是回退路径下唯一的折射来源
|
||||
const [backdrop, setBackdrop] = useState<LiquidGlassBackdropImage | undefined>(undefined)
|
||||
// 原生玻璃模式(Windows):折射由主进程的原生面板在窗口下方提供,
|
||||
// 渲染层不开视频流、不渲染折射画布,只负责上报卡片几何与内容层
|
||||
const [nativeBackdrop, setNativeBackdrop] = useState(false)
|
||||
// 屏幕实时视频流(仅回退管线使用):通知展示期间开启,玻璃折射随桌面实时更新;
|
||||
// 隐藏后释放采集。原生模式下始终为 null
|
||||
const [stream, setStream] = useState<MediaStream | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const sourceIdRef = useRef<string | null>(null)
|
||||
|
||||
// We need a ref to access the current notification inside the callback
|
||||
// without satisfying the dependency array which would recreate the listener
|
||||
// Actually, setNotification(prev => ...) pattern is better, but we need the VALUE of current to set as prev.
|
||||
// So we use setNotification callback: setNotification(current => { ... return newNode })
|
||||
// But we need to update TWO states.
|
||||
// So we use a ref to track "current displayed" for the event handler.
|
||||
// Or just use functional updates, but we need to setPrev(current).
|
||||
|
||||
// 事件回调里需要读取"当前展示中"的通知作为过渡的旧通知,用 ref 避免重建监听
|
||||
const notificationRef = useRef<NotificationData | null>(null)
|
||||
// 上次上报的窗口尺寸:重复上报会触发主进程 setSize,
|
||||
// 可见状态下反复设置尺寸会让 DWM 短暂拉伸旧帧缓冲,闪出一圈幽灵轮廓
|
||||
const lastSizeRef = useRef<{ width: number; height: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
notificationRef.current = notification
|
||||
@@ -24,7 +42,6 @@ export default function NotificationWindow() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleShow = (_event: any, data: any) => {
|
||||
// data: { title, content, avatarUrl, sessionId }
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const newNoti: NotificationData = {
|
||||
id: `noti_${timestamp}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
@@ -38,12 +55,20 @@ export default function NotificationWindow() {
|
||||
avatarUrl: data.avatarUrl
|
||||
}
|
||||
|
||||
// 获取位置配置
|
||||
if (data.position) {
|
||||
setPosition(data.position)
|
||||
}
|
||||
if (data.backdrop) {
|
||||
setBackdrop({
|
||||
width: data.backdrop.width,
|
||||
height: data.backdrop.height,
|
||||
screenX: data.backdrop.winX,
|
||||
screenY: data.backdrop.winY
|
||||
})
|
||||
setNativeBackdrop(Boolean(data.backdrop.native))
|
||||
sourceIdRef.current = data.backdrop.sourceId ?? null
|
||||
}
|
||||
|
||||
// Set previous to current (ref)
|
||||
if (notificationRef.current) {
|
||||
setPrevNotification(notificationRef.current)
|
||||
}
|
||||
@@ -67,6 +92,129 @@ export default function NotificationWindow() {
|
||||
}
|
||||
}, [prevNotification])
|
||||
|
||||
// 分区无级自适应:整卡驱动玻璃纱层方向与浓度,标题行/正文按各自背后区域
|
||||
// 的实际对比度连续取色(算法详见 useNotificationAdaptiveTheme)。
|
||||
// 流模式从视频帧采样;原生模式吃原生面板回读的亮度带事件
|
||||
useNotificationAdaptiveTheme(stream, backdrop)
|
||||
useNotificationNativeAdaptiveTheme(nativeBackdrop)
|
||||
|
||||
const stopStream = () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop())
|
||||
streamRef.current = null
|
||||
setStream(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 回退管线(原生玻璃不可用时):通知展示期间开启屏幕采集流
|
||||
// (通知窗口已被 content protection 排除,不会拍到自己);
|
||||
// 全部隐藏后立即停止采集释放资源。原生模式下 sourceId 为空,永不触发
|
||||
useEffect(() => {
|
||||
const active = Boolean(notification || prevNotification)
|
||||
|
||||
if (active && !streamRef.current && sourceIdRef.current && !nativeBackdrop) {
|
||||
let cancelled = false
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const captureW = Math.round(window.screen.width * dpr)
|
||||
const captureH = Math.round(window.screen.height * dpr)
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
// Electron 专有约束:按 sourceId 采集屏幕,无需用户手势
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: sourceIdRef.current,
|
||||
minWidth: captureW,
|
||||
maxWidth: captureW,
|
||||
minHeight: captureH,
|
||||
maxHeight: captureH,
|
||||
maxFrameRate: 60
|
||||
}
|
||||
}
|
||||
} as unknown as MediaStreamConstraints).then(mediaStream => {
|
||||
if (cancelled) {
|
||||
mediaStream.getTracks().forEach(track => track.stop())
|
||||
return
|
||||
}
|
||||
// 提示采集调度器优先保帧率而非画质(本地渲染无编码环节,无画质代价)
|
||||
mediaStream.getVideoTracks().forEach(track => { track.contentHint = 'motion' })
|
||||
streamRef.current = mediaStream
|
||||
setStream(mediaStream)
|
||||
}).catch(error => {
|
||||
console.warn('[NotificationWindow] 屏幕流获取失败,折射退化为兜底材质:', error)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!active) stopStream()
|
||||
}, [notification, prevNotification, nativeBackdrop])
|
||||
|
||||
// 原生玻璃模式:卡片挂载后上报实测几何(窗口本地 CSS 像素 + 卡片本地亮度带),
|
||||
// 主进程据此创建/复用窗口下方的原生面板;参数与 LiquidGlass 的视觉参数一致
|
||||
useEffect(() => {
|
||||
if (!nativeBackdrop || !notification) return
|
||||
// 同一条通知内的重复上报去重(双 rAF 首测 + 120ms 复测几何通常一致):
|
||||
// 跳过后主进程不会白做 setBounds/setLumaBands/anchor 原生调用,
|
||||
// 也避免 setLumaBands 触发的一次带 GPU 同步等待的亮度补采。
|
||||
// 局部变量随 effect 重建,新通知(即使几何相同)必然重新上报以驱动面板 show
|
||||
let lastSent = ''
|
||||
const report = () => {
|
||||
const host = document.getElementById('notification-current')
|
||||
const glassEl = host?.querySelector<HTMLElement>('.liquid-glass')
|
||||
if (!glassEl) return
|
||||
const card = getLayoutRect(glassEl)
|
||||
if (card.width < 1 || card.height < 1) return
|
||||
const bands: Array<{ id: number; x: number; y: number; width: number; height: number }> = [
|
||||
{ id: NATIVE_BAND_IDS.card, x: 0, y: 0, width: card.width, height: card.height }
|
||||
]
|
||||
const bandEls: Array<[number, HTMLElement | null]> = [
|
||||
[NATIVE_BAND_IDS.title, host?.querySelector<HTMLElement>('.notification-header') ?? null],
|
||||
[NATIVE_BAND_IDS.body, host?.querySelector<HTMLElement>('.notification-body') ?? null]
|
||||
]
|
||||
for (const [id, el] of bandEls) {
|
||||
if (!el) continue
|
||||
const rect = getLayoutRect(el)
|
||||
bands.push({ id, x: rect.left - card.left, y: rect.top - card.top, width: rect.width, height: rect.height })
|
||||
}
|
||||
const payload = {
|
||||
card: { x: card.left, y: card.top, width: card.width, height: card.height },
|
||||
bands,
|
||||
// CSS px → 物理 px 的换算系数(devicePixelRatio 已含页面缩放,
|
||||
// 主进程不能只用显示器 scaleFactor:缩放会随 file:// 域持久化)
|
||||
dpr: window.devicePixelRatio || 1,
|
||||
...GLASS_PARAMS
|
||||
}
|
||||
const key = JSON.stringify(payload)
|
||||
if (key === lastSent) return
|
||||
lastSent = key
|
||||
window.electronAPI?.notification?.glassRect?.(payload)
|
||||
}
|
||||
// 双 rAF 等首次布局落定后测量;120ms 复测一次,覆盖表情图/字体就绪导致的高度变化
|
||||
let raf2 = 0
|
||||
const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(report) })
|
||||
const timer = setTimeout(report, 120)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1)
|
||||
cancelAnimationFrame(raf2)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [nativeBackdrop, notification, position])
|
||||
|
||||
// 兜底释放:窗口被主进程隐藏或组件卸载时,无论上层状态如何都立即停止采集,
|
||||
// 确保任何路径下都不会在后台持续占用采集管线
|
||||
useEffect(() => {
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') stopStream()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
stopStream()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClose = () => {
|
||||
setNotification(null)
|
||||
setPrevNotification(null)
|
||||
@@ -90,96 +238,93 @@ export default function NotificationWindow() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Measure only if we have a notification (current or prev)
|
||||
if (!notification && !prevNotification) return
|
||||
|
||||
// Prefer measuring the NEW one
|
||||
const targetId = notification ? 'notification-current' : 'notification-prev'
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
// Find the wrapper of the content
|
||||
// Since we wrap them, we should measure the content inside
|
||||
// But getting root is easier if size is set by relative child
|
||||
// 窗口必须精确贴合内容高度,多余区域会拦截桌面点击
|
||||
const root = document.getElementById('notification-root')
|
||||
if (root) {
|
||||
const height = root.offsetHeight
|
||||
const width = 344
|
||||
if (window.electronAPI?.notification?.resize) {
|
||||
const finalHeight = Math.min(height + 4, 300)
|
||||
window.electronAPI.notification.resize(width, finalHeight)
|
||||
}
|
||||
if (root && window.electronAPI?.notification?.resize) {
|
||||
const width = position === 'top-center' ? 280 : 344
|
||||
const height = Math.min(Math.ceil(root.getBoundingClientRect().height), 300)
|
||||
const last = lastSizeRef.current
|
||||
if (last && last.width === width && last.height === height) return
|
||||
lastSizeRef.current = { width, height }
|
||||
window.electronAPI.notification.resize(width, height)
|
||||
}
|
||||
}, 50)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [notification, prevNotification])
|
||||
}, [notification, prevNotification, position])
|
||||
|
||||
if (!notification && !prevNotification) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
id="notification-root"
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: 'auto',
|
||||
minHeight: '10px',
|
||||
background: 'transparent',
|
||||
position: 'relative', // Context for absolute children
|
||||
overflow: 'hidden', // Prevent scrollbars during transition
|
||||
padding: '2px', // Margin safe
|
||||
boxSizing: 'border-box'
|
||||
}}>
|
||||
<>
|
||||
<div
|
||||
id="notification-root"
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: 'auto',
|
||||
background: 'transparent',
|
||||
position: 'relative', // Context for absolute children
|
||||
overflow: 'hidden' // Prevent scrollbars during transition
|
||||
}}>
|
||||
|
||||
{/* Previous Notification (Background / Fading Out) */}
|
||||
{prevNotification && (
|
||||
<div
|
||||
id="notification-prev"
|
||||
key={prevNotification.id}
|
||||
className={position === 'top-center' ? 'anim-center' : ''}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2, // Match padding
|
||||
left: 2,
|
||||
width: 'calc(100% - 4px)', // Match width logic
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none' // Disable interaction on old one
|
||||
}}
|
||||
>
|
||||
<NotificationToast
|
||||
{/* Previous Notification (Background / Fading Out) */}
|
||||
{prevNotification && (
|
||||
<div
|
||||
id="notification-prev"
|
||||
key={prevNotification.id}
|
||||
data={prevNotification}
|
||||
onClose={() => { }} // No-op for background item
|
||||
onClick={() => { }}
|
||||
position={position as any}
|
||||
isStatic={true}
|
||||
initialVisible={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
className={position === 'top-center' ? 'anim-center' : ''}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none' // Disable interaction on old one
|
||||
}}
|
||||
>
|
||||
<NotificationToast
|
||||
key={prevNotification.id}
|
||||
data={prevNotification}
|
||||
onClose={() => { }} // No-op for background item
|
||||
onClick={() => { }}
|
||||
initialVisible={true}
|
||||
backdropImage={backdrop}
|
||||
backdropStream={stream}
|
||||
nativeBackdrop={nativeBackdrop}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Notification (Foreground / Fading In) */}
|
||||
{notification && (
|
||||
<div
|
||||
id="notification-current"
|
||||
key={notification.id}
|
||||
className={position === 'top-center' ? 'anim-center' : ''}
|
||||
style={{
|
||||
position: 'relative', // Takes up space
|
||||
zIndex: 2,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<NotificationToast
|
||||
key={notification.id} // Ensure remount for animation
|
||||
data={notification}
|
||||
onClose={handleClose}
|
||||
onClick={handleClick}
|
||||
position={position as any}
|
||||
isStatic={true}
|
||||
initialVisible={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Current Notification (Foreground / Fading In) */}
|
||||
{notification && (
|
||||
<div
|
||||
id="notification-current"
|
||||
key={notification.id}
|
||||
className={position === 'top-center' ? 'anim-center' : ''}
|
||||
style={{
|
||||
position: 'relative', // Takes up space
|
||||
zIndex: 2,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<NotificationToast
|
||||
key={notification.id} // Ensure remount for animation
|
||||
data={notification}
|
||||
onClose={handleClose}
|
||||
onClick={handleClick}
|
||||
initialVisible={true}
|
||||
backdropImage={backdrop}
|
||||
backdropStream={stream}
|
||||
nativeBackdrop={nativeBackdrop}
|
||||
// 退场动画开始的一刻同步淡出原生面板(与卡片 0.3s 渐隐节奏匹配)
|
||||
onHideStart={nativeBackdrop ? () => window.electronAPI?.notification?.glassHide?.() : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+12
-3
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2, Pause, Play, Square } from 'lucide-react'
|
||||
import { RefreshCw, Search, X, Download, FolderOpen, FileCode, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2, Pause, Play, Square } from 'lucide-react'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import './SnsPage.scss'
|
||||
import { SnsPost } from '../types/sns'
|
||||
@@ -86,7 +86,7 @@ interface SnsExportResult {
|
||||
interface SnsExportRequest {
|
||||
taskId: string
|
||||
outputDir: string
|
||||
format: 'json' | 'html' | 'arkmejson'
|
||||
format: 'json' | 'html' | 'arkmejson' | 'markdown'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportImages: boolean
|
||||
@@ -204,7 +204,7 @@ export default function SnsPage() {
|
||||
// 导出相关状态
|
||||
const [showExportDialog, setShowExportDialog] = useState(false)
|
||||
const [exportScope, setExportScope] = useState<SnsExportScope>({ kind: 'all' })
|
||||
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
|
||||
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson' | 'markdown'>('html')
|
||||
const [exportFolder, setExportFolder] = useState('')
|
||||
const [exportImages, setExportImages] = useState(false)
|
||||
const [exportLivePhotos, setExportLivePhotos] = useState(false)
|
||||
@@ -2406,6 +2406,15 @@ export default function SnsPage() {
|
||||
<span>HTML</span>
|
||||
<small>浏览器可直接查看</small>
|
||||
</button>
|
||||
<button
|
||||
className={`format-option ${exportFormat === 'markdown' ? 'active' : ''}`}
|
||||
onClick={() => setExportFormat('markdown')}
|
||||
disabled={isExportLocked}
|
||||
>
|
||||
<FileCode size={20} />
|
||||
<span>Markdown</span>
|
||||
<small>纯文本,通用性强</small>
|
||||
</button>
|
||||
<button
|
||||
className={`format-option ${exportFormat === 'json' ? 'active' : ''}`}
|
||||
onClick={() => setExportFormat('json')}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { LiquidGlassBackdropImage } from '../components/LiquidGlass'
|
||||
|
||||
/**
|
||||
* 通知卡片的"无级自适应反色":对比度驱动,替代旧的亮度阈值二值反色。
|
||||
*
|
||||
* 每次采样:
|
||||
* 1. 取整卡/标题行/正文三个区域的背景均值色与明暗分位数。采样来源二选一:
|
||||
* - Chromium 桌面视频流(跨平台回退管线):48×16 缩略图 drawImage 采样
|
||||
* - 原生玻璃面板(Windows):原生层重绘的同一帧从模糊纹理回读并推送
|
||||
* (notification:luma 事件,上限 ~60Hz,桌面静止时不推送)
|
||||
* 2. 按液态玻璃的实际加工链(saturate 140% → 纱层 alpha 合成)推算玻璃内的真实背景色,
|
||||
* 保证判定依据与用户看到的像素一致(裸桌面色与玻璃内呈现色可能差一个档位)
|
||||
* 3. 材质先补偿:纱层方向优先贴合桌面明暗(浅底白纱、深底黑纱,滞回防抖),
|
||||
* 仅当贴合方向即使最大补偿也无法达标而反方向可行时才反转;
|
||||
* 纱层浓度取"宽松锚点文字色恰好达标"的最小 alpha——中间调背景下玻璃自动变浊换取可读性
|
||||
* 4. 文字后升级:在"宽松锚点(设计稿层级灰阶)↔ 极端锚点(近黑/纯白)"之间连续插值,
|
||||
* 取达到目标对比度的最浅一档;背景充裕时保持原色,不足时无级加深/提亮
|
||||
* 5. 光晕兜底:按最不利分位数背景的对比度缺口连续调节 text-shadow 强度,
|
||||
* 覆盖同一区域内明暗混杂、均值失真的场景
|
||||
*
|
||||
* 结果写入 <html> 内联的 --noti-* 变量,由 NotificationToast.scss 消费
|
||||
*/
|
||||
|
||||
type RGB = readonly [number, number, number]
|
||||
|
||||
interface TextAnchor {
|
||||
/** 对比度充裕时使用的设计稿原色(保留视觉层级) */
|
||||
relaxed: RGB
|
||||
/** 对比度不足时插值逼近的极端色 */
|
||||
strong: RGB
|
||||
}
|
||||
|
||||
export interface BandSample {
|
||||
mean: RGB
|
||||
/** 区域内 luma 的 p15 / p85(gamma 域 0-255),用于评估明暗混杂时的最不利背景 */
|
||||
darkTail: number
|
||||
lightTail: number
|
||||
}
|
||||
|
||||
const SAMPLE_W = 48
|
||||
const SAMPLE_H = 16
|
||||
/** 相邻两次采样的最小间隔:采样由视频出帧驱动,此值防止背景高频变化时空转(~30Hz) */
|
||||
const MIN_SAMPLE_GAP_MS = 33
|
||||
/** 兜底轮询间隔:桌面静止(视频不出帧)但通知自身布局变化时仍能刷新 */
|
||||
const FALLBACK_INTERVAL_MS = 250
|
||||
/** 与 NotificationToast 传给 LiquidGlass 的 saturation=140 保持一致 */
|
||||
const GLASS_SATURATION = 1.4
|
||||
/** WCAG AA 小字号文本的目标对比度 */
|
||||
const TARGET_CONTRAST = 4.5
|
||||
/** 极性切换所需的对比度优势(滞回),避免临界背景来回翻转 */
|
||||
const POLARITY_MARGIN = 0.5
|
||||
/** 小幅波动的指数平滑系数(0~1,越大跟随越快) */
|
||||
const EMA_FACTOR = 0.6
|
||||
/** 均值 luma 突变超过此值视为场景切换:跳过平滑直接采用新值 */
|
||||
const SNAP_LUMA_DELTA = 24
|
||||
/** 采样区域向外扩张:文字边缘的背景同样影响可读性 */
|
||||
const RECT_BLEED = 4
|
||||
|
||||
const WHITE_VEIL: RGB = [255, 255, 255]
|
||||
const DARK_VEIL: RGB = [22, 20, 18]
|
||||
/** 纱层 alpha 求解范围:下限保持玻璃通透,上限防止退化成实心色块 */
|
||||
const WHITE_VEIL_ALPHA: readonly [number, number] = [0.1, 0.38]
|
||||
const DARK_VEIL_ALPHA: readonly [number, number] = [0.35, 0.6]
|
||||
|
||||
/** 文字锚点:dark = 深色文字(浅背景用),light = 浅色文字(深背景用) */
|
||||
const ANCHORS: Record<'title' | 'body' | 'tertiary', { dark: TextAnchor; light: TextAnchor }> = {
|
||||
title: {
|
||||
dark: { relaxed: [44, 44, 44], strong: [10, 10, 10] },
|
||||
light: { relaxed: [240, 238, 233], strong: [255, 255, 255] }
|
||||
},
|
||||
body: {
|
||||
dark: { relaxed: [77, 77, 77], strong: [17, 17, 17] },
|
||||
light: { relaxed: [217, 214, 208], strong: [255, 255, 255] }
|
||||
},
|
||||
tertiary: {
|
||||
dark: { relaxed: [122, 122, 122], strong: [61, 61, 61] },
|
||||
light: { relaxed: [176, 172, 166], strong: [216, 213, 207] }
|
||||
}
|
||||
}
|
||||
|
||||
const SHADOW_ON_LIGHT = '0 0 0 1px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.22)'
|
||||
const SHADOW_ON_DARK = '0 0 0 1px rgba(255, 255, 255, 0.06), 0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
|
||||
// —— 颜色数学(输入输出均为 sRGB 0-255)——
|
||||
|
||||
const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
|
||||
const clamp255 = (v: number) => Math.min(255, Math.max(0, v))
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
|
||||
const lerpRgb = (a: RGB, b: RGB, t: number): RGB => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]
|
||||
const gammaLuma = (c: RGB) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
||||
|
||||
const channelLinear = (v: number) => {
|
||||
const s = v / 255
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
/** WCAG 相对亮度(线性光域,区别于阈值判定用的 gamma luma) */
|
||||
const relativeLuminance = (c: RGB) =>
|
||||
0.2126 * channelLinear(c[0]) + 0.7152 * channelLinear(c[1]) + 0.0722 * channelLinear(c[2])
|
||||
/** WCAG 对比度(1~21) */
|
||||
const contrastRatio = (a: RGB, b: RGB) => {
|
||||
const la = relativeLuminance(a)
|
||||
const lb = relativeLuminance(b)
|
||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
|
||||
}
|
||||
/** 近似 CSS saturate():以 luma 为轴拉伸色度 */
|
||||
const saturateRgb = (c: RGB, s: number): RGB => {
|
||||
const l = gammaLuma(c)
|
||||
return [clamp255(l + (c[0] - l) * s), clamp255(l + (c[1] - l) * s), clamp255(l + (c[2] - l) * s)]
|
||||
}
|
||||
/** 纱层色以 alpha 覆盖在背景上的合成结果 */
|
||||
const compositeVeil = (veil: RGB, alpha: number, bg: RGB): RGB => [
|
||||
lerp(bg[0], veil[0], alpha),
|
||||
lerp(bg[1], veil[1], alpha),
|
||||
lerp(bg[2], veil[2], alpha)
|
||||
]
|
||||
/** 保持色相整体缩放亮度:从均值色推算最不利分位数处的背景色 */
|
||||
const scaleRgb = (c: RGB, factor: number): RGB => [clamp255(c[0] * factor), clamp255(c[1] * factor), clamp255(c[2] * factor)]
|
||||
const cssRgb = (c: RGB) => `rgb(${Math.round(c[0])}, ${Math.round(c[1])}, ${Math.round(c[2])})`
|
||||
const cssRgba = (c: RGB, alpha: number) =>
|
||||
`rgba(${Math.round(c[0])}, ${Math.round(c[1])}, ${Math.round(c[2])}, ${alpha.toFixed(2)})`
|
||||
|
||||
// —— 单调量二分求解 ——
|
||||
|
||||
/** 在宽松↔极端锚点间找达到目标对比度的最小插值系数 t(对比度随 t 单调上升) */
|
||||
function solveTextTone(anchor: TextAnchor, bg: RGB, target: number): { color: RGB; t: number } {
|
||||
if (contrastRatio(anchor.relaxed, bg) >= target) return { color: anchor.relaxed, t: 0 }
|
||||
if (contrastRatio(anchor.strong, bg) < target) return { color: anchor.strong, t: 1 }
|
||||
let lo = 0
|
||||
let hi = 1
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const mid = (lo + hi) / 2
|
||||
if (contrastRatio(lerpRgb(anchor.relaxed, anchor.strong, mid), bg) >= target) hi = mid
|
||||
else lo = mid
|
||||
}
|
||||
return { color: lerpRgb(anchor.relaxed, anchor.strong, hi), t: hi }
|
||||
}
|
||||
|
||||
/** 找使宽松锚点文字色达标的最小纱层 alpha(对比度随 alpha 单调上升);不可达时取上限,由文字升级接棒 */
|
||||
function solveVeilAlpha(
|
||||
veil: RGB,
|
||||
range: readonly [number, number],
|
||||
bg: RGB,
|
||||
relaxedText: RGB,
|
||||
target: number
|
||||
): number {
|
||||
const [min, max] = range
|
||||
if (contrastRatio(relaxedText, compositeVeil(veil, min, bg)) >= target) return min
|
||||
if (contrastRatio(relaxedText, compositeVeil(veil, max, bg)) < target) return max
|
||||
let lo = min
|
||||
let hi = max
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const mid = (lo + hi) / 2
|
||||
if (contrastRatio(relaxedText, compositeVeil(veil, mid, bg)) >= target) hi = mid
|
||||
else lo = mid
|
||||
}
|
||||
return hi
|
||||
}
|
||||
|
||||
interface BandTone {
|
||||
polarity: 'dark' | 'light'
|
||||
color: RGB
|
||||
/** 宽松→极端锚点的插值进度,供同区域次级文字(时间等)联动取色 */
|
||||
t: number
|
||||
/** 最不利分位数背景下的对比度缺口(0~1),驱动光晕强度 */
|
||||
deficit: number
|
||||
}
|
||||
|
||||
/** 沿 offsetParent 链累计布局坐标:不受出入场 transform 影响,采样区域始终对准卡片落点 */
|
||||
export function getLayoutRect(el: HTMLElement): { left: number; top: number; width: number; height: number } {
|
||||
let left = 0
|
||||
let top = 0
|
||||
let node: HTMLElement | null = el
|
||||
while (node) {
|
||||
left += node.offsetLeft
|
||||
top += node.offsetTop
|
||||
node = node.offsetParent as HTMLElement | null
|
||||
}
|
||||
return { left, top, width: el.offsetWidth, height: el.offsetHeight }
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应反色引擎:吃三个区域的原始背景统计,产出 --noti-* CSS 变量。
|
||||
* 平滑(EMA/突变跳变)、极性滞回等状态都在引擎内维护,与采样来源无关
|
||||
*/
|
||||
export function createAdaptiveThemeEngine() {
|
||||
const ema: Partial<Record<'card' | 'title' | 'body', BandSample>> = {}
|
||||
const textPolarity: Partial<Record<'title' | 'body', 'dark' | 'light'>> = {}
|
||||
let veilPolarity: 'white' | 'dark' | null = null
|
||||
const appliedVars: Record<string, string> = {}
|
||||
|
||||
const setVar = (name: string, value: string) => {
|
||||
if (appliedVars[name] === value) return
|
||||
appliedVars[name] = value
|
||||
document.documentElement.style.setProperty(name, value)
|
||||
}
|
||||
|
||||
/** 自适应平滑:场景切换(luma 突变)立即跳变保证响应速度,小幅波动才指数平滑抑噪 */
|
||||
const smooth = (key: 'card' | 'title' | 'body', raw: BandSample | null): BandSample | null => {
|
||||
if (!raw) return ema[key] ?? null
|
||||
const prev = ema[key]
|
||||
const next: BandSample = prev && Math.abs(gammaLuma(raw.mean) - gammaLuma(prev.mean)) <= SNAP_LUMA_DELTA
|
||||
? {
|
||||
mean: lerpRgb(prev.mean, raw.mean, EMA_FACTOR),
|
||||
darkTail: lerp(prev.darkTail, raw.darkTail, EMA_FACTOR),
|
||||
lightTail: lerp(prev.lightTail, raw.lightTail, EMA_FACTOR)
|
||||
}
|
||||
: raw
|
||||
ema[key] = next
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 材质方向 + 纱层浓度。方向优先贴合桌面明暗(亮底白纱、暗底黑纱),
|
||||
* 亮度滞回沿用旧算法验证过的 102/118 阈值;若贴合方向即使最大补偿也达不到
|
||||
* 目标对比度而反方向可以,才反转方向(不用"两族取优":黑纱补偿上限更高,
|
||||
* 会在双方都可达标的中间调上抢走本应保持浅色观感的场景)。
|
||||
* 浓度取"宽松锚点文字色恰好达标"的最小 alpha
|
||||
*/
|
||||
const resolveVeil = (cardMean: RGB): { color: RGB; alpha: number } => {
|
||||
const bg = saturateRgb(cardMean, GLASS_SATURATION)
|
||||
const luma = gammaLuma(bg)
|
||||
let next: 'white' | 'dark'
|
||||
if (veilPolarity === 'white') next = luma < 102 ? 'dark' : 'white'
|
||||
else if (veilPolarity === 'dark') next = luma > 118 ? 'white' : 'dark'
|
||||
else next = luma >= 110 ? 'white' : 'dark'
|
||||
|
||||
const bestWithWhite = contrastRatio(ANCHORS.body.dark.strong, compositeVeil(WHITE_VEIL, WHITE_VEIL_ALPHA[1], bg))
|
||||
const bestWithDark = contrastRatio(ANCHORS.body.light.strong, compositeVeil(DARK_VEIL, DARK_VEIL_ALPHA[1], bg))
|
||||
if (next === 'white' && bestWithWhite < TARGET_CONTRAST && bestWithDark > bestWithWhite + POLARITY_MARGIN) next = 'dark'
|
||||
if (next === 'dark' && bestWithDark < TARGET_CONTRAST && bestWithWhite > bestWithDark + POLARITY_MARGIN) next = 'white'
|
||||
veilPolarity = next
|
||||
return next === 'white'
|
||||
? { color: WHITE_VEIL, alpha: solveVeilAlpha(WHITE_VEIL, WHITE_VEIL_ALPHA, bg, ANCHORS.body.dark.relaxed, TARGET_CONTRAST) }
|
||||
: { color: DARK_VEIL, alpha: solveVeilAlpha(DARK_VEIL, DARK_VEIL_ALPHA, bg, ANCHORS.body.light.relaxed, TARGET_CONTRAST) }
|
||||
}
|
||||
|
||||
const resolveBand = (key: 'title' | 'body', sample: BandSample, veil: { color: RGB; alpha: number }): BandTone => {
|
||||
const glassBg = compositeVeil(veil.color, veil.alpha, saturateRgb(sample.mean, GLASS_SATURATION))
|
||||
const anchors = ANCHORS[key]
|
||||
const bestDark = contrastRatio(anchors.dark.strong, glassBg)
|
||||
const bestLight = contrastRatio(anchors.light.strong, glassBg)
|
||||
const current = textPolarity[key]
|
||||
let next: 'dark' | 'light' = current ?? (bestDark >= bestLight ? 'dark' : 'light')
|
||||
if (current === 'dark' && bestLight > bestDark + POLARITY_MARGIN) next = 'light'
|
||||
if (current === 'light' && bestDark > bestLight + POLARITY_MARGIN) next = 'dark'
|
||||
textPolarity[key] = next
|
||||
|
||||
const tone = solveTextTone(anchors[next], glassBg, TARGET_CONTRAST)
|
||||
|
||||
// 深色文字最怕暗斑、浅色文字最怕亮斑:按不利分位数亮度缩放均值色近似最不利背景
|
||||
const meanLuma = Math.max(1, gammaLuma(sample.mean))
|
||||
const adverseLuma = next === 'dark' ? sample.darkTail : sample.lightTail
|
||||
const adverseBg = compositeVeil(
|
||||
veil.color,
|
||||
veil.alpha,
|
||||
saturateRgb(scaleRgb(sample.mean, adverseLuma / meanLuma), GLASS_SATURATION)
|
||||
)
|
||||
const deficit = clamp01((TARGET_CONTRAST - contrastRatio(tone.color, adverseBg)) / TARGET_CONTRAST)
|
||||
return { polarity: next, color: tone.color, t: tone.t, deficit }
|
||||
}
|
||||
|
||||
/** 反向微光晕:深字配白晕、浅字配黑影,强度随对比度缺口无级增强 */
|
||||
const haloFor = (tone: BandTone) =>
|
||||
tone.polarity === 'dark'
|
||||
? `0 0 2px rgba(255, 255, 255, ${(0.35 + 0.55 * tone.deficit).toFixed(2)})`
|
||||
: `0 1px 3px rgba(0, 0, 0, ${(0.4 + 0.4 * tone.deficit).toFixed(2)})`
|
||||
|
||||
return {
|
||||
/** 输入三区域原始统计(title/body 缺省时退回整卡),刷新全部 --noti-* 变量 */
|
||||
apply(raw: { card: BandSample | null; title?: BandSample | null; body?: BandSample | null }) {
|
||||
const cardSample = smooth('card', raw.card)
|
||||
if (!cardSample) return
|
||||
|
||||
const veil = resolveVeil(cardSample.mean)
|
||||
setVar('--noti-tint', cssRgba(veil.color, veil.alpha))
|
||||
setVar('--noti-shadow', veil.color === WHITE_VEIL ? SHADOW_ON_LIGHT : SHADOW_ON_DARK)
|
||||
|
||||
const titleSample = smooth('title', raw.title ?? null) ?? cardSample
|
||||
const bodySample = smooth('body', raw.body ?? null) ?? cardSample
|
||||
|
||||
const title = resolveBand('title', titleSample, veil)
|
||||
setVar('--noti-title-color', cssRgb(title.color))
|
||||
setVar('--noti-title-halo', haloFor(title))
|
||||
const tertiaryAnchor = ANCHORS.tertiary[title.polarity]
|
||||
setVar('--noti-title-tertiary', cssRgb(lerpRgb(tertiaryAnchor.relaxed, tertiaryAnchor.strong, title.t)))
|
||||
setVar('--noti-close-hover-bg', title.polarity === 'dark' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.14)')
|
||||
|
||||
const body = resolveBand('body', bodySample, veil)
|
||||
setVar('--noti-body-color', cssRgb(body.color))
|
||||
setVar('--noti-body-halo', haloFor(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流采样路径(跨平台回退管线):从 Chromium 桌面视频流按布局区域采样。
|
||||
* 采样节奏:视频每出新帧即采样(33ms 节流,~30Hz)+ 250ms 兜底轮询
|
||||
*/
|
||||
export function useNotificationAdaptiveTheme(
|
||||
stream: MediaStream | null,
|
||||
backdrop: LiquidGlassBackdropImage | undefined
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!stream || !backdrop) return
|
||||
const video = document.createElement('video')
|
||||
video.muted = true
|
||||
video.srcObject = stream
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = SAMPLE_W
|
||||
canvas.height = SAMPLE_H
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
const engine = createAdaptiveThemeEngine()
|
||||
|
||||
/** 窗口内 CSS 矩形 → 视频帧像素区域的均值色与明暗分位数(流分辨率与屏幕逻辑尺寸按比例映射) */
|
||||
const readBand = (left: number, top: number, width: number, height: number): BandSample | null => {
|
||||
if (!ctx || width < 1 || height < 1) return null
|
||||
const ratioX = video.videoWidth / backdrop.width
|
||||
const ratioY = video.videoHeight / backdrop.height
|
||||
try {
|
||||
ctx.drawImage(
|
||||
video,
|
||||
(backdrop.screenX + left) * ratioX,
|
||||
(backdrop.screenY + top) * ratioY,
|
||||
Math.max(1, width * ratioX),
|
||||
Math.max(1, height * ratioY),
|
||||
0, 0, SAMPLE_W, SAMPLE_H
|
||||
)
|
||||
const { data } = ctx.getImageData(0, 0, SAMPLE_W, SAMPLE_H)
|
||||
let r = 0
|
||||
let g = 0
|
||||
let b = 0
|
||||
const lumas: number[] = []
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
r += data[i]
|
||||
g += data[i + 1]
|
||||
b += data[i + 2]
|
||||
lumas.push(0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2])
|
||||
}
|
||||
const count = lumas.length
|
||||
lumas.sort((x, y) => x - y)
|
||||
return {
|
||||
mean: [r / count, g / count, b / count],
|
||||
darkTail: lumas[Math.floor(count * 0.15)],
|
||||
lightTail: lumas[Math.floor(count * 0.85)]
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const readRect = (rect: { left: number; top: number; width: number; height: number }) =>
|
||||
readBand(rect.left - RECT_BLEED, rect.top - RECT_BLEED, rect.width + RECT_BLEED * 2, rect.height + RECT_BLEED * 2)
|
||||
|
||||
const sampleAll = () => {
|
||||
if (!video.videoWidth) return
|
||||
const host: ParentNode = document.getElementById('notification-current') ?? document
|
||||
const glassEl = host.querySelector<HTMLElement>('.liquid-glass')
|
||||
const headerEl = host.querySelector<HTMLElement>('.notification-header')
|
||||
const bodyEl = host.querySelector<HTMLElement>('.notification-body')
|
||||
engine.apply({
|
||||
card: readRect(
|
||||
glassEl ? getLayoutRect(glassEl) : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }
|
||||
),
|
||||
title: headerEl ? readRect(getLayoutRect(headerEl)) : null,
|
||||
body: bodyEl ? readRect(getLayoutRect(bodyEl)) : null
|
||||
})
|
||||
}
|
||||
|
||||
// 事件驱动采样:视频每出新帧立即响应(节流 33ms),桌面静止时零采样开销;
|
||||
// 兜底轮询覆盖桌面静止但通知自身布局变化(尺寸校准、新旧卡片过渡)的场景
|
||||
let disposed = false
|
||||
let lastSampleAt = 0
|
||||
let frameHandle = 0
|
||||
const onFrame = () => {
|
||||
if (disposed) return
|
||||
const now = performance.now()
|
||||
if (now - lastSampleAt >= MIN_SAMPLE_GAP_MS) {
|
||||
lastSampleAt = now
|
||||
sampleAll()
|
||||
}
|
||||
frameHandle = video.requestVideoFrameCallback(onFrame)
|
||||
}
|
||||
frameHandle = video.requestVideoFrameCallback(onFrame)
|
||||
const fallbackTimer = setInterval(sampleAll, FALLBACK_INTERVAL_MS)
|
||||
video.play().catch(() => { /* 采样失败保持当前材质 */ })
|
||||
return () => {
|
||||
disposed = true
|
||||
clearInterval(fallbackTimer)
|
||||
video.cancelVideoFrameCallback(frameHandle)
|
||||
video.srcObject = null
|
||||
}
|
||||
}, [stream, backdrop])
|
||||
}
|
||||
|
||||
/** 原生玻璃面板的亮度带 id 约定(与主进程 notification:glassRect 的 bands 对应) */
|
||||
export const NATIVE_BAND_IDS = { card: 0, title: 1, body: 2 } as const
|
||||
|
||||
/**
|
||||
* 原生采样路径(Windows 原生玻璃):面板在原生层重绘的同一帧从玻璃模糊纹理
|
||||
* 回读三区域统计,经主进程以 notification:luma 事件转发(上限 ~60Hz,
|
||||
* 桌面静止时不推送),反色判定与玻璃画面帧级同步
|
||||
*/
|
||||
export function useNotificationNativeAdaptiveTheme(enabled: boolean) {
|
||||
useEffect(() => {
|
||||
if (!enabled || !window.electronAPI?.notification?.onLuma) return
|
||||
const engine = createAdaptiveThemeEngine()
|
||||
const toSample = (s?: { r: number; g: number; b: number; darkTail: number; lightTail: number }): BandSample | null =>
|
||||
s ? { mean: [s.r, s.g, s.b], darkTail: s.darkTail, lightTail: s.lightTail } : null
|
||||
return window.electronAPI.notification.onLuma(bands => {
|
||||
engine.apply({
|
||||
card: toSample(bands[String(NATIVE_BAND_IDS.card)]),
|
||||
title: toSample(bands[String(NATIVE_BAND_IDS.title)]),
|
||||
body: toSample(bands[String(NATIVE_BAND_IDS.body)])
|
||||
})
|
||||
})
|
||||
}, [enabled])
|
||||
}
|
||||
Vendored
+17
-1
@@ -382,6 +382,22 @@ export interface ElectronAPI {
|
||||
click: (payload: string | { sessionId?: string; channel?: string; insightRecordId?: string; targetRoute?: string }) => void
|
||||
ready: () => void
|
||||
resize: (width: number, height: number) => void
|
||||
glassRect: (payload: {
|
||||
card: { x: number; y: number; width: number; height: number }
|
||||
bands: Array<{ id: number; x: number; y: number; width: number; height: number }>
|
||||
dpr: number
|
||||
cornerRadius: number
|
||||
blurSigma: number
|
||||
displacementScale: number
|
||||
aberrationIntensity: number
|
||||
saturation: number
|
||||
}) => void
|
||||
glassHide: () => void
|
||||
onLuma: (
|
||||
callback: (
|
||||
bands: Record<string, { r: number; g: number; b: number; darkTail: number; lightTail: number }>
|
||||
) => void
|
||||
) => () => void
|
||||
onShow: (callback: (event: any, data: any) => void) => () => void
|
||||
onNavigateToSession: (callback: (sessionId: string) => void) => () => void
|
||||
onNavigateToRoute: (callback: (route: string) => void) => () => void
|
||||
@@ -1568,7 +1584,7 @@ export interface ElectronAPI {
|
||||
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
|
||||
exportTimeline: (options: {
|
||||
outputDir: string
|
||||
format: 'json' | 'html' | 'arkmejson'
|
||||
format: 'json' | 'html' | 'arkmejson' | 'markdown'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportImages?: boolean
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
|
||||
/**
|
||||
* 把文本中的微信表情代码(如 [微笑])渲染为内联表情图片,
|
||||
* 未匹配的方括号内容原样保留。size 为表情边长(px)
|
||||
*/
|
||||
export function renderTextWithEmoji(text: string, size = 22): ReactNode {
|
||||
if (!text) return text
|
||||
const parts = text.split(/\[(.*?)\]/g)
|
||||
return parts.map((part, index) => {
|
||||
// 奇数索引是方括号捕获组的内容
|
||||
if (index % 2 === 1) {
|
||||
const path = getEmojiPath(part as Parameters<typeof getEmojiPath>[0])
|
||||
if (path) {
|
||||
// path 例如 'assets/face/微笑.png',需要添加 base 前缀
|
||||
return (
|
||||
<img
|
||||
key={index}
|
||||
src={`${import.meta.env.BASE_URL}${path}`}
|
||||
alt={`[${part}]`}
|
||||
className="inline-emoji"
|
||||
style={{ width: size, height: size, verticalAlign: 'bottom', margin: '0 1px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return `[${part}]`
|
||||
}
|
||||
return part
|
||||
})
|
||||
}
|
||||
+3
-1
@@ -112,7 +112,9 @@ export default defineConfig({
|
||||
'exceljs',
|
||||
'node-llama-cpp',
|
||||
'@vscode/sudo-prompt',
|
||||
'silk-wasm'
|
||||
'silk-wasm',
|
||||
// 原生 .node 二进制不可打包,运行时从 asarUnpack 目录解析
|
||||
'@hicccc77/electron-liquid-glass'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user