综合更新

This commit is contained in:
cc committed 2026-07-06 18:09:09 +08:00
1 parent d733c21145
commit 147f3b7045
57 files changed
+3038 -800

No files matched your search

+55 -39
View File
@@ -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>
+11 -6
View File
@@ -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 {
+20 -12
View File
@@ -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>
)
}
+21 -8
View File
@@ -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;
+10
View File
@@ -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>
+3 -4
View File
@@ -52,10 +52,9 @@ export function GlobalSessionMonitor() {
return () => { }
}, [])
// 注意:导出统计的预加载已移除。
// 之前在这里预加载所有会话的统计到前端 zustand store,会导致启动时内存从 200MB 飙升到 500+MB。
// 后端 main.ts 启动时已经通过 getExportSessionStats 将统计结果缓存在主进程内存中,
// 导出页打开时会按需拉取当前可见批次的会话统计,后端缓存命中率很高,响应速度足够快。
// 注意:导出统计的预加载已移除(前端全量预载曾导致内存从 200MB 飙升到 500+MB,
// 主进程启动预热也因拖慢启动速度被移除)。
// 导出页打开时按需拉取可见批次的会话统计,主进程有磁盘缓存兜底,响应速度足够快。
const refreshSessions = async () => {
+21 -13
View File
@@ -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;
+10
View File
@@ -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)
}
}
}
+477
View File
@@ -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);
}
+10 -7
View File
@@ -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;
+11 -1
View File
@@ -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>
)
+36 -167
View File
@@ -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;
}
}
}
+68 -42
View File
@@ -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)
}
+1 -18
View File
@@ -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
+11 -8
View File
@@ -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;
+10 -2
View File
@@ -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>
)
}
+5 -25
View File
@@ -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;
+17 -9
View File
@@ -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>
)
}
+9 -5
View File
@@ -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 {
+15 -7
View File
@@ -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
View File
@@ -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]
+1 -1
View File
@@ -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'
+9 -31
View File
@@ -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;
}
}
}
+232 -87
View File
@@ -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
View File
@@ -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')}
+419
View File
@@ -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])
}
+17 -1
View File
@@ -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
+31
View File
@@ -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
})
}