综合更新
This commit is contained in:
1 parent
d733c21145
commit
147f3b7045
57 files changed
+3038
-800
No files matched your search
+55
-39
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from 'react-router-dom'
|
||||
import TitleBar from './components/TitleBar'
|
||||
import Sidebar from './components/Sidebar'
|
||||
@@ -6,17 +6,10 @@ import RouteGuard from './components/RouteGuard'
|
||||
import WelcomePage from './pages/WelcomePage'
|
||||
import HomePage from './pages/HomePage'
|
||||
import ChatPage from './pages/ChatPage'
|
||||
import AnalyticsPage from './pages/AnalyticsPage'
|
||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
|
||||
import AnnualReportPage from './pages/AnnualReportPage'
|
||||
import AnnualReportWindow from './pages/AnnualReportWindow'
|
||||
import DualReportPage from './pages/DualReportPage'
|
||||
import DualReportWindow from './pages/DualReportWindow'
|
||||
import AgreementPage from './pages/AgreementPage'
|
||||
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import ExportPage from './pages/Export/ExportPage'
|
||||
import MyFootprintPage from './pages/MyFootprintPage'
|
||||
import VideoWindow from './pages/VideoWindow'
|
||||
import ImageWindow from './pages/ImageWindow'
|
||||
@@ -43,6 +36,16 @@ import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import WindowCloseDialog from './components/WindowCloseDialog'
|
||||
|
||||
// 重型页面懒加载:echarts 分析页、年度/双人报告(含大字体样式)、导出页模块树,
|
||||
// 从首屏 bundle 中拆出,显著缩短主窗口首次渲染时间
|
||||
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'))
|
||||
const GroupAnalyticsPage = lazy(() => import('./pages/GroupAnalyticsPage'))
|
||||
const AnnualReportPage = lazy(() => import('./pages/AnnualReportPage'))
|
||||
const AnnualReportWindow = lazy(() => import('./pages/AnnualReportWindow'))
|
||||
const DualReportPage = lazy(() => import('./pages/DualReportPage'))
|
||||
const DualReportWindow = lazy(() => import('./pages/DualReportWindow'))
|
||||
const ExportPage = lazy(() => import('./pages/Export/ExportPage'))
|
||||
|
||||
function RouteStateRedirect({ to }: { to: string }) {
|
||||
const location = useLocation()
|
||||
|
||||
@@ -532,12 +535,20 @@ function App() {
|
||||
|
||||
// 独立年度报告全屏窗口
|
||||
if (isAnnualReportWindow) {
|
||||
return <AnnualReportWindow />
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AnnualReportWindow />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立双人报告全屏窗口
|
||||
if (isDualReportWindow) {
|
||||
return <DualReportWindow />
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DualReportWindow />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
@@ -696,38 +707,43 @@ function App() {
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<main className="content">
|
||||
<RouteGuard>
|
||||
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
|
||||
<ExportPage />
|
||||
</div>
|
||||
{/* 独立 Suspense 边界:Export chunk 异步加载时不阻塞首屏路由渲染 */}
|
||||
<Suspense fallback={null}>
|
||||
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
|
||||
<ExportPage />
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
<Routes location={routeLocation}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/account-management" element={<AccountManagementPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Suspense fallback={null}>
|
||||
<Routes location={routeLocation}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/account-management" element={<AccountManagementPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
|
||||
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
|
||||
<Route path="/analytics/private/view" element={<AnalyticsPage />} />
|
||||
<Route path="/analytics/group" element={<GroupAnalyticsPage />} />
|
||||
<Route path="/analytics/view" element={<RouteStateRedirect to="/analytics/private/view" />} />
|
||||
<Route path="/group-analytics" element={<RouteStateRedirect to="/analytics/group" />} />
|
||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||
<Route path="/dual-report" element={<DualReportPage />} />
|
||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||
<Route path="/footprint" element={<MyFootprintPage />} />
|
||||
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
|
||||
<Route path="/analytics/private/view" element={<AnalyticsPage />} />
|
||||
<Route path="/analytics/group" element={<GroupAnalyticsPage />} />
|
||||
<Route path="/analytics/view" element={<RouteStateRedirect to="/analytics/private/view" />} />
|
||||
<Route path="/group-analytics" element={<RouteStateRedirect to="/analytics/group" />} />
|
||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||
<Route path="/dual-report" element={<DualReportPage />} />
|
||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||
<Route path="/footprint" element={<MyFootprintPage />} />
|
||||
|
||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/insight-inbox" element={<InsightInboxPage />} />
|
||||
<Route path="/biz" element={<BizPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/backup" element={<BackupPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/insight-inbox" element={<InsightInboxPage />} />
|
||||
<Route path="/biz" element={<BizPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/backup" element={<BackupPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</RouteGuard>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -12,13 +12,18 @@
|
||||
z-index: 100;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
|
||||
.confirm-dialog {
|
||||
width: 480px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
// 液态玻璃外壳:宽度与入场动画在玻璃根节点上
|
||||
.confirm-dialog-glass {
|
||||
width: min(480px, calc(100% - 48px));
|
||||
animation: slideUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
width: 100%;
|
||||
// 半透明背景透出玻璃折射,阴影由 LiquidGlass 提供
|
||||
background: color-mix(in srgb, var(--bg-primary) 80%, transparent);
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.close-btn {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { X } from 'lucide-react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './ConfirmDialog.scss'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
@@ -14,19 +15,26 @@ export default function ConfirmDialog({ open, title, message, onConfirm, onCance
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||
<div className="confirm-dialog" onClick={e => e.stopPropagation()}>
|
||||
<button className="close-btn" onClick={onCancel}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{title && <div className="dialog-title">{title}</div>}
|
||||
<div className="dialog-content">
|
||||
<p style={{ whiteSpace: 'pre-line' }}>{message}</p>
|
||||
<LiquidGlass
|
||||
className="confirm-dialog-glass"
|
||||
cornerRadius={20}
|
||||
displacementScale={36}
|
||||
aberrationIntensity={1.5}
|
||||
>
|
||||
<div className="confirm-dialog" onClick={e => e.stopPropagation()}>
|
||||
<button className="close-btn" onClick={onCancel}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{title && <div className="dialog-title">{title}</div>}
|
||||
<div className="dialog-content">
|
||||
<p style={{ whiteSpace: 'pre-line' }}>{message}</p>
|
||||
</div>
|
||||
<div className="dialog-actions">
|
||||
<button className="btn-cancel" onClick={onCancel}>取消</button>
|
||||
<button className="btn-confirm" onClick={onConfirm}>开始获取</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dialog-actions">
|
||||
<button className="btn-cancel" onClick={onCancel}>取消</button>
|
||||
<button className="btn-confirm" onClick={onConfirm}>开始获取</button>
|
||||
</div>
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -54,18 +54,31 @@
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: var(--bg-secondary-solid, var(--bg-primary, var(--card-bg)));
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border: 1px solid var(--border-color);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
animation: dropdownFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
// 内容层底色:中心保留半透明底保证日历可读,四周 26px 渐隐露出玻璃的边缘折射带
|
||||
.picker-dropdown-body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background-color: color-mix(in srgb, var(--bg-secondary-solid, var(--bg-primary, var(--card-bg))) 68%, transparent);
|
||||
mask:
|
||||
linear-gradient(to right, transparent, #000 26px, #000 calc(100% - 26px), transparent),
|
||||
linear-gradient(to bottom, transparent, #000 26px, #000 calc(100% - 26px), transparent);
|
||||
mask-composite: intersect;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdownFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Calendar, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './DateRangePicker.scss'
|
||||
|
||||
interface DateRangePickerProps {
|
||||
@@ -198,6 +199,13 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
|
||||
{isOpen && (
|
||||
<div className="picker-dropdown">
|
||||
<LiquidGlass
|
||||
cornerRadius={16}
|
||||
displacementScale={48}
|
||||
aberrationIntensity={1.5}
|
||||
blurAmount={0.25}
|
||||
>
|
||||
<div className="picker-dropdown-body">
|
||||
<div className="quick-options">
|
||||
{QUICK_OPTIONS.map(opt => (
|
||||
<button key={opt.label} className="quick-option" onClick={() => handleQuickOption(opt.days)}>
|
||||
@@ -246,6 +254,8 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -52,10 +52,9 @@ export function GlobalSessionMonitor() {
|
||||
return () => { }
|
||||
}, [])
|
||||
|
||||
// 注意:导出统计的预加载已移除。
|
||||
// 之前在这里预加载所有会话的统计到前端 zustand store,会导致启动时内存从 200MB 飙升到 500+MB。
|
||||
// 后端 main.ts 启动时已经通过 getExportSessionStats 将统计结果缓存在主进程内存中,
|
||||
// 导出页打开时会按需拉取当前可见批次的会话统计,后端缓存命中率很高,响应速度足够快。
|
||||
// 注意:导出统计的预加载已移除(前端全量预载曾导致内存从 200MB 飙升到 500+MB,
|
||||
// 主进程启动预热也因拖慢启动速度被移除)。
|
||||
// 导出页打开时按需拉取可见批次的会话统计,主进程有磁盘缓存兜底,响应速度足够快。
|
||||
|
||||
|
||||
const refreshSessions = async () => {
|
||||
|
||||
@@ -3,20 +3,29 @@
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
width: 312px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: none;
|
||||
background-color: var(--bg-secondary-solid, #ffffff) !important;
|
||||
opacity: 1;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
mix-blend-mode: normal;
|
||||
isolation: isolate;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||
padding: 12px;
|
||||
z-index: 1600;
|
||||
}
|
||||
|
||||
// 内容层底色:中心保留半透明底保证日历可读,四周 26px 渐隐露出玻璃的边缘折射带
|
||||
.jump-date-popover-body {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background-color: color-mix(in srgb, var(--bg-secondary-solid, #ffffff) 68%, transparent);
|
||||
mask:
|
||||
linear-gradient(to right, transparent, #000 26px, #000 calc(100% - 26px), transparent),
|
||||
linear-gradient(to bottom, transparent, #000 26px, #000 calc(100% - 26px), transparent);
|
||||
mask-composite: intersect;
|
||||
}
|
||||
}
|
||||
|
||||
.jump-date-popover .calendar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -49,8 +58,7 @@
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
background-color: var(--bg-secondary-solid, #ffffff) !important;
|
||||
background-color: color-mix(in srgb, var(--bg-secondary-solid, #ffffff) 80%, transparent);
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './JumpToDatePopover.scss'
|
||||
|
||||
interface JumpToDatePopoverProps {
|
||||
@@ -185,6 +186,13 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
|
||||
return (
|
||||
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
||||
<LiquidGlass
|
||||
cornerRadius={14}
|
||||
displacementScale={48}
|
||||
aberrationIntensity={1.5}
|
||||
blurAmount={0.25}
|
||||
>
|
||||
<div className="jump-date-popover-body">
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
className="nav-btn"
|
||||
@@ -293,6 +301,8 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { LensDisplacementMap } from './lensDisplacementMap'
|
||||
|
||||
/**
|
||||
* SVG 滤镜:透镜位移折射 + 边缘色散
|
||||
*
|
||||
* 透镜贴图中心是中性灰(零位移),中心天然保持清晰;RGB 三通道位移量递减,
|
||||
* 在折射最强的边缘自然分离出色散。
|
||||
* 几何约定(Chromium 实测):feImage 的像素几何 (0,0,w,h) 与元素原点精确对齐,
|
||||
* 负坐标/百分比几何解析不可靠;滤镜区域收紧为元素本身,位移不会画出卡片之外。
|
||||
* scale 用 2×maxScale 还原贴图编码的几何位移,displacementScale 以 70 为基准整体缩放
|
||||
*/
|
||||
export default function GlassFilter({ id, map, displacementScale, aberrationIntensity }: {
|
||||
id: string
|
||||
map: LensDisplacementMap
|
||||
displacementScale: number
|
||||
aberrationIntensity: number
|
||||
}) {
|
||||
const scale = 2 * map.maxScale * (displacementScale / 70)
|
||||
return (
|
||||
<svg style={{ position: 'absolute', width: 0, height: 0, pointerEvents: 'none' }} aria-hidden="true">
|
||||
<defs>
|
||||
<filter id={id} x="0%" y="0%" width="100%" height="100%" colorInterpolationFilters="sRGB">
|
||||
<feImage x="0" y="0" width={map.width} height={map.height} result="DISPLACEMENT_MAP" href={map.url} preserveAspectRatio="none" />
|
||||
|
||||
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={scale} xChannelSelector="R" yChannelSelector="B" result="RED_DISPLACED" />
|
||||
<feColorMatrix
|
||||
in="RED_DISPLACED"
|
||||
type="matrix"
|
||||
values="1 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 1 0"
|
||||
result="RED_CHANNEL"
|
||||
/>
|
||||
|
||||
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={scale * (1 - aberrationIntensity * 0.05)} xChannelSelector="R" yChannelSelector="B" result="GREEN_DISPLACED" />
|
||||
<feColorMatrix
|
||||
in="GREEN_DISPLACED"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0
|
||||
0 1 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 1 0"
|
||||
result="GREEN_CHANNEL"
|
||||
/>
|
||||
|
||||
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={scale * (1 - aberrationIntensity * 0.1)} xChannelSelector="R" yChannelSelector="B" result="BLUE_DISPLACED" />
|
||||
<feColorMatrix
|
||||
in="BLUE_DISPLACED"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 1 0 0
|
||||
0 0 0 1 0"
|
||||
result="BLUE_CHANNEL"
|
||||
/>
|
||||
|
||||
{/* screen 混合合并三通道:位移一致的区域无损还原原色 */}
|
||||
<feBlend in="GREEN_CHANNEL" in2="BLUE_CHANNEL" mode="screen" result="GB_COMBINED" />
|
||||
<feBlend in="RED_CHANNEL" in2="GB_COMBINED" mode="screen" result="RGB_COMBINED" />
|
||||
|
||||
<feGaussianBlur in="RGB_COMBINED" stdDeviation={Math.max(0.1, 0.5 - aberrationIntensity * 0.1)} />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// 玻璃背景全局运动估计器:块匹配连续两帧的低分辨率亮度网格,
|
||||
// 得到内容平移速度,用于预测采样(补偿采集链路 ~60ms 延迟,见 glassStreamRenderer)。
|
||||
//
|
||||
// 设计要点:
|
||||
// - 置信门控:只有当位移显著优于零位移(SAD 对比)且方向与历史一致时才更新速度,
|
||||
// 旋转/局部运动/画面突变等不可预测场景自动衰减到零补偿(退回普通滞后表现)
|
||||
// - 亚格点精度:SAD 极值点抛物线插值,配合 EMA 平滑,避免逐帧抖动
|
||||
// - 失败模式安全:估计错误的最坏结果是折射内容短暂偏移后回正,且经过模糊层视觉柔和
|
||||
|
||||
export const MOTION_GRID_W = 56
|
||||
export const MOTION_GRID_H = 24
|
||||
|
||||
const SEARCH_X = 10 // 搜索半径(格):±10 格 ≈ ±76px/帧 ≈ 4500px/s
|
||||
const SEARCH_Y = 6
|
||||
const MAX_VELOCITY = 6 // px/ms 上限(6000px/s,超出视为误匹配)
|
||||
const MAX_OFFSET = 72 // 预测偏移上限(px),防极端外推
|
||||
|
||||
export interface GlassMotionEstimator {
|
||||
/** 输入当前帧的 RGBA 网格与时间戳,返回预测采样偏移目标(px,运动方向) */
|
||||
update: (rgba: Uint8Array, timestampMs: number) => { x: number; y: number }
|
||||
/** 无新帧时的衰减步(内容停止变化后把偏移收回零) */
|
||||
decay: () => { x: number; y: number }
|
||||
}
|
||||
|
||||
/** SAD 极值点抛物线亚格点插值 */
|
||||
function subCell(sadPrev: number, sadBest: number, sadNext: number): number {
|
||||
const denom = sadPrev - 2 * sadBest + sadNext
|
||||
if (denom <= 0) return 0
|
||||
return Math.max(-0.5, Math.min(0.5, (sadPrev - sadNext) / (2 * denom)))
|
||||
}
|
||||
|
||||
export function createGlassMotionEstimator(
|
||||
cellPxX: number,
|
||||
cellPxY: number,
|
||||
predictMs: number
|
||||
): GlassMotionEstimator {
|
||||
const size = MOTION_GRID_W * MOTION_GRID_H
|
||||
let prev: Uint8Array | null = null
|
||||
let curr: Uint8Array = new Uint8Array(size)
|
||||
let prevTs = 0
|
||||
let velX = 0
|
||||
let velY = 0
|
||||
|
||||
const target = () => {
|
||||
// 速度低于 60px/s 视为静止(噪声区),不做补偿
|
||||
const speed = Math.hypot(velX, velY)
|
||||
if (speed < 0.06) return { x: 0, y: 0 }
|
||||
return {
|
||||
x: Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, velX * predictMs)),
|
||||
y: Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, velY * predictMs))
|
||||
}
|
||||
}
|
||||
|
||||
/** 全搜索块匹配:返回最优位移(格)与置信信息 */
|
||||
const search = (prevGrid: Uint8Array) => {
|
||||
let bestDx = 0
|
||||
let bestDy = 0
|
||||
let bestSad = Infinity
|
||||
let zeroSad = 0
|
||||
// 缓存 x 方向 SAD 曲线(bestDy 行),用于亚格点插值
|
||||
const sadRow = new Float32Array(SEARCH_X * 2 + 1)
|
||||
for (let dy = -SEARCH_Y; dy <= SEARCH_Y; dy++) {
|
||||
const y0 = Math.max(0, -dy)
|
||||
const y1 = MOTION_GRID_H - Math.max(0, dy)
|
||||
for (let dx = -SEARCH_X; dx <= SEARCH_X; dx++) {
|
||||
const x0 = Math.max(0, -dx)
|
||||
const x1 = MOTION_GRID_W - Math.max(0, dx)
|
||||
let sad = 0
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const rowC = y * MOTION_GRID_W
|
||||
const rowP = (y + dy) * MOTION_GRID_W + dx
|
||||
for (let x = x0; x < x1; x++) {
|
||||
sad += Math.abs(curr[rowC + x] - prevGrid[rowP + x])
|
||||
}
|
||||
}
|
||||
sad /= (x1 - x0) * (y1 - y0)
|
||||
if (dx === 0 && dy === 0) zeroSad = sad
|
||||
if (sad < bestSad) {
|
||||
bestSad = sad
|
||||
bestDx = dx
|
||||
bestDy = dy
|
||||
}
|
||||
}
|
||||
}
|
||||
// 最优行的 x 邻域 SAD(亚格点用),只在需要时补算
|
||||
const y0 = Math.max(0, -bestDy)
|
||||
const y1 = MOTION_GRID_H - Math.max(0, bestDy)
|
||||
for (let i = 0; i < sadRow.length; i++) {
|
||||
const dx = i - SEARCH_X
|
||||
const x0 = Math.max(0, -dx)
|
||||
const x1 = MOTION_GRID_W - Math.max(0, dx)
|
||||
let sad = 0
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const rowC = y * MOTION_GRID_W
|
||||
const rowP = (y + bestDy) * MOTION_GRID_W + dx
|
||||
for (let x = x0; x < x1; x++) {
|
||||
sad += Math.abs(curr[rowC + x] - prevGrid[rowP + x])
|
||||
}
|
||||
}
|
||||
sadRow[i] = sad / ((x1 - x0) * (y1 - y0))
|
||||
}
|
||||
return { bestDx, bestDy, bestSad, zeroSad, sadRow }
|
||||
}
|
||||
|
||||
return {
|
||||
update(rgba, timestampMs) {
|
||||
// RGBA → 亮度网格,同时统计对比度
|
||||
let min = 255
|
||||
let max = 0
|
||||
const next = prev ?? new Uint8Array(size)
|
||||
for (let i = 0; i < size; i++) {
|
||||
const p = i * 4
|
||||
const luma = (rgba[p] * 54 + rgba[p + 1] * 183 + rgba[p + 2] * 19) >> 8
|
||||
next[i] = luma
|
||||
if (luma < min) min = luma
|
||||
if (luma > max) max = luma
|
||||
}
|
||||
prev = curr
|
||||
curr = next
|
||||
|
||||
const dt = timestampMs - prevTs
|
||||
prevTs = timestampMs
|
||||
// 帧间隔异常(首帧/长暂停/时钟跳变)或画面无结构:不更新速度
|
||||
if (dt < 4 || dt > 90 || max - min < 10) {
|
||||
velX *= 0.55
|
||||
velY *= 0.55
|
||||
return target()
|
||||
}
|
||||
|
||||
const { bestDx, bestDy, bestSad, zeroSad, sadRow } = search(prev)
|
||||
|
||||
if (bestDx === 0 && bestDy === 0) {
|
||||
// 零位移最优 → 置信静止
|
||||
velX *= 0.35
|
||||
velY *= 0.35
|
||||
return target()
|
||||
}
|
||||
|
||||
// 置信条件:位移匹配显著优于不动(否则可能是局部运动/画面突变)
|
||||
if (bestSad >= zeroSad * 0.8) {
|
||||
velX *= 0.55
|
||||
velY *= 0.55
|
||||
return target()
|
||||
}
|
||||
|
||||
const i = bestDx + SEARCH_X
|
||||
const fracX = i > 0 && i < sadRow.length - 1 ? subCell(sadRow[i - 1], sadRow[i], sadRow[i + 1]) : 0
|
||||
// 匹配含义:curr[x] ≈ prev[x + dx],内容位移 = -dx(向右运动时 dx 为负)
|
||||
const instVx = (-(bestDx + fracX) * cellPxX) / dt
|
||||
const instVy = (-bestDy * cellPxY) / dt
|
||||
if (Math.hypot(instVx, instVy) > MAX_VELOCITY) {
|
||||
velX *= 0.55
|
||||
velY *= 0.55
|
||||
return target()
|
||||
}
|
||||
|
||||
if (instVx * velX + instVy * velY < 0) {
|
||||
// 方向反转(如拖拽折返):快速重锚定,幅度打折避免过冲
|
||||
velX = instVx * 0.3
|
||||
velY = instVy * 0.3
|
||||
} else {
|
||||
velX = velX * 0.65 + instVx * 0.35
|
||||
velY = velY * 0.65 + instVy * 0.35
|
||||
}
|
||||
return target()
|
||||
},
|
||||
|
||||
decay() {
|
||||
velX *= 0.5
|
||||
velY *= 0.5
|
||||
return target()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
// 视频流模式的 WebGL 玻璃渲染器:替代「<video> 元素 + CSS blur/saturate + SVG 位移滤镜」管线。
|
||||
//
|
||||
// 性能与延迟设计:
|
||||
// - MediaStreamTrackProcessor 直读采集帧(无 <video> 播出缓冲,省 1~2 帧延迟),
|
||||
// 帧到达立即渲染,latest-wins 丢弃积压帧;无帧到达时零渲染(桌面静止零开销)
|
||||
// - 圆角矩形 SDF 及其梯度在片元着色器内解析式求值,替代 CPU 逐像素生成位移贴图
|
||||
// (lensDisplacementMap)+ feImage + 3×feDisplacementMap + 2×feBlend 的滤镜图
|
||||
// - 模糊降到半分辨率两趟可分离高斯(工作面积仅玻璃+边距),
|
||||
// 位移、色散、饱和度合并进最终一趟片元着色,整帧只有 3 个小渲染目标
|
||||
// - 透镜几何与 lensDisplacementMap/GlassFilter 保持同一套公式(bezel/单峰剖面/色散递减),
|
||||
// 视觉与应用内 SVG 路径一致
|
||||
// - 运动补偿预测采样(timewarp 思路):屏幕采集链路存在 ~60ms 物理延迟
|
||||
// (WGC 帧池 → 跨进程 → 合成上屏,应用层无法消除),对"拖拽/滚动"这类
|
||||
// 均匀平移场景,用块匹配估计背景运动矢量,把采样窗口向运动方向外推一个
|
||||
// 延迟量,折射内容与真实位置的感知滞后可从 ~70ms 压到接近零;
|
||||
// 不可预测运动(旋转/局部变化)由置信门控自动退回普通滞后表现
|
||||
import { createGlassMotionEstimator, MOTION_GRID_W, MOTION_GRID_H } from './glassMotionEstimator'
|
||||
|
||||
const BLUR_MARGIN = 40
|
||||
/** 预测外推时长(ms):按实测采集→上屏延迟标定,故意欠补偿留稳定余量 */
|
||||
const MOTION_PREDICT_MS = 60
|
||||
/** 预测偏移的逐帧平滑系数 */
|
||||
const OFFSET_SMOOTH = 0.45
|
||||
/** 无新帧超过该时长即进入偏移回收(内容停止运动后把预测偏移退回零) */
|
||||
const IDLE_DECAY_DELAY = 90
|
||||
|
||||
export interface GlassStreamRendererOptions {
|
||||
canvas: HTMLCanvasElement
|
||||
/** 渲染器独占的视频轨(调用方负责 clone),dispose 时停止 */
|
||||
track: MediaStreamTrack
|
||||
/** 玻璃 CSS 尺寸与圆角 */
|
||||
width: number
|
||||
height: number
|
||||
cornerRadius: number
|
||||
/** 玻璃左上角的屏幕逻辑坐标(窗口坐标 + 页内布局偏移) */
|
||||
screenX: number
|
||||
screenY: number
|
||||
/** 屏幕逻辑尺寸(视频帧与屏幕的映射基准,与帧实际分辨率无关) */
|
||||
screenW: number
|
||||
screenH: number
|
||||
displacementScale: number
|
||||
aberrationIntensity: number
|
||||
saturation: number
|
||||
/** 高斯模糊标准差(CSS px) */
|
||||
blurSigma: number
|
||||
/** 首帧渲染完成(用于淡入时机) */
|
||||
onFirstFrame?: () => void
|
||||
}
|
||||
|
||||
export interface GlassStreamRenderer {
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
const VERT = `#version 300 es
|
||||
void main() {
|
||||
vec2 pos = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
|
||||
gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
|
||||
}`
|
||||
|
||||
/** 下采样 + 单方向高斯(半分辨率,两趟可分离) */
|
||||
const BLUR_FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
uniform sampler2D uTex;
|
||||
uniform vec2 uSrcOrigin; // 采样区域原点(源纹理 UV)
|
||||
uniform vec2 uSrcSpan; // 采样区域跨度(源纹理 UV)
|
||||
uniform vec2 uOutSize; // 本目标像素尺寸
|
||||
uniform vec2 uDir; // 模糊方向(源纹理 UV 每标准差步长)
|
||||
out vec4 outColor;
|
||||
void main() {
|
||||
vec2 t = gl_FragCoord.xy / uOutSize;
|
||||
vec2 uv = uSrcOrigin + t * uSrcSpan;
|
||||
outColor = texture(uTex, uv) * 0.4026
|
||||
+ (texture(uTex, uv + uDir) + texture(uTex, uv - uDir)) * 0.2442
|
||||
+ (texture(uTex, uv + uDir * 2.0) + texture(uTex, uv - uDir * 2.0)) * 0.0545;
|
||||
}`
|
||||
|
||||
/** 透镜主着色:解析式 SDF 位移 + RGB 色散 + 饱和度 */
|
||||
const LENS_FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
uniform sampler2D uBlurTex;
|
||||
uniform vec2 uGlassSize; // CSS px
|
||||
uniform float uDpr;
|
||||
uniform float uRadius;
|
||||
uniform float uBezel;
|
||||
uniform float uMaxBend;
|
||||
uniform float uDispFactor; // displacementScale / 70
|
||||
uniform float uAberration;
|
||||
uniform float uSaturation;
|
||||
uniform float uMargin; // 模糊纹理相对玻璃的外扩边距(CSS px)
|
||||
out vec4 outColor;
|
||||
|
||||
// 圆角矩形 SDF(p 以玻璃中心为原点)
|
||||
float sdRoundRect(vec2 p, vec2 halfSize, float r) {
|
||||
vec2 q = abs(p) - halfSize + r;
|
||||
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
|
||||
}
|
||||
|
||||
// 平滑 max(q,0):法线方向在角区连续旋转,消除对角线上的方向折痕
|
||||
vec2 softClamp(vec2 q, float soft) {
|
||||
return 0.5 * (q + sqrt(q * q + vec2(soft * soft)));
|
||||
}
|
||||
|
||||
vec2 toBlurUV(vec2 cssPos) {
|
||||
return (cssPos + uMargin) / (uGlassSize + uMargin * 2.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// 画布像素 → 玻璃 CSS 坐标(画布原点左上,gl_FragCoord 原点左下)
|
||||
vec2 css = vec2(gl_FragCoord.x, uGlassSize.y * uDpr - gl_FragCoord.y) / uDpr;
|
||||
vec2 halfSize = uGlassSize * 0.5;
|
||||
vec2 p = css - halfSize;
|
||||
float r = min(uRadius, min(halfSize.x, halfSize.y));
|
||||
|
||||
// 液态弯月面边缘(iOS 边缘处理,与原生面板/lensDisplacementMap 一致):
|
||||
// 边缘带只重新分布"自己的内容"——靠边界处抻胀(拉伸)、往中心方向追赶压缩。
|
||||
// 采样映射 s(d) = -d + bump(t)·bend 两端连续(bump(0)=bump(1)=0):
|
||||
// 内容跨玻璃边无缝衔接(无断裂线)、平滑并入清晰中心。单射性:上升斜率
|
||||
// 通过 maxBend ≤ 0.7/1.5·tp·bezel 封顶(局部拉伸 ≤ ~3.3×),
|
||||
// 镜像"舌头"折叠结构上不可能;下降侧只压缩(斜率 < -1),恒单射
|
||||
float depth = -sdRoundRect(p, halfSize, r);
|
||||
vec2 disp = vec2(0.0);
|
||||
vec2 nrm = vec2(0.0);
|
||||
float bump = 0.0;
|
||||
float edgeW = 0.0;
|
||||
if (depth > 0.0 && depth < uBezel) {
|
||||
vec2 q = abs(p) - halfSize + r;
|
||||
vec2 qs = softClamp(q, max(r * 0.8, 1.0));
|
||||
nrm = (qs / max(length(qs), 1e-4)) * sign(p + vec2(1e-6));
|
||||
float t = depth / uBezel;
|
||||
edgeW = (1.0 - t) * (1.0 - t);
|
||||
// C¹ 鼓包:smoothstep 上升至 t=0.62 峰值,smoothstep 回落到 0
|
||||
float u = clamp(t < 0.62 ? t / 0.62 : (1.0 - t) / 0.38, 0.0, 1.0);
|
||||
bump = u * u * (3.0 - 2.0 * u);
|
||||
disp = nrm * (bump * uMaxBend * min(uDispFactor, 1.0));
|
||||
}
|
||||
|
||||
// 压缩带内做 5 tap 足迹积分(径向 + 切向拉丝):边缘带是 2~3 倍缩小映射,
|
||||
// 点采样会产生锯齿/摩尔纹;径向扩散随压缩强度自适应加宽做抗锯齿。
|
||||
// 色散:与 GlassFilter 相同的 RGB 位移递减(R 全量、G/B 递减)
|
||||
vec3 c;
|
||||
float dispLen2 = dot(disp, disp);
|
||||
if (dispLen2 > 0.25) {
|
||||
vec2 tangent = vec2(nrm.y, -nrm.x) * sqrt(dispLen2);
|
||||
float rs = 0.05 + 0.10 * bump;
|
||||
float radial[5]; radial[0] = 1.0 - 2.0 * rs; radial[1] = 1.0 - rs; radial[2] = 1.0; radial[3] = 1.0 + rs; radial[4] = 1.0 + 2.0 * rs;
|
||||
float lateral[5]; lateral[0] = -0.36; lateral[1] = 0.18; lateral[2] = 0.0; lateral[3] = -0.18; lateral[4] = 0.36;
|
||||
float wts[5]; wts[0] = 0.14; wts[1] = 0.22; wts[2] = 0.28; wts[3] = 0.22; wts[4] = 0.14;
|
||||
c = vec3(0.0);
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
vec2 d = disp * radial[i] + tangent * lateral[i];
|
||||
c += wts[i] * vec3(
|
||||
texture(uBlurTex, toBlurUV(css + d)).r,
|
||||
texture(uBlurTex, toBlurUV(css + d * (1.0 - uAberration * 0.05))).g,
|
||||
texture(uBlurTex, toBlurUV(css + d * (1.0 - uAberration * 0.1))).b);
|
||||
}
|
||||
} else {
|
||||
c = texture(uBlurTex, toBlurUV(css + disp)).rgb;
|
||||
}
|
||||
|
||||
// CSS saturate() 等效矩阵
|
||||
float lum = dot(c, vec3(0.213, 0.715, 0.072));
|
||||
c = mix(vec3(lum), c, uSaturation);
|
||||
|
||||
// 贴边宽幅边缘光(与原生面板一致,光源方向左上):受光侧提亮、背光侧微压暗,
|
||||
// 权重 edgeW 在边界处最大、向内 C¹ 衰减,独立于位移鼓包——iOS 玻璃的
|
||||
// "实体厚度"暗示;细锐的描边环仍由内容层负责
|
||||
float lightDot = dot(nrm, vec2(-0.40, -0.92));
|
||||
c = clamp(c + edgeW * (0.09 * clamp(lightDot, 0.0, 1.0) - 0.04 * clamp(-lightDot, 0.0, 1.0)), 0.0, 1.0);
|
||||
|
||||
outColor = vec4(c, 1.0);
|
||||
}`
|
||||
|
||||
function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
|
||||
const shader = gl.createShader(type)
|
||||
if (!shader) return null
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.warn('[LiquidGlass] shader compile failed:', gl.getShaderInfoLog(shader))
|
||||
gl.deleteShader(shader)
|
||||
return null
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
function link(gl: WebGL2RenderingContext, frag: string): WebGLProgram | null {
|
||||
const vs = compile(gl, gl.VERTEX_SHADER, VERT)
|
||||
const fs = compile(gl, gl.FRAGMENT_SHADER, frag)
|
||||
if (!vs || !fs) return null
|
||||
const program = gl.createProgram()
|
||||
if (!program) return null
|
||||
gl.attachShader(program, vs)
|
||||
gl.attachShader(program, fs)
|
||||
gl.linkProgram(program)
|
||||
gl.deleteShader(vs)
|
||||
gl.deleteShader(fs)
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
console.warn('[LiquidGlass] program link failed:', gl.getProgramInfoLog(program))
|
||||
gl.deleteProgram(program)
|
||||
return null
|
||||
}
|
||||
return program
|
||||
}
|
||||
|
||||
/** 创建流渲染器;WebGL 不可用或轨道无效时返回 null(调用方回退 <video> 管线) */
|
||||
export function createGlassStreamRenderer(options: GlassStreamRendererOptions): GlassStreamRenderer | null {
|
||||
const { canvas, track } = options
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const gl = canvas.getContext('webgl2', {
|
||||
alpha: false,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
stencil: false,
|
||||
// 低延迟提示:帧到达即绘制,desynchronized 允许绕过合成器队列
|
||||
desynchronized: true,
|
||||
powerPreference: 'low-power'
|
||||
}) as WebGL2RenderingContext | null
|
||||
if (!gl) return null
|
||||
|
||||
const blurProgram = link(gl, BLUR_FRAG)
|
||||
const lensProgram = link(gl, LENS_FRAG)
|
||||
if (!blurProgram || !lensProgram) {
|
||||
track.stop()
|
||||
return null
|
||||
}
|
||||
|
||||
canvas.width = Math.max(1, Math.round(options.width * dpr))
|
||||
canvas.height = Math.max(1, Math.round(options.height * dpr))
|
||||
|
||||
// 半分辨率模糊目标(玻璃 + 外扩边距)
|
||||
const blurW = Math.max(1, Math.ceil((options.width + BLUR_MARGIN * 2) / 2))
|
||||
const blurH = Math.max(1, Math.ceil((options.height + BLUR_MARGIN * 2) / 2))
|
||||
|
||||
const screenTex = gl.createTexture()
|
||||
const makeTarget = () => {
|
||||
const tex = gl.createTexture()
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, blurW, blurH, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
const fbo = gl.createFramebuffer()
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0)
|
||||
return { tex, fbo }
|
||||
}
|
||||
const targetA = makeTarget()
|
||||
const targetB = makeTarget()
|
||||
|
||||
// 运动估计降采样目标:56×24 网格(复用模糊程序做加权降采样,uDir=0 时权重和为 1)
|
||||
const motionTex = gl.createTexture()
|
||||
const motionFbo = gl.createFramebuffer()
|
||||
gl.bindTexture(gl.TEXTURE_2D, motionTex)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, MOTION_GRID_W, MOTION_GRID_H, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, motionFbo)
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, motionTex, 0)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
|
||||
|
||||
const blurU = {
|
||||
tex: gl.getUniformLocation(blurProgram, 'uTex'),
|
||||
srcOrigin: gl.getUniformLocation(blurProgram, 'uSrcOrigin'),
|
||||
srcSpan: gl.getUniformLocation(blurProgram, 'uSrcSpan'),
|
||||
outSize: gl.getUniformLocation(blurProgram, 'uOutSize'),
|
||||
dir: gl.getUniformLocation(blurProgram, 'uDir')
|
||||
}
|
||||
const lensU = {
|
||||
blurTex: gl.getUniformLocation(lensProgram, 'uBlurTex'),
|
||||
glassSize: gl.getUniformLocation(lensProgram, 'uGlassSize'),
|
||||
dpr: gl.getUniformLocation(lensProgram, 'uDpr'),
|
||||
radius: gl.getUniformLocation(lensProgram, 'uRadius'),
|
||||
bezel: gl.getUniformLocation(lensProgram, 'uBezel'),
|
||||
maxBend: gl.getUniformLocation(lensProgram, 'uMaxBend'),
|
||||
dispFactor: gl.getUniformLocation(lensProgram, 'uDispFactor'),
|
||||
aberration: gl.getUniformLocation(lensProgram, 'uAberration'),
|
||||
saturation: gl.getUniformLocation(lensProgram, 'uSaturation'),
|
||||
margin: gl.getUniformLocation(lensProgram, 'uMargin')
|
||||
}
|
||||
|
||||
// 与 lensDisplacementMap 相同的几何参数(iOS 式宽幅湿边)。
|
||||
// maxBend = 弯月面鼓包峰值,斜率封顶保证映射单射:
|
||||
// 上升斜率 1.5/tp·maxBend/bezel ≤ 0.7(tp=0.62)⇒ maxBend = 0.289·bezel,
|
||||
// 远小于模糊纹理外扩边距,足迹采样不会越界
|
||||
const halfMin = Math.min(options.width, options.height) / 2
|
||||
const bezel = Math.min(34, halfMin * 0.75)
|
||||
const maxBend = 0.289 * bezel
|
||||
|
||||
let disposed = false
|
||||
let firstFrame = true
|
||||
let texW = 0
|
||||
let texH = 0
|
||||
|
||||
// 玻璃区域(+模糊边距)的屏幕逻辑坐标
|
||||
const regionX = options.screenX - BLUR_MARGIN
|
||||
const regionY = options.screenY - BLUR_MARGIN
|
||||
const regionW = options.width + BLUR_MARGIN * 2
|
||||
const regionH = options.height + BLUR_MARGIN * 2
|
||||
|
||||
// —— 运动补偿状态:估计器输出目标偏移,渲染时平滑逼近 ——
|
||||
const estimator = createGlassMotionEstimator(regionW / MOTION_GRID_W, regionH / MOTION_GRID_H, MOTION_PREDICT_MS)
|
||||
const motionPixels = new Uint8Array(MOTION_GRID_W * MOTION_GRID_H * 4)
|
||||
let offsetX = 0
|
||||
let offsetY = 0
|
||||
let targetOffsetX = 0
|
||||
let targetOffsetY = 0
|
||||
let decayTimer = 0
|
||||
|
||||
// 内容停止运动后采集不再出帧,渲染循环停摆,需要主动把预测偏移收回零,
|
||||
// 否则折射内容会停在外推位置上(错位定格);首步等 IDLE_DECAY_DELAY
|
||||
// 确认无新帧,后续以短步长快速滑回
|
||||
const scheduleDecay = (delay = IDLE_DECAY_DELAY) => {
|
||||
clearTimeout(decayTimer)
|
||||
decayTimer = window.setTimeout(() => {
|
||||
if (disposed) return
|
||||
const t = estimator.decay()
|
||||
targetOffsetX = t.x
|
||||
targetOffsetY = t.y
|
||||
offsetX = offsetX * 0.5 + targetOffsetX * 0.5
|
||||
offsetY = offsetY * 0.5 + targetOffsetY * 0.5
|
||||
drawGlass()
|
||||
if (Math.abs(offsetX) > 0.5 || Math.abs(offsetY) > 0.5) scheduleDecay(40)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const uploadTexture = (source: TexImageSource, w: number, h: number) => {
|
||||
gl.activeTexture(gl.TEXTURE0)
|
||||
gl.bindTexture(gl.TEXTURE_2D, screenTex)
|
||||
if (w !== texW || h !== texH) {
|
||||
texW = w
|
||||
texH = h
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
}
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, source)
|
||||
}
|
||||
|
||||
/** 用当前 screenTex 内容执行模糊 + 透镜两级绘制(可被运动偏移回收重绘复用) */
|
||||
const drawGlass = () => {
|
||||
// 趟1:纹理采样区域 → 半分辨率 + 水平高斯。
|
||||
// 采样原点按运动预测偏移前移:显示"内容此刻应到达的位置"而非采集帧的旧位置
|
||||
gl.activeTexture(gl.TEXTURE0)
|
||||
gl.bindTexture(gl.TEXTURE_2D, screenTex)
|
||||
gl.useProgram(blurProgram)
|
||||
gl.uniform1i(blurU.tex, 0)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, targetA.fbo)
|
||||
gl.viewport(0, 0, blurW, blurH)
|
||||
gl.uniform2f(blurU.srcOrigin, (regionX - offsetX) / options.screenW, (regionY - offsetY) / options.screenH)
|
||||
gl.uniform2f(blurU.srcSpan, regionW / options.screenW, regionH / options.screenH)
|
||||
gl.uniform2f(blurU.outSize, blurW, blurH)
|
||||
gl.uniform2f(blurU.dir, options.blurSigma / options.screenW, 0)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
||||
|
||||
// 趟2:垂直高斯(源为趟1 结果,UV 全幅,步长换算到区域跨度)
|
||||
gl.bindTexture(gl.TEXTURE_2D, targetA.tex)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, targetB.fbo)
|
||||
gl.uniform2f(blurU.srcOrigin, 0, 0)
|
||||
gl.uniform2f(blurU.srcSpan, 1, 1)
|
||||
gl.uniform2f(blurU.dir, 0, options.blurSigma / regionH)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
||||
|
||||
// 趟3:透镜主着色 → 画布
|
||||
gl.useProgram(lensProgram)
|
||||
gl.bindTexture(gl.TEXTURE_2D, targetB.tex)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
|
||||
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||
gl.uniform1i(lensU.blurTex, 0)
|
||||
gl.uniform2f(lensU.glassSize, options.width, options.height)
|
||||
gl.uniform1f(lensU.dpr, dpr)
|
||||
gl.uniform1f(lensU.radius, options.cornerRadius)
|
||||
gl.uniform1f(lensU.bezel, bezel)
|
||||
gl.uniform1f(lensU.maxBend, maxBend)
|
||||
gl.uniform1f(lensU.dispFactor, options.displacementScale / 70)
|
||||
gl.uniform1f(lensU.aberration, options.aberrationIntensity)
|
||||
gl.uniform1f(lensU.saturation, options.saturation / 100)
|
||||
gl.uniform1f(lensU.margin, BLUR_MARGIN)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
||||
// 立即提交而不等下一次合成对齐(desynchronized 画布的低延迟关键)
|
||||
gl.flush()
|
||||
}
|
||||
|
||||
/** 固定区域降采样出 56×24 亮度网格并回读,喂给运动估计器。
|
||||
采样区域不随补偿偏移移动,估计的是真实屏幕运动(避免补偿反馈回路) */
|
||||
const estimateMotion = (timestampMs: number) => {
|
||||
gl.useProgram(blurProgram)
|
||||
gl.uniform1i(blurU.tex, 0)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, motionFbo)
|
||||
gl.viewport(0, 0, MOTION_GRID_W, MOTION_GRID_H)
|
||||
gl.uniform2f(blurU.srcOrigin, regionX / options.screenW, regionY / options.screenH)
|
||||
gl.uniform2f(blurU.srcSpan, regionW / options.screenW, regionH / options.screenH)
|
||||
gl.uniform2f(blurU.outSize, MOTION_GRID_W, MOTION_GRID_H)
|
||||
// uDir=0 时高斯核权重和恰为 1,等价于纯降采样
|
||||
gl.uniform2f(blurU.dir, 0, 0)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
||||
// 5KB 同步回读,实测 <0.5ms;异步 PBO 会引入一帧估计延迟,得不偿失
|
||||
gl.readPixels(0, 0, MOTION_GRID_W, MOTION_GRID_H, gl.RGBA, gl.UNSIGNED_BYTE, motionPixels)
|
||||
const t = estimator.update(motionPixels, timestampMs)
|
||||
targetOffsetX = t.x
|
||||
targetOffsetY = t.y
|
||||
offsetX += (targetOffsetX - offsetX) * OFFSET_SMOOTH
|
||||
offsetY += (targetOffsetY - offsetY) * OFFSET_SMOOTH
|
||||
scheduleDecay()
|
||||
}
|
||||
|
||||
const render = (frame: VideoFrame | HTMLVideoElement) => {
|
||||
if (disposed) return
|
||||
const fw = frame instanceof HTMLVideoElement ? frame.videoWidth : frame.displayWidth
|
||||
const fh = frame instanceof HTMLVideoElement ? frame.videoHeight : frame.displayHeight
|
||||
if (!fw || !fh) return
|
||||
|
||||
// 整帧直传:采集帧是 GPU 背书的,texImage2D 走 GPU-GPU 拷贝;
|
||||
// 实测 visibleRect 裁剪视图反而更慢(触发同步转换路径),不做预裁剪。
|
||||
// UV 按屏幕逻辑坐标映射,帧分辨率高于逻辑尺寸时(Retina/高 DPI)采样自动降分辨率
|
||||
uploadTexture(frame as TexImageSource, fw, fh)
|
||||
estimateMotion(frame instanceof HTMLVideoElement ? performance.now() : frame.timestamp / 1000)
|
||||
drawGlass()
|
||||
|
||||
if (firstFrame) {
|
||||
firstFrame = false
|
||||
options.onFirstFrame?.()
|
||||
}
|
||||
}
|
||||
|
||||
// —— 帧泵:MediaStreamTrackProcessor 为主源(帧到达即渲染,无播出缓冲)。
|
||||
// 同轨再挂一个离屏 <video> 作首帧引子与后备:MSTP 只在画面变化时出帧,
|
||||
// 静态桌面上新挂载的玻璃会一直等不到首帧;video 的 loadeddata 能立刻
|
||||
// 给出当前帧,rVFC 则在 MSTP 不可用/停摆时兜底渲染 ——
|
||||
let lastPumpRender = 0
|
||||
|
||||
const video = document.createElement('video')
|
||||
video.muted = true
|
||||
video.srcObject = new MediaStream([track])
|
||||
const onVideoFrame = () => {
|
||||
if (disposed) return
|
||||
// 主源正常供帧时后备不重复渲染(同帧二次绘制无意义)
|
||||
if (performance.now() - lastPumpRender > 250) render(video)
|
||||
video.requestVideoFrameCallback(onVideoFrame)
|
||||
}
|
||||
video.addEventListener('loadeddata', () => {
|
||||
if (!disposed && firstFrame) render(video)
|
||||
}, { once: true })
|
||||
video.requestVideoFrameCallback(onVideoFrame)
|
||||
video.play().catch(() => { /* 流启动失败保持首帧前状态 */ })
|
||||
|
||||
const ProcessorCtor = (window as unknown as {
|
||||
MediaStreamTrackProcessor?: new (init: { track: MediaStreamTrack }) => { readable: ReadableStream<VideoFrame> }
|
||||
}).MediaStreamTrackProcessor
|
||||
let stopPump = () => { /* MSTP 不可用时仅有 video 后备 */ }
|
||||
|
||||
if (ProcessorCtor) {
|
||||
// MSTP 会独占消费传入轨道,供 video 后备的轨道需要单独 clone
|
||||
const pumpTrack = track.clone()
|
||||
const reader = new ProcessorCtor({ track: pumpTrack }).readable.getReader()
|
||||
const pump = async () => {
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done || disposed) {
|
||||
value?.close()
|
||||
return
|
||||
}
|
||||
lastPumpRender = performance.now()
|
||||
render(value)
|
||||
value.close()
|
||||
}
|
||||
} catch { /* 轨道停止时 read 抛错,正常退出 */ }
|
||||
}
|
||||
void pump()
|
||||
stopPump = () => {
|
||||
void reader.cancel().catch(() => { /* 已关闭 */ })
|
||||
pumpTrack.stop()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
clearTimeout(decayTimer)
|
||||
stopPump()
|
||||
video.srcObject = null
|
||||
track.stop()
|
||||
gl.deleteTexture(screenTex)
|
||||
gl.deleteTexture(targetA.tex)
|
||||
gl.deleteTexture(targetB.tex)
|
||||
gl.deleteTexture(motionTex)
|
||||
gl.deleteFramebuffer(targetA.fbo)
|
||||
gl.deleteFramebuffer(targetB.fbo)
|
||||
gl.deleteFramebuffer(motionFbo)
|
||||
gl.deleteProgram(blurProgram)
|
||||
gl.deleteProgram(lensProgram)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import GlassFilter from './GlassFilter'
|
||||
import { generateLensDisplacementMap } from './lensDisplacementMap'
|
||||
import { createGlassStreamRenderer } from './glassStreamRenderer'
|
||||
import './liquidGlass.scss'
|
||||
|
||||
/**
|
||||
* 液态玻璃容器,移植自 liquid-glass-react v1.1.1(MIT)
|
||||
* https://github.com/rdev/liquid-glass-react
|
||||
*
|
||||
* 相对原库的调整:
|
||||
* - 移除内部 Tailwind 类名,全部改为内联样式(WeFlow 无 Tailwind 运行时)
|
||||
* - 由"固定定位 + 居中平移"的悬浮布局改为常规文档流布局,定位交给调用方
|
||||
* - 移除原库 overLight 的黑色压暗层:Chromium 中与 backdrop-filter 同级的
|
||||
* mix-blend 图层无法参与混合、会按普通合成渲染,不透明黑层因此退化为实心黑块。
|
||||
* 主题适配改由全局兼容层 liquidGlass.scss 的 CSS 变量统一控制
|
||||
* (--liquid-glass-tint / --liquid-glass-shadow),色调层只使用普通 alpha 合成;
|
||||
* 边缘高光/悬停辉光均为低透明度白色渐变,混合失效时也只会退化为普通高光,安全
|
||||
* - 注意:根节点不能设置 isolation / opacity / mask 等会形成 backdrop root 的属性,
|
||||
* 否则内部 backdrop-filter 将采样不到页面内容,玻璃会失去模糊折射
|
||||
* - 鼠标追踪仅在 elasticity > 0 时监听,并用 rAF 节流;远离激活区时不触发重渲染
|
||||
* - 尺寸改用 ResizeObserver 跟踪,内容变化时滤镜区域同步更新
|
||||
* - 位移贴图不用原库静态 PNG(边缘位移带太窄,宽扁卡片上退化成模糊),
|
||||
* 改用 lensDisplacementMap 按实际尺寸/圆角生成的透镜贴图:中心零位移完全清晰、
|
||||
* 边缘全周透镜弯曲;polar/prominent 模式与 Firefox 降级逻辑不保留(固定 Chromium)
|
||||
*/
|
||||
|
||||
/** 鼠标弹性效果的激活半径(距元素边缘的像素距离) */
|
||||
const ACTIVATION_ZONE = 200
|
||||
|
||||
/** 像素源裁剪层向外的出血宽度:容纳模糊核的边缘采样(透镜位移只向内采样,不需要出血) */
|
||||
const PIXEL_SOURCE_BLEED = 32
|
||||
|
||||
interface MouseState {
|
||||
engaged: boolean
|
||||
tx: number
|
||||
ty: number
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
/** 相对元素中心的偏移百分比,用于边缘高光渐变 */
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
const IDLE_MOUSE: MouseState = { engaged: false, tx: 0, ty: 0, scaleX: 1, scaleY: 1, offsetX: 0, offsetY: 0 }
|
||||
|
||||
/** 玻璃的替代背景采样源:屏幕快照/视频流 + 玻璃所在窗口的屏幕坐标 */
|
||||
export interface LiquidGlassBackdropImage {
|
||||
/** 静态快照,视频流启动前的首帧兜底;可为空 */
|
||||
dataUrl?: string | null
|
||||
/** 屏幕的 CSS 像素尺寸(即逻辑分辨率) */
|
||||
width: number
|
||||
height: number
|
||||
/** 玻璃所在窗口在屏幕上的位置 */
|
||||
screenX: number
|
||||
screenY: number
|
||||
}
|
||||
|
||||
export interface LiquidGlassProps {
|
||||
children: ReactNode
|
||||
/** 位移折射强度 */
|
||||
displacementScale?: number
|
||||
/** 磨砂程度(0~1,映射为额外的 backdrop blur 像素) */
|
||||
blurAmount?: number
|
||||
/** 背景饱和度百分比 */
|
||||
saturation?: number
|
||||
/** 边缘色散强度 */
|
||||
aberrationIntensity?: number
|
||||
/** 鼠标弹性系数,默认 0(完全关闭鼠标追踪,需要弹性效果时显式开启) */
|
||||
elasticity?: number
|
||||
cornerRadius?: number
|
||||
className?: string
|
||||
padding?: string
|
||||
style?: CSSProperties
|
||||
/**
|
||||
* 替代背景采样源。Electron 透明窗口内 backdrop-filter 不生效(Chromium 限制),
|
||||
* 传入与桌面逐像素对齐的屏幕快照后,玻璃改用普通 filter 在内部现场加工快照,
|
||||
* 视觉效果与真实 backdrop 采样一致
|
||||
*/
|
||||
backdropImage?: LiquidGlassBackdropImage
|
||||
/** 屏幕实时视频流:就绪后替换静态快照,折射随桌面实时更新 */
|
||||
backdropStream?: MediaStream | null
|
||||
/**
|
||||
* 原生玻璃模式(Windows):模糊/折射/色散由主进程的原生面板在窗口下方渲染
|
||||
* (@hicccc77/electron-liquid-glass,DXGI 零拷贝 + D3D11,感知滞后中位 ~6ms),
|
||||
* 组件内不再渲染折射背景层,只保留色调纱层、内容与边缘高光
|
||||
*/
|
||||
nativeBackdrop?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
/** 沿 offsetParent 链累计布局坐标:不受 transform 动画影响,可稳定对齐快照 */
|
||||
function getLayoutOffset(el: HTMLElement): { x: number; y: number } {
|
||||
let x = 0
|
||||
let y = 0
|
||||
let node: HTMLElement | null = el
|
||||
while (node) {
|
||||
x += node.offsetLeft
|
||||
y += node.offsetTop
|
||||
node = node.offsetParent as HTMLElement | null
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export default function LiquidGlass({
|
||||
children,
|
||||
displacementScale = 70,
|
||||
blurAmount = 0.0625,
|
||||
saturation = 140,
|
||||
aberrationIntensity = 2,
|
||||
elasticity = 0,
|
||||
cornerRadius = 999,
|
||||
className = '',
|
||||
padding,
|
||||
style,
|
||||
backdropImage,
|
||||
backdropStream,
|
||||
nativeBackdrop = false,
|
||||
onClick
|
||||
}: LiquidGlassProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const rawId = useId()
|
||||
// useId 可能包含 ':' 等 CSS url() 不接受的字符,需要清洗后才能用作滤镜 id
|
||||
const filterId = `liquid-glass-${rawId.replace(/[^a-zA-Z0-9_-]/g, '')}`
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [glassSize, setGlassSize] = useState({ width: 270, height: 69 })
|
||||
const [anchor, setAnchor] = useState({ x: 0, y: 0 })
|
||||
const [mouse, setMouse] = useState<MouseState>(IDLE_MOUSE)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
// 视频流出帧后才切换显示,避免黑帧闪烁
|
||||
const [streamLive, setStreamLive] = useState(false)
|
||||
// WebGL 渲染器初始化失败时回退 <video> + SVG 滤镜管线
|
||||
const [glFailed, setGlFailed] = useState(false)
|
||||
const useGlPipeline = Boolean(backdropStream) && !glFailed && !nativeBackdrop
|
||||
|
||||
// 透镜位移贴图按实际尺寸生成(中心零位移、边缘弯曲);
|
||||
// WebGL 流管线在着色器内解析求值同一几何,无需贴图;原生模式折射在原生面板完成
|
||||
const lensMap = useMemo(
|
||||
() => (useGlPipeline || nativeBackdrop ? null : generateLensDisplacementMap(glassSize.width, glassSize.height, cornerRadius)),
|
||||
[useGlPipeline, nativeBackdrop, glassSize.width, glassSize.height, cornerRadius]
|
||||
)
|
||||
// 贴图生成失败时跳过位移滤镜(模糊和材质层仍然生效)
|
||||
const refractionFilter = lensMap?.url ? `url(#${filterId})` : undefined
|
||||
|
||||
// WebGL 流管线:MediaStreamTrackProcessor 直读帧 + 着色器一次完成模糊/位移/色散/饱和,
|
||||
// 相比 <video> + CSS/SVG 滤镜省去播出缓冲与滤镜图逐帧重光栅化(延迟↓、GPU 开销↓)
|
||||
useEffect(() => {
|
||||
if (!useGlPipeline || !backdropImage) return
|
||||
const canvas = canvasRef.current
|
||||
const track = backdropStream?.getVideoTracks()[0]
|
||||
if (!canvas || !track) return
|
||||
|
||||
// clone:同一流可能有多个玻璃实例(新旧通知过渡)与亮度采样共同消费
|
||||
const renderer = createGlassStreamRenderer({
|
||||
canvas,
|
||||
track: track.clone(),
|
||||
width: glassSize.width,
|
||||
height: glassSize.height,
|
||||
cornerRadius,
|
||||
screenX: backdropImage.screenX + anchor.x,
|
||||
screenY: backdropImage.screenY + anchor.y,
|
||||
screenW: backdropImage.width,
|
||||
screenH: backdropImage.height,
|
||||
displacementScale,
|
||||
aberrationIntensity,
|
||||
saturation,
|
||||
// 与回退管线的 blur(${(4 + blurAmount * 32) / 2}px) 一致
|
||||
blurSigma: (4 + blurAmount * 32) / 2,
|
||||
onFirstFrame: () => setStreamLive(true)
|
||||
})
|
||||
if (!renderer) {
|
||||
setGlFailed(true)
|
||||
return
|
||||
}
|
||||
return () => {
|
||||
renderer.dispose()
|
||||
setStreamLive(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
useGlPipeline, backdropStream, glassSize.width, glassSize.height, cornerRadius,
|
||||
backdropImage?.screenX, backdropImage?.screenY, backdropImage?.width, backdropImage?.height,
|
||||
anchor.x, anchor.y, displacementScale, aberrationIntensity, saturation, blurAmount
|
||||
])
|
||||
|
||||
// 回退管线:<video> 承载流,CSS/SVG 滤镜加工
|
||||
useEffect(() => {
|
||||
if (useGlPipeline) return
|
||||
const video = videoRef.current
|
||||
if (!video || !backdropStream) return
|
||||
let cancelled = false
|
||||
video.srcObject = backdropStream
|
||||
// 用首帧回调而非 playing 事件:真正出帧的一刻立即显示,最小化感知延迟
|
||||
video.requestVideoFrameCallback(() => {
|
||||
if (!cancelled) setStreamLive(true)
|
||||
})
|
||||
video.play().catch(() => { /* 流启动失败时保持静态快照/材质层 */ })
|
||||
return () => {
|
||||
cancelled = true
|
||||
video.srcObject = null
|
||||
setStreamLive(false)
|
||||
}
|
||||
}, [useGlPipeline, backdropStream])
|
||||
|
||||
// 跟踪自身尺寸与页面内布局位置,供 SVG 滤镜和快照对齐使用
|
||||
useEffect(() => {
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
const width = el.offsetWidth
|
||||
const height = el.offsetHeight
|
||||
setGlassSize(prev => (prev.width === width && prev.height === height ? prev : { width, height }))
|
||||
const offset = getLayoutOffset(el)
|
||||
setAnchor(prev => (prev.x === offset.x && prev.y === offset.y ? prev : offset))
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// 鼠标接近时的弹性形变(rAF 节流;远离激活区时不重复 setState)
|
||||
useEffect(() => {
|
||||
if (elasticity <= 0) return
|
||||
|
||||
let rafHandle = 0
|
||||
let lastEvent: MouseEvent | null = null
|
||||
|
||||
const compute = () => {
|
||||
rafHandle = 0
|
||||
const el = rootRef.current
|
||||
if (!el || !lastEvent) return
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
const centerY = rect.top + rect.height / 2
|
||||
const deltaX = lastEvent.clientX - centerX
|
||||
const deltaY = lastEvent.clientY - centerY
|
||||
|
||||
const edgeDistance = Math.hypot(
|
||||
Math.max(0, Math.abs(deltaX) - rect.width / 2),
|
||||
Math.max(0, Math.abs(deltaY) - rect.height / 2)
|
||||
)
|
||||
if (edgeDistance > ACTIVATION_ZONE) {
|
||||
setMouse(prev => (prev.engaged ? IDLE_MOUSE : prev))
|
||||
return
|
||||
}
|
||||
|
||||
// 距离边缘越近效果越强
|
||||
const fadeIn = 1 - edgeDistance / ACTIVATION_ZONE
|
||||
const centerDistance = Math.hypot(deltaX, deltaY)
|
||||
const stretch = centerDistance === 0 ? 0 : Math.min(centerDistance / 300, 1) * elasticity * fadeIn
|
||||
const normalX = centerDistance === 0 ? 0 : Math.abs(deltaX / centerDistance)
|
||||
const normalY = centerDistance === 0 ? 0 : Math.abs(deltaY / centerDistance)
|
||||
|
||||
setMouse({
|
||||
engaged: true,
|
||||
tx: deltaX * elasticity * 0.1 * fadeIn,
|
||||
ty: deltaY * elasticity * 0.1 * fadeIn,
|
||||
// 沿鼠标方向拉伸、垂直方向压缩,模拟液体
|
||||
scaleX: Math.max(0.8, 1 + normalX * stretch * 0.3 - normalY * stretch * 0.15),
|
||||
scaleY: Math.max(0.8, 1 + normalY * stretch * 0.3 - normalX * stretch * 0.15),
|
||||
offsetX: (deltaX / rect.width) * 100,
|
||||
offsetY: (deltaY / rect.height) * 100
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
lastEvent = event
|
||||
if (!rafHandle) rafHandle = requestAnimationFrame(compute)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
if (rafHandle) cancelAnimationFrame(rafHandle)
|
||||
}
|
||||
}, [elasticity])
|
||||
|
||||
const clickable = Boolean(onClick)
|
||||
const pressed = isActive && clickable
|
||||
|
||||
let transform: string | undefined
|
||||
if (mouse.engaged) {
|
||||
transform = `translate(${mouse.tx}px, ${mouse.ty}px) ${pressed ? 'scale(0.96)' : `scaleX(${mouse.scaleX}) scaleY(${mouse.scaleY})`}`
|
||||
} else if (pressed) {
|
||||
transform = 'scale(0.96)'
|
||||
}
|
||||
|
||||
const borderGradient = (midAlpha: number, peakAlpha: number) => `linear-gradient(
|
||||
${135 + mouse.offsetX * 1.2}deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, ${midAlpha + Math.abs(mouse.offsetX) * 0.008}) ${Math.max(10, 33 + mouse.offsetY * 0.3)}%,
|
||||
rgba(255, 255, 255, ${peakAlpha + Math.abs(mouse.offsetX) * 0.012}) ${Math.min(90, 66 + mouse.offsetY * 0.4)}%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
)`
|
||||
|
||||
const overlayBase: CSSProperties = {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: cornerRadius,
|
||||
pointerEvents: 'none'
|
||||
}
|
||||
|
||||
// 快照/视频与屏幕逐像素对齐:按"窗口屏幕坐标 + 玻璃布局位置"负偏移,
|
||||
// 再补回裁剪层的出血宽度(像素源挂在 inset:-BLEED 的裁剪层内)
|
||||
const backdropPixelSourceStyle: CSSProperties | undefined = backdropImage
|
||||
? {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: backdropImage.width,
|
||||
height: backdropImage.height,
|
||||
maxWidth: 'none',
|
||||
transform: `translate(${PIXEL_SOURCE_BLEED - (backdropImage.screenX + anchor.x)}px, ${PIXEL_SOURCE_BLEED - (backdropImage.screenY + anchor.y)}px)`,
|
||||
pointerEvents: 'none'
|
||||
}
|
||||
: undefined
|
||||
|
||||
// 1.5px 环形高光:通过 padding + mask 挖空中心实现
|
||||
// 注意不要带深色投影,浅色页面上会形成一圈突兀的黑边
|
||||
const borderLayerBase: CSSProperties = {
|
||||
...overlayBase,
|
||||
padding: 1.5,
|
||||
WebkitMask: 'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)',
|
||||
WebkitMaskComposite: 'xor',
|
||||
maskComposite: 'exclude',
|
||||
boxShadow: '0 0 0 0.5px rgba(255, 255, 255, 0.35) inset, 0 1px 3px rgba(255, 255, 255, 0.2) inset'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`liquid-glass ${className}`.trim()}
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: cornerRadius,
|
||||
cursor: clickable ? 'pointer' : undefined,
|
||||
transform,
|
||||
transition: 'transform 0.2s ease-out',
|
||||
boxShadow: 'var(--liquid-glass-shadow)',
|
||||
...style
|
||||
}}
|
||||
onClick={onClick}
|
||||
onMouseEnter={clickable ? () => setIsHovered(true) : undefined}
|
||||
onMouseLeave={clickable ? () => { setIsHovered(false); setIsActive(false) } : undefined}
|
||||
onMouseDown={clickable ? () => setIsActive(true) : undefined}
|
||||
onMouseUp={clickable ? () => setIsActive(false) : undefined}
|
||||
>
|
||||
{lensMap?.url && (
|
||||
<GlassFilter
|
||||
id={filterId}
|
||||
map={lensMap}
|
||||
displacementScale={displacementScale}
|
||||
aberrationIntensity={aberrationIntensity}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 折射背景层:常规模式用 backdrop-filter 实时采样页面;
|
||||
原生模式(Windows)折射由主进程原生面板在窗口下方渲染,这里完全透明;
|
||||
流模式(透明窗口)优先走 WebGL 管线:采集帧直读进着色器,
|
||||
模糊/透镜位移/色散/饱和一次完成,画布尺寸即玻璃尺寸;
|
||||
WebGL 不可用时回退「<video> + CSS/SVG 滤镜」结构:
|
||||
外层裁剪(圆角)→ 中层挂位移滤镜 → 模糊裁剪层(玻璃+出血)→ 像素源。
|
||||
提供快照时先显示、视频流出帧后无缝接管;无快照时流首帧淡入 */}
|
||||
{nativeBackdrop ? null : useGlPipeline && backdropImage ? (
|
||||
<span style={{ ...overlayBase, overflow: 'hidden' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: streamLive ? 1 : 0,
|
||||
transition: 'opacity 0.12s ease',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : backdropImage ? (
|
||||
<span style={{ ...overlayBase, overflow: 'hidden' }}>
|
||||
<span style={{ position: 'absolute', inset: 0, filter: refractionFilter }}>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: -PIXEL_SOURCE_BLEED,
|
||||
overflow: 'hidden',
|
||||
// 快照模式模糊直接作用于像素源,同参数观感重于 backdrop 采样,减半以保留折射细节
|
||||
filter: `blur(${(4 + blurAmount * 32) / 2}px) saturate(${saturation}%)`
|
||||
}}
|
||||
>
|
||||
{backdropImage.dataUrl && !streamLive && (
|
||||
<img src={backdropImage.dataUrl} alt="" style={backdropPixelSourceStyle} />
|
||||
)}
|
||||
{backdropStream && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
style={{
|
||||
...backdropPixelSourceStyle,
|
||||
// 首帧就绪前保持透明、就绪后快速淡入:无快照兜底时折射内容不突兀也不拖沓
|
||||
opacity: streamLive ? 1 : 0,
|
||||
transition: 'opacity 0.12s ease'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
...overlayBase,
|
||||
overflow: 'hidden',
|
||||
// 与原库一致的轻模糊基线,保证玻璃通透而非磨砂
|
||||
backdropFilter: `blur(${4 + blurAmount * 32}px) saturate(${saturation}%)`,
|
||||
filter: refractionFilter
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 主题色调层:由全局兼容层变量控制,保证深浅模式下内容可读;
|
||||
变量可能被外部按背景高频连续调节(如通知的自适应纱层,采样上限 ~60Hz),
|
||||
短过渡只负责抹平采样步进,不引入可感知的跟随延迟 */}
|
||||
<span style={{ ...overlayBase, background: 'var(--liquid-glass-tint)', transition: 'background 0.08s linear' }} />
|
||||
|
||||
{/* 内容层保持清晰 */}
|
||||
<div style={{ position: 'relative', zIndex: 1, padding }}>{children}</div>
|
||||
|
||||
{/* 边缘高光双层(screen + overlay 混合),强度由兼容层变量整体缩放 */}
|
||||
<span style={{ ...borderLayerBase, zIndex: 2, mixBlendMode: 'screen', opacity: 'calc(0.2 * var(--liquid-glass-ring, 1))', background: borderGradient(0.12, 0.4) }} />
|
||||
<span style={{ ...borderLayerBase, zIndex: 2, mixBlendMode: 'overlay', opacity: 'var(--liquid-glass-ring, 1)', background: borderGradient(0.32, 0.6) }} />
|
||||
|
||||
{/* 可点击时的悬停 / 按下辉光 */}
|
||||
{clickable && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
...overlayBase,
|
||||
zIndex: 2,
|
||||
transition: 'opacity 0.2s ease-out',
|
||||
opacity: isHovered || isActive ? 0.5 : 0,
|
||||
backgroundImage: 'radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%)',
|
||||
mixBlendMode: 'overlay'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
...overlayBase,
|
||||
zIndex: 2,
|
||||
transition: 'opacity 0.2s ease-out',
|
||||
opacity: isActive ? 0.5 : 0,
|
||||
backgroundImage: 'radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 80%)',
|
||||
mixBlendMode: 'overlay'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
...overlayBase,
|
||||
zIndex: 2,
|
||||
transition: 'opacity 0.2s ease-out',
|
||||
opacity: isHovered ? 0.4 : isActive ? 0.8 : 0,
|
||||
backgroundImage: 'radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%)',
|
||||
mixBlendMode: 'overlay'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// 透镜位移贴图生成器(liquid-glass-react shader 模式思路的像素空间重写)
|
||||
//
|
||||
// 原版 shuding 公式在归一化坐标系里求 SDF,对宽扁卡片会把位移全部堆到四角、
|
||||
// 长边中段几乎无效果。这里改为像素空间按真实圆角计算:
|
||||
// 沿整个周边取弯曲带(bezel),带内用透镜剖面把采样点拉向中心,
|
||||
// 中心区域零位移保持完全清晰,边缘全周产生"透过厚玻璃边看背景"的弯曲折射。
|
||||
//
|
||||
// 贴图尺寸=元素尺寸、放置在滤镜坐标 (0,0):Chromium 实测 feImage 像素几何
|
||||
// (0,0,w,h) 与元素原点精确对齐,负坐标/百分比几何解析不可靠
|
||||
|
||||
/** 圆角矩形 SDF:内部为负,边界为 0(像素坐标,原点在矩形中心) */
|
||||
function roundedRectSDF(x: number, y: number, halfW: number, halfH: number, radius: number): number {
|
||||
const qx = Math.abs(x) - halfW + radius
|
||||
const qy = Math.abs(y) - halfH + radius
|
||||
return Math.min(Math.max(qx, qy), 0) + Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) - radius
|
||||
}
|
||||
|
||||
export interface LensDisplacementMap {
|
||||
/** 贴图 dataURL,生成失败时为空串(此时滤镜退化为无位移) */
|
||||
url: string
|
||||
/** 贴图编码的最大位移像素数;SVG 滤镜 scale 取 2×maxScale 时还原几何精确的透镜折射 */
|
||||
maxScale: number
|
||||
/** 贴图(=元素)尺寸,feImage 的像素几何 */
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** 按玻璃尺寸生成透镜位移贴图,R 通道编码 X 位移、G/B 通道编码 Y 位移,0.5 为零位移 */
|
||||
export function generateLensDisplacementMap(width: number, height: number, cornerRadius: number): LensDisplacementMap {
|
||||
const w = Math.max(1, Math.round(width))
|
||||
const h = Math.max(1, Math.round(height))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return { url: '', maxScale: 0, width: w, height: h }
|
||||
|
||||
const halfW = w / 2
|
||||
const halfH = h / 2
|
||||
const radius = Math.min(cornerRadius, halfW, halfH)
|
||||
// 弯曲带只占边缘一圈(真实玻璃只有斜面边缘折射,平板中心不弯曲)。
|
||||
// iOS 式宽幅湿边:带宽最多占短边一半的 75%(小药丸几乎整体是透镜,
|
||||
// 大面板保留清晰中心),不超过则弯曲不会叠进中心
|
||||
const bezel = Math.min(34, Math.min(halfW, halfH) * 0.75)
|
||||
// 弯月面鼓包峰值,斜率封顶保证映射单射(无断裂、无折叠):
|
||||
// 上升斜率 1.5/tp·maxBend/bezel ≤ 0.7(tp=0.62)⇒ maxBend = 0.289·bezel
|
||||
const maxBend = 0.289 * bezel
|
||||
|
||||
let maxScale = 0
|
||||
const rawValues: number[] = []
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
// 以玻璃中心为原点的像素坐标
|
||||
const px = x + 0.5 - halfW
|
||||
const py = y + 0.5 - halfH
|
||||
// 距边界深度(玻璃内部为正)
|
||||
const depth = -roundedRectSDF(px, py, halfW, halfH, radius)
|
||||
let dx = 0
|
||||
let dy = 0
|
||||
if (depth > 0 && depth < bezel) {
|
||||
// 液态弯月面剖面(iOS 边缘处理):位移在边界与带内缘均为 0,
|
||||
// 内容跨玻璃边无缝衔接、平滑并入清晰中心;带内 smoothstep 升降
|
||||
//(峰值在 t=0.62),近边界抻胀、向内压缩回正,斜率封顶无折叠
|
||||
const t = depth / bezel
|
||||
const u = Math.min(1, t < 0.62 ? t / 0.62 : (1 - t) / 0.38)
|
||||
const hump = u * u * (3 - 2 * u)
|
||||
const amount = hump * maxBend
|
||||
// 法线 = 平滑化 max(q,0) 的方向(角区连续旋转,无对角线折痕)
|
||||
const qx = Math.abs(px) - halfW + radius
|
||||
const qy = Math.abs(py) - halfH + radius
|
||||
const soft = Math.max(radius * 0.8, 1)
|
||||
const sx = 0.5 * (qx + Math.hypot(qx, soft))
|
||||
const sy = 0.5 * (qy + Math.hypot(qy, soft))
|
||||
const len = Math.hypot(sx, sy)
|
||||
if (len > 1e-4) {
|
||||
// 采样点沿法线向外:近边界内容被抻胀拽入边缘带(弯月面张力观感)
|
||||
dx = (sx / len) * Math.sign(px || 1) * amount
|
||||
dy = (sy / len) * Math.sign(py || 1) * amount
|
||||
}
|
||||
}
|
||||
maxScale = Math.max(maxScale, Math.abs(dx), Math.abs(dy))
|
||||
rawValues.push(dx, dy)
|
||||
}
|
||||
}
|
||||
maxScale = Math.max(maxScale, 1)
|
||||
|
||||
const imageData = context.createImageData(w, h)
|
||||
const data = imageData.data
|
||||
let rawIndex = 0
|
||||
let pixelIndex = 0
|
||||
const normalize = 2 * maxScale // [-max,max] 映射到 [0,1],不裁剪、无平顶色带
|
||||
for (let i = 0; i < rawValues.length; i += 2) {
|
||||
const r = rawValues[rawIndex++] / normalize + 0.5
|
||||
const g = rawValues[rawIndex++] / normalize + 0.5
|
||||
data[pixelIndex++] = Math.max(0, Math.min(255, Math.round(r * 255))) // R:X 位移
|
||||
data[pixelIndex++] = Math.max(0, Math.min(255, Math.round(g * 255))) // G:Y 位移
|
||||
data[pixelIndex++] = Math.max(0, Math.min(255, Math.round(g * 255))) // B:Y 位移(滤镜取 B 通道)
|
||||
data[pixelIndex++] = 255
|
||||
}
|
||||
context.putImageData(imageData, 0, 0)
|
||||
return { url: canvas.toDataURL(), maxScale, width: w, height: h }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 液态玻璃全局兼容层
|
||||
// 所有 LiquidGlass 实例的主题适配统一由这些变量控制:
|
||||
// - --liquid-glass-tint:玻璃色调层。浅色主题用白色磨砂保证深色文字可读,
|
||||
// 深色主题用暗色磨砂保证浅色文字可读(替代原库有合成缺陷的黑色 mix-blend 压暗层)
|
||||
// - --liquid-glass-shadow:玻璃投影 + 1px 描边,浅色页面上靠它与背景分离
|
||||
// - --liquid-glass-ring:边缘高光环强度系数(0~1)。玻璃直接叠在透明窗口背景上时
|
||||
// 混合层会全强度过曝,需要调低(如独立通知窗口)
|
||||
// 个别表面需要微调时,在其玻璃节点的 class 上覆盖同名变量即可
|
||||
|
||||
:root {
|
||||
--liquid-glass-tint: rgba(255, 255, 255, 0.42);
|
||||
--liquid-glass-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 12px 40px rgba(0, 0, 0, 0.24);
|
||||
--liquid-glass-ring: 1;
|
||||
}
|
||||
|
||||
[data-mode="dark"] {
|
||||
--liquid-glass-tint: rgba(24, 22, 20, 0.4);
|
||||
--liquid-glass-shadow: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: var(--bg-primary);
|
||||
// 保留少量透明度,让 backdrop blur 透出模糊后的应用内容,供解锁卡片折射
|
||||
background-color: color-mix(in srgb, var(--bg-primary) 86%, transparent);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -14,8 +15,6 @@
|
||||
-webkit-app-region: drag;
|
||||
transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
backdrop-filter: blur(25px) saturate(180%);
|
||||
background-color: var(--bg-primary);
|
||||
// 让背景带一点透明度以增强毛玻璃效果
|
||||
opacity: 1;
|
||||
|
||||
&.unlocked {
|
||||
@@ -24,21 +23,25 @@
|
||||
backdrop-filter: blur(0) saturate(100%);
|
||||
transform: scale(1.02);
|
||||
|
||||
.lock-content {
|
||||
.lock-card {
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 液态玻璃卡片外壳:承载出入场动画
|
||||
.lock-card {
|
||||
-webkit-app-region: no-drag;
|
||||
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.lock-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 320px;
|
||||
-webkit-app-region: no-drag;
|
||||
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
.lock-avatar {
|
||||
width: 100px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './LockScreen.scss'
|
||||
|
||||
interface LockScreenProps {
|
||||
@@ -99,7 +100,14 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
||||
|
||||
return (
|
||||
<div className={`lock-screen ${isUnlocked ? 'unlocked' : ''}`}>
|
||||
<div className="lock-content">
|
||||
<div className="lock-card">
|
||||
<LiquidGlass
|
||||
cornerRadius={28}
|
||||
padding="40px 36px"
|
||||
displacementScale={48}
|
||||
aberrationIntensity={1.5}
|
||||
>
|
||||
<div className="lock-content">
|
||||
<div className="lock-avatar">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="User" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
|
||||
@@ -138,6 +146,8 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
||||
</form>
|
||||
|
||||
{error && <div className="lock-error">{error}</div>}
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,166 +1,34 @@
|
||||
// 通知卡片:玻璃视觉全部由 LiquidGlass 兼容层渲染,
|
||||
// 本文件只负责留白、出入场动画与文本排版
|
||||
.notification-toast-container {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
width: 320px;
|
||||
background: var(--bg-secondary);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-light);
|
||||
|
||||
// 浅色模式下使用完全不透明背景,并禁用毛玻璃效果
|
||||
[data-mode="light"] &,
|
||||
:not([data-mode]) & {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
// 四周留白:容纳卡片投影,并透出窗口底层的仿桌面背景
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
pointer-events: none; // Allow clicking through when hidden
|
||||
transform: scale(0.96);
|
||||
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transform: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&.static {
|
||||
position: relative !important;
|
||||
width: calc(100% - 4px) !important; // Leave 2px margin for anti-aliasing saftey
|
||||
height: auto !important; // Fits content
|
||||
min-height: 0;
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
transform: none !important;
|
||||
margin: 2px !important; // 2px centered margin
|
||||
border-radius: 12px !important; // Rounded corners
|
||||
// 玻璃材质与文字颜色均由 useNotificationAdaptiveTheme 按桌面背景的实际对比度
|
||||
// 无级计算后写入 <html> 的 --noti-* 变量(纱层方向/浓度、分区连续文字色、光晕强度);
|
||||
// 此处仅提供首帧采样前的浅色默认值。投影收敛到留白宽度内,避免被窗口矩形裁剪出脏边
|
||||
--liquid-glass-shadow: var(--noti-shadow, 0 0 0 1px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.22));
|
||||
--liquid-glass-tint: var(--noti-tint, rgba(255, 255, 255, 0.1));
|
||||
|
||||
|
||||
// Disable backdrop filter
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
|
||||
// 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <html> 上的主题属性
|
||||
background: #ffffff;
|
||||
color: #3d3d3d;
|
||||
--text-primary: #3d3d3d;
|
||||
--text-secondary: #666666;
|
||||
--text-tertiary: #999999;
|
||||
--border-light: rgba(0, 0, 0, 0.08);
|
||||
|
||||
// 深色模式覆盖
|
||||
[data-mode="dark"] & {
|
||||
background: var(--bg-secondary-solid, #282420);
|
||||
color: var(--text-primary, #F0EEE9);
|
||||
--text-primary: #F0EEE9;
|
||||
--text-secondary: #b3b0aa;
|
||||
--text-tertiary: #807d78;
|
||||
--border-light: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
box-shadow: none !important; // NO SHADOW
|
||||
border: 1px solid var(--border-light);
|
||||
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
padding-right: 32px; // Make space for close button
|
||||
box-sizing: border-box;
|
||||
|
||||
// Force close button to be visible but transparent background
|
||||
.notification-close {
|
||||
opacity: 1 !important;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: transparent !important; // Transparent per user request
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1) !important; // Subtle hover effect
|
||||
}
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
top: 24px; // Match padding
|
||||
right: 40px; // Left of close button (12px + 20px + 8px)
|
||||
}
|
||||
}
|
||||
|
||||
// Position variants
|
||||
&.bottom-right {
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
transform: translate(0, 20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.top-right {
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
transform: translate(0, -20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.bottom-left {
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
transform: translate(0, 20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.top-left {
|
||||
top: 24px;
|
||||
left: 24px;
|
||||
transform: translate(0, -20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.top-center {
|
||||
top: 24px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
|
||||
// 灵动岛样式
|
||||
border-radius: 40px !important;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&.static {
|
||||
border-radius: 40px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
||||
}
|
||||
color: var(--noti-title-color, #2c2c2c);
|
||||
|
||||
.notification-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding-right: 14px; // 给右上角关闭按钮留出空间
|
||||
}
|
||||
|
||||
.notification-avatar {
|
||||
@@ -180,29 +48,34 @@
|
||||
.notification-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
color: var(--noti-title-color, #2c2c2c);
|
||||
text-shadow: var(--noti-title-halo, none);
|
||||
transition: color 0.08s linear, text-shadow 0.08s linear;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%; // 允许缩放
|
||||
flex: 1; // 占据剩余空间
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0; // 关键:允许 flex 子项收缩到内容以下
|
||||
margin-right: 60px; // Make space for absolute time + close button
|
||||
margin-right: 56px; // 给绝对定位的时间 + 关闭按钮留出空间
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
color: var(--noti-title-tertiary, #7a7a7a);
|
||||
transition: color 0.08s linear;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 36px; // Left of close button (8px + 20px + 8px)
|
||||
top: 14px;
|
||||
right: 40px; // 关闭按钮左侧
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
color: var(--noti-body-color, #4d4d4d);
|
||||
text-shadow: var(--noti-body-halo, none);
|
||||
transition: color 0.08s linear, text-shadow 0.08s linear;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -215,8 +88,8 @@
|
||||
|
||||
.notification-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
@@ -225,18 +98,14 @@
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
color: var(--noti-title-tertiary, #7a7a7a);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
z-index: 2;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
color: var(--noti-title-color, #2c2c2c);
|
||||
background: var(--noti-close-hover-bg, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .notification-close {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Avatar } from './Avatar'
|
||||
import LiquidGlass, { type LiquidGlassBackdropImage } from './LiquidGlass'
|
||||
import { renderTextWithEmoji } from '../utils/renderTextWithEmoji'
|
||||
import './NotificationToast.scss'
|
||||
|
||||
export interface NotificationData {
|
||||
@@ -21,22 +22,43 @@ interface NotificationToastProps {
|
||||
onClose: () => void
|
||||
onClick: (data: NotificationData) => void
|
||||
duration?: number
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||
isStatic?: boolean
|
||||
initialVisible?: boolean
|
||||
/** 回退管线的屏幕几何信息(含可选桌面快照),玻璃用它对齐折射采样 */
|
||||
backdropImage?: LiquidGlassBackdropImage
|
||||
/** 回退管线的屏幕实时视频流,就绪后折射随桌面实时更新;原生模式下为 null */
|
||||
backdropStream?: MediaStream | null
|
||||
/** 原生玻璃模式(Windows):折射由主进程原生面板渲染,卡片背景透明 */
|
||||
nativeBackdrop?: boolean
|
||||
/** 退场动画开始的一刻触发(原生模式用来提前淡出原生面板) */
|
||||
onHideStart?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知卡片:始终渲染为全局液态玻璃(LiquidGlass 兼容层),在独立通知窗口内展示。
|
||||
* 折射背景分两路:Windows 原生模式由主进程的原生面板在窗口下方实时渲染
|
||||
* (卡片内完全透明),原生不可用时回退为渲染层自采的桌面视频流
|
||||
*/
|
||||
export function NotificationToast({
|
||||
data,
|
||||
onClose,
|
||||
onClick,
|
||||
duration = 5000,
|
||||
position = 'top-right',
|
||||
isStatic = false,
|
||||
initialVisible = false
|
||||
initialVisible = false,
|
||||
backdropImage,
|
||||
backdropStream,
|
||||
nativeBackdrop = false,
|
||||
onHideStart
|
||||
}: NotificationToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(initialVisible)
|
||||
const [currentData, setCurrentData] = useState<NotificationData | null>(null)
|
||||
const onHideStartRef = useRef(onHideStart)
|
||||
onHideStartRef.current = onHideStart
|
||||
|
||||
// 任何路径(超时/关闭/点击)触发的退场都在动画开始的一刻通知外层
|
||||
const beginHide = () => {
|
||||
setIsVisible(false)
|
||||
onHideStartRef.current?.()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -44,7 +66,7 @@ export function NotificationToast({
|
||||
setIsVisible(true)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
beginHide()
|
||||
// clean up data after animation
|
||||
setTimeout(onClose, 300)
|
||||
}, duration)
|
||||
@@ -53,59 +75,63 @@ export function NotificationToast({
|
||||
} else {
|
||||
setIsVisible(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data, duration, onClose])
|
||||
|
||||
if (!currentData) return null
|
||||
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setIsVisible(false)
|
||||
beginHide()
|
||||
setTimeout(onClose, 300)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
setIsVisible(false)
|
||||
beginHide()
|
||||
setTimeout(() => {
|
||||
onClose()
|
||||
onClick(currentData)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`notification-toast-container ${position} ${isVisible ? 'visible' : ''} ${isStatic ? 'static' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="notification-content">
|
||||
<div className="notification-avatar">
|
||||
<Avatar
|
||||
src={currentData.avatarUrl}
|
||||
name={currentData.title}
|
||||
size={40}
|
||||
/>
|
||||
</div>
|
||||
<div className="notification-text">
|
||||
<div className="notification-header">
|
||||
<span className="notification-title">{currentData.title}</span>
|
||||
<span className="notification-time">
|
||||
{new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
return (
|
||||
<div className={`notification-toast-container ${isVisible ? 'visible' : ''}`}>
|
||||
<LiquidGlass
|
||||
cornerRadius={16}
|
||||
padding="14px 16px"
|
||||
blurAmount={0}
|
||||
saturation={140}
|
||||
displacementScale={70}
|
||||
aberrationIntensity={1}
|
||||
backdropImage={backdropImage}
|
||||
backdropStream={backdropStream}
|
||||
nativeBackdrop={nativeBackdrop}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="notification-content">
|
||||
<div className="notification-avatar">
|
||||
<Avatar
|
||||
src={currentData.avatarUrl}
|
||||
name={currentData.title}
|
||||
size={40}
|
||||
/>
|
||||
</div>
|
||||
<div className="notification-body">
|
||||
{currentData.content}
|
||||
<div className="notification-text">
|
||||
<div className="notification-header">
|
||||
<span className="notification-title">{currentData.title}</span>
|
||||
<span className="notification-time">
|
||||
{new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="notification-body">
|
||||
{renderTextWithEmoji(currentData.content, 17)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="notification-close" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<button className="notification-close" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isStatic) {
|
||||
return content
|
||||
}
|
||||
|
||||
// Portal to document.body to ensure it's on top
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-rea
|
||||
import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
|
||||
import { Avatar } from '../Avatar'
|
||||
import { SnsMediaGrid } from './SnsMediaGrid'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
import { renderTextWithEmoji } from '../../utils/renderTextWithEmoji'
|
||||
|
||||
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
|
||||
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
|
||||
@@ -437,23 +437,6 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
})
|
||||
}
|
||||
|
||||
// 解析微信表情
|
||||
const renderTextWithEmoji = (text: string) => {
|
||||
if (!text) return text
|
||||
const parts = text.split(/\[(.*?)\]/g)
|
||||
return parts.map((part, index) => {
|
||||
if (index % 2 === 1) {
|
||||
// @ts-ignore
|
||||
const path = getEmojiPath(part as any)
|
||||
if (path) {
|
||||
return <img key={index} src={`${import.meta.env.BASE_URL}${path}`} alt={`[${part}]`} className="inline-emoji" style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }} />
|
||||
}
|
||||
return `[${part}]`
|
||||
}
|
||||
return part
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (deleting || dbDeleted) return
|
||||
|
||||
@@ -12,20 +12,23 @@
|
||||
z-index: 9999;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
|
||||
// 液态玻璃外壳:宽度与入场动画在玻璃根节点上
|
||||
.update-dialog-glass {
|
||||
width: min(680px, calc(100vw - 48px));
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.update-dialog {
|
||||
width: 680px;
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
width: 100%;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* Top Section (White/Gradient) */
|
||||
/* Top Section (White/Gradient),半透明以透出玻璃折射 */
|
||||
.dialog-header {
|
||||
background: var(--bg-primary, #ffffff);
|
||||
background: color-mix(in srgb, var(--bg-primary, #ffffff) 78%, transparent);
|
||||
padding: 40px 20px 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -72,9 +75,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Content Section (Light Gray) */
|
||||
/* Content Section (Light Gray),半透明以透出玻璃折射 */
|
||||
.dialog-content {
|
||||
background: var(--bg-tertiary, #f2f2f2);
|
||||
background: color-mix(in srgb, var(--bg-tertiary, #f2f2f2) 72%, transparent);
|
||||
padding: 24px 40px 40px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Quote, X } from 'lucide-react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './UpdateDialog.scss'
|
||||
|
||||
interface UpdateInfo {
|
||||
@@ -70,7 +71,13 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
||||
|
||||
return (
|
||||
<div className="update-dialog-overlay">
|
||||
<div className="update-dialog">
|
||||
<LiquidGlass
|
||||
className="update-dialog-glass"
|
||||
cornerRadius={24}
|
||||
displacementScale={40}
|
||||
aberrationIntensity={1.5}
|
||||
>
|
||||
<div className="update-dialog">
|
||||
{!isDownloading && !isMandatory && (
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={20} />
|
||||
@@ -135,7 +142,8 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,29 +8,17 @@
|
||||
animation: capsuleSlideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
.capsule-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
// 视觉背景(毛玻璃、边框、阴影)由 LiquidGlass 提供,这里只负责内容布局
|
||||
.capsule-content {
|
||||
background: var(--bg-primary);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
padding: 8px 18px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: background 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
@@ -112,11 +100,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// State Modifiers
|
||||
// State Modifiers(改为半透明色调叠加在玻璃之上)
|
||||
&.state-available {
|
||||
.capsule-content {
|
||||
background: var(--primary);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: color-mix(in srgb, var(--primary) 82%, transparent);
|
||||
color: white;
|
||||
|
||||
.icon-wrapper {
|
||||
@@ -139,16 +126,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.state-downloading {
|
||||
.capsule-content {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.state-error {
|
||||
.capsule-content {
|
||||
background: #fff1f0;
|
||||
border-color: #ffa39e;
|
||||
background: color-mix(in srgb, #ff4d4f 14%, transparent);
|
||||
|
||||
.icon-wrapper {
|
||||
color: #ff4d4f;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { Download, X, AlertCircle, Info } from 'lucide-react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './UpdateProgressCapsule.scss'
|
||||
|
||||
const UpdateProgressCapsule: React.FC = () => {
|
||||
@@ -102,15 +103,22 @@ const UpdateProgressCapsule: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={capsuleClass} onClick={() => setShowUpdateDialog(true)}>
|
||||
<div className="capsule-content">
|
||||
{content}
|
||||
{!isDownloading && (
|
||||
<button className="capsule-close" onClick={handleClose}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={capsuleClass}>
|
||||
<LiquidGlass
|
||||
cornerRadius={24}
|
||||
displacementScale={48}
|
||||
aberrationIntensity={1.5}
|
||||
onClick={() => setShowUpdateDialog(true)}
|
||||
>
|
||||
<div className="capsule-content">
|
||||
{content}
|
||||
{!isDownloading && (
|
||||
<button className="capsule-close" onClick={handleClose}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,16 +13,20 @@
|
||||
animation: windowCloseDialogFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.window-close-dialog {
|
||||
// 液态玻璃外壳:宽度与入场动画在玻璃根节点上
|
||||
.window-close-dialog-glass {
|
||||
width: min(560px, 100%);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||
animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.window-close-dialog {
|
||||
width: 100%;
|
||||
border-radius: 24px;
|
||||
// 半透明背景透出玻璃折射,边框与阴影由 LiquidGlass 提供
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 94%, white 6%) 0%, var(--bg-primary) 100%);
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32);
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 72%, transparent) 0%, color-mix(in srgb, var(--bg-primary) 84%, transparent) 100%);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.window-close-dialog-header {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Minimize2, Power, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import LiquidGlass from './LiquidGlass'
|
||||
import './WindowCloseDialog.scss'
|
||||
|
||||
interface WindowCloseDialogProps {
|
||||
@@ -39,13 +40,19 @@ export default function WindowCloseDialog({
|
||||
|
||||
return (
|
||||
<div className="window-close-dialog-overlay" onClick={onCancel}>
|
||||
<div
|
||||
className="window-close-dialog"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="window-close-dialog-title"
|
||||
<LiquidGlass
|
||||
className="window-close-dialog-glass"
|
||||
cornerRadius={24}
|
||||
displacementScale={40}
|
||||
aberrationIntensity={1.5}
|
||||
>
|
||||
<div
|
||||
className="window-close-dialog"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="window-close-dialog-title"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="window-close-dialog-close"
|
||||
@@ -118,7 +125,8 @@ export default function WindowCloseDialog({
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-28
@@ -9,7 +9,7 @@ import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batc
|
||||
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
||||
import type { ChatRecordItem, ChatSession, Message } from '../types/models'
|
||||
import type { GroupSummaryRecord, GroupSummaryRecordSummary } from '../types/electron'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
import { renderTextWithEmoji } from '../utils/renderTextWithEmoji'
|
||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
||||
@@ -10985,33 +10985,6 @@ function MessageBubble({
|
||||
return content.replace(/^[a-zA-Z0-9]+@openim:\n?/, '')
|
||||
}, [])
|
||||
|
||||
// 解析混合文本和表情
|
||||
const renderTextWithEmoji = useCallback((text: string) => {
|
||||
if (!text) return text
|
||||
const parts = text.split(/\[(.*?)\]/g)
|
||||
return parts.map((part, index) => {
|
||||
// 奇数索引是捕获组的内容(即括号内的文字)
|
||||
if (index % 2 === 1) {
|
||||
// @ts-ignore
|
||||
const path = getEmojiPath(part as any)
|
||||
if (path) {
|
||||
// path 例如 'assets/face/微笑.png',需要添加 base 前缀
|
||||
return (
|
||||
<img
|
||||
key={index}
|
||||
src={`${import.meta.env.BASE_URL}${path}`}
|
||||
alt={`[${part}]`}
|
||||
className="inline-emoji"
|
||||
style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return `[${part}]`
|
||||
}
|
||||
return part
|
||||
})
|
||||
}, [])
|
||||
|
||||
const cleanedParsedContent = useMemo(
|
||||
() => cleanMessageContent(message.parsedContent || ''),
|
||||
[cleanMessageContent, message.parsedContent]
|
||||
|
||||
@@ -44,7 +44,7 @@ export type TextExportFormat =
|
||||
| 'weclone'
|
||||
| 'sql'
|
||||
|
||||
export type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson'
|
||||
export type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson' | 'markdown'
|
||||
|
||||
export type ContactsSortKey = 'messageCount' | 'latestMessageTime'
|
||||
export type ContactsSortOrder = 'desc' | 'asc'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// 卡片动画可以安全使用 opacity:回退管线的玻璃像素源自含在卡片内(视频流画布),
|
||||
// 原生模式的折射面板在窗口之下、由主进程独立同步淡入淡出,均不受卡片透明度影响
|
||||
@keyframes noti-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.96);
|
||||
transform: translateY(-24px) scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
@@ -22,29 +24,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 旧卡片原地缩小淡出:不做位移,避免从新卡片背后探出边缘
|
||||
@keyframes noti-exit {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
filter: blur(0);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.92) translateY(4px);
|
||||
filter: blur(2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes noti-exit-center {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px) scale(0.7);
|
||||
transform: scale(0.94);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,18 +45,12 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#notification-root {
|
||||
// Ensure the container allows 3D transforms
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
#notification-current {
|
||||
// New notification slides in
|
||||
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
will-change: transform, opacity;
|
||||
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
&.anim-center {
|
||||
animation: noti-enter-center 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
animation: noti-enter-center 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +58,7 @@ body {
|
||||
// Old notification scales out
|
||||
animation: noti-exit 0.35s cubic-bezier(0.33, 1, 0.68, 1) forwards;
|
||||
transform-origin: center top;
|
||||
will-change: transform, opacity, filter;
|
||||
|
||||
// Ensure it stays behind
|
||||
z-index: 0 !important;
|
||||
|
||||
&.anim-center {
|
||||
animation: noti-exit-center 0.5s cubic-bezier(0.33, 1, 0.68, 1) forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,40 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { NotificationToast, type NotificationData } from '../components/NotificationToast'
|
||||
import type { LiquidGlassBackdropImage } from '../components/LiquidGlass'
|
||||
import {
|
||||
useNotificationAdaptiveTheme,
|
||||
useNotificationNativeAdaptiveTheme,
|
||||
getLayoutRect,
|
||||
NATIVE_BAND_IDS
|
||||
} from './useNotificationAdaptiveTheme'
|
||||
import '../components/NotificationToast.scss'
|
||||
import './NotificationWindow.scss'
|
||||
|
||||
/** 与 NotificationToast 传给 LiquidGlass 的参数保持一致(原生面板需要同一套值) */
|
||||
const GLASS_PARAMS = { cornerRadius: 16, blurSigma: 2, displacementScale: 70, aberrationIntensity: 1, saturation: 140 }
|
||||
|
||||
export default function NotificationWindow() {
|
||||
const [notification, setNotification] = useState<NotificationData | null>(null)
|
||||
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
||||
const [position, setPosition] = useState<string>('top-right')
|
||||
// 主进程随通知下发的屏幕几何信息(尺寸 + 窗口坐标)。
|
||||
// 回退管线(原生玻璃不可用时)用它把桌面视频流与屏幕逐像素对齐——
|
||||
// Electron 透明窗口内 backdrop-filter 采不到桌面,渲染层自采视频流是回退路径下唯一的折射来源
|
||||
const [backdrop, setBackdrop] = useState<LiquidGlassBackdropImage | undefined>(undefined)
|
||||
// 原生玻璃模式(Windows):折射由主进程的原生面板在窗口下方提供,
|
||||
// 渲染层不开视频流、不渲染折射画布,只负责上报卡片几何与内容层
|
||||
const [nativeBackdrop, setNativeBackdrop] = useState(false)
|
||||
// 屏幕实时视频流(仅回退管线使用):通知展示期间开启,玻璃折射随桌面实时更新;
|
||||
// 隐藏后释放采集。原生模式下始终为 null
|
||||
const [stream, setStream] = useState<MediaStream | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const sourceIdRef = useRef<string | null>(null)
|
||||
|
||||
// We need a ref to access the current notification inside the callback
|
||||
// without satisfying the dependency array which would recreate the listener
|
||||
// Actually, setNotification(prev => ...) pattern is better, but we need the VALUE of current to set as prev.
|
||||
// So we use setNotification callback: setNotification(current => { ... return newNode })
|
||||
// But we need to update TWO states.
|
||||
// So we use a ref to track "current displayed" for the event handler.
|
||||
// Or just use functional updates, but we need to setPrev(current).
|
||||
|
||||
// 事件回调里需要读取"当前展示中"的通知作为过渡的旧通知,用 ref 避免重建监听
|
||||
const notificationRef = useRef<NotificationData | null>(null)
|
||||
// 上次上报的窗口尺寸:重复上报会触发主进程 setSize,
|
||||
// 可见状态下反复设置尺寸会让 DWM 短暂拉伸旧帧缓冲,闪出一圈幽灵轮廓
|
||||
const lastSizeRef = useRef<{ width: number; height: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
notificationRef.current = notification
|
||||
@@ -24,7 +42,6 @@ export default function NotificationWindow() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleShow = (_event: any, data: any) => {
|
||||
// data: { title, content, avatarUrl, sessionId }
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const newNoti: NotificationData = {
|
||||
id: `noti_${timestamp}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
@@ -38,12 +55,20 @@ export default function NotificationWindow() {
|
||||
avatarUrl: data.avatarUrl
|
||||
}
|
||||
|
||||
// 获取位置配置
|
||||
if (data.position) {
|
||||
setPosition(data.position)
|
||||
}
|
||||
if (data.backdrop) {
|
||||
setBackdrop({
|
||||
width: data.backdrop.width,
|
||||
height: data.backdrop.height,
|
||||
screenX: data.backdrop.winX,
|
||||
screenY: data.backdrop.winY
|
||||
})
|
||||
setNativeBackdrop(Boolean(data.backdrop.native))
|
||||
sourceIdRef.current = data.backdrop.sourceId ?? null
|
||||
}
|
||||
|
||||
// Set previous to current (ref)
|
||||
if (notificationRef.current) {
|
||||
setPrevNotification(notificationRef.current)
|
||||
}
|
||||
@@ -67,6 +92,129 @@ export default function NotificationWindow() {
|
||||
}
|
||||
}, [prevNotification])
|
||||
|
||||
// 分区无级自适应:整卡驱动玻璃纱层方向与浓度,标题行/正文按各自背后区域
|
||||
// 的实际对比度连续取色(算法详见 useNotificationAdaptiveTheme)。
|
||||
// 流模式从视频帧采样;原生模式吃原生面板回读的亮度带事件
|
||||
useNotificationAdaptiveTheme(stream, backdrop)
|
||||
useNotificationNativeAdaptiveTheme(nativeBackdrop)
|
||||
|
||||
const stopStream = () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop())
|
||||
streamRef.current = null
|
||||
setStream(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 回退管线(原生玻璃不可用时):通知展示期间开启屏幕采集流
|
||||
// (通知窗口已被 content protection 排除,不会拍到自己);
|
||||
// 全部隐藏后立即停止采集释放资源。原生模式下 sourceId 为空,永不触发
|
||||
useEffect(() => {
|
||||
const active = Boolean(notification || prevNotification)
|
||||
|
||||
if (active && !streamRef.current && sourceIdRef.current && !nativeBackdrop) {
|
||||
let cancelled = false
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const captureW = Math.round(window.screen.width * dpr)
|
||||
const captureH = Math.round(window.screen.height * dpr)
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
// Electron 专有约束:按 sourceId 采集屏幕,无需用户手势
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: sourceIdRef.current,
|
||||
minWidth: captureW,
|
||||
maxWidth: captureW,
|
||||
minHeight: captureH,
|
||||
maxHeight: captureH,
|
||||
maxFrameRate: 60
|
||||
}
|
||||
}
|
||||
} as unknown as MediaStreamConstraints).then(mediaStream => {
|
||||
if (cancelled) {
|
||||
mediaStream.getTracks().forEach(track => track.stop())
|
||||
return
|
||||
}
|
||||
// 提示采集调度器优先保帧率而非画质(本地渲染无编码环节,无画质代价)
|
||||
mediaStream.getVideoTracks().forEach(track => { track.contentHint = 'motion' })
|
||||
streamRef.current = mediaStream
|
||||
setStream(mediaStream)
|
||||
}).catch(error => {
|
||||
console.warn('[NotificationWindow] 屏幕流获取失败,折射退化为兜底材质:', error)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!active) stopStream()
|
||||
}, [notification, prevNotification, nativeBackdrop])
|
||||
|
||||
// 原生玻璃模式:卡片挂载后上报实测几何(窗口本地 CSS 像素 + 卡片本地亮度带),
|
||||
// 主进程据此创建/复用窗口下方的原生面板;参数与 LiquidGlass 的视觉参数一致
|
||||
useEffect(() => {
|
||||
if (!nativeBackdrop || !notification) return
|
||||
// 同一条通知内的重复上报去重(双 rAF 首测 + 120ms 复测几何通常一致):
|
||||
// 跳过后主进程不会白做 setBounds/setLumaBands/anchor 原生调用,
|
||||
// 也避免 setLumaBands 触发的一次带 GPU 同步等待的亮度补采。
|
||||
// 局部变量随 effect 重建,新通知(即使几何相同)必然重新上报以驱动面板 show
|
||||
let lastSent = ''
|
||||
const report = () => {
|
||||
const host = document.getElementById('notification-current')
|
||||
const glassEl = host?.querySelector<HTMLElement>('.liquid-glass')
|
||||
if (!glassEl) return
|
||||
const card = getLayoutRect(glassEl)
|
||||
if (card.width < 1 || card.height < 1) return
|
||||
const bands: Array<{ id: number; x: number; y: number; width: number; height: number }> = [
|
||||
{ id: NATIVE_BAND_IDS.card, x: 0, y: 0, width: card.width, height: card.height }
|
||||
]
|
||||
const bandEls: Array<[number, HTMLElement | null]> = [
|
||||
[NATIVE_BAND_IDS.title, host?.querySelector<HTMLElement>('.notification-header') ?? null],
|
||||
[NATIVE_BAND_IDS.body, host?.querySelector<HTMLElement>('.notification-body') ?? null]
|
||||
]
|
||||
for (const [id, el] of bandEls) {
|
||||
if (!el) continue
|
||||
const rect = getLayoutRect(el)
|
||||
bands.push({ id, x: rect.left - card.left, y: rect.top - card.top, width: rect.width, height: rect.height })
|
||||
}
|
||||
const payload = {
|
||||
card: { x: card.left, y: card.top, width: card.width, height: card.height },
|
||||
bands,
|
||||
// CSS px → 物理 px 的换算系数(devicePixelRatio 已含页面缩放,
|
||||
// 主进程不能只用显示器 scaleFactor:缩放会随 file:// 域持久化)
|
||||
dpr: window.devicePixelRatio || 1,
|
||||
...GLASS_PARAMS
|
||||
}
|
||||
const key = JSON.stringify(payload)
|
||||
if (key === lastSent) return
|
||||
lastSent = key
|
||||
window.electronAPI?.notification?.glassRect?.(payload)
|
||||
}
|
||||
// 双 rAF 等首次布局落定后测量;120ms 复测一次,覆盖表情图/字体就绪导致的高度变化
|
||||
let raf2 = 0
|
||||
const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(report) })
|
||||
const timer = setTimeout(report, 120)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1)
|
||||
cancelAnimationFrame(raf2)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [nativeBackdrop, notification, position])
|
||||
|
||||
// 兜底释放:窗口被主进程隐藏或组件卸载时,无论上层状态如何都立即停止采集,
|
||||
// 确保任何路径下都不会在后台持续占用采集管线
|
||||
useEffect(() => {
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') stopStream()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
stopStream()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClose = () => {
|
||||
setNotification(null)
|
||||
setPrevNotification(null)
|
||||
@@ -90,96 +238,93 @@ export default function NotificationWindow() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Measure only if we have a notification (current or prev)
|
||||
if (!notification && !prevNotification) return
|
||||
|
||||
// Prefer measuring the NEW one
|
||||
const targetId = notification ? 'notification-current' : 'notification-prev'
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
// Find the wrapper of the content
|
||||
// Since we wrap them, we should measure the content inside
|
||||
// But getting root is easier if size is set by relative child
|
||||
// 窗口必须精确贴合内容高度,多余区域会拦截桌面点击
|
||||
const root = document.getElementById('notification-root')
|
||||
if (root) {
|
||||
const height = root.offsetHeight
|
||||
const width = 344
|
||||
if (window.electronAPI?.notification?.resize) {
|
||||
const finalHeight = Math.min(height + 4, 300)
|
||||
window.electronAPI.notification.resize(width, finalHeight)
|
||||
}
|
||||
if (root && window.electronAPI?.notification?.resize) {
|
||||
const width = position === 'top-center' ? 280 : 344
|
||||
const height = Math.min(Math.ceil(root.getBoundingClientRect().height), 300)
|
||||
const last = lastSizeRef.current
|
||||
if (last && last.width === width && last.height === height) return
|
||||
lastSizeRef.current = { width, height }
|
||||
window.electronAPI.notification.resize(width, height)
|
||||
}
|
||||
}, 50)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [notification, prevNotification])
|
||||
}, [notification, prevNotification, position])
|
||||
|
||||
if (!notification && !prevNotification) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
id="notification-root"
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: 'auto',
|
||||
minHeight: '10px',
|
||||
background: 'transparent',
|
||||
position: 'relative', // Context for absolute children
|
||||
overflow: 'hidden', // Prevent scrollbars during transition
|
||||
padding: '2px', // Margin safe
|
||||
boxSizing: 'border-box'
|
||||
}}>
|
||||
<>
|
||||
<div
|
||||
id="notification-root"
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: 'auto',
|
||||
background: 'transparent',
|
||||
position: 'relative', // Context for absolute children
|
||||
overflow: 'hidden' // Prevent scrollbars during transition
|
||||
}}>
|
||||
|
||||
{/* Previous Notification (Background / Fading Out) */}
|
||||
{prevNotification && (
|
||||
<div
|
||||
id="notification-prev"
|
||||
key={prevNotification.id}
|
||||
className={position === 'top-center' ? 'anim-center' : ''}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2, // Match padding
|
||||
left: 2,
|
||||
width: 'calc(100% - 4px)', // Match width logic
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none' // Disable interaction on old one
|
||||
}}
|
||||
>
|
||||
<NotificationToast
|
||||
{/* Previous Notification (Background / Fading Out) */}
|
||||
{prevNotification && (
|
||||
<div
|
||||
id="notification-prev"
|
||||
key={prevNotification.id}
|
||||
data={prevNotification}
|
||||
onClose={() => { }} // No-op for background item
|
||||
onClick={() => { }}
|
||||
position={position as any}
|
||||
isStatic={true}
|
||||
initialVisible={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
className={position === 'top-center' ? 'anim-center' : ''}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none' // Disable interaction on old one
|
||||
}}
|
||||
>
|
||||
<NotificationToast
|
||||
key={prevNotification.id}
|
||||
data={prevNotification}
|
||||
onClose={() => { }} // No-op for background item
|
||||
onClick={() => { }}
|
||||
initialVisible={true}
|
||||
backdropImage={backdrop}
|
||||
backdropStream={stream}
|
||||
nativeBackdrop={nativeBackdrop}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Notification (Foreground / Fading In) */}
|
||||
{notification && (
|
||||
<div
|
||||
id="notification-current"
|
||||
key={notification.id}
|
||||
className={position === 'top-center' ? 'anim-center' : ''}
|
||||
style={{
|
||||
position: 'relative', // Takes up space
|
||||
zIndex: 2,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<NotificationToast
|
||||
key={notification.id} // Ensure remount for animation
|
||||
data={notification}
|
||||
onClose={handleClose}
|
||||
onClick={handleClick}
|
||||
position={position as any}
|
||||
isStatic={true}
|
||||
initialVisible={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Current Notification (Foreground / Fading In) */}
|
||||
{notification && (
|
||||
<div
|
||||
id="notification-current"
|
||||
key={notification.id}
|
||||
className={position === 'top-center' ? 'anim-center' : ''}
|
||||
style={{
|
||||
position: 'relative', // Takes up space
|
||||
zIndex: 2,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<NotificationToast
|
||||
key={notification.id} // Ensure remount for animation
|
||||
data={notification}
|
||||
onClose={handleClose}
|
||||
onClick={handleClick}
|
||||
initialVisible={true}
|
||||
backdropImage={backdrop}
|
||||
backdropStream={stream}
|
||||
nativeBackdrop={nativeBackdrop}
|
||||
// 退场动画开始的一刻同步淡出原生面板(与卡片 0.3s 渐隐节奏匹配)
|
||||
onHideStart={nativeBackdrop ? () => window.electronAPI?.notification?.glassHide?.() : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+12
-3
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2, Pause, Play, Square } from 'lucide-react'
|
||||
import { RefreshCw, Search, X, Download, FolderOpen, FileCode, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2, Pause, Play, Square } from 'lucide-react'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import './SnsPage.scss'
|
||||
import { SnsPost } from '../types/sns'
|
||||
@@ -86,7 +86,7 @@ interface SnsExportResult {
|
||||
interface SnsExportRequest {
|
||||
taskId: string
|
||||
outputDir: string
|
||||
format: 'json' | 'html' | 'arkmejson'
|
||||
format: 'json' | 'html' | 'arkmejson' | 'markdown'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportImages: boolean
|
||||
@@ -204,7 +204,7 @@ export default function SnsPage() {
|
||||
// 导出相关状态
|
||||
const [showExportDialog, setShowExportDialog] = useState(false)
|
||||
const [exportScope, setExportScope] = useState<SnsExportScope>({ kind: 'all' })
|
||||
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
|
||||
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson' | 'markdown'>('html')
|
||||
const [exportFolder, setExportFolder] = useState('')
|
||||
const [exportImages, setExportImages] = useState(false)
|
||||
const [exportLivePhotos, setExportLivePhotos] = useState(false)
|
||||
@@ -2406,6 +2406,15 @@ export default function SnsPage() {
|
||||
<span>HTML</span>
|
||||
<small>浏览器可直接查看</small>
|
||||
</button>
|
||||
<button
|
||||
className={`format-option ${exportFormat === 'markdown' ? 'active' : ''}`}
|
||||
onClick={() => setExportFormat('markdown')}
|
||||
disabled={isExportLocked}
|
||||
>
|
||||
<FileCode size={20} />
|
||||
<span>Markdown</span>
|
||||
<small>纯文本,通用性强</small>
|
||||
</button>
|
||||
<button
|
||||
className={`format-option ${exportFormat === 'json' ? 'active' : ''}`}
|
||||
onClick={() => setExportFormat('json')}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { LiquidGlassBackdropImage } from '../components/LiquidGlass'
|
||||
|
||||
/**
|
||||
* 通知卡片的"无级自适应反色":对比度驱动,替代旧的亮度阈值二值反色。
|
||||
*
|
||||
* 每次采样:
|
||||
* 1. 取整卡/标题行/正文三个区域的背景均值色与明暗分位数。采样来源二选一:
|
||||
* - Chromium 桌面视频流(跨平台回退管线):48×16 缩略图 drawImage 采样
|
||||
* - 原生玻璃面板(Windows):原生层重绘的同一帧从模糊纹理回读并推送
|
||||
* (notification:luma 事件,上限 ~60Hz,桌面静止时不推送)
|
||||
* 2. 按液态玻璃的实际加工链(saturate 140% → 纱层 alpha 合成)推算玻璃内的真实背景色,
|
||||
* 保证判定依据与用户看到的像素一致(裸桌面色与玻璃内呈现色可能差一个档位)
|
||||
* 3. 材质先补偿:纱层方向优先贴合桌面明暗(浅底白纱、深底黑纱,滞回防抖),
|
||||
* 仅当贴合方向即使最大补偿也无法达标而反方向可行时才反转;
|
||||
* 纱层浓度取"宽松锚点文字色恰好达标"的最小 alpha——中间调背景下玻璃自动变浊换取可读性
|
||||
* 4. 文字后升级:在"宽松锚点(设计稿层级灰阶)↔ 极端锚点(近黑/纯白)"之间连续插值,
|
||||
* 取达到目标对比度的最浅一档;背景充裕时保持原色,不足时无级加深/提亮
|
||||
* 5. 光晕兜底:按最不利分位数背景的对比度缺口连续调节 text-shadow 强度,
|
||||
* 覆盖同一区域内明暗混杂、均值失真的场景
|
||||
*
|
||||
* 结果写入 <html> 内联的 --noti-* 变量,由 NotificationToast.scss 消费
|
||||
*/
|
||||
|
||||
type RGB = readonly [number, number, number]
|
||||
|
||||
interface TextAnchor {
|
||||
/** 对比度充裕时使用的设计稿原色(保留视觉层级) */
|
||||
relaxed: RGB
|
||||
/** 对比度不足时插值逼近的极端色 */
|
||||
strong: RGB
|
||||
}
|
||||
|
||||
export interface BandSample {
|
||||
mean: RGB
|
||||
/** 区域内 luma 的 p15 / p85(gamma 域 0-255),用于评估明暗混杂时的最不利背景 */
|
||||
darkTail: number
|
||||
lightTail: number
|
||||
}
|
||||
|
||||
const SAMPLE_W = 48
|
||||
const SAMPLE_H = 16
|
||||
/** 相邻两次采样的最小间隔:采样由视频出帧驱动,此值防止背景高频变化时空转(~30Hz) */
|
||||
const MIN_SAMPLE_GAP_MS = 33
|
||||
/** 兜底轮询间隔:桌面静止(视频不出帧)但通知自身布局变化时仍能刷新 */
|
||||
const FALLBACK_INTERVAL_MS = 250
|
||||
/** 与 NotificationToast 传给 LiquidGlass 的 saturation=140 保持一致 */
|
||||
const GLASS_SATURATION = 1.4
|
||||
/** WCAG AA 小字号文本的目标对比度 */
|
||||
const TARGET_CONTRAST = 4.5
|
||||
/** 极性切换所需的对比度优势(滞回),避免临界背景来回翻转 */
|
||||
const POLARITY_MARGIN = 0.5
|
||||
/** 小幅波动的指数平滑系数(0~1,越大跟随越快) */
|
||||
const EMA_FACTOR = 0.6
|
||||
/** 均值 luma 突变超过此值视为场景切换:跳过平滑直接采用新值 */
|
||||
const SNAP_LUMA_DELTA = 24
|
||||
/** 采样区域向外扩张:文字边缘的背景同样影响可读性 */
|
||||
const RECT_BLEED = 4
|
||||
|
||||
const WHITE_VEIL: RGB = [255, 255, 255]
|
||||
const DARK_VEIL: RGB = [22, 20, 18]
|
||||
/** 纱层 alpha 求解范围:下限保持玻璃通透,上限防止退化成实心色块 */
|
||||
const WHITE_VEIL_ALPHA: readonly [number, number] = [0.1, 0.38]
|
||||
const DARK_VEIL_ALPHA: readonly [number, number] = [0.35, 0.6]
|
||||
|
||||
/** 文字锚点:dark = 深色文字(浅背景用),light = 浅色文字(深背景用) */
|
||||
const ANCHORS: Record<'title' | 'body' | 'tertiary', { dark: TextAnchor; light: TextAnchor }> = {
|
||||
title: {
|
||||
dark: { relaxed: [44, 44, 44], strong: [10, 10, 10] },
|
||||
light: { relaxed: [240, 238, 233], strong: [255, 255, 255] }
|
||||
},
|
||||
body: {
|
||||
dark: { relaxed: [77, 77, 77], strong: [17, 17, 17] },
|
||||
light: { relaxed: [217, 214, 208], strong: [255, 255, 255] }
|
||||
},
|
||||
tertiary: {
|
||||
dark: { relaxed: [122, 122, 122], strong: [61, 61, 61] },
|
||||
light: { relaxed: [176, 172, 166], strong: [216, 213, 207] }
|
||||
}
|
||||
}
|
||||
|
||||
const SHADOW_ON_LIGHT = '0 0 0 1px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.22)'
|
||||
const SHADOW_ON_DARK = '0 0 0 1px rgba(255, 255, 255, 0.06), 0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
|
||||
// —— 颜色数学(输入输出均为 sRGB 0-255)——
|
||||
|
||||
const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
|
||||
const clamp255 = (v: number) => Math.min(255, Math.max(0, v))
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
|
||||
const lerpRgb = (a: RGB, b: RGB, t: number): RGB => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]
|
||||
const gammaLuma = (c: RGB) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
||||
|
||||
const channelLinear = (v: number) => {
|
||||
const s = v / 255
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
/** WCAG 相对亮度(线性光域,区别于阈值判定用的 gamma luma) */
|
||||
const relativeLuminance = (c: RGB) =>
|
||||
0.2126 * channelLinear(c[0]) + 0.7152 * channelLinear(c[1]) + 0.0722 * channelLinear(c[2])
|
||||
/** WCAG 对比度(1~21) */
|
||||
const contrastRatio = (a: RGB, b: RGB) => {
|
||||
const la = relativeLuminance(a)
|
||||
const lb = relativeLuminance(b)
|
||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
|
||||
}
|
||||
/** 近似 CSS saturate():以 luma 为轴拉伸色度 */
|
||||
const saturateRgb = (c: RGB, s: number): RGB => {
|
||||
const l = gammaLuma(c)
|
||||
return [clamp255(l + (c[0] - l) * s), clamp255(l + (c[1] - l) * s), clamp255(l + (c[2] - l) * s)]
|
||||
}
|
||||
/** 纱层色以 alpha 覆盖在背景上的合成结果 */
|
||||
const compositeVeil = (veil: RGB, alpha: number, bg: RGB): RGB => [
|
||||
lerp(bg[0], veil[0], alpha),
|
||||
lerp(bg[1], veil[1], alpha),
|
||||
lerp(bg[2], veil[2], alpha)
|
||||
]
|
||||
/** 保持色相整体缩放亮度:从均值色推算最不利分位数处的背景色 */
|
||||
const scaleRgb = (c: RGB, factor: number): RGB => [clamp255(c[0] * factor), clamp255(c[1] * factor), clamp255(c[2] * factor)]
|
||||
const cssRgb = (c: RGB) => `rgb(${Math.round(c[0])}, ${Math.round(c[1])}, ${Math.round(c[2])})`
|
||||
const cssRgba = (c: RGB, alpha: number) =>
|
||||
`rgba(${Math.round(c[0])}, ${Math.round(c[1])}, ${Math.round(c[2])}, ${alpha.toFixed(2)})`
|
||||
|
||||
// —— 单调量二分求解 ——
|
||||
|
||||
/** 在宽松↔极端锚点间找达到目标对比度的最小插值系数 t(对比度随 t 单调上升) */
|
||||
function solveTextTone(anchor: TextAnchor, bg: RGB, target: number): { color: RGB; t: number } {
|
||||
if (contrastRatio(anchor.relaxed, bg) >= target) return { color: anchor.relaxed, t: 0 }
|
||||
if (contrastRatio(anchor.strong, bg) < target) return { color: anchor.strong, t: 1 }
|
||||
let lo = 0
|
||||
let hi = 1
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const mid = (lo + hi) / 2
|
||||
if (contrastRatio(lerpRgb(anchor.relaxed, anchor.strong, mid), bg) >= target) hi = mid
|
||||
else lo = mid
|
||||
}
|
||||
return { color: lerpRgb(anchor.relaxed, anchor.strong, hi), t: hi }
|
||||
}
|
||||
|
||||
/** 找使宽松锚点文字色达标的最小纱层 alpha(对比度随 alpha 单调上升);不可达时取上限,由文字升级接棒 */
|
||||
function solveVeilAlpha(
|
||||
veil: RGB,
|
||||
range: readonly [number, number],
|
||||
bg: RGB,
|
||||
relaxedText: RGB,
|
||||
target: number
|
||||
): number {
|
||||
const [min, max] = range
|
||||
if (contrastRatio(relaxedText, compositeVeil(veil, min, bg)) >= target) return min
|
||||
if (contrastRatio(relaxedText, compositeVeil(veil, max, bg)) < target) return max
|
||||
let lo = min
|
||||
let hi = max
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const mid = (lo + hi) / 2
|
||||
if (contrastRatio(relaxedText, compositeVeil(veil, mid, bg)) >= target) hi = mid
|
||||
else lo = mid
|
||||
}
|
||||
return hi
|
||||
}
|
||||
|
||||
interface BandTone {
|
||||
polarity: 'dark' | 'light'
|
||||
color: RGB
|
||||
/** 宽松→极端锚点的插值进度,供同区域次级文字(时间等)联动取色 */
|
||||
t: number
|
||||
/** 最不利分位数背景下的对比度缺口(0~1),驱动光晕强度 */
|
||||
deficit: number
|
||||
}
|
||||
|
||||
/** 沿 offsetParent 链累计布局坐标:不受出入场 transform 影响,采样区域始终对准卡片落点 */
|
||||
export function getLayoutRect(el: HTMLElement): { left: number; top: number; width: number; height: number } {
|
||||
let left = 0
|
||||
let top = 0
|
||||
let node: HTMLElement | null = el
|
||||
while (node) {
|
||||
left += node.offsetLeft
|
||||
top += node.offsetTop
|
||||
node = node.offsetParent as HTMLElement | null
|
||||
}
|
||||
return { left, top, width: el.offsetWidth, height: el.offsetHeight }
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应反色引擎:吃三个区域的原始背景统计,产出 --noti-* CSS 变量。
|
||||
* 平滑(EMA/突变跳变)、极性滞回等状态都在引擎内维护,与采样来源无关
|
||||
*/
|
||||
export function createAdaptiveThemeEngine() {
|
||||
const ema: Partial<Record<'card' | 'title' | 'body', BandSample>> = {}
|
||||
const textPolarity: Partial<Record<'title' | 'body', 'dark' | 'light'>> = {}
|
||||
let veilPolarity: 'white' | 'dark' | null = null
|
||||
const appliedVars: Record<string, string> = {}
|
||||
|
||||
const setVar = (name: string, value: string) => {
|
||||
if (appliedVars[name] === value) return
|
||||
appliedVars[name] = value
|
||||
document.documentElement.style.setProperty(name, value)
|
||||
}
|
||||
|
||||
/** 自适应平滑:场景切换(luma 突变)立即跳变保证响应速度,小幅波动才指数平滑抑噪 */
|
||||
const smooth = (key: 'card' | 'title' | 'body', raw: BandSample | null): BandSample | null => {
|
||||
if (!raw) return ema[key] ?? null
|
||||
const prev = ema[key]
|
||||
const next: BandSample = prev && Math.abs(gammaLuma(raw.mean) - gammaLuma(prev.mean)) <= SNAP_LUMA_DELTA
|
||||
? {
|
||||
mean: lerpRgb(prev.mean, raw.mean, EMA_FACTOR),
|
||||
darkTail: lerp(prev.darkTail, raw.darkTail, EMA_FACTOR),
|
||||
lightTail: lerp(prev.lightTail, raw.lightTail, EMA_FACTOR)
|
||||
}
|
||||
: raw
|
||||
ema[key] = next
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 材质方向 + 纱层浓度。方向优先贴合桌面明暗(亮底白纱、暗底黑纱),
|
||||
* 亮度滞回沿用旧算法验证过的 102/118 阈值;若贴合方向即使最大补偿也达不到
|
||||
* 目标对比度而反方向可以,才反转方向(不用"两族取优":黑纱补偿上限更高,
|
||||
* 会在双方都可达标的中间调上抢走本应保持浅色观感的场景)。
|
||||
* 浓度取"宽松锚点文字色恰好达标"的最小 alpha
|
||||
*/
|
||||
const resolveVeil = (cardMean: RGB): { color: RGB; alpha: number } => {
|
||||
const bg = saturateRgb(cardMean, GLASS_SATURATION)
|
||||
const luma = gammaLuma(bg)
|
||||
let next: 'white' | 'dark'
|
||||
if (veilPolarity === 'white') next = luma < 102 ? 'dark' : 'white'
|
||||
else if (veilPolarity === 'dark') next = luma > 118 ? 'white' : 'dark'
|
||||
else next = luma >= 110 ? 'white' : 'dark'
|
||||
|
||||
const bestWithWhite = contrastRatio(ANCHORS.body.dark.strong, compositeVeil(WHITE_VEIL, WHITE_VEIL_ALPHA[1], bg))
|
||||
const bestWithDark = contrastRatio(ANCHORS.body.light.strong, compositeVeil(DARK_VEIL, DARK_VEIL_ALPHA[1], bg))
|
||||
if (next === 'white' && bestWithWhite < TARGET_CONTRAST && bestWithDark > bestWithWhite + POLARITY_MARGIN) next = 'dark'
|
||||
if (next === 'dark' && bestWithDark < TARGET_CONTRAST && bestWithWhite > bestWithDark + POLARITY_MARGIN) next = 'white'
|
||||
veilPolarity = next
|
||||
return next === 'white'
|
||||
? { color: WHITE_VEIL, alpha: solveVeilAlpha(WHITE_VEIL, WHITE_VEIL_ALPHA, bg, ANCHORS.body.dark.relaxed, TARGET_CONTRAST) }
|
||||
: { color: DARK_VEIL, alpha: solveVeilAlpha(DARK_VEIL, DARK_VEIL_ALPHA, bg, ANCHORS.body.light.relaxed, TARGET_CONTRAST) }
|
||||
}
|
||||
|
||||
const resolveBand = (key: 'title' | 'body', sample: BandSample, veil: { color: RGB; alpha: number }): BandTone => {
|
||||
const glassBg = compositeVeil(veil.color, veil.alpha, saturateRgb(sample.mean, GLASS_SATURATION))
|
||||
const anchors = ANCHORS[key]
|
||||
const bestDark = contrastRatio(anchors.dark.strong, glassBg)
|
||||
const bestLight = contrastRatio(anchors.light.strong, glassBg)
|
||||
const current = textPolarity[key]
|
||||
let next: 'dark' | 'light' = current ?? (bestDark >= bestLight ? 'dark' : 'light')
|
||||
if (current === 'dark' && bestLight > bestDark + POLARITY_MARGIN) next = 'light'
|
||||
if (current === 'light' && bestDark > bestLight + POLARITY_MARGIN) next = 'dark'
|
||||
textPolarity[key] = next
|
||||
|
||||
const tone = solveTextTone(anchors[next], glassBg, TARGET_CONTRAST)
|
||||
|
||||
// 深色文字最怕暗斑、浅色文字最怕亮斑:按不利分位数亮度缩放均值色近似最不利背景
|
||||
const meanLuma = Math.max(1, gammaLuma(sample.mean))
|
||||
const adverseLuma = next === 'dark' ? sample.darkTail : sample.lightTail
|
||||
const adverseBg = compositeVeil(
|
||||
veil.color,
|
||||
veil.alpha,
|
||||
saturateRgb(scaleRgb(sample.mean, adverseLuma / meanLuma), GLASS_SATURATION)
|
||||
)
|
||||
const deficit = clamp01((TARGET_CONTRAST - contrastRatio(tone.color, adverseBg)) / TARGET_CONTRAST)
|
||||
return { polarity: next, color: tone.color, t: tone.t, deficit }
|
||||
}
|
||||
|
||||
/** 反向微光晕:深字配白晕、浅字配黑影,强度随对比度缺口无级增强 */
|
||||
const haloFor = (tone: BandTone) =>
|
||||
tone.polarity === 'dark'
|
||||
? `0 0 2px rgba(255, 255, 255, ${(0.35 + 0.55 * tone.deficit).toFixed(2)})`
|
||||
: `0 1px 3px rgba(0, 0, 0, ${(0.4 + 0.4 * tone.deficit).toFixed(2)})`
|
||||
|
||||
return {
|
||||
/** 输入三区域原始统计(title/body 缺省时退回整卡),刷新全部 --noti-* 变量 */
|
||||
apply(raw: { card: BandSample | null; title?: BandSample | null; body?: BandSample | null }) {
|
||||
const cardSample = smooth('card', raw.card)
|
||||
if (!cardSample) return
|
||||
|
||||
const veil = resolveVeil(cardSample.mean)
|
||||
setVar('--noti-tint', cssRgba(veil.color, veil.alpha))
|
||||
setVar('--noti-shadow', veil.color === WHITE_VEIL ? SHADOW_ON_LIGHT : SHADOW_ON_DARK)
|
||||
|
||||
const titleSample = smooth('title', raw.title ?? null) ?? cardSample
|
||||
const bodySample = smooth('body', raw.body ?? null) ?? cardSample
|
||||
|
||||
const title = resolveBand('title', titleSample, veil)
|
||||
setVar('--noti-title-color', cssRgb(title.color))
|
||||
setVar('--noti-title-halo', haloFor(title))
|
||||
const tertiaryAnchor = ANCHORS.tertiary[title.polarity]
|
||||
setVar('--noti-title-tertiary', cssRgb(lerpRgb(tertiaryAnchor.relaxed, tertiaryAnchor.strong, title.t)))
|
||||
setVar('--noti-close-hover-bg', title.polarity === 'dark' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.14)')
|
||||
|
||||
const body = resolveBand('body', bodySample, veil)
|
||||
setVar('--noti-body-color', cssRgb(body.color))
|
||||
setVar('--noti-body-halo', haloFor(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流采样路径(跨平台回退管线):从 Chromium 桌面视频流按布局区域采样。
|
||||
* 采样节奏:视频每出新帧即采样(33ms 节流,~30Hz)+ 250ms 兜底轮询
|
||||
*/
|
||||
export function useNotificationAdaptiveTheme(
|
||||
stream: MediaStream | null,
|
||||
backdrop: LiquidGlassBackdropImage | undefined
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!stream || !backdrop) return
|
||||
const video = document.createElement('video')
|
||||
video.muted = true
|
||||
video.srcObject = stream
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = SAMPLE_W
|
||||
canvas.height = SAMPLE_H
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
const engine = createAdaptiveThemeEngine()
|
||||
|
||||
/** 窗口内 CSS 矩形 → 视频帧像素区域的均值色与明暗分位数(流分辨率与屏幕逻辑尺寸按比例映射) */
|
||||
const readBand = (left: number, top: number, width: number, height: number): BandSample | null => {
|
||||
if (!ctx || width < 1 || height < 1) return null
|
||||
const ratioX = video.videoWidth / backdrop.width
|
||||
const ratioY = video.videoHeight / backdrop.height
|
||||
try {
|
||||
ctx.drawImage(
|
||||
video,
|
||||
(backdrop.screenX + left) * ratioX,
|
||||
(backdrop.screenY + top) * ratioY,
|
||||
Math.max(1, width * ratioX),
|
||||
Math.max(1, height * ratioY),
|
||||
0, 0, SAMPLE_W, SAMPLE_H
|
||||
)
|
||||
const { data } = ctx.getImageData(0, 0, SAMPLE_W, SAMPLE_H)
|
||||
let r = 0
|
||||
let g = 0
|
||||
let b = 0
|
||||
const lumas: number[] = []
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
r += data[i]
|
||||
g += data[i + 1]
|
||||
b += data[i + 2]
|
||||
lumas.push(0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2])
|
||||
}
|
||||
const count = lumas.length
|
||||
lumas.sort((x, y) => x - y)
|
||||
return {
|
||||
mean: [r / count, g / count, b / count],
|
||||
darkTail: lumas[Math.floor(count * 0.15)],
|
||||
lightTail: lumas[Math.floor(count * 0.85)]
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const readRect = (rect: { left: number; top: number; width: number; height: number }) =>
|
||||
readBand(rect.left - RECT_BLEED, rect.top - RECT_BLEED, rect.width + RECT_BLEED * 2, rect.height + RECT_BLEED * 2)
|
||||
|
||||
const sampleAll = () => {
|
||||
if (!video.videoWidth) return
|
||||
const host: ParentNode = document.getElementById('notification-current') ?? document
|
||||
const glassEl = host.querySelector<HTMLElement>('.liquid-glass')
|
||||
const headerEl = host.querySelector<HTMLElement>('.notification-header')
|
||||
const bodyEl = host.querySelector<HTMLElement>('.notification-body')
|
||||
engine.apply({
|
||||
card: readRect(
|
||||
glassEl ? getLayoutRect(glassEl) : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }
|
||||
),
|
||||
title: headerEl ? readRect(getLayoutRect(headerEl)) : null,
|
||||
body: bodyEl ? readRect(getLayoutRect(bodyEl)) : null
|
||||
})
|
||||
}
|
||||
|
||||
// 事件驱动采样:视频每出新帧立即响应(节流 33ms),桌面静止时零采样开销;
|
||||
// 兜底轮询覆盖桌面静止但通知自身布局变化(尺寸校准、新旧卡片过渡)的场景
|
||||
let disposed = false
|
||||
let lastSampleAt = 0
|
||||
let frameHandle = 0
|
||||
const onFrame = () => {
|
||||
if (disposed) return
|
||||
const now = performance.now()
|
||||
if (now - lastSampleAt >= MIN_SAMPLE_GAP_MS) {
|
||||
lastSampleAt = now
|
||||
sampleAll()
|
||||
}
|
||||
frameHandle = video.requestVideoFrameCallback(onFrame)
|
||||
}
|
||||
frameHandle = video.requestVideoFrameCallback(onFrame)
|
||||
const fallbackTimer = setInterval(sampleAll, FALLBACK_INTERVAL_MS)
|
||||
video.play().catch(() => { /* 采样失败保持当前材质 */ })
|
||||
return () => {
|
||||
disposed = true
|
||||
clearInterval(fallbackTimer)
|
||||
video.cancelVideoFrameCallback(frameHandle)
|
||||
video.srcObject = null
|
||||
}
|
||||
}, [stream, backdrop])
|
||||
}
|
||||
|
||||
/** 原生玻璃面板的亮度带 id 约定(与主进程 notification:glassRect 的 bands 对应) */
|
||||
export const NATIVE_BAND_IDS = { card: 0, title: 1, body: 2 } as const
|
||||
|
||||
/**
|
||||
* 原生采样路径(Windows 原生玻璃):面板在原生层重绘的同一帧从玻璃模糊纹理
|
||||
* 回读三区域统计,经主进程以 notification:luma 事件转发(上限 ~60Hz,
|
||||
* 桌面静止时不推送),反色判定与玻璃画面帧级同步
|
||||
*/
|
||||
export function useNotificationNativeAdaptiveTheme(enabled: boolean) {
|
||||
useEffect(() => {
|
||||
if (!enabled || !window.electronAPI?.notification?.onLuma) return
|
||||
const engine = createAdaptiveThemeEngine()
|
||||
const toSample = (s?: { r: number; g: number; b: number; darkTail: number; lightTail: number }): BandSample | null =>
|
||||
s ? { mean: [s.r, s.g, s.b], darkTail: s.darkTail, lightTail: s.lightTail } : null
|
||||
return window.electronAPI.notification.onLuma(bands => {
|
||||
engine.apply({
|
||||
card: toSample(bands[String(NATIVE_BAND_IDS.card)]),
|
||||
title: toSample(bands[String(NATIVE_BAND_IDS.title)]),
|
||||
body: toSample(bands[String(NATIVE_BAND_IDS.body)])
|
||||
})
|
||||
})
|
||||
}, [enabled])
|
||||
}
|
||||
Vendored
+17
-1
@@ -382,6 +382,22 @@ export interface ElectronAPI {
|
||||
click: (payload: string | { sessionId?: string; channel?: string; insightRecordId?: string; targetRoute?: string }) => void
|
||||
ready: () => void
|
||||
resize: (width: number, height: number) => void
|
||||
glassRect: (payload: {
|
||||
card: { x: number; y: number; width: number; height: number }
|
||||
bands: Array<{ id: number; x: number; y: number; width: number; height: number }>
|
||||
dpr: number
|
||||
cornerRadius: number
|
||||
blurSigma: number
|
||||
displacementScale: number
|
||||
aberrationIntensity: number
|
||||
saturation: number
|
||||
}) => void
|
||||
glassHide: () => void
|
||||
onLuma: (
|
||||
callback: (
|
||||
bands: Record<string, { r: number; g: number; b: number; darkTail: number; lightTail: number }>
|
||||
) => void
|
||||
) => () => void
|
||||
onShow: (callback: (event: any, data: any) => void) => () => void
|
||||
onNavigateToSession: (callback: (sessionId: string) => void) => () => void
|
||||
onNavigateToRoute: (callback: (route: string) => void) => () => void
|
||||
@@ -1568,7 +1584,7 @@ export interface ElectronAPI {
|
||||
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
|
||||
exportTimeline: (options: {
|
||||
outputDir: string
|
||||
format: 'json' | 'html' | 'arkmejson'
|
||||
format: 'json' | 'html' | 'arkmejson' | 'markdown'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportImages?: boolean
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
|
||||
/**
|
||||
* 把文本中的微信表情代码(如 [微笑])渲染为内联表情图片,
|
||||
* 未匹配的方括号内容原样保留。size 为表情边长(px)
|
||||
*/
|
||||
export function renderTextWithEmoji(text: string, size = 22): ReactNode {
|
||||
if (!text) return text
|
||||
const parts = text.split(/\[(.*?)\]/g)
|
||||
return parts.map((part, index) => {
|
||||
// 奇数索引是方括号捕获组的内容
|
||||
if (index % 2 === 1) {
|
||||
const path = getEmojiPath(part as Parameters<typeof getEmojiPath>[0])
|
||||
if (path) {
|
||||
// path 例如 'assets/face/微笑.png',需要添加 base 前缀
|
||||
return (
|
||||
<img
|
||||
key={index}
|
||||
src={`${import.meta.env.BASE_URL}${path}`}
|
||||
alt={`[${part}]`}
|
||||
className="inline-emoji"
|
||||
style={{ width: size, height: size, verticalAlign: 'bottom', margin: '0 1px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return `[${part}]`
|
||||
}
|
||||
return part
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user