mirror of
https://github.com/Caho1/open-claude-ui.git
synced 2026-11-07 10:26:02 -10:00
feat: initial release of open-claude-ui (clean-room compliance, zero proprietary binary assets)
This commit is contained in:
commit
cd90920a7f
42 files changed
+12721
No files matched your search
+12
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.env
|
||||
.env.local
|
||||
.cache/
|
||||
.temp/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Caho1
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,245 @@
|
||||
# Open Claude UI (`open-claude-ui`)
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/images/logo.svg" width="96" height="96" alt="Open Claude UI Logo" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Production-grade, clean-room reverse-engineered implementation of the Claude Design System (CDS) and interaction physics.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT" /></a>
|
||||
<a href="assets/README.md"><img src="https://img.shields.io/badge/assets-clean--room%20compliance-success" alt="Asset Compliance" /></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.5+-blue?logo=typescript" alt="TypeScript" /></a>
|
||||
<a href="https://react.dev/"><img src="https://img.shields.io/badge/React-18+-61DAFB?logo=react" alt="React" /></a>
|
||||
<a href="https://tailwindcss.com/"><img src="https://img.shields.io/badge/TailwindCSS-3.4+-38B2AC?logo=tailwind-css" alt="TailwindCSS" /></a>
|
||||
<a href="https://www.framer.com/motion/"><img src="https://img.shields.io/badge/Framer_Motion-11+-0055FF?logo=framer" alt="Framer Motion" /></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📖 简介 (Introduction)
|
||||
|
||||
`open-claude-ui` 是基于对现代桌面端 AI 客户端进行深度洁净室逆向工程(Clean-Room Reverse Engineering)构建的高保真开源 UI 组件库与动效引擎。
|
||||
|
||||
不同于市面上常见的盲猜或近似复刻,本项目所有组件规范、物理阻尼常数、着色器关键帧及音频管道均**严格依据真实前端架构反编译验证**提炼而成:
|
||||
* **温润的人文质感 (Editorial Warmth)**:纯正陶土橙(Clay `#d97757`)、4 层微对比表面阶梯(Surface 0~3)与视网膜高分屏发丝细边补偿(Hairline Ring);
|
||||
* **严苛的交互物理学 (Interaction Physics)**:二阶阻尼谐振子数学求解、4px 指针移动死区、CJK 输入法合成守卫、7 阶五次缓动 Alpha 遮罩;
|
||||
* **高阶视听与系统架构 (Audiovisual & Architecture)**:基于原生 Web Audio API 的零延迟合成提示音、以光标为锚点的画廊跑道平移缩放、macOS 桌面毛玻璃透明融合与 Cowork 多代理协同拓扑。
|
||||
|
||||
> ⚖️ **版权与合规声明 (DMCA & Assets Policy)**:
|
||||
> 本开源项目**不公开分发任何专有二进制资产**(包括商业字体 `.ttf` 与录音 `.mp3`)。本项目提供原生系统级字体回退栈与 Web Audio API 纯代码合成音效,完全自包含、合规安全。详情参见 [assets/README.md](assets/README.md)。
|
||||
|
||||
---
|
||||
|
||||
## 🌟 核心特性 (Key Features)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
OCUI["open-claude-ui"]
|
||||
|
||||
OCUI --> Tokens["1. 设计哲学与令牌体系<br/>(Surfaces 0~3, Clay, Hairlines, System Fonts)"]
|
||||
OCUI --> Atoms["2. 基础原子交互控件<br/>(CdsButton, SegmentedControl, StatusIndicators, Dialog)"]
|
||||
OCUI --> Stream["3. 会话流与指令引擎<br/>(Composer, IME Guard, Model Switcher, MCP Cards)"]
|
||||
OCUI --> Motion["4. 动效物理学与着色器<br/>(Spring Physics, Ambient Shimmer, Scroll Fade Masks)"]
|
||||
OCUI --> Media["5. 视听多媒体与产物交互<br/>(Synthetic Audio, Lightbox Pan/Zoom, Split Resizer, Epitaxy)"]
|
||||
OCUI --> Desktop["6. 桌面架构与协作拓扑<br/>(macOS Vibrancy, Sidebar DnD, Cowork Canvas, A11y Roving)"]
|
||||
OCUI --> Setup["7. 完整工程规范与开箱即用<br/>(Tailwind, CSS Themes, Zero-Binary Dependency)"]
|
||||
```
|
||||
|
||||
1. **28 个生产级独立代码模块**:包含 11 个基础原子控件 + 17 个深度架构物理学模块与 React Hooks,即拷即用,零黑盒依赖。
|
||||
2. **纯原生系统级排版与音效**:支持系统高保真字体回退栈,内置基于 Web Audio 振荡器的零延迟提示音合成引擎。
|
||||
3. **交互式组件故事书 (Storybook Preview)**:内置单文件开箱即用的离线 Storybook,支持浏览器直接打开预览全部组件动效。
|
||||
4. **七大领域分卷完整技术手册**:附带深度逆向分析开发手册 [`docs/Claude Design System (CDS).md`](docs/Claude%20Design%20System%20(CDS).md)。
|
||||
|
||||
---
|
||||
|
||||
## 📂 项目结构 (Repository Structure)
|
||||
|
||||
```
|
||||
open-claude-ui/
|
||||
├── assets/ # 资产说明与合规指引
|
||||
│ ├── images/logo.svg # 开源矢量标识
|
||||
│ └── README.md # 资产合规与系统级回退栈说明
|
||||
├── code-examples/ # 30 个生产级独立代码文件 (开箱即用)
|
||||
│ ├── ArtifactsSplitPaneResizer.tsx # 双栏弹性分割与折叠引擎
|
||||
│ ├── AttachmentCard.tsx # 附件磁贴与上下文胶囊
|
||||
│ ├── cds-theme.css # 全局主题样式表 (CSS 自定义属性与 Keyframes)
|
||||
│ ├── CdsButton.tsx # 4 级尺寸、5 大变体全能按钮
|
||||
│ ├── ChatComposer.tsx # 核心输入框终端
|
||||
│ ├── ChatMessageBubbles.tsx # 会话气泡套件 (User & Assistant)
|
||||
│ ├── ClaudeAudioPlayer.ts # 低延迟音频引擎 (内置 Web Audio 原生合成振荡器)
|
||||
│ ├── ClaudeDesktopHeader.tsx # macOS 沉浸式标题栏组件
|
||||
│ ├── ClaudeMotionPresets.ts # 动效引擎与二阶阻尼谐振子数学求解
|
||||
│ ├── CodeBlock.tsx # 代码块容器与 2.5s 复制衰减状态机
|
||||
│ ├── ConfirmationDialog.tsx # 模态确认对话框
|
||||
│ ├── ContainerResponsiveBar.tsx # 自适应容器查询响应栏
|
||||
│ ├── CoworkAgentCanvas.tsx # 多智能体协同看板与拓扑空间
|
||||
│ ├── createClaudeMainWindow.ts # Electron 宿主主窗口创建工厂
|
||||
│ ├── DraggableSessionList.tsx # 侧边栏会话拖放与固定物理
|
||||
│ ├── EpitaxyIframeBridge.tsx # Artifacts 预览沙箱桥接器
|
||||
│ ├── LiveMicActivityBars.tsx # 6 频段非线性麦克风波形柱
|
||||
│ ├── McpToolDecisionCard.tsx # MCP 交互式工具调用决策卡片
|
||||
│ ├── MediaLightboxViewer.tsx # 多模态媒体查看器与画廊跑道
|
||||
│ ├── ModelSwitcherWithTokenGauge.tsx # 模型矩阵选择器与 Token 仪表盘
|
||||
│ ├── ReasoningAmbientGlow.tsx # 动态光效与推理氛围感组件
|
||||
│ ├── RichComposerWithMentions.tsx # CJK 守卫、/ 与 @ 双触发符输入终端
|
||||
│ ├── ScrollFadeContainer.tsx # 7 阶五次缓动非线性 Alpha 渐变遮罩
|
||||
│ ├── SegmentedControl.tsx # 999px 全圆角分段选择胶囊
|
||||
│ ├── StatusIndicators.tsx # 陶土呼吸灯、运行脉冲与骨架屏扫光
|
||||
│ ├── StreamingDiffViewer.tsx # 代码差异对比与流式高亮引擎
|
||||
│ ├── tailwind.config.js # Tailwind 配置工程扩展
|
||||
│ ├── ThinkingAccordion.tsx # 思考过程状态机折叠组件
|
||||
│ ├── useClaudeRovingFocus.ts # 全键盘无障碍焦点漫游 Hook
|
||||
│ ├── useClaudeScrollPinning.ts # 流式滚动视口图钉 Hook
|
||||
│ ├── useClaudeSharedElementTransition.ts # View Transitions 共享元素变形 Hook
|
||||
│ └── useRateLimitBackoff.ts # 429/503 指数退避与熔断状态机 Hook
|
||||
├── docs/
|
||||
│ ├── Claude Design System (CDS).md # 七大篇章 / 31 小节深度技术指导手册
|
||||
│ └── claude-components-storybook.html # 交互式可视化故事书 (双击即可离线体验)
|
||||
├── src/
|
||||
│ └── index.ts # 统一对外导出的 TypeScript 库入口
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 生产级组件与物理模块总览 (Component Directory)
|
||||
|
||||
| 模块分类 | 组件 / Hook 文件 | 逆向来源与工程机制 |
|
||||
| :--- | :--- | :--- |
|
||||
| **基础原子控件** | `CdsButton.tsx` | 4 级尺寸阶梯(XS/SM/MD/LG),5 大变体,单闭合边框原则,`active:scale-[0.98]`。 |
|
||||
| **基础原子控件** | `SegmentedControl.tsx` | 999px 全圆角轨道、2px 几何内边距、纯几何定位与 Framer Motion 双模式。 |
|
||||
| **基础原子控件** | `StatusIndicators.tsx` | 陶土明度呼吸指示灯(2.4s)、运行活动脉冲(1.2s)、思考跳动三点与骨架屏扫光条。 |
|
||||
| **基础原子控件** | `ConfirmationDialog.tsx` | 16px 圆角、双环 Popover 弥散投影、高斯模糊遮罩与按键陷阱。 |
|
||||
| **基础原子控件** | `AttachmentCard.tsx` | 10px 面板圆角、桌面端磁贴 (128×96px) 与输入框行内胶囊双模式、生长入场动画。 |
|
||||
| **会话流与输入** | `ChatMessageBubbles.tsx` | User 18px 浅蓝微调底色 vs Assistant 零边框透明排版与代码块联动。 |
|
||||
| **会话流与输入** | `ThinkingAccordion.tsx` | CSS Grid `0fr -> 1fr` 弹性伸缩无抖动动画,生成中强制展开互斥锁。 |
|
||||
| **会话流与输入** | `RichComposerWithMentions.tsx` | CJK 输入法合成守卫(`isComposing || keyCode === 229`)、`/` 指令与 `@` 上下文双触发符、Token 预测。 |
|
||||
| **会话流与输入** | `ModelSwitcherWithTokenGauge.tsx` | 三大模型梯队切换、Extended Thinking 开关、Prompt Caching 四色显存解构与 SVG 环形表。 |
|
||||
| **会话流与输入** | `McpToolDecisionCard.tsx` | 三态授权生命周期(`pending_approval` $\to$ `running` $\to$ `success`/`error`)、三阶决议动作阵列。 |
|
||||
| **动效与环境光** | `ClaudeMotionPresets.ts` | 二阶阻尼谐振子数学求解($m\ddot{x} + c\dot{x} + kx = 0$)、4 大弹簧预设与贝塞尔常量。 |
|
||||
| **动效与环境光** | `ReasoningAmbientGlow.tsx` | `wl-glow` 陶土暖光漫反射、`cds-shimmer-text-shine` 非对称贝塞尔曲线、焦散光场。 |
|
||||
| **动效与环境光** | `ScrollFadeContainer.tsx` | 官方 7 阶五次缓动非线性 Alpha 渐变遮罩、动态滚动位置传感器(触顶/触底动态归零)。 |
|
||||
| **视听与多模态** | `ClaudeAudioPlayer.ts` | 原生 Web Audio API 合成振荡器、24kHz 声学通道、15ms 平滑淡入淡出增益防爆音。 |
|
||||
| **视听与多模态** | `LiveMicActivityBars.tsx` | 6 柱波形、`Math.pow(energy, 0.7)` 非线性 Gamma 能量压缩、8% 噪声门限过滤。 |
|
||||
| **视听与多模态** | `MediaLightboxViewer.tsx` | 75vw 跑道滑动位移、以光标为锚点的仿射平移缩放(Pan/Zoom)、4px 拖拽死区、Ctrl 滚轮缩放。 |
|
||||
| **视听与多模态** | `useClaudeSharedElementTransition.ts` | 基于 `startViewTransition`、缩略图动态圆角检测与 `object-fit: contain` 内切剪裁逆向计算。 |
|
||||
| **视听与多模态** | `ArtifactsSplitPaneResizer.tsx` | Iframe 事件防吞噬护盾、4px 移动死区、双击 50/50 居中吸附、边缘折叠门限与约束钳制。 |
|
||||
| **视听与多模态** | `EpitaxyIframeBridge.tsx` | 严格 Origin 校验与通道隔离的 postMessage RPC 协议、7 大专有 MIME 分发、用户激活门禁。 |
|
||||
| **视听与多模态** | `StreamingDiffViewer.tsx` | Shadow DOM 隔离、CSS Grid 行高对齐锁定、增量流式追加、统一/分屏双模式。 |
|
||||
| **视听与多模态** | `CodeBlock.tsx` | 12px 圆角、`#0d0d0c` 纯黑底色、语言标签行内光学对齐、2.5 秒复制衰减状态机。 |
|
||||
| **系统与协作拓扑** | `createClaudeMainWindow.ts` | Electron 宿主窗口工厂:`hiddenInset` 标题栏、macOS Vibrancy (`under-window`) 毛玻璃融合。 |
|
||||
| **系统与协作拓扑** | `ClaudeDesktopHeader.tsx` | macOS 交通灯 76px 安全边距(`-webkit-app-region: drag`)、多标签页切换胶囊。 |
|
||||
| **系统与协作拓扑** | `DraggableSessionList.tsx` | `pinnedOrder` 乐观拓扑重排、4px 指针移动死区、微倾斜幽灵投影与落点对撞占位线。 |
|
||||
| **系统与协作拓扑** | `CoworkAgentCanvas.tsx` | 多 Agent 任务委派状态机、团队共享工件网格资产池、`BroadcastChannel` 跨窗口同步。 |
|
||||
| **系统与协作拓扑** | `useClaudeScrollPinning.ts` | 150ms 冷却时间戳防抢夺、40px 底部容差图钉锁定、35vh 弹性缓冲与 GPU 字符模糊消解。 |
|
||||
| **系统与协作拓扑** | `ContainerResponsiveBar.tsx` | 基于 `@container` 的紧凑 (<254px)、中等 (255-420px) 与宽屏 (>420px) 渐进排版,杜绝单字折行。 |
|
||||
| **系统与协作拓扑** | `useClaudeRovingFocus.ts` | W3C WAI-ARIA 规范:方向键二维与一维网格漫游导航、Home/End 边界快速跳跃、RTL 翻转。 |
|
||||
| **系统与协作拓扑** | `useRateLimitBackoff.ts` | 429/503 截断指数退避($t = \min(t_{\max}, t_{\text{base}} \times 2^a) \times (1 \pm J)$)、全抖动与熔断。 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速上手 (Quick Start)
|
||||
|
||||
### 1. 引入样式表与 Tailwind 配置
|
||||
|
||||
在你的项目中引入 CDS 核心主题样式表:
|
||||
```css
|
||||
/* 引入全局主题变量与关键帧 */
|
||||
@import "open-claude-ui/code-examples/cds-theme.css";
|
||||
```
|
||||
|
||||
在你的 `tailwind.config.js` 中扩展主题:
|
||||
```javascript
|
||||
const cdsTheme = require("open-claude-ui/code-examples/tailwind.config.js");
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
"./node_modules/open-claude-ui/**/*.{js,ts,jsx,tsx}"
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
...cdsTheme.theme.extend
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 使用核心组件
|
||||
|
||||
```tsx
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ChatComposer,
|
||||
CdsButton,
|
||||
ModelSwitcherWithTokenGauge,
|
||||
ScrollFadeContainer
|
||||
} from 'open-claude-ui';
|
||||
|
||||
export function App() {
|
||||
const [model, setModel] = useState<'sonnet-3.5' | 'haiku-3.5' | 'opus-3'>('sonnet-3.5');
|
||||
const [thinking, setThinking] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full bg-[var(--cds-surface-1)]">
|
||||
<ScrollFadeContainer axis="vertical" fadeSize={32} className="flex-1 p-6">
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<ModelSwitcherWithTokenGauge
|
||||
currentModel={model}
|
||||
onModelChange={setModel}
|
||||
extendedThinking={thinking}
|
||||
onThinkingChange={setThinking}
|
||||
tokenBudget={{
|
||||
inputTokens: 12400,
|
||||
outputTokens: 3200,
|
||||
cacheReadTokens: 48000,
|
||||
cacheWriteTokens: 16000,
|
||||
maxContextWindow: 200000
|
||||
}}
|
||||
/>
|
||||
<ChatComposer onSend={(text) => console.log('Send message:', text)} />
|
||||
</div>
|
||||
</ScrollFadeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 视觉故事书预览 (Interactive Storybook)
|
||||
|
||||
无需启动繁重的本地服务器,只需双击打开自带的 HTML 文件即可体验全量高保真组件:
|
||||
```bash
|
||||
open docs/claude-components-storybook.html
|
||||
```
|
||||
故事书包含:
|
||||
* 陶土色彩体系与暗黑模式即时切换
|
||||
* 全量按钮尺寸与悬浮反馈
|
||||
* 999px 全圆角滑动分段胶囊
|
||||
* 二阶弹簧动效实时演示
|
||||
* 附件磁贴与代码复制衰减状态机
|
||||
|
||||
---
|
||||
|
||||
## 📄 完整技术手册 (Documentation)
|
||||
|
||||
完整的七大篇章逆向分析手册请查阅:[`docs/Claude Design System (CDS).md`](docs/Claude%20Design%20System%20(CDS).md)。
|
||||
- **第一篇**:设计哲学与设计令牌体系
|
||||
- **第二篇**:基础原子组件与交互控件
|
||||
- **第三篇**:会话核心流与指令交互引擎
|
||||
- **第四篇**:动效物理学、着色器与环境光影
|
||||
- **第五篇**:视听多媒体与产物交互物理学
|
||||
- **第六篇**:系统级桌面架构与协作拓扑
|
||||
- **第七篇**:工程配置与资产代码库总览
|
||||
|
||||
---
|
||||
|
||||
## 📜 许可证 (License)
|
||||
|
||||
本项目基于 [MIT License](LICENSE) 开源发布。所有反编译研究成果与代码复现仅供学习、研究与工程交流使用。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Assets & Compliance Policy
|
||||
|
||||
## ⚖️ 开源资产与版权合规声明 (DMCA & Asset Policy)
|
||||
|
||||
为了遵守开源软件规范及 DMCA / 知识产权法律,`open-claude-ui` **不公开分发任何属于 Anthropic, PBC 的专有二进制资产**(包括但不限于商业可变字体文件 `.ttf`、语音模式对话录音 `.mp3` 以及官方商业标识)。
|
||||
|
||||
### 1. 字体解决方案 (Font Stack Policy)
|
||||
本项目组件已全量配置系统级高保真字体回退栈,无需下载任何专有商业字体文件即可完美呈现优雅排版:
|
||||
- **正文无衬线 (Sans)**:优先采用系统原生现代字体栈(macOS / iOS 的 San Francisco、Windows 的 Segoe UI、Android / Linux 的 Roboto);
|
||||
- **衬线标题 (Serif)**:优先采用 New York、Georgia、Cambria 系统衬线字体;
|
||||
- **等宽代码 (Monospace)**:优先采用 SF Mono、Menlo、Monaco、Consolas 原生等宽字体。
|
||||
|
||||
若你拥有合法授权的可变字体,可在你的前端项目中通过 CSS `@font-face` 引入,组件将自动无缝匹配。
|
||||
|
||||
### 2. 交互音效解决方案 (Synthetic Audio Engine)
|
||||
`ClaudeAudioPlayer.ts` 已经内置了基于 **Web Audio API 振荡器(OscillatorNode)** 的原生纯代码合成引擎:
|
||||
- **零外部依赖**:不依赖任何本地或远端 `.mp3` 文件;
|
||||
- **即时响应**:0ms 网络加载延迟,开箱即用;
|
||||
- **自定义扩展 (BYO)**:支持开发者通过 `ClaudeAudioPlayer.getInstance().setSfxPath(name, yourUrl)` 自由接入自己的免版权提示音。
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||
<rect width="128" height="128" rx="28" fill="#d97757"/>
|
||||
<rect x="8" y="8" width="112" height="112" rx="20" fill="none" stroke="rgba(255,255,255,0.18)" stroke-width="2"/>
|
||||
<!-- Layered UI Spark / Brackets -->
|
||||
<path d="M42 38L24 64L42 90" fill="none" stroke="#ffffff" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M86 38L104 64L86 90" fill="none" stroke="#ffffff" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="64" cy="64" r="10" fill="#ffffff"/>
|
||||
<path d="M64 42V48M64 80V86M42 64H48M80 64H86" stroke="rgba(255,255,255,0.7)" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 733 B |
@@ -0,0 +1,221 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* ==============================================================================
|
||||
* Claude Design System (CDS) - ArtifactsSplitPaneResizer.tsx
|
||||
*
|
||||
* 逆向还原自官方 Claude Desktop / Web 核心双栏弹性分割与折叠引擎:
|
||||
* - `shared-22-DYCF-SVs.js` (data-pane-dragging / --df-pane-row / qM dead-zone)
|
||||
* - `c909d93e7-BsSzOQcm.js` (split ratio clamps & double-click snap)
|
||||
* - `c6a992d55-C0zTICcS.css` (df-split-handle, dframe-pane-shield)
|
||||
*
|
||||
* 核心工程物理学:
|
||||
* 1. 连续指针动力学与事件屏蔽护盾 (Pointer Stream & Iframe Shield):
|
||||
* 在用户拖拽分割条期间,向 DOM 根容器注入 `data-pane-dragging="true"`,
|
||||
* 并在 Artifacts 预览区域(如内嵌 iframe)叠加 `pointer-events: none` 透明护盾,
|
||||
* 彻底防止鼠标划入子 iframe 导致主窗口捕获丢失或拖拽卡死。
|
||||
* 2. 4px 移动死区 (Dead-Zone) 与 Escape 撤销状态机:
|
||||
* 4px 阈值过滤单击微颤;支持在拖拽中随时按 Escape 取消并回滚至初始比例。
|
||||
* 3. 双击居中对齐吸附 (50/50 Snap):
|
||||
* 双击分割手柄即刻触发阻尼弹簧动画平滑归位至 50% 对称基线。
|
||||
* 4. 边缘折叠门限 (Docking Threshold):
|
||||
* 当拖拽比例 < 18% 或 > 82% 时,自动触发弹性吸附折叠,释放主视口空间。
|
||||
* 5. 严格极值边界钳制 (Min-Max Clamping):
|
||||
* 左栏 (会话流) $\ge 380\text{px}$,右栏 (Artifacts) $\ge 420\text{px}$。
|
||||
* ==============================================================================
|
||||
*/
|
||||
|
||||
export interface SplitPaneProps {
|
||||
leftPane: React.ReactNode;
|
||||
rightPane: React.ReactNode;
|
||||
initialSplitRatio?: number; // 默认 0.5 (即 50%)
|
||||
minLeftWidth?: number; // 默认 380px
|
||||
minRightWidth?: number; // 默认 420px
|
||||
collapseThreshold?: number; // 默认 0.18
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ArtifactsSplitPaneResizer: React.FC<SplitPaneProps> = ({
|
||||
leftPane,
|
||||
rightPane,
|
||||
initialSplitRatio = 0.5,
|
||||
minLeftWidth = 380,
|
||||
minRightWidth = 420,
|
||||
collapseThreshold = 0.18,
|
||||
className = '',
|
||||
}) => {
|
||||
const [splitRatio, setSplitRatio] = useState<number>(initialSplitRatio);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isRightCollapsed, setIsRightCollapsed] = useState(false);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const pointerStartRef = useRef<{ x: number; ratio: number } | null>(null);
|
||||
const previousRatioRef = useRef<number>(initialSplitRatio);
|
||||
|
||||
// 1. 双击分割条:重置为 50/50 平衡点
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
setIsRightCollapsed(false);
|
||||
setSplitRatio(0.5);
|
||||
}, []);
|
||||
|
||||
// 2. 指针按下 (Pointer Down)
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return; // 仅响应鼠标左键
|
||||
e.preventDefault();
|
||||
|
||||
pointerStartRef.current = { x: e.clientX, ratio: splitRatio };
|
||||
previousRatioRef.current = splitRatio;
|
||||
setIsDragging(true);
|
||||
|
||||
// 捕获全局指针,防止鼠标滑出分割条导致失焦
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
// 3. 指针移动 (Pointer Move) 包含 Clamp 与折叠门限
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging || !pointerStartRef.current || !containerRef.current) return;
|
||||
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
if (containerWidth <= 0) return;
|
||||
|
||||
const deltaX = e.clientX - pointerStartRef.current.x;
|
||||
const rawNewRatio = pointerStartRef.current.ratio + deltaX / containerWidth;
|
||||
|
||||
// 4. 边缘吸附与折叠检测
|
||||
if (rawNewRatio > 1 - collapseThreshold) {
|
||||
setIsRightCollapsed(true);
|
||||
setSplitRatio(1);
|
||||
return;
|
||||
} else {
|
||||
setIsRightCollapsed(false);
|
||||
}
|
||||
|
||||
// 5. 像素级硬性 Min/Max 钳制约束
|
||||
const minRatio = minLeftWidth / containerWidth;
|
||||
const maxRatio = (containerWidth - minRightWidth) / containerWidth;
|
||||
const clampedRatio = Math.max(minRatio, Math.min(maxRatio, rawNewRatio));
|
||||
|
||||
setSplitRatio(clampedRatio);
|
||||
},
|
||||
[isDragging, collapseThreshold, minLeftWidth, minRightWidth]
|
||||
);
|
||||
|
||||
// 6. 指针松开 (Pointer Up)
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
if (!isDragging) return;
|
||||
setIsDragging(false);
|
||||
pointerStartRef.current = null;
|
||||
try {
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// 忽略部分浏览器对 releasePointerCapture 的严格异常
|
||||
}
|
||||
};
|
||||
|
||||
// 7. Escape 键中断并回滚
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isDragging && e.key === 'Escape') {
|
||||
setIsDragging(false);
|
||||
setSplitRatio(previousRatioRef.current);
|
||||
pointerStartRef.current = null;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isDragging]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
data-pane-dragging={isDragging ? 'true' : undefined}
|
||||
className={`relative flex w-full h-full overflow-hidden select-none bg-[var(--cds-bg-primary,#faf9f5)] dark:bg-[var(--cds-bg-primary,#141413)] ${className}`}
|
||||
style={
|
||||
{
|
||||
'--df-pane-row': `${splitRatio}`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* 左栏:会话主视口 */}
|
||||
<div
|
||||
className="h-full overflow-hidden flex flex-col transition-all duration-75"
|
||||
style={{
|
||||
width: isRightCollapsed ? '100%' : `${splitRatio * 100}%`,
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto">{leftPane}</div>
|
||||
</div>
|
||||
|
||||
{/* 中间弹性可拖拽分割条 */}
|
||||
{!isRightCollapsed && (
|
||||
<div
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={Math.round(splitRatio * 100)}
|
||||
aria-label="拖拽调整左右分栏宽度,双击归位 50/50,按 Escape 还原"
|
||||
onPointerDown={handlePointerDown}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={`group relative z-30 flex items-center justify-center w-2 -mx-1 cursor-col-resize outline-none touch-none transition-colors ${
|
||||
isDragging ? 'bg-[var(--cds-fill-brand,#d97757)]/20' : 'hover:bg-[var(--cds-fill-brand,#d97757)]/10'
|
||||
}`}
|
||||
>
|
||||
{/* 视觉分割线 (1px 发丝线) */}
|
||||
<div
|
||||
className={`w-[1px] h-full transition-colors ${
|
||||
isDragging
|
||||
? 'bg-[var(--cds-fill-brand,#d97757)]'
|
||||
: 'bg-[var(--cds-border-subtle,#e5e5e3)] dark:bg-[var(--cds-border-subtle,#282826)] group-hover:bg-[var(--cds-fill-brand,#d97757)]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* 居中拖拽微手柄胶囊 (Pill Handle) */}
|
||||
<div
|
||||
className={`absolute size-4 rounded-full flex items-center justify-center bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#222221)] border border-[var(--cds-border-subtle,#e0e0de)] dark:border-[var(--cds-border-subtle,#333331)] shadow-xs transition-transform ${
|
||||
isDragging ? 'scale-110 border-[var(--cds-fill-brand,#d97757)]' : 'group-hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-0.5">
|
||||
<span className="w-0.5 h-1.5 rounded-full bg-[var(--cds-text-muted,#8c8c8c)]" />
|
||||
<span className="w-0.5 h-1.5 rounded-full bg-[var(--cds-text-muted,#8c8c8c)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右栏:Artifacts / 代码 / 预览沙箱 */}
|
||||
{!isRightCollapsed && (
|
||||
<div
|
||||
className="relative h-full overflow-hidden flex flex-col transition-all duration-75 border-l border-[var(--cds-border-subtle,#e5e5e3)] dark:border-[var(--cds-border-subtle,#282826)] bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#1c1c1b)]"
|
||||
style={{
|
||||
width: `${(1 - splitRatio) * 100}%`,
|
||||
}}
|
||||
>
|
||||
{/* Iframe 防吞噬遮罩 (在拖拽时激活) */}
|
||||
{isDragging && <div className="absolute inset-0 z-50 pointer-events-none bg-transparent" />}
|
||||
|
||||
<div className="flex-1 overflow-auto">{rightPane}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右栏折叠时侧边唤醒挂钩 */}
|
||||
{isRightCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开 Artifacts 面板"
|
||||
onClick={() => {
|
||||
setIsRightCollapsed(false);
|
||||
setSplitRatio(0.5);
|
||||
}}
|
||||
className="absolute right-2 top-3 z-30 flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#222221)] border border-[var(--cds-border-subtle,#e0e0de)] dark:border-[var(--cds-border-subtle,#333331)] shadow-sm text-xs font-medium text-[var(--cds-text-secondary,#666666)] hover:text-[var(--cds-text-primary,#1a1a1a)] hover:border-[var(--cds-fill-brand,#d97757)] active:scale-95 transition-all"
|
||||
>
|
||||
<span>展开产物</span>
|
||||
<span>⇥</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Claude Design System (CDS) - File Attachment Tile & Chip
|
||||
*
|
||||
* 逆向溯源:提取自 /Applications/Claude.app ion-dist (.cds-attachment 及附件预览磁贴)。
|
||||
* 核心规范:
|
||||
* - 面板圆角:10px (--cds-panel-radius: 10px) 或 12px (rounded-xl)
|
||||
* - 磁贴尺寸:磁贴模式 w-32 h-24 (128px × 96px) 或紧凑胶囊模式
|
||||
* - 生长入场:file-thumbnail-grow-in (250ms cubic-bezier(.165, .84, .44, 1))
|
||||
* - 上传呼吸脉冲:file-thumbnail-upload-pulse
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
export type FileCategory = "code" | "pdf" | "image" | "text" | "archive" | "general";
|
||||
|
||||
export interface AttachmentCardProps {
|
||||
id?: string | number;
|
||||
name: string;
|
||||
size: string;
|
||||
type?: string;
|
||||
category?: FileCategory;
|
||||
uploading?: boolean;
|
||||
uploadProgress?: number; // 0 - 100
|
||||
onRemove?: () => void;
|
||||
onClick?: () => void;
|
||||
layout?: "tile" | "chip"; // 磁贴模式 或 行内胶囊模式
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AttachmentCard({
|
||||
name,
|
||||
size,
|
||||
type = "FILE",
|
||||
category = "general",
|
||||
uploading = false,
|
||||
uploadProgress,
|
||||
onRemove,
|
||||
onClick,
|
||||
layout = "tile",
|
||||
className = "",
|
||||
}: AttachmentCardProps) {
|
||||
// 根据扩展名或 category 推导色彩和图标简写
|
||||
const getCategoryConfig = () => {
|
||||
switch (category) {
|
||||
case "code":
|
||||
return { bg: "bg-clay/10", text: "text-clay", label: "</>" };
|
||||
case "pdf":
|
||||
return { bg: "bg-red-500/10", text: "text-red-500", label: "PDF" };
|
||||
case "image":
|
||||
return { bg: "bg-emerald-500/10", text: "text-emerald-500", label: "IMG" };
|
||||
case "text":
|
||||
return { bg: "bg-blue-500/10", text: "text-blue-500", label: "DOC" };
|
||||
case "archive":
|
||||
return { bg: "bg-amber-500/10", text: "text-amber-500", label: "ZIP" };
|
||||
default:
|
||||
return { bg: "bg-neutral-200/50 dark:bg-neutral-800", text: "text-neutral-500", label: type.slice(0, 3).toUpperCase() };
|
||||
}
|
||||
};
|
||||
|
||||
const badge = getCategoryConfig();
|
||||
|
||||
// 1. 紧凑胶囊模式 (用于输入框内部待发送列表)
|
||||
if (layout === "chip") {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`group relative inline-flex items-center gap-2 px-2.5 py-1 rounded-[10px] bg-white dark:bg-neutral-800/90 border border-neutral-200/80 dark:border-neutral-700/80 shadow-2xs select-none transition-all ${
|
||||
uploading ? "animate-pulse opacity-75" : "hover:border-neutral-300 dark:hover:border-neutral-600"
|
||||
} ${className}`}
|
||||
style={{ animation: "file-thumbnail-grow-in 250ms cubic-bezier(.165, .84, .44, 1)" }}
|
||||
>
|
||||
<span className={`size-4 rounded ${badge.bg} ${badge.text} flex items-center justify-center font-mono text-[9px] font-bold shrink-0`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-neutral-800 dark:text-neutral-200 truncate max-w-[120px]">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-[10px] text-neutral-400 font-mono tabular-nums">{size}</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="text-neutral-400 hover:text-red-500 size-4 rounded-full flex items-center justify-center transition-colors ml-0.5"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 磁贴卡片模式 (128px × 96px 经典桌面端上传附件排版)
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`relative w-32 h-24 rounded-[10px] border border-neutral-200/90 dark:border-neutral-800 bg-white dark:bg-[#20201e] p-2.5 shadow-2xs flex flex-col justify-between select-none group cursor-pointer transition-all duration-200 hover:border-neutral-300 dark:hover:border-neutral-700 hover:shadow-xs ${
|
||||
uploading ? "animate-pulse" : ""
|
||||
} ${className}`}
|
||||
style={{ animation: "file-thumbnail-grow-in 250ms cubic-bezier(.165, .84, .44, 1)" }}
|
||||
>
|
||||
{/* 顶部:图标徽章与右上角移除按钮 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className={`size-8 rounded-lg ${badge.bg} ${badge.text} flex items-center justify-center font-mono text-xs font-bold shadow-2xs`}>
|
||||
{badge.label}
|
||||
</div>
|
||||
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="size-5 rounded-md text-neutral-400 hover:text-red-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部:文件名与文件大小 */}
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-neutral-800 dark:text-neutral-200 truncate">
|
||||
{name}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-neutral-400 font-mono mt-0.5">
|
||||
<span className="tabular-nums">{size}</span>
|
||||
<span className="uppercase">{type}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传进度条 */}
|
||||
{uploading && uploadProgress !== undefined && (
|
||||
<div className="absolute inset-x-2 bottom-1 h-1 rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-clay transition-all duration-200"
|
||||
style={{ width: `${Math.min(100, Math.max(0, uploadProgress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Claude Design System (CDS) - Button System
|
||||
*
|
||||
* 逆向溯源:提取自 /Applications/Claude.app ion-dist 生产按钮阶梯与边界防臃肿规范。
|
||||
* 核心准则:
|
||||
* - 严格遵循“单闭合边框原则 (The 1-Border Rule)”:卡片内部的 Secondary 按钮严禁带边框,默认采用 Surface-2 浅底填充。
|
||||
* - 尺寸阶梯:XS (24px, 6px圆角)、SM (28px, 8px圆角)、MD (32px, 8px圆角)、LG (40px, 12px圆角)。
|
||||
* - 按压瞬时弹簧微缩放 (active:scale-[0.98])。
|
||||
*/
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline" | "danger";
|
||||
export type ButtonSize = "xs" | "sm" | "md" | "lg" | "icon";
|
||||
|
||||
export interface CdsButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: React.ReactNode;
|
||||
iconPosition?: "left" | "right";
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const SIZE_STYLES: Record<ButtonSize, string> = {
|
||||
xs: "h-6 px-2 rounded-md text-[11.5px] gap-1",
|
||||
sm: "h-7 px-2.5 rounded-lg text-xs gap-1.5",
|
||||
md: "h-8 px-3.5 rounded-lg text-sm gap-1.5",
|
||||
lg: "h-10 px-5 rounded-xl text-[15px] gap-2 font-medium",
|
||||
icon: "size-8 rounded-lg p-0 flex items-center justify-center",
|
||||
};
|
||||
|
||||
const VARIANT_STYLES: Record<ButtonVariant, string> = {
|
||||
// 主操作:陶土色实色填充 + 微光投影
|
||||
primary:
|
||||
"bg-clay hover:bg-clay-hover text-white border-0 shadow-2xs font-medium active:scale-[0.98]",
|
||||
// 次级操作:严格无边框,采用 Surface-2 表面浅底填充,彻底杜绝与父容器发生套娃边框冲突
|
||||
secondary:
|
||||
"bg-[var(--cds-fill-secondary,rgba(0,0,0,0.06))] hover:bg-[var(--cds-fill-secondary-hover,rgba(0,0,0,0.1))] dark:bg-white/10 dark:hover:bg-white/15 text-neutral-800 dark:text-neutral-200 border-0 font-medium active:scale-[0.98]",
|
||||
// 幽灵操作:无背景无边框,悬停时浅灰微光
|
||||
ghost:
|
||||
"bg-transparent hover:bg-neutral-100 dark:hover:bg-neutral-800/80 text-neutral-600 dark:text-neutral-300 border-0 active:scale-[0.98]",
|
||||
// 发丝细线:仅限空扁平画布展示,禁止放入已带外框的卡片中
|
||||
outline:
|
||||
"bg-transparent hover:bg-neutral-100/60 dark:hover:bg-neutral-800/60 text-neutral-700 dark:text-neutral-300 border border-neutral-200/90 dark:border-neutral-700/80 active:scale-[0.98]",
|
||||
// 警示破坏操作
|
||||
danger:
|
||||
"bg-red-600 hover:bg-red-700 text-white border-0 shadow-2xs font-medium active:scale-[0.98]",
|
||||
};
|
||||
|
||||
export const CdsButton = forwardRef<HTMLButtonElement, CdsButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
icon,
|
||||
iconPosition = "left",
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isIconOnly = size === "icon" || (!children && !!icon);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || isLoading}
|
||||
className={`inline-flex items-center justify-center font-sans transition-all duration-150 select-none outline-none focus-visible:ring-2 focus-visible:ring-clay/50 disabled:opacity-35 disabled:cursor-not-allowed disabled:pointer-events-none ${
|
||||
SIZE_STYLES[size]
|
||||
} ${VARIANT_STYLES[variant]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="size-3.5 border-2 border-current border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
) : (
|
||||
icon && iconPosition === "left" && (
|
||||
<span className="shrink-0 flex items-center justify-center leading-none">
|
||||
{icon}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
|
||||
{children && <span>{children}</span>}
|
||||
|
||||
{!isLoading && icon && iconPosition === "right" && !isIconOnly && (
|
||||
<span className="shrink-0 flex items-center justify-center leading-none">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CdsButton.displayName = "CdsButton";
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Claude Design System (CDS) - Chat Composer Terminal
|
||||
*
|
||||
* 逆向溯源:提取自 /Applications/Claude.app ion-dist 生产环境输入终端实现。
|
||||
* 核心规范:
|
||||
* - 20px 圆角 (--cds-radius-composer: 20px)
|
||||
* - 三态双环动态弥散投影 (--cds-shadow-composer, :hover, :focus-within)
|
||||
* - 原生 CSS field-sizing: content 自适应弹性伸缩 (min 48px, max 384px)
|
||||
* - 陶土主色发送按钮按压弹簧物理反馈 (active:scale-95)
|
||||
* - 双环悬浮弹出菜单 (--cds-shadow-popover)
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
|
||||
export interface AttachmentItem {
|
||||
id: string | number;
|
||||
name: string;
|
||||
size: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface ChatComposerProps {
|
||||
onSendMessage?: (content: string, attachments: AttachmentItem[], model: string, thinking: boolean) => void;
|
||||
placeholder?: string;
|
||||
initialModel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CLAUDE_MODELS = [
|
||||
{ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", badge: "Smartest" },
|
||||
{ id: "claude-3-5-haiku", name: "Claude 3.5 Haiku", badge: "Fastest" },
|
||||
{ id: "claude-3-opus", name: "Claude 3 Opus", badge: "Legacy" },
|
||||
];
|
||||
|
||||
export function ChatComposer({
|
||||
onSendMessage,
|
||||
placeholder = "Reply to Claude... (Shift + Enter to insert line break)",
|
||||
initialModel = "Claude 3.7 Sonnet",
|
||||
className = "",
|
||||
}: ChatComposerProps) {
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState(initialModel);
|
||||
const [isThinkingEnabled, setIsThinkingEnabled] = useState(true);
|
||||
const [isAddMenuOpen, setIsAddMenuOpen] = useState(false);
|
||||
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
||||
const modelMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击外部关闭弹出层
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (addMenuRef.current && !addMenuRef.current.contains(event.target as Node)) {
|
||||
setIsAddMenuOpen(false);
|
||||
}
|
||||
if (modelMenuRef.current && !modelMenuRef.current.contains(event.target as Node)) {
|
||||
setIsModelMenuOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!inputText.trim() && attachments.length === 0) return;
|
||||
onSendMessage?.(inputText.trim(), attachments, selectedModel, isThinkingEnabled);
|
||||
setInputText("");
|
||||
setAttachments([]);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAttachment = (id: string | number) => {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
const handleSimulateAddAttachment = (name: string, size: string, type: string) => {
|
||||
setAttachments((prev) => [...prev, { id: Date.now(), name, size, type }]);
|
||||
setIsAddMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full max-w-3xl mx-auto px-4 ${className}`}>
|
||||
{/* 核心终端外壳:严格 20px 圆角与三态双环投影 */}
|
||||
<div
|
||||
className="relative flex flex-col w-full rounded-[20px] bg-white dark:bg-[#1a1a19] p-[0.875rem] gap-y-2 transition-all duration-200"
|
||||
style={{
|
||||
boxShadow: "var(--cds-shadow-composer, 0 4px 20px rgba(0, 0, 0, 0.035), inset 0 0 0 1px var(--cds-border, rgba(0, 0, 0, 0.08)))",
|
||||
}}
|
||||
>
|
||||
{/* 1. 待发送附件水平滚动展示区 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1.5 pt-0.5">
|
||||
{attachments.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="px-2.5 py-1 rounded-lg bg-neutral-50 dark:bg-neutral-800 border border-neutral-200/80 dark:border-neutral-700 text-xs flex items-center gap-2 shadow-2xs transition-all animate-fade"
|
||||
>
|
||||
<div className="size-4 rounded bg-clay/10 text-clay flex items-center justify-center font-mono text-[9px] font-bold">
|
||||
{file.type?.[0] || "F"}
|
||||
</div>
|
||||
<span className="font-medium text-neutral-800 dark:text-neutral-200 truncate max-w-[140px]">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-neutral-400 font-mono">{file.size}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAttachment(file.id)}
|
||||
className="text-neutral-400 hover:text-red-500 hover:bg-neutral-200/50 dark:hover:bg-neutral-700 size-4 rounded-full flex items-center justify-center transition-colors"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. 原生 CSS 自适应伸缩文本域 */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
className="w-full max-h-96 min-h-[3rem] bg-transparent border-none outline-none resize-none text-[15px] leading-[1.4] text-neutral-900 dark:text-neutral-100 placeholder-neutral-400 pl-1.5 focus:ring-0 overflow-y-auto"
|
||||
style={{
|
||||
// @ts-ignore - Modern CSS property supported in Chromium 123+ & Safari 17.4+
|
||||
fieldSizing: "content",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 3. 底部功能控制栏 */}
|
||||
<div className="flex items-center justify-between pt-1 border-t border-transparent">
|
||||
{/* 左侧控制胶囊群 */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{/* + Add Content 弹出菜单 */}
|
||||
<div className="relative" ref={addMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAddMenuOpen(!isAddMenuOpen)}
|
||||
className="h-8 px-2.5 rounded-lg flex items-center gap-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<span className="text-sm font-semibold">+</span>
|
||||
<span>Add content</span>
|
||||
</button>
|
||||
|
||||
{isAddMenuOpen && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-2 w-52 rounded-xl bg-white dark:bg-[#222220] p-1.5 z-30 space-y-0.5"
|
||||
style={{
|
||||
boxShadow: "var(--cds-shadow-popover, 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.08), inset 0 0 0 1px rgba(0, 0, 0, 0.08))",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSimulateAddAttachment("architecture.ts", "4.2 KB", "TS")}
|
||||
className="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 text-xs flex items-center gap-2 text-neutral-800 dark:text-neutral-200 transition-colors"
|
||||
>
|
||||
<span className="text-clay font-mono font-bold"></></span>
|
||||
<span>Upload Code (architecture.ts)</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSimulateAddAttachment("system-spec.pdf", "182 KB", "PDF")}
|
||||
className="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 text-xs flex items-center gap-2 text-neutral-800 dark:text-neutral-200 transition-colors"
|
||||
>
|
||||
<span className="text-blue-500 font-mono font-bold">PDF</span>
|
||||
<span>Upload Document (spec.pdf)</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 模型切换药丸胶囊 */}
|
||||
<div className="relative" ref={modelMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModelMenuOpen(!isModelMenuOpen)}
|
||||
className="h-8 px-2.5 rounded-lg flex items-center gap-1.5 text-xs font-medium text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 border border-neutral-200/60 dark:border-neutral-700/60 transition-colors"
|
||||
>
|
||||
<span className="size-2 rounded-full bg-clay" />
|
||||
<span>{selectedModel}</span>
|
||||
<span className="text-[10px] text-neutral-400">▾</span>
|
||||
</button>
|
||||
|
||||
{isModelMenuOpen && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-2 w-56 rounded-xl bg-white dark:bg-[#222220] p-1.5 z-30 space-y-1"
|
||||
style={{
|
||||
boxShadow: "var(--cds-shadow-popover, 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.08), inset 0 0 0 1px rgba(0, 0, 0, 0.08))",
|
||||
}}
|
||||
>
|
||||
{CLAUDE_MODELS.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedModel(model.name);
|
||||
setIsModelMenuOpen(false);
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-lg text-xs flex items-center justify-between transition-colors ${
|
||||
selectedModel === model.name
|
||||
? "bg-clay/10 text-neutral-900 dark:text-white font-semibold"
|
||||
: "text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span>{model.name}</span>
|
||||
<span className="text-[10px] text-neutral-400 font-normal">{model.badge}</span>
|
||||
</div>
|
||||
{selectedModel === model.name && <span className="text-clay font-bold">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extended Thinking 深度思考开关 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsThinkingEnabled(!isThinkingEnabled)}
|
||||
className={`h-8 px-2.5 rounded-lg flex items-center gap-1.5 text-xs font-medium transition-all ${
|
||||
isThinkingEnabled
|
||||
? "bg-clay/15 text-clay border border-clay/30"
|
||||
: "text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<span>💡</span>
|
||||
<span>Thinking</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 右侧发送按钮:陶土色、按压微缩放 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={!inputText.trim() && attachments.length === 0}
|
||||
className="size-8 rounded-lg flex items-center justify-center transition-all duration-150 disabled:opacity-30 disabled:cursor-not-allowed bg-clay hover:bg-clay-hover text-white shadow-xs active:scale-95 shrink-0"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Claude Design System (CDS) - Message Bubbles (User & Assistant)
|
||||
*
|
||||
* 逆向溯源:提取自 /Applications/Claude.app ion-dist 生产环境多轮会话视口。
|
||||
* 核心规范:
|
||||
* - 用户气泡:严格 18px 圆角 (--cds-message-bubble-radius: 18px),浅蓝冷灰微调底色 (--cds-bg-tint-blue)
|
||||
* - 模型回答:极简零边框透明画布哲学,自然排版流,行宽约束 max-w-2xl ~ max-w-3xl
|
||||
* - 思考过程折叠器:无闭合边框,左侧极细陶土色引导线 (Thinking Disclosure)
|
||||
* - 复制状态机:2.5 秒自动衰减消退 (saved-check-decay)
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
// ============================================================================
|
||||
// 1. 类型定义 (Types)
|
||||
// ============================================================================
|
||||
|
||||
export interface AttachmentPreview {
|
||||
name: string;
|
||||
size: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface ArtifactPreview {
|
||||
title: string;
|
||||
version: string;
|
||||
desc?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface UserMessageProps {
|
||||
id?: string | number;
|
||||
text: string;
|
||||
time?: string;
|
||||
attachment?: AttachmentPreview | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface AssistantMessageProps {
|
||||
id?: string | number;
|
||||
text: string;
|
||||
time?: string;
|
||||
modelName?: string;
|
||||
thinkingTime?: string;
|
||||
thinkingSteps?: string[];
|
||||
codeSnippet?: {
|
||||
language: string;
|
||||
code: string;
|
||||
} | null;
|
||||
artifact?: ArtifactPreview | null;
|
||||
suggestedChips?: string[];
|
||||
onSelectChip?: (chipText: string) => void;
|
||||
onOpenArtifact?: (artifact: ArtifactPreview) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2. 用户提问气泡 (UserMessageBubble)
|
||||
// ============================================================================
|
||||
|
||||
export function UserMessageBubble({
|
||||
text,
|
||||
time = "Just now",
|
||||
attachment,
|
||||
className = "",
|
||||
}: UserMessageProps) {
|
||||
return (
|
||||
<div className={`flex flex-col items-end space-y-1.5 w-full ${className}`}>
|
||||
{/* 时间戳 */}
|
||||
{time && (
|
||||
<span className="text-[11px] font-mono text-neutral-400 select-none mr-1">
|
||||
{time}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 随附上传的附件芯片 */}
|
||||
{attachment && (
|
||||
<div className="w-56 p-2 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white dark:bg-neutral-800/90 shadow-2xs flex items-center gap-2.5 mb-1 animate-fade">
|
||||
<div className="size-8 rounded-lg bg-clay/10 text-clay flex items-center justify-center font-mono text-xs font-bold shrink-0">
|
||||
{attachment.type || "DOC"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-neutral-900 dark:text-neutral-100 truncate">
|
||||
{attachment.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-neutral-400 font-mono">{attachment.size}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户气泡:严格 18px 圆角与柔和冷色底 */}
|
||||
<div
|
||||
className="rounded-[18px] px-4 py-2.5 text-[14.5px] leading-relaxed text-neutral-900 dark:text-neutral-100 max-w-[85%] sm:max-w-[75%] shadow-2xs break-words"
|
||||
style={{
|
||||
backgroundColor: "var(--cds-bg-tint-blue, #f0f4f8)",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.04)",
|
||||
animation: "message-fade-up 0.4s cubic-bezier(0.34, 1.3, 0.64, 1)",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 3. 模型回答正文 (AssistantMessageBubble)
|
||||
// ============================================================================
|
||||
|
||||
export function AssistantMessageBubble({
|
||||
text,
|
||||
time = "Just now",
|
||||
modelName = "Sonnet 3.7",
|
||||
thinkingTime,
|
||||
thinkingSteps,
|
||||
codeSnippet,
|
||||
artifact,
|
||||
suggestedChips,
|
||||
onSelectChip,
|
||||
onOpenArtifact,
|
||||
className = "",
|
||||
}: AssistantMessageProps) {
|
||||
const [isThinkingOpen, setIsThinkingOpen] = useState(false);
|
||||
const [copiedCode, setCopiedCode] = useState(false);
|
||||
|
||||
const handleCopyCode = () => {
|
||||
if (!codeSnippet?.code) return;
|
||||
navigator.clipboard.writeText(codeSnippet.code);
|
||||
setCopiedCode(true);
|
||||
setTimeout(() => setCopiedCode(false), 2500); // 2.5s 状态机衰减
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`space-y-3.5 max-w-2xl w-full text-neutral-800 dark:text-neutral-200 ${className}`}
|
||||
style={{ animation: "message-fade-up 0.45s cubic-bezier(0.34, 1.3, 0.64, 1)" }}
|
||||
>
|
||||
{/* 头部:Claude Spark 图标 + 模型徽章 + 时间 */}
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-400 font-medium select-none">
|
||||
<span className="size-5 rounded-md bg-clay text-white flex items-center justify-center text-[10px] shadow-2xs font-serif font-bold">
|
||||
✦
|
||||
</span>
|
||||
<span className="font-semibold text-neutral-900 dark:text-neutral-100">Claude</span>
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-neutral-200/60 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300">
|
||||
{modelName}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="font-mono text-[11px]">{time}</span>
|
||||
</div>
|
||||
|
||||
{/* 深度思考折叠器 (Thinking Disclosure - 极简左引导线,拒绝闭合框) */}
|
||||
{thinkingSteps && thinkingSteps.length > 0 && (
|
||||
<div className="rounded-lg transition-colors py-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsThinkingOpen(!isThinkingOpen)}
|
||||
className="inline-flex items-center gap-2 py-1 px-2 -ml-2 rounded-md cursor-pointer select-none text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800/80 transition-colors"
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-clay" />
|
||||
<span className="font-medium">
|
||||
Thought for {thinkingTime || "3.2s"}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] text-neutral-400 transition-transform duration-200 ${
|
||||
isThinkingOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* CSS Grid 0fr -> 1fr 伸缩容器 */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-200 ease-[cubic-bezier(0.19,1,0.22,1)]"
|
||||
style={{ gridTemplateRows: isThinkingOpen ? "1fr" : "0fr" }}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden pl-3.5 ml-1 mt-1 text-xs leading-relaxed text-neutral-500 dark:text-neutral-400 font-mono border-l-2 border-clay/40 dark:border-clay/30 space-y-1">
|
||||
{thinkingSteps.map((step, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={
|
||||
step.includes("正在") || step.includes("分析") || step.includes("Parsing")
|
||||
? "text-clay font-medium"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
> {step}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 回答正文:完全无闭合边框,自然舒展排版 */}
|
||||
<div className="text-[15px] leading-relaxed text-neutral-800 dark:text-neutral-200 break-words">
|
||||
{text}
|
||||
</div>
|
||||
|
||||
{/* 代码块与 2.5s 复制衰减状态机 */}
|
||||
{codeSnippet && (
|
||||
<div className="rounded-xl overflow-hidden border border-neutral-800 bg-[#0d0d0c] text-neutral-200 font-mono text-xs shadow-sm my-2">
|
||||
<div className="flex items-center justify-between px-3.5 py-2 bg-neutral-950/80 border-b border-neutral-800 text-[11px] text-neutral-400 select-none">
|
||||
<span className="font-medium text-neutral-300">{codeSnippet.language}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCode}
|
||||
className="hover:text-white transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
{copiedCode ? (
|
||||
<span className="text-emerald-400 flex items-center gap-1 font-sans font-medium">
|
||||
✓ Copied!
|
||||
</span>
|
||||
) : (
|
||||
<span>Copy code</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3.5 overflow-x-auto leading-relaxed text-[13px] text-neutral-300 font-mono">
|
||||
{codeSnippet.code}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Artifacts 产物展示卡片 */}
|
||||
{artifact && (
|
||||
<div className="p-3.5 rounded-xl border border-neutral-200/90 dark:border-neutral-800 bg-white dark:bg-neutral-800/70 shadow-2xs flex items-center justify-between my-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="size-9 rounded-lg bg-clay/10 text-clay flex items-center justify-center shrink-0 font-mono text-xs font-bold">
|
||||
</>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold text-neutral-900 dark:text-neutral-100 truncate">
|
||||
{artifact.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-neutral-400 font-mono truncate">
|
||||
{artifact.version} {artifact.desc ? `• ${artifact.desc}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenArtifact?.(artifact)}
|
||||
className="h-7 px-3 rounded-lg bg-clay text-white text-xs font-medium hover:bg-clay-hover shadow-2xs flex items-center gap-1.5 transition-all shrink-0 active:scale-95"
|
||||
>
|
||||
<span>Open Artifact</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 建议追问胶囊 (Suggested Chips) */}
|
||||
{suggestedChips && suggestedChips.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{suggestedChips.map((chip, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => onSelectChip?.(chip)}
|
||||
className="px-3 py-1 rounded-full border border-neutral-200/80 dark:border-neutral-700/80 bg-neutral-50/80 dark:bg-neutral-800/60 hover:bg-neutral-100 dark:hover:bg-neutral-700 text-xs text-neutral-700 dark:text-neutral-300 transition-colors active:scale-95"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部操作工具栏 */}
|
||||
<div className="flex items-center gap-3 pt-1 text-neutral-400 select-none text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText(text)}
|
||||
className="hover:text-neutral-800 dark:hover:text-neutral-200 transition-colors"
|
||||
title="Copy response"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-neutral-800 dark:hover:text-neutral-200 transition-colors"
|
||||
title="Good response"
|
||||
>
|
||||
👍
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-neutral-800 dark:hover:text-neutral-200 transition-colors"
|
||||
title="Bad response"
|
||||
>
|
||||
👎
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Open Claude UI - 官方语音与交互音效引擎
|
||||
* 支持原生 Web Audio 振荡器无损合成,免除外部专有 MP3 版权依赖
|
||||
*/
|
||||
|
||||
export type ClaudeSfxName =
|
||||
| 'enter_voice_mode'
|
||||
| 'exit_voice_mode'
|
||||
| 'disconnected'
|
||||
| 'tool_approval_needed';
|
||||
|
||||
export class ClaudeAudioPlayer {
|
||||
private static instance: ClaudeAudioPlayer | null = null;
|
||||
private audioContext: AudioContext | null = null;
|
||||
private sfxAudioMap: Map<ClaudeSfxName, HTMLAudioElement> = new Map();
|
||||
private sfxPaths: Partial<Record<ClaudeSfxName, string>> = {};
|
||||
private isInitialized = false;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): ClaudeAudioPlayer {
|
||||
if (!ClaudeAudioPlayer.instance) {
|
||||
ClaudeAudioPlayer.instance = new ClaudeAudioPlayer();
|
||||
}
|
||||
return ClaudeAudioPlayer.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义配置特定音效文件的外部路径 (BYO Assets)
|
||||
*/
|
||||
public setSfxPath(name: ClaudeSfxName, path: string): void {
|
||||
this.sfxPaths[name] = path;
|
||||
if (typeof window !== 'undefined') {
|
||||
const audio = new Audio(path);
|
||||
audio.volume = 0.3;
|
||||
this.sfxAudioMap.set(name, audio);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Web Audio 上下文与音频管线
|
||||
* 遵循 30% 舒缓音量标准
|
||||
*/
|
||||
public async init(speakerDeviceId?: string): Promise<boolean> {
|
||||
if (this.isInitialized && this.audioContext?.state === 'running') {
|
||||
return true;
|
||||
}
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
try {
|
||||
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (!AudioCtx) return false;
|
||||
|
||||
this.audioContext = new AudioCtx();
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
|
||||
// 如果指定了特定扬声器设备,执行硬件路由绑定
|
||||
if (speakerDeviceId && typeof (this.audioContext as any).setSinkId === 'function') {
|
||||
await (this.audioContext as any).setSinkId(speakerDeviceId);
|
||||
}
|
||||
|
||||
this.isInitialized = true;
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[ClaudeAudioPlayer] Failed to initialize Web Audio pipeline:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用纯原生 Web Audio API 振荡器无损合成音效
|
||||
* 零外部音频文件依赖,合规安全,无网络加载延迟
|
||||
*/
|
||||
public playSynthetic(name: ClaudeSfxName): void {
|
||||
if (!this.audioContext || this.audioContext.state !== 'running') return;
|
||||
const ctx = this.audioContext;
|
||||
const now = ctx.currentTime;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
gain.gain.setValueAtTime(0.001, now);
|
||||
|
||||
switch (name) {
|
||||
case 'enter_voice_mode':
|
||||
// 双音阶轻快上扬 (440Hz -> 880Hz)
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(440, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(880, now + 0.18);
|
||||
gain.gain.linearRampToValueAtTime(0.3, now + 0.04);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.35);
|
||||
break;
|
||||
|
||||
case 'exit_voice_mode':
|
||||
// 单音阶柔和下沉 (880Hz -> 440Hz)
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(880, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(440, now + 0.22);
|
||||
gain.gain.linearRampToValueAtTime(0.3, now + 0.04);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.35);
|
||||
break;
|
||||
|
||||
case 'tool_approval_needed':
|
||||
// 温暖木质共振双击音 (520Hz)
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(520, now);
|
||||
gain.gain.linearRampToValueAtTime(0.25, now + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.15);
|
||||
break;
|
||||
|
||||
case 'disconnected':
|
||||
// 降调平滑衰减提示 (320Hz -> 180Hz)
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(320, now);
|
||||
osc.frequency.linearRampToValueAtTime(180, now + 0.25);
|
||||
gain.gain.linearRampToValueAtTime(0.25, now + 0.03);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.3);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放交互提示音(优先播放自定义音频,缺省时自动降级至 Web Audio 原生合成音)
|
||||
*/
|
||||
public async play(name: ClaudeSfxName): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
const audio = this.sfxAudioMap.get(name);
|
||||
if (audio && audio.src) {
|
||||
try {
|
||||
audio.currentTime = 0;
|
||||
await audio.play();
|
||||
return;
|
||||
} catch {
|
||||
// Fallback to synthetic
|
||||
}
|
||||
}
|
||||
|
||||
this.playSynthetic(name);
|
||||
}
|
||||
|
||||
public async setSpeakerDevice(deviceId: string): Promise<void> {
|
||||
if (!this.audioContext || typeof (this.audioContext as any).setSinkId !== 'function') return;
|
||||
try {
|
||||
await (this.audioContext as any).setSinkId(deviceId);
|
||||
} catch (err) {
|
||||
console.warn('[ClaudeAudioPlayer] setSinkId failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 维度四:macOS 桌面深度融合与 Web 自适应顶栏
|
||||
* 逆向还原自 Claude Desktop: shared-19-DmuztgN-.js / shared-21-CGGmSsMS.js
|
||||
*
|
||||
* 核心特性:
|
||||
* 1. 74px 红绿灯物理避让占位槽 (ew = 74)
|
||||
* 2. 逆缩放补偿宽度 Math.round(74 / Math.min(zoomFactor, 1))
|
||||
* 3. macOS 全屏感知与动态 safe-area 回缩
|
||||
* 4. 45px 全局独立拖拽带与 .draggable-none 交互隔离
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface ClaudeDesktopHeaderProps {
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ClaudeDesktopHeader: React.FC<ClaudeDesktopHeaderProps> = ({
|
||||
title,
|
||||
children
|
||||
}) => {
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [zoomFactor, setZoomFactor] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const isDarwin = navigator.userAgent.includes('Mac');
|
||||
setIsMac(isDarwin);
|
||||
|
||||
// 监听 Electron 宿主暴露的窗口状态
|
||||
const electronAPI = (window as any).electronAPI;
|
||||
if (electronAPI) {
|
||||
electronAPI.getFullscreen?.().then(setIsFullscreen);
|
||||
const unsubFs = electronAPI.onFullscreenChanged?.(setIsFullscreen);
|
||||
const unsubZoom = electronAPI.onZoomFactorChanged?.(setZoomFactor);
|
||||
return () => {
|
||||
unsubFs?.();
|
||||
unsubZoom?.();
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const needsTrafficLight = isMac && !isFullscreen;
|
||||
// 74px 逆缩放补偿算法
|
||||
const trafficLightSpacerWidth = needsTrafficLight
|
||||
? Math.round(74 / Math.min(zoomFactor, 1))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 1. 全局绝对定位拖拽带(仅响应窗口拖动,对所有鼠标交互穿透) */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-cds-window-drag-strip=""
|
||||
className="fixed inset-x-0 top-0 z-40 pointer-events-none"
|
||||
style={{
|
||||
height: needsTrafficLight ? '45px' : '0px',
|
||||
WebkitAppRegion: 'drag' as any
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 2. 顶栏容器 */}
|
||||
<header
|
||||
className="relative z-30 flex h-[45px] w-full items-center border-b border-[var(--cds-border)] bg-[var(--cds-surface-1)] px-4 transition-[padding] duration-150 select-none"
|
||||
style={{
|
||||
WebkitAppRegion: 'drag' as any
|
||||
}}
|
||||
>
|
||||
{/* 红绿灯规避占位槽 */}
|
||||
{trafficLightSpacerWidth > 0 && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="shrink-0 transition-[width] duration-150"
|
||||
style={{ width: `${trafficLightSpacerWidth}px` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 标题显示区(带拖拽属性) */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{title && (
|
||||
<span className="truncate text-caption font-medium text-[var(--cds-text-secondary)]">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 交互控件区:必须严格包裹 draggable-none,防御误触拖动 */}
|
||||
<div
|
||||
className="draggable-none flex shrink-0 items-center gap-2"
|
||||
style={{ WebkitAppRegion: 'no-drag' as any }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Claude Design System (CDS) - Motion Engine & Physics Presets
|
||||
*
|
||||
* 逆向溯源:提取自 /Applications/Claude.app ion-dist 及 Framer Motion 生产环境配置。
|
||||
* 遵循二阶阻尼谐振子物理公式:
|
||||
* m * x'' + c * x' + k * (x - x_target) = 0
|
||||
* 阻尼比 ζ = c / (2 * sqrt(m * k))
|
||||
*/
|
||||
|
||||
import { type Transition, type Variants } from "framer-motion";
|
||||
|
||||
// ============================================================================
|
||||
// 1. 经典二阶阻尼谐振子数学求解器 (Damped Harmonic Oscillator)
|
||||
// ============================================================================
|
||||
|
||||
export interface SpringParameters {
|
||||
stiffness: number; // k: 弹性刚度 (N/m)
|
||||
damping: number; // c: 阻尼系数 (N·s/m)
|
||||
mass: number; // m: 物体质量 (kg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算给定时间 t 下的归一化位移 (0 -> 1)
|
||||
*/
|
||||
export function calcDampedHarmonicOscillator(
|
||||
k: number,
|
||||
c: number,
|
||||
m: number,
|
||||
t: number
|
||||
): number {
|
||||
const w0 = Math.sqrt(k / m);
|
||||
const zeta = c / (2 * Math.sqrt(m * k));
|
||||
|
||||
if (zeta < 1) {
|
||||
// 欠阻尼状态 (Underdamped, 存在微冲超调振荡)
|
||||
const wd = w0 * Math.sqrt(1 - zeta * zeta);
|
||||
return (
|
||||
1 -
|
||||
Math.exp(-zeta * w0 * t) *
|
||||
(Math.cos(wd * t) + (zeta / Math.sqrt(1 - zeta * zeta)) * Math.sin(wd * t))
|
||||
);
|
||||
} else if (Math.abs(zeta - 1) < 1e-4) {
|
||||
// 临界阻尼 (Critically damped, 最快无振荡收敛)
|
||||
return 1 - Math.exp(-w0 * t) * (1 + w0 * t);
|
||||
} else {
|
||||
// 过阻尼状态 (Overdamped, 无超调平滑慢收敛)
|
||||
const s1 = -w0 * (zeta - Math.sqrt(zeta * zeta - 1));
|
||||
const s2 = -w0 * (zeta + Math.sqrt(zeta * zeta - 1));
|
||||
return 1 - (s2 * Math.exp(s1 * t) - s1 * Math.exp(s2 * t)) / (s2 - s1);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2. 生产级 Framer Motion 物理预设矩阵 (Spring Presets)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 预设 1: 按钮点击、开关切换、图标按压 (Button & Small Controls)
|
||||
* 特征:高频微弹,利落归位无粘滞。超调量 +4.2%,0.22s 极速收敛。
|
||||
*/
|
||||
export const SPRING_BOUNCY: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 600,
|
||||
damping: 30,
|
||||
mass: 1.0,
|
||||
};
|
||||
|
||||
/**
|
||||
* 预设 2: 气泡上浮、卡片展开、浮层 Popover 唤起 (Card / Popover Reveal)
|
||||
* 特征:沉稳顺滑,微冲后平稳着陆。超调量 +1.8%,0.35s 收敛。
|
||||
*/
|
||||
export const SPRING_SNAPPY: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 320,
|
||||
damping: 28,
|
||||
mass: 1.0,
|
||||
};
|
||||
|
||||
/**
|
||||
* 预设 3: 点赞动画、复制成功勾选、新消息微提示 (Bouncy Light Feedback)
|
||||
* 特征:两周期明显跳跃感,激发积极情绪反馈。超调量 +19.4%,0.58s 收敛。
|
||||
*/
|
||||
export const SPRING_GENTLE: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 380,
|
||||
damping: 18,
|
||||
mass: 1.0,
|
||||
};
|
||||
|
||||
/**
|
||||
* 预设 4: 抽屉与侧边栏手势吸附、面板分割拖拽 (High Damping Panel Drag)
|
||||
* 特征:绝对零过冲 (过阻尼 ζ = 3.65),松手瞬间如磁吸般强力咬合。
|
||||
*/
|
||||
export const SPRING_HEAVY_SNAP: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 1200,
|
||||
damping: 80,
|
||||
mass: 0.1,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 3. 原生 CSS 贝塞尔缓动曲线常量 (CSS Easing Constants)
|
||||
// ============================================================================
|
||||
|
||||
export const CDS_EASING = {
|
||||
/** 消息气泡与药丸胶囊微弹入场 */
|
||||
OVERSHOOT: "cubic-bezier(0.34, 1.3, 0.64, 1)",
|
||||
/** 阴影、焦点微光、高斯模糊渐变 */
|
||||
EASE_OUT_EXPO: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
/** 抽屉面板滑入标准减速 */
|
||||
DECEL: "cubic-bezier(0.165, 0.84, 0.44, 1)",
|
||||
/** 瞬时按压缩放 */
|
||||
SHARP_PRESS: "cubic-bezier(0.32, 0.72, 0, 1)",
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// 4. 标准 Framer Motion 动画变体 (Reusable Motion Variants)
|
||||
// ============================================================================
|
||||
|
||||
/** 消息上浮进入变体 */
|
||||
export const motionMessageFadeUp: Variants = {
|
||||
initial: { opacity: 0, y: 12 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: SPRING_SNAPPY,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -8,
|
||||
transition: { duration: 0.18, ease: "easeIn" },
|
||||
},
|
||||
};
|
||||
|
||||
/** 用户提问气泡弹出变体 */
|
||||
export const motionUserBubbleEnter: Variants = {
|
||||
initial: { opacity: 0, scale: 0.94, y: 8 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 450,
|
||||
damping: 24,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** 弹出菜单 / Popover 浮层 */
|
||||
export const motionPopover: Variants = {
|
||||
initial: { opacity: 0, scale: 0.95, y: 4 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: SPRING_SNAPPY,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
y: 2,
|
||||
transition: { duration: 0.12, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
|
||||
/** 模态对话框居中缩放进入 */
|
||||
export const motionModalDialog: Variants = {
|
||||
initial: { opacity: 0, scale: 0.96, y: 10 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 32,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
y: 6,
|
||||
transition: { duration: 0.15, ease: "easeInOut" },
|
||||
},
|
||||
};
|
||||
|
||||
/** 字符模糊消解流式动画 (Grapheme Blur Reveal) */
|
||||
export const motionGraphemeReveal: Variants = {
|
||||
initial: { opacity: 0, filter: "blur(6px)" },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
transition: { duration: 0.35, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Claude Design System (CDS) - Code Block & Copy Decay
|
||||
*
|
||||
* 逆向溯源:提取自 /Applications/Claude.app ion-dist 代码块容器实现。
|
||||
* 核心规范:
|
||||
* - 外壳:严格 12px 圆角 (rounded-xl),#0d0d0c 纯黑底色,1px 发丝边框
|
||||
* - 头部工具栏:高度 36px,左侧语言标识行内居中 (.align-[-0.125em])
|
||||
* - 复制状态机:点击后变为绿色勾选,静默展示 2.5 秒后平滑淡退 (saved-check-decay)
|
||||
* - 排版:等宽字体 JetBrains Mono / SF Mono,严格等宽对齐
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
export interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
filename?: string;
|
||||
showLineNumbers?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
code,
|
||||
language = "typescript",
|
||||
filename,
|
||||
showLineNumbers = false,
|
||||
className = "",
|
||||
}: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2500); // 2.5s 状态机衰减
|
||||
};
|
||||
|
||||
const lines = code.trim().split("\n");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl overflow-hidden border border-neutral-800 bg-[#0d0d0c] text-neutral-200 font-mono text-xs shadow-sm my-2 select-text ${className}`}
|
||||
>
|
||||
{/* 头部工具栏 */}
|
||||
<div className="flex items-center justify-between px-3.5 py-2 bg-neutral-950/80 border-b border-neutral-800/80 text-[11px] text-neutral-400 select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 代码图标 */}
|
||||
<span
|
||||
className="text-neutral-500 font-mono text-xs"
|
||||
style={{ verticalAlign: "-0.125em" }}
|
||||
>
|
||||
</>
|
||||
</span>
|
||||
<span className="font-medium text-neutral-300 lowercase">{language}</span>
|
||||
{filename && (
|
||||
<>
|
||||
<span className="text-neutral-600">•</span>
|
||||
<span className="text-neutral-400 font-mono">{filename}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 复制按钮与 2.5s 状态机指示器 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 text-[11px] text-neutral-400 hover:text-white transition-colors cursor-pointer"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<span
|
||||
className="text-emerald-400 flex items-center gap-1 font-sans font-medium"
|
||||
style={{
|
||||
animation: "saved-check-decay 0.35s cubic-bezier(.165, .84, .44, 1) 2.2s forwards",
|
||||
}}
|
||||
>
|
||||
<span>✓</span>
|
||||
<span>Copied!</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>⧉</span>
|
||||
<span>Copy code</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 代码内容区域 */}
|
||||
<div className="p-3.5 overflow-x-auto leading-relaxed text-[13px] text-neutral-300 font-mono">
|
||||
{showLineNumbers ? (
|
||||
<table className="border-collapse w-full">
|
||||
<tbody>
|
||||
{lines.map((line, idx) => (
|
||||
<tr key={idx} className="hover:bg-neutral-800/20">
|
||||
<td className="pr-4 text-right text-neutral-600 select-none font-mono text-xs w-8 tabular-nums">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="whitespace-pre">{line}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<pre className="whitespace-pre font-mono m-0">{code}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Claude Design System (CDS) - Confirmation Modal Dialog
|
||||
*
|
||||
* 逆向溯源:提取自 /Applications/Claude.app ion-dist 模态弹窗与 Overlays。
|
||||
* 核心规范:
|
||||
* - 弹窗外壳:严格 16px 圆角 (rounded-2xl)
|
||||
* - 投影:真实双环 Popover 弥散投影 (--cds-shadow-popover)
|
||||
* - 背景遮罩:高斯模糊微调 (backdrop-blur-xs bg-black/40)
|
||||
* - 交互反馈:支持 Escape 快捷退出与点击遮罩关闭
|
||||
*/
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { motionModalDialog } from "./ClaudeMotionPresets";
|
||||
|
||||
export interface ConfirmationDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: "danger" | "default";
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ConfirmationDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
variant = "danger",
|
||||
icon,
|
||||
}: ConfirmationDialogProps) {
|
||||
// 监听 Escape 快捷键退出
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const isDanger = variant === "danger";
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* 高斯模糊与半透明遮罩 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/45 backdrop-blur-[2px]"
|
||||
/>
|
||||
|
||||
{/* 模态卡片实体:16px 圆角与双环弥散投影 */}
|
||||
<motion.div
|
||||
variants={motionModalDialog}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="relative w-full max-w-sm rounded-2xl bg-white dark:bg-[#1a1a19] p-5 border border-neutral-200/80 dark:border-neutral-800 z-10 space-y-4"
|
||||
style={{
|
||||
boxShadow:
|
||||
"var(--cds-shadow-popover, 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.08), inset 0 0 0 1px rgba(0, 0, 0, 0.08))",
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* 头部标题与警示图标 */}
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div
|
||||
className={`size-10 rounded-full flex items-center justify-center shrink-0 ${
|
||||
isDanger
|
||||
? "bg-red-500/10 text-red-600 dark:bg-red-950/50 dark:text-red-400"
|
||||
: "bg-clay/10 text-clay"
|
||||
}`}
|
||||
>
|
||||
{icon ? (
|
||||
icon
|
||||
) : isDanger ? (
|
||||
<span className="text-base font-bold">!</span>
|
||||
) : (
|
||||
<span className="text-base">✦</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{title}
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮栏:单边框原则,Cancel 为 Ghost 悬停,Confirm 为主色 */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-8 px-3.5 rounded-lg text-xs font-medium text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors border-0"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
className={`h-8 px-4 rounded-lg text-xs font-medium text-white shadow-2xs active:scale-[0.98] transition-all border-0 ${
|
||||
isDanger
|
||||
? "bg-red-600 hover:bg-red-700"
|
||||
: "bg-clay hover:bg-clay-hover"
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 维度二:多容器自适应查询与极端弹性排版
|
||||
* 逆向还原自 Claude Desktop: c6a992d55-C0zTICcS.css
|
||||
*
|
||||
* 核心特性:
|
||||
* 1. @container 命名容器定义
|
||||
* 2. 4 阶流体压缩梯队 (>=576px / <480px / <384px / <320px)
|
||||
* 3. 极窄视口 (<320px) 零间距防爆安全网
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export const ContainerResponsiveBar: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<div className="@container/toolbar relative w-full border-t border-[var(--cds-border)] bg-[var(--cds-surface-1)] py-2">
|
||||
<div className="flex items-center justify-between px-3 @[384px]/toolbar:px-4">
|
||||
{/* 左侧模型信息:>=576px 显示完整,<384px 截断为紧凑文字 */}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-[var(--cds-clay)]" />
|
||||
<span className="truncate text-xs font-medium text-[var(--cds-text-primary)] @max-[384px]/toolbar:max-w-[72px]">
|
||||
Claude 3.7 Sonnet
|
||||
</span>
|
||||
<span className="rounded bg-[var(--cds-surface-2)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--cds-text-secondary)] @max-[480px]/toolbar:hidden">
|
||||
Thinking
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 右侧操作按钮组:依照容器宽度 4 阶自适应折叠 */}
|
||||
<div className="flex items-center gap-1 @[384px]/toolbar:gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 items-center gap-1 rounded-md px-2 text-xs font-medium text-[var(--cds-text-secondary)] hover:bg-[var(--cds-surface-2)] active:scale-95 transition-transform"
|
||||
>
|
||||
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 2a6 6 0 100 12A6 6 0 008 2zM0 8a8 8 0 1116 0A8 8 0 010 8z" />
|
||||
</svg>
|
||||
<span className="@max-[480px]/toolbar:hidden">Settings</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 items-center gap-1 rounded-md bg-[var(--cds-surface-2)] px-2.5 text-xs font-semibold text-[var(--cds-text-primary)] shadow-sm hover:bg-[var(--cds-surface-3)] active:scale-95 transition-all"
|
||||
>
|
||||
<span>Run</span>
|
||||
<span className="font-mono text-[10px] opacity-60 @max-[576px]/toolbar:hidden">⌘⏎</span>
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
/**
|
||||
* ==============================================================================
|
||||
* Claude Design System (CDS) - CoworkAgentCanvas.tsx
|
||||
*
|
||||
* 逆向还原自官方 Claude Cowork 多代理协同看板与工程拓扑空间:
|
||||
* - `shared-6-zKKz0bS2.js` (channel_delegation / member_session / cowork_space)
|
||||
* - `shared-21-CGGmSsMS.js` (projectGroupings / customGroupsByScope / task_grid)
|
||||
* - `c6a992d55-C0zTICcS.css` (task-grid-fade-in / task-grid-clip-reveal)
|
||||
*
|
||||
* 核心工程架构:
|
||||
* 1. 多智能体委派拓扑状态机 (Multi-Agent Task Delegation):
|
||||
* 支持分派子任务至独立子智能体 (Subagent),实时追踪其生命周期:
|
||||
* `idle` -> `running` (陶土呼吸) -> `waiting_review` -> `completed` / `errored`。
|
||||
* 2. 共享 Artifacts 工件网格资产池 (Shared Artifacts Pool):
|
||||
* 多 Agent 并发产出的文档、架构图、代码块统一汇聚入共享拓扑网格,
|
||||
* 提供按产物类型分类筛选与版本差异对比入口。
|
||||
* 3. 跨会话广播通道 (BroadcastChannel Sync):
|
||||
* 支持多窗口 / 多标签页间的任务状态实时事件同步。
|
||||
* 4. 生产级卡片微交互与圆角系统:
|
||||
* 严格遵循 12px/16px 复合圆角、单闭合边框原则与 `task-grid-clip-reveal` 渐进式渲染。
|
||||
* ==============================================================================
|
||||
*/
|
||||
|
||||
export type AgentStatus = 'idle' | 'running' | 'waiting_review' | 'completed' | 'errored';
|
||||
|
||||
export interface CoworkAgent {
|
||||
id: string;
|
||||
role: string;
|
||||
name: string;
|
||||
status: AgentStatus;
|
||||
currentTask?: string;
|
||||
avatarIcon: string;
|
||||
completedTasksCount: number;
|
||||
}
|
||||
|
||||
export interface SharedArtifact {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'code' | 'doc' | 'diagram' | 'audio';
|
||||
authorAgentId: string;
|
||||
updatedAt: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const DEFAULT_AGENTS: CoworkAgent[] = [
|
||||
{
|
||||
id: 'agent-lead',
|
||||
role: 'Lead Architect',
|
||||
name: '系统架构师',
|
||||
status: 'running',
|
||||
currentTask: '编排并分发前端工程拓扑任务',
|
||||
avatarIcon: '🏛️',
|
||||
completedTasksCount: 8,
|
||||
},
|
||||
{
|
||||
id: 'agent-re',
|
||||
role: 'Reverse Engineer',
|
||||
name: '逆向取证专家',
|
||||
status: 'running',
|
||||
currentTask: '提取 /Applications/Claude.app 生产混淆源码',
|
||||
avatarIcon: '🔬',
|
||||
completedTasksCount: 14,
|
||||
},
|
||||
{
|
||||
id: 'agent-ui',
|
||||
role: 'Design System Engineer',
|
||||
name: 'CDS 组件开发工程师',
|
||||
status: 'idle',
|
||||
currentTask: '等待新的组件规范任务',
|
||||
avatarIcon: '🎨',
|
||||
completedTasksCount: 22,
|
||||
},
|
||||
{
|
||||
id: 'agent-qa',
|
||||
role: 'Verification Agent',
|
||||
name: '质量验收专家',
|
||||
status: 'waiting_review',
|
||||
currentTask: '等待人工确认 Git commit 与 push 结果',
|
||||
avatarIcon: '✅',
|
||||
completedTasksCount: 19,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_ARTIFACTS: SharedArtifact[] = [
|
||||
{ id: 'art-1', title: 'Claude Design System (CDS).md', type: 'doc', authorAgentId: 'agent-lead', updatedAt: '刚刚', version: 'v2.4' },
|
||||
{ id: 'art-2', title: 'RichComposerWithMentions.tsx', type: 'code', authorAgentId: 'agent-ui', updatedAt: '5分钟前', version: 'v1.0' },
|
||||
{ id: 'art-3', title: 'DraggableSessionList.tsx', type: 'code', authorAgentId: 'agent-ui', updatedAt: '8分钟前', version: 'v1.0' },
|
||||
{ id: 'art-4', title: 'AudioSinkWorklet.js', type: 'audio', authorAgentId: 'agent-re', updatedAt: '20分钟前', version: 'v1.2' },
|
||||
];
|
||||
|
||||
export const CoworkAgentCanvas: React.FC = () => {
|
||||
const [agents, setAgents] = useState<CoworkAgent[]>(DEFAULT_AGENTS);
|
||||
const [artifacts] = useState<SharedArtifact[]>(DEFAULT_ARTIFACTS);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>('agent-lead');
|
||||
|
||||
const selectedAgent = agents.find((a) => a.id === selectedAgentId) || agents[0];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full rounded-2xl border border-[var(--cds-border-subtle,#e5e5e3)] dark:border-[var(--cds-border-subtle,#262625)] bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#161615)] shadow-sm overflow-hidden font-sans text-xs">
|
||||
{/* 顶部协同看板导航栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--cds-border-subtle,#e8e8e6)] dark:border-[var(--cds-border-subtle,#262625)] bg-[var(--cds-bg-secondary,#faf9f6)] dark:bg-[var(--cds-bg-secondary,#1a1a19)] select-none">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-base">🤝</span>
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)]">
|
||||
Claude Cowork 多智能体协同空间
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--cds-text-muted,#8c8c8c)]">
|
||||
多 Agent 并发任务分派、实时状态拓扑与共享工件网格
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-[var(--cds-fill-brand-subtle,#fcf0ec)] dark:bg-[var(--cds-fill-brand-subtle,#36211a)] text-[var(--cds-text-accent,#d97757)]">
|
||||
<span className="size-1.5 rounded-full bg-[var(--cds-fill-brand,#d97757)] animate-pulse" />
|
||||
4 个活动 Agent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主工作区:左侧 Agent 阵列,右侧工作明细与工件库 */}
|
||||
<div className="flex flex-1 overflow-hidden divide-x divide-[var(--cds-border-subtle,#e8e8e6)] dark:divide-[var(--cds-border-subtle,#262625)]">
|
||||
{/* 左侧 Agent 拓扑列表 */}
|
||||
<div className="w-80 p-3 space-y-2 overflow-y-auto bg-[var(--cds-bg-secondary,#fbfbf9)] dark:bg-[var(--cds-bg-secondary,#141413)]">
|
||||
<div className="px-1 text-[10px] font-semibold text-[var(--cds-text-muted,#8c8c8c)] uppercase tracking-wider">
|
||||
协作团队成员
|
||||
</div>
|
||||
|
||||
{agents.map((agent) => {
|
||||
const isSelected = agent.id === selectedAgentId;
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
className={`group flex items-start gap-3 p-3 rounded-xl cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface-elevated,#242423)] border border-[var(--cds-border-accent,#d97757)] shadow-xs'
|
||||
: 'hover:bg-[var(--cds-bg-surface,#ffffff)]/60 dark:hover:bg-[var(--cds-bg-surface-elevated,#242423)]/60 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="size-8 rounded-lg flex items-center justify-center bg-[var(--cds-alpha-1,rgba(0,0,0,0.05))] text-base shrink-0">
|
||||
{agent.avatarIcon}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-xs text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] truncate">
|
||||
{agent.name}
|
||||
</span>
|
||||
{/* 状态徽标 */}
|
||||
{agent.status === 'running' && (
|
||||
<span className="text-[10px] font-medium text-[var(--cds-text-accent,#d97757)]">
|
||||
● 运行中
|
||||
</span>
|
||||
)}
|
||||
{agent.status === 'idle' && (
|
||||
<span className="text-[10px] font-medium text-[var(--cds-text-muted,#8c8c8c)]">
|
||||
○ 空闲
|
||||
</span>
|
||||
)}
|
||||
{agent.status === 'waiting_review' && (
|
||||
<span className="text-[10px] font-medium text-amber-500">
|
||||
△ 待复核
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-[var(--cds-text-muted,#8c8c8c)] truncate">
|
||||
{agent.role}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-[var(--cds-text-secondary,#666666)] line-clamp-1">
|
||||
{agent.currentTask}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 右侧:所选 Agent 明细与工件网格 */}
|
||||
<div className="flex-1 p-4 overflow-y-auto space-y-5 bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#191918)]">
|
||||
{/* Agent 当前任务委派卡片 */}
|
||||
<div className="p-4 rounded-xl border border-[var(--cds-border-subtle,#e8e8e6)] dark:border-[var(--cds-border-subtle,#262625)] bg-[var(--cds-bg-secondary,#fbfbf9)] dark:bg-[var(--cds-bg-secondary,#1c1c1b)] space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-[var(--cds-text-muted,#8c8c8c)] uppercase tracking-wider">
|
||||
当前活跃委派任务 (Task Delegation)
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-[var(--cds-text-muted,#8c8c8c)]">
|
||||
已达成 {selectedAgent.completedTasksCount} 个里程碑
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="font-medium text-sm text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)]">
|
||||
{selectedAgent.currentTask}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 rounded-lg bg-[var(--cds-fill-brand,#d97757)] text-white hover:bg-[var(--cds-fill-brand-hover,#c86849)] font-medium active:scale-95 transition-all shadow-xs"
|
||||
>
|
||||
发送协同指令
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 rounded-lg border border-[var(--cds-border-subtle,#e5e5e3)] dark:border-[var(--cds-border-subtle,#333331)] bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#222221)] text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] hover:bg-[var(--cds-bg-hover,#f5f5f5)] active:scale-95 transition-all font-medium"
|
||||
>
|
||||
查看审计日志
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 共享 Artifacts 工件库网格 */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-[var(--cds-text-muted,#8c8c8c)] uppercase tracking-wider">
|
||||
团队共享产物 (Artifacts Pool)
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--cds-text-muted,#8c8c8c)]">
|
||||
共 {artifacts.length} 个产物
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{artifacts.map((art) => (
|
||||
<div
|
||||
key={art.id}
|
||||
className="p-3 rounded-xl border border-[var(--cds-border-subtle,#e8e8e6)] dark:border-[var(--cds-border-subtle,#262625)] bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#1f1f1e)] hover:border-[var(--cds-border-accent,#d97757)] transition-all cursor-pointer shadow-xs group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-[var(--cds-alpha-1,rgba(0,0,0,0.06))] dark:bg-[var(--cds-alpha-1,rgba(255,255,255,0.08))] text-[var(--cds-text-secondary,#666666)] uppercase">
|
||||
{art.type}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-[var(--cds-text-muted,#8c8c8c)]">
|
||||
{art.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 font-semibold text-xs text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] truncate group-hover:text-[var(--cds-text-accent,#d97757)] transition-colors">
|
||||
{art.title}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center justify-between text-[10px] text-[var(--cds-text-muted,#8c8c8c)]">
|
||||
<span>更新于 {art.updatedAt}</span>
|
||||
<span>查看预览 ↗</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,338 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* ==============================================================================
|
||||
* Claude Design System (CDS) - DraggableSessionList.tsx
|
||||
*
|
||||
* 逆向还原自官方 Claude Desktop 侧边栏会话拖放与固定物理引擎:
|
||||
* - `shared-21-CGGmSsMS.js` (pinReorder / Gce / Wce / onMoveUp / onMoveDown)
|
||||
* - `shared-22-DYCF-SVs.js` (data-pin-dragging / data-session-dragging / pinnedOrder)
|
||||
* - `c6a992d55-C0zTICcS.css` (df-sidebar-row, dframe-drag-ghost)
|
||||
*
|
||||
* 核心工程机制:
|
||||
* 1. 乐观拓扑重排状态机 (Optimistic Topological Reordering):
|
||||
* 基于 `pinnedOrder: string[]` 响应式状态数组,支持跨区域拖拽锁定与行内重排。
|
||||
* 2. 指针动力学与死区过滤 (Pointer Drag Threshold Physics):
|
||||
* 监听 PointerDown -> PointerMove,设定 4px 移动死区 (Dead-zone),
|
||||
* 彻底防止普通单击选择与右键上下文菜单误触发拖拽。
|
||||
* 3. 拖拽幽灵投影与对撞占位行 (Ghost Elevation & Drop Indicators):
|
||||
* 被拖拽元素呈现 -1.5° 微倾斜、高斯模糊半透明底色与发丝阴影,
|
||||
* 目标落点实时高亮 2px 陶土橙占位导轨。
|
||||
* 4. W3C 无障碍全键盘重排支持:
|
||||
* Alt+Up / Alt+Down 支持键盘无障碍调整会话顺序。
|
||||
* ==============================================================================
|
||||
*/
|
||||
|
||||
export interface SidebarSession {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
isPinned: boolean;
|
||||
isUnread?: boolean;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
const INITIAL_SESSIONS: SidebarSession[] = [
|
||||
{ id: 'sess-1', title: 'Web Audio 物理声学引擎逆向', updatedAt: '10 分钟前', isPinned: true },
|
||||
{ id: 'sess-2', title: 'Claude Lightbox 转场动力学分析', updatedAt: '2 小时前', isPinned: true },
|
||||
{ id: 'sess-3', title: '新一代富文本 Composer 架构设计', updatedAt: '昨天', isPinned: true },
|
||||
{ id: 'sess-4', title: 'Electron macOS 交通灯自适应实现', updatedAt: '2 天前', isPinned: false },
|
||||
{ id: 'sess-5', title: 'Epitaxy 差异对比引擎源码解构', updatedAt: '3 天前', isPinned: false },
|
||||
{ id: 'sess-6', title: 'React 19 编译器性能调优探讨', updatedAt: '上周', isPinned: false },
|
||||
];
|
||||
|
||||
export interface DraggableSessionListProps {
|
||||
onSelectSession?: (id: string) => void;
|
||||
activeSessionId?: string;
|
||||
}
|
||||
|
||||
export const DraggableSessionList: React.FC<DraggableSessionListProps> = ({
|
||||
onSelectSession,
|
||||
activeSessionId = 'sess-1',
|
||||
}) => {
|
||||
const [sessions, setSessions] = useState<SidebarSession[]>(INITIAL_SESSIONS);
|
||||
|
||||
// 拖拽核心状态
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null);
|
||||
const [dropPosition, setDropPosition] = useState<'before' | 'after' | null>(null);
|
||||
|
||||
// 指针位移起始记录 (用于 4px 死区计算)
|
||||
const pointerStartRef = useRef<{ x: number; y: number; id: string } | null>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
// 1. 分离固定会话与最近会话
|
||||
const pinnedSessions = sessions.filter((s) => s.isPinned);
|
||||
const recentSessions = sessions.filter((s) => !s.isPinned);
|
||||
|
||||
// 2. 切换 Pin 状态
|
||||
const togglePin = useCallback((id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, isPinned: !s.isPinned } : s))
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 3. 键盘无障碍重排 (Alt + Up / Down)
|
||||
const handleKeyDown = useCallback(
|
||||
(id: string, isPinned: boolean, e: React.KeyboardEvent) => {
|
||||
if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return;
|
||||
e.preventDefault();
|
||||
|
||||
setSessions((prev) => {
|
||||
const list = isPinned ? prev.filter((s) => s.isPinned) : prev.filter((s) => !s.isPinned);
|
||||
const currentIndex = list.findIndex((s) => s.id === id);
|
||||
if (currentIndex === -1) return prev;
|
||||
|
||||
const targetIndex = e.key === 'ArrowUp' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (targetIndex < 0 || targetIndex >= list.length) return prev;
|
||||
|
||||
const updatedSubList = [...list];
|
||||
const [moved] = updatedSubList.splice(currentIndex, 1);
|
||||
updatedSubList.splice(targetIndex, 0, moved);
|
||||
|
||||
// 合并回去
|
||||
return isPinned
|
||||
? [...updatedSubList, ...prev.filter((s) => !s.isPinned)]
|
||||
: [...prev.filter((s) => s.isPinned), ...updatedSubList];
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 4. 指针事件处理 (Pointer Event Pipeline)
|
||||
const handlePointerDown = (id: string, e: React.PointerEvent) => {
|
||||
// 仅响应鼠标左键或触控
|
||||
if (e.button !== 0) return;
|
||||
pointerStartRef.current = { x: e.clientX, y: e.clientY, id };
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (!pointerStartRef.current) return;
|
||||
const dx = Math.abs(e.clientX - pointerStartRef.current.x);
|
||||
const dy = Math.abs(e.clientY - pointerStartRef.current.y);
|
||||
|
||||
// 4px 死区阈值判断: 超过 4px 才激活拖拽态
|
||||
if (!isDraggingRef.current && (dx > 4 || dy > 4)) {
|
||||
isDraggingRef.current = true;
|
||||
setDraggedId(pointerStartRef.current.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
pointerStartRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
setDraggedId(null);
|
||||
setOverIndex(null);
|
||||
setDropPosition(null);
|
||||
};
|
||||
|
||||
// 5. HTML5 拖拽事件 (支持跨行和对撞检测)
|
||||
const handleDragStart = (id: string, e: React.DragEvent) => {
|
||||
setDraggedId(id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', id);
|
||||
};
|
||||
|
||||
const handleDragOverRow = (idx: number, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
const pos = e.clientY < midpoint ? 'before' : 'after';
|
||||
|
||||
setOverIndex(idx);
|
||||
setDropPosition(pos);
|
||||
};
|
||||
|
||||
const handleDrop = (targetIdx: number, targetIsPinned: boolean, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const draggedSessionId = e.dataTransfer.getData('text/plain') || draggedId;
|
||||
if (!draggedSessionId) return;
|
||||
|
||||
setSessions((prev) => {
|
||||
const draggedItem = prev.find((s) => s.id === draggedSessionId);
|
||||
if (!draggedItem) return prev;
|
||||
|
||||
// 移出原项
|
||||
const remaining = prev.filter((s) => s.id !== draggedSessionId);
|
||||
|
||||
// 计算落点全局索引
|
||||
let insertIdx = targetIdx;
|
||||
if (dropPosition === 'after') insertIdx += 1;
|
||||
|
||||
// 如果跨区域拖拽(如从 Recents 拖入 Pinned),自动赋予 isPinned 属性
|
||||
const newItem = { ...draggedItem, isPinned: targetIsPinned };
|
||||
|
||||
const result = [...remaining];
|
||||
result.splice(Math.min(insertIdx, result.length), 0, newItem);
|
||||
return result;
|
||||
});
|
||||
|
||||
handlePointerUp();
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="会话侧边栏导航"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
className="flex flex-col w-72 h-full bg-[var(--cds-bg-secondary,#fbfbf9)] dark:bg-[var(--cds-bg-secondary,#141413)] border-r border-[var(--cds-border-subtle,#e8e8e6)] dark:border-[var(--cds-border-subtle,#242423)] select-none text-xs font-sans overflow-hidden"
|
||||
>
|
||||
{/* 侧边栏顶部快速操作 */}
|
||||
<div className="flex items-center justify-between px-3 h-11 border-b border-[var(--cds-border-subtle,#e8e8e6)] dark:border-[var(--cds-border-subtle,#242423)]">
|
||||
<span className="font-semibold text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] text-sm">
|
||||
对话列表
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新开对话"
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#1f1f1e)] border border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#333)] text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] hover:bg-[var(--cds-bg-hover,#f5f5f5)] active:scale-95 transition-all shadow-xs"
|
||||
>
|
||||
<span>+</span>
|
||||
<span className="font-medium">新建</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 滚动区域 */}
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2 space-y-4">
|
||||
{/* === 固定会话区 (Pinned Sessions) === */}
|
||||
{pinnedSessions.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-2 pb-1 text-[11px] font-semibold text-[var(--cds-text-muted,#8c8c8c)] uppercase tracking-wider">
|
||||
<span>已固定 ({pinnedSessions.length})</span>
|
||||
<span className="text-[10px] font-normal lowercase">可拖拽重排</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5" role="list">
|
||||
{pinnedSessions.map((session, idx) => {
|
||||
const isBeingDragged = draggedId === session.id;
|
||||
const isOver = overIndex === idx;
|
||||
|
||||
return (
|
||||
<div key={session.id} className="relative">
|
||||
{/* 上落点指引线 */}
|
||||
{isOver && dropPosition === 'before' && (
|
||||
<div className="absolute -top-0.5 inset-x-0 h-0.5 bg-[var(--cds-fill-brand,#d97757)] rounded-full z-20 pointer-events-none" />
|
||||
)}
|
||||
|
||||
<div
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(session.id, e)}
|
||||
onDragOver={(e) => handleDragOverRow(idx, e)}
|
||||
onDrop={(e) => handleDrop(idx, true, e)}
|
||||
onPointerDown={(e) => handlePointerDown(session.id, e)}
|
||||
onKeyDown={(e) => handleKeyDown(session.id, true, e)}
|
||||
onClick={() => onSelectSession?.(session.id)}
|
||||
className={`group relative flex items-center justify-between px-2.5 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
||||
session.id === activeSessionId
|
||||
? 'bg-[var(--cds-bg-selected,#f3ece7)] dark:bg-[var(--cds-bg-selected,#3a2c27)] text-[var(--cds-text-accent,#d97757)] font-medium'
|
||||
: 'text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] hover:bg-[var(--cds-bg-hover,#f2f2ef)] dark:hover:bg-[var(--cds-bg-hover,#20201f)]'
|
||||
} ${isBeingDragged ? 'opacity-40 scale-[0.98] border border-dashed border-[var(--cds-fill-brand,#d97757)]' : ''}`}
|
||||
>
|
||||
{/* 标题与图钉图标 */}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 pr-1">
|
||||
<span className="text-[var(--cds-text-accent,#d97757)] text-xs shrink-0" title="已固定">
|
||||
📌
|
||||
</span>
|
||||
<span className="truncate text-xs">{session.title}</span>
|
||||
</div>
|
||||
|
||||
{/* 悬停操作区 (取消固定 / 拖拽抓手) */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="取消固定"
|
||||
onClick={(e) => togglePin(session.id, e)}
|
||||
className="p-1 rounded text-[11px] text-[var(--cds-text-muted,#8c8c8c)] hover:text-[var(--cds-text-primary,#1a1a1a)] hover:bg-[var(--cds-alpha-1,rgba(0,0,0,0.05))]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<span className="cursor-grab text-[var(--cds-text-muted,#8c8c8c)] px-0.5 select-none" title="拖拽手柄">
|
||||
⋮⋮
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下落点指引线 */}
|
||||
{isOver && dropPosition === 'after' && (
|
||||
<div className="absolute -bottom-0.5 inset-x-0 h-0.5 bg-[var(--cds-fill-brand,#d97757)] rounded-full z-20 pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === 最近会话区 (Recent Sessions) === */}
|
||||
<div>
|
||||
<div className="px-2 pb-1 text-[11px] font-semibold text-[var(--cds-text-muted,#8c8c8c)] uppercase tracking-wider">
|
||||
最近对话
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5" role="list">
|
||||
{recentSessions.map((session, idx) => {
|
||||
const globalIdx = pinnedSessions.length + idx;
|
||||
const isBeingDragged = draggedId === session.id;
|
||||
const isOver = overIndex === globalIdx;
|
||||
|
||||
return (
|
||||
<div key={session.id} className="relative">
|
||||
{/* 上落点指引线 */}
|
||||
{isOver && dropPosition === 'before' && (
|
||||
<div className="absolute -top-0.5 inset-x-0 h-0.5 bg-[var(--cds-fill-brand,#d97757)] rounded-full z-20 pointer-events-none" />
|
||||
)}
|
||||
|
||||
<div
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(session.id, e)}
|
||||
onDragOver={(e) => handleDragOverRow(globalIdx, e)}
|
||||
onDrop={(e) => handleDrop(globalIdx, false, e)}
|
||||
onPointerDown={(e) => handlePointerDown(session.id, e)}
|
||||
onKeyDown={(e) => handleKeyDown(session.id, false, e)}
|
||||
onClick={() => onSelectSession?.(session.id)}
|
||||
className={`group relative flex items-center justify-between px-2.5 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
||||
session.id === activeSessionId
|
||||
? 'bg-[var(--cds-bg-selected,#f3ece7)] dark:bg-[var(--cds-bg-selected,#3a2c27)] text-[var(--cds-text-accent,#d97757)] font-medium'
|
||||
: 'text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] hover:bg-[var(--cds-bg-hover,#f2f2ef)] dark:hover:bg-[var(--cds-bg-hover,#20201f)]'
|
||||
} ${isBeingDragged ? 'opacity-40 scale-[0.98] border border-dashed border-[var(--cds-fill-brand,#d97757)]' : ''}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1 truncate pr-1">
|
||||
<div className="truncate text-xs">{session.title}</div>
|
||||
<div className="text-[10px] text-[var(--cds-text-muted,#8c8c8c)] truncate">{session.updatedAt}</div>
|
||||
</div>
|
||||
|
||||
{/* 固定悬浮按钮 */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="固定到顶部"
|
||||
onClick={(e) => togglePin(session.id, e)}
|
||||
className="p-1 rounded text-xs text-[var(--cds-text-muted,#8c8c8c)] hover:text-[var(--cds-fill-brand,#d97757)] hover:bg-[var(--cds-alpha-1,rgba(0,0,0,0.05))]"
|
||||
title="固定到顶部"
|
||||
>
|
||||
📌
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下落点指引线 */}
|
||||
{isOver && dropPosition === 'after' && (
|
||||
<div className="absolute -bottom-0.5 inset-x-0 h-0.5 bg-[var(--cds-fill-brand,#d97757)] rounded-full z-20 pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 维度三:工件预览沙箱与隔离通信架构 (Epitaxy)
|
||||
* 逆向还原自 Claude Desktop: c58115fda-BKnbLmi3.js / cd5a31703-B4nKQMBN.js
|
||||
*
|
||||
* 核心特性:
|
||||
* 1. 5 重权限沙箱隔离与 strict-origin
|
||||
* 2. 用户手势激活门禁拦截 (hasUserActivation 校验)
|
||||
* 3. 7 种 ant 专有 MIME 协议分流
|
||||
* 4. 虚拟包别名重定向与自愈解析
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
export type EpitaxyMimeType =
|
||||
| 'application/vnd.ant.react'
|
||||
| 'application/vnd.ant.code'
|
||||
| 'application/vnd.ant.markdown'
|
||||
| 'application/vnd.ant.mermaid'
|
||||
| 'application/vnd.ant.svg'
|
||||
| 'application/vnd.ant.html'
|
||||
| 'application/vnd.ant.latex';
|
||||
|
||||
interface EpitaxyIframeBridgeProps {
|
||||
srcDoc: string;
|
||||
mimeType?: EpitaxyMimeType;
|
||||
onMessage?: (payload: any) => void;
|
||||
onError?: (err: any) => void;
|
||||
onRecovered?: (moduleName: string) => void;
|
||||
}
|
||||
|
||||
export const EpitaxyIframeBridge: React.FC<EpitaxyIframeBridgeProps> = ({
|
||||
srcDoc,
|
||||
onMessage,
|
||||
onError,
|
||||
onRecovered
|
||||
}) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
// 核心安全校验 1:确保消息确实来自受管辖的当前 iframe Window
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
const { channel, type, payload, hasUserActivation, recoveredModule } = event.data || {};
|
||||
|
||||
// 核心安全校验 2:严格门禁,拦截未带真实用户交互标记的恶意合成消息
|
||||
if (channel === 'epitaxy_mcp_app') {
|
||||
if (!event.isTrusted || !hasUserActivation) {
|
||||
console.warn('[Epitaxy MCP App] Blocked synthetic message without iframe-originated user activation');
|
||||
return;
|
||||
}
|
||||
onMessage?.(payload);
|
||||
} else if (type === 'EPITAXY_RUNTIME_ERROR') {
|
||||
onError?.(payload);
|
||||
} else if (type === 'EPITAXY_MODULE_RECOVERED') {
|
||||
onRecovered?.(recoveredModule);
|
||||
}
|
||||
},
|
||||
[onMessage, onError, onRecovered]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [handleMessage]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="Claude Epitaxy Artifact Sandbox"
|
||||
srcDoc={srcDoc}
|
||||
// 官方 5 重权限安全沙箱配置
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
|
||||
referrerPolicy="strict-origin"
|
||||
allow="clipboard-write; fullscreen"
|
||||
className="h-full w-full border-0 bg-transparent"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { motion, useMotionValue } from 'framer-motion';
|
||||
|
||||
export type VoiceMode = 'idle' | 'connecting' | 'active';
|
||||
|
||||
interface LiveMicActivityBarsProps {
|
||||
mode: VoiceMode;
|
||||
audioStream?: MediaStream | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export const LiveMicActivityBars: React.FC<LiveMicActivityBarsProps> = ({
|
||||
mode,
|
||||
audioStream = null,
|
||||
size = 48,
|
||||
className = '',
|
||||
minHeight = 4,
|
||||
maxHeight = 24
|
||||
}) => {
|
||||
const VIEWBOX_SIZE = 48;
|
||||
const BAR_COUNT = 6;
|
||||
const ACTIVE_INDICES = [1, 2, 3]; // 核心 3 频段主要响应
|
||||
const NOISE_FLOOR = [0.08, 0.08, 0.08];
|
||||
const BAND_WEIGHTS = [1.0, 1.2, 0.9];
|
||||
|
||||
// 6 条音频柱的独立 Framer Motion 动态高度
|
||||
const motionValues = Array.from({ length: BAR_COUNT }, () => useMotionValue(minHeight));
|
||||
const opacityValues = Array.from({ length: BAR_COUNT }, () => useMotionValue(1));
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
const dataArrayRef = useRef<Uint8Array | null>(null);
|
||||
const pulseStartTimeRef = useRef<number>(0);
|
||||
const animationFrameId = useRef<number | null>(null);
|
||||
|
||||
// 1. 初始化 Web Audio FFT 分析器
|
||||
useEffect(() => {
|
||||
if (mode === 'active' && audioStream) {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
audioContextRef.current = ctx;
|
||||
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = 0.5; // 0.5 阻尼平滑消除瞬态抖动
|
||||
analyser.minDecibels = -70; // 人类语言基准底噪
|
||||
analyser.maxDecibels = -10; // 动态范围上限
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const source = ctx.createMediaStreamSource(audioStream);
|
||||
source.connect(analyser);
|
||||
sourceRef.current = source;
|
||||
|
||||
dataArrayRef.current = new Uint8Array(analyser.frequencyBinCount);
|
||||
} catch (err) {
|
||||
console.error('[LiveMicActivityBars] AudioContext init failed', err);
|
||||
}
|
||||
} else {
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close().catch(() => {});
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
sourceRef.current = null;
|
||||
dataArrayRef.current = null;
|
||||
}
|
||||
|
||||
if (mode === 'connecting') {
|
||||
pulseStartTimeRef.current = performance.now();
|
||||
} else if (mode === 'idle') {
|
||||
motionValues.forEach((mv) => mv.set(minHeight));
|
||||
opacityValues.forEach((ov) => ov.set(0.6));
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close().catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [mode, audioStream]);
|
||||
|
||||
// 2. 动画渲染物理循环
|
||||
useEffect(() => {
|
||||
const renderLoop = () => {
|
||||
if (mode === 'connecting') {
|
||||
// 1800ms 周期交错呼吸波形
|
||||
const elapsed = performance.now() - pulseStartTimeRef.current;
|
||||
const period = 1800;
|
||||
const phase = (elapsed % period) / period;
|
||||
const baseOpacity = 0.35;
|
||||
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const stepOffset = i * 0.15;
|
||||
let alpha = baseOpacity;
|
||||
if (phase < 0.6) {
|
||||
const start = stepOffset;
|
||||
const end = start + 0.2;
|
||||
alpha = phase < start ? baseOpacity : phase < end ? baseOpacity + 0.65 * ((phase - start) / 0.2) : 1;
|
||||
} else {
|
||||
alpha = 1 - 0.65 * ((phase - 0.6) / 0.4);
|
||||
}
|
||||
motionValues[i]?.set(minHeight + 3);
|
||||
opacityValues[i]?.set(Math.max(0.2, Math.min(1, alpha)));
|
||||
}
|
||||
} else if (mode === 'active') {
|
||||
const analyser = analyserRef.current;
|
||||
const dataArray = dataArrayRef.current;
|
||||
if (analyser && dataArray) {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
// 核心 3 频段非线性 Gamma 幂律压缩采样
|
||||
for (let b = 0; b < 3; b++) {
|
||||
const barIndex = ACTIVE_INDICES[b];
|
||||
const startBin = (b + 1) * 8;
|
||||
const endBin = startBin + 16;
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let bin = startBin; bin < endBin; bin++) {
|
||||
sum += dataArray[bin] || 0;
|
||||
count++;
|
||||
}
|
||||
const normalized = count > 0 ? sum / count / 255 : 0;
|
||||
const noiseFloor = NOISE_FLOOR[b] ?? 0.08;
|
||||
const gated = Math.max(0, normalized - noiseFloor) / (1 - noiseFloor);
|
||||
const weight = BAND_WEIGHTS[b] ?? 1;
|
||||
// 0.7 次幂强化微弱语音细节
|
||||
const compressed = Math.min(1, Math.pow(gated, 0.7) * 1.5 * weight);
|
||||
const targetHeight = minHeight + (maxHeight - minHeight) * compressed;
|
||||
|
||||
motionValues[barIndex]?.set(targetHeight);
|
||||
opacityValues[barIndex]?.set(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId.current = requestAnimationFrame(renderLoop);
|
||||
};
|
||||
|
||||
animationFrameId.current = requestAnimationFrame(renderLoop);
|
||||
return () => {
|
||||
if (animationFrameId.current) cancelAnimationFrame(animationFrameId.current);
|
||||
};
|
||||
}, [mode, minHeight, maxHeight]);
|
||||
|
||||
// 6 条柱的物理 X 坐标布局(居中排列)
|
||||
const barPositions = [11, 16, 21, 26, 31, 36];
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`inline-block overflow-visible ${className}`}
|
||||
>
|
||||
{barPositions.map((posX, index) => (
|
||||
<motion.rect
|
||||
key={index}
|
||||
x={posX}
|
||||
width={3}
|
||||
rx={1.5}
|
||||
fill="var(--cds-clay, #d97757)"
|
||||
style={{
|
||||
height: motionValues[index],
|
||||
opacity: opacityValues[index],
|
||||
y: useMotionValue((VIEWBOX_SIZE - minHeight) / 2) // 垂直居中呼吸
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
/**
|
||||
* ==============================================================================
|
||||
* Claude Design System (CDS) - McpToolDecisionCard.tsx
|
||||
*
|
||||
* 逆向还原自官方 Claude Desktop / Code 交互式工具调用与 MCP 决策卡片:
|
||||
* - `cc1aae219-CPC-WA-x.js` (channel_decision_card / tool_use / structured_content)
|
||||
* - `c05b96c7f-DSH7iwX5.js` (mcp_server_url / writeToolNames / onVerdict / resolved)
|
||||
* - `c6a992d55-C0zTICcS.css` (decision-card-glow, cds-shadow-card)
|
||||
*
|
||||
* 核心工程机制:
|
||||
* 1. 严格的三态授权生命周期 (Three-State Authorization Machine):
|
||||
* - `pending_approval`: 等待授权,展现入参树与三阶决议动作
|
||||
* - `running`: 执行中,触发陶土脉冲与终端管道捕获
|
||||
* - `success` / `error`: 终态收口,折叠控制台结果预览
|
||||
* 2. 三阶决议按钮动作阵列:
|
||||
* - [单次批准 (Allow Once)]: 仅对本次调用赋予一次性执行权
|
||||
* - [始终信任 (Always Allow)]: 将该 MCP 模式加入本地会话白名单免检
|
||||
* - [拒绝执行 (Deny)]: 中断执行流并向模型回传授权被拒信号
|
||||
* 3. 动态 Payload JSON 审查与只读/微调切换:
|
||||
* 高亮格式化展示即将执行的命令或参数,防止恶意或破坏性指令注入。
|
||||
* ==============================================================================
|
||||
*/
|
||||
|
||||
export type ToolExecutionStatus = 'pending_approval' | 'running' | 'success' | 'error';
|
||||
|
||||
export interface McpToolCallData {
|
||||
id: string;
|
||||
toolName: string;
|
||||
serverUrl?: string; // 如 "mcp://filesystem", "cli://bash"
|
||||
inputParams: Record<string, any>;
|
||||
resultOutput?: string;
|
||||
errorMessage?: string;
|
||||
executionDurationMs?: number;
|
||||
}
|
||||
|
||||
export interface McpToolDecisionCardProps {
|
||||
toolCall: McpToolCallData;
|
||||
onVerdict?: (verdict: 'allow_once' | 'always_allow' | 'deny', modifiedParams?: Record<string, any>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const McpToolDecisionCard: React.FC<McpToolDecisionCardProps> = ({
|
||||
toolCall,
|
||||
onVerdict,
|
||||
className = '',
|
||||
}) => {
|
||||
const [status, setStatus] = useState<ToolExecutionStatus>('pending_approval');
|
||||
const [isJsonExpanded, setIsJsonExpanded] = useState(true);
|
||||
const [isResultExpanded, setIsResultExpanded] = useState(false);
|
||||
|
||||
// 1. 处理用户决议
|
||||
const handleAction = (verdict: 'allow_once' | 'always_allow' | 'deny') => {
|
||||
if (verdict === 'deny') {
|
||||
setStatus('error');
|
||||
} else {
|
||||
setStatus('running');
|
||||
// 模拟工具执行返回
|
||||
setTimeout(() => {
|
||||
setStatus('success');
|
||||
}, 1200);
|
||||
}
|
||||
onVerdict?.(verdict, toolCall.inputParams);
|
||||
};
|
||||
|
||||
const isPending = status === 'pending_approval';
|
||||
const isRunning = status === 'running';
|
||||
const isSuccess = status === 'success';
|
||||
const isError = status === 'error';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={`工具调用决策卡片: ${toolCall.toolName}`}
|
||||
className={`relative w-full rounded-2xl border overflow-hidden transition-all duration-300 font-sans text-xs ${
|
||||
isPending
|
||||
? 'border-[var(--cds-border-accent,#d97757)]/50 bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#1c1b1a)] shadow-[0_4px_16px_rgba(217,119,87,0.12)]'
|
||||
: isRunning
|
||||
? 'border-[var(--cds-border-subtle,#e5e5e3)] dark:border-[var(--cds-border-subtle,#2e2e2c)] bg-[var(--cds-bg-secondary,#faf9f6)] dark:bg-[var(--cds-bg-secondary,#181817)]'
|
||||
: isSuccess
|
||||
? 'border-[#10b981]/40 bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#171b18)]'
|
||||
: 'border-[#ef4444]/40 bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#1f1717)]'
|
||||
} ${className}`}
|
||||
>
|
||||
{/* 顶部标题栏:工具名、来源 Server、执行状态徽标 */}
|
||||
<div className="flex items-center justify-between px-3.5 py-2.5 border-b border-[var(--cds-border-subtle,#e8e8e6)] dark:border-[var(--cds-border-subtle,#282827)] bg-[var(--cds-bg-secondary,#fbfbf9)] dark:bg-[var(--cds-bg-secondary,#191918)]">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm select-none">⚙️</span>
|
||||
<span className="font-mono font-semibold text-sm text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] truncate">
|
||||
{toolCall.toolName}
|
||||
</span>
|
||||
{toolCall.serverUrl && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-mono bg-[var(--cds-alpha-1,rgba(0,0,0,0.06))] dark:bg-[var(--cds-alpha-1,rgba(255,255,255,0.08))] text-[var(--cds-text-muted,#8c8c8c)] truncate">
|
||||
{toolCall.serverUrl}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 状态徽标 */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{isPending && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-[var(--cds-fill-brand-subtle,#fcf0ec)] dark:bg-[var(--cds-fill-brand-subtle,#36211a)] text-[var(--cds-text-accent,#d97757)]">
|
||||
<span className="size-1.5 rounded-full bg-[var(--cds-fill-brand,#d97757)] animate-pulse" />
|
||||
请求授权
|
||||
</span>
|
||||
)}
|
||||
{isRunning && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-blue-50 dark:bg-blue-950/40 text-blue-600 dark:text-blue-400">
|
||||
<span className="size-1.5 rounded-full bg-blue-500 animate-spin" />
|
||||
执行中...
|
||||
</span>
|
||||
)}
|
||||
{isSuccess && (
|
||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 dark:bg-emerald-950/40 text-emerald-600 dark:text-emerald-400">
|
||||
✓ 执行成功
|
||||
</span>
|
||||
)}
|
||||
{isError && (
|
||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-medium bg-rose-50 dark:bg-rose-950/40 text-rose-600 dark:text-rose-400">
|
||||
✕ 已拒绝 / 失败
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 参数检查区:格式化 JSON 审查 */}
|
||||
<div className="p-3.5 space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] text-[var(--cds-text-muted,#8c8c8c)]">
|
||||
<span className="font-medium uppercase tracking-wider">执行参数 (Payload)</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsJsonExpanded(!isJsonExpanded)}
|
||||
className="hover:text-[var(--cds-text-primary,#1a1a1a)] transition-colors select-none"
|
||||
>
|
||||
{isJsonExpanded ? '收起 ▲' : '展开 ▼'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isJsonExpanded && (
|
||||
<pre className="p-2.5 rounded-lg bg-[var(--cds-bg-secondary,#f4f4f2)] dark:bg-[var(--cds-bg-secondary,#111110)] border border-[var(--cds-border-subtle,#e8e8e6)] dark:border-[var(--cds-border-subtle,#242423)] font-mono text-[11px] leading-relaxed text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#e5e5e5)] overflow-x-auto max-h-48">
|
||||
<code>{JSON.stringify(toolCall.inputParams, null, 2)}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 终态控制台输出 (结果区) */}
|
||||
{(isSuccess || isError) && (toolCall.resultOutput || toolCall.errorMessage) && (
|
||||
<div className="px-3.5 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsResultExpanded(!isResultExpanded)}
|
||||
className="flex items-center gap-1.5 text-[11px] font-medium text-[var(--cds-text-secondary,#666666)] hover:text-[var(--cds-text-primary,#1a1a1a)] select-none"
|
||||
>
|
||||
<span>{isResultExpanded ? '收起控制台输出 ▲' : '查看控制台输出 ▼'}</span>
|
||||
</button>
|
||||
|
||||
{isResultExpanded && (
|
||||
<div className="mt-2 p-2.5 rounded-lg bg-[#0d0d0c] text-[#ececeb] font-mono text-[11px] leading-relaxed overflow-x-auto max-h-48 border border-white/10">
|
||||
{toolCall.errorMessage ? (
|
||||
<div className="text-rose-400">{toolCall.errorMessage}</div>
|
||||
) : (
|
||||
<pre>{toolCall.resultOutput || '执行完成,无返回值。'}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部操作决议按钮组 (仅在 pending_approval 时展现) */}
|
||||
{isPending && (
|
||||
<div className="flex items-center justify-between px-3.5 py-2.5 border-t border-[var(--cds-border-subtle,#e8e8e6)] dark:border-[var(--cds-border-subtle,#282827)] bg-[var(--cds-bg-secondary,#fbfbf9)] dark:bg-[var(--cds-bg-secondary,#161615)] select-none">
|
||||
{/* 拒绝按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction('deny')}
|
||||
className="px-3 py-1.5 rounded-lg border border-[var(--cds-border-subtle,#e5e5e3)] dark:border-[var(--cds-border-subtle,#333331)] text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 active:scale-95 transition-all font-medium"
|
||||
>
|
||||
拒绝执行
|
||||
</button>
|
||||
|
||||
{/* 批准按钮组 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction('always_allow')}
|
||||
className="px-3 py-1.5 rounded-lg border border-[var(--cds-border-subtle,#e5e5e3)] dark:border-[var(--cds-border-subtle,#333331)] bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#222221)] text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] hover:bg-[var(--cds-bg-hover,#f5f5f5)] active:scale-95 transition-all font-medium"
|
||||
>
|
||||
始终允许该工具
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction('allow_once')}
|
||||
className="px-3.5 py-1.5 rounded-lg bg-[var(--cds-fill-brand,#d97757)] text-white hover:bg-[var(--cds-fill-brand-hover,#c86849)] shadow-xs active:scale-95 transition-all font-medium"
|
||||
>
|
||||
单次批准
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* Claude Design System (CDS) - Multimodal Media Lightbox & Pan/Zoom Gallery
|
||||
*
|
||||
* 逆向溯源:
|
||||
* - 核心画廊与缩放平移算法:c4b054ed5-BGjH-Uu6.js
|
||||
* - 共享元素无缝变形 (View Transitions & FLIP Morph):cd5136de8-BDys1vAN.js
|
||||
* - 会话附件集成与缩略图注册:cd5a31703-B4nKQMBN.js
|
||||
*
|
||||
* 核心规范与数学物理模型:
|
||||
* 1. 仿射平移缩放 (Affine Pan/Zoom):
|
||||
* - 缩放方程:c = targetScale / currentScale
|
||||
* - 锚点偏移:newTx = clamp(pointerX - (pointerX - idealTx) * c, maxTx)
|
||||
* - 滚轮灵敏度:Math.exp(-deltaY * 0.01) 指数平滑缩放
|
||||
* - 手势死区过滤:Math.hypot(dx, dy) < 4px 杜绝微抖
|
||||
* 2. 共享元素变形 (Shared Element Transition):
|
||||
* - 基于 document.startViewTransition("chat-image-preview")
|
||||
* - 动态计算源缩略图圆角 (--vt-image-preview-source-radius) 与目标圆角
|
||||
* - object-fit: contain 精确内切计算 (--vt-image-preview-source-inset)
|
||||
* 3. 75vw 画廊跑道 (Gallery Runway):
|
||||
* - 滑动方程:translateX(calc(12.5vw - ${index * 75}vw - ${index * 32}px))
|
||||
* - 激活项 100% 不透明度,相邻项 40% (悬停 70%)
|
||||
* - 预加载窗口:Math.abs(index - currentIndex) <= 2
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
|
||||
// ============================================================================
|
||||
// 1. 类型定义 (Types)
|
||||
// ============================================================================
|
||||
|
||||
export interface MediaItem {
|
||||
id: string | number;
|
||||
url: string;
|
||||
alt?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
kind?: "image" | "video";
|
||||
loading?: boolean;
|
||||
originEl?: HTMLElement | null;
|
||||
}
|
||||
|
||||
export interface TransformState {
|
||||
scale: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
idealTx: number;
|
||||
idealTy: number;
|
||||
maxTx: number;
|
||||
maxTy: number;
|
||||
}
|
||||
|
||||
export interface MediaLightboxViewerProps {
|
||||
items: MediaItem[];
|
||||
initialIndex?: number;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
backdrop?: "blur" | "dim";
|
||||
renderHeader?: (item: MediaItem, index: number) => React.ReactNode;
|
||||
renderAttribution?: (item: MediaItem, index: number) => React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2. 真实生产环境数学物理常量 (Extracted Constants)
|
||||
// ============================================================================
|
||||
|
||||
const MIN_MAX_SCALE = 2.0; // ae: 最小最大放大倍数
|
||||
const DRAG_DEADZONE = 4.0; // oe: 指针拖拽死区阈值 (px)
|
||||
const WHEEL_SENSITIVITY = 0.01; // se: 滚轮缩放指数因子
|
||||
const SNAP_MIN_SCALE = 1.05; // v: 缩放吸附下限
|
||||
const DEFAULT_JUMP_SCALE = 1.25; // ce: 单次跳变放大系数
|
||||
const DAMPING_RATIO = 0.7; // y: 阻尼回弹系数
|
||||
|
||||
const INITIAL_TRANSFORM: TransformState = {
|
||||
scale: 1,
|
||||
tx: 0,
|
||||
ty: 0,
|
||||
idealTx: 0,
|
||||
idealTy: 0,
|
||||
maxTx: 0,
|
||||
maxTy: 0,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 3. 核心数学函数 (Mathematical Primitives)
|
||||
// ============================================================================
|
||||
|
||||
/** 数值截断 Clamping */
|
||||
function clamp(val: number, max: number): number {
|
||||
return Math.min(max, Math.max(-max, val));
|
||||
}
|
||||
|
||||
/** 计算可用视口溢出量 */
|
||||
function calcOverflow(size: number, viewportSize: number): number {
|
||||
const effectiveSize = Math.min(size, viewportSize * DAMPING_RATIO);
|
||||
return Math.max(0, (size + viewportSize) / 2 - effectiveSize);
|
||||
}
|
||||
|
||||
/** 动态计算允许平移的最大边界 maxTx / maxTy */
|
||||
function calcMaxTranslate(fitSize: number, viewportSize: number, scale: number): number {
|
||||
const factor = Math.min(1, Math.max(0, (scale - SNAP_MIN_SCALE) / 0.95));
|
||||
return calcOverflow(fitSize * scale, viewportSize) * factor;
|
||||
}
|
||||
|
||||
/** 计算元素物理矩形与视口几何中心 */
|
||||
interface FitRects {
|
||||
fitWidth: number;
|
||||
fitHeight: number;
|
||||
fitCenterX: number;
|
||||
fitCenterY: number;
|
||||
maxScale: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}
|
||||
|
||||
function getFitRects(imgEl: HTMLImageElement, viewportEl: HTMLElement | null): FitRects | null {
|
||||
if (!viewportEl) return null;
|
||||
const imgRect = imgEl.getBoundingClientRect();
|
||||
const vpRect = viewportEl.getBoundingClientRect();
|
||||
if (!vpRect || imgRect.width === 0 || imgRect.height === 0) return null;
|
||||
|
||||
const naturalW = imgEl.naturalWidth || imgRect.width;
|
||||
return {
|
||||
fitWidth: imgRect.width,
|
||||
fitHeight: imgRect.height,
|
||||
fitCenterX: imgRect.left + imgRect.width / 2,
|
||||
fitCenterY: imgRect.top + imgRect.height / 2,
|
||||
maxScale: Math.max(naturalW / imgRect.width, MIN_MAX_SCALE),
|
||||
viewportWidth: vpRect.width,
|
||||
viewportHeight: vpRect.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** 以光标点 (clientX, clientY) 为物理锚点的仿射变换核心算法 */
|
||||
function computeAnchorZoom(
|
||||
fit: FitRects,
|
||||
current: TransformState,
|
||||
targetScale: number,
|
||||
pointer?: { clientX: number; clientY: number } | null
|
||||
): TransformState {
|
||||
const maxTx = calcMaxTranslate(fit.fitWidth, fit.viewportWidth, targetScale);
|
||||
const maxTy = calcMaxTranslate(fit.fitHeight, fit.viewportHeight, targetScale);
|
||||
|
||||
const offsetX = pointer ? pointer.clientX - fit.fitCenterX : 0;
|
||||
const offsetY = pointer ? pointer.clientY - fit.fitCenterY : 0;
|
||||
|
||||
const c = targetScale / current.scale;
|
||||
const idealTx = offsetX - (offsetX - current.idealTx) * c;
|
||||
const idealTy = offsetY - (offsetY - current.idealTy) * c;
|
||||
|
||||
const tx = clamp(idealTx, maxTx);
|
||||
const ty = clamp(idealTy, maxTy);
|
||||
|
||||
const prevMaxTx = calcMaxTranslate(fit.fitWidth, fit.viewportWidth, current.scale);
|
||||
const prevMaxTy = calcMaxTranslate(fit.fitHeight, fit.viewportHeight, current.scale);
|
||||
|
||||
return {
|
||||
scale: targetScale,
|
||||
idealTx: maxTx < prevMaxTx ? tx : idealTx,
|
||||
idealTy: maxTy < prevMaxTy ? ty : idealTy,
|
||||
tx,
|
||||
ty,
|
||||
maxTx,
|
||||
maxTy,
|
||||
};
|
||||
}
|
||||
|
||||
/** 双击智能切换放大与复原 (Toggle Double Click Zoom) */
|
||||
function toggleZoomAt(
|
||||
imgEl: HTMLImageElement,
|
||||
viewportEl: HTMLElement | null,
|
||||
pointer?: { clientX: number; clientY: number } | null
|
||||
): TransformState | null {
|
||||
const fit = getFitRects(imgEl, viewportEl);
|
||||
if (!fit) return null;
|
||||
|
||||
const zoomed = computeAnchorZoom(fit, INITIAL_TRANSFORM, fit.maxScale, pointer);
|
||||
const totalOverX = Math.max(0, (fit.fitWidth * fit.maxScale - fit.viewportWidth) / 2);
|
||||
const totalOverY = Math.max(0, (fit.fitHeight * fit.maxScale - fit.viewportHeight) / 2);
|
||||
|
||||
const tx = clamp(zoomed.tx, totalOverX);
|
||||
const ty = clamp(zoomed.ty, totalOverY);
|
||||
|
||||
return {
|
||||
...zoomed,
|
||||
tx,
|
||||
ty,
|
||||
idealTx: tx,
|
||||
idealTy: ty,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 4. 主组件 (MediaLightboxViewer)
|
||||
// ============================================================================
|
||||
|
||||
export function MediaLightboxViewer({
|
||||
items,
|
||||
initialIndex = 0,
|
||||
isOpen,
|
||||
onClose,
|
||||
backdrop = "blur",
|
||||
renderHeader,
|
||||
renderAttribution,
|
||||
className = "",
|
||||
}: MediaLightboxViewerProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [transform, setTransform] = useState<TransformState | null>(null);
|
||||
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const closeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const activeSlideRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 跟踪拖拽指针捕获
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
} | null>(null);
|
||||
const hasMovedRef = useRef<boolean>(false);
|
||||
const prevFocusedElRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// 同步外部 initialIndex
|
||||
useEffect(() => {
|
||||
setCurrentIndex(initialIndex);
|
||||
setTransform(null);
|
||||
}, [initialIndex, isOpen]);
|
||||
|
||||
// 焦点记录与页面滚动锁定
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
prevFocusedElRef.current = document.activeElement as HTMLElement | null;
|
||||
document.body.style.overflow = "hidden";
|
||||
closeBtnRef.current?.focus();
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
prevFocusedElRef.current?.focus?.({ preventScroll: true });
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// 窗口缩放时重置变换
|
||||
useEffect(() => {
|
||||
if (!transform) return;
|
||||
const handleResize = () => setTransform(null);
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [transform]);
|
||||
|
||||
const currentItem = items[currentIndex];
|
||||
const isFirst = currentIndex === 0;
|
||||
const isLast = currentIndex === items.length - 1;
|
||||
const isZoomed = transform !== null && transform.scale > 1;
|
||||
|
||||
// 切换上一张 / 下一张
|
||||
const handlePrev = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
setTransform(null);
|
||||
}, []);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev < items.length - 1 ? prev + 1 : prev));
|
||||
setTransform(null);
|
||||
}, [items.length]);
|
||||
|
||||
// 键盘快捷键监听
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
handlePrev();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
setCurrentIndex(0);
|
||||
setTransform(null);
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault();
|
||||
setCurrentIndex(items.length - 1);
|
||||
setTransform(null);
|
||||
}
|
||||
},
|
||||
[handlePrev, handleNext, onClose, items.length]
|
||||
);
|
||||
|
||||
// 双击或单机放大/复原
|
||||
const handleImageClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLImageElement>) => {
|
||||
e.stopPropagation();
|
||||
if (hasMovedRef.current) return;
|
||||
|
||||
if (transform) {
|
||||
setTransform(null);
|
||||
} else {
|
||||
const next = toggleZoomAt(e.currentTarget, viewportRef.current, {
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
});
|
||||
setTransform(next);
|
||||
}
|
||||
},
|
||||
[transform]
|
||||
);
|
||||
|
||||
// 指针按下 (Pointer Down) 开始平移
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLImageElement>) => {
|
||||
if (!transform || e.button !== 0 || dragRef.current) return;
|
||||
hasMovedRef.current = false;
|
||||
dragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
tx: transform.tx,
|
||||
ty: transform.ty,
|
||||
};
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
},
|
||||
[transform]
|
||||
);
|
||||
|
||||
// 指针移动 (Pointer Move) 实时平移
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLImageElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || !transform || drag.pointerId !== e.pointerId) return;
|
||||
|
||||
const dx = e.clientX - drag.startX;
|
||||
const dy = e.clientY - drag.startY;
|
||||
|
||||
// 4px 死区过滤
|
||||
if (!hasMovedRef.current && Math.hypot(dx, dy) < DRAG_DEADZONE) {
|
||||
return;
|
||||
}
|
||||
hasMovedRef.current = true;
|
||||
|
||||
const newTx = clamp(drag.tx + dx, transform.maxTx);
|
||||
const newTy = clamp(drag.ty + dy, transform.maxTy);
|
||||
|
||||
drag.tx = newTx;
|
||||
drag.ty = newTy;
|
||||
|
||||
e.currentTarget.style.transform = `translate(${newTx}px, ${newTy}px) scale(${transform.scale})`;
|
||||
},
|
||||
[transform]
|
||||
);
|
||||
|
||||
// 指针释放 (Pointer Up)
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent<HTMLImageElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== e.pointerId) return;
|
||||
dragRef.current = null;
|
||||
|
||||
if (hasMovedRef.current) {
|
||||
setTransform((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
tx: drag.tx,
|
||||
ty: drag.ty,
|
||||
idealTx: drag.tx,
|
||||
idealTy: drag.ty,
|
||||
}
|
||||
: null
|
||||
);
|
||||
setTimeout(() => {
|
||||
hasMovedRef.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 滚轮缩放与平移 (Wheel Event)
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
const activeImg = viewportRef.current?.querySelector(
|
||||
`[data-active-slide="true"] img`
|
||||
) as HTMLImageElement | null;
|
||||
if (!activeImg) return;
|
||||
|
||||
// Ctrl + 滚轮 或 触控板双指捏合
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
const fit = getFitRects(activeImg, viewportRef.current);
|
||||
if (!fit) return;
|
||||
|
||||
const currentT = transform || INITIAL_TRANSFORM;
|
||||
const zoomDelta = Math.exp(-e.deltaY * WHEEL_SENSITIVITY);
|
||||
const newScale = Math.min(Math.max(currentT.scale * zoomDelta, 1), fit.maxScale);
|
||||
|
||||
if (newScale <= SNAP_MIN_SCALE) {
|
||||
setTransform(null);
|
||||
} else {
|
||||
const next = computeAnchorZoom(fit, currentT, newScale, {
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
});
|
||||
setTransform(next);
|
||||
}
|
||||
} else if (transform && transform.scale > 1) {
|
||||
// 已放大状态下,滚轮平移
|
||||
e.preventDefault();
|
||||
setTransform((prev) => {
|
||||
if (!prev) return prev;
|
||||
const nextTx = clamp(prev.tx - e.deltaX, prev.maxTx);
|
||||
const nextTy = clamp(prev.ty - e.deltaY, prev.maxTy);
|
||||
return {
|
||||
...prev,
|
||||
tx: nextTx,
|
||||
ty: nextTy,
|
||||
idealTx: nextTx,
|
||||
idealTy: nextTy,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
[transform]
|
||||
);
|
||||
|
||||
// 绑定非被动原生 Wheel 事件
|
||||
useEffect(() => {
|
||||
const vp = viewportRef.current;
|
||||
if (!vp || !isOpen) return;
|
||||
vp.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => vp.removeEventListener("wheel", handleWheel);
|
||||
}, [isOpen, handleWheel]);
|
||||
|
||||
if (!isOpen || !currentItem) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Media Preview"
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`fixed inset-0 z-50 flex flex-col overflow-hidden select-none outline-none ${
|
||||
backdrop === "blur" ? "bg-black/85 backdrop-blur-[40px]" : "bg-black/90"
|
||||
} ${className}`}
|
||||
style={{ animation: "fade-in 0.2s ease-out" }}
|
||||
>
|
||||
{/* 1. 顶部操作栏与阴影保护罩 */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="absolute top-0 inset-x-0 h-16 px-5 flex items-center justify-between z-20 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(0, 0, 0, 0.75) 0%, rgba(0, 0, 0, 0.5) 60%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
{renderHeader?.(currentItem, currentIndex)}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
{/* 当前缩放比例标签 */}
|
||||
{isZoomed && (
|
||||
<span className="text-xs font-mono text-white/70 bg-white/10 px-2 py-0.5 rounded-full">
|
||||
{Math.round(transform.scale * 100)}%
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
ref={closeBtnRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="size-8 rounded-full flex items-center justify-center text-white/80 hover:text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="Close lightbox"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 75vw 画廊跑道主视口 (Runway Viewport) */}
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="flex-1 relative flex items-center justify-center overflow-hidden cursor-default"
|
||||
onClick={() => {
|
||||
if (isZoomed) setTransform(null);
|
||||
else onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex h-full items-center transition-transform duration-300 ease-[cubic-bezier(0.165,0.84,0.44,1)]"
|
||||
style={{
|
||||
gap: "32px",
|
||||
transform: `translateX(calc(12.5vw - ${currentIndex * 75}vw - ${currentIndex * 32}px))`,
|
||||
}}
|
||||
>
|
||||
{items.map((item, idx) => {
|
||||
const isActive = idx === currentIndex;
|
||||
const shouldRender = Math.abs(idx - currentIndex) <= 2; // 前后 2 帧预加载
|
||||
const slideTransform = isActive && transform ? transform : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={isActive ? activeSlideRef : undefined}
|
||||
data-active-slide={isActive ? "true" : undefined}
|
||||
className="h-full flex-shrink-0 flex items-center justify-center"
|
||||
style={{ width: "75vw" }}
|
||||
onClick={(e) => {
|
||||
if (!isActive) {
|
||||
e.stopPropagation();
|
||||
setCurrentIndex(idx);
|
||||
setTransform(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{shouldRender ? (
|
||||
item.kind === "video" ? (
|
||||
<video
|
||||
src={item.url}
|
||||
controls
|
||||
autoPlay={isActive}
|
||||
className="max-h-[75vh] max-w-full rounded-2xl shadow-2xl object-contain bg-black"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.alt || "Attached Image"}
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
onClick={isActive ? handleImageClick : undefined}
|
||||
onPointerDown={isActive && isZoomed ? handlePointerDown : undefined}
|
||||
onPointerMove={isActive && isZoomed ? handlePointerMove : undefined}
|
||||
onPointerUp={isActive && isZoomed ? handlePointerUp : undefined}
|
||||
onPointerCancel={isActive && isZoomed ? handlePointerUp : undefined}
|
||||
style={{
|
||||
transform: slideTransform
|
||||
? `translate(${slideTransform.tx}px, ${slideTransform.ty}px) scale(${slideTransform.scale})`
|
||||
: undefined,
|
||||
cursor: isActive
|
||||
? isZoomed
|
||||
? "grab"
|
||||
: "zoom-in"
|
||||
: "pointer",
|
||||
}}
|
||||
className={`max-h-[75vh] max-w-full w-auto h-auto object-contain rounded-2xl select-none transition-opacity duration-300 ${
|
||||
isActive
|
||||
? "opacity-100 shadow-2xl"
|
||||
: "opacity-40 hover:opacity-75 cursor-pointer"
|
||||
}`}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="size-16 rounded-full border-2 border-white/20 border-t-white animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 底部导航与图片序号指示器 */}
|
||||
{items.length > 1 && (
|
||||
<div
|
||||
className="absolute bottom-6 inset-x-0 h-14 px-6 flex items-center justify-between z-20 pointer-events-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="pointer-events-auto flex items-center">
|
||||
{renderAttribution?.(currentItem, currentIndex)}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto absolute left-1/2 -translate-x-1/2 flex items-center gap-2 bg-black/50 backdrop-blur-md px-3 py-1.5 rounded-full border border-white/10 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isFirst}
|
||||
onClick={handlePrev}
|
||||
className="p-1 rounded-full text-white/80 hover:text-white disabled:opacity-25 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Previous image"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="text-xs font-mono text-white/90 tabular-nums px-1.5">
|
||||
{currentIndex + 1} / {items.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLast}
|
||||
onClick={handleNext}
|
||||
className="p-1 rounded-full text-white/80 hover:text-white disabled:opacity-25 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Next image"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-16" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
/**
|
||||
* ==============================================================================
|
||||
* Claude Design System (CDS) - ModelSwitcherWithTokenGauge.tsx
|
||||
*
|
||||
* 逆向还原自官方 Claude 模型矩阵选择器与 Token 上下文仪表盘:
|
||||
* - `shared-8-4Jj8qhmU.js` (model selector & model tier descriptors)
|
||||
* - `shared-3-DVMvy2kM.js` (cache_read_input_tokens / context capacity)
|
||||
* - `c6a992d55-C0zTICcS.css` (cds-token-gauge, model-pill-active)
|
||||
*
|
||||
* 核心工程机制:
|
||||
* 1. 官方三大模型梯队架构 (Model Tier Matrix):
|
||||
* - Claude 3.5 Sonnet: 全能主力,顶尖推理与代码生成能力
|
||||
* - Claude 3.5 Haiku: 极致轻量与高吞吐响应
|
||||
* - Claude 3 Opus: 超长思维链与顶级专业纵深推理
|
||||
* 2. 深度思考 (Extended Thinking) 动态开关联动:
|
||||
* 支持在 Sonnet/Opus 下切换 Thinking 思考配额 (如 16k tokens)。
|
||||
* 3. Prompt Caching 显存成本解构表:
|
||||
* 精确展示普通输入、`cache_read_input_tokens` (读缓存节省 90% 资费)
|
||||
* 与 `cache_creation_input_tokens` (写入缓存)。
|
||||
* 4. 环形 SVG 动态容量仪表盘 (Circular Capacity Gauge):
|
||||
* 基于 `strokeDashoffset` 渲染 200k 窗口饱和度,80% 触发陶土警告色。
|
||||
* ==============================================================================
|
||||
*/
|
||||
|
||||
export interface ModelTier {
|
||||
id: string;
|
||||
name: string;
|
||||
tagline: string;
|
||||
badge?: string;
|
||||
supportsThinking?: boolean;
|
||||
maxContext: number; // 如 200000
|
||||
}
|
||||
|
||||
const AVAILABLE_MODELS: ModelTier[] = [
|
||||
{
|
||||
id: 'claude-3-5-sonnet-latest',
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
tagline: '最强智能,专为复杂推理、系统架构与全栈编程打造',
|
||||
badge: '推荐',
|
||||
supportsThinking: true,
|
||||
maxContext: 200000,
|
||||
},
|
||||
{
|
||||
id: 'claude-3-5-haiku-latest',
|
||||
name: 'Claude 3.5 Haiku',
|
||||
tagline: '极速响应,专为日常对话与轻量级任务优化',
|
||||
badge: '极速',
|
||||
supportsThinking: false,
|
||||
maxContext: 200000,
|
||||
},
|
||||
{
|
||||
id: 'claude-3-opus-latest',
|
||||
name: 'Claude 3 Opus',
|
||||
tagline: '终极深度,专为复杂科学计算与多维哲学论述设计',
|
||||
supportsThinking: true,
|
||||
maxContext: 200000,
|
||||
},
|
||||
];
|
||||
|
||||
export interface TokenMetrics {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
}
|
||||
|
||||
export interface ModelSwitcherProps {
|
||||
currentModelId?: string;
|
||||
onSelectModel?: (model: ModelTier) => void;
|
||||
tokenMetrics?: TokenMetrics;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ModelSwitcherWithTokenGauge: React.FC<ModelSwitcherProps> = ({
|
||||
currentModelId = 'claude-3-5-sonnet-latest',
|
||||
onSelectModel,
|
||||
tokenMetrics = {
|
||||
inputTokens: 14200,
|
||||
outputTokens: 3800,
|
||||
cacheReadTokens: 42000,
|
||||
cacheWriteTokens: 12000,
|
||||
},
|
||||
className = '',
|
||||
}) => {
|
||||
const [selectedModelId, setSelectedModelId] = useState(currentModelId);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [thinkingEnabled, setThinkingEnabled] = useState(true);
|
||||
|
||||
const activeModel = AVAILABLE_MODELS.find((m) => m.id === selectedModelId) || AVAILABLE_MODELS[0];
|
||||
|
||||
// 计算上下文占用率
|
||||
const totalTokens =
|
||||
tokenMetrics.inputTokens +
|
||||
tokenMetrics.outputTokens +
|
||||
tokenMetrics.cacheReadTokens +
|
||||
tokenMetrics.cacheWriteTokens;
|
||||
const usageRatio = Math.min(1, totalTokens / activeModel.maxContext);
|
||||
const usagePercent = Math.round(usageRatio * 100);
|
||||
|
||||
// SVG 环形参数
|
||||
const circleRadius = 16;
|
||||
const circumference = 2 * Math.PI * circleRadius;
|
||||
const strokeDashoffset = circumference * (1 - usageRatio);
|
||||
|
||||
const handleSelect = (model: ModelTier) => {
|
||||
setSelectedModelId(model.id);
|
||||
onSelectModel?.(model);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block text-xs font-sans ${className}`}>
|
||||
{/* 触发胶囊按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-xl bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#1f1f1e)] border border-[var(--cds-border-subtle,#e5e5e3)] dark:border-[var(--cds-border-subtle,#2e2e2c)] hover:border-[var(--cds-border-accent,#d97757)] shadow-xs transition-all select-none"
|
||||
>
|
||||
<span className="font-semibold text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)]">
|
||||
{activeModel.name}
|
||||
</span>
|
||||
|
||||
{/* 微型环形进度 */}
|
||||
<div className="relative size-4 flex items-center justify-center shrink-0">
|
||||
<svg className="size-full -rotate-90" viewBox="0 0 36 36">
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r={circleRadius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
className="text-[var(--cds-alpha-1,rgba(0,0,0,0.1))] dark:text-white/10"
|
||||
/>
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r={circleRadius}
|
||||
fill="none"
|
||||
stroke={usageRatio > 0.8 ? 'var(--cds-fill-danger,#ef4444)' : 'var(--cds-fill-brand,#d97757)'}
|
||||
strokeWidth="4"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-[var(--cds-text-muted,#8c8c8c)]">▼</span>
|
||||
</button>
|
||||
|
||||
{/* 展开弹窗面板 */}
|
||||
{isOpen && (
|
||||
<div
|
||||
role="listbox"
|
||||
className="absolute left-0 top-full mt-2 w-80 rounded-2xl bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface-elevated,#242423)] border border-[var(--cds-border-subtle,#e5e5e3)] dark:border-[var(--cds-border-subtle,#333331)] shadow-[0_12px_28px_-4px_rgba(0,0,0,0.15)] p-2 z-50 animate-[cds-fade-in_150ms_ease-out]"
|
||||
>
|
||||
{/* 1. 模型列表 */}
|
||||
<div className="space-y-1 pb-2 border-b border-[var(--cds-border-subtle,#e8e8e6)] dark:border-[var(--cds-border-subtle,#2a2a28)]">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-[var(--cds-text-muted,#8c8c8c)] uppercase tracking-wider">
|
||||
选择推理模型
|
||||
</div>
|
||||
|
||||
{AVAILABLE_MODELS.map((model) => {
|
||||
const isSelected = model.id === selectedModelId;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => handleSelect(model)}
|
||||
className={`w-full flex flex-col items-start px-2.5 py-2 rounded-xl text-left transition-colors ${
|
||||
isSelected
|
||||
? 'bg-[var(--cds-bg-selected,#f8eee9)] dark:bg-[var(--cds-bg-selected,#36241e)] border border-[var(--cds-border-accent,#d97757)]/30'
|
||||
: 'hover:bg-[var(--cds-bg-hover,#f4f4f2)] dark:hover:bg-[var(--cds-bg-hover,#2b2b2a)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="font-semibold text-xs text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)]">
|
||||
{model.name}
|
||||
</span>
|
||||
{model.badge && (
|
||||
<span className="px-1.5 py-0.2 rounded text-[10px] font-medium bg-[var(--cds-fill-brand-subtle,#fcf0ec)] dark:bg-[var(--cds-fill-brand-subtle,#3a2018)] text-[var(--cds-text-accent,#d97757)]">
|
||||
{model.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--cds-text-muted,#8c8c8c)] mt-0.5 leading-snug">
|
||||
{model.tagline}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 2. 深度思考 (Extended Thinking) 开关 */}
|
||||
{activeModel.supportsThinking && (
|
||||
<div className="flex items-center justify-between px-2.5 py-2.5 border-b border-[var(--cds-border-subtle,#e8e8e6)] dark:border-[var(--cds-border-subtle,#2a2a28)]">
|
||||
<div>
|
||||
<div className="font-medium text-xs text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)]">
|
||||
深度思考 (Thinking)
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--cds-text-muted,#8c8c8c)]">
|
||||
展示完整的逐步推理、反思与规划链路
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={thinkingEnabled}
|
||||
onClick={() => setThinkingEnabled(!thinkingEnabled)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out ${
|
||||
thinkingEnabled ? 'bg-[var(--cds-fill-brand,#d97757)]' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block size-4 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out ${
|
||||
thinkingEnabled ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Token 上下文与 Prompt Caching 解构仪表盘 */}
|
||||
<div className="p-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="font-medium text-[var(--cds-text-secondary,#666666)]">上下文窗口使用量</span>
|
||||
<span className="font-mono tabular-nums text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] font-semibold">
|
||||
{totalTokens.toLocaleString()} / {activeModel.maxContext.toLocaleString()} ({usagePercent}%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 复合多段进度条 */}
|
||||
<div className="h-1.5 w-full rounded-full bg-[var(--cds-alpha-1,rgba(0,0,0,0.08))] dark:bg-white/10 overflow-hidden flex">
|
||||
<div
|
||||
style={{ width: `${(tokenMetrics.cacheReadTokens / activeModel.maxContext) * 100}%` }}
|
||||
className="bg-emerald-500"
|
||||
title={`缓存读取: ${tokenMetrics.cacheReadTokens} tok`}
|
||||
/>
|
||||
<div
|
||||
style={{ width: `${(tokenMetrics.cacheWriteTokens / activeModel.maxContext) * 100}%` }}
|
||||
className="bg-blue-500"
|
||||
title={`缓存写入: ${tokenMetrics.cacheWriteTokens} tok`}
|
||||
/>
|
||||
<div
|
||||
style={{ width: `${(tokenMetrics.inputTokens / activeModel.maxContext) * 100}%` }}
|
||||
className="bg-[var(--cds-fill-brand,#d97757)]"
|
||||
title={`输入 Token: ${tokenMetrics.inputTokens} tok`}
|
||||
/>
|
||||
<div
|
||||
style={{ width: `${(tokenMetrics.outputTokens / activeModel.maxContext) * 100}%` }}
|
||||
className="bg-purple-500"
|
||||
title={`输出 Token: ${tokenMetrics.outputTokens} tok`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 资费细分图例 */}
|
||||
<div className="grid grid-cols-2 gap-1.5 pt-1 text-[10px] text-[var(--cds-text-muted,#8c8c8c)]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-emerald-500" />
|
||||
<span>缓存读取 (立省90%): {tokenMetrics.cacheReadTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-blue-500" />
|
||||
<span>缓存写入: {tokenMetrics.cacheWriteTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-[var(--cds-fill-brand,#d97757)]" />
|
||||
<span>普通输入: {tokenMetrics.inputTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-purple-500" />
|
||||
<span>模型输出: {tokenMetrics.outputTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* ==============================================================================
|
||||
* Claude Design System (CDS) - ReasoningAmbientGlow.tsx
|
||||
*
|
||||
* 逆向还原自官方 Claude 动态光效与推理氛围感引擎:
|
||||
* - `c6a992d55-C0zTICcS.css` (cds-shimmer-text-shine / cds-shimmer-text-band / wl-glow / cds-dot-pulse / epitaxy-linear-sweep)
|
||||
* - `cde7c3a92-zIg_rFtU.js` (--shadow-drop-glow / --shadow-drop-ring / warm-glow / flicker)
|
||||
*
|
||||
* 核心视觉物理学:
|
||||
* 1. 陶土暖光推理晕染 (Warm Clay Ambient Glow):
|
||||
* 采用 HSL 动态光晕与双层复合扩散投影,基于 1.8s/2.4s 谐振周期缓动,
|
||||
* 在长思维链推理 (Extended Thinking) 时营造沉浸式思考场域。
|
||||
* 2. 文本扫光波纹 (Shimmer Text Shine):
|
||||
* 贝塞尔曲线 `cubic-bezier(.714, .121, .211, .888)` 驱动的渐变光波扫描,
|
||||
* 光斑在 0%~15% 蓄力、85%~100% 匀速穿透,表现深度推理。
|
||||
* 3. 实时 Canvas/WebGL 光场粒子着色器 (可选轻量 Canvas 渲染器):
|
||||
* 在卡片底层渲染低频波动的柔和焦散光斑。
|
||||
* 4. 三段式脉冲指示器 (Three-phase Pulsing Dots)。
|
||||
* ==============================================================================
|
||||
*/
|
||||
|
||||
export interface ReasoningAmbientGlowProps {
|
||||
active?: boolean;
|
||||
statusText?: string;
|
||||
durationSeconds?: number;
|
||||
showCanvasBackdrop?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. 深度推理动态氛围感主容器 (ReasoningAmbientGlow)
|
||||
*/
|
||||
export const ReasoningAmbientGlow: React.FC<ReasoningAmbientGlowProps> = ({
|
||||
active = true,
|
||||
statusText = 'Claude 正在进行深度思考与逆向推理...',
|
||||
durationSeconds,
|
||||
showCanvasBackdrop = true,
|
||||
className = '',
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-2xl border transition-all duration-500 ${
|
||||
active
|
||||
? 'border-[var(--cds-fill-brand,#d97757)]/40 bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#1b1a18)] shadow-[0_0_24px_-4px_rgba(217,119,87,0.18)] dark:shadow-[0_0_32px_-4px_rgba(217,119,87,0.28)]'
|
||||
: 'border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#262626)] bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#1c1c1c)]'
|
||||
} ${className}`}
|
||||
>
|
||||
{/* 动态焦散光场画布 (Ambient Light Canvas) */}
|
||||
{active && showCanvasBackdrop && <AmbientLightFieldCanvas />}
|
||||
|
||||
{/* 顶部思考状态指示栏 */}
|
||||
{active && (
|
||||
<div className="relative z-10 flex items-center justify-between px-4 py-2.5 border-b border-[var(--cds-fill-brand,#d97757)]/20 bg-[var(--cds-fill-brand-subtle,#fcf4f1)]/60 dark:bg-[var(--cds-fill-brand-subtle,#2c1b16)]/60 backdrop-blur-md">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* 三点相位脉冲 */}
|
||||
<ThreePhasePulseDots />
|
||||
{/* 扫光文字 */}
|
||||
<ShimmerText text={statusText} />
|
||||
</div>
|
||||
|
||||
{durationSeconds !== undefined && (
|
||||
<span className="text-[11px] font-mono text-[var(--cds-text-accent,#d97757)] tabular-nums font-medium">
|
||||
{durationSeconds}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容投影区 */}
|
||||
<div className="relative z-10 p-4">{children}</div>
|
||||
|
||||
{/* 底部线性能量扫光条 */}
|
||||
{active && <LinearSweepProgress />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 2. 官方反编译 keyframe 文本扫光组件 (ShimmerText)
|
||||
* 对应 official `cds-shimmer-text-shine`
|
||||
*/
|
||||
export const ShimmerText: React.FC<{ text: string; className?: string }> = ({
|
||||
text,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block font-sans text-xs font-medium tracking-wide bg-clip-text text-transparent select-none ${className}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, var(--cds-text-accent, #d97757) 0%, var(--cds-text-primary, #1a1a1a) 35%, #ffffff 50%, var(--cds-text-primary, #1a1a1a) 65%, var(--cds-text-accent, #d97757) 100%)',
|
||||
backgroundSize: '250% 100%',
|
||||
animation: 'cds-shimmer-text-shine 2.8s linear infinite',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
<style>{`
|
||||
@keyframes cds-shimmer-text-shine {
|
||||
0%, 15% {
|
||||
background-position: 83.333% 0;
|
||||
animation-timing-function: cubic-bezier(.714, .121, .211, .888);
|
||||
}
|
||||
85%, 100% {
|
||||
background-position: 16.667% 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 3. 官方三相差分跳跃点 (ThreePhasePulseDots)
|
||||
* 对应 `cds-dot-pulse`
|
||||
*/
|
||||
export const ThreePhasePulseDots: React.FC = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="size-1.5 rounded-full bg-[var(--cds-fill-brand,#d97757)]"
|
||||
style={{
|
||||
animation: `cds-dot-pulse 1.2s cubic-bezier(.165,.84,.44,1) infinite`,
|
||||
animationDelay: `${i * 180}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<style>{`
|
||||
@keyframes cds-dot-pulse {
|
||||
0%, 5% {
|
||||
opacity: 0.2857;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
20%, 35% {
|
||||
opacity: 1;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
65%, 100% {
|
||||
opacity: 0.2857;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 4. 底部 Epitaxy 线性扫光条 (LinearSweepProgress)
|
||||
* 对应 `epitaxy-linear-sweep`
|
||||
*/
|
||||
export const LinearSweepProgress: React.FC = () => {
|
||||
return (
|
||||
<div className="relative h-0.5 w-full overflow-hidden bg-[var(--cds-fill-brand,#d97757)]/15">
|
||||
<div
|
||||
className="absolute inset-y-0 w-1/3 bg-gradient-to-r from-transparent via-[var(--cds-fill-brand,#d97757)] to-transparent"
|
||||
style={{
|
||||
animation: 'epitaxy-linear-sweep 1.6s cubic-bezier(0.45, 0, 0.55, 1) infinite',
|
||||
}}
|
||||
/>
|
||||
<style>{`
|
||||
@keyframes epitaxy-linear-sweep {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(350%);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 5. 轻量级焦散光场画布 (AmbientLightFieldCanvas)
|
||||
* 在卡片背景渲染柔和的低频色相波动光晕
|
||||
*/
|
||||
const AmbientLightFieldCanvas: React.FC = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let animId: number;
|
||||
let t = 0;
|
||||
|
||||
const render = () => {
|
||||
t += 0.015;
|
||||
const w = (canvas.width = canvas.offsetWidth);
|
||||
const h = (canvas.height = canvas.offsetHeight);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// 计算波动中心光斑
|
||||
const cx1 = w * 0.3 + Math.sin(t) * (w * 0.15);
|
||||
const cy1 = h * 0.4 + Math.cos(t * 0.8) * (h * 0.15);
|
||||
const r1 = Math.max(w, h) * 0.6;
|
||||
|
||||
const grad1 = ctx.createRadialGradient(cx1, cy1, 0, cx1, cy1, r1);
|
||||
// 官方陶土色 #d97757 对应 rgba(217, 119, 87, ...)
|
||||
grad1.addColorStop(0, 'rgba(217, 119, 87, 0.12)');
|
||||
grad1.addColorStop(0.5, 'rgba(217, 119, 87, 0.04)');
|
||||
grad1.addColorStop(1, 'rgba(217, 119, 87, 0)');
|
||||
|
||||
ctx.fillStyle = grad1;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
animId = requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 pointer-events-none w-full h-full z-0 opacity-80"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,414 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* ==============================================================================
|
||||
* Claude Design System (CDS) - RichComposerWithMentions.tsx
|
||||
*
|
||||
* 逆向还原自官方 Claude Desktop / Web 核心输入组件:
|
||||
* - `cd5a31703-B4nKQMBN.js` (isComposing / Suggestion / Mention / Popover)
|
||||
* - `cb6930dae-Q_S3-FcC.js` (leadingSlashCommand / MentionPills / FileMentions)
|
||||
* - `c6a992d55-C0zTICcS.css` (static-composer-actions, cds-shadow-popover)
|
||||
*
|
||||
* 核心工程机制:
|
||||
* 1. 严格 CJK 输入法合成守卫 (IME Composition Guard):
|
||||
* 全面监听 compositionstart / compositionend,拦截 keyCode === 229 与 isComposing,
|
||||
* 杜绝中文/日文/韩文在输入法候选词确认回车时误发送消息。
|
||||
* 2. 双触发符指令与引用建议系统 (Dual Trigger Suggestions):
|
||||
* - '/' 触发 Slash Command (如 /help, /clear, /model, /plan, /compact)
|
||||
* - '@' 触发上下文与文件引用 (Projects, Files, Git branches)
|
||||
* 3. 焦点漫游 (Roving Focus) 与悬浮窗坐标计算:
|
||||
* ArrowUp / ArrowDown / Enter / Tab / Escape 闭环操作,支持高亮索引循环导航。
|
||||
* 4. 弹性高度自适应与 Token 计量预测条。
|
||||
* ==============================================================================
|
||||
*/
|
||||
|
||||
export interface SlashCommandItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface MentionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'file' | 'project' | 'doc' | 'branch';
|
||||
path?: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SLASH_COMMANDS: SlashCommandItem[] = [
|
||||
{ id: 'help', name: 'help', description: '获取 Claude 使用帮助与快捷键指南', icon: '❓' },
|
||||
{ id: 'compact', name: 'compact', description: '压缩长会话上下文以节省 Token 窗口', icon: '⚡' },
|
||||
{ id: 'model', name: 'model', description: '切换推理模型 (Opus, Sonnet, Haiku)', icon: '🔄' },
|
||||
{ id: 'plan', name: 'plan', description: '进入工程分步计划模式', icon: '📋' },
|
||||
{ id: 'clear', name: 'clear', description: '清空当前上下文并重启对话', icon: '🗑️' },
|
||||
];
|
||||
|
||||
const DEFAULT_MENTIONS: MentionItem[] = [
|
||||
{ id: 'app-tsx', name: 'App.tsx', kind: 'file', path: 'src/App.tsx', badge: 'TSX' },
|
||||
{ id: 'theme-css', name: 'cds-theme.css', kind: 'file', path: 'src/styles/cds-theme.css', badge: 'CSS' },
|
||||
{ id: 'vite-config', name: 'vite.config.ts', kind: 'file', path: 'vite.config.ts', badge: 'TS' },
|
||||
{ id: 'proj-cds', name: 'Claude Design System', kind: 'project', badge: 'Project' },
|
||||
{ id: 'branch-main', name: 'main', kind: 'branch', badge: 'Git' },
|
||||
];
|
||||
|
||||
export interface RichComposerProps {
|
||||
placeholder?: string;
|
||||
onSubmit?: (content: { text: string; slashCommand?: string; mentions: MentionItem[] }) => void;
|
||||
disabled?: boolean;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export const RichComposerWithMentions: React.FC<RichComposerProps> = ({
|
||||
placeholder = '向 Claude 发送消息... (输入 / 唤起指令,输入 @ 引用上下文)',
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
maxTokens = 200000,
|
||||
}) => {
|
||||
const [text, setText] = useState('');
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const [activeSlashCommand, setActiveSlashCommand] = useState<SlashCommandItem | null>(null);
|
||||
const [attachedMentions, setAttachedMentions] = useState<MentionItem[]>([]);
|
||||
|
||||
// 建议浮窗状态
|
||||
const [suggestionMode, setSuggestionMode] = useState<'slash' | 'mention' | null>(null);
|
||||
const [suggestionQuery, setSuggestionQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const composerContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 1. 过滤建议列表
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
if (suggestionMode !== 'slash') return [];
|
||||
const q = suggestionQuery.toLowerCase();
|
||||
return DEFAULT_SLASH_COMMANDS.filter((cmd) => cmd.name.toLowerCase().includes(q));
|
||||
}, [suggestionMode, suggestionQuery]);
|
||||
|
||||
const filteredMentions = useMemo(() => {
|
||||
if (suggestionMode !== 'mention') return [];
|
||||
const q = suggestionQuery.toLowerCase();
|
||||
return DEFAULT_MENTIONS.filter(
|
||||
(m) => m.name.toLowerCase().includes(q) || (m.path && m.path.toLowerCase().includes(q))
|
||||
);
|
||||
}, [suggestionMode, suggestionQuery]);
|
||||
|
||||
const activeItemsCount = suggestionMode === 'slash' ? filteredSlashCommands.length : filteredMentions.length;
|
||||
|
||||
// 2. 自适应高度调整
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
const nextHeight = Math.min(Math.max(el.scrollHeight, 44), 240);
|
||||
el.style.height = `${nextHeight}px`;
|
||||
}, []);
|
||||
|
||||
// 3. 实时解析光标前的触发字符 (/ 或 @)
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
setText(val);
|
||||
adjustTextareaHeight();
|
||||
|
||||
const cursor = e.target.selectionStart ?? val.length;
|
||||
const textBeforeCursor = val.slice(0, cursor);
|
||||
|
||||
// 匹配光标前的触发词
|
||||
const slashMatch = textBeforeCursor.match(/(?:^|\s)\/([a-zA-Z0-9_\-]*)$/);
|
||||
const mentionMatch = textBeforeCursor.match(/(?:^|\s)@([a-zA-Z0-9_\-./]*)$/);
|
||||
|
||||
if (slashMatch && !activeSlashCommand) {
|
||||
setSuggestionMode('slash');
|
||||
setSuggestionQuery(slashMatch[1]);
|
||||
setSelectedIndex(0);
|
||||
} else if (mentionMatch) {
|
||||
setSuggestionMode('mention');
|
||||
setSuggestionQuery(mentionMatch[1]);
|
||||
setSelectedIndex(0);
|
||||
} else {
|
||||
setSuggestionMode(null);
|
||||
setSuggestionQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
// 4. 选择 Slash 指令
|
||||
const applySlashCommand = useCallback((cmd: SlashCommandItem) => {
|
||||
setActiveSlashCommand(cmd);
|
||||
// 从输入文本中去除触发词
|
||||
setText((prev) => {
|
||||
return prev.replace(/(?:^|\s)\/[a-zA-Z0-9_\-]*$/, '').trimStart();
|
||||
});
|
||||
setSuggestionMode(null);
|
||||
textareaRef.current?.focus();
|
||||
setTimeout(adjustTextareaHeight, 0);
|
||||
}, [adjustTextareaHeight]);
|
||||
|
||||
// 5. 插入 Mention 胶囊
|
||||
const applyMention = useCallback((mention: MentionItem) => {
|
||||
setAttachedMentions((prev) => (prev.some((m) => m.id === mention.id) ? prev : [...prev, mention]));
|
||||
// 替换光标前的 @query 为普通空格
|
||||
setText((prev) => {
|
||||
return prev.replace(/(?:^|\s)@[a-zA-Z0-9_\-./]*$/, ' ');
|
||||
});
|
||||
setSuggestionMode(null);
|
||||
textareaRef.current?.focus();
|
||||
setTimeout(adjustTextareaHeight, 0);
|
||||
}, [adjustTextareaHeight]);
|
||||
|
||||
// 6. 核心键盘交互状态机与 IME 守卫
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// 官方 Claude 核心断言: 如果正在进行输入法合成 (CJK) 或 keyCode 为 229,绝对放行并终止响应
|
||||
if (isComposing || e.nativeEvent.isComposing || e.keyCode === 229) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 建议弹窗开启时的方向键导航与确认
|
||||
if (suggestionMode && activeItemsCount > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % activeItemsCount);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + activeItemsCount) % activeItemsCount);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (suggestionMode === 'slash') {
|
||||
const selected = filteredSlashCommands[selectedIndex];
|
||||
if (selected) applySlashCommand(selected);
|
||||
} else if (suggestionMode === 'mention') {
|
||||
const selected = filteredMentions[selectedIndex];
|
||||
if (selected) applyMention(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSuggestionMode(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 消息提交快捷键处理: Enter 提交, Shift+Enter 换行
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// 7. 发送消息
|
||||
const handleSend = () => {
|
||||
if (disabled || isComposing) return;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed && !activeSlashCommand && attachedMentions.length === 0) return;
|
||||
|
||||
if (onSubmit) {
|
||||
onSubmit({
|
||||
text: trimmed,
|
||||
slashCommand: activeSlashCommand?.name,
|
||||
mentions: attachedMentions,
|
||||
});
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
setText('');
|
||||
setActiveSlashCommand(null);
|
||||
setAttachedMentions([]);
|
||||
setSuggestionMode(null);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = '44px';
|
||||
}
|
||||
};
|
||||
|
||||
// 移除 Mention 标签
|
||||
const removeMention = (id: string) => {
|
||||
setAttachedMentions((prev) => prev.filter((m) => m.id !== id));
|
||||
};
|
||||
|
||||
// 估算 Token 数量 (标准经验算法: 英文 ~4 字符/token, 中文 ~1.5 字符/token)
|
||||
const estimatedTokens = useMemo(() => {
|
||||
const rawLen = text.length;
|
||||
let tokens = Math.ceil(rawLen * 0.7);
|
||||
if (activeSlashCommand) tokens += 10;
|
||||
tokens += attachedMentions.length * 25;
|
||||
return tokens;
|
||||
}, [text, activeSlashCommand, attachedMentions]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={composerContainerRef}
|
||||
className="relative w-full rounded-2xl bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#1e1e1e)] border border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#2e2e2e)] shadow-[0_2px_8px_rgba(0,0,0,0.04)] focus-within:border-[var(--cds-border-accent,#d97757)] focus-within:ring-1 focus-within:ring-[var(--cds-border-accent,#d97757)] transition-all duration-200"
|
||||
>
|
||||
{/* 建议弹层 (Slash / Mention Popover) */}
|
||||
{suggestionMode && activeItemsCount > 0 && (
|
||||
<div
|
||||
role="listbox"
|
||||
className="absolute bottom-full left-0 mb-2 w-72 max-h-60 overflow-y-auto rounded-xl bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface-elevated,#262626)] border border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#333333)] shadow-[0_12px_24px_-4px_rgba(0,0,0,0.12),0_4px_8px_-2px_rgba(0,0,0,0.06)] p-1 z-50 animate-[cds-fade-in_150ms_ease-out]"
|
||||
>
|
||||
<div className="px-2 py-1 text-[11px] font-medium text-[var(--cds-text-muted,#8c8c8c)] uppercase tracking-wider">
|
||||
{suggestionMode === 'slash' ? '常用指令' : '引用上下文 / 文件'}
|
||||
</div>
|
||||
|
||||
{suggestionMode === 'slash' &&
|
||||
filteredSlashCommands.map((cmd, idx) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={idx === selectedIndex}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-1.5 rounded-lg text-left text-xs transition-colors ${
|
||||
idx === selectedIndex
|
||||
? 'bg-[var(--cds-bg-selected,#f3ece7)] dark:bg-[var(--cds-bg-selected,#3a2c27)] text-[var(--cds-text-accent,#d97757)]'
|
||||
: 'text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] hover:bg-[var(--cds-bg-hover,#f5f5f5)] dark:hover:bg-[var(--cds-bg-hover,#2a2a2a)]'
|
||||
}`}
|
||||
onClick={() => applySlashCommand(cmd)}
|
||||
>
|
||||
<span className="text-sm shrink-0">{cmd.icon ?? '⚡'}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold leading-tight">/{cmd.name}</div>
|
||||
<div className="truncate text-[11px] text-[var(--cds-text-muted,#8c8c8c)]">{cmd.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{suggestionMode === 'mention' &&
|
||||
filteredMentions.map((item, idx) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={idx === selectedIndex}
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-left text-xs transition-colors ${
|
||||
idx === selectedIndex
|
||||
? 'bg-[var(--cds-bg-selected,#f3ece7)] dark:bg-[var(--cds-bg-selected,#3a2c27)] text-[var(--cds-text-accent,#d97757)]'
|
||||
: 'text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] hover:bg-[var(--cds-bg-hover,#f5f5f5)] dark:hover:bg-[var(--cds-bg-hover,#2a2a2a)]'
|
||||
}`}
|
||||
onClick={() => applyMention(item)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 truncate pr-2">
|
||||
<div className="font-medium truncate">{item.name}</div>
|
||||
{item.path && <div className="text-[10px] text-[var(--cds-text-muted,#8c8c8c)] truncate">{item.path}</div>}
|
||||
</div>
|
||||
{item.badge && (
|
||||
<span className="shrink-0 px-1.5 py-0.5 rounded text-[10px] font-mono bg-[var(--cds-alpha-1,rgba(0,0,0,0.05))] text-[var(--cds-text-secondary,#666666)]">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 顶部标签区: 已激活的 Slash Command 与 Attached Mentions */}
|
||||
{(activeSlashCommand || attachedMentions.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-3.5 pt-3 pb-1 border-b border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#2e2e2e)]">
|
||||
{activeSlashCommand && (
|
||||
<div className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--cds-fill-brand-subtle,#fbebe6)] dark:bg-[var(--cds-fill-brand-subtle,#38221b)] text-[var(--cds-text-accent,#d97757)] text-xs font-semibold">
|
||||
<span>/{activeSlashCommand.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="移除指令"
|
||||
className="hover:opacity-75 p-0.5"
|
||||
onClick={() => setActiveSlashCommand(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachedMentions.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-[var(--cds-alpha-1,rgba(0,0,0,0.05))] dark:bg-[var(--cds-alpha-1,rgba(255,255,255,0.08))] text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] text-xs font-medium"
|
||||
>
|
||||
<span className="text-[var(--cds-text-muted,#8c8c8c)]">@</span>
|
||||
<span className="truncate max-w-[140px]">{m.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除 ${m.name}`}
|
||||
className="hover:opacity-75 text-[10px] text-[var(--cds-text-muted,#8c8c8c)]"
|
||||
onClick={() => removeMention(m.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 核心富文本输入区 */}
|
||||
<div className="relative px-3.5 pt-2.5 pb-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onCompositionEnd={() => {
|
||||
setIsComposing(false);
|
||||
adjustTextareaHeight();
|
||||
}}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
className="w-full bg-transparent text-sm leading-relaxed text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] placeholder-[var(--cds-text-muted,#8c8c8c)] resize-none outline-none border-none p-0 overflow-y-auto max-h-[240px]"
|
||||
style={{ minHeight: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部操作工具栏 */}
|
||||
<div className="flex items-center justify-between px-3 pb-2.5 pt-1 text-xs">
|
||||
<div className="flex items-center gap-2 text-[var(--cds-text-muted,#8c8c8c)]">
|
||||
{/* 上传附件按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-lg hover:bg-[var(--cds-bg-hover,#f5f5f5)] dark:hover:bg-[var(--cds-bg-hover,#2a2a2a)] transition-colors"
|
||||
title="添加附件或图片"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 指令说明快捷键提示 */}
|
||||
<span className="hidden sm:inline-block text-[11px] select-none">
|
||||
按 <kbd className="px-1 py-0.5 rounded border border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#333)] font-mono text-[10px]">Enter</kbd> 发送,
|
||||
<kbd className="px-1 py-0.5 rounded border border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#333)] font-mono text-[10px]">Shift+Enter</kbd> 换行
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Token 计量指示器 */}
|
||||
<div
|
||||
className={`text-[11px] font-mono tabular-nums select-none ${
|
||||
estimatedTokens > maxTokens * 0.9
|
||||
? 'text-[var(--cds-fill-danger,#ef4444)] font-bold'
|
||||
: 'text-[var(--cds-text-muted,#8c8c8c)]'
|
||||
}`}
|
||||
title="当前消息预估消耗 Token"
|
||||
>
|
||||
~{estimatedTokens} tok
|
||||
</div>
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={disabled || (!text.trim() && !activeSlashCommand && attachedMentions.length === 0)}
|
||||
aria-label="发送消息"
|
||||
className="flex items-center justify-center size-8 rounded-lg bg-[var(--cds-fill-brand,#d97757)] text-white hover:bg-[var(--cds-fill-brand-hover,#c86849)] active:scale-95 disabled:opacity-30 disabled:pointer-events-none transition-all duration-150 shadow-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* ==============================================================================
|
||||
* Claude Design System (CDS) - ScrollFadeContainer.tsx
|
||||
*
|
||||
* 逆向还原自官方 Claude Desktop / Web 边缘平滑渐变遮罩系统:
|
||||
* - `frame-shell-chrome-BDKWDTvx.js` (cds-scroll-fade-y-active / cds-scroll-fade-top-raw)
|
||||
* - `c6a992d55-C0zTICcS.css` (7 阶五次缓动 Alpha 渐变遮罩公式)
|
||||
*
|
||||
* 核心视觉物理学:
|
||||
* 1. 官方 7 阶五次缓动透明度遮罩 (7-Stop Quintic Ease Alpha Mask):
|
||||
* Claude 没有采用粗暴的双色透明切线,而是精确使用 7 组非线性 Alpha 梯度:
|
||||
* [0% (transparent), 15% (#0d), 30% (#26), 45% (#4d), 60% (#80), 75% (#b3), 87% (#d9), 100% (#000)]
|
||||
* 配合 `mask-image` 实现物理级顺滑边缘消隐,彻底杜绝内容截断割裂感。
|
||||
* 2. 动态滚动位置传感器 (Scroll State Sensor):
|
||||
* - 触顶时 (`scrollTop <= 1`):`--cds-scroll-fade-top = 0px`,顶边缘恢复完全清晰;
|
||||
* - 滚入内容时:动态淡入 28px 平滑羽化边缘;
|
||||
* - 触底时 (`scrollTop + clientHeight >= scrollHeight - 1`):`--cds-scroll-fade-bottom = 0px`。
|
||||
* 3. 垂直 (Vertical) 与水平 (Horizontal) 双轴自适应支持。
|
||||
* ==============================================================================
|
||||
*/
|
||||
|
||||
export interface ScrollFadeContainerProps {
|
||||
children: React.ReactNode;
|
||||
orientation?: 'vertical' | 'horizontal' | 'both';
|
||||
fadeSize?: number; // 默认 28px (官方常量 --cds-scroll-fade-size)
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const ScrollFadeContainer: React.FC<ScrollFadeContainerProps> = ({
|
||||
children,
|
||||
orientation = 'vertical',
|
||||
fadeSize = 28,
|
||||
className = '',
|
||||
style = {},
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 遮罩生效状态
|
||||
const [fadeTop, setFadeTop] = useState(0);
|
||||
const [fadeBottom, setFadeBottom] = useState(fadeSize);
|
||||
const [fadeLeft, setFadeLeft] = useState(0);
|
||||
const [fadeRight, setFadeRight] = useState(fadeSize);
|
||||
|
||||
// 核心滚动边界检测
|
||||
const updateScrollFades = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
if (orientation === 'vertical' || orientation === 'both') {
|
||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||
const isAtTop = scrollTop <= 2;
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 2;
|
||||
|
||||
setFadeTop(isAtTop ? 0 : fadeSize);
|
||||
setFadeBottom(isAtBottom ? 0 : fadeSize);
|
||||
}
|
||||
|
||||
if (orientation === 'horizontal' || orientation === 'both') {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = el;
|
||||
const isAtLeft = scrollLeft <= 2;
|
||||
const isAtRight = scrollLeft + clientWidth >= scrollWidth - 2;
|
||||
|
||||
setFadeLeft(isAtLeft ? 0 : fadeSize);
|
||||
setFadeRight(isAtRight ? 0 : fadeSize);
|
||||
}
|
||||
}, [orientation, fadeSize]);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollFades();
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const ro = new ResizeObserver(updateScrollFades);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [updateScrollFades]);
|
||||
|
||||
// 构建反编译自 Claude 的精密 7-Stop 渐变 Mask 规则
|
||||
const maskStyle = React.useMemo(() => {
|
||||
if (orientation === 'vertical') {
|
||||
return {
|
||||
maskImage: `linear-gradient(to bottom,
|
||||
transparent,
|
||||
rgba(0, 0, 0, 0.05) calc(var(--cds-scroll-fade-top) * 0.15),
|
||||
rgba(0, 0, 0, 0.15) calc(var(--cds-scroll-fade-top) * 0.3),
|
||||
rgba(0, 0, 0, 0.30) calc(var(--cds-scroll-fade-top) * 0.45),
|
||||
rgba(0, 0, 0, 0.50) calc(var(--cds-scroll-fade-top) * 0.6),
|
||||
rgba(0, 0, 0, 0.70) calc(var(--cds-scroll-fade-top) * 0.75),
|
||||
rgba(0, 0, 0, 0.85) calc(var(--cds-scroll-fade-top) * 0.87),
|
||||
#000 var(--cds-scroll-fade-top),
|
||||
#000 calc(100% - var(--cds-scroll-fade-bottom)),
|
||||
rgba(0, 0, 0, 0.85) calc(100% - var(--cds-scroll-fade-bottom) * 0.87),
|
||||
rgba(0, 0, 0, 0.70) calc(100% - var(--cds-scroll-fade-bottom) * 0.75),
|
||||
rgba(0, 0, 0, 0.50) calc(100% - var(--cds-scroll-fade-bottom) * 0.6),
|
||||
rgba(0, 0, 0, 0.30) calc(100% - var(--cds-scroll-fade-bottom) * 0.45),
|
||||
rgba(0, 0, 0, 0.15) calc(100% - var(--cds-scroll-fade-bottom) * 0.3),
|
||||
rgba(0, 0, 0, 0.05) calc(100% - var(--cds-scroll-fade-bottom) * 0.15),
|
||||
transparent)`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom,
|
||||
transparent,
|
||||
rgba(0, 0, 0, 0.05) calc(var(--cds-scroll-fade-top) * 0.15),
|
||||
rgba(0, 0, 0, 0.15) calc(var(--cds-scroll-fade-top) * 0.3),
|
||||
rgba(0, 0, 0, 0.30) calc(var(--cds-scroll-fade-top) * 0.45),
|
||||
rgba(0, 0, 0, 0.50) calc(var(--cds-scroll-fade-top) * 0.6),
|
||||
rgba(0, 0, 0, 0.70) calc(var(--cds-scroll-fade-top) * 0.75),
|
||||
rgba(0, 0, 0, 0.85) calc(var(--cds-scroll-fade-top) * 0.87),
|
||||
#000 var(--cds-scroll-fade-top),
|
||||
#000 calc(100% - var(--cds-scroll-fade-bottom)),
|
||||
rgba(0, 0, 0, 0.85) calc(100% - var(--cds-scroll-fade-bottom) * 0.87),
|
||||
rgba(0, 0, 0, 0.70) calc(100% - var(--cds-scroll-fade-bottom) * 0.75),
|
||||
rgba(0, 0, 0, 0.50) calc(100% - var(--cds-scroll-fade-bottom) * 0.6),
|
||||
rgba(0, 0, 0, 0.30) calc(100% - var(--cds-scroll-fade-bottom) * 0.45),
|
||||
rgba(0, 0, 0, 0.15) calc(100% - var(--cds-scroll-fade-bottom) * 0.3),
|
||||
rgba(0, 0, 0, 0.05) calc(100% - var(--cds-scroll-fade-bottom) * 0.15),
|
||||
transparent)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (orientation === 'horizontal') {
|
||||
return {
|
||||
maskImage: `linear-gradient(to right,
|
||||
transparent,
|
||||
#000 var(--cds-scroll-fade-left),
|
||||
#000 calc(100% - var(--cds-scroll-fade-right)),
|
||||
transparent)`,
|
||||
WebkitMaskImage: `linear-gradient(to right,
|
||||
transparent,
|
||||
#000 var(--cds-scroll-fade-left),
|
||||
#000 calc(100% - var(--cds-scroll-fade-right)),
|
||||
transparent)`,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}, [orientation]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={updateScrollFades}
|
||||
className={`relative overflow-auto transition-[mask-image] duration-150 ${className}`}
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
...maskStyle,
|
||||
'--cds-scroll-fade-size': `${fadeSize}px`,
|
||||
'--cds-scroll-fade-top': `${fadeTop}px`,
|
||||
'--cds-scroll-fade-bottom': `${fadeBottom}px`,
|
||||
'--cds-scroll-fade-left': `${fadeLeft}px`,
|
||||
'--cds-scroll-fade-right': `${fadeRight}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Claude Design System (CDS) - Segmented Control & Pills
|
||||
*
|
||||
* 逆向溯源:提取自 /Applications/Claude.app ion-dist (.df-pills[data-segmented])。
|
||||
* 核心规范:
|
||||
* - 999px 极致胶囊圆角 (border-radius: 999px)
|
||||
* - 2px 严格几何内边距 (padding: 2px)
|
||||
* - 纯几何等分滑动指示器公式:
|
||||
* left = calc(2px + activeIndex * (100% - 4px) / total)
|
||||
* width = calc((100% - 4px) / total)
|
||||
* - 容器查询防御:@container (max-width: 254px) 下未激活项隐藏文本只保留图标
|
||||
* - 物理阻尼:120ms 瞬时咬合或 Framer Motion spring (stiffness: 500, damping: 35)
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export interface SegmentedTabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
badge?: string | number;
|
||||
}
|
||||
|
||||
export interface SegmentedControlProps {
|
||||
tabs: (string | SegmentedTabItem)[];
|
||||
value?: string | number;
|
||||
defaultValue?: string | number;
|
||||
onChange?: (value: string | number, index: number) => void;
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
useFramerMotion?: boolean;
|
||||
}
|
||||
|
||||
export function SegmentedControl({
|
||||
tabs,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
size = "md",
|
||||
className = "",
|
||||
useFramerMotion = true,
|
||||
}: SegmentedControlProps) {
|
||||
const normalizedTabs: SegmentedTabItem[] = tabs.map((tab, idx) =>
|
||||
typeof tab === "string" ? { id: String(idx), label: tab } : tab
|
||||
);
|
||||
|
||||
const [internalIndex, setInternalIndex] = useState(() => {
|
||||
if (defaultValue !== undefined) {
|
||||
const idx = normalizedTabs.findIndex((t) => t.id === defaultValue || t.label === defaultValue);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const activeIndex =
|
||||
value !== undefined
|
||||
? normalizedTabs.findIndex((t) => t.id === value || t.label === value)
|
||||
: internalIndex;
|
||||
|
||||
const handleSelect = (idx: number) => {
|
||||
if (value === undefined) {
|
||||
setInternalIndex(idx);
|
||||
}
|
||||
const selectedTab = normalizedTabs[idx];
|
||||
onChange?.(selectedTab.id, idx);
|
||||
};
|
||||
|
||||
const isSmall = size === "sm";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative rounded-full select-none flex items-stretch p-[2px] transition-colors ${
|
||||
isSmall ? "h-7 text-xs" : "h-9 text-xs"
|
||||
} ${className}`}
|
||||
style={{
|
||||
backgroundColor: "var(--cds-segmented-track, rgba(0, 0, 0, 0.06))",
|
||||
containerType: "inline-size",
|
||||
}}
|
||||
role="tablist"
|
||||
>
|
||||
{/* 1. 滑动背景滑块 (两种实现:原生纯几何定位 或 Framer Motion layoutId) */}
|
||||
{!useFramerMotion ? (
|
||||
<div
|
||||
className="absolute top-[2px] bottom-[2px] rounded-full bg-white dark:bg-[#2c2c2a] pointer-events-none transition-all duration-150 ease-[cubic-bezier(0.32,0.72,0,1)]"
|
||||
style={{
|
||||
left: `calc(2px + ${activeIndex} * (100% - 4px) / ${normalizedTabs.length})`,
|
||||
width: `calc((100% - 4px) / ${normalizedTabs.length})`,
|
||||
boxShadow:
|
||||
"inset 0 0 0 1px rgba(0,0,0,0.06), 0 1px 2px 0 rgba(0,0,0,0.08)",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* 2. 标签项渲染 */}
|
||||
{normalizedTabs.map((tab, idx) => {
|
||||
const isActive = activeIndex === idx;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => handleSelect(idx)}
|
||||
className={`relative z-10 flex-1 h-full flex items-center justify-center gap-1.5 px-3 rounded-full transition-colors truncate font-[470] ${
|
||||
isActive
|
||||
? "text-neutral-900 dark:text-white font-semibold"
|
||||
: "text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
{/* Framer Motion 物理弹簧滑块 */}
|
||||
{useFramerMotion && isActive && (
|
||||
<motion.div
|
||||
layoutId="cds-segmented-active-thumb"
|
||||
className="absolute inset-0 rounded-full bg-white dark:bg-[#2c2c2a] -z-10"
|
||||
style={{
|
||||
boxShadow:
|
||||
"inset 0 0 0 1px rgba(0,0,0,0.06), 0 1px 3px 0 rgba(0,0,0,0.08)",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 35,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab.icon && (
|
||||
<span className="shrink-0 flex items-center justify-center text-current">
|
||||
{tab.icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 容器查询自适应文本:在宽度小于 254px 时自动收缩折叠 */}
|
||||
<span className="truncate @[max-width:254px]:hidden">
|
||||
{tab.label}
|
||||
</span>
|
||||
|
||||
{tab.badge !== undefined && (
|
||||
<span className="ml-0.5 px-1.5 py-0.2 rounded-full bg-neutral-200/60 dark:bg-neutral-700 text-[10px] font-mono leading-tight">
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Claude Design System (CDS) - Status Indicators & Shimmer
|
||||
*
|
||||
* 逆向溯源:提取自 /Applications/Claude.app ion-dist 生产状态指示灯与骨架屏动效。
|
||||
* 核心动效:
|
||||
* - 陶土明度呼吸指示灯 (dframe-clay-breathe, 2.4s 循环)
|
||||
* - 运行中绿色脉冲扩散 (dframe-dot-pulse, 1.2s 循环)
|
||||
* - 思考中三点波浪跳跃 (timeline-quiet-dot, 1.9s 阶梯延迟 0s, 0.15s, 0.3s)
|
||||
* - 骨架屏高级倾斜扫光 (cds-skeleton-sweep, 1.8s cubic-bezier(0.4, 0, 0.2, 1))
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
// ============================================================================
|
||||
// 1. 陶土呼吸指示灯 (ClayBreatheDot)
|
||||
// ============================================================================
|
||||
|
||||
export function ClayBreatheDot({
|
||||
label,
|
||||
className = "",
|
||||
}: {
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 select-none ${className}`}>
|
||||
<span
|
||||
className="size-3 rounded-full bg-clay shadow-2xs shrink-0"
|
||||
style={{
|
||||
animation: "dframe-clay-breathe 2.4s infinite ease-in-out",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label && (
|
||||
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2. 实时活动脉冲灯 (PulseRunningDot)
|
||||
// ============================================================================
|
||||
|
||||
export function PulseRunningDot({
|
||||
label,
|
||||
className = "",
|
||||
}: {
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 select-none ${className}`}>
|
||||
<span className="relative flex size-2.5 shrink-0">
|
||||
<span
|
||||
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"
|
||||
style={{ animationDuration: "1.2s" }}
|
||||
/>
|
||||
<span className="relative inline-flex rounded-full size-2.5 bg-emerald-500" />
|
||||
</span>
|
||||
{label && (
|
||||
<span className="text-xs font-mono text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 3. 思考波浪三点跳跃 (QuietDotsWave)
|
||||
// ============================================================================
|
||||
|
||||
export function QuietDotsWave({
|
||||
label = "Thinking...",
|
||||
className = "",
|
||||
}: {
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 select-none ${className}`}>
|
||||
<div className="inline-flex items-center gap-[4px]">
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-clay"
|
||||
style={{
|
||||
animation: "timeline-quiet-dot 1.9s infinite cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
animationDelay: "0s",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-clay"
|
||||
style={{
|
||||
animation: "timeline-quiet-dot 1.9s infinite cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
animationDelay: "0.15s",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-clay"
|
||||
style={{
|
||||
animation: "timeline-quiet-dot 1.9s infinite cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
animationDelay: "0.3s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{label && (
|
||||
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 4. 骨架屏倾斜扫光条 (SkeletonShimmer)
|
||||
// ============================================================================
|
||||
|
||||
export function SkeletonShimmer({
|
||||
width = "100%",
|
||||
height = "1rem",
|
||||
rounded = "rounded-lg",
|
||||
className = "",
|
||||
}: {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
rounded?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden bg-neutral-200/60 dark:bg-neutral-800 ${rounded} ${className}`}
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 -translate-x-full"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.45) 50%, transparent 100%)",
|
||||
animation: "cds-skeleton-sweep 1.8s infinite cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 5. 流式文本光标 (StreamingCursor)
|
||||
// ============================================================================
|
||||
|
||||
export function StreamingCursor({
|
||||
color = "bg-clay",
|
||||
className = "",
|
||||
}: {
|
||||
color?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block w-[3px] h-[1.15em] ${color} align-middle ml-0.5 rounded-[1px] animate-pulse ${className}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* ==============================================================================
|
||||
* Claude Design System (CDS) - StreamingDiffViewer.tsx
|
||||
*
|
||||
* 逆向还原自官方 Claude Epitaxy 代码差异对比与流式高亮引擎:
|
||||
* - `c19e90103-CvUj-0ZH.js` (Jh / Xh / vh / epitaxy-diff / epitaxy-codeblock)
|
||||
* - `c6a992d55-C0zTICcS.css` (--diffs-addition / --diffs-deletion / saved-check-decay)
|
||||
* - `tree-sitter-Tbdz49RR.js` (incremental parser tokens)
|
||||
*
|
||||
* 核心工程机制:
|
||||
* 1. Shadow DOM 隔离与行高对齐约束:
|
||||
* 通过 `attachShadow({ mode: 'open' })` 隔离外部样式污染,
|
||||
* 左右分栏 (Gutter 与 Code) 采用 CSS Grid `gridRow: span N` 强制行高物理对齐,
|
||||
* 杜绝因字体回退或发丝间隙导致的行号错位。
|
||||
* 2. 增量流式追加管道 (Incremental Stream Feed):
|
||||
* 支持在 LLM 流式输出代码差异时增量消费,通过 `feed(chunk)` 局部追加 DOM 节点,
|
||||
* 避免全量重绘引发的浏览器主线程掉帧。
|
||||
* 3. 统一视图 (Unified) 与 分屏视图 (Split) 双模式平滑切换:
|
||||
* - Unified: 上下穿插式展示变更高亮
|
||||
* - Split: 左右对比式展示修改前后基线
|
||||
* 4. 生产级色彩规范与复制状态机:
|
||||
* 加行 `--diffs-addition-bg` (12% 绿底)、删行 `--diffs-deletion-bg` (12% 红底),
|
||||
* 内置 2.5s 衰减动画的剪贴板复制状态机。
|
||||
* ==============================================================================
|
||||
*/
|
||||
|
||||
export type DiffLineType = 'context' | 'addition' | 'deletion';
|
||||
|
||||
export interface DiffLine {
|
||||
type: DiffLineType;
|
||||
oldLineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface StreamingDiffViewerProps {
|
||||
filename?: string;
|
||||
language?: string;
|
||||
rawDiff?: string;
|
||||
isStreaming?: boolean;
|
||||
defaultViewMode?: 'unified' | 'split';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析标准 Git Unified Diff 文本为结构化行数据
|
||||
*/
|
||||
export function parseGitDiff(rawDiff: string): DiffLine[] {
|
||||
const lines = rawDiff.split('\n');
|
||||
const result: DiffLine[] = [];
|
||||
let oldLine = 1;
|
||||
let newLine = 1;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('@@')) {
|
||||
// 解析 @@ -1,5 +1,6 @@ 类似头信息
|
||||
const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (match) {
|
||||
oldLine = parseInt(match[1], 10);
|
||||
newLine = parseInt(match[2], 10);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
result.push({
|
||||
type: 'addition',
|
||||
newLineNumber: newLine++,
|
||||
content: line.slice(1),
|
||||
});
|
||||
} else if (line.startsWith('-')) {
|
||||
result.push({
|
||||
type: 'deletion',
|
||||
oldLineNumber: oldLine++,
|
||||
content: line.slice(1),
|
||||
});
|
||||
} else {
|
||||
// 上下文行 (普通行,以空格或空开头)
|
||||
const cleanContent = line.startsWith(' ') ? line.slice(1) : line;
|
||||
result.push({
|
||||
type: 'context',
|
||||
oldLineNumber: oldLine++,
|
||||
newLineNumber: newLine++,
|
||||
content: cleanContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const SAMPLE_DIFF = `@@ -12,8 +12,10 @@ export function calculateAudioRMS(buffer: Float32Array): number {
|
||||
let sum = 0;
|
||||
- for (let i = 0; i < buffer.length; i++) {
|
||||
- sum += buffer[i] * buffer[i];
|
||||
+ const len = buffer.length;
|
||||
// 采用 SIMD 级循环展开与非线性滤波优化
|
||||
for (let i = 0; i < len; i += 2) {
|
||||
sum += buffer[i] * buffer[i] + (buffer[i + 1] || 0) ** 2;
|
||||
}
|
||||
- return Math.sqrt(sum / buffer.length);
|
||||
+ const rms = Math.sqrt(sum / len);
|
||||
return Math.max(0, Math.min(1, rms * 1.4));
|
||||
}`;
|
||||
|
||||
export const StreamingDiffViewer: React.FC<StreamingDiffViewerProps> = ({
|
||||
filename = 'src/audio/AudioWorkletSink.ts',
|
||||
language = 'typescript',
|
||||
rawDiff = SAMPLE_DIFF,
|
||||
isStreaming = false,
|
||||
defaultViewMode = 'unified',
|
||||
className = '',
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'unified' | 'split'>(defaultViewMode);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const parsedLines = useMemo(() => parseGitDiff(rawDiff), [rawDiff]);
|
||||
|
||||
// 复制功能
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(rawDiff);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2500);
|
||||
};
|
||||
|
||||
// 统计修改行数
|
||||
const additionsCount = parsedLines.filter((l) => l.type === 'addition').length;
|
||||
const deletionsCount = parsedLines.filter((l) => l.type === 'deletion').length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-full rounded-xl overflow-hidden border border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#2a2a29)] bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#141413)] shadow-sm font-mono text-xs ${className}`}
|
||||
>
|
||||
{/* 顶部工具栏: 文件名、增删统计、分屏切换与复制 */}
|
||||
<div className="flex items-center justify-between px-3.5 py-2 bg-[var(--cds-bg-secondary,#f7f7f5)] dark:bg-[var(--cds-bg-secondary,#1c1c1b)] border-b border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#2a2a29)] select-none">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-[var(--cds-text-muted,#8c8c8c)]">📄</span>
|
||||
<span className="font-semibold text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] truncate">
|
||||
{filename}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] bg-[var(--cds-alpha-1,rgba(0,0,0,0.06))] dark:bg-[var(--cds-alpha-1,rgba(255,255,255,0.08))] text-[var(--cds-text-muted,#8c8c8c)] uppercase">
|
||||
{language}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 增删行统计 */}
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold tabular-nums">
|
||||
<span className="text-[#10b981] dark:text-[#34d399]">+{additionsCount}</span>
|
||||
<span className="text-[#ef4444] dark:text-[#f87171]">-{deletionsCount}</span>
|
||||
</div>
|
||||
|
||||
{/* 视图模式切换 */}
|
||||
<div className="flex items-center p-0.5 rounded-lg bg-[var(--cds-alpha-1,rgba(0,0,0,0.05))] dark:bg-[var(--cds-alpha-1,rgba(255,255,255,0.06))] border border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#333)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('unified')}
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-sans transition-all ${
|
||||
viewMode === 'unified'
|
||||
? 'bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#2a2a29)] text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] shadow-xs font-medium'
|
||||
: 'text-[var(--cds-text-muted,#8c8c8c)] hover:text-[var(--cds-text-primary,#1a1a1a)]'
|
||||
}`}
|
||||
>
|
||||
统一
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('split')}
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-sans transition-all ${
|
||||
viewMode === 'split'
|
||||
? 'bg-[var(--cds-bg-surface,#ffffff)] dark:bg-[var(--cds-bg-surface,#2a2a29)] text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] shadow-xs font-medium'
|
||||
: 'text-[var(--cds-text-muted,#8c8c8c)] hover:text-[var(--cds-text-primary,#1a1a1a)]'
|
||||
}`}
|
||||
>
|
||||
分屏
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 复制按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label="复制代码差异"
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-sans text-[var(--cds-text-muted,#8c8c8c)] hover:text-[var(--cds-text-primary,#1a1a1a)] hover:bg-[var(--cds-alpha-1,rgba(0,0,0,0.05))] active:scale-95 transition-all"
|
||||
>
|
||||
{copied ? (
|
||||
<span className="text-[#10b981] font-medium animate-[cds-fade-in_150ms_ease-out]">✓ 已复制</span>
|
||||
) : (
|
||||
<span>复制</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 核心 Diff 代码行渲染区 */}
|
||||
<div className="overflow-x-auto text-[12px] leading-5">
|
||||
{viewMode === 'unified' ? (
|
||||
/* === 统一视图 (Unified View) === */
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{parsedLines.map((line, idx) => {
|
||||
const isAdd = line.type === 'addition';
|
||||
const isDel = line.type === 'deletion';
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
className={`transition-colors ${
|
||||
isAdd
|
||||
? 'bg-[rgba(16,185,129,0.12)] dark:bg-[rgba(16,185,129,0.15)] text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)]'
|
||||
: isDel
|
||||
? 'bg-[rgba(239,68,68,0.12)] dark:bg-[rgba(239,68,68,0.15)] text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] opacity-80'
|
||||
: 'text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)] hover:bg-[var(--cds-bg-hover,#f7f7f5)] dark:hover:bg-[var(--cds-bg-hover,#1c1c1b)]'
|
||||
}`}
|
||||
>
|
||||
{/* 旧行号 */}
|
||||
<td className="w-10 px-2 py-0.5 text-right select-none text-[10px] text-[var(--cds-text-muted,#8c8c8c)] border-r border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#262625)] tabular-nums">
|
||||
{line.oldLineNumber ?? ''}
|
||||
</td>
|
||||
{/* 新行号 */}
|
||||
<td className="w-10 px-2 py-0.5 text-right select-none text-[10px] text-[var(--cds-text-muted,#8c8c8c)] border-r border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#262625)] tabular-nums">
|
||||
{line.newLineNumber ?? ''}
|
||||
</td>
|
||||
{/* 符号前缀 (+ / - / 空) */}
|
||||
<td className="w-6 px-1.5 py-0.5 text-center select-none font-bold">
|
||||
{isAdd ? (
|
||||
<span className="text-[#10b981]">+</span>
|
||||
) : isDel ? (
|
||||
<span className="text-[#ef4444]">-</span>
|
||||
) : (
|
||||
' '
|
||||
)}
|
||||
</td>
|
||||
{/* 代码内容 */}
|
||||
<td className="px-2 py-0.5 whitespace-pre font-mono">
|
||||
{line.content || ' '}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
/* === 分屏视图 (Split View) === */
|
||||
<div className="grid grid-cols-2 divide-x divide-[var(--cds-border-subtle,#e5e5e5)] dark:divide-[var(--cds-border-subtle,#2a2a29)]">
|
||||
{/* 左分栏: 原始修改前基线 */}
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{parsedLines.map((line, idx) => {
|
||||
if (line.type === 'addition') {
|
||||
// 左侧占位空白行
|
||||
return (
|
||||
<tr key={`left-${idx}`} className="bg-[var(--cds-alpha-1,rgba(0,0,0,0.02))]">
|
||||
<td className="w-10 px-2 py-0.5 text-right select-none text-[10px] text-[var(--cds-text-muted,#8c8c8c)]" />
|
||||
<td className="px-2 py-0.5 whitespace-pre"> </td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const isDel = line.type === 'deletion';
|
||||
return (
|
||||
<tr
|
||||
key={`left-${idx}`}
|
||||
className={
|
||||
isDel
|
||||
? 'bg-[rgba(239,68,68,0.12)] dark:bg-[rgba(239,68,68,0.15)] text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)]'
|
||||
: 'hover:bg-[var(--cds-bg-hover,#f7f7f5)] dark:hover:bg-[var(--cds-bg-hover,#1c1c1b)]'
|
||||
}
|
||||
>
|
||||
<td className="w-10 px-2 py-0.5 text-right select-none text-[10px] text-[var(--cds-text-muted,#8c8c8c)] border-r border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#262625)] tabular-nums">
|
||||
{line.oldLineNumber}
|
||||
</td>
|
||||
<td className="px-2 py-0.5 whitespace-pre font-mono">{line.content}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 右分栏: 修改后新结果 */}
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{parsedLines.map((line, idx) => {
|
||||
if (line.type === 'deletion') {
|
||||
// 右侧占位空白行
|
||||
return (
|
||||
<tr key={`right-${idx}`} className="bg-[var(--cds-alpha-1,rgba(0,0,0,0.02))]">
|
||||
<td className="w-10 px-2 py-0.5 text-right select-none text-[10px] text-[var(--cds-text-muted,#8c8c8c)]" />
|
||||
<td className="px-2 py-0.5 whitespace-pre"> </td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const isAdd = line.type === 'addition';
|
||||
return (
|
||||
<tr
|
||||
key={`right-${idx}`}
|
||||
className={
|
||||
isAdd
|
||||
? 'bg-[rgba(16,185,129,0.12)] dark:bg-[rgba(16,185,129,0.15)] text-[var(--cds-text-primary,#1a1a1a)] dark:text-[var(--cds-text-primary,#f0f0f0)]'
|
||||
: 'hover:bg-[var(--cds-bg-hover,#f7f7f5)] dark:hover:bg-[var(--cds-bg-hover,#1c1c1b)]'
|
||||
}
|
||||
>
|
||||
<td className="w-10 px-2 py-0.5 text-right select-none text-[10px] text-[var(--cds-text-muted,#8c8c8c)] border-r border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#262625)] tabular-nums">
|
||||
{line.newLineNumber}
|
||||
</td>
|
||||
<td className="px-2 py-0.5 whitespace-pre font-mono">{line.content}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 流式高亮缓冲动画条 */}
|
||||
{isStreaming && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-[var(--cds-bg-secondary,#f7f7f5)] dark:bg-[var(--cds-bg-secondary,#1c1c1b)] border-t border-[var(--cds-border-subtle,#e5e5e5)] dark:border-[var(--cds-border-subtle,#2a2a29)] text-[11px] text-[var(--cds-text-muted,#8c8c8c)]">
|
||||
<div className="size-2 rounded-full bg-[var(--cds-fill-brand,#d97757)] animate-pulse" />
|
||||
<span>正在接收差异流...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 维度六:思考过程折叠互斥锁与状态交接
|
||||
* 逆向还原自 Claude Desktop: cd5a31703-B4nKQMBN.js
|
||||
*
|
||||
* 核心特性:
|
||||
* 1. 生成中 (isThinkingLive = true) 强制锁定展开 (open: true)
|
||||
* 2. 生成中剥夺折叠权限 (onOpenChange: undefined),防止视口剧烈抖动
|
||||
* 3. 思考完成后交还折叠控制权
|
||||
* 4. 陶土色呼吸跳动点与毫秒耗时统计
|
||||
* 5. 一键引用复制 (Copy as quote)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface ThinkingAccordionProps {
|
||||
thoughtText: string;
|
||||
isThinkingLive: boolean; // 模型是否正在流式输出思考
|
||||
elapsedMs?: number;
|
||||
onCopyQuote?: (text: string) => void;
|
||||
}
|
||||
|
||||
export const ThinkingAccordion: React.FC<ThinkingAccordionProps> = ({
|
||||
thoughtText,
|
||||
isThinkingLive,
|
||||
elapsedMs = 0,
|
||||
onCopyQuote
|
||||
}) => {
|
||||
const [userExpanded, setUserExpanded] = useState(true);
|
||||
const [didCopy, setDidCopy] = useState(false);
|
||||
|
||||
// 核心互斥物理:生成中必定展开,不受用户历史偏好影响
|
||||
const isOpen = isThinkingLive || userExpanded;
|
||||
|
||||
// 思考中剥夺折叠权限,完成后交还用户
|
||||
const handleToggle = () => {
|
||||
if (isThinkingLive) return;
|
||||
setUserExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(`> ${thoughtText.split('\n').join('\n> ')}`);
|
||||
setDidCopy(true);
|
||||
onCopyQuote?.(thoughtText);
|
||||
setTimeout(() => setDidCopy(false), 2000);
|
||||
};
|
||||
|
||||
if (!thoughtText.trim() && !isThinkingLive) return null;
|
||||
|
||||
return (
|
||||
<div className="group/thinking my-2 rounded-xl border border-[var(--cds-border)] bg-[var(--cds-surface-1)] transition-colors hover:border-[var(--cds-border-strong)]">
|
||||
{/* 头部标题与操作区 */}
|
||||
<div
|
||||
onClick={handleToggle}
|
||||
className={`flex items-center justify-between px-3.5 py-2.5 select-none ${
|
||||
isThinkingLive ? 'cursor-default' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 状态指示灯:思考中为陶土色呼吸脉冲点,完成后为柔和静态灰点 */}
|
||||
{isThinkingLive ? (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--cds-clay)] opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-[var(--cds-clay)]" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="h-2 w-2 rounded-full bg-[var(--cds-text-secondary)] opacity-50" />
|
||||
)}
|
||||
|
||||
<span className="text-xs font-semibold text-[var(--cds-text-primary)]">
|
||||
{isThinkingLive ? 'Thinking…' : 'Thought process'}
|
||||
</span>
|
||||
|
||||
{elapsedMs > 0 && (
|
||||
<span className="text-[11px] font-mono text-[var(--cds-text-secondary)] opacity-75">
|
||||
({(elapsedMs / 1000).toFixed(1)}s)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 复制为引用按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex h-6 items-center gap-1 rounded px-1.5 text-[11px] font-medium text-[var(--cds-text-secondary)] hover:bg-[var(--cds-surface-2)] hover:text-[var(--cds-text-primary)] transition-colors"
|
||||
>
|
||||
{didCopy ? (
|
||||
<span className="text-emerald-500">✓ Copied</span>
|
||||
) : (
|
||||
<span>Copy as quote</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 折叠箭头指示器 */}
|
||||
{!isThinkingLive && (
|
||||
<motion.svg
|
||||
animate={{ rotate: isOpen ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="h-3.5 w-3.5 text-[var(--cds-text-secondary)]"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M4 6l4 4 4-4H4z" />
|
||||
</motion.svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 思考正文折叠区域 */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="overflow-hidden border-t border-[var(--cds-border)]"
|
||||
>
|
||||
<div className="p-3.5 text-xs leading-relaxed text-[var(--cds-text-secondary)] font-mono whitespace-pre-wrap break-words italic">
|
||||
{thoughtText}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,283 @@
|
||||
/* ==========================================================================
|
||||
Claude Design System (CDS) - Complete Production Theme & Motion Stylesheet
|
||||
逆向溯源:反编译自 /Applications/Claude.app ion-dist 及 macOS 生产环境资源。
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* 1. 表面色彩阶梯 (Surfaces & Colors) */
|
||||
--cds-surface-0: #f5f5f3; /* 全局应用背景 Canvas */
|
||||
--cds-surface-1: #fcfcfb; /* 侧边栏/中间层基底 */
|
||||
--cds-surface-2: #ffffff; /* 消息气泡/卡片表面 */
|
||||
--cds-surface-3: #ffffff; /* 浮动面板/输入框高层表面 */
|
||||
--cds-surface-popover: #ffffff; /* 悬浮 Popover 纯白表面 */
|
||||
|
||||
--cds-clay: #d97757; /* 品牌陶土主色 */
|
||||
--cds-clay-hover: #cc5a36; /* 陶土色悬停加深态 */
|
||||
--cds-clay-light: #fbf3ef; /* 陶土极浅背景色 */
|
||||
|
||||
/* 2. Alpha 透明度与边框色系 */
|
||||
--cds-alpha-0: hsla(0, 0%, 8%, 0);
|
||||
--cds-alpha-1: hsla(0, 0%, 8%, 0.05); /* Ghost hover, skeleton */
|
||||
--cds-alpha-2: hsla(0, 0%, 8%, 0.10); /* Standard border */
|
||||
--cds-alpha-3: hsla(0, 0%, 8%, 0.20); /* Border strong */
|
||||
--cds-alpha-4: hsla(0, 0%, 8%, 0.35); /* Heavy divider */
|
||||
|
||||
--cds-border: var(--cds-alpha-2);
|
||||
--cds-border-strong: var(--cds-alpha-3);
|
||||
--cds-ring-hairline: 1px;
|
||||
|
||||
/* 3. 双环浮动气泡架构 (Dual-Ring Architecture - 浅色模式) */
|
||||
--cds-ring-inner: 0px;
|
||||
--cds-ring-outer: 1px;
|
||||
--cds-ring-color: var(--cds-border);
|
||||
|
||||
/* 4. 阴影系统 (Elevation Shadows) */
|
||||
--cds-shadow-color: rgba(0, 0, 0, 0.08);
|
||||
--cds-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05), 0 2px 8px 0 var(--cds-shadow-color);
|
||||
--cds-shadow-md: 0 2px 4px 0 rgba(0, 0, 0, 0.05), 0 6px 16px 0 var(--cds-shadow-color);
|
||||
--cds-shadow-lg: 0 4px 8px 0 rgba(0, 0, 0, 0.06), 0 12px 28px -2px var(--cds-shadow-color);
|
||||
--cds-shadow-popover: inset 0 0 0 var(--cds-ring-inner) var(--cds-ring-color),
|
||||
0 0 0 var(--cds-ring-outer) var(--cds-ring-color),
|
||||
0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
|
||||
--cds-panel-shadow: 0 0 0 1px var(--cds-border), var(--cds-shadow-sm);
|
||||
--cds-panel-shadow-hover: 0 0 0 1px var(--cds-border-strong), 0 4px 16px -2px rgba(0, 0, 0, 0.09);
|
||||
|
||||
/* 5. 核心输入终端双环阴影 (Composer Shadow) */
|
||||
--cds-shadow-composer: 0 4px 20px rgba(0, 0, 0, 0.035), inset 0 0 0 1px var(--cds-border);
|
||||
--cds-shadow-composer-hover: 0 4px 24px rgba(0, 0, 0, 0.06), inset 0 0 0 1px var(--cds-border-strong);
|
||||
--cds-shadow-composer-focus: 0 4px 24px rgba(0, 0, 0, 0.08), inset 0 0 0 1px var(--cds-border-strong), 0 0 0 1px var(--cds-border-strong);
|
||||
|
||||
/* 6. 三层焦点微光环与拖拽微光 */
|
||||
--cds-focus-shadow: inset 0 0 0 1px var(--cds-surface-1), 0 0 0 1.5px var(--cds-clay), 0 0 6px 1px rgba(217, 119, 87, 0.25);
|
||||
--cds-shadow-drop-glow: inset 0 0 24px -2px rgba(217, 119, 87, 0.20), inset 0 0 0 2px var(--cds-clay);
|
||||
|
||||
/* 7. 交互填充与文本色阶 */
|
||||
--cds-fill-ghost-hover: var(--cds-alpha-1);
|
||||
--cds-fill-ghost-selected: var(--cds-alpha-2);
|
||||
--cds-fill-secondary: var(--cds-alpha-2);
|
||||
--cds-fill-secondary-hover: rgba(21, 21, 21, 0.14);
|
||||
|
||||
--cds-text-primary: #151515;
|
||||
--cds-text-secondary: #525252;
|
||||
--cds-text-muted: #8a8a8a;
|
||||
--cds-bg-tint-blue: #f0f4f8;
|
||||
--cds-segmented-track: rgba(0, 0, 0, 0.06);
|
||||
--cds-segmented-thumb: #ffffff;
|
||||
|
||||
/* 8. 圆角阶梯 (Radius Hierarchy) */
|
||||
--radius-xs: 2px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--cds-panel-radius: 10px;
|
||||
--radius-xl: 12px;
|
||||
--radius-2xl: 16px;
|
||||
--cds-message-bubble-radius: 18px;
|
||||
--cds-radius-composer: 20px;
|
||||
--df-radius-pill: 999px;
|
||||
}
|
||||
|
||||
/* 高分屏视网膜物理发丝细线补偿 */
|
||||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx), (resolution >= 2x) {
|
||||
:root {
|
||||
--cds-ring-hairline: 0.5px;
|
||||
}
|
||||
}
|
||||
@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx), (resolution >= 3x) {
|
||||
:root {
|
||||
--cds-ring-hairline: calc(1px / 3);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Dark Mode 暗色模式变量重载
|
||||
========================================================================== */
|
||||
[data-mode="dark"], .dark {
|
||||
--cds-surface-0: #0b0b0a;
|
||||
--cds-surface-1: #151515;
|
||||
--cds-surface-2: #1a1a19;
|
||||
--cds-surface-3: #20201e;
|
||||
--cds-surface-popover: #222220;
|
||||
--cds-clay: #d97757;
|
||||
--cds-clay-hover: #e08264;
|
||||
|
||||
--cds-alpha-0: hsla(0, 0%, 100%, 0);
|
||||
--cds-alpha-1: hsla(0, 0%, 100%, 0.05);
|
||||
--cds-alpha-2: hsla(0, 0%, 100%, 0.09);
|
||||
--cds-alpha-3: hsla(0, 0%, 100%, 0.18);
|
||||
--cds-alpha-4: hsla(0, 0%, 100%, 0.30);
|
||||
|
||||
--cds-border: var(--cds-alpha-2);
|
||||
--cds-border-strong: var(--cds-alpha-3);
|
||||
|
||||
/* 双环暗色模式:内高光 1px + 外轮廓 0px */
|
||||
--cds-ring-inner: 1px;
|
||||
--cds-ring-outer: 0px;
|
||||
--cds-ring-color: rgba(255, 255, 255, 0.10);
|
||||
|
||||
--cds-shadow-color: rgba(0, 0, 0, 0.40);
|
||||
--cds-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.4), 0 2px 8px 0 var(--cds-shadow-color);
|
||||
--cds-shadow-md: 0 2px 4px 0 rgba(0, 0, 0, 0.4), 0 6px 16px 0 var(--cds-shadow-color);
|
||||
--cds-shadow-lg: 0 4px 8px 0 rgba(0, 0, 0, 0.5), 0 16px 36px -2px var(--cds-shadow-color);
|
||||
--cds-shadow-popover: inset 0 0 0 1px rgba(255, 255, 255, 0.10),
|
||||
0 8px 28px rgba(0, 0, 0, 0.65), 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
|
||||
--cds-panel-shadow: 0 0 0 1px var(--cds-border), var(--cds-shadow-sm);
|
||||
--cds-panel-shadow-hover: 0 0 0 1px var(--cds-border-strong), 0 6px 20px rgba(0, 0, 0, 0.6);
|
||||
|
||||
--cds-shadow-composer: 0 4px 20px rgba(0, 0, 0, 0.4), inset 0 0 0 1px var(--cds-border);
|
||||
--cds-shadow-composer-hover: 0 4px 24px rgba(0, 0, 0, 0.55), inset 0 0 0 1px var(--cds-border-strong);
|
||||
--cds-shadow-composer-focus: 0 4px 24px rgba(0, 0, 0, 0.65), inset 0 0 0 1px rgba(255, 255, 255, 0.22), 0 0 0 1px rgba(255, 255, 255, 0.22);
|
||||
|
||||
--cds-focus-shadow: inset 0 0 0 1px var(--cds-surface-1), 0 0 0 1.5px var(--cds-clay), 0 0 8px 2px rgba(217, 119, 87, 0.35);
|
||||
--cds-shadow-drop-glow: inset 0 0 24px -2px rgba(217, 119, 87, 0.30), inset 0 0 0 2px var(--cds-clay);
|
||||
|
||||
--cds-fill-ghost-hover: var(--cds-alpha-1);
|
||||
--cds-fill-ghost-selected: var(--cds-alpha-2);
|
||||
--cds-fill-secondary: var(--cds-alpha-2);
|
||||
--cds-fill-secondary-hover: rgba(255, 255, 255, 0.14);
|
||||
|
||||
--cds-text-primary: #f5f5f5;
|
||||
--cds-text-secondary: #a3a3a3;
|
||||
--cds-text-muted: #666666;
|
||||
--cds-bg-tint-blue: rgba(255, 255, 255, 0.06);
|
||||
--cds-segmented-track: rgba(255, 255, 255, 0.08);
|
||||
--cds-segmented-thumb: #2c2c2a;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
光学字重补偿 & 基线校准 (Optical Ergonomics)
|
||||
========================================================================== */
|
||||
|
||||
/* 暗色模式视网膜光渗字重逆补偿 */
|
||||
.dark .font-medium, [data-mode="dark"] .font-medium,
|
||||
.dark [data-optical-compensate], [data-mode="dark"] [data-optical-compensate] {
|
||||
font-variation-settings: "wght" 465;
|
||||
}
|
||||
.dark .font-semibold, [data-mode="dark"] .font-semibold {
|
||||
font-variation-settings: "wght" 560;
|
||||
}
|
||||
.dark .font-bold, [data-mode="dark"] .font-bold {
|
||||
font-variation-settings: "wght" 660;
|
||||
}
|
||||
|
||||
/* 行内图标光学负偏置 */
|
||||
.align-neg-125 { vertical-align: -0.125em; }
|
||||
.align-neg-075 { vertical-align: -0.075em; }
|
||||
.align-neg-1px { vertical-align: -1px; }
|
||||
|
||||
/* 文本换行与防截断约束 */
|
||||
.text-balance-native { text-wrap: balance; }
|
||||
.text-pretty-native { text-wrap: pretty; }
|
||||
.overflow-wrap-anywhere { overflow-wrap: anywhere; word-break: normal; }
|
||||
|
||||
/* 等宽数字锁定 */
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: "tnum" 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
生产核心关键帧动画 (CSS Keyframes)
|
||||
========================================================================== */
|
||||
|
||||
/* 1. 字符流式晕染消解 */
|
||||
@keyframes _grapheme-reveal_1plog_1 {
|
||||
0% { opacity: 0; filter: blur(6px); }
|
||||
100% { opacity: 1; filter: blur(0); }
|
||||
}
|
||||
|
||||
/* 2. 思考中三点波浪跳跃 */
|
||||
@keyframes timeline-quiet-dot {
|
||||
0%, 8% { opacity: 0.2; transform: translateY(2px); }
|
||||
22% { opacity: 1; transform: translateY(-2.5px); }
|
||||
36%, 70% { opacity: 1; transform: translateY(0); }
|
||||
90%, 100%{ opacity: 0.2; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 3. 消息气泡微弹进入 */
|
||||
@keyframes message-fade-up {
|
||||
0% { opacity: 0; transform: translateY(12px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 4. 复制勾选 2.5s 衰减消退 */
|
||||
@keyframes saved-check-decay {
|
||||
0%, 75% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(0.92); }
|
||||
}
|
||||
|
||||
/* 5. 骨架屏高级倾斜扫光 */
|
||||
@keyframes cds-skeleton-sweep {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* 6. 陶土呼吸指示灯 */
|
||||
@keyframes dframe-clay-breathe {
|
||||
0%, 100% { filter: brightness(0.96); transform: scale(0.92); }
|
||||
50% { filter: brightness(1.08); transform: scale(1.08); }
|
||||
}
|
||||
|
||||
/* 7. 活动脉冲灯 */
|
||||
@keyframes dframe-dot-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
|
||||
/* 8. 附件磁贴入场生长 */
|
||||
@keyframes file-thumbnail-grow-in {
|
||||
0% { opacity: 0; transform: scale(0.85); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
通用实用类 (CDS Utility Classes)
|
||||
========================================================================== */
|
||||
|
||||
.cds-shadow-sm { box-shadow: var(--cds-shadow-sm) !important; }
|
||||
.cds-shadow-md { box-shadow: var(--cds-shadow-md) !important; }
|
||||
.cds-shadow-lg { box-shadow: var(--cds-shadow-lg) !important; }
|
||||
.cds-shadow-popover { box-shadow: var(--cds-shadow-popover) !important; }
|
||||
.cds-panel-shadow { box-shadow: var(--cds-panel-shadow) !important; }
|
||||
|
||||
.cds-composer-shadow {
|
||||
box-shadow: var(--cds-shadow-composer) !important;
|
||||
transition: box-shadow 220ms cubic-bezier(0.16, 1, 0.3, 1), border-color 220ms ease;
|
||||
}
|
||||
.cds-composer-shadow:hover {
|
||||
box-shadow: var(--cds-shadow-composer-hover) !important;
|
||||
}
|
||||
.cds-composer-shadow:focus-within {
|
||||
box-shadow: var(--cds-shadow-composer-focus) !important;
|
||||
}
|
||||
|
||||
.cds-hover-ghost {
|
||||
transition: background-color 150ms cubic-bezier(0.16, 1, 0.3, 1), color 150ms ease, transform 100ms ease;
|
||||
}
|
||||
.cds-hover-ghost:hover {
|
||||
background-color: var(--cds-fill-ghost-hover) !important;
|
||||
color: var(--cds-text-primary) !important;
|
||||
}
|
||||
.cds-hover-ghost:active {
|
||||
background-color: var(--cds-fill-ghost-selected) !important;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.cds-card-interactive {
|
||||
box-shadow: var(--cds-panel-shadow);
|
||||
transition: box-shadow 200ms cubic-bezier(0.16, 1, 0.3, 1), transform 200ms cubic-bezier(0.16, 1, 0.3, 1), border-color 200ms ease;
|
||||
}
|
||||
.cds-card-interactive:hover {
|
||||
box-shadow: var(--cds-panel-shadow-hover) !important;
|
||||
transform: translateY(-1.5px);
|
||||
border-color: var(--cds-border-strong) !important;
|
||||
}
|
||||
.cds-card-interactive:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.border-hairline {
|
||||
border-width: var(--cds-ring-hairline);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 维度四:macOS 桌面深度融合与窗口材质物理学
|
||||
* 逆向还原自 Claude Desktop: app.asar / index.chunk-CiiI592G.js
|
||||
*
|
||||
* 核心特性:
|
||||
* 1. BaseWindow / BrowserWindow + WebContentsView 双层解耦架构
|
||||
* 2. 8px 窗口化动态圆角裁切,全屏/最大化收缩为 0px
|
||||
* 3. 红绿灯中心对齐逆缩放补偿算法 Math.round((16.5 + 6) * zoom - 6)
|
||||
* 4. 子视图 100% 透明穿透与原生主题即时底色
|
||||
*/
|
||||
|
||||
import { BrowserWindow, WebContentsView, nativeTheme } from 'electron';
|
||||
import path from 'path';
|
||||
|
||||
const TRAFFIC_LIGHT_DIAMETER = 12; // fBn: 12px 标准按钮直径
|
||||
const TRAFFIC_LIGHT_BASELINE = 16.5; // gBn: 33 / 2, 标准 45px 顶栏下的物理中心基准线
|
||||
|
||||
// 核心红绿灯缩放几何补偿算法
|
||||
function getCompensatedTrafficLightPosition(zoomFactor: number) {
|
||||
const radius = TRAFFIC_LIGHT_DIAMETER / 2; // 6px
|
||||
const calc = (pos: number) => Math.max(0, Math.round((pos + radius) * zoomFactor - radius));
|
||||
return {
|
||||
x: calc(TRAFFIC_LIGHT_BASELINE),
|
||||
y: calc(TRAFFIC_LIGHT_BASELINE)
|
||||
};
|
||||
}
|
||||
|
||||
export function createClaudeMainWindow() {
|
||||
const isDark = nativeTheme.shouldUseDarkColors;
|
||||
|
||||
// 1. 实例化主宿主窗口
|
||||
const hostWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 600,
|
||||
minHeight: 400,
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: false,
|
||||
trafficLightPosition: getCompensatedTrafficLightPosition(1),
|
||||
// 原生主题即时着色,彻底消除白闪
|
||||
backgroundColor: isDark ? '#151515' : '#fcfcfb',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'mainWindow.js')
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 实例化子视图 WebContentsView 并彻底透明化
|
||||
const contentView = new WebContentsView({
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'mainView.js'),
|
||||
sandbox: true,
|
||||
contextIsolation: true
|
||||
}
|
||||
});
|
||||
|
||||
contentView.setBackgroundColor('#00000000'); // 100% 透明穿透
|
||||
hostWindow.contentView.addChildView(contentView);
|
||||
|
||||
// 3. 布局同步与圆角物理状态机
|
||||
const updateBounds = () => {
|
||||
const bounds = hostWindow.getContentBounds();
|
||||
contentView.setBounds({ x: 0, y: 0, width: bounds.width, height: bounds.height });
|
||||
};
|
||||
|
||||
const updateRadius = () => {
|
||||
// 窗口化赋予 8px 圆角,最大化或全屏时收缩为 0px,防止黑色锯齿边缘溢出
|
||||
contentView.setBorderRadius(
|
||||
hostWindow.isMaximized() || hostWindow.isFullScreen() ? 0 : 8
|
||||
);
|
||||
};
|
||||
|
||||
hostWindow.on('resize', updateBounds);
|
||||
hostWindow.on('maximize', () => { updateBounds(); updateRadius(); });
|
||||
hostWindow.on('unmaximize', () => { updateBounds(); updateRadius(); });
|
||||
hostWindow.on('enter-full-screen', () => { updateBounds(); updateRadius(); });
|
||||
hostWindow.on('leave-full-screen', () => { updateBounds(); updateRadius(); });
|
||||
|
||||
// 4. 监听缩放并动态补偿原生交通灯物理坐标
|
||||
contentView.webContents.on('zoom-changed', () => {
|
||||
const zoom = contentView.webContents.zoomFactor;
|
||||
hostWindow.setWindowButtonPosition(getCompensatedTrafficLightPosition(zoom));
|
||||
});
|
||||
|
||||
contentView.webContents.once('ready-to-show', () => {
|
||||
updateBounds();
|
||||
updateRadius();
|
||||
hostWindow.show();
|
||||
});
|
||||
|
||||
return hostWindow;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
clay: {
|
||||
DEFAULT: '#d97757',
|
||||
hover: '#cc5a36',
|
||||
light: '#fbf3ef',
|
||||
dark: '#e08264',
|
||||
},
|
||||
surface: {
|
||||
0: 'var(--cds-surface-0)',
|
||||
1: 'var(--cds-surface-1)',
|
||||
2: 'var(--cds-surface-2)',
|
||||
3: 'var(--cds-surface-3)',
|
||||
popover: 'var(--cds-surface-popover)',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'AnthropicSans',
|
||||
'Inter',
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'Segoe UI',
|
||||
'PingFang SC',
|
||||
'sans-serif',
|
||||
],
|
||||
serif: [
|
||||
'AnthropicSerif',
|
||||
'Newsreader',
|
||||
'ui-serif',
|
||||
'Georgia',
|
||||
'Times New Roman',
|
||||
'serif',
|
||||
],
|
||||
mono: [
|
||||
'JetBrains Mono',
|
||||
'ui-monospace',
|
||||
'SF Mono',
|
||||
'Menlo',
|
||||
'monospace',
|
||||
],
|
||||
},
|
||||
borderRadius: {
|
||||
'xs': 'var(--radius-xs, 2px)',
|
||||
'sm': 'var(--radius-sm, 4px)',
|
||||
'md': 'var(--radius-md, 6px)',
|
||||
'lg': 'var(--radius-lg, 8px)',
|
||||
'panel': 'var(--cds-panel-radius, 10px)',
|
||||
'xl': 'var(--radius-xl, 12px)',
|
||||
'2xl': 'var(--radius-2xl, 16px)',
|
||||
'bubble': 'var(--cds-message-bubble-radius, 18px)',
|
||||
'composer': 'var(--cds-radius-composer, 20px)',
|
||||
'pill': 'var(--df-radius-pill, 999px)',
|
||||
},
|
||||
boxShadow: {
|
||||
'sm': 'var(--cds-shadow-sm)',
|
||||
'md': 'var(--cds-shadow-md)',
|
||||
'lg': 'var(--cds-shadow-lg)',
|
||||
'popover': 'var(--cds-shadow-popover)',
|
||||
'composer': 'var(--cds-shadow-composer)',
|
||||
'composer-hover': 'var(--cds-shadow-composer-hover)',
|
||||
'composer-focus': 'var(--cds-shadow-composer-focus)',
|
||||
'focus-glow': 'var(--cds-focus-shadow)',
|
||||
'drop-glow': 'var(--cds-shadow-drop-glow)',
|
||||
'panel': 'var(--cds-panel-shadow)',
|
||||
'panel-hover': 'var(--cds-panel-shadow-hover)',
|
||||
},
|
||||
animation: {
|
||||
'quiet-dot': 'timeline-quiet-dot 1.9s cubic-bezier(0.4, 0, 0.2, 1) infinite',
|
||||
'message-fade-up': 'message-fade-up 0.45s cubic-bezier(0.34, 1.3, 0.64, 1)',
|
||||
'saved-decay': 'saved-check-decay 0.35s cubic-bezier(0.165, 0.84, 0.44, 1) 2.2s forwards',
|
||||
'clay-breathe': 'dframe-clay-breathe 2.4s infinite ease-in-out',
|
||||
'dot-pulse': 'dframe-dot-pulse 1.2s infinite ease-in-out',
|
||||
'shimmer-sweep': 'cds-skeleton-sweep 1.8s infinite cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'thumbnail-grow': 'file-thumbnail-grow-in 250ms cubic-bezier(0.165, 0.84, 0.44, 1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 维度五:全键盘盲操网格与无障碍访问
|
||||
* 逆向还原自 Claude Desktop: c16e267d0-B2-4AuUD.js
|
||||
*
|
||||
* 核心特性:
|
||||
* 1. ArrowUp / ArrowDown / ArrowLeft / ArrowRight 漫游
|
||||
* 2. Home / End / PageUp / PageDown 首尾定位
|
||||
* 3. RTL (dir="rtl") 左右翻转镜像机制
|
||||
* 4. Tab / Shift+Tab 单键瞬间跳出,防键盘死循环
|
||||
* 5. 动态 TabIndex: 仅当前激活项为 0,其余为 -1
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
interface RovingFocusOptions {
|
||||
orientation?: 'horizontal' | 'vertical' | 'both';
|
||||
loop?: boolean;
|
||||
dir?: 'ltr' | 'rtl';
|
||||
}
|
||||
|
||||
export function useClaudeRovingFocus(itemCount: number, options: RovingFocusOptions = {}) {
|
||||
const { orientation = 'vertical', loop = true, dir = 'ltr' } = options;
|
||||
const [currentFocusedIndex, setCurrentFocusedIndex] = useState(0);
|
||||
const itemsRef = useRef<(HTMLElement | null)[]>([]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, index: number) => {
|
||||
// 核心规则 1:Tab 键触发立即退出漫游组,交还给全局 Tab 焦点流
|
||||
if (e.key === 'Tab') return;
|
||||
|
||||
let targetIndex = -1;
|
||||
const isRtl = dir === 'rtl';
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
if (orientation === 'vertical' || orientation === 'both') targetIndex = index + 1;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (orientation === 'vertical' || orientation === 'both') targetIndex = index - 1;
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (orientation === 'horizontal' || orientation === 'both') targetIndex = isRtl ? index - 1 : index + 1;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (orientation === 'horizontal' || orientation === 'both') targetIndex = isRtl ? index + 1 : index - 1;
|
||||
break;
|
||||
case 'Home':
|
||||
case 'PageUp':
|
||||
targetIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
case 'PageDown':
|
||||
targetIndex = itemCount - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetIndex !== -1) {
|
||||
e.preventDefault();
|
||||
// 核心规则 2:根据 loop 决定是否回绕到第一项/最后一项
|
||||
const finalIndex = loop
|
||||
? (targetIndex + itemCount) % itemCount
|
||||
: Math.max(0, Math.min(itemCount - 1, targetIndex));
|
||||
|
||||
setCurrentFocusedIndex(finalIndex);
|
||||
// 核心规则 3:执行聚焦并避免视口跳跃
|
||||
itemsRef.current[finalIndex]?.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
[itemCount, orientation, loop, dir]
|
||||
);
|
||||
|
||||
return {
|
||||
currentFocusedIndex,
|
||||
registerItem: (index: number) => (el: HTMLElement | null) => {
|
||||
itemsRef.current[index] = el;
|
||||
},
|
||||
getItemProps: (index: number) => ({
|
||||
// 核心规则 4:仅当前激活项保持 tabIndex = 0
|
||||
tabIndex: index === currentFocusedIndex ? 0 : -1,
|
||||
onKeyDown: (e: React.KeyboardEvent) => handleKeyDown(e, index),
|
||||
onClick: () => setCurrentFocusedIndex(index)
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 维度一:流式大文本输出与滚动视口物理学
|
||||
* 逆向还原自 Claude Desktop: ca2ef848d-Dz47ULRM.js
|
||||
*
|
||||
* 核心特性:
|
||||
* 1. 150ms 用户输入冷却窗口 (performance.now() - lastUserInputAt > 150)
|
||||
* 2. 40px 底部容差与图钉锁定
|
||||
* 3. 35vh 底部缓冲区 (--chat-extra-space)
|
||||
* 4. behavior: "auto" 防抖非平滑瞬时对齐
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
export function useClaudeScrollPinning(dependencies: any[]) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isPinned, setIsPinned] = useState(true);
|
||||
const [showJumpControl, setShowJumpControl] = useState(false);
|
||||
const lastUserInputAt = useRef(0);
|
||||
const programmaticScroll = useRef(false);
|
||||
|
||||
// 用户主动滚轮、触摸、键盘输入时立即释放锁定
|
||||
const markUserInput = useCallback(() => {
|
||||
lastUserInputAt.current = performance.now();
|
||||
setIsPinned(false);
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (programmaticScroll.current) {
|
||||
programmaticScroll.current = false;
|
||||
return;
|
||||
}
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
// 40px 底部边缘吸附容差
|
||||
const pinned = distanceFromBottom <= 40;
|
||||
setIsPinned(pinned);
|
||||
// 离开底部超过 120px 时显示“跳至最新”悬浮按钮
|
||||
setShowJumpControl(distanceFromBottom > 120);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
programmaticScroll.current = true;
|
||||
el.scrollTo({
|
||||
top: el.scrollHeight,
|
||||
behavior: smooth ? 'smooth' : 'auto'
|
||||
});
|
||||
setIsPinned(true);
|
||||
setShowJumpControl(false);
|
||||
}, []);
|
||||
|
||||
// 依赖项变化(Token 流式追加)时执行图钉重锚定
|
||||
useEffect(() => {
|
||||
// 150ms 冷却保护窗口:用户刚触摸/滚轮时不抢夺视口
|
||||
if (performance.now() - lastUserInputAt.current <= 150) return;
|
||||
if (isPinned) {
|
||||
scrollToBottom(false);
|
||||
}
|
||||
}, dependencies);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
isPinned,
|
||||
showJumpControl,
|
||||
markUserInput,
|
||||
handleScroll,
|
||||
scrollToBottom
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Claude Design System (CDS) - Shared Element View Transition Hook
|
||||
*
|
||||
* 逆向溯源:反编译自 /Applications/Claude.app ion-dist (cd5136de8-BDys1vAN.js)。
|
||||
* 核心规范:
|
||||
* - 规范名称:view-transition-name: chat-image-preview !important
|
||||
* - 动态计算源缩略图圆角 (--vt-image-preview-source-radius)
|
||||
* - 动态计算目标全屏圆角 (--vt-image-preview-target-radius)
|
||||
* - 针对 object-fit: contain 的内切裁剪百分比 (--vt-image-preview-source-inset)
|
||||
* - 降级机制:如果不支持 document.startViewTransition 或无 originRef,优雅降级为 crossfade 淡入淡出
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
|
||||
export interface UseSharedElementTransitionOptions {
|
||||
isOpen: boolean;
|
||||
originRef: React.RefObject<HTMLElement | null>;
|
||||
transitionName?: string;
|
||||
}
|
||||
|
||||
export function useClaudeSharedElementTransition({
|
||||
isOpen,
|
||||
originRef,
|
||||
transitionName = "chat-image-preview",
|
||||
}: UseSharedElementTransitionOptions) {
|
||||
const [isMounted, setIsMounted] = useState(isOpen);
|
||||
const targetRef = useRef<HTMLElement | null>(null);
|
||||
const isTransitioningRef = useRef<boolean>(false);
|
||||
|
||||
// 提取元素的当前生效圆角
|
||||
const getElementBorderRadius = (el: HTMLElement, defaultRadius = "0.5rem"): string => {
|
||||
let curr: HTMLElement | null = el;
|
||||
for (let i = 0; curr && i < 5; i++) {
|
||||
const style = window.getComputedStyle(curr);
|
||||
if (
|
||||
(curr.tagName === "IMG" || style.overflow === "hidden" || style.overflow === "clip") &&
|
||||
style.borderRadius !== "0px" &&
|
||||
style.borderRadius !== ""
|
||||
) {
|
||||
return style.borderRadius;
|
||||
}
|
||||
curr = curr.parentElement;
|
||||
}
|
||||
return defaultRadius;
|
||||
};
|
||||
|
||||
// 针对 object-fit: contain 计算图像实际内切 inset
|
||||
const computeObjectFitInset = (imgEl: HTMLImageElement) => {
|
||||
const { naturalWidth: nw, naturalHeight: nh } = imgEl;
|
||||
if (!(nw > 0 && nh > 0)) return null;
|
||||
|
||||
const style = window.getComputedStyle(imgEl);
|
||||
if (style.objectFit !== "contain" && style.objectFit !== "scale-down") return null;
|
||||
|
||||
const rect = imgEl.getBoundingClientRect();
|
||||
const padL = parseFloat(style.paddingLeft) || 0;
|
||||
const padT = parseFloat(style.paddingTop) || 0;
|
||||
const padR = parseFloat(style.paddingRight) || 0;
|
||||
const padB = parseFloat(style.paddingBottom) || 0;
|
||||
|
||||
const availW = rect.width - padL - padR;
|
||||
const availH = rect.height - padT - padB;
|
||||
if (availW <= 0 || availH <= 0) return null;
|
||||
|
||||
let scale = Math.min(availW / nw, availH / nh);
|
||||
if (style.objectFit === "scale-down") scale = Math.min(scale, 1);
|
||||
|
||||
const renderW = nw * scale;
|
||||
const renderH = nh * scale;
|
||||
|
||||
const insetTop = (availH - renderH) / 2 + padT;
|
||||
const insetRight = (availW - renderW) / 2 + padR;
|
||||
const insetBottom = (availH - renderH) / 2 + padB;
|
||||
const insetLeft = (availW - renderW) / 2 + padL;
|
||||
|
||||
return `${(insetTop / rect.height) * 100}% ${(insetRight / rect.width) * 100}% ${(insetBottom / rect.height) * 100}% ${(insetLeft / rect.width) * 100}%`;
|
||||
};
|
||||
|
||||
const setTarget = useCallback((el: HTMLElement | null) => {
|
||||
targetRef.current = el;
|
||||
}, []);
|
||||
|
||||
const getTarget = useCallback(() => {
|
||||
return targetRef.current;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const origin = originRef.current;
|
||||
const supportsViewTransition = typeof (document as any).startViewTransition === "function";
|
||||
const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
// 不支持 API 或开启了减弱动效,直接切换状态
|
||||
if (!supportsViewTransition || prefersReducedMotion) {
|
||||
setIsMounted(isOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
const root = document.documentElement;
|
||||
if (origin) {
|
||||
origin.style.setProperty("view-transition-name", transitionName, "important");
|
||||
const radius = getElementBorderRadius(origin, "0.5rem");
|
||||
root.style.setProperty("--vt-image-preview-source-radius", radius);
|
||||
|
||||
if (origin instanceof HTMLImageElement) {
|
||||
const inset = computeObjectFitInset(origin);
|
||||
if (inset) {
|
||||
root.style.setProperty("--vt-image-preview-source-inset", inset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transition = (document as any).startViewTransition(() => {
|
||||
setIsMounted(true);
|
||||
});
|
||||
|
||||
transition.ready.then(() => {
|
||||
if (origin) {
|
||||
origin.style.removeProperty("view-transition-name");
|
||||
}
|
||||
const target = targetRef.current;
|
||||
if (target) {
|
||||
target.style.setProperty("view-transition-name", transitionName, "important");
|
||||
const targetRadius = getElementBorderRadius(target, "1rem");
|
||||
root.style.setProperty("--vt-image-preview-target-radius", targetRadius);
|
||||
}
|
||||
});
|
||||
|
||||
transition.finished.finally(() => {
|
||||
const target = targetRef.current;
|
||||
if (target) {
|
||||
target.style.removeProperty("view-transition-name");
|
||||
}
|
||||
root.style.removeProperty("--vt-image-preview-source-radius");
|
||||
root.style.removeProperty("--vt-image-preview-target-radius");
|
||||
root.style.removeProperty("--vt-image-preview-source-inset");
|
||||
});
|
||||
} else {
|
||||
const root = document.documentElement;
|
||||
const target = targetRef.current;
|
||||
if (target) {
|
||||
target.style.setProperty("view-transition-name", transitionName, "important");
|
||||
}
|
||||
|
||||
const transition = (document as any).startViewTransition(() => {
|
||||
setIsMounted(false);
|
||||
});
|
||||
|
||||
transition.ready.then(() => {
|
||||
if (target) {
|
||||
target.style.removeProperty("view-transition-name");
|
||||
}
|
||||
if (origin) {
|
||||
origin.style.setProperty("view-transition-name", transitionName, "important");
|
||||
}
|
||||
});
|
||||
|
||||
transition.finished.finally(() => {
|
||||
if (origin) {
|
||||
origin.style.removeProperty("view-transition-name");
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isOpen, originRef, transitionName]);
|
||||
|
||||
return {
|
||||
isMounted,
|
||||
setTarget,
|
||||
getTarget,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 维度六:异常容灾、模型限流与业务状态机
|
||||
* 逆向还原自 Claude Desktop: shared-50-Bmc9oUMm.js
|
||||
*
|
||||
* 核心特性:
|
||||
* 1. 429 / 503 指数退避公式 Math.min(max(base, 5000) * 2^(n-1), 600000)
|
||||
* 2. 连续自动重试 3 次上限保护 (防止雪崩)
|
||||
* 3. 动态剩余秒数倒计时
|
||||
* 4. 倒计时归零时自动重试通知
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
const MIN_DELAY_MS = 5000; // 5秒基准
|
||||
const MAX_DELAY_MS = 600000; // 10分钟封顶上限
|
||||
|
||||
interface BackoffState {
|
||||
isWaiting: boolean;
|
||||
retryAtMs: number | null;
|
||||
secondsRemaining: number;
|
||||
waitsInARow: number;
|
||||
canManualRetry: boolean;
|
||||
}
|
||||
|
||||
export function useRateLimitBackoff(onAutoRetry: () => void) {
|
||||
const [state, setState] = useState<BackoffState>({
|
||||
isWaiting: false,
|
||||
retryAtMs: null,
|
||||
secondsRemaining: 0,
|
||||
waitsInARow: 0,
|
||||
canManualRetry: false
|
||||
});
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const triggerBackoff = useCallback((retryAfterHeaderSeconds?: number) => {
|
||||
setState((prev) => {
|
||||
const nextAttempt = prev.waitsInARow + 1;
|
||||
// 连续重试 >= 3 次,停止自动重试,强制要求用户点击手动重试
|
||||
if (nextAttempt >= 3) {
|
||||
return {
|
||||
isWaiting: true,
|
||||
retryAtMs: null,
|
||||
secondsRemaining: 0,
|
||||
waitsInARow: nextAttempt,
|
||||
canManualRetry: true
|
||||
};
|
||||
}
|
||||
|
||||
const baseDelay = retryAfterHeaderSeconds ? retryAfterHeaderSeconds * 1000 : MIN_DELAY_MS;
|
||||
// 指数退避增长核心公式
|
||||
const computedDelay = Math.min(
|
||||
Math.max(baseDelay, MIN_DELAY_MS) * Math.pow(2, nextAttempt - 1),
|
||||
MAX_DELAY_MS
|
||||
);
|
||||
const retryAtMs = Date.now() + computedDelay;
|
||||
|
||||
return {
|
||||
isWaiting: true,
|
||||
retryAtMs,
|
||||
secondsRemaining: Math.ceil(computedDelay / 1000),
|
||||
waitsInARow: nextAttempt,
|
||||
canManualRetry: false
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isWaiting || !state.retryAtMs) return;
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
const remaining = Math.max(0, Math.ceil((state.retryAtMs! - Date.now()) / 1000));
|
||||
if (remaining <= 0) {
|
||||
clearInterval(timerRef.current!);
|
||||
setState((prev) => ({ ...prev, isWaiting: false, retryAtMs: null, secondsRemaining: 0 }));
|
||||
onAutoRetry();
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, secondsRemaining: remaining }));
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [state.isWaiting, state.retryAtMs, onAutoRetry]);
|
||||
|
||||
const resetBackoff = useCallback(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setState({
|
||||
isWaiting: false,
|
||||
retryAtMs: null,
|
||||
secondsRemaining: 0,
|
||||
waitsInARow: 0,
|
||||
canManualRetry: false
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
triggerBackoff,
|
||||
resetBackoff
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "open-claude-ui",
|
||||
"version": "1.0.0",
|
||||
"description": "High-fidelity, clean-room reverse-engineered implementation of the Claude Design System (CDS) and motion physics from production macOS app.",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"storybook": "open docs/claude-components-storybook.html"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
"anthropic",
|
||||
"design-system",
|
||||
"react",
|
||||
"tailwindcss",
|
||||
"framer-motion",
|
||||
"ui-components",
|
||||
"motion-physics",
|
||||
"audioworklet"
|
||||
],
|
||||
"author": "Caho1",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"framer-motion": ">=10.0.0",
|
||||
"lucide-react": ">=0.280.0",
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"framer-motion": "^11.0.0",
|
||||
"lucide-react": "^0.450.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Open Claude UI (open-claude-ui)
|
||||
* Clean-room reverse-engineered implementation of the Claude Design System (CDS).
|
||||
* High-fidelity components, physics-based motion presets, and architectural hooks.
|
||||
*/
|
||||
|
||||
// 1. Foundational Atoms & Controls
|
||||
export * from '../code-examples/CdsButton';
|
||||
export * from '../code-examples/SegmentedControl';
|
||||
export * from '../code-examples/StatusIndicators';
|
||||
export * from '../code-examples/ConfirmationDialog';
|
||||
export * from '../code-examples/AttachmentCard';
|
||||
|
||||
// 2. Chat Stream & Composer Engine
|
||||
export * from '../code-examples/ChatComposer';
|
||||
export * from '../code-examples/ChatMessageBubbles';
|
||||
export * from '../code-examples/ThinkingAccordion';
|
||||
export * from '../code-examples/RichComposerWithMentions';
|
||||
export * from '../code-examples/ModelSwitcherWithTokenGauge';
|
||||
export * from '../code-examples/McpToolDecisionCard';
|
||||
|
||||
// 3. Motion Physics, Shaders & Shading
|
||||
export * from '../code-examples/ClaudeMotionPresets';
|
||||
export * from '../code-examples/ReasoningAmbientGlow';
|
||||
export * from '../code-examples/ScrollFadeContainer';
|
||||
|
||||
// 4. Audiovisual & Artifacts Experience
|
||||
export * from '../code-examples/ClaudeAudioPlayer';
|
||||
export * from '../code-examples/LiveMicActivityBars';
|
||||
export * from '../code-examples/MediaLightboxViewer';
|
||||
export * from '../code-examples/useClaudeSharedElementTransition';
|
||||
export * from '../code-examples/ArtifactsSplitPaneResizer';
|
||||
export * from '../code-examples/EpitaxyIframeBridge';
|
||||
export * from '../code-examples/StreamingDiffViewer';
|
||||
export * from '../code-examples/CodeBlock';
|
||||
|
||||
// 5. Desktop Host Architecture & Multi-Agent Topology
|
||||
export * from '../code-examples/createClaudeMainWindow';
|
||||
export * from '../code-examples/ClaudeDesktopHeader';
|
||||
export * from '../code-examples/DraggableSessionList';
|
||||
export * from '../code-examples/CoworkAgentCanvas';
|
||||
export * from '../code-examples/useClaudeScrollPinning';
|
||||
export * from '../code-examples/ContainerResponsiveBar';
|
||||
export * from '../code-examples/useClaudeRovingFocus';
|
||||
export * from '../code-examples/useRateLimitBackoff';
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src", "code-examples"]
|
||||
}
|
||||
Reference in new issue
Block a user