mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-09 23:10:20 +08:00
refactor(Preview,CodeBlock): preview components and tools (#8565)
* refactor(CodeBlockView): generalize tool and preview Generalize code tool to action tool - CodeTool -> ActionTool - usePreviewTools -> useImagePreview - rename code tool classname from icon to tool-icon Generalize preview - move image preview components to Preview dir - simplify file names * refactor(useImageTools): simplify implementation, add pan * refactor(Preview): move image tools to floating toolbar * refactor: add enableDrag, enable zooming for SvgPreview * test: add tests for preview components * feat(Preview): add download buttons to dropdown * refactor(Preview): remove setTools from preview, improve SvgPreview * refactor: add useTemporaryValue * test: add tests for hooks * test: add tests for CodeToolButton and CodeToolbar * refactor(PreviewTool): add a setting item to enable preview tools * test: update snapshot * refactor: extract more code tools to hooks, add tests * refactor: extract tools from CodeEditor and CodeViewer * test: add tests for new tool hooks * refactor(CodeEditor): change collapsible to expanded, change wrappable to unwrapped * refactor: migrate codePreview to codeViewer * docs: CodeBlockView * refactor: add custom file icons, center the reset button * refactor: improve code quality * refactor: improve migration by deprecating codePreview * refactor: improve PlantUml and svgToCanvas * fix: plantuml style * test: fix tests * fix: button icon * refactor(SvgPreview): debounce rendering * feat(PreviewTool): add a dialog tool * fix: remove isValidPlantUML, improve plantuml rendering * refactor: extract shadow dom renderer * refactor: improve plantuml error messages * test: add tests for ImageToolbar and ImageToolButton * refactor: add ImagePreviewLayout to simplify layout and tests * refactor: add useDebouncedRender, update docs * chore: clean up unused props * refactor: clean transformation before copy/download/preview * refactor: update migrate version * refactor: style refactoring and fixes - show header background in split view - fix status bar radius - reset special view transformation on theme change - fix wrap tool icon - add a divider to split view - improve split view toggling (switch back to previous view) - revert copy tool to separate tools - fix top border radius for special views * refactor: move GraphvizPreview to shadow DOM - use renderString - keep renderSvgInShadowHost api consistent with others * fix: tests, icons, deleted files * refactor: use ResetIcon in ImageToolbar * test: remove unnecessary tests * fix: min height for special preview * fix: update migrate
This commit is contained in:
parent
39d96a63ac
commit
d05d1309ca
180
docs/technical/CodeBlockView-en.md
Normal file
180
docs/technical/CodeBlockView-en.md
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
# CodeBlockView Component Structure
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CodeBlockView is the core component in Cherry Studio for displaying and manipulating code blocks. It supports multiple view modes and visual previews for special languages, providing rich interactive tools.
|
||||||
|
|
||||||
|
## Component Structure
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[CodeBlockView] --> B[CodeToolbar]
|
||||||
|
A --> C[SourceView]
|
||||||
|
A --> D[SpecialView]
|
||||||
|
A --> E[StatusBar]
|
||||||
|
|
||||||
|
B --> F[CodeToolButton]
|
||||||
|
|
||||||
|
C --> G[CodeEditor / CodeViewer]
|
||||||
|
|
||||||
|
D --> H[MermaidPreview]
|
||||||
|
D --> I[PlantUmlPreview]
|
||||||
|
D --> J[SvgPreview]
|
||||||
|
D --> K[GraphvizPreview]
|
||||||
|
|
||||||
|
F --> L[useCopyTool]
|
||||||
|
F --> M[useDownloadTool]
|
||||||
|
F --> N[useViewSourceTool]
|
||||||
|
F --> O[useSplitViewTool]
|
||||||
|
F --> P[useRunTool]
|
||||||
|
F --> Q[useExpandTool]
|
||||||
|
F --> R[useWrapTool]
|
||||||
|
F --> S[useSaveTool]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### View Types
|
||||||
|
|
||||||
|
- **preview**: Preview view, where non-source code is displayed as special views
|
||||||
|
- **edit**: Edit view
|
||||||
|
|
||||||
|
### View Modes
|
||||||
|
|
||||||
|
- **source**: Source code view mode
|
||||||
|
- **special**: Special view mode (Mermaid, PlantUML, SVG)
|
||||||
|
- **split**: Split view mode (source code and special view displayed side by side)
|
||||||
|
|
||||||
|
### Special View Languages
|
||||||
|
|
||||||
|
- mermaid
|
||||||
|
- plantuml
|
||||||
|
- svg
|
||||||
|
- dot
|
||||||
|
- graphviz
|
||||||
|
|
||||||
|
## Component Details
|
||||||
|
|
||||||
|
### CodeBlockView Main Component
|
||||||
|
|
||||||
|
Main responsibilities:
|
||||||
|
|
||||||
|
1. Managing view mode state
|
||||||
|
2. Coordinating the display of source code view and special view
|
||||||
|
3. Managing toolbar tools
|
||||||
|
4. Handling code execution state
|
||||||
|
|
||||||
|
### Subcomponents
|
||||||
|
|
||||||
|
#### CodeToolbar
|
||||||
|
|
||||||
|
- Toolbar displayed at the top-right corner of the code block
|
||||||
|
- Contains core and quick tools
|
||||||
|
- Dynamically displays relevant tools based on context
|
||||||
|
|
||||||
|
#### CodeEditor/CodeViewer Source View
|
||||||
|
|
||||||
|
- Editable code editor or read-only code viewer
|
||||||
|
- Uses either component based on settings
|
||||||
|
- Supports syntax highlighting for multiple programming languages
|
||||||
|
|
||||||
|
#### Special View Components
|
||||||
|
|
||||||
|
- **MermaidPreview**: Mermaid diagram preview
|
||||||
|
- **PlantUmlPreview**: PlantUML diagram preview
|
||||||
|
- **SvgPreview**: SVG image preview
|
||||||
|
- **GraphvizPreview**: Graphviz diagram preview
|
||||||
|
|
||||||
|
All special view components share a common architecture for consistent user experience and functionality. For detailed information about these components and their implementation, see [Image Preview Components Documentation](./ImagePreview-en.md).
|
||||||
|
|
||||||
|
#### StatusBar
|
||||||
|
|
||||||
|
- Displays Python code execution results
|
||||||
|
- Can show both text and image results
|
||||||
|
|
||||||
|
## Tool System
|
||||||
|
|
||||||
|
CodeBlockView uses a hook-based tool system:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[CodeBlockView] --> B[useCopyTool]
|
||||||
|
A --> C[useDownloadTool]
|
||||||
|
A --> D[useViewSourceTool]
|
||||||
|
A --> E[useSplitViewTool]
|
||||||
|
A --> F[useRunTool]
|
||||||
|
A --> G[useExpandTool]
|
||||||
|
A --> H[useWrapTool]
|
||||||
|
A --> I[useSaveTool]
|
||||||
|
|
||||||
|
B --> J[ToolManager]
|
||||||
|
C --> J
|
||||||
|
D --> J
|
||||||
|
E --> J
|
||||||
|
F --> J
|
||||||
|
G --> J
|
||||||
|
H --> J
|
||||||
|
I --> J
|
||||||
|
|
||||||
|
J --> K[CodeToolbar]
|
||||||
|
```
|
||||||
|
|
||||||
|
Each tool hook is responsible for registering specific function tool buttons to the tool manager, which then passes these tools to the CodeToolbar component for rendering.
|
||||||
|
|
||||||
|
### Tool Types
|
||||||
|
|
||||||
|
- **core**: Core tools, always displayed in the toolbar
|
||||||
|
- **quick**: Quick tools, displayed in a dropdown menu when there are more than one
|
||||||
|
|
||||||
|
### Tool List
|
||||||
|
|
||||||
|
1. **Copy**: Copy code or image
|
||||||
|
2. **Download**: Download code or image
|
||||||
|
3. **View Source**: Switch between special view and source code view
|
||||||
|
4. **Split View**: Toggle split view mode
|
||||||
|
5. **Run**: Run Python code
|
||||||
|
6. **Expand/Collapse**: Control code block expansion/collapse
|
||||||
|
7. **Wrap**: Control automatic line wrapping
|
||||||
|
8. **Save**: Save edited code
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
CodeBlockView manages the following states through React hooks:
|
||||||
|
|
||||||
|
1. **viewMode**: Current view mode ('source' | 'special' | 'split')
|
||||||
|
2. **isRunning**: Python code execution status
|
||||||
|
3. **executionResult**: Python code execution result
|
||||||
|
4. **tools**: Toolbar tool list
|
||||||
|
5. **expandOverride/unwrapOverride**: User override settings for expand/wrap
|
||||||
|
6. **sourceScrollHeight**: Source code view scroll height
|
||||||
|
|
||||||
|
## Interaction Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant CB as CodeBlockView
|
||||||
|
participant CT as CodeToolbar
|
||||||
|
participant SV as SpecialView
|
||||||
|
participant SE as SourceEditor
|
||||||
|
|
||||||
|
U->>CB: View code block
|
||||||
|
CB->>CB: Initialize state
|
||||||
|
CB->>CT: Register tools
|
||||||
|
CB->>SV: Render special view (if applicable)
|
||||||
|
CB->>SE: Render source view
|
||||||
|
U->>CT: Click tool button
|
||||||
|
CT->>CB: Trigger tool callback
|
||||||
|
CB->>CB: Update state
|
||||||
|
CB->>CT: Re-register tools (if needed)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Special Handling
|
||||||
|
|
||||||
|
### HTML Code Blocks
|
||||||
|
|
||||||
|
HTML code blocks are specially handled using the HtmlArtifactsCard component.
|
||||||
|
|
||||||
|
### Python Code Execution
|
||||||
|
|
||||||
|
Supports executing Python code and displaying results using Pyodide to run Python code in the browser.
|
||||||
180
docs/technical/CodeBlockView-zh.md
Normal file
180
docs/technical/CodeBlockView-zh.md
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
# CodeBlockView 组件结构说明
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
CodeBlockView 是 Cherry Studio 中用于显示和操作代码块的核心组件。它支持多种视图模式和特殊语言的可视化预览,提供丰富的交互工具。
|
||||||
|
|
||||||
|
## 组件结构
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[CodeBlockView] --> B[CodeToolbar]
|
||||||
|
A --> C[SourceView]
|
||||||
|
A --> D[SpecialView]
|
||||||
|
A --> E[StatusBar]
|
||||||
|
|
||||||
|
B --> F[CodeToolButton]
|
||||||
|
|
||||||
|
C --> G[CodeEditor / CodeViewer]
|
||||||
|
|
||||||
|
D --> H[MermaidPreview]
|
||||||
|
D --> I[PlantUmlPreview]
|
||||||
|
D --> J[SvgPreview]
|
||||||
|
D --> K[GraphvizPreview]
|
||||||
|
|
||||||
|
F --> L[useCopyTool]
|
||||||
|
F --> M[useDownloadTool]
|
||||||
|
F --> N[useViewSourceTool]
|
||||||
|
F --> O[useSplitViewTool]
|
||||||
|
F --> P[useRunTool]
|
||||||
|
F --> Q[useExpandTool]
|
||||||
|
F --> R[useWrapTool]
|
||||||
|
F --> S[useSaveTool]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
### 视图类型
|
||||||
|
|
||||||
|
- **preview**: 预览视图,非源代码的是特殊视图
|
||||||
|
- **edit**: 编辑视图
|
||||||
|
|
||||||
|
### 视图模式
|
||||||
|
|
||||||
|
- **source**: 源代码视图模式
|
||||||
|
- **special**: 特殊视图模式(Mermaid、PlantUML、SVG)
|
||||||
|
- **split**: 分屏模式(源代码和特殊视图并排显示)
|
||||||
|
|
||||||
|
### 特殊视图语言
|
||||||
|
|
||||||
|
- mermaid
|
||||||
|
- plantuml
|
||||||
|
- svg
|
||||||
|
- dot
|
||||||
|
- graphviz
|
||||||
|
|
||||||
|
## 组件详细说明
|
||||||
|
|
||||||
|
### CodeBlockView 主组件
|
||||||
|
|
||||||
|
主要负责:
|
||||||
|
|
||||||
|
1. 管理视图模式状态
|
||||||
|
2. 协调源代码视图和特殊视图的显示
|
||||||
|
3. 管理工具栏工具
|
||||||
|
4. 处理代码执行状态
|
||||||
|
|
||||||
|
### 子组件
|
||||||
|
|
||||||
|
#### CodeToolbar 工具栏
|
||||||
|
|
||||||
|
- 显示在代码块右上角的工具栏
|
||||||
|
- 包含核心(core)和快捷(quick)两类工具
|
||||||
|
- 根据上下文动态显示相关工具
|
||||||
|
|
||||||
|
#### CodeEditor/CodeViewer 源代码视图
|
||||||
|
|
||||||
|
- 可编辑的代码编辑器或只读的代码查看器
|
||||||
|
- 根据设置决定使用哪个组件
|
||||||
|
- 支持多种编程语言高亮
|
||||||
|
|
||||||
|
#### 特殊视图组件
|
||||||
|
|
||||||
|
- **MermaidPreview**: Mermaid 图表预览
|
||||||
|
- **PlantUmlPreview**: PlantUML 图表预览
|
||||||
|
- **SvgPreview**: SVG 图像预览
|
||||||
|
- **GraphvizPreview**: Graphviz 图表预览
|
||||||
|
|
||||||
|
所有特殊视图组件共享通用架构,以确保一致的用户体验和功能。有关这些组件及其实现的详细信息,请参阅 [图像预览组件文档](./ImagePreview-zh.md)。
|
||||||
|
|
||||||
|
#### StatusBar 状态栏
|
||||||
|
|
||||||
|
- 显示 Python 代码执行结果
|
||||||
|
- 可显示文本和图像结果
|
||||||
|
|
||||||
|
## 工具系统
|
||||||
|
|
||||||
|
CodeBlockView 使用基于 hooks 的工具系统:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[CodeBlockView] --> B[useCopyTool]
|
||||||
|
A --> C[useDownloadTool]
|
||||||
|
A --> D[useViewSourceTool]
|
||||||
|
A --> E[useSplitViewTool]
|
||||||
|
A --> F[useRunTool]
|
||||||
|
A --> G[useExpandTool]
|
||||||
|
A --> H[useWrapTool]
|
||||||
|
A --> I[useSaveTool]
|
||||||
|
|
||||||
|
B --> J[ToolManager]
|
||||||
|
C --> J
|
||||||
|
D --> J
|
||||||
|
E --> J
|
||||||
|
F --> J
|
||||||
|
G --> J
|
||||||
|
H --> J
|
||||||
|
I --> J
|
||||||
|
|
||||||
|
J --> K[CodeToolbar]
|
||||||
|
```
|
||||||
|
|
||||||
|
每个工具 hook 负责注册特定功能的工具按钮到工具管理器,工具管理器再将这些工具传递给 CodeToolbar 组件进行渲染。
|
||||||
|
|
||||||
|
### 工具类型
|
||||||
|
|
||||||
|
- **core**: 核心工具,始终显示在工具栏
|
||||||
|
- **quick**: 快捷工具,当数量大于1时通过下拉菜单显示
|
||||||
|
|
||||||
|
### 工具列表
|
||||||
|
|
||||||
|
1. **复制(copy)**: 复制代码或图像
|
||||||
|
2. **下载(download)**: 下载代码或图像
|
||||||
|
3. **查看源码(view-source)**: 在特殊视图和源码视图间切换
|
||||||
|
4. **分屏(split-view)**: 切换分屏模式
|
||||||
|
5. **运行(run)**: 运行 Python 代码
|
||||||
|
6. **展开/折叠(expand)**: 控制代码块的展开/折叠
|
||||||
|
7. **换行(wrap)**: 控制代码的自动换行
|
||||||
|
8. **保存(save)**: 保存编辑的代码
|
||||||
|
|
||||||
|
## 状态管理
|
||||||
|
|
||||||
|
CodeBlockView 通过 React hooks 管理以下状态:
|
||||||
|
|
||||||
|
1. **viewMode**: 当前视图模式 ('source' | 'special' | 'split')
|
||||||
|
2. **isRunning**: Python 代码执行状态
|
||||||
|
3. **executionResult**: Python 代码执行结果
|
||||||
|
4. **tools**: 工具栏工具列表
|
||||||
|
5. **expandOverride/unwrapOverride**: 用户展开/换行的覆盖设置
|
||||||
|
6. **sourceScrollHeight**: 源代码视图滚动高度
|
||||||
|
|
||||||
|
## 交互流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant CB as CodeBlockView
|
||||||
|
participant CT as CodeToolbar
|
||||||
|
participant SV as SpecialView
|
||||||
|
participant SE as SourceEditor
|
||||||
|
|
||||||
|
U->>CB: 查看代码块
|
||||||
|
CB->>CB: 初始化状态
|
||||||
|
CB->>CT: 注册工具
|
||||||
|
CB->>SV: 渲染特殊视图(如果适用)
|
||||||
|
CB->>SE: 渲染源码视图
|
||||||
|
U->>CT: 点击工具按钮
|
||||||
|
CT->>CB: 触发工具回调
|
||||||
|
CB->>CB: 更新状态
|
||||||
|
CB->>CT: 重新注册工具(如果需要)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 特殊处理
|
||||||
|
|
||||||
|
### HTML 代码块
|
||||||
|
|
||||||
|
HTML 代码块会被特殊处理,使用 HtmlArtifactsCard 组件显示。
|
||||||
|
|
||||||
|
### Python 代码执行
|
||||||
|
|
||||||
|
支持执行 Python 代码并显示结果,使用 Pyodide 在浏览器中运行 Python 代码。
|
||||||
195
docs/technical/ImagePreview-en.md
Normal file
195
docs/technical/ImagePreview-en.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
# Image Preview Components
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Image Preview Components are a set of specialized components in Cherry Studio for rendering and displaying various diagram and image formats. They provide a consistent user experience across different preview types with shared functionality for loading states, error handling, and interactive controls.
|
||||||
|
|
||||||
|
## Supported Formats
|
||||||
|
|
||||||
|
- **Mermaid**: Interactive diagrams and flowcharts
|
||||||
|
- **PlantUML**: UML diagrams and system architecture
|
||||||
|
- **SVG**: Scalable vector graphics
|
||||||
|
- **Graphviz/DOT**: Graph visualization and network diagrams
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[MermaidPreview] --> D[ImagePreviewLayout]
|
||||||
|
B[PlantUmlPreview] --> D
|
||||||
|
C[SvgPreview] --> D
|
||||||
|
E[GraphvizPreview] --> D
|
||||||
|
|
||||||
|
D --> F[ImageToolbar]
|
||||||
|
D --> G[useDebouncedRender]
|
||||||
|
|
||||||
|
F --> H[Pan Controls]
|
||||||
|
F --> I[Zoom Controls]
|
||||||
|
F --> J[Reset Function]
|
||||||
|
F --> K[Dialog Control]
|
||||||
|
|
||||||
|
G --> L[Debounced Rendering]
|
||||||
|
G --> M[Error Handling]
|
||||||
|
G --> N[Loading State]
|
||||||
|
G --> O[Dependency Management]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Components
|
||||||
|
|
||||||
|
### ImagePreviewLayout
|
||||||
|
|
||||||
|
A common layout wrapper that provides the foundation for all image preview components.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- **Loading State Management**: Shows loading spinner during rendering
|
||||||
|
- **Error Display**: Displays error messages when rendering fails
|
||||||
|
- **Toolbar Integration**: Conditionally renders ImageToolbar when enabled
|
||||||
|
- **Container Management**: Wraps preview content with consistent styling
|
||||||
|
- **Responsive Design**: Adapts to different container sizes
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
|
||||||
|
- `children`: The preview content to be displayed
|
||||||
|
- `loading`: Boolean indicating if content is being rendered
|
||||||
|
- `error`: Error message to display if rendering fails
|
||||||
|
- `enableToolbar`: Whether to show the interactive toolbar
|
||||||
|
- `imageRef`: Reference to the container element for image manipulation
|
||||||
|
|
||||||
|
### ImageToolbar
|
||||||
|
|
||||||
|
Interactive toolbar component providing image manipulation controls.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- **Pan Controls**: 4-directional pan buttons (up, down, left, right)
|
||||||
|
- **Zoom Controls**: Zoom in/out functionality with configurable increments
|
||||||
|
- **Reset Function**: Restore original pan and zoom state
|
||||||
|
- **Dialog Control**: Open preview in expanded dialog view
|
||||||
|
- **Accessible Design**: Full keyboard navigation and screen reader support
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
|
||||||
|
- 3x3 grid layout positioned at bottom-right of preview
|
||||||
|
- Responsive button sizing
|
||||||
|
- Tooltip support for all controls
|
||||||
|
|
||||||
|
### useDebouncedRender Hook
|
||||||
|
|
||||||
|
A specialized React hook for managing preview rendering with performance optimizations.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- **Debounced Rendering**: Prevents excessive re-renders during rapid content changes (default 300ms delay)
|
||||||
|
- **Automatic Dependency Management**: Handles dependencies for render and condition functions
|
||||||
|
- **Error Handling**: Catches and manages rendering errors with detailed error messages
|
||||||
|
- **Loading State**: Tracks rendering progress with automatic state updates
|
||||||
|
- **Conditional Rendering**: Supports pre-render condition checks
|
||||||
|
- **Manual Controls**: Provides trigger, cancel, and state management functions
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { containerRef, error, isLoading, triggerRender, cancelRender, clearError, setLoading } = useDebouncedRender(
|
||||||
|
value,
|
||||||
|
renderFunction,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
- `debounceDelay`: Customize debounce timing
|
||||||
|
- `shouldRender`: Function for conditional rendering logic
|
||||||
|
|
||||||
|
## Component Implementations
|
||||||
|
|
||||||
|
### MermaidPreview
|
||||||
|
|
||||||
|
Renders Mermaid diagrams with special handling for visibility detection.
|
||||||
|
|
||||||
|
**Special Features:**
|
||||||
|
|
||||||
|
- Syntax validation before rendering
|
||||||
|
- Visibility detection to handle collapsed containers
|
||||||
|
- SVG coordinate fixing for edge cases
|
||||||
|
- Integration with mermaid.js library
|
||||||
|
|
||||||
|
### PlantUmlPreview
|
||||||
|
|
||||||
|
Renders PlantUML diagrams using the online PlantUML server.
|
||||||
|
|
||||||
|
**Special Features:**
|
||||||
|
|
||||||
|
- Network error handling and retry logic
|
||||||
|
- Diagram encoding using deflate compression
|
||||||
|
- Support for light/dark themes
|
||||||
|
- Server status monitoring
|
||||||
|
|
||||||
|
### SvgPreview
|
||||||
|
|
||||||
|
Renders SVG content using Shadow DOM for isolation.
|
||||||
|
|
||||||
|
**Special Features:**
|
||||||
|
|
||||||
|
- Shadow DOM rendering for style isolation
|
||||||
|
- Direct SVG content injection
|
||||||
|
- Minimal processing overhead
|
||||||
|
- Cross-browser compatibility
|
||||||
|
|
||||||
|
### GraphvizPreview
|
||||||
|
|
||||||
|
Renders Graphviz/DOT diagrams using the viz.js library.
|
||||||
|
|
||||||
|
**Special Features:**
|
||||||
|
|
||||||
|
- Client-side rendering with viz.js
|
||||||
|
- Lazy loading of viz.js library
|
||||||
|
- SVG element generation
|
||||||
|
- Memory-efficient processing
|
||||||
|
|
||||||
|
## Shared Functionality
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
All preview components provide consistent error handling:
|
||||||
|
|
||||||
|
- Network errors (connection failures)
|
||||||
|
- Syntax errors (invalid diagram code)
|
||||||
|
- Server errors (external service failures)
|
||||||
|
- Rendering errors (library failures)
|
||||||
|
|
||||||
|
### Loading States
|
||||||
|
|
||||||
|
Standardized loading indicators across all components:
|
||||||
|
|
||||||
|
- Spinner animation during processing
|
||||||
|
- Progress feedback for long operations
|
||||||
|
- Smooth transitions between states
|
||||||
|
|
||||||
|
### Interactive Controls
|
||||||
|
|
||||||
|
Common interaction patterns:
|
||||||
|
|
||||||
|
- Pan and zoom functionality
|
||||||
|
- Reset to original view
|
||||||
|
- Full-screen dialog mode
|
||||||
|
- Keyboard accessibility
|
||||||
|
|
||||||
|
### Performance Optimizations
|
||||||
|
|
||||||
|
- Debounced rendering to prevent excessive updates
|
||||||
|
- Lazy loading of heavy libraries
|
||||||
|
- Memory management for large diagrams
|
||||||
|
- Efficient re-rendering strategies
|
||||||
|
|
||||||
|
## Integration with CodeBlockView
|
||||||
|
|
||||||
|
Image Preview Components integrate seamlessly with CodeBlockView:
|
||||||
|
|
||||||
|
- Automatic format detection based on language tags
|
||||||
|
- Consistent toolbar integration
|
||||||
|
- Shared state management
|
||||||
|
- Responsive layout adaptation
|
||||||
|
|
||||||
|
For more information about the overall CodeBlockView architecture, see [CodeBlockView Documentation](./CodeBlockView-en.md).
|
||||||
195
docs/technical/ImagePreview-zh.md
Normal file
195
docs/technical/ImagePreview-zh.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
# 图像预览组件
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
图像预览组件是 Cherry Studio 中用于渲染和显示各种图表和图像格式的专用组件集合。它们为不同预览类型提供一致的用户体验,具有共享的加载状态、错误处理和交互控制功能。
|
||||||
|
|
||||||
|
## 支持格式
|
||||||
|
|
||||||
|
- **Mermaid**: 交互式图表和流程图
|
||||||
|
- **PlantUML**: UML 图表和系统架构
|
||||||
|
- **SVG**: 可缩放矢量图形
|
||||||
|
- **Graphviz/DOT**: 图形可视化和网络图表
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[MermaidPreview] --> D[ImagePreviewLayout]
|
||||||
|
B[PlantUmlPreview] --> D
|
||||||
|
C[SvgPreview] --> D
|
||||||
|
E[GraphvizPreview] --> D
|
||||||
|
|
||||||
|
D --> F[ImageToolbar]
|
||||||
|
D --> G[useDebouncedRender]
|
||||||
|
|
||||||
|
F --> H[平移控制]
|
||||||
|
F --> I[缩放控制]
|
||||||
|
F --> J[重置功能]
|
||||||
|
F --> K[对话框控制]
|
||||||
|
|
||||||
|
G --> L[防抖渲染]
|
||||||
|
G --> M[错误处理]
|
||||||
|
G --> N[加载状态]
|
||||||
|
G --> O[依赖管理]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
|
||||||
|
### ImagePreviewLayout 图像预览布局
|
||||||
|
|
||||||
|
为所有图像预览组件提供基础的通用布局包装器。
|
||||||
|
|
||||||
|
**功能特性:**
|
||||||
|
|
||||||
|
- **加载状态管理**: 在渲染期间显示加载动画
|
||||||
|
- **错误显示**: 渲染失败时显示错误信息
|
||||||
|
- **工具栏集成**: 启用时有条件地渲染 ImageToolbar
|
||||||
|
- **容器管理**: 使用一致的样式包装预览内容
|
||||||
|
- **响应式设计**: 适应不同的容器尺寸
|
||||||
|
|
||||||
|
**属性:**
|
||||||
|
|
||||||
|
- `children`: 要显示的预览内容
|
||||||
|
- `loading`: 指示内容是否正在渲染的布尔值
|
||||||
|
- `error`: 渲染失败时显示的错误信息
|
||||||
|
- `enableToolbar`: 是否显示交互式工具栏
|
||||||
|
- `imageRef`: 用于图像操作的容器元素引用
|
||||||
|
|
||||||
|
### ImageToolbar 图像工具栏
|
||||||
|
|
||||||
|
提供图像操作控制的交互式工具栏组件。
|
||||||
|
|
||||||
|
**功能特性:**
|
||||||
|
|
||||||
|
- **平移控制**: 4方向平移按钮(上、下、左、右)
|
||||||
|
- **缩放控制**: 放大/缩小功能,支持可配置的增量
|
||||||
|
- **重置功能**: 恢复原始平移和缩放状态
|
||||||
|
- **对话框控制**: 在展开对话框中打开预览
|
||||||
|
- **无障碍设计**: 完整的键盘导航和屏幕阅读器支持
|
||||||
|
|
||||||
|
**布局:**
|
||||||
|
|
||||||
|
- 3x3 网格布局,位于预览右下角
|
||||||
|
- 响应式按钮尺寸
|
||||||
|
- 所有控件的工具提示支持
|
||||||
|
|
||||||
|
### useDebouncedRender Hook 防抖渲染钩子
|
||||||
|
|
||||||
|
用于管理预览渲染的专用 React Hook,具有性能优化功能。
|
||||||
|
|
||||||
|
**功能特性:**
|
||||||
|
|
||||||
|
- **防抖渲染**: 防止内容快速变化时的过度重新渲染(默认 300ms 延迟)
|
||||||
|
- **自动依赖管理**: 处理渲染和条件函数的依赖项
|
||||||
|
- **错误处理**: 捕获和管理渲染错误,提供详细的错误信息
|
||||||
|
- **加载状态**: 跟踪渲染进度并自动更新状态
|
||||||
|
- **条件渲染**: 支持预渲染条件检查
|
||||||
|
- **手动控制**: 提供触发、取消和状态管理功能
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { containerRef, error, isLoading, triggerRender, cancelRender, clearError, setLoading } = useDebouncedRender(
|
||||||
|
value,
|
||||||
|
renderFunction,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**选项:**
|
||||||
|
|
||||||
|
- `debounceDelay`: 自定义防抖时间
|
||||||
|
- `shouldRender`: 条件渲染逻辑函数
|
||||||
|
|
||||||
|
## 组件实现
|
||||||
|
|
||||||
|
### MermaidPreview Mermaid 预览
|
||||||
|
|
||||||
|
渲染 Mermaid 图表,具有可见性检测的特殊处理。
|
||||||
|
|
||||||
|
**特殊功能:**
|
||||||
|
|
||||||
|
- 渲染前语法验证
|
||||||
|
- 可见性检测以处理折叠的容器
|
||||||
|
- 边缘情况的 SVG 坐标修复
|
||||||
|
- 与 mermaid.js 库集成
|
||||||
|
|
||||||
|
### PlantUmlPreview PlantUML 预览
|
||||||
|
|
||||||
|
使用在线 PlantUML 服务器渲染 PlantUML 图表。
|
||||||
|
|
||||||
|
**特殊功能:**
|
||||||
|
|
||||||
|
- 网络错误处理和重试逻辑
|
||||||
|
- 使用 deflate 压缩的图表编码
|
||||||
|
- 支持明/暗主题
|
||||||
|
- 服务器状态监控
|
||||||
|
|
||||||
|
### SvgPreview SVG 预览
|
||||||
|
|
||||||
|
使用 Shadow DOM 隔离渲染 SVG 内容。
|
||||||
|
|
||||||
|
**特殊功能:**
|
||||||
|
|
||||||
|
- Shadow DOM 渲染实现样式隔离
|
||||||
|
- 直接 SVG 内容注入
|
||||||
|
- 最小化处理开销
|
||||||
|
- 跨浏览器兼容性
|
||||||
|
|
||||||
|
### GraphvizPreview Graphviz 预览
|
||||||
|
|
||||||
|
使用 viz.js 库渲染 Graphviz/DOT 图表。
|
||||||
|
|
||||||
|
**特殊功能:**
|
||||||
|
|
||||||
|
- 使用 viz.js 进行客户端渲染
|
||||||
|
- viz.js 库的懒加载
|
||||||
|
- SVG 元素生成
|
||||||
|
- 内存高效处理
|
||||||
|
|
||||||
|
## 共享功能
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
|
||||||
|
所有预览组件提供一致的错误处理:
|
||||||
|
|
||||||
|
- 网络错误(连接失败)
|
||||||
|
- 语法错误(无效的图表代码)
|
||||||
|
- 服务器错误(外部服务失败)
|
||||||
|
- 渲染错误(库失败)
|
||||||
|
|
||||||
|
### 加载状态
|
||||||
|
|
||||||
|
所有组件的标准化加载指示器:
|
||||||
|
|
||||||
|
- 处理期间的动画
|
||||||
|
- 长时间操作的进度反馈
|
||||||
|
- 状态间的平滑过渡
|
||||||
|
|
||||||
|
### 交互控制
|
||||||
|
|
||||||
|
通用交互模式:
|
||||||
|
|
||||||
|
- 平移和缩放功能
|
||||||
|
- 重置到原始视图
|
||||||
|
- 全屏对话框模式
|
||||||
|
- 键盘无障碍访问
|
||||||
|
|
||||||
|
### 性能优化
|
||||||
|
|
||||||
|
- 防抖渲染以防止过度更新
|
||||||
|
- 重型库的懒加载
|
||||||
|
- 大型图表的内存管理
|
||||||
|
- 高效的重新渲染策略
|
||||||
|
|
||||||
|
## 与 CodeBlockView 的集成
|
||||||
|
|
||||||
|
图像预览组件与 CodeBlockView 无缝集成:
|
||||||
|
|
||||||
|
- 基于语言标签的自动格式检测
|
||||||
|
- 一致的工具栏集成
|
||||||
|
- 共享状态管理
|
||||||
|
- 响应式布局适应
|
||||||
|
|
||||||
|
有关整体 CodeBlockView 架构的更多信息,请参阅 [CodeBlockView 文档](./CodeBlockView-zh.md)。
|
||||||
@ -0,0 +1,555 @@
|
|||||||
|
import { useImageTools } from '@renderer/components/ActionTools'
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
i18n: {
|
||||||
|
t: (key: string) => key
|
||||||
|
},
|
||||||
|
svgToPngBlob: vi.fn(),
|
||||||
|
svgToSvgBlob: vi.fn(),
|
||||||
|
download: vi.fn(),
|
||||||
|
ImagePreviewService: {
|
||||||
|
show: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/utils/image', () => ({
|
||||||
|
svgToPngBlob: mocks.svgToPngBlob,
|
||||||
|
svgToSvgBlob: mocks.svgToSvgBlob
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/utils/download', () => ({
|
||||||
|
download: mocks.download
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: mocks.i18n.t
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/services/ImagePreviewService', () => ({
|
||||||
|
ImagePreviewService: mocks.ImagePreviewService
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/context/ThemeProvider', () => ({
|
||||||
|
useTheme: () => ({
|
||||||
|
theme: 'light'
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock navigator.clipboard
|
||||||
|
const mockWrite = vi.fn()
|
||||||
|
|
||||||
|
// Mock window.message
|
||||||
|
const mockMessage = {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock ClipboardItem
|
||||||
|
class MockClipboardItem {
|
||||||
|
constructor(items: any) {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock URL
|
||||||
|
const mockCreateObjectURL = vi.fn(() => 'blob:test-url')
|
||||||
|
const mockRevokeObjectURL = vi.fn()
|
||||||
|
|
||||||
|
describe('useImageTools', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup global mocks
|
||||||
|
Object.defineProperty(global.navigator, 'clipboard', {
|
||||||
|
value: { write: mockWrite },
|
||||||
|
writable: true
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.defineProperty(global.window, 'message', {
|
||||||
|
value: mockMessage,
|
||||||
|
writable: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock ClipboardItem
|
||||||
|
global.ClipboardItem = MockClipboardItem as any
|
||||||
|
|
||||||
|
// Mock URL
|
||||||
|
global.URL = {
|
||||||
|
createObjectURL: mockCreateObjectURL,
|
||||||
|
revokeObjectURL: mockRevokeObjectURL
|
||||||
|
} as any
|
||||||
|
|
||||||
|
// Mock DOMMatrix
|
||||||
|
global.DOMMatrix = class DOMMatrix {
|
||||||
|
m41 = 0
|
||||||
|
m42 = 0
|
||||||
|
a = 1
|
||||||
|
d = 1
|
||||||
|
|
||||||
|
constructor(transform?: string) {
|
||||||
|
if (transform) {
|
||||||
|
// 简单解析 translate(x, y)
|
||||||
|
const translateMatch = transform.match(/translate\(([^,]+),\s*([^)]+)\)/)
|
||||||
|
if (translateMatch) {
|
||||||
|
this.m41 = parseFloat(translateMatch[1])
|
||||||
|
this.m42 = parseFloat(translateMatch[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 scale(s)
|
||||||
|
const scaleMatch = transform.match(/scale\(([^)]+)\)/)
|
||||||
|
if (scaleMatch) {
|
||||||
|
const scaleValue = parseFloat(scaleMatch[1])
|
||||||
|
this.a = scaleValue
|
||||||
|
this.d = scaleValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromMatrix() {
|
||||||
|
return new DOMMatrix()
|
||||||
|
}
|
||||||
|
} as any
|
||||||
|
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建模拟的 DOM 环境
|
||||||
|
const createMockContainer = () => {
|
||||||
|
const mockContainer = {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
contains: vi.fn().mockReturnValue(true),
|
||||||
|
style: {
|
||||||
|
cursor: ''
|
||||||
|
},
|
||||||
|
querySelector: vi.fn(),
|
||||||
|
shadowRoot: null
|
||||||
|
} as unknown as HTMLDivElement
|
||||||
|
|
||||||
|
return mockContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMockSvgElement = () => {
|
||||||
|
const mockSvg = {
|
||||||
|
style: {
|
||||||
|
transform: '',
|
||||||
|
transformOrigin: ''
|
||||||
|
},
|
||||||
|
cloneNode: vi.fn().mockReturnThis()
|
||||||
|
} as unknown as SVGElement
|
||||||
|
|
||||||
|
return mockSvg
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('should initialize with default scale', () => {
|
||||||
|
const mockContainer = createMockContainer()
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const transform = result.current.getCurrentTransform()
|
||||||
|
expect(transform.scale).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pan function', () => {
|
||||||
|
it('should pan with relative and absolute coordinates', () => {
|
||||||
|
const mockContainer = createMockContainer()
|
||||||
|
const mockSvg = createMockSvgElement()
|
||||||
|
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 相对坐标平移
|
||||||
|
act(() => {
|
||||||
|
result.current.pan(10, 20)
|
||||||
|
})
|
||||||
|
expect(mockSvg.style.transform).toContain('translate(10px, 20px)')
|
||||||
|
|
||||||
|
// 绝对坐标平移
|
||||||
|
act(() => {
|
||||||
|
result.current.pan(50, 60, true)
|
||||||
|
})
|
||||||
|
expect(mockSvg.style.transform).toContain('translate(50px, 60px)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('zoom function', () => {
|
||||||
|
it('should zoom in/out and set absolute zoom level', () => {
|
||||||
|
const mockContainer = createMockContainer()
|
||||||
|
const mockSvg = createMockSvgElement()
|
||||||
|
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 放大
|
||||||
|
act(() => {
|
||||||
|
result.current.zoom(0.5)
|
||||||
|
})
|
||||||
|
expect(result.current.getCurrentTransform().scale).toBe(1.5)
|
||||||
|
expect(mockSvg.style.transform).toContain('scale(1.5)')
|
||||||
|
|
||||||
|
// 缩小
|
||||||
|
act(() => {
|
||||||
|
result.current.zoom(-0.3)
|
||||||
|
})
|
||||||
|
expect(result.current.getCurrentTransform().scale).toBe(1.2)
|
||||||
|
expect(mockSvg.style.transform).toContain('scale(1.2)')
|
||||||
|
|
||||||
|
// 设置绝对缩放级别
|
||||||
|
act(() => {
|
||||||
|
result.current.zoom(2.5, true)
|
||||||
|
})
|
||||||
|
expect(result.current.getCurrentTransform().scale).toBe(2.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should constrain zoom between 0.1 and 3', () => {
|
||||||
|
const mockContainer = createMockContainer()
|
||||||
|
const mockSvg = createMockSvgElement()
|
||||||
|
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 尝试过度缩小
|
||||||
|
act(() => {
|
||||||
|
result.current.zoom(-10)
|
||||||
|
})
|
||||||
|
expect(result.current.getCurrentTransform().scale).toBe(0.1)
|
||||||
|
|
||||||
|
// 尝试过度放大
|
||||||
|
act(() => {
|
||||||
|
result.current.zoom(10)
|
||||||
|
})
|
||||||
|
expect(result.current.getCurrentTransform().scale).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('copy and download functions', () => {
|
||||||
|
it('should copy image to clipboard successfully', async () => {
|
||||||
|
const mockContainer = createMockContainer()
|
||||||
|
const mockSvg = createMockSvgElement()
|
||||||
|
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||||
|
|
||||||
|
// Mock svgToPngBlob to return a blob
|
||||||
|
const mockBlob = new Blob(['test'], { type: 'image/png' })
|
||||||
|
mocks.svgToPngBlob.mockResolvedValue(mockBlob)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.copy()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mocks.svgToPngBlob).toHaveBeenCalledWith(mockSvg)
|
||||||
|
expect(mockWrite).toHaveBeenCalled()
|
||||||
|
expect(mockMessage.success).toHaveBeenCalledWith('message.copy.success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should download image as PNG and SVG', async () => {
|
||||||
|
const mockContainer = createMockContainer()
|
||||||
|
const mockSvg = createMockSvgElement()
|
||||||
|
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||||
|
|
||||||
|
// Mock svgToPngBlob to return a blob
|
||||||
|
const pngBlob = new Blob(['test'], { type: 'image/png' })
|
||||||
|
mocks.svgToPngBlob.mockResolvedValue(pngBlob)
|
||||||
|
|
||||||
|
// Mock svgToSvgBlob to return a blob
|
||||||
|
const svgBlob = new Blob(['<svg></svg>'], { type: 'image/svg+xml' })
|
||||||
|
mocks.svgToSvgBlob.mockReturnValue(svgBlob)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 下载 PNG
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.download('png')
|
||||||
|
})
|
||||||
|
expect(mocks.svgToPngBlob).toHaveBeenCalledWith(mockSvg)
|
||||||
|
|
||||||
|
// 下载 SVG
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.download('svg')
|
||||||
|
})
|
||||||
|
expect(mocks.svgToSvgBlob).toHaveBeenCalledWith(mockSvg)
|
||||||
|
|
||||||
|
// 验证通用的下载流程
|
||||||
|
expect(mockCreateObjectURL).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mocks.download).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockRevokeObjectURL).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle copy/download failures and missing elements', async () => {
|
||||||
|
const mockContainer = createMockContainer()
|
||||||
|
const mockSvg = createMockSvgElement()
|
||||||
|
|
||||||
|
// 测试无元素情况
|
||||||
|
mockContainer.querySelector = vi.fn().mockReturnValue(null)
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 复制无元素
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.copy()
|
||||||
|
})
|
||||||
|
expect(mocks.svgToPngBlob).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// 下载无元素
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.download('png')
|
||||||
|
})
|
||||||
|
expect(mocks.svgToPngBlob).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// 测试失败情况
|
||||||
|
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||||
|
mocks.svgToPngBlob.mockRejectedValue(new Error('Conversion failed'))
|
||||||
|
|
||||||
|
// 复制失败
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.copy()
|
||||||
|
})
|
||||||
|
expect(mockMessage.error).toHaveBeenCalledWith('message.copy.failed')
|
||||||
|
|
||||||
|
// 下载失败
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.download('png')
|
||||||
|
})
|
||||||
|
expect(mockMessage.error).toHaveBeenCalledWith('message.download.failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dialog function', () => {
|
||||||
|
it('should preview image successfully', async () => {
|
||||||
|
const mockContainer = createMockContainer()
|
||||||
|
const mockSvg = createMockSvgElement()
|
||||||
|
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||||
|
|
||||||
|
mocks.ImagePreviewService.show.mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.dialog()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mocks.ImagePreviewService.show).toHaveBeenCalledWith(mockSvg, { format: 'svg' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle preview failure', async () => {
|
||||||
|
const mockContainer = createMockContainer()
|
||||||
|
const mockSvg = createMockSvgElement()
|
||||||
|
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||||
|
|
||||||
|
mocks.ImagePreviewService.show.mockRejectedValue(new Error('Preview failed'))
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.dialog()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockMessage.error).toHaveBeenCalledWith('message.dialog.failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should do nothing when no element is found', async () => {
|
||||||
|
const mockContainer = createMockContainer()
|
||||||
|
mockContainer.querySelector = vi.fn().mockReturnValue(null)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.dialog()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mocks.ImagePreviewService.show).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('event listener management', () => {
|
||||||
|
it('should attach/remove event listeners based on options', () => {
|
||||||
|
const mockContainer = createMockContainer()
|
||||||
|
|
||||||
|
// 启用拖拽和滚轮缩放
|
||||||
|
renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg',
|
||||||
|
enableDrag: true,
|
||||||
|
enableWheelZoom: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockContainer.addEventListener).toHaveBeenCalledWith('mousedown', expect.any(Function))
|
||||||
|
expect(mockContainer.addEventListener).toHaveBeenCalledWith('wheel', expect.any(Function), { passive: true })
|
||||||
|
|
||||||
|
// 重置并测试禁用情况
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg',
|
||||||
|
enableDrag: false,
|
||||||
|
enableWheelZoom: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockContainer.addEventListener).not.toHaveBeenCalledWith('mousedown', expect.any(Function))
|
||||||
|
expect(mockContainer.addEventListener).not.toHaveBeenCalledWith('wheel', expect.any(Function))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getCurrentTransform function', () => {
|
||||||
|
it('should return current scale and position', () => {
|
||||||
|
const mockContainer = createMockContainer()
|
||||||
|
const mockSvg = createMockSvgElement()
|
||||||
|
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 初始状态
|
||||||
|
const initialTransform = result.current.getCurrentTransform()
|
||||||
|
expect(initialTransform).toEqual({ scale: 1, x: 0, y: 0 })
|
||||||
|
|
||||||
|
// 缩放后状态
|
||||||
|
act(() => {
|
||||||
|
result.current.zoom(0.5)
|
||||||
|
})
|
||||||
|
const zoomedTransform = result.current.getCurrentTransform()
|
||||||
|
expect(zoomedTransform.scale).toBe(1.5)
|
||||||
|
expect(zoomedTransform.x).toBe(0)
|
||||||
|
expect(zoomedTransform.y).toBe(0)
|
||||||
|
|
||||||
|
// 平移后状态
|
||||||
|
act(() => {
|
||||||
|
result.current.pan(10, 20)
|
||||||
|
})
|
||||||
|
const pannedTransform = result.current.getCurrentTransform()
|
||||||
|
expect(pannedTransform.scale).toBe(1.5)
|
||||||
|
expect(pannedTransform.x).toBe(10)
|
||||||
|
expect(pannedTransform.y).toBe(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get position from DOMMatrix when element has transform', () => {
|
||||||
|
const mockContainer = createMockContainer()
|
||||||
|
const mockSvg = createMockSvgElement()
|
||||||
|
mockSvg.style.transform = 'translate(30px, 40px) scale(2)'
|
||||||
|
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useImageTools(
|
||||||
|
{ current: mockContainer },
|
||||||
|
{
|
||||||
|
prefix: 'test',
|
||||||
|
imgSelector: 'svg'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 手动设置 transformRef 以匹配 DOM 状态
|
||||||
|
act(() => {
|
||||||
|
result.current.pan(30, 40, true)
|
||||||
|
result.current.zoom(2, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const transform = result.current.getCurrentTransform()
|
||||||
|
expect(transform.scale).toBe(2)
|
||||||
|
expect(transform.x).toBe(30)
|
||||||
|
expect(transform.y).toBe(40)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,215 @@
|
|||||||
|
import { ActionTool, useToolManager } from '@renderer/components/ActionTools'
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
// 创建测试工具数据
|
||||||
|
const createTestTool = (overrides: Partial<ActionTool> = {}): ActionTool => ({
|
||||||
|
id: 'test-tool',
|
||||||
|
type: 'core',
|
||||||
|
order: 10,
|
||||||
|
icon: 'TestIcon',
|
||||||
|
tooltip: 'Test Tool',
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useToolManager', () => {
|
||||||
|
describe('registerTool', () => {
|
||||||
|
it('should register a new tool', () => {
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
const [tools, setTools] = useState<ActionTool[]>([])
|
||||||
|
const { registerTool } = useToolManager(setTools)
|
||||||
|
return { tools, registerTool }
|
||||||
|
})
|
||||||
|
|
||||||
|
const testTool = createTestTool()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.registerTool(testTool)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tools).toHaveLength(1)
|
||||||
|
expect(result.current.tools[0]).toEqual(testTool)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should replace existing tool with same id', () => {
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
const [tools, setTools] = useState<ActionTool[]>([])
|
||||||
|
const { registerTool } = useToolManager(setTools)
|
||||||
|
return { tools, registerTool }
|
||||||
|
})
|
||||||
|
|
||||||
|
const originalTool = createTestTool({ tooltip: 'Original' })
|
||||||
|
const updatedTool = createTestTool({ tooltip: 'Updated' })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.registerTool(originalTool)
|
||||||
|
result.current.registerTool(updatedTool)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tools).toHaveLength(1)
|
||||||
|
expect(result.current.tools[0]).toEqual(updatedTool)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sort tools by order (descending)', () => {
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
const [tools, setTools] = useState<ActionTool[]>([])
|
||||||
|
const { registerTool } = useToolManager(setTools)
|
||||||
|
return { tools, registerTool }
|
||||||
|
})
|
||||||
|
|
||||||
|
const tool1 = createTestTool({ id: 'tool1', order: 10 })
|
||||||
|
const tool2 = createTestTool({ id: 'tool2', order: 30 })
|
||||||
|
const tool3 = createTestTool({ id: 'tool3', order: 20 })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.registerTool(tool1)
|
||||||
|
result.current.registerTool(tool2)
|
||||||
|
result.current.registerTool(tool3)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 应该按 order 降序排列
|
||||||
|
expect(result.current.tools[0].id).toBe('tool2') // order: 30
|
||||||
|
expect(result.current.tools[1].id).toBe('tool3') // order: 20
|
||||||
|
expect(result.current.tools[2].id).toBe('tool1') // order: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle tools with children', () => {
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
const [tools, setTools] = useState<ActionTool[]>([])
|
||||||
|
const { registerTool } = useToolManager(setTools)
|
||||||
|
return { tools, registerTool }
|
||||||
|
})
|
||||||
|
|
||||||
|
const childTool = createTestTool({ id: 'child-tool', order: 5 })
|
||||||
|
const parentTool = createTestTool({
|
||||||
|
id: 'parent-tool',
|
||||||
|
order: 15,
|
||||||
|
children: [childTool]
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.registerTool(parentTool)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tools).toHaveLength(1)
|
||||||
|
expect(result.current.tools[0]).toEqual(parentTool)
|
||||||
|
expect(result.current.tools[0].children).toEqual([childTool])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not modify state if setTools is not provided', () => {
|
||||||
|
const { result } = renderHook(() => useToolManager(undefined))
|
||||||
|
|
||||||
|
// 不应该抛出错误
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
result.current.registerTool(createTestTool())
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('removeTool', () => {
|
||||||
|
it('should remove tool by id', () => {
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
const [tools, setTools] = useState<ActionTool[]>([createTestTool()])
|
||||||
|
const { registerTool, removeTool } = useToolManager(setTools)
|
||||||
|
return { tools, registerTool, removeTool }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tools).toHaveLength(1)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.removeTool('test-tool')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tools).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not affect other tools when removing one', () => {
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
const toolsData = [
|
||||||
|
createTestTool({ id: 'tool1' }),
|
||||||
|
createTestTool({ id: 'tool2' }),
|
||||||
|
createTestTool({ id: 'tool3' })
|
||||||
|
]
|
||||||
|
const [tools, setTools] = useState<ActionTool[]>(toolsData)
|
||||||
|
const { removeTool } = useToolManager(setTools)
|
||||||
|
return { tools, removeTool }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tools).toHaveLength(3)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.removeTool('tool2')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tools).toHaveLength(2)
|
||||||
|
expect(result.current.tools[0].id).toBe('tool1')
|
||||||
|
expect(result.current.tools[1].id).toBe('tool3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle removing non-existent tool', () => {
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
const [tools, setTools] = useState<ActionTool[]>([createTestTool()])
|
||||||
|
const { removeTool } = useToolManager(setTools)
|
||||||
|
return { tools, removeTool }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tools).toHaveLength(1)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.removeTool('non-existent-tool')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tools).toHaveLength(1) // 应该没有变化
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not modify state if setTools is not provided', () => {
|
||||||
|
const { result } = renderHook(() => useToolManager(undefined))
|
||||||
|
|
||||||
|
// 不应该抛出错误
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
result.current.removeTool('test-tool')
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('integration', () => {
|
||||||
|
it('should handle register and remove operations together', () => {
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
const [tools, setTools] = useState<ActionTool[]>([])
|
||||||
|
const { registerTool, removeTool } = useToolManager(setTools)
|
||||||
|
return { tools, registerTool, removeTool }
|
||||||
|
})
|
||||||
|
|
||||||
|
const tool1 = createTestTool({ id: 'tool1' })
|
||||||
|
const tool2 = createTestTool({ id: 'tool2' })
|
||||||
|
|
||||||
|
// 注册两个工具
|
||||||
|
act(() => {
|
||||||
|
result.current.registerTool(tool1)
|
||||||
|
result.current.registerTool(tool2)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tools).toHaveLength(2)
|
||||||
|
|
||||||
|
// 移除一个工具
|
||||||
|
act(() => {
|
||||||
|
result.current.removeTool('tool1')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tools).toHaveLength(1)
|
||||||
|
expect(result.current.tools[0].id).toBe('tool2')
|
||||||
|
|
||||||
|
// 再次注册被移除的工具
|
||||||
|
act(() => {
|
||||||
|
result.current.registerTool(tool1)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.tools).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { CodeToolSpec } from './types'
|
import { ActionToolSpec } from './types'
|
||||||
|
|
||||||
export const TOOL_SPECS: Record<string, CodeToolSpec> = {
|
export const TOOL_SPECS: Record<string, ActionToolSpec> = {
|
||||||
// Core tools
|
// Core tools
|
||||||
copy: {
|
copy: {
|
||||||
id: 'copy',
|
id: 'copy',
|
||||||
292
src/renderer/src/components/ActionTools/hooks/useImageTools.tsx
Normal file
292
src/renderer/src/components/ActionTools/hooks/useImageTools.tsx
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
|
import { ImagePreviewService } from '@renderer/services/ImagePreviewService'
|
||||||
|
import { download as downloadFile } from '@renderer/utils/download'
|
||||||
|
import { svgToPngBlob, svgToSvgBlob } from '@renderer/utils/image'
|
||||||
|
import { RefObject, useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('usePreviewToolHandlers')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用图像处理工具的自定义Hook
|
||||||
|
* 提供图像缩放、复制和下载功能
|
||||||
|
*/
|
||||||
|
export const useImageTools = (
|
||||||
|
containerRef: RefObject<HTMLDivElement | null>,
|
||||||
|
options: {
|
||||||
|
prefix: string
|
||||||
|
imgSelector: string
|
||||||
|
enableDrag?: boolean
|
||||||
|
enableWheelZoom?: boolean
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const transformRef = useRef({ scale: 1, x: 0, y: 0 }) // 管理变换状态
|
||||||
|
const { imgSelector, prefix, enableDrag, enableWheelZoom } = options
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
// 创建选择器函数
|
||||||
|
const getImgElement = useCallback(() => {
|
||||||
|
if (!containerRef.current) return null
|
||||||
|
|
||||||
|
// 优先尝试从 Shadow DOM 中查找
|
||||||
|
const shadowRoot = containerRef.current.shadowRoot
|
||||||
|
if (shadowRoot) {
|
||||||
|
return shadowRoot.querySelector(imgSelector) as SVGElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级到常规 DOM 查找
|
||||||
|
return containerRef.current.querySelector(imgSelector) as SVGElement | null
|
||||||
|
}, [containerRef, imgSelector])
|
||||||
|
|
||||||
|
// 获取原始图像元素(移除所有变换)
|
||||||
|
const getCleanImgElement = useCallback((): SVGElement | null => {
|
||||||
|
const imgElement = getImgElement()
|
||||||
|
if (!imgElement) return null
|
||||||
|
|
||||||
|
const clonedElement = imgElement.cloneNode(true) as SVGElement
|
||||||
|
clonedElement.style.transform = ''
|
||||||
|
clonedElement.style.transformOrigin = ''
|
||||||
|
return clonedElement
|
||||||
|
}, [getImgElement])
|
||||||
|
|
||||||
|
// 查询当前位置
|
||||||
|
const getCurrentPosition = useCallback(() => {
|
||||||
|
const imgElement = getImgElement()
|
||||||
|
if (!imgElement) return transformRef.current
|
||||||
|
|
||||||
|
const transform = imgElement.style.transform
|
||||||
|
if (!transform || transform === 'none') return transformRef.current
|
||||||
|
|
||||||
|
// 使用CSS矩阵解析
|
||||||
|
const matrix = new DOMMatrix(transform)
|
||||||
|
return { x: matrix.m41, y: matrix.m42 }
|
||||||
|
}, [getImgElement])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平移缩放变换
|
||||||
|
* @param element 要应用变换的元素
|
||||||
|
* @param x X轴偏移量
|
||||||
|
* @param y Y轴偏移量
|
||||||
|
* @param scale 缩放比例
|
||||||
|
*/
|
||||||
|
const applyTransform = useCallback((element: SVGElement | null, x: number, y: number, scale: number) => {
|
||||||
|
if (!element) return
|
||||||
|
element.style.transformOrigin = 'top left'
|
||||||
|
element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平移函数 - 按指定方向和距离移动图像
|
||||||
|
* @param dx X轴偏移量(正数向右,负数向左)
|
||||||
|
* @param dy Y轴偏移量(正数向下,负数向上)
|
||||||
|
* @param absolute 是否为绝对位置(true)或相对偏移(false)
|
||||||
|
*/
|
||||||
|
const pan = useCallback(
|
||||||
|
(dx: number, dy: number, absolute = false) => {
|
||||||
|
const currentPos = getCurrentPosition()
|
||||||
|
const newX = absolute ? dx : currentPos.x + dx
|
||||||
|
const newY = absolute ? dy : currentPos.y + dy
|
||||||
|
|
||||||
|
transformRef.current.x = newX
|
||||||
|
transformRef.current.y = newY
|
||||||
|
|
||||||
|
const imgElement = getImgElement()
|
||||||
|
applyTransform(imgElement, newX, newY, transformRef.current.scale)
|
||||||
|
},
|
||||||
|
[getCurrentPosition, getImgElement, applyTransform]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 拖拽平移支持
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableDrag || !containerRef.current) return
|
||||||
|
|
||||||
|
const container = containerRef.current
|
||||||
|
const startPos = { x: 0, y: 0 }
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const dx = e.clientX - startPos.x
|
||||||
|
const dy = e.clientY - startPos.y
|
||||||
|
|
||||||
|
// 直接使用 transformRef 中的初始偏移量进行计算
|
||||||
|
const newX = transformRef.current.x + dx
|
||||||
|
const newY = transformRef.current.y + dy
|
||||||
|
|
||||||
|
const imgElement = getImgElement()
|
||||||
|
// 实时应用变换,但不更新 ref,避免累积误差
|
||||||
|
applyTransform(imgElement, newX, newY, transformRef.current.scale)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = (e: MouseEvent) => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
|
||||||
|
container.style.cursor = 'default'
|
||||||
|
|
||||||
|
// 拖拽结束后,计算最终位置并更新 ref
|
||||||
|
const dx = e.clientX - startPos.x
|
||||||
|
const dy = e.clientY - startPos.y
|
||||||
|
transformRef.current.x += dx
|
||||||
|
transformRef.current.y += dy
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
if (e.button !== 0) return // 只响应左键
|
||||||
|
|
||||||
|
// 每次拖拽开始时,都以 ref 中当前的位置为基准
|
||||||
|
const currentPos = getCurrentPosition()
|
||||||
|
transformRef.current.x = currentPos.x
|
||||||
|
transformRef.current.y = currentPos.y
|
||||||
|
|
||||||
|
startPos.x = e.clientX
|
||||||
|
startPos.y = e.clientY
|
||||||
|
|
||||||
|
container.style.cursor = 'grabbing'
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('mousedown', handleMouseDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('mousedown', handleMouseDown)
|
||||||
|
// 清理以防万一,例如组件在拖拽过程中被卸载
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
}, [containerRef, getImgElement, applyTransform, getCurrentPosition, enableDrag])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缩放
|
||||||
|
* @param delta 缩放增量(正值放大,负值缩小)
|
||||||
|
*/
|
||||||
|
const zoom = useCallback(
|
||||||
|
(delta: number, absolute = false) => {
|
||||||
|
const newScale = absolute
|
||||||
|
? Math.max(0.1, Math.min(3, delta))
|
||||||
|
: Math.max(0.1, Math.min(3, transformRef.current.scale + delta))
|
||||||
|
|
||||||
|
transformRef.current.scale = newScale
|
||||||
|
|
||||||
|
const imgElement = getImgElement()
|
||||||
|
applyTransform(imgElement, transformRef.current.x, transformRef.current.y, newScale)
|
||||||
|
},
|
||||||
|
[getImgElement, applyTransform]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 滚轮缩放支持
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableWheelZoom || !containerRef.current) return
|
||||||
|
|
||||||
|
const container = containerRef.current
|
||||||
|
|
||||||
|
const handleWheel = (e: WheelEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.target) {
|
||||||
|
// 确认事件发生在容器内部
|
||||||
|
if (container.contains(e.target as Node)) {
|
||||||
|
const delta = e.deltaY < 0 ? 0.1 : -0.1
|
||||||
|
zoom(delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('wheel', handleWheel, { passive: true })
|
||||||
|
return () => container.removeEventListener('wheel', handleWheel)
|
||||||
|
}, [containerRef, zoom, enableWheelZoom])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制图像
|
||||||
|
*
|
||||||
|
* 目前使用了清理变换后的图像,因此不适用于画布
|
||||||
|
*/
|
||||||
|
const copy = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const imgElement = getCleanImgElement()
|
||||||
|
if (!imgElement) return
|
||||||
|
|
||||||
|
const blob = await svgToPngBlob(imgElement)
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||||
|
window.message.success(t('message.copy.success'))
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Copy failed:', error as Error)
|
||||||
|
window.message.error(t('message.copy.failed'))
|
||||||
|
}
|
||||||
|
}, [getCleanImgElement, t])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载图像
|
||||||
|
*
|
||||||
|
* 目前使用了清理变换后的图像,因此不适用于画布
|
||||||
|
*/
|
||||||
|
const download = useCallback(
|
||||||
|
async (format: 'svg' | 'png') => {
|
||||||
|
try {
|
||||||
|
const imgElement = getCleanImgElement()
|
||||||
|
if (!imgElement) return
|
||||||
|
|
||||||
|
const timestamp = Date.now()
|
||||||
|
|
||||||
|
if (format === 'svg') {
|
||||||
|
const blob = svgToSvgBlob(imgElement)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
downloadFile(url, `${prefix}-${timestamp}.svg`)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} else {
|
||||||
|
const blob = await svgToPngBlob(imgElement)
|
||||||
|
const pngUrl = URL.createObjectURL(blob)
|
||||||
|
downloadFile(pngUrl, `${prefix}-${timestamp}.png`)
|
||||||
|
URL.revokeObjectURL(pngUrl)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Download failed:', error as Error)
|
||||||
|
window.message.error(t('message.download.failed'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getCleanImgElement, prefix, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览 dialog
|
||||||
|
*
|
||||||
|
* 目前使用了清理变换后的图像,因此不适用于画布
|
||||||
|
*/
|
||||||
|
const dialog = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const imgElement = getCleanImgElement()
|
||||||
|
if (!imgElement) return
|
||||||
|
|
||||||
|
await ImagePreviewService.show(imgElement, { format: 'svg' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Dialog preview failed:', error as Error)
|
||||||
|
window.message.error(t('message.dialog.failed'))
|
||||||
|
}
|
||||||
|
}, [getCleanImgElement, t])
|
||||||
|
|
||||||
|
// 获取当前变换状态
|
||||||
|
const getCurrentTransform = useCallback(() => {
|
||||||
|
return {
|
||||||
|
scale: transformRef.current.scale,
|
||||||
|
x: transformRef.current.x,
|
||||||
|
y: transformRef.current.y
|
||||||
|
}
|
||||||
|
}, [transformRef])
|
||||||
|
|
||||||
|
// 切换主题时重置变换
|
||||||
|
useEffect(() => {
|
||||||
|
pan(0, 0, true)
|
||||||
|
zoom(1, true)
|
||||||
|
}, [pan, zoom, theme])
|
||||||
|
|
||||||
|
return {
|
||||||
|
zoom,
|
||||||
|
pan,
|
||||||
|
copy,
|
||||||
|
download,
|
||||||
|
dialog,
|
||||||
|
getCurrentTransform
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,11 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
import { CodeTool } from './types'
|
import { ActionTool, ToolRegisterProps } from '../types'
|
||||||
|
|
||||||
export const useCodeTool = (setTools?: (value: React.SetStateAction<CodeTool[]>) => void) => {
|
export const useToolManager = (setTools?: ToolRegisterProps['setTools']) => {
|
||||||
// 注册工具,如果已存在同ID工具则替换
|
// 注册工具,如果已存在同ID工具则替换
|
||||||
const registerTool = useCallback(
|
const registerTool = useCallback(
|
||||||
(tool: CodeTool) => {
|
(tool: ActionTool) => {
|
||||||
setTools?.((prev) => {
|
setTools?.((prev) => {
|
||||||
const filtered = prev.filter((t) => t.id !== tool.id)
|
const filtered = prev.filter((t) => t.id !== tool.id)
|
||||||
return [...filtered, tool].sort((a, b) => b.order - a.order)
|
return [...filtered, tool].sort((a, b) => b.order - a.order)
|
||||||
4
src/renderer/src/components/ActionTools/index.ts
Normal file
4
src/renderer/src/components/ActionTools/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './constants'
|
||||||
|
export * from './hooks/useImageTools'
|
||||||
|
export * from './hooks/useToolManager'
|
||||||
|
export * from './types'
|
||||||
34
src/renderer/src/components/ActionTools/types.ts
Normal file
34
src/renderer/src/components/ActionTools/types.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 动作工具基本信息
|
||||||
|
*/
|
||||||
|
export interface ActionToolSpec {
|
||||||
|
id: string
|
||||||
|
type: 'core' | 'quick'
|
||||||
|
order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动作工具定义接口
|
||||||
|
* @param id 唯一标识符
|
||||||
|
* @param type 工具类型
|
||||||
|
* @param order 显示顺序,越小越靠右
|
||||||
|
* @param icon 按钮图标
|
||||||
|
* @param tooltip 提示文本
|
||||||
|
* @param visible 显示条件
|
||||||
|
* @param onClick 点击动作
|
||||||
|
* @param children 子工具(例如 more 下拉菜单)
|
||||||
|
*/
|
||||||
|
export interface ActionTool extends ActionToolSpec {
|
||||||
|
icon: React.ReactNode
|
||||||
|
tooltip?: string
|
||||||
|
visible?: () => boolean
|
||||||
|
onClick?: () => void
|
||||||
|
children?: Omit<ActionTool, 'children'>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子组件向父组件注册工具所需的 props
|
||||||
|
*/
|
||||||
|
export interface ToolRegisterProps {
|
||||||
|
setTools?: (value: React.SetStateAction<ActionTool[]>) => void
|
||||||
|
}
|
||||||
@ -1,102 +0,0 @@
|
|||||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
|
||||||
import { LoadingIcon } from '@renderer/components/Icons'
|
|
||||||
import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
|
|
||||||
import { Flex, Spin } from 'antd'
|
|
||||||
import { debounce } from 'lodash'
|
|
||||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import styled from 'styled-components'
|
|
||||||
|
|
||||||
import PreviewError from './PreviewError'
|
|
||||||
import { BasicPreviewProps } from './types'
|
|
||||||
|
|
||||||
// 管理 viz 实例
|
|
||||||
const vizInitializer = new AsyncInitializer(async () => {
|
|
||||||
const module = await import('@viz-js/viz')
|
|
||||||
return await module.instance()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 预览 Graphviz 图表
|
|
||||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
|
||||||
*/
|
|
||||||
const GraphvizPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
|
||||||
const graphvizRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
// 使用通用图像工具
|
|
||||||
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(graphvizRef, {
|
|
||||||
imgSelector: 'svg',
|
|
||||||
prefix: 'graphviz',
|
|
||||||
enableWheelZoom: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// 使用工具栏
|
|
||||||
usePreviewTools({
|
|
||||||
setTools,
|
|
||||||
handleZoom,
|
|
||||||
handleCopyImage,
|
|
||||||
handleDownload
|
|
||||||
})
|
|
||||||
|
|
||||||
// 实际的渲染函数
|
|
||||||
const renderGraphviz = useCallback(async (content: string) => {
|
|
||||||
if (!content || !graphvizRef.current) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
const viz = await vizInitializer.get()
|
|
||||||
const svgElement = viz.renderSVGElement(content)
|
|
||||||
|
|
||||||
// 清空容器并添加新的 SVG
|
|
||||||
graphvizRef.current.innerHTML = ''
|
|
||||||
graphvizRef.current.appendChild(svgElement)
|
|
||||||
|
|
||||||
// 渲染成功,清除错误记录
|
|
||||||
setError(null)
|
|
||||||
} catch (error) {
|
|
||||||
setError((error as Error).message || 'DOT syntax error or rendering failed')
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// debounce 渲染
|
|
||||||
const debouncedRender = useMemo(
|
|
||||||
() =>
|
|
||||||
debounce((content: string) => {
|
|
||||||
startTransition(() => renderGraphviz(content))
|
|
||||||
}, 300),
|
|
||||||
[renderGraphviz]
|
|
||||||
)
|
|
||||||
|
|
||||||
// 触发渲染
|
|
||||||
useEffect(() => {
|
|
||||||
if (children) {
|
|
||||||
setIsLoading(true)
|
|
||||||
debouncedRender(children)
|
|
||||||
} else {
|
|
||||||
debouncedRender.cancel()
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
debouncedRender.cancel()
|
|
||||||
}
|
|
||||||
}, [children, debouncedRender])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Spin spinning={isLoading} indicator={<LoadingIcon color="var(--color-text-2)" />}>
|
|
||||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
|
||||||
{error && <PreviewError>{error}</PreviewError>}
|
|
||||||
<StyledGraphviz ref={graphvizRef} className="graphviz special-preview" />
|
|
||||||
</Flex>
|
|
||||||
</Spin>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const StyledGraphviz = styled.div`
|
|
||||||
overflow: auto;
|
|
||||||
`
|
|
||||||
|
|
||||||
export default memo(GraphvizPreview)
|
|
||||||
@ -147,9 +147,10 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
|||||||
editable={true}
|
editable={true}
|
||||||
onSave={setCurrentHtml}
|
onSave={setCurrentHtml}
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
|
expanded
|
||||||
|
unwrapped={false}
|
||||||
options={{
|
options={{
|
||||||
stream: false,
|
stream: false
|
||||||
collapsible: false
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</CodeSection>
|
</CodeSection>
|
||||||
|
|||||||
@ -1,155 +0,0 @@
|
|||||||
import { nanoid } from '@reduxjs/toolkit'
|
|
||||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
|
||||||
import { LoadingIcon } from '@renderer/components/Icons'
|
|
||||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
|
||||||
import { Flex, Spin } from 'antd'
|
|
||||||
import { debounce } from 'lodash'
|
|
||||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import styled from 'styled-components'
|
|
||||||
|
|
||||||
import PreviewError from './PreviewError'
|
|
||||||
import { BasicPreviewProps } from './types'
|
|
||||||
|
|
||||||
/** 预览 Mermaid 图表
|
|
||||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
|
||||||
* FIXME: 等将来容易判断代码块结束位置时再重构。
|
|
||||||
*/
|
|
||||||
const MermaidPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
|
||||||
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
|
|
||||||
const mermaidRef = useRef<HTMLDivElement>(null)
|
|
||||||
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [isRendering, setIsRendering] = useState(false)
|
|
||||||
const [isVisible, setIsVisible] = useState(true)
|
|
||||||
|
|
||||||
// 使用通用图像工具
|
|
||||||
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(mermaidRef, {
|
|
||||||
imgSelector: 'svg',
|
|
||||||
prefix: 'mermaid',
|
|
||||||
enableWheelZoom: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// 使用工具栏
|
|
||||||
usePreviewTools({
|
|
||||||
setTools,
|
|
||||||
handleZoom,
|
|
||||||
handleCopyImage,
|
|
||||||
handleDownload
|
|
||||||
})
|
|
||||||
|
|
||||||
// 实际的渲染函数
|
|
||||||
const renderMermaid = useCallback(
|
|
||||||
async (content: string) => {
|
|
||||||
if (!content || !mermaidRef.current) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsRendering(true)
|
|
||||||
|
|
||||||
// 验证语法,提前抛出异常
|
|
||||||
await mermaid.parse(content)
|
|
||||||
|
|
||||||
const { svg } = await mermaid.render(diagramId, content, mermaidRef.current)
|
|
||||||
|
|
||||||
// 避免不可见时产生 undefined 和 NaN
|
|
||||||
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
|
|
||||||
mermaidRef.current.innerHTML = fixedSvg
|
|
||||||
|
|
||||||
// 渲染成功,清除错误记录
|
|
||||||
setError(null)
|
|
||||||
} catch (error) {
|
|
||||||
setError((error as Error).message)
|
|
||||||
} finally {
|
|
||||||
setIsRendering(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[diagramId, mermaid]
|
|
||||||
)
|
|
||||||
|
|
||||||
// debounce 渲染
|
|
||||||
const debouncedRender = useMemo(
|
|
||||||
() =>
|
|
||||||
debounce((content: string) => {
|
|
||||||
startTransition(() => renderMermaid(content))
|
|
||||||
}, 300),
|
|
||||||
[renderMermaid]
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听可见性变化,用于触发重新渲染。
|
|
||||||
* 这是为了解决 `MessageGroup` 组件的 `fold` 布局中被 `display: none` 隐藏的图标无法正确渲染的问题。
|
|
||||||
* 监听时向上遍历到第一个有 `fold` className 的父节点为止(也就是目前的 `MessageWrapper`)。
|
|
||||||
* FIXME: 将来 mermaid-js 修复此问题后可以移除这里的相关逻辑。
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mermaidRef.current) return
|
|
||||||
|
|
||||||
const checkVisibility = () => {
|
|
||||||
const element = mermaidRef.current
|
|
||||||
if (!element) return
|
|
||||||
|
|
||||||
const currentlyVisible = element.offsetParent !== null
|
|
||||||
setIsVisible(currentlyVisible)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始检查
|
|
||||||
checkVisibility()
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
checkVisibility()
|
|
||||||
})
|
|
||||||
|
|
||||||
let targetElement = mermaidRef.current.parentElement
|
|
||||||
while (targetElement) {
|
|
||||||
observer.observe(targetElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['class', 'style']
|
|
||||||
})
|
|
||||||
|
|
||||||
if (targetElement.className?.includes('fold')) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
targetElement = targetElement.parentElement
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 触发渲染
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoadingMermaid) return
|
|
||||||
|
|
||||||
if (mermaidRef.current?.offsetParent === null) return
|
|
||||||
|
|
||||||
if (children) {
|
|
||||||
setIsRendering(true)
|
|
||||||
debouncedRender(children)
|
|
||||||
} else {
|
|
||||||
debouncedRender.cancel()
|
|
||||||
setIsRendering(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
debouncedRender.cancel()
|
|
||||||
}
|
|
||||||
}, [children, isLoadingMermaid, debouncedRender, isVisible])
|
|
||||||
|
|
||||||
const isLoading = isLoadingMermaid || isRendering
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Spin spinning={isLoading} indicator={<LoadingIcon color="var(--color-text-2)" />}>
|
|
||||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
|
||||||
{(mermaidError || error) && <PreviewError>{mermaidError || error}</PreviewError>}
|
|
||||||
<StyledMermaid ref={mermaidRef} className="mermaid special-preview" />
|
|
||||||
</Flex>
|
|
||||||
</Spin>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const StyledMermaid = styled.div`
|
|
||||||
overflow: auto;
|
|
||||||
`
|
|
||||||
|
|
||||||
export default memo(MermaidPreview)
|
|
||||||
@ -1,192 +0,0 @@
|
|||||||
import { LoadingOutlined } from '@ant-design/icons'
|
|
||||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
|
||||||
import { Spin } from 'antd'
|
|
||||||
import pako from 'pako'
|
|
||||||
import React, { memo, useCallback, useRef, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import styled from 'styled-components'
|
|
||||||
|
|
||||||
import { BasicPreviewProps } from './types'
|
|
||||||
|
|
||||||
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
|
|
||||||
function encode64(data: Uint8Array) {
|
|
||||||
let r = ''
|
|
||||||
for (let i = 0; i < data.length; i += 3) {
|
|
||||||
if (i + 2 === data.length) {
|
|
||||||
r += append3bytes(data[i], data[i + 1], 0)
|
|
||||||
} else if (i + 1 === data.length) {
|
|
||||||
r += append3bytes(data[i], 0, 0)
|
|
||||||
} else {
|
|
||||||
r += append3bytes(data[i], data[i + 1], data[i + 2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
function encode6bit(b: number) {
|
|
||||||
if (b < 10) {
|
|
||||||
return String.fromCharCode(48 + b)
|
|
||||||
}
|
|
||||||
b -= 10
|
|
||||||
if (b < 26) {
|
|
||||||
return String.fromCharCode(65 + b)
|
|
||||||
}
|
|
||||||
b -= 26
|
|
||||||
if (b < 26) {
|
|
||||||
return String.fromCharCode(97 + b)
|
|
||||||
}
|
|
||||||
b -= 26
|
|
||||||
if (b === 0) {
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
if (b === 1) {
|
|
||||||
return '_'
|
|
||||||
}
|
|
||||||
return '?'
|
|
||||||
}
|
|
||||||
|
|
||||||
function append3bytes(b1: number, b2: number, b3: number) {
|
|
||||||
const c1 = b1 >> 2
|
|
||||||
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
|
|
||||||
const c3 = ((b2 & 0xf) << 2) | (b3 >> 6)
|
|
||||||
const c4 = b3 & 0x3f
|
|
||||||
let r = ''
|
|
||||||
r += encode6bit(c1 & 0x3f)
|
|
||||||
r += encode6bit(c2 & 0x3f)
|
|
||||||
r += encode6bit(c3 & 0x3f)
|
|
||||||
r += encode6bit(c4 & 0x3f)
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* https://plantuml.com/zh/code-javascript-synchronous
|
|
||||||
* To use PlantUML image generation, a text diagram description have to be :
|
|
||||||
1. Encoded in UTF-8
|
|
||||||
2. Compressed using Deflate algorithm
|
|
||||||
3. Reencoded in ASCII using a transformation _close_ to base64
|
|
||||||
*/
|
|
||||||
function encodeDiagram(diagram: string): string {
|
|
||||||
const utf8text = new TextEncoder().encode(diagram)
|
|
||||||
const compressed = pako.deflateRaw(utf8text)
|
|
||||||
return encode64(compressed)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadUrl(url: string, filename: string) {
|
|
||||||
const response = await fetch(url)
|
|
||||||
if (!response.ok) {
|
|
||||||
window.message.warning({ content: response.statusText, duration: 1.5 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const blob = await response.blob()
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = URL.createObjectURL(blob)
|
|
||||||
link.download = filename
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
document.body.removeChild(link)
|
|
||||||
URL.revokeObjectURL(link.href)
|
|
||||||
}
|
|
||||||
|
|
||||||
type PlantUMLServerImageProps = {
|
|
||||||
format: 'png' | 'svg'
|
|
||||||
diagram: string
|
|
||||||
onClick?: React.MouseEventHandler<HTMLDivElement>
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPlantUMLImageUrl(format: 'png' | 'svg', diagram: string, isDark?: boolean) {
|
|
||||||
const encodedDiagram = encodeDiagram(diagram)
|
|
||||||
if (isDark) {
|
|
||||||
return `${PlantUMLServer}/d${format}/${encodedDiagram}`
|
|
||||||
}
|
|
||||||
return `${PlantUMLServer}/${format}/${encodedDiagram}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagram, onClick, className }) => {
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
// FIXME: 黑暗模式背景太黑了,目前让 PlantUML 和 SVG 一样保持白色背景
|
|
||||||
const url = getPlantUMLImageUrl(format, diagram, false)
|
|
||||||
return (
|
|
||||||
<StyledPlantUML onClick={onClick} className={className}>
|
|
||||||
<Spin
|
|
||||||
spinning={loading}
|
|
||||||
indicator={
|
|
||||||
<LoadingOutlined
|
|
||||||
spin
|
|
||||||
style={{
|
|
||||||
fontSize: 32
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}>
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
onLoad={() => {
|
|
||||||
setLoading(false)
|
|
||||||
}}
|
|
||||||
onError={(e) => {
|
|
||||||
setLoading(false)
|
|
||||||
const target = e.target as HTMLImageElement
|
|
||||||
target.style.opacity = '0.5'
|
|
||||||
target.style.filter = 'blur(2px)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Spin>
|
|
||||||
</StyledPlantUML>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlantUmlPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const encodedDiagram = encodeDiagram(children)
|
|
||||||
|
|
||||||
// 自定义 PlantUML 下载方法
|
|
||||||
const customDownload = useCallback(
|
|
||||||
(format: 'svg' | 'png') => {
|
|
||||||
const timestamp = Date.now()
|
|
||||||
const url = `${PlantUMLServer}/${format}/${encodedDiagram}`
|
|
||||||
const filename = `plantuml-diagram-${timestamp}.${format}`
|
|
||||||
downloadUrl(url, filename).catch(() => {
|
|
||||||
window.message.error(t('code_block.download.failed.network'))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[encodedDiagram, t]
|
|
||||||
)
|
|
||||||
|
|
||||||
// 使用通用图像工具,提供自定义下载方法
|
|
||||||
const { handleZoom, handleCopyImage } = usePreviewToolHandlers(containerRef, {
|
|
||||||
imgSelector: '.plantuml-preview img',
|
|
||||||
prefix: 'plantuml-diagram',
|
|
||||||
enableWheelZoom: true,
|
|
||||||
customDownloader: customDownload
|
|
||||||
})
|
|
||||||
|
|
||||||
// 使用工具栏
|
|
||||||
usePreviewTools({
|
|
||||||
setTools,
|
|
||||||
handleZoom,
|
|
||||||
handleCopyImage,
|
|
||||||
handleDownload: customDownload
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef}>
|
|
||||||
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview special-preview" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const StyledPlantUML = styled.div`
|
|
||||||
max-height: calc(80vh - 100px);
|
|
||||||
text-align: left;
|
|
||||||
overflow-y: auto;
|
|
||||||
background-color: white;
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
min-height: 100px;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export default memo(PlantUmlPreview)
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
import { memo } from 'react'
|
|
||||||
import { styled } from 'styled-components'
|
|
||||||
|
|
||||||
const PreviewError = styled.div`
|
|
||||||
overflow: auto;
|
|
||||||
padding: 16px;
|
|
||||||
color: #ff4d4f;
|
|
||||||
border: 1px solid #ff4d4f;
|
|
||||||
border-radius: 4px;
|
|
||||||
word-wrap: break-word;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
`
|
|
||||||
|
|
||||||
export default memo(PreviewError)
|
|
||||||
@ -18,6 +18,7 @@ const Container = styled(Flex)`
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
text-wrap: wrap;
|
text-wrap: wrap;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
`
|
`
|
||||||
|
|
||||||
export default memo(StatusBar)
|
export default memo(StatusBar)
|
||||||
|
|||||||
@ -1,61 +0,0 @@
|
|||||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
|
||||||
import { memo, useEffect, useRef } from 'react'
|
|
||||||
|
|
||||||
import { BasicPreviewProps } from './types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 使用 Shadow DOM 渲染 SVG
|
|
||||||
*/
|
|
||||||
const SvgPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
|
||||||
const svgContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const container = svgContainerRef.current
|
|
||||||
if (!container) return
|
|
||||||
|
|
||||||
const shadowRoot = container.shadowRoot || container.attachShadow({ mode: 'open' })
|
|
||||||
|
|
||||||
// 添加基础样式
|
|
||||||
const style = document.createElement('style')
|
|
||||||
style.textContent = `
|
|
||||||
:host {
|
|
||||||
padding: 1em;
|
|
||||||
background-color: white;
|
|
||||||
overflow: auto;
|
|
||||||
border: 0.5px solid var(--color-code-background);
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
// 清空并重新添加内容
|
|
||||||
shadowRoot.innerHTML = ''
|
|
||||||
shadowRoot.appendChild(style)
|
|
||||||
|
|
||||||
const svgContainer = document.createElement('div')
|
|
||||||
svgContainer.innerHTML = children
|
|
||||||
shadowRoot.appendChild(svgContainer)
|
|
||||||
}, [children])
|
|
||||||
|
|
||||||
// 使用通用图像工具
|
|
||||||
const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, {
|
|
||||||
imgSelector: 'svg',
|
|
||||||
prefix: 'svg-image'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 使用工具栏
|
|
||||||
usePreviewTools({
|
|
||||||
setTools,
|
|
||||||
handleCopyImage,
|
|
||||||
handleDownload
|
|
||||||
})
|
|
||||||
|
|
||||||
return <div ref={svgContainerRef} className="svg-preview special-preview" />
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(SvgPreview)
|
|
||||||
@ -1,7 +1,4 @@
|
|||||||
import GraphvizPreview from './GraphvizPreview'
|
import { GraphvizPreview, MermaidPreview, PlantUmlPreview, SvgPreview } from '@renderer/components/Preview'
|
||||||
import MermaidPreview from './MermaidPreview'
|
|
||||||
import PlantUmlPreview from './PlantUmlPreview'
|
|
||||||
import SvgPreview from './SvgPreview'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 特殊视图语言列表
|
* 特殊视图语言列表
|
||||||
|
|||||||
@ -1,13 +1,3 @@
|
|||||||
import { CodeTool } from '@renderer/components/CodeToolbar'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 预览组件的基本 props
|
|
||||||
*/
|
|
||||||
export interface BasicPreviewProps {
|
|
||||||
children: string
|
|
||||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 视图模式
|
* 视图模式
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,19 +1,30 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import CodeEditor from '@renderer/components/CodeEditor'
|
import { ActionTool } from '@renderer/components/ActionTools'
|
||||||
import { CodeTool, CodeToolbar, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
|
||||||
import { LoadingIcon } from '@renderer/components/Icons'
|
import {
|
||||||
|
CodeToolbar,
|
||||||
|
useCopyTool,
|
||||||
|
useDownloadTool,
|
||||||
|
useExpandTool,
|
||||||
|
useRunTool,
|
||||||
|
useSaveTool,
|
||||||
|
useSplitViewTool,
|
||||||
|
useViewSourceTool,
|
||||||
|
useWrapTool
|
||||||
|
} from '@renderer/components/CodeToolbar'
|
||||||
|
import CodeViewer from '@renderer/components/CodeViewer'
|
||||||
|
import ImageViewer from '@renderer/components/ImageViewer'
|
||||||
|
import { BasicPreviewHandles } from '@renderer/components/Preview'
|
||||||
|
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { pyodideService } from '@renderer/services/PyodideService'
|
import { pyodideService } from '@renderer/services/PyodideService'
|
||||||
import { extractTitle } from '@renderer/utils/formats'
|
import { extractTitle } from '@renderer/utils/formats'
|
||||||
import { getExtensionByLanguage, isHtmlCode, isValidPlantUML } from '@renderer/utils/markdown'
|
import { getExtensionByLanguage, isHtmlCode } from '@renderer/utils/markdown'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react'
|
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled, { css } from 'styled-components'
|
||||||
|
|
||||||
import ImageViewer from '../ImageViewer'
|
|
||||||
import CodePreview from './CodePreview'
|
|
||||||
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
|
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
|
||||||
import HtmlArtifactsCard from './HtmlArtifactsCard'
|
import HtmlArtifactsCard from './HtmlArtifactsCard'
|
||||||
import StatusBar from './StatusBar'
|
import StatusBar from './StatusBar'
|
||||||
@ -45,31 +56,83 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
|
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { codeEditor, codeExecution } = useSettings()
|
const { codeEditor, codeExecution, codeImageTools, codeCollapsible, codeWrappable } = useSettings()
|
||||||
|
|
||||||
|
const [viewState, setViewState] = useState({
|
||||||
|
mode: 'special' as ViewMode,
|
||||||
|
previousMode: 'special' as ViewMode
|
||||||
|
})
|
||||||
|
const { mode: viewMode } = viewState
|
||||||
|
|
||||||
|
const setViewMode = useCallback((newMode: ViewMode) => {
|
||||||
|
setViewState((current) => ({
|
||||||
|
mode: newMode,
|
||||||
|
// 当新模式不是 'split' 时才更新
|
||||||
|
previousMode: newMode !== 'split' ? newMode : current.previousMode
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleSplitView = useCallback(() => {
|
||||||
|
setViewState((current) => {
|
||||||
|
// 如果当前是 split 模式,恢复到上一个模式
|
||||||
|
if (current.mode === 'split') {
|
||||||
|
return { ...current, mode: current.previousMode }
|
||||||
|
}
|
||||||
|
return { mode: 'split', previousMode: current.mode }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('special')
|
|
||||||
const [isRunning, setIsRunning] = useState(false)
|
const [isRunning, setIsRunning] = useState(false)
|
||||||
const [executionResult, setExecutionResult] = useState<{ text: string; image?: string } | null>(null)
|
const [executionResult, setExecutionResult] = useState<{ text: string; image?: string } | null>(null)
|
||||||
|
|
||||||
const [tools, setTools] = useState<CodeTool[]>([])
|
const [tools, setTools] = useState<ActionTool[]>([])
|
||||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
|
||||||
|
|
||||||
const isExecutable = useMemo(() => {
|
const isExecutable = useMemo(() => {
|
||||||
return codeExecution.enabled && language === 'python'
|
return codeExecution.enabled && language === 'python'
|
||||||
}, [codeExecution.enabled, language])
|
}, [codeExecution.enabled, language])
|
||||||
|
|
||||||
|
const sourceViewRef = useRef<CodeEditorHandles>(null)
|
||||||
|
const specialViewRef = useRef<BasicPreviewHandles>(null)
|
||||||
|
|
||||||
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
|
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
|
||||||
|
|
||||||
const isInSpecialView = useMemo(() => {
|
const isInSpecialView = useMemo(() => {
|
||||||
return hasSpecialView && viewMode === 'special'
|
return hasSpecialView && viewMode === 'special'
|
||||||
}, [hasSpecialView, viewMode])
|
}, [hasSpecialView, viewMode])
|
||||||
|
|
||||||
|
const [expandOverride, setExpandOverride] = useState(!codeCollapsible)
|
||||||
|
const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable)
|
||||||
|
|
||||||
|
// 重置用户操作
|
||||||
|
useEffect(() => {
|
||||||
|
setExpandOverride(!codeCollapsible)
|
||||||
|
}, [codeCollapsible])
|
||||||
|
|
||||||
|
// 重置用户操作
|
||||||
|
useEffect(() => {
|
||||||
|
setUnwrapOverride(!codeWrappable)
|
||||||
|
}, [codeWrappable])
|
||||||
|
|
||||||
|
const shouldExpand = useMemo(() => !codeCollapsible || expandOverride, [codeCollapsible, expandOverride])
|
||||||
|
const shouldUnwrap = useMemo(() => !codeWrappable || unwrapOverride, [codeWrappable, unwrapOverride])
|
||||||
|
|
||||||
|
const [sourceScrollHeight, setSourceScrollHeight] = useState(0)
|
||||||
|
const expandable = useMemo(() => {
|
||||||
|
return codeCollapsible && sourceScrollHeight > MAX_COLLAPSED_CODE_HEIGHT
|
||||||
|
}, [codeCollapsible, sourceScrollHeight])
|
||||||
|
|
||||||
|
const handleHeightChange = useCallback((height: number) => {
|
||||||
|
startTransition(() => {
|
||||||
|
setSourceScrollHeight((prev) => (prev === height ? prev : height))
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleCopySource = useCallback(() => {
|
const handleCopySource = useCallback(() => {
|
||||||
navigator.clipboard.writeText(children)
|
navigator.clipboard.writeText(children)
|
||||||
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
|
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
|
||||||
}, [children, t])
|
}, [children, t])
|
||||||
|
|
||||||
const handleDownloadSource = useCallback(async () => {
|
const handleDownloadSource = useCallback(() => {
|
||||||
let fileName = ''
|
let fileName = ''
|
||||||
|
|
||||||
// 尝试提取 HTML 标题
|
// 尝试提取 HTML 标题
|
||||||
@ -82,7 +145,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
|||||||
fileName = `${dayjs().format('YYYYMMDDHHmm')}`
|
fileName = `${dayjs().format('YYYYMMDDHHmm')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = await getExtensionByLanguage(language)
|
const ext = getExtensionByLanguage(language)
|
||||||
window.api.file.save(`${fileName}${ext}`, children)
|
window.api.file.save(`${fileName}${ext}`, children)
|
||||||
}, [children, language])
|
}, [children, language])
|
||||||
|
|
||||||
@ -106,101 +169,103 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
|||||||
})
|
})
|
||||||
}, [children, codeExecution.timeoutMinutes])
|
}, [children, codeExecution.timeoutMinutes])
|
||||||
|
|
||||||
useEffect(() => {
|
const showPreviewTools = useMemo(() => {
|
||||||
// 复制按钮
|
return viewMode !== 'source' && hasSpecialView
|
||||||
registerTool({
|
}, [hasSpecialView, viewMode])
|
||||||
...TOOL_SPECS.copy,
|
|
||||||
icon: <Copy className="icon" />,
|
|
||||||
tooltip: t('code_block.copy.source'),
|
|
||||||
onClick: handleCopySource
|
|
||||||
})
|
|
||||||
|
|
||||||
// 下载按钮
|
// 复制按钮
|
||||||
registerTool({
|
useCopyTool({
|
||||||
...TOOL_SPECS.download,
|
showPreviewTools,
|
||||||
icon: <Download className="icon" />,
|
previewRef: specialViewRef,
|
||||||
tooltip: t('code_block.download.source'),
|
onCopySource: handleCopySource,
|
||||||
onClick: handleDownloadSource
|
setTools
|
||||||
})
|
})
|
||||||
return () => {
|
|
||||||
removeTool(TOOL_SPECS.copy.id)
|
|
||||||
removeTool(TOOL_SPECS.download.id)
|
|
||||||
}
|
|
||||||
}, [handleCopySource, handleDownloadSource, registerTool, removeTool, t])
|
|
||||||
|
|
||||||
// 特殊视图的编辑按钮,在分屏模式下不可用
|
// 下载按钮
|
||||||
useEffect(() => {
|
useDownloadTool({
|
||||||
if (!hasSpecialView || viewMode === 'split') return
|
showPreviewTools,
|
||||||
|
previewRef: specialViewRef,
|
||||||
|
onDownloadSource: handleDownloadSource,
|
||||||
|
setTools
|
||||||
|
})
|
||||||
|
|
||||||
const viewSourceToolSpec = codeEditor.enabled ? TOOL_SPECS.edit : TOOL_SPECS['view-source']
|
// 特殊视图的编辑/查看源码按钮,在分屏模式下不可用
|
||||||
|
useViewSourceTool({
|
||||||
|
enabled: hasSpecialView,
|
||||||
|
editable: codeEditor.enabled,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange: setViewMode,
|
||||||
|
setTools
|
||||||
|
})
|
||||||
|
|
||||||
if (codeEditor.enabled) {
|
// 特殊视图存在时的分屏按钮
|
||||||
registerTool({
|
useSplitViewTool({
|
||||||
...viewSourceToolSpec,
|
enabled: hasSpecialView,
|
||||||
icon: viewMode === 'source' ? <Eye className="icon" /> : <SquarePen className="icon" />,
|
viewMode,
|
||||||
tooltip: viewMode === 'source' ? t('code_block.preview.label') : t('code_block.edit.label'),
|
onToggleSplitView: toggleSplitView,
|
||||||
onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source')
|
setTools
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
registerTool({
|
|
||||||
...viewSourceToolSpec,
|
|
||||||
icon: viewMode === 'source' ? <Eye className="icon" /> : <CodeXml className="icon" />,
|
|
||||||
tooltip: viewMode === 'source' ? t('code_block.preview.label') : t('code_block.preview.source'),
|
|
||||||
onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => removeTool(viewSourceToolSpec.id)
|
|
||||||
}, [codeEditor.enabled, hasSpecialView, viewMode, registerTool, removeTool, t])
|
|
||||||
|
|
||||||
// 特殊视图的分屏按钮
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasSpecialView) return
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
...TOOL_SPECS['split-view'],
|
|
||||||
icon: viewMode === 'split' ? <Square className="icon" /> : <SquareSplitHorizontal className="icon" />,
|
|
||||||
tooltip: viewMode === 'split' ? t('code_block.split.restore') : t('code_block.split.label'),
|
|
||||||
onClick: () => setViewMode(viewMode === 'split' ? 'special' : 'split')
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => removeTool(TOOL_SPECS['split-view'].id)
|
|
||||||
}, [hasSpecialView, viewMode, registerTool, removeTool, t])
|
|
||||||
|
|
||||||
// 运行按钮
|
// 运行按钮
|
||||||
useEffect(() => {
|
useRunTool({
|
||||||
if (!isExecutable) return
|
enabled: isExecutable,
|
||||||
|
isRunning,
|
||||||
|
onRun: handleRunScript,
|
||||||
|
setTools
|
||||||
|
})
|
||||||
|
|
||||||
registerTool({
|
// 源代码视图的展开/折叠按钮
|
||||||
...TOOL_SPECS.run,
|
useExpandTool({
|
||||||
icon: isRunning ? <LoadingIcon /> : <CirclePlay className="icon" />,
|
enabled: !isInSpecialView,
|
||||||
tooltip: t('code_block.run'),
|
expanded: shouldExpand,
|
||||||
onClick: () => !isRunning && handleRunScript()
|
expandable,
|
||||||
})
|
toggle: useCallback(() => setExpandOverride((prev) => !prev), []),
|
||||||
|
setTools
|
||||||
|
})
|
||||||
|
|
||||||
return () => isExecutable && removeTool(TOOL_SPECS.run.id)
|
// 源代码视图的自动换行按钮
|
||||||
}, [isExecutable, isRunning, handleRunScript, registerTool, removeTool, t])
|
useWrapTool({
|
||||||
|
enabled: !isInSpecialView,
|
||||||
|
unwrapped: shouldUnwrap,
|
||||||
|
wrappable: codeWrappable,
|
||||||
|
toggle: useCallback(() => setUnwrapOverride((prev) => !prev), []),
|
||||||
|
setTools
|
||||||
|
})
|
||||||
|
|
||||||
|
// 代码编辑器的保存按钮
|
||||||
|
useSaveTool({
|
||||||
|
enabled: codeEditor.enabled && !isInSpecialView,
|
||||||
|
sourceViewRef,
|
||||||
|
setTools
|
||||||
|
})
|
||||||
|
|
||||||
// 源代码视图组件
|
// 源代码视图组件
|
||||||
const sourceView = useMemo(() => {
|
const sourceView = useMemo(
|
||||||
if (codeEditor.enabled) {
|
() =>
|
||||||
return (
|
codeEditor.enabled ? (
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
className="source-view"
|
||||||
|
ref={sourceViewRef}
|
||||||
value={children}
|
value={children}
|
||||||
language={language}
|
language={language}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
|
onHeightChange={handleHeightChange}
|
||||||
options={{ stream: true }}
|
options={{ stream: true }}
|
||||||
setTools={setTools}
|
expanded={shouldExpand}
|
||||||
|
unwrapped={shouldUnwrap}
|
||||||
/>
|
/>
|
||||||
)
|
) : (
|
||||||
} else {
|
<CodeViewer
|
||||||
return (
|
className="source-view"
|
||||||
<CodePreview language={language} setTools={setTools}>
|
language={language}
|
||||||
|
expanded={shouldExpand}
|
||||||
|
unwrapped={shouldUnwrap}
|
||||||
|
onHeightChange={handleHeightChange}>
|
||||||
{children}
|
{children}
|
||||||
</CodePreview>
|
</CodeViewer>
|
||||||
)
|
),
|
||||||
}
|
[children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldUnwrap]
|
||||||
}, [children, codeEditor.enabled, language, onSave, setTools])
|
)
|
||||||
|
|
||||||
// 特殊视图组件映射
|
// 特殊视图组件映射
|
||||||
const specialView = useMemo(() => {
|
const specialView = useMemo(() => {
|
||||||
@ -208,13 +273,12 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
|||||||
|
|
||||||
if (!SpecialView) return null
|
if (!SpecialView) return null
|
||||||
|
|
||||||
// PlantUML 语法验证
|
return (
|
||||||
if (language === 'plantuml' && !isValidPlantUML(children)) {
|
<SpecialView ref={specialViewRef} enableToolbar={codeImageTools}>
|
||||||
return null
|
{children}
|
||||||
}
|
</SpecialView>
|
||||||
|
)
|
||||||
return <SpecialView setTools={setTools}>{children}</SpecialView>
|
}, [children, codeImageTools, language])
|
||||||
}, [children, language])
|
|
||||||
|
|
||||||
const renderHeader = useMemo(() => {
|
const renderHeader = useMemo(() => {
|
||||||
const langTag = '<' + language.toUpperCase() + '>'
|
const langTag = '<' + language.toUpperCase() + '>'
|
||||||
@ -227,7 +291,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
|||||||
const showSourceView = !specialView || viewMode !== 'special'
|
const showSourceView = !specialView || viewMode !== 'special'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitViewWrapper className="split-view-wrapper">
|
<SplitViewWrapper className="split-view-wrapper" $viewMode={viewMode}>
|
||||||
{showSpecialView && specialView}
|
{showSpecialView && specialView}
|
||||||
{showSourceView && sourceView}
|
{showSourceView && sourceView}
|
||||||
</SplitViewWrapper>
|
</SplitViewWrapper>
|
||||||
@ -260,7 +324,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* FIXME: 最小宽度用于解决两个问题。
|
/* FIXME: 最小宽度用于解决两个问题。
|
||||||
* 一是 CodePreview 在气泡样式下的用户消息中无法撑开气泡,
|
* 一是 CodeViewer 在气泡样式下的用户消息中无法撑开气泡,
|
||||||
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
|
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
|
||||||
*/
|
*/
|
||||||
min-width: 45ch;
|
min-width: 45ch;
|
||||||
@ -295,9 +359,10 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
|
|||||||
border-top-right-radius: 8px;
|
border-top-right-radius: 8px;
|
||||||
margin-top: ${(props) => (props.$isInSpecialView ? '6px' : '0')};
|
margin-top: ${(props) => (props.$isInSpecialView ? '6px' : '0')};
|
||||||
height: ${(props) => (props.$isInSpecialView ? '16px' : '34px')};
|
height: ${(props) => (props.$isInSpecialView ? '16px' : '34px')};
|
||||||
|
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||||
`
|
`
|
||||||
|
|
||||||
const SplitViewWrapper = styled.div`
|
const SplitViewWrapper = styled.div<{ $viewMode?: ViewMode }>`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
> * {
|
> * {
|
||||||
@ -306,7 +371,27 @@ const SplitViewWrapper = styled.div`
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:not(:has(+ [class*='Container'])) {
|
&:not(:has(+ [class*='Container'])) {
|
||||||
border-radius: 0 0 8px 8px;
|
// 特殊视图的 header 会隐藏,所以全都使用圆角
|
||||||
|
border-radius: ${(props) => (props.$viewMode === 'special' ? '8px' : '0 0 8px 8px')};
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 在 split 模式下添加中间分隔线
|
||||||
|
${(props) =>
|
||||||
|
props.$viewMode === 'split' &&
|
||||||
|
css`
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
background-color: var(--color-background-mute);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
`}
|
||||||
`
|
`
|
||||||
|
|||||||
@ -175,3 +175,26 @@ export function useBlurHandler({ onBlur }: UseBlurHandlerProps) {
|
|||||||
})
|
})
|
||||||
}, [onBlur])
|
}, [onBlur])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UseHeightListenerProps {
|
||||||
|
onHeightChange?: (scrollHeight: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CodeMirror 扩展,用于监听编辑器高度变化
|
||||||
|
* @param onHeightChange 高度变化时触发的回调函数
|
||||||
|
* @returns 扩展或空数组
|
||||||
|
*/
|
||||||
|
export function useHeightListener({ onHeightChange }: UseHeightListenerProps) {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!onHeightChange) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged || update.heightChanged) {
|
||||||
|
onHeightChange(update.view.scrollDOM?.scrollHeight ?? 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [onHeightChange])
|
||||||
|
}
|
||||||
|
|||||||
@ -1,32 +1,29 @@
|
|||||||
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror'
|
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror'
|
||||||
import diff from 'fast-diff'
|
import diff from 'fast-diff'
|
||||||
import {
|
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||||
ChevronsDownUp,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Save as SaveIcon,
|
|
||||||
Text as UnWrapIcon,
|
|
||||||
WrapText as WrapIcon
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
import { useBlurHandler, useLanguageExtensions, useSaveKeymap } from './hooks'
|
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
|
||||||
|
|
||||||
// 标记非用户编辑的变更
|
// 标记非用户编辑的变更
|
||||||
const External = Annotation.define<boolean>()
|
const External = Annotation.define<boolean>()
|
||||||
|
|
||||||
interface Props {
|
export interface CodeEditorHandles {
|
||||||
|
save?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeEditorProps {
|
||||||
|
ref?: React.RefObject<CodeEditorHandles | null>
|
||||||
value: string
|
value: string
|
||||||
placeholder?: string | HTMLElement
|
placeholder?: string | HTMLElement
|
||||||
language: string
|
language: string
|
||||||
onSave?: (newContent: string) => void
|
onSave?: (newContent: string) => void
|
||||||
onChange?: (newContent: string) => void
|
onChange?: (newContent: string) => void
|
||||||
onBlur?: (newContent: string) => void
|
onBlur?: (newContent: string) => void
|
||||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
onHeightChange?: (scrollHeight: number) => void
|
||||||
height?: string
|
height?: string
|
||||||
minHeight?: string
|
minHeight?: string
|
||||||
maxHeight?: string
|
maxHeight?: string
|
||||||
@ -35,15 +32,16 @@ interface Props {
|
|||||||
options?: {
|
options?: {
|
||||||
stream?: boolean // 用于流式响应场景,默认 false
|
stream?: boolean // 用于流式响应场景,默认 false
|
||||||
lint?: boolean
|
lint?: boolean
|
||||||
collapsible?: boolean
|
|
||||||
wrappable?: boolean
|
|
||||||
keymap?: boolean
|
keymap?: boolean
|
||||||
} & BasicSetupOptions
|
} & BasicSetupOptions
|
||||||
/** 用于追加 extensions */
|
/** 用于追加 extensions */
|
||||||
extensions?: Extension[]
|
extensions?: Extension[]
|
||||||
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
|
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
|
className?: string
|
||||||
editable?: boolean
|
editable?: boolean
|
||||||
|
expanded?: boolean
|
||||||
|
unwrapped?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,13 +50,14 @@ interface Props {
|
|||||||
* 目前必须和 CodeToolbar 配合使用。
|
* 目前必须和 CodeToolbar 配合使用。
|
||||||
*/
|
*/
|
||||||
const CodeEditor = ({
|
const CodeEditor = ({
|
||||||
|
ref,
|
||||||
value,
|
value,
|
||||||
placeholder,
|
placeholder,
|
||||||
language,
|
language,
|
||||||
onSave,
|
onSave,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
setTools,
|
onHeightChange,
|
||||||
height,
|
height,
|
||||||
minHeight,
|
minHeight,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
@ -66,17 +65,12 @@ const CodeEditor = ({
|
|||||||
options,
|
options,
|
||||||
extensions,
|
extensions,
|
||||||
style,
|
style,
|
||||||
editable = true
|
className,
|
||||||
}: Props) => {
|
editable = true,
|
||||||
const {
|
expanded = true,
|
||||||
fontSize: _fontSize,
|
unwrapped = false
|
||||||
codeShowLineNumbers: _lineNumbers,
|
}: CodeEditorProps) => {
|
||||||
codeCollapsible: _collapsible,
|
const { fontSize: _fontSize, codeShowLineNumbers: _lineNumbers, codeEditor } = useSettings()
|
||||||
codeWrappable: _wrappable,
|
|
||||||
codeEditor
|
|
||||||
} = useSettings()
|
|
||||||
const collapsible = useMemo(() => options?.collapsible ?? _collapsible, [options?.collapsible, _collapsible])
|
|
||||||
const wrappable = useMemo(() => options?.wrappable ?? _wrappable, [options?.wrappable, _wrappable])
|
|
||||||
const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap])
|
const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap])
|
||||||
|
|
||||||
// 合并 codeEditor 和 options 的 basicSetup,options 优先
|
// 合并 codeEditor 和 options 的 basicSetup,options 优先
|
||||||
@ -91,63 +85,16 @@ const CodeEditor = ({
|
|||||||
const customFontSize = useMemo(() => fontSize ?? `${_fontSize - 1}px`, [fontSize, _fontSize])
|
const customFontSize = useMemo(() => fontSize ?? `${_fontSize - 1}px`, [fontSize, _fontSize])
|
||||||
|
|
||||||
const { activeCmTheme } = useCodeStyle()
|
const { activeCmTheme } = useCodeStyle()
|
||||||
const [isExpanded, setIsExpanded] = useState(!collapsible)
|
|
||||||
const [isUnwrapped, setIsUnwrapped] = useState(!wrappable)
|
|
||||||
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
|
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
|
||||||
const [editorReady, setEditorReady] = useState(false)
|
|
||||||
const editorViewRef = useRef<EditorView | null>(null)
|
const editorViewRef = useRef<EditorView | null>(null)
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
const langExtensions = useLanguageExtensions(language, options?.lint)
|
const langExtensions = useLanguageExtensions(language, options?.lint)
|
||||||
|
|
||||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
|
||||||
|
|
||||||
// 展开/折叠工具
|
|
||||||
useEffect(() => {
|
|
||||||
registerTool({
|
|
||||||
...TOOL_SPECS.expand,
|
|
||||||
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
|
||||||
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
|
|
||||||
visible: () => {
|
|
||||||
const scrollHeight = editorViewRef?.current?.scrollDOM?.scrollHeight
|
|
||||||
return collapsible && (scrollHeight ?? 0) > 350
|
|
||||||
},
|
|
||||||
onClick: () => setIsExpanded((prev) => !prev)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => removeTool(TOOL_SPECS.expand.id)
|
|
||||||
}, [collapsible, isExpanded, registerTool, removeTool, t, editorReady])
|
|
||||||
|
|
||||||
// 自动换行工具
|
|
||||||
useEffect(() => {
|
|
||||||
registerTool({
|
|
||||||
...TOOL_SPECS.wrap,
|
|
||||||
icon: isUnwrapped ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
|
|
||||||
tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
|
||||||
visible: () => wrappable,
|
|
||||||
onClick: () => setIsUnwrapped((prev) => !prev)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => removeTool(TOOL_SPECS.wrap.id)
|
|
||||||
}, [wrappable, isUnwrapped, registerTool, removeTool, t])
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
const currentDoc = editorViewRef.current?.state.doc.toString() ?? ''
|
const currentDoc = editorViewRef.current?.state.doc.toString() ?? ''
|
||||||
onSave?.(currentDoc)
|
onSave?.(currentDoc)
|
||||||
}, [onSave])
|
}, [onSave])
|
||||||
|
|
||||||
// 保存按钮
|
|
||||||
useEffect(() => {
|
|
||||||
registerTool({
|
|
||||||
...TOOL_SPECS.save,
|
|
||||||
icon: <SaveIcon className="icon" />,
|
|
||||||
tooltip: t('code_block.edit.save.label'),
|
|
||||||
onClick: handleSave
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => removeTool(TOOL_SPECS.save.id)
|
|
||||||
}, [handleSave, registerTool, removeTool, t])
|
|
||||||
|
|
||||||
// 流式响应过程中计算 changes 来更新 EditorView
|
// 流式响应过程中计算 changes 来更新 EditorView
|
||||||
// 无法处理用户在流式响应过程中编辑代码的情况(应该也不必处理)
|
// 无法处理用户在流式响应过程中编辑代码的情况(应该也不必处理)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -166,26 +113,24 @@ const CodeEditor = ({
|
|||||||
}
|
}
|
||||||
}, [options?.stream, value])
|
}, [options?.stream, value])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsExpanded(!collapsible)
|
|
||||||
}, [collapsible])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsUnwrapped(!wrappable)
|
|
||||||
}, [wrappable])
|
|
||||||
|
|
||||||
const saveKeymapExtension = useSaveKeymap({ onSave, enabled: enableKeymap })
|
const saveKeymapExtension = useSaveKeymap({ onSave, enabled: enableKeymap })
|
||||||
const blurExtension = useBlurHandler({ onBlur })
|
const blurExtension = useBlurHandler({ onBlur })
|
||||||
|
const heightListenerExtension = useHeightListener({ onHeightChange })
|
||||||
|
|
||||||
const customExtensions = useMemo(() => {
|
const customExtensions = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
...(extensions ?? []),
|
...(extensions ?? []),
|
||||||
...langExtensions,
|
...langExtensions,
|
||||||
...(isUnwrapped ? [] : [EditorView.lineWrapping]),
|
...(unwrapped ? [] : [EditorView.lineWrapping]),
|
||||||
saveKeymapExtension,
|
saveKeymapExtension,
|
||||||
blurExtension
|
blurExtension,
|
||||||
|
heightListenerExtension
|
||||||
].flat()
|
].flat()
|
||||||
}, [extensions, langExtensions, isUnwrapped, saveKeymapExtension, blurExtension])
|
}, [extensions, langExtensions, unwrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
save: handleSave
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
@ -195,14 +140,14 @@ const CodeEditor = ({
|
|||||||
width="100%"
|
width="100%"
|
||||||
height={height}
|
height={height}
|
||||||
minHeight={minHeight}
|
minHeight={minHeight}
|
||||||
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
|
maxHeight={expanded ? 'none' : (maxHeight ?? `${MAX_COLLAPSED_CODE_HEIGHT}px`)}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
||||||
theme={activeCmTheme}
|
theme={activeCmTheme}
|
||||||
extensions={customExtensions}
|
extensions={customExtensions}
|
||||||
onCreateEditor={(view: EditorView) => {
|
onCreateEditor={(view: EditorView) => {
|
||||||
editorViewRef.current = view
|
editorViewRef.current = view
|
||||||
setEditorReady(true)
|
onHeightChange?.(view.scrollDOM?.scrollHeight ?? 0)
|
||||||
}}
|
}}
|
||||||
onChange={(value, viewUpdate) => {
|
onChange={(value, viewUpdate) => {
|
||||||
if (onChange && viewUpdate.docChanged) onChange(value)
|
if (onChange && viewUpdate.docChanged) onChange(value)
|
||||||
@ -230,6 +175,7 @@ const CodeEditor = ({
|
|||||||
borderRadius: 'inherit',
|
borderRadius: 'inherit',
|
||||||
...style
|
...style
|
||||||
}}
|
}}
|
||||||
|
className={`code-editor ${className ?? ''}`}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,164 @@
|
|||||||
|
import { ActionTool } from '@renderer/components/ActionTools'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import CodeToolButton from '../button'
|
||||||
|
|
||||||
|
// Mock Antd components
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
Tooltip: vi.fn(({ children, title }) => (
|
||||||
|
<div data-testid="tooltip" data-title={title}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
Dropdown: vi.fn(({ children, menu }) => (
|
||||||
|
<div data-testid="dropdown" data-menu={JSON.stringify(menu)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('antd', () => ({
|
||||||
|
Tooltip: mocks.Tooltip,
|
||||||
|
Dropdown: mocks.Dropdown
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock ToolWrapper
|
||||||
|
vi.mock('../styles', () => ({
|
||||||
|
ToolWrapper: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
|
||||||
|
<button type="button" data-testid="tool-wrapper" onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Helper function to create mock tools
|
||||||
|
const createMockTool = (overrides: Partial<ActionTool> = {}): ActionTool => ({
|
||||||
|
id: 'test-tool',
|
||||||
|
type: 'core',
|
||||||
|
order: 10,
|
||||||
|
icon: <span data-testid="test-icon">Test Icon</span>,
|
||||||
|
tooltip: 'Test Tool',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMockChildTool = (id: string, tooltip: string): Omit<ActionTool, 'children'> => ({
|
||||||
|
id,
|
||||||
|
type: 'quick',
|
||||||
|
order: 10,
|
||||||
|
icon: <span data-testid={`${id}-icon`}>{tooltip} Icon</span>,
|
||||||
|
tooltip,
|
||||||
|
onClick: vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CodeToolButton', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rendering modes', () => {
|
||||||
|
it('should render as simple button when no children', () => {
|
||||||
|
const tool = createMockTool()
|
||||||
|
render(<CodeToolButton tool={tool} />)
|
||||||
|
|
||||||
|
// Should render button with tooltip
|
||||||
|
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tool-wrapper')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Should not render dropdown
|
||||||
|
expect(screen.queryByTestId('dropdown')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render as simple button when children array is empty', () => {
|
||||||
|
const tool = createMockTool({ children: [] })
|
||||||
|
render(<CodeToolButton tool={tool} />)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('dropdown')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render as dropdown when has children', () => {
|
||||||
|
const children = [createMockChildTool('child1', 'Child 1')]
|
||||||
|
const tool = createMockTool({ children })
|
||||||
|
render(<CodeToolButton tool={tool} />)
|
||||||
|
|
||||||
|
// Should render dropdown containing the main button
|
||||||
|
expect(screen.getByTestId('dropdown')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tool-wrapper')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('user interactions', () => {
|
||||||
|
it('should trigger onClick when simple button is clicked', () => {
|
||||||
|
const mockOnClick = vi.fn()
|
||||||
|
const tool = createMockTool({ onClick: mockOnClick })
|
||||||
|
render(<CodeToolButton tool={tool} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('tool-wrapper'))
|
||||||
|
|
||||||
|
expect(mockOnClick).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing onClick gracefully', () => {
|
||||||
|
const tool = createMockTool({ onClick: undefined })
|
||||||
|
render(<CodeToolButton tool={tool} />)
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
fireEvent.click(screen.getByTestId('tool-wrapper'))
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dropdown functionality', () => {
|
||||||
|
it('should configure dropdown with correct menu structure', () => {
|
||||||
|
const mockOnClick1 = vi.fn()
|
||||||
|
const mockOnClick2 = vi.fn()
|
||||||
|
const children = [createMockChildTool('child1', 'Child 1'), createMockChildTool('child2', 'Child 2')]
|
||||||
|
children[0].onClick = mockOnClick1
|
||||||
|
children[1].onClick = mockOnClick2
|
||||||
|
|
||||||
|
const tool = createMockTool({ children })
|
||||||
|
render(<CodeToolButton tool={tool} />)
|
||||||
|
|
||||||
|
// Verify dropdown was called with correct menu structure
|
||||||
|
expect(mocks.Dropdown).toHaveBeenCalled()
|
||||||
|
const dropdownProps = mocks.Dropdown.mock.calls[0][0]
|
||||||
|
|
||||||
|
expect(dropdownProps.menu.items).toHaveLength(2)
|
||||||
|
expect(dropdownProps.menu.items[0].key).toBe('child1')
|
||||||
|
expect(dropdownProps.menu.items[0].label).toBe('Child 1')
|
||||||
|
expect(dropdownProps.menu.items[0].onClick).toBe(mockOnClick1)
|
||||||
|
expect(dropdownProps.trigger).toEqual(['click'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('should provide accessible button element with tooltip', () => {
|
||||||
|
const tool = createMockTool({ tooltip: 'Accessible Tool' })
|
||||||
|
render(<CodeToolButton tool={tool} />)
|
||||||
|
|
||||||
|
const button = screen.getByTestId('tool-wrapper')
|
||||||
|
expect(button.tagName).toBe('BUTTON')
|
||||||
|
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-title', 'Accessible Tool')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should render without crashing for minimal tool configuration', () => {
|
||||||
|
const minimalTool: ActionTool = {
|
||||||
|
id: 'minimal',
|
||||||
|
type: 'core',
|
||||||
|
order: 1,
|
||||||
|
icon: null,
|
||||||
|
tooltip: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
render(<CodeToolButton tool={minimalTool} />)
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,262 @@
|
|||||||
|
import { ActionTool } from '@renderer/components/ActionTools'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import CodeToolbar from '../toolbar'
|
||||||
|
|
||||||
|
// Test constants
|
||||||
|
const MORE_BUTTON_TOOLTIP = 'code_block.more'
|
||||||
|
|
||||||
|
// Mock components
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
CodeToolButton: vi.fn(({ tool }) => (
|
||||||
|
<div data-testid={`tool-button-${tool.id}`} data-tool-id={tool.id} data-tool-type={tool.type}>
|
||||||
|
{tool.icon}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
Tooltip: vi.fn(({ children, title }) => (
|
||||||
|
<div data-testid="tooltip" data-title={title}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
HStack: vi.fn(({ children, className }) => (
|
||||||
|
<div data-testid="hstack" className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
ToolWrapper: vi.fn(({ children, onClick, className }) => (
|
||||||
|
<div data-testid="tool-wrapper" onClick={onClick} className={className} role="button" tabIndex={0}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
EllipsisVertical: vi.fn(() => <div data-testid="ellipsis-icon" className="tool-icon" />),
|
||||||
|
useTranslation: vi.fn(() => ({
|
||||||
|
t: vi.fn((key: string) => key)
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../button', () => ({
|
||||||
|
default: mocks.CodeToolButton
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('antd', () => ({
|
||||||
|
Tooltip: mocks.Tooltip
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Layout', () => ({
|
||||||
|
HStack: mocks.HStack
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./styles', () => ({
|
||||||
|
ToolWrapper: mocks.ToolWrapper
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
EllipsisVertical: mocks.EllipsisVertical
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: mocks.useTranslation
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Helper function to create mock tools
|
||||||
|
const createMockTool = (overrides: Partial<ActionTool> = {}): ActionTool => ({
|
||||||
|
id: 'test-tool',
|
||||||
|
type: 'core',
|
||||||
|
order: 1,
|
||||||
|
icon: <div data-testid="test-icon">Icon</div>,
|
||||||
|
tooltip: 'Test Tool',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
// Common test data
|
||||||
|
const createMixedTools = () => [
|
||||||
|
createMockTool({ id: 'quick1', type: 'quick' }),
|
||||||
|
createMockTool({ id: 'quick2', type: 'quick' }),
|
||||||
|
createMockTool({ id: 'core1', type: 'core' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const createCoreOnlyTools = () => [
|
||||||
|
createMockTool({ id: 'core1', type: 'core' }),
|
||||||
|
createMockTool({ id: 'core2', type: 'core' })
|
||||||
|
]
|
||||||
|
|
||||||
|
// Helper function to click more button
|
||||||
|
const clickMoreButton = () => {
|
||||||
|
const tooltip = screen.getByTestId('tooltip')
|
||||||
|
fireEvent.click(tooltip.firstChild as Element)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CodeToolbar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('basic rendering', () => {
|
||||||
|
it('should match snapshot with mixed tools', () => {
|
||||||
|
const { container } = render(<CodeToolbar tools={createMixedTools()} />)
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should match snapshot with only core tools', () => {
|
||||||
|
const { container } = render(<CodeToolbar tools={[createMockTool({ id: 'core1', type: 'core' })]} />)
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('empty state', () => {
|
||||||
|
it('should render nothing when no tools provided', () => {
|
||||||
|
const { container } = render(<CodeToolbar tools={[]} />)
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render nothing when all tools are not visible', () => {
|
||||||
|
const tools = [
|
||||||
|
createMockTool({ id: 'tool1', visible: () => false }),
|
||||||
|
createMockTool({ id: 'tool2', visible: () => false })
|
||||||
|
]
|
||||||
|
const { container } = render(<CodeToolbar tools={tools} />)
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tool visibility filtering', () => {
|
||||||
|
it('should only render visible tools', () => {
|
||||||
|
const tools = [
|
||||||
|
createMockTool({ id: 'visible-tool', visible: () => true }),
|
||||||
|
createMockTool({ id: 'hidden-tool', visible: () => false }),
|
||||||
|
createMockTool({ id: 'no-visible-prop' }) // Should be visible by default
|
||||||
|
]
|
||||||
|
render(<CodeToolbar tools={tools} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('tool-button-visible-tool')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tool-button-no-visible-prop')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('tool-button-hidden-tool')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show tools without visible function by default', () => {
|
||||||
|
const tools = [createMockTool({ id: 'default-visible' })]
|
||||||
|
render(<CodeToolbar tools={tools} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('tool-button-default-visible')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tool type grouping and quick tools behavior', () => {
|
||||||
|
it('should separate core and quick tools - show quick tools when expanded', () => {
|
||||||
|
const tools = [
|
||||||
|
createMockTool({ id: 'core1', type: 'core' }),
|
||||||
|
createMockTool({ id: 'quick1', type: 'quick' }),
|
||||||
|
createMockTool({ id: 'core2', type: 'core' }),
|
||||||
|
createMockTool({ id: 'quick2', type: 'quick' })
|
||||||
|
]
|
||||||
|
render(<CodeToolbar tools={tools} />)
|
||||||
|
|
||||||
|
// Initial state: core tools visible, quick tools hidden
|
||||||
|
expect(screen.getByTestId('tool-button-core1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tool-button-core2')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('tool-button-quick1')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('tool-button-quick2')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// After clicking more button, quick tools should be visible
|
||||||
|
clickMoreButton()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('tool-button-quick1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tool-button-quick2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render only core tools when no quick tools exist', () => {
|
||||||
|
render(<CodeToolbar tools={createCoreOnlyTools()} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('tool-button-core1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tool-button-core2')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() // No more button
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show single quick tool directly without more button', () => {
|
||||||
|
const tools = [createMockTool({ id: 'quick1', type: 'quick' }), createMockTool({ id: 'core1', type: 'core' })]
|
||||||
|
render(<CodeToolbar tools={tools} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('tool-button-quick1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tool-button-core1')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() // No more button
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show more button when multiple quick tools exist', () => {
|
||||||
|
render(<CodeToolbar tools={createMixedTools()} />)
|
||||||
|
|
||||||
|
// Initially quick tools should be hidden
|
||||||
|
expect(screen.queryByTestId('tool-button-quick1')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('tool-button-quick2')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tool-button-core1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tooltip')).toBeInTheDocument() // More button exists
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle quick tools visibility when more button is clicked', () => {
|
||||||
|
render(<CodeToolbar tools={createMixedTools()} />)
|
||||||
|
|
||||||
|
// Initial state: quick tools hidden
|
||||||
|
expect(screen.queryByTestId('tool-button-quick1')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('tool-button-quick2')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// Click more button: quick tools visible
|
||||||
|
clickMoreButton()
|
||||||
|
expect(screen.getByTestId('tool-button-quick1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tool-button-quick2')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Click more button again: quick tools hidden
|
||||||
|
clickMoreButton()
|
||||||
|
expect(screen.queryByTestId('tool-button-quick1')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('tool-button-quick2')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply active class to more button when quick tools are shown', () => {
|
||||||
|
const tools = [createMockTool({ id: 'quick1', type: 'quick' }), createMockTool({ id: 'quick2', type: 'quick' })]
|
||||||
|
render(<CodeToolbar tools={tools} />)
|
||||||
|
|
||||||
|
const tooltip = screen.getByTestId('tooltip')
|
||||||
|
const moreButton = tooltip.firstChild as Element
|
||||||
|
|
||||||
|
// Initial state: no active class
|
||||||
|
expect(moreButton).not.toHaveClass('active')
|
||||||
|
|
||||||
|
// After click: has active class
|
||||||
|
fireEvent.click(moreButton)
|
||||||
|
expect(moreButton).toHaveClass('active')
|
||||||
|
|
||||||
|
// After second click: no active class
|
||||||
|
fireEvent.click(moreButton)
|
||||||
|
expect(moreButton).not.toHaveClass('active')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should display correct tooltip and icon for more button', () => {
|
||||||
|
render(<CodeToolbar tools={createMixedTools()} />)
|
||||||
|
|
||||||
|
const tooltip = screen.getByTestId('tooltip')
|
||||||
|
expect(tooltip).toHaveAttribute('data-title', MORE_BUTTON_TOOLTIP)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('ellipsis-icon')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('ellipsis-icon')).toHaveClass('tool-icon')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render core tools regardless of quick tools state', () => {
|
||||||
|
const tools = [
|
||||||
|
createMockTool({ id: 'quick1', type: 'quick' }),
|
||||||
|
createMockTool({ id: 'quick2', type: 'quick' }),
|
||||||
|
createMockTool({ id: 'core1', type: 'core' }),
|
||||||
|
createMockTool({ id: 'core2', type: 'core' })
|
||||||
|
]
|
||||||
|
render(<CodeToolbar tools={tools} />)
|
||||||
|
|
||||||
|
// Core tools always visible
|
||||||
|
expect(screen.getByTestId('tool-button-core1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tool-button-core2')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// After clicking more button, core tools still visible
|
||||||
|
clickMoreButton()
|
||||||
|
expect(screen.getByTestId('tool-button-core1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tool-button-core2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,129 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`CodeToolbar > basic rendering > should match snapshot with mixed tools 1`] = `
|
||||||
|
.c2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2:hover {
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2:hover .tool-icon {
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2.active .tool-icon {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2 .tool-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c0 {
|
||||||
|
position: sticky;
|
||||||
|
top: 28px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c1 {
|
||||||
|
position: absolute;
|
||||||
|
align-items: center;
|
||||||
|
bottom: 0.3rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
height: 24px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="c0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="c1 code-toolbar"
|
||||||
|
data-testid="hstack"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="tooltip"
|
||||||
|
data-title="code_block.more"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="c2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="tool-icon"
|
||||||
|
data-testid="ellipsis-icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="tool-button-core1"
|
||||||
|
data-tool-id="core1"
|
||||||
|
data-tool-type="core"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="test-icon"
|
||||||
|
>
|
||||||
|
Icon
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`CodeToolbar > basic rendering > should match snapshot with only core tools 1`] = `
|
||||||
|
.c0 {
|
||||||
|
position: sticky;
|
||||||
|
top: 28px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c1 {
|
||||||
|
position: absolute;
|
||||||
|
align-items: center;
|
||||||
|
bottom: 0.3rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
height: 24px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="c0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="c1 code-toolbar"
|
||||||
|
data-testid="hstack"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="tool-button-core1"
|
||||||
|
data-tool-id="core1"
|
||||||
|
data-tool-type="core"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="test-icon"
|
||||||
|
>
|
||||||
|
Icon
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@ -0,0 +1,251 @@
|
|||||||
|
import { useCopyTool } from '@renderer/components/CodeToolbar/hooks/useCopyTool'
|
||||||
|
import { BasicPreviewHandles } from '@renderer/components/Preview'
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
i18n: {
|
||||||
|
t: vi.fn((key: string) => key)
|
||||||
|
},
|
||||||
|
useTemporaryValue: vi.fn(),
|
||||||
|
useToolManager: vi.fn(),
|
||||||
|
TOOL_SPECS: {
|
||||||
|
copy: {
|
||||||
|
id: 'copy',
|
||||||
|
type: 'core',
|
||||||
|
order: 11
|
||||||
|
},
|
||||||
|
'copy-image': {
|
||||||
|
id: 'copy-image',
|
||||||
|
type: 'quick',
|
||||||
|
order: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
Check: () => <div data-testid="check-icon" />,
|
||||||
|
Image: () => <div data-testid="image-icon" />
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: mocks.i18n.t
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Icons', () => ({
|
||||||
|
CopyIcon: () => <div data-testid="copy-icon" />
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ActionTools', () => ({
|
||||||
|
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||||
|
useToolManager: mocks.useToolManager
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/hooks/useTemporaryValue', () => ({
|
||||||
|
useTemporaryValue: mocks.useTemporaryValue
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock useToolManager
|
||||||
|
const mockRegisterTool = vi.fn()
|
||||||
|
const mockRemoveTool = vi.fn()
|
||||||
|
mocks.useToolManager.mockImplementation(() => ({
|
||||||
|
registerTool: mockRegisterTool,
|
||||||
|
removeTool: mockRemoveTool
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock useTemporaryValue setters
|
||||||
|
const mockSetCopiedTemporarily = vi.fn()
|
||||||
|
const mockSetCopiedImageTemporarily = vi.fn()
|
||||||
|
|
||||||
|
describe('useCopyTool', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Reset mocks for each test to ensure isolation
|
||||||
|
mocks.useTemporaryValue
|
||||||
|
.mockImplementationOnce(() => [false, mockSetCopiedTemporarily])
|
||||||
|
.mockImplementationOnce(() => [false, mockSetCopiedImageTemporarily])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper function to create mock props
|
||||||
|
const createMockProps = (overrides: Partial<Parameters<typeof useCopyTool>[0]> = {}) => ({
|
||||||
|
showPreviewTools: false,
|
||||||
|
previewRef: { current: null },
|
||||||
|
onCopySource: vi.fn(),
|
||||||
|
setTools: vi.fn(),
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMockPreviewHandles = (): BasicPreviewHandles => ({
|
||||||
|
pan: vi.fn(),
|
||||||
|
zoom: vi.fn(),
|
||||||
|
copy: vi.fn(),
|
||||||
|
download: vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tool registration', () => {
|
||||||
|
it('should register only the copy-source tool when showPreviewTools is false', () => {
|
||||||
|
const props = createMockProps({ showPreviewTools: false })
|
||||||
|
renderHook(() => useCopyTool(props))
|
||||||
|
|
||||||
|
expect(mocks.useToolManager).toHaveBeenCalledWith(props.setTools)
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'copy',
|
||||||
|
tooltip: 'code_block.copy.source'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should register only the copy-source tool when previewRef is null', () => {
|
||||||
|
const props = createMockProps({ showPreviewTools: true, previewRef: { current: null } })
|
||||||
|
renderHook(() => useCopyTool(props))
|
||||||
|
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'copy'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should register both copy-source and copy-image tools when preview is available', () => {
|
||||||
|
const props = createMockProps({
|
||||||
|
showPreviewTools: true,
|
||||||
|
previewRef: { current: createMockPreviewHandles() }
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHook(() => useCopyTool(props))
|
||||||
|
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
// Check first tool: copy source
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'copy',
|
||||||
|
tooltip: 'code_block.copy.source',
|
||||||
|
onClick: expect.any(Function)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check second tool: copy image
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'copy-image',
|
||||||
|
tooltip: 'preview.copy.image',
|
||||||
|
onClick: expect.any(Function)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('copy functionality', () => {
|
||||||
|
it('should execute copy source behavior when copy-source tool is clicked', () => {
|
||||||
|
const mockOnCopySource = vi.fn()
|
||||||
|
const props = createMockProps({ onCopySource: mockOnCopySource })
|
||||||
|
renderHook(() => useCopyTool(props))
|
||||||
|
|
||||||
|
const copySourceTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
act(() => {
|
||||||
|
copySourceTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockOnCopySource).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetCopiedTemporarily).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute copy image behavior when copy-image tool is clicked', () => {
|
||||||
|
const mockPreviewHandles = createMockPreviewHandles()
|
||||||
|
const props = createMockProps({
|
||||||
|
showPreviewTools: true,
|
||||||
|
previewRef: { current: mockPreviewHandles }
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHook(() => useCopyTool(props))
|
||||||
|
|
||||||
|
// The copy-image tool is the second one registered
|
||||||
|
const copyImageTool = mockRegisterTool.mock.calls[1][0]
|
||||||
|
act(() => {
|
||||||
|
copyImageTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPreviewHandles.copy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetCopiedImageTemporarily).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
it('should remove both tools on unmount when both are registered', () => {
|
||||||
|
const props = createMockProps({
|
||||||
|
showPreviewTools: true,
|
||||||
|
previewRef: { current: createMockPreviewHandles() }
|
||||||
|
})
|
||||||
|
const { unmount } = renderHook(() => useCopyTool(props))
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledWith('copy')
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledWith('copy-image')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should attempt to remove both tools on unmount even if only one is registered', () => {
|
||||||
|
const props = createMockProps({ showPreviewTools: false })
|
||||||
|
const { unmount } = renderHook(() => useCopyTool(props))
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
// The cleanup function is static and always tries to remove both
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledWith('copy')
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledWith('copy-image')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle copy source failure gracefully', () => {
|
||||||
|
const mockOnCopySource = vi.fn().mockImplementation(() => {
|
||||||
|
throw new Error('Copy failed')
|
||||||
|
})
|
||||||
|
const props = createMockProps({ onCopySource: mockOnCopySource })
|
||||||
|
renderHook(() => useCopyTool(props))
|
||||||
|
|
||||||
|
const copySourceTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
copySourceTool.onClick()
|
||||||
|
})
|
||||||
|
}).toThrow('Copy failed')
|
||||||
|
|
||||||
|
expect(mockOnCopySource).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetCopiedTemporarily).toHaveBeenCalledWith(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle copy image failure gracefully', () => {
|
||||||
|
const mockPreviewHandles = createMockPreviewHandles()
|
||||||
|
mockPreviewHandles.copy = vi.fn().mockImplementation(() => {
|
||||||
|
throw new Error('Image copy failed')
|
||||||
|
})
|
||||||
|
const props = createMockProps({
|
||||||
|
showPreviewTools: true,
|
||||||
|
previewRef: { current: mockPreviewHandles }
|
||||||
|
})
|
||||||
|
renderHook(() => useCopyTool(props))
|
||||||
|
|
||||||
|
const copyImageTool = mockRegisterTool.mock.calls[1][0]
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
copyImageTool.onClick()
|
||||||
|
})
|
||||||
|
}).toThrow('Image copy failed')
|
||||||
|
|
||||||
|
expect(mockPreviewHandles.copy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetCopiedImageTemporarily).toHaveBeenCalledWith(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,348 @@
|
|||||||
|
import { useDownloadTool } from '@renderer/components/CodeToolbar/hooks/useDownloadTool'
|
||||||
|
import { BasicPreviewHandles } from '@renderer/components/Preview'
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
i18n: {
|
||||||
|
t: vi.fn((key: string) => key)
|
||||||
|
},
|
||||||
|
useToolManager: vi.fn(),
|
||||||
|
TOOL_SPECS: {
|
||||||
|
download: {
|
||||||
|
id: 'download',
|
||||||
|
type: 'core',
|
||||||
|
order: 10
|
||||||
|
},
|
||||||
|
'download-svg': {
|
||||||
|
id: 'download-svg',
|
||||||
|
type: 'quick',
|
||||||
|
order: 31
|
||||||
|
},
|
||||||
|
'download-png': {
|
||||||
|
id: 'download-png',
|
||||||
|
type: 'quick',
|
||||||
|
order: 32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: mocks.i18n.t
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Icons', () => ({
|
||||||
|
FilePngIcon: () => <div data-testid="file-png-icon" />,
|
||||||
|
FileSvgIcon: () => <div data-testid="file-svg-icon" />
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ActionTools', () => ({
|
||||||
|
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||||
|
useToolManager: mocks.useToolManager
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock useToolManager
|
||||||
|
const mockRegisterTool = vi.fn()
|
||||||
|
const mockRemoveTool = vi.fn()
|
||||||
|
mocks.useToolManager.mockImplementation(() => ({
|
||||||
|
registerTool: mockRegisterTool,
|
||||||
|
removeTool: mockRemoveTool
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useDownloadTool', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Note: mock implementations are already set in vi.hoisted() above
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper function to create mock props
|
||||||
|
const createMockProps = (overrides: Partial<Parameters<typeof useDownloadTool>[0]> = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
showPreviewTools: false,
|
||||||
|
previewRef: { current: null },
|
||||||
|
onDownloadSource: vi.fn(),
|
||||||
|
setTools: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...defaultProps, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create mock preview handles
|
||||||
|
const createMockPreviewHandles = (): BasicPreviewHandles => ({
|
||||||
|
pan: vi.fn(),
|
||||||
|
zoom: vi.fn(),
|
||||||
|
copy: vi.fn(),
|
||||||
|
download: vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper function for tool registration assertions
|
||||||
|
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||||
|
if (times > 0 && toolConfig) {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectNoChildren = () => {
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
expect(registeredTool).not.toHaveProperty('children')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tool registration', () => {
|
||||||
|
it('should register single download tool when showPreviewTools is false', () => {
|
||||||
|
const props = createMockProps({ showPreviewTools: false })
|
||||||
|
renderHook(() => useDownloadTool(props))
|
||||||
|
|
||||||
|
expect(mocks.useToolManager).toHaveBeenCalledWith(props.setTools)
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
id: 'download',
|
||||||
|
type: 'core',
|
||||||
|
order: 10,
|
||||||
|
tooltip: 'code_block.download.source',
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
icon: expect.any(Object)
|
||||||
|
})
|
||||||
|
expectNoChildren()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should register single download tool when showPreviewTools is true but previewRef.current is null', () => {
|
||||||
|
const props = createMockProps({ showPreviewTools: true, previewRef: { current: null } })
|
||||||
|
renderHook(() => useDownloadTool(props))
|
||||||
|
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
id: 'download',
|
||||||
|
type: 'core',
|
||||||
|
order: 10,
|
||||||
|
tooltip: 'code_block.download.source', // When previewRef.current is null, showPreviewTools is false
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
icon: expect.any(Object)
|
||||||
|
})
|
||||||
|
expectNoChildren()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should register download tool with children when showPreviewTools is true and previewRef.current is not null', () => {
|
||||||
|
const mockPreviewHandles = createMockPreviewHandles()
|
||||||
|
const props = createMockProps({
|
||||||
|
showPreviewTools: true,
|
||||||
|
previewRef: { current: mockPreviewHandles }
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHook(() => useDownloadTool(props))
|
||||||
|
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
id: 'download',
|
||||||
|
type: 'core',
|
||||||
|
order: 10,
|
||||||
|
tooltip: undefined,
|
||||||
|
icon: expect.any(Object),
|
||||||
|
children: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'download',
|
||||||
|
type: 'core',
|
||||||
|
order: 10,
|
||||||
|
tooltip: 'code_block.download.source',
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
icon: expect.any(Object)
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'download-svg',
|
||||||
|
type: 'quick',
|
||||||
|
order: 31,
|
||||||
|
tooltip: 'code_block.download.svg',
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
icon: expect.any(Object)
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'download-png',
|
||||||
|
type: 'quick',
|
||||||
|
order: 32,
|
||||||
|
tooltip: 'code_block.download.png',
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
icon: expect.any(Object)
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('download functionality', () => {
|
||||||
|
it('should execute download source behavior when tool is activated', () => {
|
||||||
|
const mockOnDownloadSource = vi.fn()
|
||||||
|
const props = createMockProps({ onDownloadSource: mockOnDownloadSource })
|
||||||
|
renderHook(() => useDownloadTool(props))
|
||||||
|
|
||||||
|
// Get the onClick handler from the registered tool
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockOnDownloadSource).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute download SVG behavior when SVG download tool is activated', () => {
|
||||||
|
const mockPreviewHandles = createMockPreviewHandles()
|
||||||
|
const props = createMockProps({
|
||||||
|
showPreviewTools: true,
|
||||||
|
previewRef: { current: mockPreviewHandles }
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHook(() => useDownloadTool(props))
|
||||||
|
|
||||||
|
// Get the download-svg child tool
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
const downloadSvgTool = registeredTool.children?.find((child: any) => child.tooltip === 'code_block.download.svg')
|
||||||
|
|
||||||
|
expect(downloadSvgTool).toBeDefined()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
downloadSvgTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPreviewHandles.download).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockPreviewHandles.download).toHaveBeenCalledWith('svg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute download PNG behavior when PNG download tool is activated', () => {
|
||||||
|
const mockPreviewHandles = createMockPreviewHandles()
|
||||||
|
const props = createMockProps({
|
||||||
|
showPreviewTools: true,
|
||||||
|
previewRef: { current: mockPreviewHandles }
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHook(() => useDownloadTool(props))
|
||||||
|
|
||||||
|
// Get the download-png child tool
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
const downloadPngTool = registeredTool.children?.find((child: any) => child.tooltip === 'code_block.download.png')
|
||||||
|
|
||||||
|
expect(downloadPngTool).toBeDefined()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
downloadPngTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPreviewHandles.download).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockPreviewHandles.download).toHaveBeenCalledWith('png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute download source behavior from child tool', () => {
|
||||||
|
const mockOnDownloadSource = vi.fn()
|
||||||
|
const props = createMockProps({
|
||||||
|
showPreviewTools: true,
|
||||||
|
onDownloadSource: mockOnDownloadSource,
|
||||||
|
previewRef: { current: createMockPreviewHandles() }
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHook(() => useDownloadTool(props))
|
||||||
|
|
||||||
|
// Get the download source child tool
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
const downloadSourceTool = registeredTool.children?.find(
|
||||||
|
(child: any) => child.tooltip === 'code_block.download.source'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(downloadSourceTool).toBeDefined()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
downloadSourceTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockOnDownloadSource).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
it('should remove tool on unmount', () => {
|
||||||
|
const props = createMockProps()
|
||||||
|
const { unmount } = renderHook(() => useDownloadTool(props))
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledWith('download')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle missing setTools gracefully', () => {
|
||||||
|
const props = createMockProps({ setTools: undefined })
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useDownloadTool(props))
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
// Should still call useToolManager (but won't actually register)
|
||||||
|
expect(mocks.useToolManager).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing previewRef.current gracefully', () => {
|
||||||
|
const props = createMockProps({
|
||||||
|
showPreviewTools: true,
|
||||||
|
previewRef: { current: null }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useDownloadTool(props))
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
// Should register single tool without children
|
||||||
|
expectToolRegistration(1)
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
expect(registeredTool).not.toHaveProperty('children')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle download source operation failures gracefully', () => {
|
||||||
|
const mockOnDownloadSource = vi.fn().mockImplementation(() => {
|
||||||
|
throw new Error('Download failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = createMockProps({ onDownloadSource: mockOnDownloadSource })
|
||||||
|
renderHook(() => useDownloadTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
|
||||||
|
// Errors should be propagated up
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
}).toThrow('Download failed')
|
||||||
|
|
||||||
|
// Callback should still be called
|
||||||
|
expect(mockOnDownloadSource).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle download image operation failures gracefully', () => {
|
||||||
|
const mockPreviewHandles = createMockPreviewHandles()
|
||||||
|
mockPreviewHandles.download = vi.fn().mockImplementation(() => {
|
||||||
|
throw new Error('Image download failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = createMockProps({
|
||||||
|
showPreviewTools: true,
|
||||||
|
previewRef: { current: mockPreviewHandles }
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHook(() => useDownloadTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
const downloadSvgTool = registeredTool.children?.find((child: any) => child.tooltip === 'code_block.download.svg')
|
||||||
|
|
||||||
|
expect(downloadSvgTool).toBeDefined()
|
||||||
|
|
||||||
|
// Errors should be propagated up
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
downloadSvgTool.onClick()
|
||||||
|
})
|
||||||
|
}).toThrow('Image download failed')
|
||||||
|
|
||||||
|
// Callback should still be called
|
||||||
|
expect(mockPreviewHandles.download).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockPreviewHandles.download).toHaveBeenCalledWith('svg')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
import { useExpandTool } from '@renderer/components/CodeToolbar/hooks/useExpandTool'
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
i18n: {
|
||||||
|
t: vi.fn((key: string) => key)
|
||||||
|
},
|
||||||
|
useToolManager: vi.fn(),
|
||||||
|
TOOL_SPECS: {
|
||||||
|
expand: {
|
||||||
|
id: 'expand',
|
||||||
|
type: 'quick',
|
||||||
|
order: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: mocks.i18n.t
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ActionTools', () => ({
|
||||||
|
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||||
|
useToolManager: mocks.useToolManager
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock useToolManager
|
||||||
|
const mockRegisterTool = vi.fn()
|
||||||
|
const mockRemoveTool = vi.fn()
|
||||||
|
mocks.useToolManager.mockImplementation(() => ({
|
||||||
|
registerTool: mockRegisterTool,
|
||||||
|
removeTool: mockRemoveTool
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
ChevronsDownUp: () => <div data-testid="chevrons-down-up" />,
|
||||||
|
ChevronsUpDown: () => <div data-testid="chevrons-up-down" />
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useExpandTool', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper function to create mock props
|
||||||
|
const createMockProps = (overrides: Partial<Parameters<typeof useExpandTool>[0]> = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
enabled: true,
|
||||||
|
expanded: false,
|
||||||
|
expandable: true,
|
||||||
|
toggle: vi.fn(),
|
||||||
|
setTools: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...defaultProps, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for tool registration assertions
|
||||||
|
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||||
|
if (times > 0 && toolConfig) {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tool registration', () => {
|
||||||
|
it('should register expand tool when enabled', () => {
|
||||||
|
const props = createMockProps({ enabled: true })
|
||||||
|
renderHook(() => useExpandTool(props))
|
||||||
|
|
||||||
|
expect(mocks.useToolManager).toHaveBeenCalledWith(props.setTools)
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
id: 'expand',
|
||||||
|
type: 'quick',
|
||||||
|
order: 12,
|
||||||
|
tooltip: 'code_block.expand',
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
visible: expect.any(Function)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not register tool when disabled', () => {
|
||||||
|
const props = createMockProps({ enabled: false })
|
||||||
|
renderHook(() => useExpandTool(props))
|
||||||
|
|
||||||
|
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should re-register tool when expanded changes', () => {
|
||||||
|
const props = createMockProps({ expanded: false })
|
||||||
|
const { rerender } = renderHook((hookProps) => useExpandTool(hookProps), {
|
||||||
|
initialProps: props
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||||
|
const firstCall = mockRegisterTool.mock.calls[0][0]
|
||||||
|
expect(firstCall.tooltip).toBe('code_block.expand')
|
||||||
|
|
||||||
|
// Change expanded to true and rerender
|
||||||
|
const newProps = { ...props, expanded: true }
|
||||||
|
rerender(newProps)
|
||||||
|
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||||
|
const secondCall = mockRegisterTool.mock.calls[1][0]
|
||||||
|
expect(secondCall.tooltip).toBe('code_block.collapse')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('visibility behavior', () => {
|
||||||
|
it('should be visible when expandable is true', () => {
|
||||||
|
const props = createMockProps({ expandable: true })
|
||||||
|
renderHook(() => useExpandTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
expect(registeredTool.visible()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not be visible when expandable is false', () => {
|
||||||
|
const props = createMockProps({ expandable: false })
|
||||||
|
renderHook(() => useExpandTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
expect(registeredTool.visible()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not be visible when expandable is undefined', () => {
|
||||||
|
const props = createMockProps({ expandable: undefined })
|
||||||
|
renderHook(() => useExpandTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
expect(registeredTool.visible()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toggle functionality', () => {
|
||||||
|
it('should execute toggle function when tool is clicked', () => {
|
||||||
|
const mockToggle = vi.fn()
|
||||||
|
const props = createMockProps({ toggle: mockToggle })
|
||||||
|
renderHook(() => useExpandTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockToggle).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
it('should remove tool on unmount', () => {
|
||||||
|
const props = createMockProps()
|
||||||
|
const { unmount } = renderHook(() => useExpandTool(props))
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledWith('expand')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle missing setTools gracefully', () => {
|
||||||
|
const props = createMockProps({ setTools: undefined })
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useExpandTool(props))
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
// Should still call useToolManager (but won't actually register)
|
||||||
|
expect(mocks.useToolManager).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not break when toggle is undefined', () => {
|
||||||
|
const props = createMockProps({ toggle: undefined })
|
||||||
|
renderHook(() => useExpandTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
import { useRunTool } from '@renderer/components/CodeToolbar/hooks/useRunTool'
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
i18n: {
|
||||||
|
t: vi.fn((key: string) => key)
|
||||||
|
},
|
||||||
|
useToolManager: vi.fn(),
|
||||||
|
TOOL_SPECS: {
|
||||||
|
run: {
|
||||||
|
id: 'run',
|
||||||
|
type: 'quick',
|
||||||
|
order: 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: mocks.i18n.t
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
CirclePlay: () => <div>CirclePlay</div>
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Icons', () => ({
|
||||||
|
LoadingIcon: () => <div>Loading</div>
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ActionTools', () => ({
|
||||||
|
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||||
|
useToolManager: mocks.useToolManager
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockRegisterTool = vi.fn()
|
||||||
|
const mockRemoveTool = vi.fn()
|
||||||
|
mocks.useToolManager.mockImplementation(() => ({
|
||||||
|
registerTool: mockRegisterTool,
|
||||||
|
removeTool: mockRemoveTool
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useRunTool', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMockProps = (overrides: Partial<Parameters<typeof useRunTool>[0]> = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
enabled: true,
|
||||||
|
isRunning: false,
|
||||||
|
onRun: vi.fn(),
|
||||||
|
setTools: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...defaultProps, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||||
|
if (times > 0 && toolConfig) {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tool registration', () => {
|
||||||
|
it('should not register tool when disabled', () => {
|
||||||
|
const props = createMockProps({ enabled: false })
|
||||||
|
renderHook(() => useRunTool(props))
|
||||||
|
|
||||||
|
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should register run tool when enabled', () => {
|
||||||
|
const props = createMockProps({ enabled: true })
|
||||||
|
renderHook(() => useRunTool(props))
|
||||||
|
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
id: 'run',
|
||||||
|
type: 'quick',
|
||||||
|
order: 11,
|
||||||
|
tooltip: 'code_block.run'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should re-register tool when isRunning changes', () => {
|
||||||
|
const props = createMockProps({ isRunning: false })
|
||||||
|
const { rerender } = renderHook((hookProps) => useRunTool(hookProps), {
|
||||||
|
initialProps: props
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const newProps = { ...props, isRunning: true }
|
||||||
|
rerender(newProps)
|
||||||
|
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('run functionality', () => {
|
||||||
|
it('should execute onRun when tool is clicked and not running', () => {
|
||||||
|
const mockOnRun = vi.fn()
|
||||||
|
const props = createMockProps({ onRun: mockOnRun, isRunning: false })
|
||||||
|
renderHook(() => useRunTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockOnRun).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not execute onRun when tool is clicked and already running', () => {
|
||||||
|
const mockOnRun = vi.fn()
|
||||||
|
const props = createMockProps({ onRun: mockOnRun, isRunning: true })
|
||||||
|
renderHook(() => useRunTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockOnRun).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
it('should remove tool on unmount', () => {
|
||||||
|
const props = createMockProps()
|
||||||
|
const { unmount } = renderHook(() => useRunTool(props))
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledWith('run')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle missing setTools gracefully', () => {
|
||||||
|
const props = createMockProps({ setTools: undefined })
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useRunTool(props))
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not break when onRun is undefined', () => {
|
||||||
|
const props = createMockProps({ onRun: undefined })
|
||||||
|
renderHook(() => useRunTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,193 @@
|
|||||||
|
import { useSaveTool } from '@renderer/components/CodeToolbar/hooks/useSaveTool'
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
i18n: {
|
||||||
|
t: vi.fn((key: string) => key)
|
||||||
|
},
|
||||||
|
useToolManager: vi.fn(),
|
||||||
|
useTemporaryValue: vi.fn(),
|
||||||
|
TOOL_SPECS: {
|
||||||
|
save: {
|
||||||
|
id: 'save',
|
||||||
|
type: 'core',
|
||||||
|
order: 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: mocks.i18n.t
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ActionTools', () => ({
|
||||||
|
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||||
|
useToolManager: mocks.useToolManager
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock useTemporaryValue
|
||||||
|
const mockSetTemporaryValue = vi.fn()
|
||||||
|
mocks.useTemporaryValue.mockImplementation(() => [false, mockSetTemporaryValue])
|
||||||
|
|
||||||
|
vi.mock('@renderer/hooks/useTemporaryValue', () => ({
|
||||||
|
useTemporaryValue: mocks.useTemporaryValue
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock useToolManager
|
||||||
|
const mockRegisterTool = vi.fn()
|
||||||
|
const mockRemoveTool = vi.fn()
|
||||||
|
mocks.useToolManager.mockImplementation(() => ({
|
||||||
|
registerTool: mockRegisterTool,
|
||||||
|
removeTool: mockRemoveTool
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
Check: () => <div data-testid="check-icon" />,
|
||||||
|
SaveIcon: () => <div data-testid="save-icon" />
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useSaveTool', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Reset to default values
|
||||||
|
mocks.useTemporaryValue.mockImplementation(() => [false, mockSetTemporaryValue])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper function to create mock props
|
||||||
|
const createMockProps = (overrides: Partial<Parameters<typeof useSaveTool>[0]> = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
enabled: true,
|
||||||
|
sourceViewRef: { current: null },
|
||||||
|
setTools: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...defaultProps, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for tool registration assertions
|
||||||
|
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||||
|
if (times > 0 && toolConfig) {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tool registration', () => {
|
||||||
|
it('should register save tool when enabled', () => {
|
||||||
|
const props = createMockProps({ enabled: true })
|
||||||
|
renderHook(() => useSaveTool(props))
|
||||||
|
|
||||||
|
expect(mocks.useToolManager).toHaveBeenCalledWith(props.setTools)
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
id: 'save',
|
||||||
|
type: 'core',
|
||||||
|
order: 14,
|
||||||
|
tooltip: 'code_block.edit.save.label',
|
||||||
|
onClick: expect.any(Function)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not register tool when disabled', () => {
|
||||||
|
const props = createMockProps({ enabled: false })
|
||||||
|
renderHook(() => useSaveTool(props))
|
||||||
|
|
||||||
|
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should re-register tool when saved state changes', () => {
|
||||||
|
// Initially not saved
|
||||||
|
mocks.useTemporaryValue.mockImplementation(() => [false, mockSetTemporaryValue])
|
||||||
|
const props = createMockProps()
|
||||||
|
const { rerender } = renderHook(() => useSaveTool(props))
|
||||||
|
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Change to saved state and rerender
|
||||||
|
mocks.useTemporaryValue.mockImplementation(() => [true, mockSetTemporaryValue])
|
||||||
|
rerender()
|
||||||
|
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('save functionality', () => {
|
||||||
|
it('should execute save behavior when tool is clicked', () => {
|
||||||
|
const mockSave = vi.fn()
|
||||||
|
const mockEditorHandles = { save: mockSave }
|
||||||
|
const props = createMockProps({
|
||||||
|
sourceViewRef: { current: mockEditorHandles }
|
||||||
|
})
|
||||||
|
renderHook(() => useSaveTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockSave).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetTemporaryValue).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle when sourceViewRef.current is null', () => {
|
||||||
|
const props = createMockProps({
|
||||||
|
sourceViewRef: { current: null }
|
||||||
|
})
|
||||||
|
renderHook(() => useSaveTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
expect(mockSetTemporaryValue).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle when sourceViewRef.current.save is undefined', () => {
|
||||||
|
const props = createMockProps({
|
||||||
|
sourceViewRef: { current: {} }
|
||||||
|
})
|
||||||
|
renderHook(() => useSaveTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
expect(mockSetTemporaryValue).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
it('should remove tool on unmount', () => {
|
||||||
|
const props = createMockProps()
|
||||||
|
const { unmount } = renderHook(() => useSaveTool(props))
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledWith('save')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle missing setTools gracefully', () => {
|
||||||
|
const props = createMockProps({ setTools: undefined })
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useSaveTool(props))
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
// Should still call useToolManager (but won't actually register)
|
||||||
|
expect(mocks.useToolManager).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,180 @@
|
|||||||
|
import { ViewMode } from '@renderer/components/CodeBlockView/types'
|
||||||
|
import { useSplitViewTool } from '@renderer/components/CodeToolbar/hooks/useSplitViewTool'
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
i18n: {
|
||||||
|
t: vi.fn((key: string) => key)
|
||||||
|
},
|
||||||
|
useToolManager: vi.fn(),
|
||||||
|
TOOL_SPECS: {
|
||||||
|
'split-view': {
|
||||||
|
id: 'split-view',
|
||||||
|
type: 'quick',
|
||||||
|
order: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: mocks.i18n.t
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ActionTools', () => ({
|
||||||
|
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||||
|
useToolManager: mocks.useToolManager
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock useToolManager
|
||||||
|
const mockRegisterTool = vi.fn()
|
||||||
|
const mockRemoveTool = vi.fn()
|
||||||
|
mocks.useToolManager.mockImplementation(() => ({
|
||||||
|
registerTool: mockRegisterTool,
|
||||||
|
removeTool: mockRemoveTool
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useSplitViewTool', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper function to create mock props
|
||||||
|
const createMockProps = (overrides: Partial<Parameters<typeof useSplitViewTool>[0]> = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
enabled: true,
|
||||||
|
viewMode: 'special' as ViewMode,
|
||||||
|
onToggleSplitView: vi.fn(),
|
||||||
|
setTools: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...defaultProps, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for tool registration assertions
|
||||||
|
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||||
|
if (times > 0 && toolConfig) {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tool registration', () => {
|
||||||
|
it('should not register tool when disabled', () => {
|
||||||
|
const props = createMockProps({ enabled: false })
|
||||||
|
renderHook(() => useSplitViewTool(props))
|
||||||
|
|
||||||
|
expect(mocks.useToolManager).toHaveBeenCalledWith(props.setTools)
|
||||||
|
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should register split view tool when enabled', () => {
|
||||||
|
const props = createMockProps({ enabled: true })
|
||||||
|
renderHook(() => useSplitViewTool(props))
|
||||||
|
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
id: 'split-view',
|
||||||
|
type: 'quick',
|
||||||
|
order: 10,
|
||||||
|
tooltip: 'code_block.split.label',
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
icon: expect.any(Object)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show different tooltip when in split mode', () => {
|
||||||
|
const props = createMockProps({ viewMode: 'split' })
|
||||||
|
renderHook(() => useSplitViewTool(props))
|
||||||
|
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
tooltip: 'code_block.split.restore'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show different tooltip when not in split mode', () => {
|
||||||
|
const props = createMockProps({ viewMode: 'special' })
|
||||||
|
renderHook(() => useSplitViewTool(props))
|
||||||
|
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
tooltip: 'code_block.split.label'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should re-register tool when viewMode changes', () => {
|
||||||
|
const props = createMockProps({ viewMode: 'special' })
|
||||||
|
const { rerender } = renderHook((hookProps) => useSplitViewTool(hookProps), {
|
||||||
|
initialProps: props
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Change viewMode and rerender
|
||||||
|
const newProps = { ...props, viewMode: 'split' as ViewMode }
|
||||||
|
rerender(newProps)
|
||||||
|
|
||||||
|
// Should register tool again with updated state
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
// Verify the new registration has correct tooltip
|
||||||
|
const secondRegistration = mockRegisterTool.mock.calls[1][0]
|
||||||
|
expect(secondRegistration.tooltip).toBe('code_block.split.restore')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('view mode switching', () => {
|
||||||
|
it('should call onToggleSplitView when tool is clicked', () => {
|
||||||
|
const mockOnToggleSplitView = vi.fn()
|
||||||
|
const props = createMockProps({
|
||||||
|
onToggleSplitView: mockOnToggleSplitView
|
||||||
|
})
|
||||||
|
renderHook(() => useSplitViewTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockOnToggleSplitView).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
it('should remove tool on unmount', () => {
|
||||||
|
const props = createMockProps()
|
||||||
|
const { unmount } = renderHook(() => useSplitViewTool(props))
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledWith('split-view')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle missing setTools gracefully', () => {
|
||||||
|
const props = createMockProps({ setTools: undefined })
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useSplitViewTool(props))
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
// Should still call useToolManager (but won't actually register)
|
||||||
|
expect(mocks.useToolManager).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not break when onToggleSplitView is undefined', () => {
|
||||||
|
const props = createMockProps({ onToggleSplitView: undefined })
|
||||||
|
renderHook(() => useSplitViewTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,226 @@
|
|||||||
|
import { ViewMode } from '@renderer/components/CodeBlockView/types'
|
||||||
|
import { useViewSourceTool } from '@renderer/components/CodeToolbar/hooks/useViewSourceTool'
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
i18n: {
|
||||||
|
t: vi.fn((key: string) => key)
|
||||||
|
},
|
||||||
|
useToolManager: vi.fn(),
|
||||||
|
TOOL_SPECS: {
|
||||||
|
edit: {
|
||||||
|
id: 'edit',
|
||||||
|
type: 'core',
|
||||||
|
order: 12
|
||||||
|
},
|
||||||
|
'view-source': {
|
||||||
|
id: 'view-source',
|
||||||
|
type: 'core',
|
||||||
|
order: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: mocks.i18n.t
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ActionTools', () => ({
|
||||||
|
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||||
|
useToolManager: mocks.useToolManager
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockRegisterTool = vi.fn()
|
||||||
|
const mockRemoveTool = vi.fn()
|
||||||
|
mocks.useToolManager.mockImplementation(() => ({
|
||||||
|
registerTool: mockRegisterTool,
|
||||||
|
removeTool: mockRemoveTool
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useViewSourceTool', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMockProps = (overrides: Partial<Parameters<typeof useViewSourceTool>[0]> = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
enabled: true,
|
||||||
|
editable: false,
|
||||||
|
viewMode: 'special' as ViewMode,
|
||||||
|
onViewModeChange: vi.fn(),
|
||||||
|
setTools: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...defaultProps, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||||
|
if (times > 0 && toolConfig) {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tool registration', () => {
|
||||||
|
it('should not register tool when disabled', () => {
|
||||||
|
const props = createMockProps({ enabled: false })
|
||||||
|
renderHook(() => useViewSourceTool(props))
|
||||||
|
|
||||||
|
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not register tool when in split mode', () => {
|
||||||
|
const props = createMockProps({ viewMode: 'split' })
|
||||||
|
renderHook(() => useViewSourceTool(props))
|
||||||
|
|
||||||
|
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should register view-source tool when not editable', () => {
|
||||||
|
const props = createMockProps({ editable: false })
|
||||||
|
renderHook(() => useViewSourceTool(props))
|
||||||
|
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
id: 'view-source',
|
||||||
|
type: 'core',
|
||||||
|
order: 12
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should register edit tool when editable', () => {
|
||||||
|
const props = createMockProps({ editable: true })
|
||||||
|
renderHook(() => useViewSourceTool(props))
|
||||||
|
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
id: 'edit',
|
||||||
|
type: 'core',
|
||||||
|
order: 12
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should re-register tool when editable changes', () => {
|
||||||
|
const props = createMockProps({ editable: false })
|
||||||
|
const { rerender } = renderHook((hookProps) => useViewSourceTool(hookProps), {
|
||||||
|
initialProps: props
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const newProps = { ...props, editable: true }
|
||||||
|
rerender(newProps)
|
||||||
|
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledWith('view-source')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tooltip variations', () => {
|
||||||
|
it('should show correct tooltips for edit mode', () => {
|
||||||
|
const props = createMockProps({ editable: true, viewMode: 'source' })
|
||||||
|
renderHook(() => useViewSourceTool(props))
|
||||||
|
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
tooltip: 'preview.label'
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
const propsSpecial = createMockProps({ editable: true, viewMode: 'special' })
|
||||||
|
renderHook(() => useViewSourceTool(propsSpecial))
|
||||||
|
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
tooltip: 'code_block.edit.label'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show correct tooltips for view-source mode', () => {
|
||||||
|
const props = createMockProps({ editable: false, viewMode: 'source' })
|
||||||
|
renderHook(() => useViewSourceTool(props))
|
||||||
|
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
tooltip: 'preview.label'
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
const propsSpecial = createMockProps({ editable: false, viewMode: 'special' })
|
||||||
|
renderHook(() => useViewSourceTool(propsSpecial))
|
||||||
|
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
tooltip: 'preview.source'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('view mode switching', () => {
|
||||||
|
it('should switch from special to source when tool is clicked', () => {
|
||||||
|
const mockOnViewModeChange = vi.fn()
|
||||||
|
const props = createMockProps({
|
||||||
|
viewMode: 'special',
|
||||||
|
onViewModeChange: mockOnViewModeChange
|
||||||
|
})
|
||||||
|
renderHook(() => useViewSourceTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockOnViewModeChange).toHaveBeenCalledWith('source')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should switch from source to special when tool is clicked', () => {
|
||||||
|
const mockOnViewModeChange = vi.fn()
|
||||||
|
const props = createMockProps({
|
||||||
|
viewMode: 'source',
|
||||||
|
onViewModeChange: mockOnViewModeChange
|
||||||
|
})
|
||||||
|
renderHook(() => useViewSourceTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockOnViewModeChange).toHaveBeenCalledWith('special')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
it('should remove tool on unmount', () => {
|
||||||
|
const props = createMockProps()
|
||||||
|
const { unmount } = renderHook(() => useViewSourceTool(props))
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledWith('view-source')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle missing setTools gracefully', () => {
|
||||||
|
const props = createMockProps({ setTools: undefined })
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useViewSourceTool(props))
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not break when onViewModeChange is undefined', () => {
|
||||||
|
const props = createMockProps({ onViewModeChange: undefined })
|
||||||
|
renderHook(() => useViewSourceTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
import { useWrapTool } from '@renderer/components/CodeToolbar/hooks/useWrapTool'
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
i18n: {
|
||||||
|
t: vi.fn((key: string) => key)
|
||||||
|
},
|
||||||
|
useToolManager: vi.fn(),
|
||||||
|
TOOL_SPECS: {
|
||||||
|
wrap: {
|
||||||
|
id: 'wrap',
|
||||||
|
type: 'quick',
|
||||||
|
order: 13
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: mocks.i18n.t
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ActionTools', () => ({
|
||||||
|
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||||
|
useToolManager: mocks.useToolManager
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock useToolManager
|
||||||
|
const mockRegisterTool = vi.fn()
|
||||||
|
const mockRemoveTool = vi.fn()
|
||||||
|
mocks.useToolManager.mockImplementation(() => ({
|
||||||
|
registerTool: mockRegisterTool,
|
||||||
|
removeTool: mockRemoveTool
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
Text: () => <div data-testid="text-icon" />,
|
||||||
|
WrapText: () => <div data-testid="wrap-text-icon" />
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useWrapTool', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper function to create mock props
|
||||||
|
const createMockProps = (overrides: Partial<Parameters<typeof useWrapTool>[0]> = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
enabled: true,
|
||||||
|
unwrapped: false,
|
||||||
|
wrappable: true,
|
||||||
|
toggle: vi.fn(),
|
||||||
|
setTools: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...defaultProps, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for tool registration assertions
|
||||||
|
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||||
|
if (times > 0 && toolConfig) {
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tool registration', () => {
|
||||||
|
it('should register wrap tool when enabled', () => {
|
||||||
|
const props = createMockProps({ enabled: true })
|
||||||
|
renderHook(() => useWrapTool(props))
|
||||||
|
|
||||||
|
expect(mocks.useToolManager).toHaveBeenCalledWith(props.setTools)
|
||||||
|
expectToolRegistration(1, {
|
||||||
|
id: 'wrap',
|
||||||
|
type: 'quick',
|
||||||
|
order: 13,
|
||||||
|
tooltip: 'code_block.wrap.off',
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
visible: expect.any(Function)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not register tool when disabled', () => {
|
||||||
|
const props = createMockProps({ enabled: false })
|
||||||
|
renderHook(() => useWrapTool(props))
|
||||||
|
|
||||||
|
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should re-register tool when unwrapped changes', () => {
|
||||||
|
const props = createMockProps({ unwrapped: false })
|
||||||
|
const { rerender } = renderHook((hookProps) => useWrapTool(hookProps), {
|
||||||
|
initialProps: props
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||||
|
const firstCall = mockRegisterTool.mock.calls[0][0]
|
||||||
|
expect(firstCall.tooltip).toBe('code_block.wrap.off')
|
||||||
|
|
||||||
|
// Change unwrapped to true and rerender
|
||||||
|
const newProps = { ...props, unwrapped: true }
|
||||||
|
rerender(newProps)
|
||||||
|
|
||||||
|
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||||
|
const secondCall = mockRegisterTool.mock.calls[1][0]
|
||||||
|
expect(secondCall.tooltip).toBe('code_block.wrap.on')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('visibility behavior', () => {
|
||||||
|
it('should be visible when wrappable is true', () => {
|
||||||
|
const props = createMockProps({ wrappable: true })
|
||||||
|
renderHook(() => useWrapTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
expect(registeredTool.visible()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not be visible when wrappable is false', () => {
|
||||||
|
const props = createMockProps({ wrappable: false })
|
||||||
|
renderHook(() => useWrapTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
expect(registeredTool.visible()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not be visible when wrappable is undefined', () => {
|
||||||
|
const props = createMockProps({ wrappable: undefined })
|
||||||
|
renderHook(() => useWrapTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
expect(registeredTool.visible()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toggle functionality', () => {
|
||||||
|
it('should execute toggle function when tool is clicked', () => {
|
||||||
|
const mockToggle = vi.fn()
|
||||||
|
const props = createMockProps({ toggle: mockToggle })
|
||||||
|
renderHook(() => useWrapTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockToggle).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
it('should remove tool on unmount', () => {
|
||||||
|
const props = createMockProps()
|
||||||
|
const { unmount } = renderHook(() => useWrapTool(props))
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(mockRemoveTool).toHaveBeenCalledWith('wrap')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle missing setTools gracefully', () => {
|
||||||
|
const props = createMockProps({ setTools: undefined })
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useWrapTool(props))
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
// Should still call useToolManager (but won't actually register)
|
||||||
|
expect(mocks.useToolManager).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not break when toggle is undefined', () => {
|
||||||
|
const props = createMockProps({ toggle: undefined })
|
||||||
|
renderHook(() => useWrapTool(props))
|
||||||
|
|
||||||
|
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
registeredTool.onClick()
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
41
src/renderer/src/components/CodeToolbar/button.tsx
Normal file
41
src/renderer/src/components/CodeToolbar/button.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { ActionTool } from '@renderer/components/ActionTools'
|
||||||
|
import { Dropdown, Tooltip } from 'antd'
|
||||||
|
import { memo, useMemo } from 'react'
|
||||||
|
|
||||||
|
import { ToolWrapper } from './styles'
|
||||||
|
|
||||||
|
interface CodeToolButtonProps {
|
||||||
|
tool: ActionTool
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeToolButton = ({ tool }: CodeToolButtonProps) => {
|
||||||
|
const mainTool = useMemo(
|
||||||
|
() => (
|
||||||
|
<Tooltip key={tool.id} title={tool.tooltip} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||||
|
<ToolWrapper onClick={tool.onClick}>{tool.icon}</ToolWrapper>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
[tool]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tool.children?.length && tool.children.length > 0) {
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: tool.children.map((child) => ({
|
||||||
|
key: child.id,
|
||||||
|
label: child.tooltip,
|
||||||
|
icon: child.icon,
|
||||||
|
onClick: child.onClick
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
trigger={['click']}>
|
||||||
|
{mainTool}
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mainTool
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(CodeToolButton)
|
||||||
8
src/renderer/src/components/CodeToolbar/hooks/index.ts
Normal file
8
src/renderer/src/components/CodeToolbar/hooks/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export * from './useCopyTool'
|
||||||
|
export * from './useDownloadTool'
|
||||||
|
export * from './useExpandTool'
|
||||||
|
export * from './useRunTool'
|
||||||
|
export * from './useSaveTool'
|
||||||
|
export * from './useSplitViewTool'
|
||||||
|
export * from './useViewSourceTool'
|
||||||
|
export * from './useWrapTool'
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||||
|
import { CopyIcon } from '@renderer/components/Icons'
|
||||||
|
import { BasicPreviewHandles } from '@renderer/components/Preview'
|
||||||
|
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||||
|
import { Check, Image } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface UseCopyToolProps {
|
||||||
|
showPreviewTools?: boolean
|
||||||
|
previewRef: React.RefObject<BasicPreviewHandles | null>
|
||||||
|
onCopySource: () => void
|
||||||
|
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCopyTool = ({ showPreviewTools, previewRef, onCopySource, setTools }: UseCopyToolProps) => {
|
||||||
|
const [copied, setCopiedTemporarily] = useTemporaryValue(false)
|
||||||
|
const [copiedImage, setCopiedImageTemporarily] = useTemporaryValue(false)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { registerTool, removeTool } = useToolManager(setTools)
|
||||||
|
|
||||||
|
const handleCopySource = useCallback(() => {
|
||||||
|
try {
|
||||||
|
onCopySource()
|
||||||
|
setCopiedTemporarily(true)
|
||||||
|
} catch (error) {
|
||||||
|
setCopiedTemporarily(false)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}, [onCopySource, setCopiedTemporarily])
|
||||||
|
|
||||||
|
const handleCopyImage = useCallback(() => {
|
||||||
|
try {
|
||||||
|
previewRef.current?.copy()
|
||||||
|
setCopiedImageTemporarily(true)
|
||||||
|
} catch (error) {
|
||||||
|
setCopiedImageTemporarily(false)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}, [previewRef, setCopiedImageTemporarily])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const includePreviewTools = showPreviewTools && previewRef.current !== null
|
||||||
|
|
||||||
|
const baseTool = {
|
||||||
|
...TOOL_SPECS.copy,
|
||||||
|
icon: copied ? (
|
||||||
|
<Check className="tool-icon" color="var(--color-status-success)" />
|
||||||
|
) : (
|
||||||
|
<CopyIcon className="tool-icon" />
|
||||||
|
),
|
||||||
|
tooltip: t('code_block.copy.source'),
|
||||||
|
onClick: handleCopySource
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyImageTool = {
|
||||||
|
...TOOL_SPECS['copy-image'],
|
||||||
|
icon: copiedImage ? (
|
||||||
|
<Check className="tool-icon" color="var(--color-status-success)" />
|
||||||
|
) : (
|
||||||
|
<Image className="tool-icon" />
|
||||||
|
),
|
||||||
|
tooltip: t('preview.copy.image'),
|
||||||
|
onClick: handleCopyImage
|
||||||
|
}
|
||||||
|
|
||||||
|
registerTool(baseTool)
|
||||||
|
|
||||||
|
if (includePreviewTools) {
|
||||||
|
registerTool(copyImageTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeTool(TOOL_SPECS.copy.id)
|
||||||
|
removeTool(TOOL_SPECS['copy-image'].id)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
onCopySource,
|
||||||
|
registerTool,
|
||||||
|
removeTool,
|
||||||
|
t,
|
||||||
|
copied,
|
||||||
|
copiedImage,
|
||||||
|
handleCopySource,
|
||||||
|
handleCopyImage,
|
||||||
|
showPreviewTools,
|
||||||
|
previewRef
|
||||||
|
])
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||||
|
import { FilePngIcon, FileSvgIcon } from '@renderer/components/Icons'
|
||||||
|
import { BasicPreviewHandles } from '@renderer/components/Preview'
|
||||||
|
import { Download, FileCode } from 'lucide-react'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface UseDownloadToolProps {
|
||||||
|
showPreviewTools?: boolean
|
||||||
|
previewRef: React.RefObject<BasicPreviewHandles | null>
|
||||||
|
onDownloadSource: () => void
|
||||||
|
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDownloadTool = ({ showPreviewTools, previewRef, onDownloadSource, setTools }: UseDownloadToolProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { registerTool, removeTool } = useToolManager(setTools)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const includePreviewTools = showPreviewTools && previewRef.current !== null
|
||||||
|
|
||||||
|
const baseTool = {
|
||||||
|
...TOOL_SPECS.download,
|
||||||
|
icon: <Download className="tool-icon" />,
|
||||||
|
tooltip: includePreviewTools ? undefined : t('code_block.download.source')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includePreviewTools) {
|
||||||
|
registerTool({
|
||||||
|
...baseTool,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
...TOOL_SPECS.download,
|
||||||
|
icon: <FileCode size={'1rem'} />,
|
||||||
|
tooltip: t('code_block.download.source'),
|
||||||
|
onClick: onDownloadSource
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...TOOL_SPECS['download-svg'],
|
||||||
|
icon: <FileSvgIcon size={'1rem'} className="lucide" />,
|
||||||
|
tooltip: t('code_block.download.svg'),
|
||||||
|
onClick: () => previewRef.current?.download('svg')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...TOOL_SPECS['download-png'],
|
||||||
|
icon: <FilePngIcon size={'1rem'} className="lucide" />,
|
||||||
|
tooltip: t('code_block.download.png'),
|
||||||
|
onClick: () => previewRef.current?.download('png')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
registerTool({
|
||||||
|
...baseTool,
|
||||||
|
onClick: onDownloadSource
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => removeTool(TOOL_SPECS.download.id)
|
||||||
|
}, [onDownloadSource, registerTool, removeTool, t, showPreviewTools, previewRef])
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||||
|
import { ChevronsDownUp, ChevronsUpDown } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface UseExpandToolProps {
|
||||||
|
enabled?: boolean
|
||||||
|
expanded?: boolean
|
||||||
|
expandable?: boolean
|
||||||
|
toggle: () => void
|
||||||
|
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useExpandTool = ({ enabled, expanded, expandable, toggle, setTools }: UseExpandToolProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { registerTool, removeTool } = useToolManager(setTools)
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
toggle?.()
|
||||||
|
}, [toggle])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) {
|
||||||
|
registerTool({
|
||||||
|
...TOOL_SPECS.expand,
|
||||||
|
icon: expanded ? <ChevronsDownUp className="tool-icon" /> : <ChevronsUpDown className="tool-icon" />,
|
||||||
|
tooltip: expanded ? t('code_block.collapse') : t('code_block.expand'),
|
||||||
|
visible: () => expandable ?? false,
|
||||||
|
onClick: handleToggle
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => removeTool(TOOL_SPECS.expand.id)
|
||||||
|
}, [enabled, expandable, expanded, handleToggle, registerTool, removeTool, t])
|
||||||
|
}
|
||||||
30
src/renderer/src/components/CodeToolbar/hooks/useRunTool.tsx
Normal file
30
src/renderer/src/components/CodeToolbar/hooks/useRunTool.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||||
|
import { LoadingIcon } from '@renderer/components/Icons'
|
||||||
|
import { CirclePlay } from 'lucide-react'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface UseRunToolProps {
|
||||||
|
enabled: boolean
|
||||||
|
isRunning: boolean
|
||||||
|
onRun: () => void
|
||||||
|
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRunTool = ({ enabled, isRunning, onRun, setTools }: UseRunToolProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { registerTool, removeTool } = useToolManager(setTools)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
registerTool({
|
||||||
|
...TOOL_SPECS.run,
|
||||||
|
icon: isRunning ? <LoadingIcon className="tool-icon" /> : <CirclePlay className="tool-icon" />,
|
||||||
|
tooltip: t('code_block.run'),
|
||||||
|
onClick: () => !isRunning && onRun?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => removeTool(TOOL_SPECS.run.id)
|
||||||
|
}, [enabled, isRunning, onRun, registerTool, removeTool, t])
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||||
|
import { CodeEditorHandles } from '@renderer/components/CodeEditor'
|
||||||
|
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||||
|
import { Check, SaveIcon } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface UseSaveToolProps {
|
||||||
|
enabled?: boolean
|
||||||
|
sourceViewRef: React.RefObject<CodeEditorHandles | null>
|
||||||
|
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSaveTool = ({ enabled, sourceViewRef, setTools }: UseSaveToolProps) => {
|
||||||
|
const [saved, setSavedTemporarily] = useTemporaryValue(false)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { registerTool, removeTool } = useToolManager(setTools)
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
sourceViewRef.current?.save?.()
|
||||||
|
setSavedTemporarily(true)
|
||||||
|
}, [sourceViewRef, setSavedTemporarily])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) {
|
||||||
|
registerTool({
|
||||||
|
...TOOL_SPECS.save,
|
||||||
|
icon: saved ? (
|
||||||
|
<Check className="tool-icon" color="var(--color-status-success)" />
|
||||||
|
) : (
|
||||||
|
<SaveIcon className="tool-icon" />
|
||||||
|
),
|
||||||
|
tooltip: t('code_block.edit.save.label'),
|
||||||
|
onClick: handleSave
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => removeTool(TOOL_SPECS.save.id)
|
||||||
|
}, [enabled, handleSave, registerTool, removeTool, saved, t])
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||||
|
import { ViewMode } from '@renderer/components/CodeBlockView/types'
|
||||||
|
import { Square, SquareSplitHorizontal } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface UseSplitViewToolProps {
|
||||||
|
enabled: boolean
|
||||||
|
viewMode: ViewMode
|
||||||
|
onToggleSplitView: () => void
|
||||||
|
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSplitViewTool = ({ enabled, viewMode, onToggleSplitView, setTools }: UseSplitViewToolProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { registerTool, removeTool } = useToolManager(setTools)
|
||||||
|
|
||||||
|
const handleToggleSplitView = useCallback(() => {
|
||||||
|
onToggleSplitView?.()
|
||||||
|
}, [onToggleSplitView])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
registerTool({
|
||||||
|
...TOOL_SPECS['split-view'],
|
||||||
|
icon: viewMode === 'split' ? <Square className="tool-icon" /> : <SquareSplitHorizontal className="tool-icon" />,
|
||||||
|
tooltip: viewMode === 'split' ? t('code_block.split.restore') : t('code_block.split.label'),
|
||||||
|
onClick: handleToggleSplitView
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => removeTool(TOOL_SPECS['split-view'].id)
|
||||||
|
}, [enabled, viewMode, registerTool, removeTool, t, handleToggleSplitView])
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||||
|
import { ViewMode } from '@renderer/components/CodeBlockView/types'
|
||||||
|
import { CodeXml, Eye, SquarePen } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface UseViewSourceToolProps {
|
||||||
|
enabled: boolean
|
||||||
|
editable: boolean
|
||||||
|
viewMode: ViewMode
|
||||||
|
onViewModeChange: (mode: ViewMode) => void
|
||||||
|
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useViewSourceTool = ({
|
||||||
|
enabled,
|
||||||
|
editable,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
setTools
|
||||||
|
}: UseViewSourceToolProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { registerTool, removeTool } = useToolManager(setTools)
|
||||||
|
|
||||||
|
const handleToggleViewMode = useCallback(() => {
|
||||||
|
const newMode = viewMode === 'source' ? 'special' : 'source'
|
||||||
|
onViewModeChange?.(newMode)
|
||||||
|
}, [viewMode, onViewModeChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || viewMode === 'split') return
|
||||||
|
|
||||||
|
const toolSpec = editable ? TOOL_SPECS.edit : TOOL_SPECS['view-source']
|
||||||
|
|
||||||
|
if (editable) {
|
||||||
|
registerTool({
|
||||||
|
...toolSpec,
|
||||||
|
icon: viewMode === 'source' ? <Eye className="tool-icon" /> : <SquarePen className="tool-icon" />,
|
||||||
|
tooltip: viewMode === 'source' ? t('preview.label') : t('code_block.edit.label'),
|
||||||
|
onClick: handleToggleViewMode
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
registerTool({
|
||||||
|
...toolSpec,
|
||||||
|
icon: viewMode === 'source' ? <Eye className="tool-icon" /> : <CodeXml className="tool-icon" />,
|
||||||
|
tooltip: viewMode === 'source' ? t('preview.label') : t('preview.source'),
|
||||||
|
onClick: handleToggleViewMode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => removeTool(toolSpec.id)
|
||||||
|
}, [enabled, editable, viewMode, registerTool, removeTool, t, handleToggleViewMode])
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||||
|
import { Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface UseWrapToolProps {
|
||||||
|
enabled?: boolean
|
||||||
|
unwrapped?: boolean
|
||||||
|
wrappable?: boolean
|
||||||
|
toggle: () => void
|
||||||
|
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWrapTool = ({ enabled, unwrapped, wrappable, toggle, setTools }: UseWrapToolProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { registerTool, removeTool } = useToolManager(setTools)
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
toggle?.()
|
||||||
|
}, [toggle])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) {
|
||||||
|
registerTool({
|
||||||
|
...TOOL_SPECS.wrap,
|
||||||
|
icon: unwrapped ? <WrapIcon className="tool-icon" /> : <UnWrapIcon className="tool-icon" />,
|
||||||
|
tooltip: unwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
||||||
|
visible: () => wrappable ?? false,
|
||||||
|
onClick: handleToggle
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => removeTool(TOOL_SPECS.wrap.id)
|
||||||
|
}, [enabled, handleToggle, registerTool, removeTool, t, unwrapped, wrappable])
|
||||||
|
}
|
||||||
@ -1,5 +1,3 @@
|
|||||||
export * from './constants'
|
export { default as CodeToolButton } from './button'
|
||||||
export * from './hook'
|
export * from './hooks'
|
||||||
export * from './toolbar'
|
export { default as CodeToolbar } from './toolbar'
|
||||||
export * from './types'
|
|
||||||
export * from './usePreviewTools'
|
|
||||||
|
|||||||
35
src/renderer/src/components/CodeToolbar/styles.ts
Normal file
35
src/renderer/src/components/CodeToolbar/styles.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
export const ToolWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
.tool-icon {
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
.tool-icon {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Lucide icons */
|
||||||
|
.tool-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
`
|
||||||
@ -1,25 +1,15 @@
|
|||||||
|
import { ActionTool } from '@renderer/components/ActionTools'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import { EllipsisVertical } from 'lucide-react'
|
import { EllipsisVertical } from 'lucide-react'
|
||||||
import React, { memo, useMemo, useState } from 'react'
|
import { memo, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import { CodeTool } from './types'
|
import CodeToolButton from './button'
|
||||||
|
import { ToolWrapper } from './styles'
|
||||||
|
|
||||||
interface CodeToolButtonProps {
|
const CodeToolbar = ({ tools }: { tools: ActionTool[] }) => {
|
||||||
tool: CodeTool
|
|
||||||
}
|
|
||||||
|
|
||||||
const CodeToolButton: React.FC<CodeToolButtonProps> = memo(({ tool }) => {
|
|
||||||
return (
|
|
||||||
<Tooltip key={tool.id} title={tool.tooltip} mouseEnterDelay={0.5}>
|
|
||||||
<ToolWrapper onClick={() => tool.onClick()}>{tool.icon}</ToolWrapper>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const CodeToolbar: React.FC<{ tools: CodeTool[] }> = memo(({ tools }) => {
|
|
||||||
const [showQuickTools, setShowQuickTools] = useState(false)
|
const [showQuickTools, setShowQuickTools] = useState(false)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@ -51,7 +41,7 @@ export const CodeToolbar: React.FC<{ tools: CodeTool[] }> = memo(({ tools }) =>
|
|||||||
{quickTools.length > 1 && (
|
{quickTools.length > 1 && (
|
||||||
<Tooltip title={t('code_block.more')} mouseEnterDelay={0.5}>
|
<Tooltip title={t('code_block.more')} mouseEnterDelay={0.5}>
|
||||||
<ToolWrapper onClick={() => setShowQuickTools(!showQuickTools)} className={showQuickTools ? 'active' : ''}>
|
<ToolWrapper onClick={() => setShowQuickTools(!showQuickTools)} className={showQuickTools ? 'active' : ''}>
|
||||||
<EllipsisVertical className="icon" />
|
<EllipsisVertical className="tool-icon" />
|
||||||
</ToolWrapper>
|
</ToolWrapper>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@ -63,7 +53,7 @@ export const CodeToolbar: React.FC<{ tools: CodeTool[] }> = memo(({ tools }) =>
|
|||||||
</ToolbarWrapper>
|
</ToolbarWrapper>
|
||||||
</StickyWrapper>
|
</StickyWrapper>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
const StickyWrapper = styled.div`
|
const StickyWrapper = styled.div`
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@ -80,36 +70,4 @@ const ToolbarWrapper = styled(HStack)`
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
`
|
`
|
||||||
|
|
||||||
const ToolWrapper = styled.div`
|
export default memo(CodeToolbar)
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
color: var(--color-text-3);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-background-soft);
|
|
||||||
.icon {
|
|
||||||
color: var(--color-text-1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: var(--color-primary);
|
|
||||||
.icon {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* For Lucide icons */
|
|
||||||
.icon {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
color: var(--color-text-3);
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* 代码块工具基本信息
|
|
||||||
*/
|
|
||||||
export interface CodeToolSpec {
|
|
||||||
id: string
|
|
||||||
type: 'core' | 'quick'
|
|
||||||
order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 代码块工具定义接口
|
|
||||||
* @param id 唯一标识符
|
|
||||||
* @param type 工具类型
|
|
||||||
* @param icon 按钮图标
|
|
||||||
* @param tooltip 提示文本
|
|
||||||
* @param condition 显示条件
|
|
||||||
* @param onClick 点击动作
|
|
||||||
* @param order 显示顺序,越小越靠右
|
|
||||||
*/
|
|
||||||
export interface CodeTool extends CodeToolSpec {
|
|
||||||
icon: React.ReactNode
|
|
||||||
tooltip: string
|
|
||||||
visible?: () => boolean
|
|
||||||
onClick: () => void
|
|
||||||
}
|
|
||||||
@ -1,363 +0,0 @@
|
|||||||
import { loggerService } from '@logger'
|
|
||||||
import { download } from '@renderer/utils/download'
|
|
||||||
import { FileImage, ZoomIn, ZoomOut } from 'lucide-react'
|
|
||||||
import { RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
import { DownloadPngIcon, DownloadSvgIcon } from '../Icons/DownloadIcons'
|
|
||||||
import { TOOL_SPECS } from './constants'
|
|
||||||
import { useCodeTool } from './hook'
|
|
||||||
import { CodeTool } from './types'
|
|
||||||
|
|
||||||
const logger = loggerService.withContext('usePreviewToolHandlers')
|
|
||||||
|
|
||||||
// 预编译正则表达式用于查询位置
|
|
||||||
const TRANSFORM_REGEX = /translate\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 使用图像处理工具的自定义Hook
|
|
||||||
* 提供图像缩放、复制和下载功能
|
|
||||||
*/
|
|
||||||
export const usePreviewToolHandlers = (
|
|
||||||
containerRef: RefObject<HTMLDivElement | null>,
|
|
||||||
options: {
|
|
||||||
prefix: string
|
|
||||||
imgSelector: string
|
|
||||||
enableWheelZoom?: boolean
|
|
||||||
customDownloader?: (format: 'svg' | 'png') => void
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
const transformRef = useRef({ scale: 1, x: 0, y: 0 }) // 管理变换状态
|
|
||||||
const [renderTrigger, setRenderTrigger] = useState(0) // 仅用于触发组件重渲染的状态
|
|
||||||
const { imgSelector, prefix, customDownloader, enableWheelZoom } = options
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
// 创建选择器函数
|
|
||||||
const getImgElement = useCallback(() => {
|
|
||||||
if (!containerRef.current) return null
|
|
||||||
|
|
||||||
// 优先尝试从 Shadow DOM 中查找
|
|
||||||
const shadowRoot = containerRef.current.shadowRoot
|
|
||||||
if (shadowRoot) {
|
|
||||||
return shadowRoot.querySelector(imgSelector) as SVGElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 降级到常规 DOM 查找
|
|
||||||
return containerRef.current.querySelector(imgSelector) as SVGElement | null
|
|
||||||
}, [containerRef, imgSelector])
|
|
||||||
|
|
||||||
// 查询当前位置
|
|
||||||
const getCurrentPosition = useCallback(() => {
|
|
||||||
const imgElement = getImgElement()
|
|
||||||
if (!imgElement) return { x: transformRef.current.x, y: transformRef.current.y }
|
|
||||||
|
|
||||||
const transform = imgElement.style.transform
|
|
||||||
if (!transform || transform === 'none') return { x: transformRef.current.x, y: transformRef.current.y }
|
|
||||||
|
|
||||||
const match = transform.match(TRANSFORM_REGEX)
|
|
||||||
if (match && match.length >= 3) {
|
|
||||||
return {
|
|
||||||
x: parseFloat(match[1]),
|
|
||||||
y: parseFloat(match[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { x: transformRef.current.x, y: transformRef.current.y }
|
|
||||||
}, [getImgElement])
|
|
||||||
|
|
||||||
// 平移缩放变换
|
|
||||||
const applyTransform = useCallback((element: SVGElement | null, x: number, y: number, scale: number) => {
|
|
||||||
if (!element) return
|
|
||||||
element.style.transformOrigin = 'top left'
|
|
||||||
element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 拖拽平移支持
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current
|
|
||||||
if (!container) return
|
|
||||||
|
|
||||||
let isDragging = false
|
|
||||||
const startPos = { x: 0, y: 0 }
|
|
||||||
const startOffset = { x: 0, y: 0 }
|
|
||||||
|
|
||||||
const onMouseDown = (e: MouseEvent) => {
|
|
||||||
if (e.button !== 0) return // 只响应左键
|
|
||||||
|
|
||||||
// 更新当前实际位置
|
|
||||||
const position = getCurrentPosition()
|
|
||||||
transformRef.current.x = position.x
|
|
||||||
transformRef.current.y = position.y
|
|
||||||
|
|
||||||
isDragging = true
|
|
||||||
startPos.x = e.clientX
|
|
||||||
startPos.y = e.clientY
|
|
||||||
startOffset.x = position.x
|
|
||||||
startOffset.y = position.y
|
|
||||||
|
|
||||||
container.style.cursor = 'grabbing'
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!isDragging) return
|
|
||||||
|
|
||||||
const dx = e.clientX - startPos.x
|
|
||||||
const dy = e.clientY - startPos.y
|
|
||||||
const newX = startOffset.x + dx
|
|
||||||
const newY = startOffset.y + dy
|
|
||||||
|
|
||||||
const imgElement = getImgElement()
|
|
||||||
applyTransform(imgElement, newX, newY, transformRef.current.scale)
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopDrag = () => {
|
|
||||||
if (!isDragging) return
|
|
||||||
|
|
||||||
// 更新位置但不立即触发状态变更
|
|
||||||
const position = getCurrentPosition()
|
|
||||||
transformRef.current.x = position.x
|
|
||||||
transformRef.current.y = position.y
|
|
||||||
|
|
||||||
// 只触发一次渲染以保持组件状态同步
|
|
||||||
setRenderTrigger((prev) => prev + 1)
|
|
||||||
|
|
||||||
isDragging = false
|
|
||||||
container.style.cursor = 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 绑定到document以确保拖拽可以在鼠标离开容器后继续
|
|
||||||
container.addEventListener('mousedown', onMouseDown)
|
|
||||||
document.addEventListener('mousemove', onMouseMove)
|
|
||||||
document.addEventListener('mouseup', stopDrag)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
container.removeEventListener('mousedown', onMouseDown)
|
|
||||||
document.removeEventListener('mousemove', onMouseMove)
|
|
||||||
document.removeEventListener('mouseup', stopDrag)
|
|
||||||
}
|
|
||||||
}, [containerRef, getCurrentPosition, getImgElement, applyTransform])
|
|
||||||
|
|
||||||
// 缩放处理函数
|
|
||||||
const handleZoom = useCallback(
|
|
||||||
(delta: number) => {
|
|
||||||
const newScale = Math.max(0.1, Math.min(3, transformRef.current.scale + delta))
|
|
||||||
transformRef.current.scale = newScale
|
|
||||||
|
|
||||||
const imgElement = getImgElement()
|
|
||||||
applyTransform(imgElement, transformRef.current.x, transformRef.current.y, newScale)
|
|
||||||
|
|
||||||
// 触发重渲染以保持组件状态同步
|
|
||||||
setRenderTrigger((prev) => prev + 1)
|
|
||||||
},
|
|
||||||
[getImgElement, applyTransform]
|
|
||||||
)
|
|
||||||
|
|
||||||
// 滚轮缩放支持
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enableWheelZoom || !containerRef.current) return
|
|
||||||
|
|
||||||
const container = containerRef.current
|
|
||||||
|
|
||||||
const handleWheel = (e: WheelEvent) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.target) {
|
|
||||||
// 确认事件发生在容器内部
|
|
||||||
if (container.contains(e.target as Node)) {
|
|
||||||
const delta = e.deltaY < 0 ? 0.1 : -0.1
|
|
||||||
handleZoom(delta)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
container.addEventListener('wheel', handleWheel, { passive: true })
|
|
||||||
return () => container.removeEventListener('wheel', handleWheel)
|
|
||||||
}, [containerRef, handleZoom, enableWheelZoom])
|
|
||||||
|
|
||||||
// 复制图像处理函数
|
|
||||||
const handleCopyImage = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const imgElement = getImgElement()
|
|
||||||
if (!imgElement) return
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
const img = new Image()
|
|
||||||
img.crossOrigin = 'anonymous'
|
|
||||||
|
|
||||||
const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
|
|
||||||
const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width
|
|
||||||
const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height
|
|
||||||
|
|
||||||
const svgData = new XMLSerializer().serializeToString(imgElement)
|
|
||||||
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
|
|
||||||
|
|
||||||
img.onload = async () => {
|
|
||||||
const scale = 3
|
|
||||||
canvas.width = width * scale
|
|
||||||
canvas.height = height * scale
|
|
||||||
|
|
||||||
if (ctx) {
|
|
||||||
ctx.scale(scale, scale)
|
|
||||||
ctx.drawImage(img, 0, 0, width, height)
|
|
||||||
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png'))
|
|
||||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
|
||||||
window.message.success(t('message.copy.success'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
img.src = svgBase64
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Copy failed:', error as Error)
|
|
||||||
window.message.error(t('message.copy.failed'))
|
|
||||||
}
|
|
||||||
}, [getImgElement, t])
|
|
||||||
|
|
||||||
// 下载处理函数
|
|
||||||
const handleDownload = useCallback(
|
|
||||||
(format: 'svg' | 'png') => {
|
|
||||||
// 如果有自定义下载器,使用自定义实现
|
|
||||||
if (customDownloader) {
|
|
||||||
customDownloader(format)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const imgElement = getImgElement()
|
|
||||||
if (!imgElement) return
|
|
||||||
|
|
||||||
const timestamp = Date.now()
|
|
||||||
|
|
||||||
if (format === 'svg') {
|
|
||||||
const svgData = new XMLSerializer().serializeToString(imgElement)
|
|
||||||
const blob = new Blob([svgData], { type: 'image/svg+xml' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
download(url, `${prefix}-${timestamp}.svg`)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
} else if (format === 'png') {
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
const img = new Image()
|
|
||||||
img.crossOrigin = 'anonymous'
|
|
||||||
|
|
||||||
const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
|
|
||||||
const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width
|
|
||||||
const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height
|
|
||||||
|
|
||||||
const svgData = new XMLSerializer().serializeToString(imgElement)
|
|
||||||
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
const scale = 3
|
|
||||||
canvas.width = width * scale
|
|
||||||
canvas.height = height * scale
|
|
||||||
|
|
||||||
if (ctx) {
|
|
||||||
ctx.scale(scale, scale)
|
|
||||||
ctx.drawImage(img, 0, 0, width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.toBlob((blob) => {
|
|
||||||
if (blob) {
|
|
||||||
const pngUrl = URL.createObjectURL(blob)
|
|
||||||
download(pngUrl, `${prefix}-${timestamp}.png`)
|
|
||||||
URL.revokeObjectURL(pngUrl)
|
|
||||||
}
|
|
||||||
}, 'image/png')
|
|
||||||
}
|
|
||||||
img.src = svgBase64
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Download failed:', error as Error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[getImgElement, prefix, customDownloader]
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
scale: transformRef.current.scale,
|
|
||||||
handleZoom,
|
|
||||||
handleCopyImage,
|
|
||||||
handleDownload,
|
|
||||||
renderTrigger // 导出渲染触发器,万一要用
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PreviewToolsOptions {
|
|
||||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
|
||||||
handleZoom?: (delta: number) => void
|
|
||||||
handleCopyImage?: () => Promise<void>
|
|
||||||
handleDownload?: (format: 'svg' | 'png') => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提供预览组件通用工具栏功能的自定义Hook
|
|
||||||
*/
|
|
||||||
export const usePreviewTools = ({ setTools, handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 根据提供的功能有选择性地注册工具
|
|
||||||
if (handleZoom) {
|
|
||||||
// 放大工具
|
|
||||||
registerTool({
|
|
||||||
...TOOL_SPECS['zoom-in'],
|
|
||||||
icon: <ZoomIn className="icon" />,
|
|
||||||
tooltip: t('code_block.preview.zoom_in'),
|
|
||||||
onClick: () => handleZoom(0.1)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 缩小工具
|
|
||||||
registerTool({
|
|
||||||
...TOOL_SPECS['zoom-out'],
|
|
||||||
icon: <ZoomOut className="icon" />,
|
|
||||||
tooltip: t('code_block.preview.zoom_out'),
|
|
||||||
onClick: () => handleZoom(-0.1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handleCopyImage) {
|
|
||||||
// 复制图片工具
|
|
||||||
registerTool({
|
|
||||||
...TOOL_SPECS['copy-image'],
|
|
||||||
icon: <FileImage className="icon" />,
|
|
||||||
tooltip: t('code_block.preview.copy.image'),
|
|
||||||
onClick: handleCopyImage
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handleDownload) {
|
|
||||||
// 下载 SVG 工具
|
|
||||||
registerTool({
|
|
||||||
...TOOL_SPECS['download-svg'],
|
|
||||||
icon: <DownloadSvgIcon />,
|
|
||||||
tooltip: t('code_block.download.svg'),
|
|
||||||
onClick: () => handleDownload('svg')
|
|
||||||
})
|
|
||||||
|
|
||||||
// 下载 PNG 工具
|
|
||||||
registerTool({
|
|
||||||
...TOOL_SPECS['download-png'],
|
|
||||||
icon: <DownloadPngIcon />,
|
|
||||||
tooltip: t('code_block.download.png'),
|
|
||||||
onClick: () => handleDownload('png')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理函数
|
|
||||||
return () => {
|
|
||||||
if (handleZoom) {
|
|
||||||
removeTool(TOOL_SPECS['zoom-in'].id)
|
|
||||||
removeTool(TOOL_SPECS['zoom-out'].id)
|
|
||||||
}
|
|
||||||
if (handleCopyImage) {
|
|
||||||
removeTool(TOOL_SPECS['copy-image'].id)
|
|
||||||
}
|
|
||||||
if (handleDownload) {
|
|
||||||
removeTool(TOOL_SPECS['download-svg'].id)
|
|
||||||
removeTool(TOOL_SPECS['download-png'].id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [handleCopyImage, handleDownload, handleZoom, registerTool, removeTool, t])
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||||
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
|
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
@ -6,82 +6,34 @@ import { uuid } from '@renderer/utils'
|
|||||||
import { getReactStyleFromToken } from '@renderer/utils/shiki'
|
import { getReactStyleFromToken } from '@renderer/utils/shiki'
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { debounce } from 'lodash'
|
import { debounce } from 'lodash'
|
||||||
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
|
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { ThemedToken } from 'shiki/core'
|
import { ThemedToken } from 'shiki/core'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import { BasicPreviewProps } from './types'
|
interface CodeViewerProps {
|
||||||
|
|
||||||
interface CodePreviewProps extends BasicPreviewProps {
|
|
||||||
language: string
|
language: string
|
||||||
|
children: string
|
||||||
|
expanded?: boolean
|
||||||
|
unwrapped?: boolean
|
||||||
|
onHeightChange?: (scrollHeight: number) => void
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_COLLAPSE_HEIGHT = 350
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shiki 流式代码高亮组件
|
* Shiki 流式代码高亮组件
|
||||||
* - 通过 shiki tokenizer 处理流式响应,高性能
|
* - 通过 shiki tokenizer 处理流式响应,高性能
|
||||||
* - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应
|
* - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应
|
||||||
* - 并发安全
|
* - 并发安全
|
||||||
*/
|
*/
|
||||||
const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
const CodeViewer = ({ children, language, expanded, unwrapped, onHeightChange, className }: CodeViewerProps) => {
|
||||||
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
|
const { codeShowLineNumbers, fontSize } = useSettings()
|
||||||
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
|
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
|
||||||
const [expandOverride, setExpandOverride] = useState(!codeCollapsible)
|
|
||||||
const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable)
|
|
||||||
const shikiThemeRef = useRef<HTMLDivElement>(null)
|
const shikiThemeRef = useRef<HTMLDivElement>(null)
|
||||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||||
const callerId = useRef(`${Date.now()}-${uuid()}`).current
|
const callerId = useRef(`${Date.now()}-${uuid()}`).current
|
||||||
|
|
||||||
const rawLines = useMemo(() => (typeof children === 'string' ? children.trimEnd().split('\n') : []), [children])
|
const rawLines = useMemo(() => (typeof children === 'string' ? children.trimEnd().split('\n') : []), [children])
|
||||||
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
|
||||||
|
|
||||||
// 展开/折叠工具
|
|
||||||
useEffect(() => {
|
|
||||||
registerTool({
|
|
||||||
...TOOL_SPECS.expand,
|
|
||||||
icon: expandOverride ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
|
||||||
tooltip: expandOverride ? t('code_block.collapse') : t('code_block.expand'),
|
|
||||||
visible: () => {
|
|
||||||
const scrollHeight = scrollerRef.current?.scrollHeight
|
|
||||||
return codeCollapsible && (scrollHeight ?? 0) > MAX_COLLAPSE_HEIGHT
|
|
||||||
},
|
|
||||||
onClick: () => setExpandOverride((prev) => !prev)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => removeTool(TOOL_SPECS.expand.id)
|
|
||||||
}, [codeCollapsible, expandOverride, registerTool, removeTool, t])
|
|
||||||
|
|
||||||
// 自动换行工具
|
|
||||||
useEffect(() => {
|
|
||||||
registerTool({
|
|
||||||
...TOOL_SPECS.wrap,
|
|
||||||
icon: unwrapOverride ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
|
|
||||||
tooltip: unwrapOverride ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
|
||||||
visible: () => codeWrappable,
|
|
||||||
onClick: () => setUnwrapOverride((prev) => !prev)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => removeTool(TOOL_SPECS.wrap.id)
|
|
||||||
}, [codeWrappable, unwrapOverride, registerTool, removeTool, t])
|
|
||||||
|
|
||||||
// 重置用户操作(可以考虑移除,保持用户操作结果)
|
|
||||||
useEffect(() => {
|
|
||||||
setExpandOverride(!codeCollapsible)
|
|
||||||
}, [codeCollapsible])
|
|
||||||
|
|
||||||
// 重置用户操作(可以考虑移除,保持用户操作结果)
|
|
||||||
useEffect(() => {
|
|
||||||
setUnwrapOverride(!codeWrappable)
|
|
||||||
}, [codeWrappable])
|
|
||||||
|
|
||||||
const shouldCollapse = useMemo(() => codeCollapsible && !expandOverride, [codeCollapsible, expandOverride])
|
|
||||||
const shouldWrap = useMemo(() => codeWrappable && !unwrapOverride, [codeWrappable, unwrapOverride])
|
|
||||||
|
|
||||||
// 计算行号数字位数
|
// 计算行号数字位数
|
||||||
const gutterDigits = useMemo(
|
const gutterDigits = useMemo(
|
||||||
() => (codeShowLineNumbers ? Math.max(rawLines.length.toString().length, 1) : 0),
|
() => (codeShowLineNumbers ? Math.max(rawLines.length.toString().length, 1) : 0),
|
||||||
@ -90,10 +42,12 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
|||||||
|
|
||||||
// 设置 pre 标签属性
|
// 设置 pre 标签属性
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
let mounted = true
|
||||||
getShikiPreProperties(language).then((properties) => {
|
getShikiPreProperties(language).then((properties) => {
|
||||||
|
if (!mounted) return
|
||||||
const shikiTheme = shikiThemeRef.current
|
const shikiTheme = shikiThemeRef.current
|
||||||
if (shikiTheme) {
|
if (shikiTheme) {
|
||||||
shikiTheme.className = `${properties.class || 'shiki'}`
|
shikiTheme.className = `${properties.class || 'shiki'} code-viewer ${className ?? ''}`
|
||||||
// 滚动条适应 shiki 主题变化而非应用主题
|
// 滚动条适应 shiki 主题变化而非应用主题
|
||||||
shikiTheme.classList.add(isShikiThemeDark ? 'shiki-dark' : 'shiki-light')
|
shikiTheme.classList.add(isShikiThemeDark ? 'shiki-dark' : 'shiki-light')
|
||||||
|
|
||||||
@ -103,7 +57,10 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
|||||||
shikiTheme.tabIndex = properties.tabindex
|
shikiTheme.tabIndex = properties.tabindex
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [language, getShikiPreProperties, isShikiThemeDark])
|
return () => {
|
||||||
|
mounted = false
|
||||||
|
}
|
||||||
|
}, [language, getShikiPreProperties, isShikiThemeDark, className])
|
||||||
|
|
||||||
// Virtualizer 配置
|
// Virtualizer 配置
|
||||||
const getScrollElement = useCallback(() => scrollerRef.current, [])
|
const getScrollElement = useCallback(() => scrollerRef.current, [])
|
||||||
@ -140,19 +97,24 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
|||||||
}
|
}
|
||||||
}, [virtualItems, debouncedHighlightLines])
|
}, [virtualItems, debouncedHighlightLines])
|
||||||
|
|
||||||
|
// Report scrollHeight when it might change
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
onHeightChange?.(scrollerRef.current?.scrollHeight ?? 0)
|
||||||
|
}, [rawLines.length, onHeightChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={shikiThemeRef}>
|
<div ref={shikiThemeRef}>
|
||||||
<ScrollContainer
|
<ScrollContainer
|
||||||
ref={scrollerRef}
|
ref={scrollerRef}
|
||||||
className="shiki-scroller"
|
className="shiki-scroller"
|
||||||
$wrap={shouldWrap}
|
$wrap={!unwrapped}
|
||||||
$lineHeight={estimateSize()}
|
$lineHeight={estimateSize()}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
'--gutter-width': `${gutterDigits}ch`,
|
'--gutter-width': `${gutterDigits}ch`,
|
||||||
fontSize: `${fontSize - 1}px`,
|
fontSize: `${fontSize - 1}px`,
|
||||||
maxHeight: shouldCollapse ? MAX_COLLAPSE_HEIGHT : undefined,
|
maxHeight: expanded ? undefined : MAX_COLLAPSED_CODE_HEIGHT,
|
||||||
overflowY: shouldCollapse ? 'auto' : 'hidden'
|
overflowY: expanded ? 'hidden' : 'auto'
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}>
|
}>
|
||||||
<div
|
<div
|
||||||
@ -187,7 +149,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
CodePreview.displayName = 'CodePreview'
|
CodeViewer.displayName = 'CodeViewer'
|
||||||
|
|
||||||
const plainTokenStyle = {
|
const plainTokenStyle = {
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
@ -296,4 +258,4 @@ const ScrollContainer = styled.div<{
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
export default memo(CodePreview)
|
export default memo(CodeViewer)
|
||||||
@ -1,68 +0,0 @@
|
|||||||
import { SVGProps } from 'react'
|
|
||||||
|
|
||||||
// 基础下载图标
|
|
||||||
export const DownloadIcon = (props: SVGProps<SVGSVGElement>) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="1.1em"
|
|
||||||
height="1.1em"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}>
|
|
||||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
|
||||||
<path d="M12 15V3" />
|
|
||||||
<polygon points="12,15 9,11 15,11" fill="currentColor" stroke="none" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
// 带有文件类型的下载图标基础组件
|
|
||||||
const DownloadTypeIconBase = ({ type, ...props }: SVGProps<SVGSVGElement> & { type: string }) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="1.1em"
|
|
||||||
height="1.1em"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}>
|
|
||||||
<text
|
|
||||||
x="12"
|
|
||||||
y="7"
|
|
||||||
fontSize="8"
|
|
||||||
textAnchor="middle"
|
|
||||||
fill="currentColor"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="0.3"
|
|
||||||
letterSpacing="1"
|
|
||||||
fontFamily="Arial Black, sans-serif"
|
|
||||||
style={{
|
|
||||||
paintOrder: 'stroke',
|
|
||||||
fontStretch: 'expanded',
|
|
||||||
userSelect: 'none',
|
|
||||||
WebkitUserSelect: 'none',
|
|
||||||
MozUserSelect: 'none',
|
|
||||||
msUserSelect: 'none'
|
|
||||||
}}>
|
|
||||||
{type}
|
|
||||||
</text>
|
|
||||||
<path d="M21 16v3a2 2 0 01-2 2H5a2 2 0 01-2-2v-3" />
|
|
||||||
<path d="M12 17V10" />
|
|
||||||
<polygon points="12,17 9.5,14 14.5,14" fill="currentColor" stroke="none" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
// JPG 文件下载图标
|
|
||||||
export const DownloadJpgIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="JPG" {...props} />
|
|
||||||
|
|
||||||
// PNG 文件下载图标
|
|
||||||
export const DownloadPngIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="PNG" {...props} />
|
|
||||||
|
|
||||||
// SVG 文件下载图标
|
|
||||||
export const DownloadSvgIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="SVG" {...props} />
|
|
||||||
70
src/renderer/src/components/Icons/FileIcons.tsx
Normal file
70
src/renderer/src/components/Icons/FileIcons.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { CSSProperties, SVGProps } from 'react'
|
||||||
|
|
||||||
|
interface BaseFileIconProps extends SVGProps<SVGSVGElement> {
|
||||||
|
size?: string
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const textStyle: CSSProperties = {
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontSize: '7.70985px',
|
||||||
|
lineHeight: 0.8,
|
||||||
|
fontFamily: "'Times New Roman'",
|
||||||
|
textAlign: 'center',
|
||||||
|
writingMode: 'horizontal-tb',
|
||||||
|
direction: 'ltr',
|
||||||
|
textAnchor: 'middle',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: '#000000',
|
||||||
|
strokeWidth: '0.289119',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
strokeDasharray: 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
const tspanStyle: CSSProperties = {
|
||||||
|
fontStyle: 'normal',
|
||||||
|
fontVariant: 'normal',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
fontStretch: 'condensed',
|
||||||
|
fontSize: '7.70985px',
|
||||||
|
lineHeight: 0.8,
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
fill: '#000000',
|
||||||
|
fillOpacity: 1,
|
||||||
|
strokeWidth: '0.289119',
|
||||||
|
strokeDasharray: 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
const BaseFileIcon = ({ size = '1.1em', text = 'SVG', ...props }: BaseFileIconProps) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
version="1.1"
|
||||||
|
id="svg4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}>
|
||||||
|
<defs id="defs4" />
|
||||||
|
<path d="m 14,2 v 4 a 2,2 0 0 0 2,2 h 4" id="path3" />
|
||||||
|
<path d="M 15,2 H 6 A 2,2 0 0 0 4,4 v 16 a 2,2 0 0 0 2,2 h 12 a 2,2 0 0 0 2,-2 V 7 Z" id="path4" />
|
||||||
|
<text
|
||||||
|
xmlSpace="preserve"
|
||||||
|
style={textStyle}
|
||||||
|
x="12.478625"
|
||||||
|
y="17.170216"
|
||||||
|
id="text4"
|
||||||
|
transform="scale(0.96196394,1.03954)">
|
||||||
|
<tspan id="tspan4" x="12.478625" y="17.170216" style={tspanStyle}>
|
||||||
|
{text}
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const FileSvgIcon = (props: Omit<BaseFileIconProps, 'text'>) => <BaseFileIcon text="SVG" {...props} />
|
||||||
|
export const FilePngIcon = (props: Omit<BaseFileIconProps, 'text'>) => <BaseFileIcon text="PNG" {...props} />
|
||||||
@ -1,8 +1,8 @@
|
|||||||
export { default as CopyIcon } from './CopyIcon'
|
export { default as CopyIcon } from './CopyIcon'
|
||||||
export { default as DeleteIcon } from './DeleteIcon'
|
export { default as DeleteIcon } from './DeleteIcon'
|
||||||
export * from './DownloadIcons'
|
|
||||||
export { default as EditIcon } from './EditIcon'
|
export { default as EditIcon } from './EditIcon'
|
||||||
export { default as FallbackFavicon } from './FallbackFavicon'
|
export { default as FallbackFavicon } from './FallbackFavicon'
|
||||||
|
export * from './FileIcons'
|
||||||
export { default as MinAppIcon } from './MinAppIcon'
|
export { default as MinAppIcon } from './MinAppIcon'
|
||||||
export * from './NutstoreIcons'
|
export * from './NutstoreIcons'
|
||||||
export { default as OcrIcon } from './OcrIcon'
|
export { default as OcrIcon } from './OcrIcon'
|
||||||
|
|||||||
@ -86,7 +86,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'copy-image',
|
key: 'copy-image',
|
||||||
label: t('code_block.preview.copy.image'),
|
label: t('preview.copy.image'),
|
||||||
icon: <FileImageOutlined />,
|
icon: <FileImageOutlined />,
|
||||||
onClick: () => handleCopyImage(src)
|
onClick: () => handleCopyImage(src)
|
||||||
}
|
}
|
||||||
@ -101,6 +101,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
|
|||||||
{...props}
|
{...props}
|
||||||
preview={{
|
preview={{
|
||||||
mask: typeof props.preview === 'object' ? props.preview.mask : false,
|
mask: typeof props.preview === 'object' ? props.preview.mask : false,
|
||||||
|
...(typeof props.preview === 'object' ? props.preview : {}),
|
||||||
toolbarRender: (
|
toolbarRender: (
|
||||||
_,
|
_,
|
||||||
{
|
{
|
||||||
|
|||||||
56
src/renderer/src/components/Preview/GraphvizPreview.tsx
Normal file
56
src/renderer/src/components/Preview/GraphvizPreview.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
|
||||||
|
import React, { memo, useCallback } from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import { useDebouncedRender } from './hooks/useDebouncedRender'
|
||||||
|
import ImagePreviewLayout from './ImagePreviewLayout'
|
||||||
|
import { BasicPreviewHandles, BasicPreviewProps } from './types'
|
||||||
|
import { renderSvgInShadowHost } from './utils'
|
||||||
|
|
||||||
|
// 管理 viz 实例
|
||||||
|
const vizInitializer = new AsyncInitializer(async () => {
|
||||||
|
const module = await import('@viz-js/viz')
|
||||||
|
return await module.instance()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 预览 Graphviz 图表
|
||||||
|
* 使用 usePreviewRenderer hook 大幅简化组件逻辑
|
||||||
|
*/
|
||||||
|
const GraphvizPreview = ({
|
||||||
|
children,
|
||||||
|
enableToolbar = false,
|
||||||
|
ref
|
||||||
|
}: BasicPreviewProps & { ref?: React.RefObject<BasicPreviewHandles | null> }) => {
|
||||||
|
// 定义渲染函数
|
||||||
|
const renderGraphviz = useCallback(async (content: string, container: HTMLDivElement) => {
|
||||||
|
const viz = await vizInitializer.get()
|
||||||
|
const svg = viz.renderString(content, { format: 'svg' })
|
||||||
|
renderSvgInShadowHost(svg, container)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 使用预览渲染器 hook
|
||||||
|
const { containerRef, error, isLoading } = useDebouncedRender(children, renderGraphviz, {
|
||||||
|
debounceDelay: 300
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ImagePreviewLayout
|
||||||
|
loading={isLoading}
|
||||||
|
error={error}
|
||||||
|
enableToolbar={enableToolbar}
|
||||||
|
ref={ref}
|
||||||
|
imageRef={containerRef}
|
||||||
|
source="graphviz">
|
||||||
|
<StyledGraphviz ref={containerRef} className="graphviz special-preview" />
|
||||||
|
</ImagePreviewLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledGraphviz = styled.div`
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
|
export default memo(GraphvizPreview)
|
||||||
60
src/renderer/src/components/Preview/ImagePreviewLayout.tsx
Normal file
60
src/renderer/src/components/Preview/ImagePreviewLayout.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { useImageTools } from '@renderer/components/ActionTools/hooks/useImageTools'
|
||||||
|
import { LoadingIcon } from '@renderer/components/Icons'
|
||||||
|
import { Spin } from 'antd'
|
||||||
|
import { memo, useImperativeHandle } from 'react'
|
||||||
|
|
||||||
|
import ImageToolbar from './ImageToolbar'
|
||||||
|
import { PreviewContainer, PreviewError } from './styles'
|
||||||
|
import { BasicPreviewHandles } from './types'
|
||||||
|
|
||||||
|
interface ImagePreviewLayoutProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
ref?: React.RefObject<BasicPreviewHandles | null>
|
||||||
|
imageRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
source: string
|
||||||
|
loading?: boolean
|
||||||
|
error?: string | null
|
||||||
|
enableToolbar?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImagePreviewLayout = ({
|
||||||
|
children,
|
||||||
|
ref,
|
||||||
|
imageRef,
|
||||||
|
source,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
enableToolbar,
|
||||||
|
className
|
||||||
|
}: ImagePreviewLayoutProps) => {
|
||||||
|
// 使用通用图像工具
|
||||||
|
const { pan, zoom, copy, download, dialog } = useImageTools(imageRef, {
|
||||||
|
imgSelector: 'svg',
|
||||||
|
prefix: source ?? 'svg',
|
||||||
|
enableDrag: true,
|
||||||
|
enableWheelZoom: true
|
||||||
|
})
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
return {
|
||||||
|
pan,
|
||||||
|
zoom,
|
||||||
|
copy,
|
||||||
|
download,
|
||||||
|
dialog
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Spin spinning={loading} indicator={<LoadingIcon color="var(--color-text-2)" />}>
|
||||||
|
<PreviewContainer vertical className={`image-preview-layout ${className ?? ''}`}>
|
||||||
|
{error && <PreviewError>{error}</PreviewError>}
|
||||||
|
{children}
|
||||||
|
{!error && enableToolbar && <ImageToolbar pan={pan} zoom={zoom} dialog={dialog} />}
|
||||||
|
</PreviewContainer>
|
||||||
|
</Spin>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ImagePreviewLayout)
|
||||||
18
src/renderer/src/components/Preview/ImageToolButton.tsx
Normal file
18
src/renderer/src/components/Preview/ImageToolButton.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Button, Tooltip } from 'antd'
|
||||||
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
interface ImageToolButtonProps {
|
||||||
|
tooltip: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageToolButton = ({ tooltip, icon, onClick }: ImageToolButtonProps) => {
|
||||||
|
return (
|
||||||
|
<Tooltip title={tooltip} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||||
|
<Button shape="circle" icon={icon} onClick={onClick} role="button" aria-label={tooltip} />
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ImageToolButton)
|
||||||
107
src/renderer/src/components/Preview/ImageToolbar.tsx
Normal file
107
src/renderer/src/components/Preview/ImageToolbar.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { ResetIcon } from '@renderer/components/Icons'
|
||||||
|
import { classNames } from '@renderer/utils'
|
||||||
|
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Scan, ZoomIn, ZoomOut } from 'lucide-react'
|
||||||
|
import { memo, useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import ImageToolButton from './ImageToolButton'
|
||||||
|
|
||||||
|
interface ImageToolbarProps {
|
||||||
|
pan: (dx: number, dy: number, absolute?: boolean) => void
|
||||||
|
zoom: (delta: number, absolute?: boolean) => void
|
||||||
|
dialog: () => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageToolbar = ({ pan, zoom, dialog, className }: ImageToolbarProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
// 定义平移距离
|
||||||
|
const panDistance = 20
|
||||||
|
|
||||||
|
// 定义缩放增量
|
||||||
|
const zoomDelta = 0.1
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
pan(0, 0, true)
|
||||||
|
zoom(1, true)
|
||||||
|
}, [pan, zoom])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolbarWrapper className={classNames('preview-toolbar', className)} role="toolbar" aria-label={t('preview.label')}>
|
||||||
|
{/* Up */}
|
||||||
|
<ActionButtonRow>
|
||||||
|
<Spacer />
|
||||||
|
<ImageToolButton
|
||||||
|
tooltip={t('preview.pan_up')}
|
||||||
|
icon={<ChevronUp size={'1rem'} />}
|
||||||
|
onClick={() => pan(0, -panDistance)}
|
||||||
|
/>
|
||||||
|
<ImageToolButton tooltip={t('preview.dialog')} icon={<Scan size={'1rem'} />} onClick={dialog} />
|
||||||
|
</ActionButtonRow>
|
||||||
|
|
||||||
|
{/* Left, Reset, Right */}
|
||||||
|
<ActionButtonRow>
|
||||||
|
<ImageToolButton
|
||||||
|
tooltip={t('preview.pan_left')}
|
||||||
|
icon={<ChevronLeft size={'1rem'} />}
|
||||||
|
onClick={() => pan(-panDistance, 0)}
|
||||||
|
/>
|
||||||
|
<ImageToolButton tooltip={t('preview.reset')} icon={<ResetIcon size={'1rem'} />} onClick={handleReset} />
|
||||||
|
<ImageToolButton
|
||||||
|
tooltip={t('preview.pan_right')}
|
||||||
|
icon={<ChevronRight size={'1rem'} />}
|
||||||
|
onClick={() => pan(panDistance, 0)}
|
||||||
|
/>
|
||||||
|
</ActionButtonRow>
|
||||||
|
|
||||||
|
{/* Down, Zoom */}
|
||||||
|
<ActionButtonRow>
|
||||||
|
<ImageToolButton
|
||||||
|
tooltip={t('preview.zoom_out')}
|
||||||
|
icon={<ZoomOut size={'1rem'} />}
|
||||||
|
onClick={() => zoom(-zoomDelta)}
|
||||||
|
/>
|
||||||
|
<ImageToolButton
|
||||||
|
tooltip={t('preview.pan_down')}
|
||||||
|
icon={<ChevronDown size={'1rem'} />}
|
||||||
|
onClick={() => pan(0, panDistance)}
|
||||||
|
/>
|
||||||
|
<ImageToolButton
|
||||||
|
tooltip={t('preview.zoom_in')}
|
||||||
|
icon={<ZoomIn size={'1rem'} />}
|
||||||
|
onClick={() => zoom(zoomDelta)}
|
||||||
|
/>
|
||||||
|
</ActionButtonRow>
|
||||||
|
</ToolbarWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToolbarWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: absolute;
|
||||||
|
gap: 4px;
|
||||||
|
right: 1em;
|
||||||
|
bottom: 1em;
|
||||||
|
z-index: 5;
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const ActionButtonRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Spacer = styled.div`
|
||||||
|
flex: 1;
|
||||||
|
`
|
||||||
|
|
||||||
|
export default memo(ImageToolbar)
|
||||||
120
src/renderer/src/components/Preview/MermaidPreview.tsx
Normal file
120
src/renderer/src/components/Preview/MermaidPreview.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { nanoid } from '@reduxjs/toolkit'
|
||||||
|
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||||
|
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import { useDebouncedRender } from './hooks/useDebouncedRender'
|
||||||
|
import ImagePreviewLayout from './ImagePreviewLayout'
|
||||||
|
import { BasicPreviewHandles, BasicPreviewProps } from './types'
|
||||||
|
|
||||||
|
/** 预览 Mermaid 图表
|
||||||
|
* 使用 usePreviewRenderer hook 重构,同时保留必要的可见性检测逻辑
|
||||||
|
* FIXME: 等将来 mermaid-js 修复可见性问题后可以进一步简化
|
||||||
|
*/
|
||||||
|
const MermaidPreview = ({
|
||||||
|
children,
|
||||||
|
enableToolbar = false,
|
||||||
|
ref
|
||||||
|
}: BasicPreviewProps & { ref?: React.RefObject<BasicPreviewHandles | null> }) => {
|
||||||
|
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
|
||||||
|
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
||||||
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
|
|
||||||
|
// 定义渲染函数
|
||||||
|
const renderMermaid = useCallback(
|
||||||
|
async (content: string, container: HTMLDivElement) => {
|
||||||
|
// 验证语法,提前抛出异常
|
||||||
|
await mermaid.parse(content)
|
||||||
|
|
||||||
|
const { svg } = await mermaid.render(diagramId, content, container)
|
||||||
|
|
||||||
|
// 避免不可见时产生 undefined 和 NaN
|
||||||
|
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
|
||||||
|
container.innerHTML = fixedSvg
|
||||||
|
},
|
||||||
|
[diagramId, mermaid]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 可见性检测函数
|
||||||
|
const shouldRender = useCallback(() => {
|
||||||
|
return !isLoadingMermaid && isVisible
|
||||||
|
}, [isLoadingMermaid, isVisible])
|
||||||
|
|
||||||
|
// 使用预览渲染器 hook
|
||||||
|
const {
|
||||||
|
containerRef,
|
||||||
|
error: renderError,
|
||||||
|
isLoading: isRendering
|
||||||
|
} = useDebouncedRender(children, renderMermaid, {
|
||||||
|
debounceDelay: 300,
|
||||||
|
shouldRender
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听可见性变化,用于触发重新渲染。
|
||||||
|
* 这是为了解决 `MessageGroup` 组件的 `fold` 布局中被 `display: none` 隐藏的图标无法正确渲染的问题。
|
||||||
|
* 监听时向上遍历到第一个有 `fold` className 的父节点为止(也就是目前的 `MessageWrapper`)。
|
||||||
|
* FIXME: 将来 mermaid-js 修复此问题后可以移除这里的相关逻辑。
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
const checkVisibility = () => {
|
||||||
|
const element = containerRef.current
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
const currentlyVisible = element.offsetParent !== null
|
||||||
|
setIsVisible(currentlyVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始检查
|
||||||
|
checkVisibility()
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
checkVisibility()
|
||||||
|
})
|
||||||
|
|
||||||
|
let targetElement = containerRef.current.parentElement
|
||||||
|
while (targetElement) {
|
||||||
|
observer.observe(targetElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'style']
|
||||||
|
})
|
||||||
|
|
||||||
|
if (targetElement.className?.includes('fold')) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
targetElement = targetElement.parentElement
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [containerRef])
|
||||||
|
|
||||||
|
// 合并加载状态和错误状态
|
||||||
|
const isLoading = isLoadingMermaid || isRendering
|
||||||
|
const error = mermaidError || renderError
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ImagePreviewLayout
|
||||||
|
loading={isLoading}
|
||||||
|
error={error}
|
||||||
|
enableToolbar={enableToolbar}
|
||||||
|
ref={ref}
|
||||||
|
imageRef={containerRef}
|
||||||
|
source="mermaid">
|
||||||
|
<StyledMermaid ref={containerRef} className="mermaid special-preview" />
|
||||||
|
</ImagePreviewLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledMermaid = styled.div`
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
|
export default memo(MermaidPreview)
|
||||||
136
src/renderer/src/components/Preview/PlantUmlPreview.tsx
Normal file
136
src/renderer/src/components/Preview/PlantUmlPreview.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import pako from 'pako'
|
||||||
|
import React, { memo, useCallback, useEffect } from 'react'
|
||||||
|
|
||||||
|
import { useDebouncedRender } from './hooks/useDebouncedRender'
|
||||||
|
import ImagePreviewLayout from './ImagePreviewLayout'
|
||||||
|
import { BasicPreviewHandles, BasicPreviewProps } from './types'
|
||||||
|
import { renderSvgInShadowHost } from './utils'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('PlantUmlPreview')
|
||||||
|
|
||||||
|
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
|
||||||
|
function encode64(data: Uint8Array) {
|
||||||
|
let r = ''
|
||||||
|
for (let i = 0; i < data.length; i += 3) {
|
||||||
|
if (i + 2 === data.length) {
|
||||||
|
r += append3bytes(data[i], data[i + 1], 0)
|
||||||
|
} else if (i + 1 === data.length) {
|
||||||
|
r += append3bytes(data[i], 0, 0)
|
||||||
|
} else {
|
||||||
|
r += append3bytes(data[i], data[i + 1], data[i + 2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
function encode6bit(b: number) {
|
||||||
|
if (b < 10) {
|
||||||
|
return String.fromCharCode(48 + b)
|
||||||
|
}
|
||||||
|
b -= 10
|
||||||
|
if (b < 26) {
|
||||||
|
return String.fromCharCode(65 + b)
|
||||||
|
}
|
||||||
|
b -= 26
|
||||||
|
if (b < 26) {
|
||||||
|
return String.fromCharCode(97 + b)
|
||||||
|
}
|
||||||
|
b -= 26
|
||||||
|
if (b === 0) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
if (b === 1) {
|
||||||
|
return '_'
|
||||||
|
}
|
||||||
|
return '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
function append3bytes(b1: number, b2: number, b3: number) {
|
||||||
|
const c1 = b1 >> 2
|
||||||
|
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
|
||||||
|
const c3 = ((b2 & 0xf) << 2) | (b3 >> 6)
|
||||||
|
const c4 = b3 & 0x3f
|
||||||
|
let r = ''
|
||||||
|
r += encode6bit(c1 & 0x3f)
|
||||||
|
r += encode6bit(c2 & 0x3f)
|
||||||
|
r += encode6bit(c3 & 0x3f)
|
||||||
|
r += encode6bit(c4 & 0x3f)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* https://plantuml.com/zh/code-javascript-synchronous
|
||||||
|
* To use PlantUML image generation, a text diagram description have to be :
|
||||||
|
1. Encoded in UTF-8
|
||||||
|
2. Compressed using Deflate algorithm
|
||||||
|
3. Reencoded in ASCII using a transformation _close_ to base64
|
||||||
|
*/
|
||||||
|
function encodeDiagram(diagram: string): string {
|
||||||
|
const utf8text = new TextEncoder().encode(diagram)
|
||||||
|
const compressed = pako.deflateRaw(utf8text)
|
||||||
|
return encode64(compressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlantUMLImageUrl(format: 'png' | 'svg', diagram: string, isDark?: boolean) {
|
||||||
|
const encodedDiagram = encodeDiagram(diagram)
|
||||||
|
if (isDark) {
|
||||||
|
return `${PlantUMLServer}/d${format}/${encodedDiagram}`
|
||||||
|
}
|
||||||
|
return `${PlantUMLServer}/${format}/${encodedDiagram}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlantUmlPreview = ({
|
||||||
|
children,
|
||||||
|
enableToolbar = false,
|
||||||
|
ref
|
||||||
|
}: BasicPreviewProps & { ref?: React.RefObject<BasicPreviewHandles | null> }) => {
|
||||||
|
// 定义渲染函数
|
||||||
|
const renderPlantUml = useCallback(async (content: string, container: HTMLDivElement) => {
|
||||||
|
const url = getPlantUMLImageUrl('svg', content, false)
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 400) {
|
||||||
|
throw new Error(
|
||||||
|
'Diagram rendering failed (400): This is likely due to a syntax error in the diagram. Please check your code.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (response.status >= 500) {
|
||||||
|
throw new Error(
|
||||||
|
`Diagram rendering failed (${response.status}): The PlantUML server is temporarily unavailable. Please try again later.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw new Error(`Diagram rendering failed, server returned: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
renderSvgInShadowHost(text, container)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 使用预览渲染器 hook
|
||||||
|
const { containerRef, error, isLoading } = useDebouncedRender(children, renderPlantUml, {
|
||||||
|
debounceDelay: 300
|
||||||
|
})
|
||||||
|
|
||||||
|
// 记录网络错误
|
||||||
|
useEffect(() => {
|
||||||
|
if (error && error.includes('Failed to fetch')) {
|
||||||
|
logger.warn('Network Error: Unable to connect to PlantUML server. Please check your network connection.')
|
||||||
|
} else if (error) {
|
||||||
|
logger.warn(error)
|
||||||
|
}
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ImagePreviewLayout
|
||||||
|
loading={isLoading}
|
||||||
|
error={error}
|
||||||
|
enableToolbar={enableToolbar}
|
||||||
|
ref={ref}
|
||||||
|
imageRef={containerRef}
|
||||||
|
source="plantuml">
|
||||||
|
<div ref={containerRef} className="plantuml-preview special-preview" />
|
||||||
|
</ImagePreviewLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(PlantUmlPreview)
|
||||||
42
src/renderer/src/components/Preview/SvgPreview.tsx
Normal file
42
src/renderer/src/components/Preview/SvgPreview.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { memo, useCallback } from 'react'
|
||||||
|
|
||||||
|
import { useDebouncedRender } from './hooks/useDebouncedRender'
|
||||||
|
import ImagePreviewLayout from './ImagePreviewLayout'
|
||||||
|
import { BasicPreviewHandles } from './types'
|
||||||
|
import { renderSvgInShadowHost } from './utils'
|
||||||
|
|
||||||
|
interface SvgPreviewProps {
|
||||||
|
children: string
|
||||||
|
enableToolbar?: boolean
|
||||||
|
className?: string
|
||||||
|
ref?: React.RefObject<BasicPreviewHandles | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Shadow DOM 渲染 SVG
|
||||||
|
*/
|
||||||
|
const SvgPreview = ({ children, enableToolbar = false, className, ref }: SvgPreviewProps) => {
|
||||||
|
// 定义渲染函数
|
||||||
|
const renderSvg = useCallback(async (content: string, container: HTMLDivElement) => {
|
||||||
|
renderSvgInShadowHost(content, container)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 使用预览渲染器 hook
|
||||||
|
const { containerRef, error, isLoading } = useDebouncedRender(children, renderSvg, {
|
||||||
|
debounceDelay: 300
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ImagePreviewLayout
|
||||||
|
loading={isLoading}
|
||||||
|
error={error}
|
||||||
|
enableToolbar={enableToolbar}
|
||||||
|
ref={ref}
|
||||||
|
imageRef={containerRef}
|
||||||
|
source="svg">
|
||||||
|
<div ref={containerRef} className={className ?? 'svg-preview special-preview'}></div>
|
||||||
|
</ImagePreviewLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(SvgPreview)
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
import GraphvizPreview from '@renderer/components/Preview/GraphvizPreview'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Use vi.hoisted to manage mocks
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
vizInstance: {
|
||||||
|
renderSVGElement: vi.fn()
|
||||||
|
},
|
||||||
|
vizInitializer: {
|
||||||
|
get: vi.fn()
|
||||||
|
},
|
||||||
|
ImagePreviewLayout: vi.fn(({ children, loading, error, enableToolbar, source }) => (
|
||||||
|
<div data-testid="image-preview-layout" data-source={source}>
|
||||||
|
{enableToolbar && <div data-testid="toolbar">Toolbar</div>}
|
||||||
|
{loading && <div data-testid="loading">Loading...</div>}
|
||||||
|
{error && <div data-testid="error">{error}</div>}
|
||||||
|
<div data-testid="preview-content">{children}</div>
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
useDebouncedRender: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Preview/ImagePreviewLayout', () => ({
|
||||||
|
default: mocks.ImagePreviewLayout
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/utils/asyncInitializer', () => ({
|
||||||
|
AsyncInitializer: class {
|
||||||
|
constructor() {
|
||||||
|
return mocks.vizInitializer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Preview/hooks/useDebouncedRender', () => ({
|
||||||
|
useDebouncedRender: mocks.useDebouncedRender
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('GraphvizPreview', () => {
|
||||||
|
const dotCode = 'digraph { a -> b }'
|
||||||
|
const mockContainerRef = { current: document.createElement('div') }
|
||||||
|
|
||||||
|
// Helper function to create mock useDebouncedRender return value
|
||||||
|
const createMockHookReturn = (overrides = {}) => ({
|
||||||
|
containerRef: mockContainerRef,
|
||||||
|
error: null,
|
||||||
|
isLoading: false,
|
||||||
|
triggerRender: vi.fn(),
|
||||||
|
cancelRender: vi.fn(),
|
||||||
|
clearError: vi.fn(),
|
||||||
|
setLoading: vi.fn(),
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup default successful state
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('basic rendering', () => {
|
||||||
|
it('should match snapshot', () => {
|
||||||
|
const { container } = render(<GraphvizPreview enableToolbar>{dotCode}</GraphvizPreview>)
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle valid dot code', () => {
|
||||||
|
render(<GraphvizPreview>{dotCode}</GraphvizPreview>)
|
||||||
|
|
||||||
|
// Component should render without throwing
|
||||||
|
expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
|
||||||
|
expect(mocks.useDebouncedRender).toHaveBeenCalledWith(
|
||||||
|
dotCode,
|
||||||
|
expect.any(Function),
|
||||||
|
expect.objectContaining({ debounceDelay: 300 })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty content', () => {
|
||||||
|
render(<GraphvizPreview>{''}</GraphvizPreview>)
|
||||||
|
|
||||||
|
// Component should render without throwing
|
||||||
|
expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
|
||||||
|
expect(mocks.useDebouncedRender).toHaveBeenCalledWith('', expect.any(Function), expect.any(Object))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loading state', () => {
|
||||||
|
it('should show loading indicator when rendering', () => {
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: true }))
|
||||||
|
|
||||||
|
render(<GraphvizPreview>{dotCode}</GraphvizPreview>)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show loading indicator when not rendering', () => {
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: false }))
|
||||||
|
|
||||||
|
render(<GraphvizPreview>{dotCode}</GraphvizPreview>)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should show error message when rendering fails', () => {
|
||||||
|
const errorMessage = 'Invalid dot syntax'
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: errorMessage }))
|
||||||
|
|
||||||
|
render(<GraphvizPreview>{dotCode}</GraphvizPreview>)
|
||||||
|
|
||||||
|
const errorElement = screen.getByTestId('error')
|
||||||
|
expect(errorElement).toBeInTheDocument()
|
||||||
|
expect(errorElement).toHaveTextContent(errorMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show error when rendering is successful', () => {
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: null }))
|
||||||
|
|
||||||
|
render(<GraphvizPreview>{dotCode}</GraphvizPreview>)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('error')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ref forwarding', () => {
|
||||||
|
it('should forward ref to ImagePreviewLayout', () => {
|
||||||
|
const ref = { current: null }
|
||||||
|
render(<GraphvizPreview ref={ref}>{dotCode}</GraphvizPreview>)
|
||||||
|
|
||||||
|
// The ref should be passed to ImagePreviewLayout
|
||||||
|
expect(mocks.ImagePreviewLayout).toHaveBeenCalledWith(expect.objectContaining({ ref }), undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import ImagePreviewLayout from '../ImagePreviewLayout'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
useImageTools: vi.fn(() => ({
|
||||||
|
pan: vi.fn(),
|
||||||
|
zoom: vi.fn(),
|
||||||
|
copy: vi.fn(),
|
||||||
|
download: vi.fn(),
|
||||||
|
dialog: vi.fn()
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock antd components
|
||||||
|
vi.mock('antd', () => ({
|
||||||
|
Spin: ({ children, spinning }: any) => (
|
||||||
|
<div data-testid="spin" data-spinning={spinning}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Icons', () => ({
|
||||||
|
LoadingIcon: () => <div data-testid="spinner">Spinner</div>
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock ImageToolbar
|
||||||
|
vi.mock('../ImageToolbar', () => ({
|
||||||
|
default: () => <div data-testid="image-toolbar">ImageToolbar</div>
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock styles
|
||||||
|
vi.mock('../styles', () => ({
|
||||||
|
PreviewContainer: ({ children, vertical }: any) => (
|
||||||
|
<div data-testid="preview-container" data-vertical={vertical}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
PreviewError: ({ children }: any) => <div data-testid="preview-error">{children}</div>
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock useImageTools
|
||||||
|
vi.mock('@renderer/components/ActionTools/hooks/useImageTools', () => ({
|
||||||
|
useImageTools: mocks.useImageTools
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('ImagePreviewLayout', () => {
|
||||||
|
const mockImageRef = { current: null }
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
imageRef: mockImageRef,
|
||||||
|
source: 'test-source',
|
||||||
|
children: <div>Test Content</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should match snapshot', () => {
|
||||||
|
const { container } = render(<ImagePreviewLayout {...defaultProps} />)
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render children correctly', () => {
|
||||||
|
render(<ImagePreviewLayout {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Test Content')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show loading state when loading is true', () => {
|
||||||
|
render(<ImagePreviewLayout {...defaultProps} loading={true} />)
|
||||||
|
expect(screen.getByTestId('spin')).toHaveAttribute('data-spinning', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show loading state when loading is false', () => {
|
||||||
|
render(<ImagePreviewLayout {...defaultProps} loading={false} />)
|
||||||
|
expect(screen.getByTestId('spin')).toHaveAttribute('data-spinning', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should display error message when error is provided', () => {
|
||||||
|
const errorMessage = 'Test error message'
|
||||||
|
render(<ImagePreviewLayout {...defaultProps} error={errorMessage} />)
|
||||||
|
expect(screen.getByText(errorMessage)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not display error message when error is null', () => {
|
||||||
|
render(<ImagePreviewLayout {...defaultProps} error={null} />)
|
||||||
|
expect(screen.queryByText('preview-error')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render ImageToolbar when enableToolbar is true and no error', () => {
|
||||||
|
render(<ImagePreviewLayout {...defaultProps} enableToolbar={true} />)
|
||||||
|
expect(screen.getByTestId('image-toolbar')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render ImageToolbar when enableToolbar is false', () => {
|
||||||
|
render(<ImagePreviewLayout {...defaultProps} enableToolbar={false} />)
|
||||||
|
expect(screen.queryByTestId('image-toolbar')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render ImageToolbar when there is an error', () => {
|
||||||
|
render(<ImagePreviewLayout {...defaultProps} enableToolbar={true} error="Error occurred" />)
|
||||||
|
expect(screen.queryByTestId('image-toolbar')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call useImageTools with correct parameters', () => {
|
||||||
|
render(<ImagePreviewLayout {...defaultProps} />)
|
||||||
|
|
||||||
|
// Verify useImageTools was called with correct parameters
|
||||||
|
expect(mocks.useImageTools).toHaveBeenCalledWith(
|
||||||
|
mockImageRef,
|
||||||
|
expect.objectContaining({
|
||||||
|
imgSelector: 'svg',
|
||||||
|
prefix: 'test-source',
|
||||||
|
enableDrag: true,
|
||||||
|
enableWheelZoom: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import ImageToolButton from '../ImageToolButton'
|
||||||
|
|
||||||
|
// Mock antd components
|
||||||
|
vi.mock('antd', () => ({
|
||||||
|
Button: vi.fn(({ children, onClick, ...props }) => (
|
||||||
|
<button type="button" data-testid="custom-button" onClick={onClick} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)),
|
||||||
|
Tooltip: vi.fn(({ children, title }) => <div title={title}>{children}</div>)
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('ImageToolButton', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
tooltip: 'Test tooltip',
|
||||||
|
icon: <span data-testid="test-icon">Icon</span>,
|
||||||
|
onClick: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should match snapshot', () => {
|
||||||
|
const { asFragment } = render(<ImageToolButton {...defaultProps} />)
|
||||||
|
expect(asFragment()).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import ImageToolbar from '../ImageToolbar'
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock ImageToolButton
|
||||||
|
vi.mock('../ImageToolButton', () => ({
|
||||||
|
default: vi.fn(({ tooltip, onClick, icon }) => (
|
||||||
|
<button type="button" onClick={onClick} role="button" aria-label={tooltip}>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock lucide-react icons
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
ChevronUp: () => <span data-testid="chevron-up">↑</span>,
|
||||||
|
ChevronDown: () => <span data-testid="chevron-down">↓</span>,
|
||||||
|
ChevronLeft: () => <span data-testid="chevron-left">←</span>,
|
||||||
|
ChevronRight: () => <span data-testid="chevron-right">→</span>,
|
||||||
|
ZoomIn: () => <span data-testid="zoom-in">+</span>,
|
||||||
|
ZoomOut: () => <span data-testid="zoom-out">-</span>,
|
||||||
|
Scan: () => <span data-testid="scan">⊞</span>
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Icons', () => ({
|
||||||
|
ResetIcon: () => <span data-testid="reset">↻</span>
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock utils
|
||||||
|
vi.mock('@renderer/utils', () => ({
|
||||||
|
classNames: (...args: any[]) => args.filter(Boolean).join(' ')
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('ImageToolbar', () => {
|
||||||
|
const mockPan = vi.fn()
|
||||||
|
const mockZoom = vi.fn()
|
||||||
|
const mockOpenDialog = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should match snapshot', () => {
|
||||||
|
const { asFragment } = render(<ImageToolbar pan={mockPan} zoom={mockZoom} dialog={mockOpenDialog} />)
|
||||||
|
expect(asFragment()).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onPan with correct values when pan buttons are clicked', () => {
|
||||||
|
render(<ImageToolbar pan={mockPan} zoom={mockZoom} dialog={mockOpenDialog} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'preview.pan_up' }))
|
||||||
|
expect(mockPan).toHaveBeenCalledWith(0, -20)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'preview.pan_down' }))
|
||||||
|
expect(mockPan).toHaveBeenCalledWith(0, 20)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'preview.pan_left' }))
|
||||||
|
expect(mockPan).toHaveBeenCalledWith(-20, 0)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'preview.pan_right' }))
|
||||||
|
expect(mockPan).toHaveBeenCalledWith(20, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onZoom with correct values when zoom buttons are clicked', () => {
|
||||||
|
render(<ImageToolbar pan={mockPan} zoom={mockZoom} dialog={mockOpenDialog} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'preview.zoom_in' }))
|
||||||
|
expect(mockZoom).toHaveBeenCalledWith(0.1)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'preview.zoom_out' }))
|
||||||
|
expect(mockZoom).toHaveBeenCalledWith(-0.1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onReset with correct values when reset button is clicked', () => {
|
||||||
|
render(<ImageToolbar pan={mockPan} zoom={mockZoom} dialog={mockOpenDialog} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'preview.reset' }))
|
||||||
|
expect(mockPan).toHaveBeenCalledWith(0, 0, true)
|
||||||
|
expect(mockZoom).toHaveBeenCalledWith(1, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onOpenDialog when dialog button is clicked', () => {
|
||||||
|
render(<ImageToolbar pan={mockPan} zoom={mockZoom} dialog={mockOpenDialog} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'preview.dialog' }))
|
||||||
|
expect(mockOpenDialog).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,259 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { MermaidPreview } from '..'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
useMermaid: vi.fn(),
|
||||||
|
useDebouncedRender: vi.fn(),
|
||||||
|
ImagePreviewLayout: vi.fn(({ children, loading, error, enableToolbar, source }) => (
|
||||||
|
<div data-testid="image-preview-layout" data-source={source}>
|
||||||
|
{enableToolbar && <div data-testid="toolbar">Toolbar</div>}
|
||||||
|
{loading && <div data-testid="loading">Loading...</div>}
|
||||||
|
{error && <div data-testid="error">{error}</div>}
|
||||||
|
<div data-testid="preview-content">{children}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock hooks
|
||||||
|
vi.mock('@renderer/hooks/useMermaid', () => ({
|
||||||
|
useMermaid: () => mocks.useMermaid()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Preview/ImagePreviewLayout', () => ({
|
||||||
|
default: mocks.ImagePreviewLayout
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Preview/hooks/useDebouncedRender', () => ({
|
||||||
|
useDebouncedRender: mocks.useDebouncedRender
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock nanoid
|
||||||
|
vi.mock('@reduxjs/toolkit', () => ({
|
||||||
|
nanoid: () => 'test-id-123456'
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('MermaidPreview', () => {
|
||||||
|
const mermaidCode = 'graph TD\nA-->B'
|
||||||
|
const mockContainerRef = { current: document.createElement('div') }
|
||||||
|
|
||||||
|
const mockMermaid = {
|
||||||
|
parse: vi.fn(),
|
||||||
|
render: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create mock useDebouncedRender return value
|
||||||
|
const createMockHookReturn = (overrides = {}) => ({
|
||||||
|
containerRef: mockContainerRef,
|
||||||
|
error: null,
|
||||||
|
isLoading: false,
|
||||||
|
triggerRender: vi.fn(),
|
||||||
|
cancelRender: vi.fn(),
|
||||||
|
clearError: vi.fn(),
|
||||||
|
setLoading: vi.fn(),
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup default mocks
|
||||||
|
mocks.useMermaid.mockReturnValue({
|
||||||
|
mermaid: mockMermaid,
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn())
|
||||||
|
|
||||||
|
mockMermaid.parse.mockResolvedValue(true)
|
||||||
|
mockMermaid.render.mockResolvedValue({
|
||||||
|
svg: '<svg class="flowchart" viewBox="0 0 100 100"><g>test diagram</g></svg>'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock MutationObserver
|
||||||
|
global.MutationObserver = vi.fn().mockImplementation(() => ({
|
||||||
|
observe: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
takeRecords: vi.fn()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('basic rendering', () => {
|
||||||
|
it('should match snapshot', () => {
|
||||||
|
const { container } = render(<MermaidPreview enableToolbar>{mermaidCode}</MermaidPreview>)
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle valid mermaid content', () => {
|
||||||
|
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
|
||||||
|
expect(mocks.useDebouncedRender).toHaveBeenCalledWith(
|
||||||
|
mermaidCode,
|
||||||
|
expect.any(Function),
|
||||||
|
expect.objectContaining({
|
||||||
|
debounceDelay: 300,
|
||||||
|
shouldRender: expect.any(Function)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty content', () => {
|
||||||
|
render(<MermaidPreview>{''}</MermaidPreview>)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
|
||||||
|
expect(mocks.useDebouncedRender).toHaveBeenCalledWith('', expect.any(Function), expect.any(Object))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loading state', () => {
|
||||||
|
it('should show loading when useMermaid is loading', () => {
|
||||||
|
mocks.useMermaid.mockReturnValue({
|
||||||
|
mermaid: mockMermaid,
|
||||||
|
isLoading: true,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show loading when useDebouncedRender is loading', () => {
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: true }))
|
||||||
|
|
||||||
|
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show loading when both are not loading', () => {
|
||||||
|
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should show error from useMermaid', () => {
|
||||||
|
const mermaidError = 'Mermaid initialization failed'
|
||||||
|
mocks.useMermaid.mockReturnValue({
|
||||||
|
mermaid: mockMermaid,
|
||||||
|
isLoading: false,
|
||||||
|
error: mermaidError
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
||||||
|
|
||||||
|
const errorElement = screen.getByTestId('error')
|
||||||
|
expect(errorElement).toBeInTheDocument()
|
||||||
|
expect(errorElement).toHaveTextContent(mermaidError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show error from useDebouncedRender', () => {
|
||||||
|
const renderError = 'Diagram rendering failed'
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: renderError }))
|
||||||
|
|
||||||
|
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
||||||
|
|
||||||
|
const errorElement = screen.getByTestId('error')
|
||||||
|
expect(errorElement).toBeInTheDocument()
|
||||||
|
expect(errorElement).toHaveTextContent(renderError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prioritize useMermaid error over render error', () => {
|
||||||
|
const mermaidError = 'Mermaid initialization failed'
|
||||||
|
const renderError = 'Diagram rendering failed'
|
||||||
|
|
||||||
|
mocks.useMermaid.mockReturnValue({
|
||||||
|
mermaid: mockMermaid,
|
||||||
|
isLoading: false,
|
||||||
|
error: mermaidError
|
||||||
|
})
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: renderError }))
|
||||||
|
|
||||||
|
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
||||||
|
|
||||||
|
const errorElement = screen.getByTestId('error')
|
||||||
|
expect(errorElement).toHaveTextContent(mermaidError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ref forwarding', () => {
|
||||||
|
it('should forward ref to ImagePreviewLayout', () => {
|
||||||
|
const ref = { current: null }
|
||||||
|
render(<MermaidPreview ref={ref}>{mermaidCode}</MermaidPreview>)
|
||||||
|
|
||||||
|
expect(mocks.ImagePreviewLayout).toHaveBeenCalledWith(expect.objectContaining({ ref }), undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('visibility detection', () => {
|
||||||
|
it('should observe parent elements up to fold className', () => {
|
||||||
|
// Create a DOM structure that simulates MessageGroup fold layout
|
||||||
|
const foldContainer = document.createElement('div')
|
||||||
|
foldContainer.className = 'fold selected'
|
||||||
|
|
||||||
|
const messageWrapper = document.createElement('div')
|
||||||
|
messageWrapper.className = 'message-wrapper'
|
||||||
|
|
||||||
|
const codeBlock = document.createElement('div')
|
||||||
|
codeBlock.className = 'code-block'
|
||||||
|
|
||||||
|
foldContainer.appendChild(messageWrapper)
|
||||||
|
messageWrapper.appendChild(codeBlock)
|
||||||
|
document.body.appendChild(foldContainer)
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<MermaidPreview>{mermaidCode}</MermaidPreview>, {
|
||||||
|
container: codeBlock
|
||||||
|
})
|
||||||
|
|
||||||
|
const observerInstance = (global.MutationObserver as Mock).mock.results[0]?.value
|
||||||
|
expect(observerInstance.observe).toHaveBeenCalled()
|
||||||
|
} finally {
|
||||||
|
// Cleanup
|
||||||
|
document.body.removeChild(foldContainer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle visibility changes and trigger re-render', () => {
|
||||||
|
const mockTriggerRender = vi.fn()
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ triggerRender: mockTriggerRender }))
|
||||||
|
|
||||||
|
const { container } = render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
||||||
|
|
||||||
|
// Get the MutationObserver callback
|
||||||
|
const observerCallback = (global.MutationObserver as Mock).mock.calls[0][0]
|
||||||
|
|
||||||
|
// Mock the container element to be initially hidden
|
||||||
|
const mermaidElement = container.querySelector('.mermaid')
|
||||||
|
Object.defineProperty(mermaidElement, 'offsetParent', {
|
||||||
|
get: () => null, // Hidden
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simulate MutationObserver detecting visibility change
|
||||||
|
observerCallback([])
|
||||||
|
|
||||||
|
// Now make it visible
|
||||||
|
Object.defineProperty(mermaidElement, 'offsetParent', {
|
||||||
|
get: () => document.body, // Visible
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simulate another MutationObserver callback for visibility change
|
||||||
|
observerCallback([])
|
||||||
|
|
||||||
|
// The visibility change should have been detected and component should be ready to re-render
|
||||||
|
// We verify the component structure is correct for potential re-rendering
|
||||||
|
expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
|
||||||
|
expect(mermaidElement).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,169 @@
|
|||||||
|
import PlantUmlPreview from '@renderer/components/Preview/PlantUmlPreview'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Use vi.hoisted to manage mocks
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
ImagePreviewLayout: vi.fn(({ children, loading, error, enableToolbar, source }) => (
|
||||||
|
<div data-testid="image-preview-layout" data-source={source}>
|
||||||
|
{enableToolbar && <div data-testid="toolbar">Toolbar</div>}
|
||||||
|
{loading && <div data-testid="loading">Loading...</div>}
|
||||||
|
{error && <div data-testid="error">{error}</div>}
|
||||||
|
<div data-testid="preview-content">{children}</div>
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
renderSvgInShadowHost: vi.fn(),
|
||||||
|
useDebouncedRender: vi.fn(),
|
||||||
|
logger: {
|
||||||
|
warn: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Preview/ImagePreviewLayout', () => ({
|
||||||
|
default: mocks.ImagePreviewLayout
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Preview/utils', () => ({
|
||||||
|
renderSvgInShadowHost: mocks.renderSvgInShadowHost
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Preview/hooks/useDebouncedRender', () => ({
|
||||||
|
useDebouncedRender: mocks.useDebouncedRender
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('PlantUmlPreview', () => {
|
||||||
|
const diagram = '@startuml\nA -> B\n@enduml'
|
||||||
|
const mockContainerRef = { current: document.createElement('div') }
|
||||||
|
|
||||||
|
// Helper function to create mock useDebouncedRender return value
|
||||||
|
const createMockHookReturn = (overrides = {}) => ({
|
||||||
|
containerRef: mockContainerRef,
|
||||||
|
error: null,
|
||||||
|
isLoading: false,
|
||||||
|
triggerRender: vi.fn(),
|
||||||
|
cancelRender: vi.fn(),
|
||||||
|
clearError: vi.fn(),
|
||||||
|
setLoading: vi.fn(),
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup default successful state
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('basic rendering', () => {
|
||||||
|
it('should match snapshot', () => {
|
||||||
|
const { container } = render(<PlantUmlPreview enableToolbar>{diagram}</PlantUmlPreview>)
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle valid plantuml diagram', () => {
|
||||||
|
render(<PlantUmlPreview>{diagram}</PlantUmlPreview>)
|
||||||
|
|
||||||
|
// Component should render without throwing
|
||||||
|
expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
|
||||||
|
expect(mocks.useDebouncedRender).toHaveBeenCalledWith(
|
||||||
|
diagram,
|
||||||
|
expect.any(Function),
|
||||||
|
expect.objectContaining({ debounceDelay: 300 })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty content', () => {
|
||||||
|
render(<PlantUmlPreview>{''}</PlantUmlPreview>)
|
||||||
|
|
||||||
|
// Component should render without throwing
|
||||||
|
expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
|
||||||
|
expect(mocks.useDebouncedRender).toHaveBeenCalledWith('', expect.any(Function), expect.any(Object))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loading state', () => {
|
||||||
|
it('should show loading indicator when rendering', () => {
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: true }))
|
||||||
|
|
||||||
|
render(<PlantUmlPreview>{diagram}</PlantUmlPreview>)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show loading indicator when not rendering', () => {
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: false }))
|
||||||
|
|
||||||
|
render(<PlantUmlPreview>{diagram}</PlantUmlPreview>)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should show network error message', () => {
|
||||||
|
const networkError = 'Network Error: Unable to connect to PlantUML server. Please check your network connection.'
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: networkError }))
|
||||||
|
|
||||||
|
render(<PlantUmlPreview>{diagram}</PlantUmlPreview>)
|
||||||
|
|
||||||
|
const errorElement = screen.getByTestId('error')
|
||||||
|
expect(errorElement).toBeInTheDocument()
|
||||||
|
expect(errorElement).toHaveTextContent(networkError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show syntax error message for invalid diagram', () => {
|
||||||
|
const syntaxError =
|
||||||
|
'Diagram rendering failed (400): This is likely due to a syntax error in the diagram. Please check your code.'
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: syntaxError }))
|
||||||
|
|
||||||
|
render(<PlantUmlPreview>{diagram}</PlantUmlPreview>)
|
||||||
|
|
||||||
|
const errorElement = screen.getByTestId('error')
|
||||||
|
expect(errorElement).toBeInTheDocument()
|
||||||
|
expect(errorElement).toHaveTextContent(syntaxError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show server error message', () => {
|
||||||
|
const serverError =
|
||||||
|
'Diagram rendering failed (503): The PlantUML server is temporarily unavailable. Please try again later.'
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: serverError }))
|
||||||
|
|
||||||
|
render(<PlantUmlPreview>{diagram}</PlantUmlPreview>)
|
||||||
|
|
||||||
|
const errorElement = screen.getByTestId('error')
|
||||||
|
expect(errorElement).toBeInTheDocument()
|
||||||
|
expect(errorElement).toHaveTextContent(serverError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show generic error message for other errors', () => {
|
||||||
|
const genericError = "Diagram rendering failed, server returned: 418 I'm a teapot"
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: genericError }))
|
||||||
|
|
||||||
|
render(<PlantUmlPreview>{diagram}</PlantUmlPreview>)
|
||||||
|
|
||||||
|
const errorElement = screen.getByTestId('error')
|
||||||
|
expect(errorElement).toBeInTheDocument()
|
||||||
|
expect(errorElement).toHaveTextContent(genericError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show error when rendering is successful', () => {
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: null }))
|
||||||
|
|
||||||
|
render(<PlantUmlPreview>{diagram}</PlantUmlPreview>)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('error')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ref forwarding', () => {
|
||||||
|
it('should forward ref to ImagePreviewLayout', () => {
|
||||||
|
const ref = { current: null }
|
||||||
|
render(<PlantUmlPreview ref={ref}>{diagram}</PlantUmlPreview>)
|
||||||
|
|
||||||
|
// The ref should be passed to ImagePreviewLayout
|
||||||
|
expect(mocks.ImagePreviewLayout).toHaveBeenCalledWith(expect.objectContaining({ ref }), undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,149 @@
|
|||||||
|
import SvgPreview from '@renderer/components/Preview/SvgPreview'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Use vi.hoisted to manage mocks
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
ImagePreviewLayout: vi.fn(({ children, loading, error, enableToolbar, source }) => (
|
||||||
|
<div data-testid="image-preview-layout" data-source={source}>
|
||||||
|
{enableToolbar && <div data-testid="toolbar">Toolbar</div>}
|
||||||
|
{loading && <div data-testid="loading">Loading...</div>}
|
||||||
|
{error && <div data-testid="error">{error}</div>}
|
||||||
|
<div data-testid="preview-content">{children}</div>
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
renderSvgInShadowHost: vi.fn(),
|
||||||
|
useDebouncedRender: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Preview/ImagePreviewLayout', () => ({
|
||||||
|
default: mocks.ImagePreviewLayout
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Preview/utils', () => ({
|
||||||
|
renderSvgInShadowHost: mocks.renderSvgInShadowHost
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/Preview/hooks/useDebouncedRender', () => ({
|
||||||
|
useDebouncedRender: mocks.useDebouncedRender
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('SvgPreview', () => {
|
||||||
|
const svgContent = '<svg><rect width="100" height="100" /></svg>'
|
||||||
|
const mockContainerRef = { current: document.createElement('div') }
|
||||||
|
|
||||||
|
// Helper function to create mock useDebouncedRender return value
|
||||||
|
const createMockHookReturn = (overrides = {}) => ({
|
||||||
|
containerRef: mockContainerRef,
|
||||||
|
error: null,
|
||||||
|
isLoading: false,
|
||||||
|
triggerRender: vi.fn(),
|
||||||
|
cancelRender: vi.fn(),
|
||||||
|
clearError: vi.fn(),
|
||||||
|
setLoading: vi.fn(),
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup default successful state
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('basic rendering', () => {
|
||||||
|
it('should match snapshot', () => {
|
||||||
|
const { container } = render(<SvgPreview enableToolbar>{svgContent}</SvgPreview>)
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle valid svg content', () => {
|
||||||
|
render(<SvgPreview>{svgContent}</SvgPreview>)
|
||||||
|
|
||||||
|
// Component should render without throwing
|
||||||
|
expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
|
||||||
|
expect(mocks.useDebouncedRender).toHaveBeenCalledWith(
|
||||||
|
svgContent,
|
||||||
|
expect.any(Function),
|
||||||
|
expect.objectContaining({ debounceDelay: 300 })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty content', () => {
|
||||||
|
render(<SvgPreview>{''}</SvgPreview>)
|
||||||
|
|
||||||
|
// Component should render without throwing
|
||||||
|
expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
|
||||||
|
expect(mocks.useDebouncedRender).toHaveBeenCalledWith('', expect.any(Function), expect.any(Object))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loading state', () => {
|
||||||
|
it('should show loading indicator when rendering', () => {
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: true }))
|
||||||
|
|
||||||
|
render(<SvgPreview>{svgContent}</SvgPreview>)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show loading indicator when not rendering', () => {
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: false }))
|
||||||
|
|
||||||
|
render(<SvgPreview>{svgContent}</SvgPreview>)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should show error message when rendering fails', () => {
|
||||||
|
const errorMessage = 'Invalid SVG content'
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: errorMessage }))
|
||||||
|
|
||||||
|
render(<SvgPreview>{svgContent}</SvgPreview>)
|
||||||
|
|
||||||
|
const errorElement = screen.getByTestId('error')
|
||||||
|
expect(errorElement).toBeInTheDocument()
|
||||||
|
expect(errorElement).toHaveTextContent(errorMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show error when rendering is successful', () => {
|
||||||
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: null }))
|
||||||
|
|
||||||
|
render(<SvgPreview>{svgContent}</SvgPreview>)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('error')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('custom styling', () => {
|
||||||
|
it('should use custom className when provided', () => {
|
||||||
|
render(<SvgPreview className="custom-svg-class">{svgContent}</SvgPreview>)
|
||||||
|
|
||||||
|
const content = screen.getByTestId('preview-content')
|
||||||
|
const svgContainer = content.querySelector('.custom-svg-class')
|
||||||
|
expect(svgContainer).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use default className when not provided', () => {
|
||||||
|
render(<SvgPreview>{svgContent}</SvgPreview>)
|
||||||
|
|
||||||
|
const content = screen.getByTestId('preview-content')
|
||||||
|
const svgContainer = content.querySelector('.svg-preview.special-preview')
|
||||||
|
expect(svgContainer).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ref forwarding', () => {
|
||||||
|
it('should forward ref to ImagePreviewLayout', () => {
|
||||||
|
const ref = { current: null }
|
||||||
|
render(<SvgPreview ref={ref}>{svgContent}</SvgPreview>)
|
||||||
|
|
||||||
|
// The ref should be passed to ImagePreviewLayout
|
||||||
|
expect(mocks.ImagePreviewLayout).toHaveBeenCalledWith(expect.objectContaining({ ref }), undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`GraphvizPreview > basic rendering > should match snapshot 1`] = `
|
||||||
|
.c0 {
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
data-source="graphviz"
|
||||||
|
data-testid="image-preview-layout"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="toolbar"
|
||||||
|
>
|
||||||
|
Toolbar
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="preview-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="c0 graphviz special-preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`ImagePreviewLayout > should match snapshot 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
data-testid="spin"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="preview-container"
|
||||||
|
data-vertical="true"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
Test Content
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`ImageToolButton > should match snapshot 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
title="Test tooltip"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="Test tooltip"
|
||||||
|
data-testid="custom-button"
|
||||||
|
icon="[object Object]"
|
||||||
|
role="button"
|
||||||
|
shape="circle"
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
@ -0,0 +1,141 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`ImageToolbar > should match snapshot 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
.c0 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: absolute;
|
||||||
|
gap: 4px;
|
||||||
|
right: 1em;
|
||||||
|
bottom: 1em;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c0 .ant-btn {
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c1 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-label="preview.label"
|
||||||
|
class="c0 preview-toolbar"
|
||||||
|
role="toolbar"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="c1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="c2"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label="preview.pan_up"
|
||||||
|
role="button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-testid="chevron-up"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="preview.dialog"
|
||||||
|
role="button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-testid="scan"
|
||||||
|
>
|
||||||
|
⊞
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="c1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="preview.pan_left"
|
||||||
|
role="button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-testid="chevron-left"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="preview.reset"
|
||||||
|
role="button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-testid="reset"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="preview.pan_right"
|
||||||
|
role="button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-testid="chevron-right"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="c1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="preview.zoom_out"
|
||||||
|
role="button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-testid="zoom-out"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="preview.pan_down"
|
||||||
|
role="button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-testid="chevron-down"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="preview.zoom_in"
|
||||||
|
role="button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-testid="zoom-in"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`MermaidPreview > basic rendering > should match snapshot 1`] = `
|
||||||
|
.c0 {
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
data-source="mermaid"
|
||||||
|
data-testid="image-preview-layout"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="toolbar"
|
||||||
|
>
|
||||||
|
Toolbar
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="preview-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="c0 mermaid special-preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`PlantUmlPreview > basic rendering > should match snapshot 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
data-source="plantuml"
|
||||||
|
data-testid="image-preview-layout"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="toolbar"
|
||||||
|
>
|
||||||
|
Toolbar
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="preview-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="plantuml-preview special-preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`SvgPreview > basic rendering > should match snapshot 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
data-source="svg"
|
||||||
|
data-testid="image-preview-layout"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="toolbar"
|
||||||
|
>
|
||||||
|
Toolbar
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="preview-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="svg-preview special-preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useDebouncedRender } from '../hooks/useDebouncedRender'
|
||||||
|
|
||||||
|
describe('useDebouncedRender', () => {
|
||||||
|
const mockRenderFunction = vi.fn()
|
||||||
|
|
||||||
|
it('should return expected interface', () => {
|
||||||
|
const { result } = renderHook(() => useDebouncedRender('test content', mockRenderFunction))
|
||||||
|
|
||||||
|
// Verify hook returns all expected properties
|
||||||
|
expect(result.current).toHaveProperty('containerRef')
|
||||||
|
expect(result.current).toHaveProperty('error')
|
||||||
|
expect(result.current).toHaveProperty('isLoading')
|
||||||
|
expect(result.current).toHaveProperty('triggerRender')
|
||||||
|
expect(result.current).toHaveProperty('cancelRender')
|
||||||
|
expect(result.current).toHaveProperty('clearError')
|
||||||
|
expect(result.current).toHaveProperty('setLoading')
|
||||||
|
|
||||||
|
// Verify types of returned values
|
||||||
|
expect(result.current.containerRef).toEqual(expect.objectContaining({ current: null }))
|
||||||
|
expect(result.current.error).toBe(null)
|
||||||
|
expect(typeof result.current.isLoading).toBe('boolean')
|
||||||
|
expect(typeof result.current.triggerRender).toBe('function')
|
||||||
|
expect(typeof result.current.cancelRender).toBe('function')
|
||||||
|
expect(typeof result.current.clearError).toBe('function')
|
||||||
|
expect(typeof result.current.setLoading).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle different hook configurations', () => {
|
||||||
|
const shouldRender = vi.fn(() => true)
|
||||||
|
const options = {
|
||||||
|
debounceDelay: 500,
|
||||||
|
shouldRender
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDebouncedRender('content', mockRenderFunction, options))
|
||||||
|
|
||||||
|
// Hook should still return the expected interface regardless of options
|
||||||
|
expect(result.current).toHaveProperty('containerRef')
|
||||||
|
expect(result.current).toHaveProperty('error')
|
||||||
|
expect(result.current).toHaveProperty('isLoading')
|
||||||
|
expect(result.current).toHaveProperty('triggerRender')
|
||||||
|
expect(result.current).toHaveProperty('cancelRender')
|
||||||
|
expect(result.current).toHaveProperty('clearError')
|
||||||
|
expect(result.current).toHaveProperty('setLoading')
|
||||||
|
})
|
||||||
|
})
|
||||||
105
src/renderer/src/components/Preview/__tests__/utils.test.ts
Normal file
105
src/renderer/src/components/Preview/__tests__/utils.test.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { renderSvgInShadowHost } from '@renderer/components/Preview/utils'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
describe('renderSvgInShadowHost', () => {
|
||||||
|
let hostElement: HTMLElement
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
hostElement = document.createElement('div')
|
||||||
|
document.body.appendChild(hostElement)
|
||||||
|
|
||||||
|
// Mock attachShadow
|
||||||
|
Element.prototype.attachShadow = vi.fn().mockImplementation(function (this: HTMLElement) {
|
||||||
|
const shadowRoot = document.createElement('div')
|
||||||
|
Object.defineProperty(this, 'shadowRoot', {
|
||||||
|
value: shadowRoot,
|
||||||
|
writable: true,
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
// Simple innerHTML copy for test verification
|
||||||
|
Object.defineProperty(shadowRoot, 'innerHTML', {
|
||||||
|
set(value) {
|
||||||
|
shadowRoot.textContent = value // A simplified mock
|
||||||
|
},
|
||||||
|
get() {
|
||||||
|
return shadowRoot.textContent || ''
|
||||||
|
},
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
|
||||||
|
shadowRoot.appendChild = vi.fn(<T extends Node>(node: T): T => {
|
||||||
|
shadowRoot.append(node)
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
|
||||||
|
return shadowRoot as unknown as ShadowRoot
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (hostElement && hostElement.parentNode) {
|
||||||
|
hostElement.parentNode.removeChild(hostElement)
|
||||||
|
}
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should attach a shadow root if one does not exist', () => {
|
||||||
|
renderSvgInShadowHost('<svg></svg>', hostElement)
|
||||||
|
expect(Element.prototype.attachShadow).toHaveBeenCalledWith({ mode: 'open' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not attach a new shadow root if one already exists', () => {
|
||||||
|
// Attach a shadow root first
|
||||||
|
const existingShadowRoot = hostElement.attachShadow({ mode: 'open' })
|
||||||
|
vi.clearAllMocks() // Clear the mock call from the setup
|
||||||
|
|
||||||
|
renderSvgInShadowHost('<svg></svg>', hostElement)
|
||||||
|
|
||||||
|
expect(Element.prototype.attachShadow).not.toHaveBeenCalled()
|
||||||
|
// Verify it works with the existing shadow root
|
||||||
|
expect(existingShadowRoot.appendChild).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should inject styles and valid SVG content into the shadow DOM', () => {
|
||||||
|
const svgContent = '<svg><rect /></svg>'
|
||||||
|
renderSvgInShadowHost(svgContent, hostElement)
|
||||||
|
|
||||||
|
const shadowRoot = hostElement.shadowRoot
|
||||||
|
expect(shadowRoot).not.toBeNull()
|
||||||
|
expect(shadowRoot?.querySelector('style')).not.toBeNull()
|
||||||
|
expect(shadowRoot?.querySelector('svg')).not.toBeNull()
|
||||||
|
expect(shadowRoot?.querySelector('rect')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if the host element is not available', () => {
|
||||||
|
expect(() => renderSvgInShadowHost('<svg></svg>', null as any)).toThrow(
|
||||||
|
'Host element for SVG rendering is not available.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error for invalid SVG content', () => {
|
||||||
|
const invalidSvg = '<svg><rect></svg>' // Malformed
|
||||||
|
expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).toThrow(/SVG parsing error/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error for non-SVG content', () => {
|
||||||
|
const nonSvg = '<div>this is not svg</div>'
|
||||||
|
expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow('Invalid SVG content')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not throw an error for empty or whitespace content', () => {
|
||||||
|
expect(() => renderSvgInShadowHost('', hostElement)).not.toThrow()
|
||||||
|
expect(() => renderSvgInShadowHost(' ', hostElement)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear previous content before rendering new content', () => {
|
||||||
|
const firstSvg = '<svg id="first"></svg>'
|
||||||
|
renderSvgInShadowHost(firstSvg, hostElement)
|
||||||
|
expect(hostElement.shadowRoot?.querySelector('#first')).not.toBeNull()
|
||||||
|
|
||||||
|
const secondSvg = '<svg id="second"></svg>'
|
||||||
|
renderSvgInShadowHost(secondSvg, hostElement)
|
||||||
|
expect(hostElement.shadowRoot?.querySelector('#first')).toBeNull()
|
||||||
|
expect(hostElement.shadowRoot?.querySelector('#second')).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
167
src/renderer/src/components/Preview/hooks/useDebouncedRender.ts
Normal file
167
src/renderer/src/components/Preview/hooks/useDebouncedRender.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { debounce } from 'lodash'
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('useDebouncedRender')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览渲染器选项
|
||||||
|
*/
|
||||||
|
export interface DebouncedRenderOptions {
|
||||||
|
/** 防抖延迟时间,默认 300ms */
|
||||||
|
debounceDelay?: number
|
||||||
|
/** 渲染前的额外条件检查 */
|
||||||
|
shouldRender?: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览渲染器返回值
|
||||||
|
*/
|
||||||
|
export interface DebouncedRenderResult {
|
||||||
|
/** 容器元素引用 */
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
/** 错误状态 */
|
||||||
|
error: string | null
|
||||||
|
/** 加载状态 */
|
||||||
|
isLoading: boolean
|
||||||
|
/** 手动触发渲染 */
|
||||||
|
triggerRender: (content: string) => void
|
||||||
|
/** 取消渲染 */
|
||||||
|
cancelRender: () => void
|
||||||
|
/** 清除错误状态 */
|
||||||
|
clearError: () => void
|
||||||
|
/** 手动设置加载状态 */
|
||||||
|
setLoading: (loading: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图像预览防抖渲染器 Hook
|
||||||
|
*
|
||||||
|
* - 容器 ref 管理
|
||||||
|
* - value 变化监听
|
||||||
|
* - 防抖渲染
|
||||||
|
* - 错误处理
|
||||||
|
* - 加载状态管理
|
||||||
|
*
|
||||||
|
* @param value 要渲染的内容
|
||||||
|
* @param renderFunction 实际的渲染函数,接收内容和容器元素
|
||||||
|
* @param options 配置选项
|
||||||
|
* @returns 渲染器状态、容器引用和控制函数
|
||||||
|
*/
|
||||||
|
export const useDebouncedRender = (
|
||||||
|
value: string,
|
||||||
|
renderFunction: (content: string, container: HTMLDivElement) => Promise<void>,
|
||||||
|
options: DebouncedRenderOptions = {}
|
||||||
|
): DebouncedRenderResult => {
|
||||||
|
const { debounceDelay = 300, shouldRender } = options
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const debouncedFunctionRef = useRef<ReturnType<typeof debounce> | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
// 包装渲染函数,添加容器检查和错误处理
|
||||||
|
const wrappedRenderFunction = useCallback(
|
||||||
|
async (content: string): Promise<void> => {
|
||||||
|
// 检查渲染前条件
|
||||||
|
if ((shouldRender && !shouldRender()) || !content) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!containerRef.current) {
|
||||||
|
logger.warn('Container element not available')
|
||||||
|
throw new Error('Container element not available')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
await renderFunction(content, containerRef.current)
|
||||||
|
|
||||||
|
// 渲染成功,确保清除错误状态
|
||||||
|
setError(null)
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown rendering error'
|
||||||
|
logger.error(errorMessage)
|
||||||
|
setError(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[renderFunction, shouldRender]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 创建防抖版本的渲染函数
|
||||||
|
const debouncedRender = useMemo(() => {
|
||||||
|
const debouncedFn = debounce((content: string) => {
|
||||||
|
React.startTransition(() => {
|
||||||
|
wrappedRenderFunction(content)
|
||||||
|
})
|
||||||
|
}, debounceDelay)
|
||||||
|
|
||||||
|
// 存储引用用于后续取消
|
||||||
|
debouncedFunctionRef.current = debouncedFn
|
||||||
|
|
||||||
|
return debouncedFn
|
||||||
|
}, [wrappedRenderFunction, debounceDelay])
|
||||||
|
|
||||||
|
// 手动触发渲染的函数
|
||||||
|
const triggerRender = useCallback(
|
||||||
|
(content: string) => {
|
||||||
|
if (content) {
|
||||||
|
setIsLoading(true)
|
||||||
|
debouncedRender(content)
|
||||||
|
} else {
|
||||||
|
debouncedRender.cancel()
|
||||||
|
setIsLoading(false)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[debouncedRender]
|
||||||
|
)
|
||||||
|
|
||||||
|
const cancelRender = useCallback(() => {
|
||||||
|
debouncedRender.cancel()
|
||||||
|
setIsLoading(false)
|
||||||
|
}, [debouncedRender])
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 手动设置加载状态
|
||||||
|
const setLoadingState = useCallback((loading: boolean) => {
|
||||||
|
setIsLoading(loading)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 监听 children 变化,自动触发渲染
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
triggerRender(value)
|
||||||
|
} else {
|
||||||
|
cancelRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelRender()
|
||||||
|
}
|
||||||
|
}, [value, triggerRender, cancelRender])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debouncedFunctionRef.current) {
|
||||||
|
debouncedFunctionRef.current.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerRef,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
triggerRender,
|
||||||
|
cancelRender,
|
||||||
|
clearError,
|
||||||
|
setLoading: setLoadingState
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/renderer/src/components/Preview/index.ts
Normal file
5
src/renderer/src/components/Preview/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { default as GraphvizPreview } from './GraphvizPreview'
|
||||||
|
export { default as MermaidPreview } from './MermaidPreview'
|
||||||
|
export { default as PlantUmlPreview } from './PlantUmlPreview'
|
||||||
|
export { default as SvgPreview } from './SvgPreview'
|
||||||
|
export * from './types'
|
||||||
35
src/renderer/src/components/Preview/styles.ts
Normal file
35
src/renderer/src/components/Preview/styles.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Flex } from 'antd'
|
||||||
|
import { styled } from 'styled-components'
|
||||||
|
|
||||||
|
export const PreviewError = styled.div`
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
color: #ff4d4f;
|
||||||
|
border: 1px solid #ff4d4f;
|
||||||
|
border-radius: 4px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const PreviewContainer = styled(Flex).attrs({ role: 'alert' })`
|
||||||
|
position: relative;
|
||||||
|
/* Make sure the toolbar is visible */
|
||||||
|
min-height: 8rem;
|
||||||
|
|
||||||
|
.special-preview {
|
||||||
|
min-height: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-toolbar {
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
transform: translateZ(0);
|
||||||
|
will-change: opacity;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.preview-toolbar {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
17
src/renderer/src/components/Preview/types.ts
Normal file
17
src/renderer/src/components/Preview/types.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 预览组件的基本 props
|
||||||
|
*/
|
||||||
|
export interface BasicPreviewProps {
|
||||||
|
children: string
|
||||||
|
enableToolbar?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 useImperativeHandle 暴露的方法类型
|
||||||
|
*/
|
||||||
|
export interface BasicPreviewHandles {
|
||||||
|
pan: (dx: number, dy: number, absolute?: boolean) => void
|
||||||
|
zoom: (delta: number, absolute?: boolean) => void
|
||||||
|
copy: () => Promise<void>
|
||||||
|
download: (format: 'svg' | 'png') => Promise<void>
|
||||||
|
}
|
||||||
61
src/renderer/src/components/Preview/utils.ts
Normal file
61
src/renderer/src/components/Preview/utils.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Renders an SVG string inside a host element's Shadow DOM to ensure style encapsulation.
|
||||||
|
* This function handles creating the shadow root, injecting base styles for the host,
|
||||||
|
* and safely parsing and appending the SVG content.
|
||||||
|
*
|
||||||
|
* @param svgContent The SVG string to render.
|
||||||
|
* @param hostElement The container element that will host the Shadow DOM.
|
||||||
|
* @throws An error if the SVG content is invalid or cannot be parsed.
|
||||||
|
*/
|
||||||
|
export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLElement): void {
|
||||||
|
if (!hostElement) {
|
||||||
|
throw new Error('Host element for SVG rendering is not available.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
|
||||||
|
|
||||||
|
// Base styles for the host element
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.textContent = `
|
||||||
|
:host {
|
||||||
|
padding: 1em;
|
||||||
|
background-color: white;
|
||||||
|
overflow: auto;
|
||||||
|
border: 0.5px solid var(--color-code-background);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// Clear previous content and append new style and SVG
|
||||||
|
shadowRoot.innerHTML = ''
|
||||||
|
shadowRoot.appendChild(style)
|
||||||
|
|
||||||
|
// Parse and append the SVG using DOMParser to prevent script execution and check for errors
|
||||||
|
if (svgContent.trim() === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parser = new DOMParser()
|
||||||
|
const doc = parser.parseFromString(svgContent, 'image/svg+xml')
|
||||||
|
|
||||||
|
const parserError = doc.querySelector('parsererror')
|
||||||
|
if (parserError) {
|
||||||
|
// Throw a specific error that can be caught by the calling component
|
||||||
|
throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgElement = doc.documentElement
|
||||||
|
if (svgElement && svgElement.nodeName.toLowerCase() === 'svg') {
|
||||||
|
shadowRoot.appendChild(svgElement.cloneNode(true))
|
||||||
|
} else if (svgContent.trim() !== '') {
|
||||||
|
// Do not throw error for empty content
|
||||||
|
throw new Error('Invalid SVG content: The provided string is not a valid SVG document.')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,221 +0,0 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react'
|
|
||||||
import { act } from 'react'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
|
||||||
|
|
||||||
import MermaidPreview from '../CodeBlockView/MermaidPreview'
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
useMermaid: vi.fn(),
|
|
||||||
usePreviewToolHandlers: vi.fn(),
|
|
||||||
usePreviewTools: vi.fn()
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock hooks
|
|
||||||
vi.mock('@renderer/hooks/useMermaid', () => ({
|
|
||||||
useMermaid: () => mocks.useMermaid()
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@renderer/components/CodeToolbar', () => ({
|
|
||||||
usePreviewToolHandlers: () => mocks.usePreviewToolHandlers(),
|
|
||||||
usePreviewTools: () => mocks.usePreviewTools()
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock nanoid
|
|
||||||
vi.mock('@reduxjs/toolkit', () => ({
|
|
||||||
nanoid: () => 'test-id-123456'
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock lodash debounce
|
|
||||||
vi.mock('lodash', async () => {
|
|
||||||
const actual = await import('lodash')
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
debounce: vi.fn((fn) => {
|
|
||||||
const debounced = (...args: any[]) => fn(...args)
|
|
||||||
debounced.cancel = vi.fn()
|
|
||||||
return debounced
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mock antd components
|
|
||||||
vi.mock('antd', () => ({
|
|
||||||
Flex: ({ children, vertical, ...props }: any) => (
|
|
||||||
<div data-testid="flex" data-vertical={vertical} {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
Spin: ({ children, spinning, indicator }: any) => (
|
|
||||||
<div data-testid="spin" data-spinning={spinning}>
|
|
||||||
{spinning && indicator}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('MermaidPreview', () => {
|
|
||||||
const mockMermaid = {
|
|
||||||
parse: vi.fn(),
|
|
||||||
render: vi.fn()
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
|
|
||||||
mocks.useMermaid.mockReturnValue({
|
|
||||||
mermaid: mockMermaid,
|
|
||||||
isLoading: false,
|
|
||||||
error: null
|
|
||||||
})
|
|
||||||
|
|
||||||
mocks.usePreviewToolHandlers.mockReturnValue({
|
|
||||||
handleZoom: vi.fn(),
|
|
||||||
handleCopyImage: vi.fn(),
|
|
||||||
handleDownload: vi.fn()
|
|
||||||
})
|
|
||||||
|
|
||||||
mocks.usePreviewTools.mockReturnValue({})
|
|
||||||
|
|
||||||
mockMermaid.parse.mockResolvedValue(true)
|
|
||||||
mockMermaid.render.mockResolvedValue({
|
|
||||||
svg: '<svg class="flowchart" viewBox="0 0 100 100"><g>test diagram</g></svg>'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mock MutationObserver
|
|
||||||
global.MutationObserver = vi.fn().mockImplementation(() => ({
|
|
||||||
observe: vi.fn(),
|
|
||||||
disconnect: vi.fn(),
|
|
||||||
takeRecords: vi.fn()
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('visibility detection', () => {
|
|
||||||
it('should not render mermaid when element has display: none', async () => {
|
|
||||||
const mermaidCode = 'graph TD\nA-->B'
|
|
||||||
|
|
||||||
const { container } = render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
|
||||||
|
|
||||||
// Mock offsetParent to be null (simulating display: none)
|
|
||||||
const mermaidElement = container.querySelector('.mermaid')
|
|
||||||
if (mermaidElement) {
|
|
||||||
Object.defineProperty(mermaidElement, 'offsetParent', {
|
|
||||||
get: () => null,
|
|
||||||
configurable: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-render to trigger the effect
|
|
||||||
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
|
||||||
|
|
||||||
// Should not call mermaid render when offsetParent is null
|
|
||||||
expect(mockMermaid.render).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
const svgElement = mermaidElement?.querySelector('svg.flowchart')
|
|
||||||
expect(svgElement).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should setup MutationObserver to monitor parent elements', () => {
|
|
||||||
const mermaidCode = 'graph TD\nA-->B'
|
|
||||||
|
|
||||||
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
|
||||||
|
|
||||||
expect(global.MutationObserver).toHaveBeenCalledWith(expect.any(Function))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should observe parent elements up to fold className', () => {
|
|
||||||
const mermaidCode = 'graph TD\nA-->B'
|
|
||||||
|
|
||||||
// Create a DOM structure that simulates MessageGroup fold layout
|
|
||||||
const foldContainer = document.createElement('div')
|
|
||||||
foldContainer.className = 'fold selected'
|
|
||||||
|
|
||||||
const messageWrapper = document.createElement('div')
|
|
||||||
messageWrapper.className = 'message-wrapper'
|
|
||||||
|
|
||||||
const codeBlock = document.createElement('div')
|
|
||||||
codeBlock.className = 'code-block'
|
|
||||||
|
|
||||||
foldContainer.appendChild(messageWrapper)
|
|
||||||
messageWrapper.appendChild(codeBlock)
|
|
||||||
document.body.appendChild(foldContainer)
|
|
||||||
|
|
||||||
render(<MermaidPreview>{mermaidCode}</MermaidPreview>, {
|
|
||||||
container: codeBlock
|
|
||||||
})
|
|
||||||
|
|
||||||
const observerInstance = (global.MutationObserver as Mock).mock.results[0]?.value
|
|
||||||
expect(observerInstance.observe).toHaveBeenCalled()
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
document.body.removeChild(foldContainer)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should trigger re-render when visibility changes from hidden to visible', async () => {
|
|
||||||
const mermaidCode = 'graph TD\nA-->B'
|
|
||||||
|
|
||||||
const { container, rerender } = render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
|
||||||
|
|
||||||
const mermaidElement = container.querySelector('.mermaid')
|
|
||||||
|
|
||||||
// Initially hidden (offsetParent is null)
|
|
||||||
Object.defineProperty(mermaidElement, 'offsetParent', {
|
|
||||||
get: () => null,
|
|
||||||
configurable: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Clear previous calls
|
|
||||||
mockMermaid.render.mockClear()
|
|
||||||
|
|
||||||
// Re-render with hidden state
|
|
||||||
rerender(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
|
||||||
|
|
||||||
// Should not render when hidden
|
|
||||||
expect(mockMermaid.render).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
// Now make it visible
|
|
||||||
Object.defineProperty(mermaidElement, 'offsetParent', {
|
|
||||||
get: () => document.body,
|
|
||||||
configurable: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Simulate MutationObserver callback
|
|
||||||
const observerCallback = (global.MutationObserver as Mock).mock.calls[0][0]
|
|
||||||
act(() => {
|
|
||||||
observerCallback([])
|
|
||||||
})
|
|
||||||
|
|
||||||
// Re-render to trigger visibility change effect
|
|
||||||
rerender(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockMermaid.render).toHaveBeenCalledWith('mermaid-test-id-123456', mermaidCode, expect.any(Object))
|
|
||||||
|
|
||||||
const svgElement = mermaidElement?.querySelector('svg.flowchart')
|
|
||||||
expect(svgElement).toBeInTheDocument()
|
|
||||||
expect(svgElement).toHaveClass('flowchart')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle mermaid loading state', () => {
|
|
||||||
mocks.useMermaid.mockReturnValue({
|
|
||||||
mermaid: mockMermaid,
|
|
||||||
isLoading: true,
|
|
||||||
error: null
|
|
||||||
})
|
|
||||||
|
|
||||||
const mermaidCode = 'graph TD\nA-->B'
|
|
||||||
|
|
||||||
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
|
||||||
|
|
||||||
// Should not render when mermaid is loading
|
|
||||||
expect(mockMermaid.render).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
// Should show loading state
|
|
||||||
expect(screen.getByTestId('spin')).toHaveAttribute('data-spinning', 'true')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -39,3 +39,5 @@ export const THEME_COLOR_PRESETS = [
|
|||||||
|
|
||||||
export const MAX_CONTEXT_COUNT = 100
|
export const MAX_CONTEXT_COUNT = 100
|
||||||
export const UNLIMITED_CONTEXT_COUNT = 100000
|
export const UNLIMITED_CONTEXT_COUNT = 100000
|
||||||
|
|
||||||
|
export const MAX_COLLAPSED_CODE_HEIGHT = 350
|
||||||
|
|||||||
@ -40,7 +40,7 @@ const defaultCodeStyleContext: CodeStyleContextType = {
|
|||||||
const CodeStyleContext = createContext<CodeStyleContextType>(defaultCodeStyleContext)
|
const CodeStyleContext = createContext<CodeStyleContextType>(defaultCodeStyleContext)
|
||||||
|
|
||||||
export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
const { codeEditor, codePreview } = useSettings()
|
const { codeEditor, codeViewer } = useSettings()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const [shikiThemesInfo, setShikiThemesInfo] = useState<BundledThemeInfo[]>([])
|
const [shikiThemesInfo, setShikiThemesInfo] = useState<BundledThemeInfo[]>([])
|
||||||
useMermaid()
|
useMermaid()
|
||||||
@ -71,12 +71,12 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
|||||||
// 获取当前使用的 Shiki 主题名称(只用于代码预览)
|
// 获取当前使用的 Shiki 主题名称(只用于代码预览)
|
||||||
const activeShikiTheme = useMemo(() => {
|
const activeShikiTheme = useMemo(() => {
|
||||||
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
|
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
|
||||||
const codeStyle = codePreview[field]
|
const codeStyle = codeViewer[field]
|
||||||
if (!codeStyle || codeStyle === 'auto' || !themeNames.includes(codeStyle)) {
|
if (!codeStyle || codeStyle === 'auto' || !themeNames.includes(codeStyle)) {
|
||||||
return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker'
|
return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker'
|
||||||
}
|
}
|
||||||
return codeStyle
|
return codeStyle
|
||||||
}, [theme, codePreview, themeNames])
|
}, [theme, codeViewer, themeNames])
|
||||||
|
|
||||||
const isShikiThemeDark = useMemo(() => {
|
const isShikiThemeDark = useMemo(() => {
|
||||||
const themeInfo = shikiThemesInfo.find((info) => info.id === activeShikiTheme)
|
const themeInfo = shikiThemesInfo.find((info) => info.id === activeShikiTheme)
|
||||||
|
|||||||
206
src/renderer/src/hooks/__tests__/useTemporaryValue.test.ts
Normal file
206
src/renderer/src/hooks/__tests__/useTemporaryValue.test.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useTemporaryValue } from '../useTemporaryValue'
|
||||||
|
|
||||||
|
describe('useTemporaryValue', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// 使用假定时器
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// 恢复真实定时器
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('basic functionality', () => {
|
||||||
|
it('should return the default value initially', () => {
|
||||||
|
const { result } = renderHook(() => useTemporaryValue('default'))
|
||||||
|
const [value] = result.current
|
||||||
|
|
||||||
|
expect(value).toBe('default')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should temporarily change the value and then revert', () => {
|
||||||
|
const { result } = renderHook(() => useTemporaryValue('default', 1000))
|
||||||
|
const [, setTemporaryValue] = result.current
|
||||||
|
|
||||||
|
// 设置临时值
|
||||||
|
act(() => {
|
||||||
|
setTemporaryValue('temporary')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('temporary')
|
||||||
|
|
||||||
|
// 快进定时器
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('default')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle same value as default', () => {
|
||||||
|
const { result } = renderHook(() => useTemporaryValue('default', 1000))
|
||||||
|
const [, setTemporaryValue] = result.current
|
||||||
|
|
||||||
|
// 设置与默认值相同的值
|
||||||
|
act(() => {
|
||||||
|
setTemporaryValue('default')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('default')
|
||||||
|
|
||||||
|
// 快进定时器(即使不需要恢复,也不会出错)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 应该保持默认值
|
||||||
|
expect(result.current[0]).toBe('default')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('timer management', () => {
|
||||||
|
it('should clear timeout on unmount', () => {
|
||||||
|
const { result, unmount } = renderHook(() => useTemporaryValue('default', 1000))
|
||||||
|
const [, setTemporaryValue] = result.current
|
||||||
|
|
||||||
|
// 设置临时值
|
||||||
|
act(() => {
|
||||||
|
setTemporaryValue('temporary')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 验证值已更改
|
||||||
|
expect(result.current[0]).toBe('temporary')
|
||||||
|
|
||||||
|
// 卸载 hook
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
// 快进定时器
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 验证没有错误发生(值保持不变,因为我们已卸载)
|
||||||
|
expect(result.current[0]).toBe('temporary') // 注意:这里应该还是'temporary',因为组件已卸载
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple calls correctly', () => {
|
||||||
|
const { result } = renderHook(() => useTemporaryValue('default', 1000))
|
||||||
|
const [, setTemporaryValue] = result.current
|
||||||
|
|
||||||
|
// 设置临时值
|
||||||
|
act(() => {
|
||||||
|
setTemporaryValue('temporary1')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('temporary1')
|
||||||
|
|
||||||
|
// 在第一个值过期前设置另一个临时值
|
||||||
|
act(() => {
|
||||||
|
setTemporaryValue('temporary2')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('temporary2')
|
||||||
|
|
||||||
|
// 快进定时器
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('default')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle custom duration', () => {
|
||||||
|
const { result } = renderHook(() => useTemporaryValue('default', 500))
|
||||||
|
const [, setTemporaryValue] = result.current
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setTemporaryValue('temporary')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('temporary')
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('default')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle very short duration', () => {
|
||||||
|
const { result } = renderHook(() => useTemporaryValue('default', 0))
|
||||||
|
const [, setTemporaryValue] = result.current
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setTemporaryValue('temporary')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('temporary')
|
||||||
|
|
||||||
|
// 对于0ms的定时器,需要运行所有微任务
|
||||||
|
act(() => {
|
||||||
|
vi.runAllTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('default')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('data types', () => {
|
||||||
|
it.each([
|
||||||
|
[false, true],
|
||||||
|
[0, 5],
|
||||||
|
['', 'temporary'],
|
||||||
|
[null, 'value'],
|
||||||
|
[undefined, 'value'],
|
||||||
|
[{}, { key: 'value' }],
|
||||||
|
[[], [1, 2, 3]]
|
||||||
|
])('should work with type: %p', (defaultValue, temporaryValue) => {
|
||||||
|
const { result } = renderHook(() => useTemporaryValue(defaultValue, 1000))
|
||||||
|
const [, setTemporaryValue] = result.current
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setTemporaryValue(temporaryValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toEqual(temporaryValue)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toEqual(defaultValue)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle same temporary value multiple times', () => {
|
||||||
|
const { result } = renderHook(() => useTemporaryValue('default', 1000))
|
||||||
|
const [, setTemporaryValue] = result.current
|
||||||
|
|
||||||
|
// 设置临时值
|
||||||
|
act(() => {
|
||||||
|
setTemporaryValue('temporary')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('temporary')
|
||||||
|
|
||||||
|
// 再次设置相同的临时值
|
||||||
|
act(() => {
|
||||||
|
setTemporaryValue('temporary')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('temporary')
|
||||||
|
|
||||||
|
// 快进定时器
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('default')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
62
src/renderer/src/hooks/useTemporaryValue.ts
Normal file
62
src/renderer/src/hooks/useTemporaryValue.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hook for managing a temporary value that automatically reverts to its default after a specified duration.
|
||||||
|
*
|
||||||
|
* @param defaultValue - The default value to revert to
|
||||||
|
* @param duration - The duration in milliseconds before the value reverts to default (default: 2000ms)
|
||||||
|
* @returns A tuple containing the current value and a function to set a temporary value
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [copied, setCopiedTemporarily] = useTemporaryValue(false)
|
||||||
|
*
|
||||||
|
* const handleCopy = () => {
|
||||||
|
* // Copy logic here
|
||||||
|
* setCopiedTemporarily(true) // Will automatically revert to false after 2 seconds
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [status, setStatusTemporarily] = useTemporaryValue('idle', 3000)
|
||||||
|
*
|
||||||
|
* const handleSubmit = async () => {
|
||||||
|
* setStatusTemporarily('saving')
|
||||||
|
* await saveData()
|
||||||
|
* setStatusTemporarily('saved') // Will automatically revert to 'idle' after 3 seconds
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export const useTemporaryValue = <T>(defaultValue: T, duration: number = 2000) => {
|
||||||
|
const [value, setValue] = useState<T>(defaultValue)
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
const setTemporaryValue = useCallback(
|
||||||
|
(tempValue: T) => {
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the new value
|
||||||
|
setValue(tempValue)
|
||||||
|
|
||||||
|
// Set timeout to revert to default value
|
||||||
|
if (tempValue !== defaultValue) {
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setValue(defaultValue)
|
||||||
|
timeoutRef.current = null
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[defaultValue, duration]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clear timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return [value, setTemporaryValue] as const
|
||||||
|
}
|
||||||
@ -505,6 +505,7 @@
|
|||||||
"tip": "The run button will be displayed in the toolbar of executable code blocks, please do not execute dangerous code!",
|
"tip": "The run button will be displayed in the toolbar of executable code blocks, please do not execute dangerous code!",
|
||||||
"title": "Code Execution"
|
"title": "Code Execution"
|
||||||
},
|
},
|
||||||
|
"code_image_tools": "Enable preview tools",
|
||||||
"code_wrappable": "Code block wrappable",
|
"code_wrappable": "Code block wrappable",
|
||||||
"context_count": {
|
"context_count": {
|
||||||
"label": "Context",
|
"label": "Context",
|
||||||
@ -648,15 +649,6 @@
|
|||||||
},
|
},
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"more": "More",
|
"more": "More",
|
||||||
"preview": {
|
|
||||||
"copy": {
|
|
||||||
"image": "Copy as image"
|
|
||||||
},
|
|
||||||
"label": "Preview",
|
|
||||||
"source": "View Source Code",
|
|
||||||
"zoom_in": "Zoom In",
|
|
||||||
"zoom_out": "Zoom Out"
|
|
||||||
},
|
|
||||||
"run": "Run",
|
"run": "Run",
|
||||||
"split": {
|
"split": {
|
||||||
"label": "Split View",
|
"label": "Split View",
|
||||||
@ -1159,6 +1151,9 @@
|
|||||||
"failed": "Delete Failed",
|
"failed": "Delete Failed",
|
||||||
"success": "Delete Successful"
|
"success": "Delete Successful"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"failed": "Preview failed"
|
||||||
|
},
|
||||||
"download": {
|
"download": {
|
||||||
"failed": "Download failed",
|
"failed": "Download failed",
|
||||||
"success": "Download successfully"
|
"success": "Download successfully"
|
||||||
@ -1654,6 +1649,22 @@
|
|||||||
"seed_tip": "Controls upscaling randomness"
|
"seed_tip": "Controls upscaling randomness"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"preview": {
|
||||||
|
"copy": {
|
||||||
|
"image": "Copy as image"
|
||||||
|
},
|
||||||
|
"dialog": "Open Dialog",
|
||||||
|
"label": "Preview",
|
||||||
|
"pan": "Pan",
|
||||||
|
"pan_down": "Pan Down",
|
||||||
|
"pan_left": "Pan Left",
|
||||||
|
"pan_right": "Pan Right",
|
||||||
|
"pan_up": "Pan Up",
|
||||||
|
"reset": "Reset",
|
||||||
|
"source": "View Source Code",
|
||||||
|
"zoom_in": "Zoom In",
|
||||||
|
"zoom_out": "Zoom Out"
|
||||||
|
},
|
||||||
"prompts": {
|
"prompts": {
|
||||||
"explanation": "Explain this concept to me",
|
"explanation": "Explain this concept to me",
|
||||||
"summarize": "Summarize this text",
|
"summarize": "Summarize this text",
|
||||||
|
|||||||
@ -505,6 +505,7 @@
|
|||||||
"tip": "実行可能なコードブロックのツールバーには実行ボタンが表示されます。危険なコードを実行しないでください!",
|
"tip": "実行可能なコードブロックのツールバーには実行ボタンが表示されます。危険なコードを実行しないでください!",
|
||||||
"title": "コード実行"
|
"title": "コード実行"
|
||||||
},
|
},
|
||||||
|
"code_image_tools": "プレビューツールを有効にする",
|
||||||
"code_wrappable": "コードブロック折り返し",
|
"code_wrappable": "コードブロック折り返し",
|
||||||
"context_count": {
|
"context_count": {
|
||||||
"label": "コンテキスト",
|
"label": "コンテキスト",
|
||||||
@ -648,15 +649,6 @@
|
|||||||
},
|
},
|
||||||
"expand": "展開する",
|
"expand": "展開する",
|
||||||
"more": "もっと",
|
"more": "もっと",
|
||||||
"preview": {
|
|
||||||
"copy": {
|
|
||||||
"image": "画像としてコピー"
|
|
||||||
},
|
|
||||||
"label": "プレビュー",
|
|
||||||
"source": "ソースコードを表示",
|
|
||||||
"zoom_in": "拡大",
|
|
||||||
"zoom_out": "縮小"
|
|
||||||
},
|
|
||||||
"run": "コードを実行",
|
"run": "コードを実行",
|
||||||
"split": {
|
"split": {
|
||||||
"label": "分割視圖",
|
"label": "分割視圖",
|
||||||
@ -1159,6 +1151,9 @@
|
|||||||
"failed": "削除に失敗しました",
|
"failed": "削除に失敗しました",
|
||||||
"success": "削除が成功しました"
|
"success": "削除が成功しました"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"failed": "プレビューに失敗しました"
|
||||||
|
},
|
||||||
"download": {
|
"download": {
|
||||||
"failed": "ダウンロードに失敗しました",
|
"failed": "ダウンロードに失敗しました",
|
||||||
"success": "ダウンロードに成功しました"
|
"success": "ダウンロードに成功しました"
|
||||||
@ -1654,6 +1649,22 @@
|
|||||||
"seed_tip": "拡大結果のランダム性を制御します"
|
"seed_tip": "拡大結果のランダム性を制御します"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"preview": {
|
||||||
|
"copy": {
|
||||||
|
"image": "画像としてコピー"
|
||||||
|
},
|
||||||
|
"dialog": "ダイアログを開く",
|
||||||
|
"label": "プレビュー",
|
||||||
|
"pan": "パン",
|
||||||
|
"pan_down": "下にパン",
|
||||||
|
"pan_left": "左にパン",
|
||||||
|
"pan_right": "右にパン",
|
||||||
|
"pan_up": "上にパン",
|
||||||
|
"reset": "リセット",
|
||||||
|
"source": "ソースコードを表示",
|
||||||
|
"zoom_in": "拡大",
|
||||||
|
"zoom_out": "縮小"
|
||||||
|
},
|
||||||
"prompts": {
|
"prompts": {
|
||||||
"explanation": "この概念を説明してください",
|
"explanation": "この概念を説明してください",
|
||||||
"summarize": "このテキストを要約してください",
|
"summarize": "このテキストを要約してください",
|
||||||
|
|||||||
@ -505,6 +505,7 @@
|
|||||||
"tip": "Выполнение кода в блоке кода возможно, но не рекомендуется выполнять опасный код!",
|
"tip": "Выполнение кода в блоке кода возможно, но не рекомендуется выполнять опасный код!",
|
||||||
"title": "Выполнение кода"
|
"title": "Выполнение кода"
|
||||||
},
|
},
|
||||||
|
"code_image_tools": "Включить инструменты предпросмотра",
|
||||||
"code_wrappable": "Блок кода можно переносить",
|
"code_wrappable": "Блок кода можно переносить",
|
||||||
"context_count": {
|
"context_count": {
|
||||||
"label": "Контекст",
|
"label": "Контекст",
|
||||||
@ -648,15 +649,6 @@
|
|||||||
},
|
},
|
||||||
"expand": "Развернуть",
|
"expand": "Развернуть",
|
||||||
"more": "Ещё",
|
"more": "Ещё",
|
||||||
"preview": {
|
|
||||||
"copy": {
|
|
||||||
"image": "Скопировать как изображение"
|
|
||||||
},
|
|
||||||
"label": "Предварительный просмотр",
|
|
||||||
"source": "Смотреть исходный код",
|
|
||||||
"zoom_in": "Увеличить",
|
|
||||||
"zoom_out": "Уменьшить"
|
|
||||||
},
|
|
||||||
"run": "Выполнить код",
|
"run": "Выполнить код",
|
||||||
"split": {
|
"split": {
|
||||||
"label": "Разделить на два окна",
|
"label": "Разделить на два окна",
|
||||||
@ -1159,6 +1151,9 @@
|
|||||||
"failed": "Ошибка удаления",
|
"failed": "Ошибка удаления",
|
||||||
"success": "Удаление успешно"
|
"success": "Удаление успешно"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"failed": "Не удалось открыть диалог"
|
||||||
|
},
|
||||||
"download": {
|
"download": {
|
||||||
"failed": "Скачивание не удалось",
|
"failed": "Скачивание не удалось",
|
||||||
"success": "Скачано успешно"
|
"success": "Скачано успешно"
|
||||||
@ -1654,6 +1649,22 @@
|
|||||||
"seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов"
|
"seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"preview": {
|
||||||
|
"copy": {
|
||||||
|
"image": "Скопировать как изображение"
|
||||||
|
},
|
||||||
|
"dialog": "Открыть диалог",
|
||||||
|
"label": "Предварительный просмотр",
|
||||||
|
"pan": "Перемещать",
|
||||||
|
"pan_down": "Переместить вниз",
|
||||||
|
"pan_left": "Переместить влево",
|
||||||
|
"pan_right": "Переместить вправо",
|
||||||
|
"pan_up": "Переместить вверх",
|
||||||
|
"reset": "Сбросить",
|
||||||
|
"source": "Смотреть исходный код",
|
||||||
|
"zoom_in": "Увеличить",
|
||||||
|
"zoom_out": "Уменьшить"
|
||||||
|
},
|
||||||
"prompts": {
|
"prompts": {
|
||||||
"explanation": "Объясните мне этот концепт",
|
"explanation": "Объясните мне этот концепт",
|
||||||
"summarize": "Суммируйте этот текст",
|
"summarize": "Суммируйте этот текст",
|
||||||
|
|||||||
@ -505,6 +505,7 @@
|
|||||||
"tip": "可执行的代码块工具栏中会显示运行按钮,注意不要执行危险代码!",
|
"tip": "可执行的代码块工具栏中会显示运行按钮,注意不要执行危险代码!",
|
||||||
"title": "代码执行"
|
"title": "代码执行"
|
||||||
},
|
},
|
||||||
|
"code_image_tools": "启用预览工具",
|
||||||
"code_wrappable": "代码块可换行",
|
"code_wrappable": "代码块可换行",
|
||||||
"context_count": {
|
"context_count": {
|
||||||
"label": "上下文数",
|
"label": "上下文数",
|
||||||
@ -648,15 +649,6 @@
|
|||||||
},
|
},
|
||||||
"expand": "展开",
|
"expand": "展开",
|
||||||
"more": "更多",
|
"more": "更多",
|
||||||
"preview": {
|
|
||||||
"copy": {
|
|
||||||
"image": "复制为图片"
|
|
||||||
},
|
|
||||||
"label": "预览",
|
|
||||||
"source": "查看源代码",
|
|
||||||
"zoom_in": "放大",
|
|
||||||
"zoom_out": "缩小"
|
|
||||||
},
|
|
||||||
"run": "运行代码",
|
"run": "运行代码",
|
||||||
"split": {
|
"split": {
|
||||||
"label": "分割视图",
|
"label": "分割视图",
|
||||||
@ -1159,6 +1151,9 @@
|
|||||||
"failed": "删除失败",
|
"failed": "删除失败",
|
||||||
"success": "删除成功"
|
"success": "删除成功"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"failed": "预览失败"
|
||||||
|
},
|
||||||
"download": {
|
"download": {
|
||||||
"failed": "下载失败",
|
"failed": "下载失败",
|
||||||
"success": "下载成功"
|
"success": "下载成功"
|
||||||
@ -1654,6 +1649,22 @@
|
|||||||
"seed_tip": "控制放大结果的随机性"
|
"seed_tip": "控制放大结果的随机性"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"preview": {
|
||||||
|
"copy": {
|
||||||
|
"image": "复制为图片"
|
||||||
|
},
|
||||||
|
"dialog": "打开预览窗口",
|
||||||
|
"label": "预览",
|
||||||
|
"pan": "移动",
|
||||||
|
"pan_down": "下移",
|
||||||
|
"pan_left": "左移",
|
||||||
|
"pan_right": "右移",
|
||||||
|
"pan_up": "上移",
|
||||||
|
"reset": "重置",
|
||||||
|
"source": "查看源代码",
|
||||||
|
"zoom_in": "放大",
|
||||||
|
"zoom_out": "缩小"
|
||||||
|
},
|
||||||
"prompts": {
|
"prompts": {
|
||||||
"explanation": "帮我解释一下这个概念",
|
"explanation": "帮我解释一下这个概念",
|
||||||
"summarize": "帮我总结一下这段话",
|
"summarize": "帮我总结一下这段话",
|
||||||
|
|||||||
@ -505,6 +505,7 @@
|
|||||||
"tip": "可執行的程式碼塊工具欄中會顯示運行按鈕,注意不要執行危險程式碼!",
|
"tip": "可執行的程式碼塊工具欄中會顯示運行按鈕,注意不要執行危險程式碼!",
|
||||||
"title": "程式碼執行"
|
"title": "程式碼執行"
|
||||||
},
|
},
|
||||||
|
"code_image_tools": "啟用預覽工具",
|
||||||
"code_wrappable": "程式碼區塊可自動換行",
|
"code_wrappable": "程式碼區塊可自動換行",
|
||||||
"context_count": {
|
"context_count": {
|
||||||
"label": "上下文",
|
"label": "上下文",
|
||||||
@ -648,15 +649,6 @@
|
|||||||
},
|
},
|
||||||
"expand": "展開",
|
"expand": "展開",
|
||||||
"more": "更多",
|
"more": "更多",
|
||||||
"preview": {
|
|
||||||
"copy": {
|
|
||||||
"image": "複製為圖片"
|
|
||||||
},
|
|
||||||
"label": "預覽",
|
|
||||||
"source": "查看源碼",
|
|
||||||
"zoom_in": "放大",
|
|
||||||
"zoom_out": "縮小"
|
|
||||||
},
|
|
||||||
"run": "運行代碼",
|
"run": "運行代碼",
|
||||||
"split": {
|
"split": {
|
||||||
"label": "分割視圖",
|
"label": "分割視圖",
|
||||||
@ -1159,6 +1151,9 @@
|
|||||||
"failed": "刪除失敗",
|
"failed": "刪除失敗",
|
||||||
"success": "刪除成功"
|
"success": "刪除成功"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"failed": "預覽失敗"
|
||||||
|
},
|
||||||
"download": {
|
"download": {
|
||||||
"failed": "下載失敗",
|
"failed": "下載失敗",
|
||||||
"success": "下載成功"
|
"success": "下載成功"
|
||||||
@ -1654,6 +1649,22 @@
|
|||||||
"seed_tip": "控制放大結果的隨機性"
|
"seed_tip": "控制放大結果的隨機性"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"preview": {
|
||||||
|
"copy": {
|
||||||
|
"image": "複製為圖片"
|
||||||
|
},
|
||||||
|
"dialog": "開啟預覽窗口",
|
||||||
|
"label": "預覽",
|
||||||
|
"pan": "移動",
|
||||||
|
"pan_down": "下移",
|
||||||
|
"pan_left": "左移",
|
||||||
|
"pan_right": "右移",
|
||||||
|
"pan_up": "上移",
|
||||||
|
"reset": "重置",
|
||||||
|
"source": "查看源碼",
|
||||||
|
"zoom_in": "放大",
|
||||||
|
"zoom_out": "縮小"
|
||||||
|
},
|
||||||
"prompts": {
|
"prompts": {
|
||||||
"explanation": "幫我解釋一下這個概念",
|
"explanation": "幫我解釋一下這個概念",
|
||||||
"summarize": "幫我總結一下這段話",
|
"summarize": "幫我總結一下這段話",
|
||||||
|
|||||||
@ -25,8 +25,9 @@ import {
|
|||||||
setCodeCollapsible,
|
setCodeCollapsible,
|
||||||
setCodeEditor,
|
setCodeEditor,
|
||||||
setCodeExecution,
|
setCodeExecution,
|
||||||
setCodePreview,
|
setCodeImageTools,
|
||||||
setCodeShowLineNumbers,
|
setCodeShowLineNumbers,
|
||||||
|
setCodeViewer,
|
||||||
setCodeWrappable,
|
setCodeWrappable,
|
||||||
setEnableBackspaceDeleteModel,
|
setEnableBackspaceDeleteModel,
|
||||||
setEnableQuickPanelTriggers,
|
setEnableQuickPanelTriggers,
|
||||||
@ -92,7 +93,8 @@ const SettingsTab: FC<Props> = (props) => {
|
|||||||
codeCollapsible,
|
codeCollapsible,
|
||||||
codeWrappable,
|
codeWrappable,
|
||||||
codeEditor,
|
codeEditor,
|
||||||
codePreview,
|
codeViewer,
|
||||||
|
codeImageTools,
|
||||||
codeExecution,
|
codeExecution,
|
||||||
mathEngine,
|
mathEngine,
|
||||||
autoTranslateWithSpace,
|
autoTranslateWithSpace,
|
||||||
@ -133,21 +135,21 @@ const SettingsTab: FC<Props> = (props) => {
|
|||||||
? codeEditor.themeLight
|
? codeEditor.themeLight
|
||||||
: codeEditor.themeDark
|
: codeEditor.themeDark
|
||||||
: theme === ThemeMode.light
|
: theme === ThemeMode.light
|
||||||
? codePreview.themeLight
|
? codeViewer.themeLight
|
||||||
: codePreview.themeDark
|
: codeViewer.themeDark
|
||||||
}, [
|
}, [
|
||||||
codeEditor.enabled,
|
codeEditor.enabled,
|
||||||
codeEditor.themeLight,
|
codeEditor.themeLight,
|
||||||
codeEditor.themeDark,
|
codeEditor.themeDark,
|
||||||
theme,
|
theme,
|
||||||
codePreview.themeLight,
|
codeViewer.themeLight,
|
||||||
codePreview.themeDark
|
codeViewer.themeDark
|
||||||
])
|
])
|
||||||
|
|
||||||
const onCodeStyleChange = useCallback(
|
const onCodeStyleChange = useCallback(
|
||||||
(value: CodeStyleVarious) => {
|
(value: CodeStyleVarious) => {
|
||||||
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
|
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
|
||||||
const action = codeEditor.enabled ? setCodeEditor : setCodePreview
|
const action = codeEditor.enabled ? setCodeEditor : setCodeViewer
|
||||||
dispatch(action({ [field]: value }))
|
dispatch(action({ [field]: value }))
|
||||||
},
|
},
|
||||||
[dispatch, theme, codeEditor.enabled]
|
[dispatch, theme, codeEditor.enabled]
|
||||||
@ -532,6 +534,15 @@ const SettingsTab: FC<Props> = (props) => {
|
|||||||
<SettingRowTitleSmall>{t('chat.settings.code_wrappable')}</SettingRowTitleSmall>
|
<SettingRowTitleSmall>{t('chat.settings.code_wrappable')}</SettingRowTitleSmall>
|
||||||
<Switch size="small" checked={codeWrappable} onChange={(checked) => dispatch(setCodeWrappable(checked))} />
|
<Switch size="small" checked={codeWrappable} onChange={(checked) => dispatch(setCodeWrappable(checked))} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitleSmall>{t('chat.settings.code_image_tools')}</SettingRowTitleSmall>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={codeImageTools}
|
||||||
|
onChange={(checked) => dispatch(setCodeImageTools(checked))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
</CollapsibleSettingGroup>
|
</CollapsibleSettingGroup>
|
||||||
|
|||||||
@ -132,13 +132,13 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
|||||||
onBlur={onUpdate}
|
onBlur={onUpdate}
|
||||||
height="calc(80vh - 202px)"
|
height="calc(80vh - 202px)"
|
||||||
fontSize="var(--ant-font-size)"
|
fontSize="var(--ant-font-size)"
|
||||||
|
expanded
|
||||||
|
unwrapped={false}
|
||||||
options={{
|
options={{
|
||||||
autocompletion: false,
|
autocompletion: false,
|
||||||
collapsible: false,
|
|
||||||
keymap: true,
|
keymap: true,
|
||||||
lineNumbers: false,
|
lineNumbers: false,
|
||||||
lint: false,
|
lint: false
|
||||||
wrappable: true
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
border: '0.5px solid var(--color-border)',
|
border: '0.5px solid var(--color-border)',
|
||||||
|
|||||||
@ -338,9 +338,9 @@ const DisplaySettings: FC = () => {
|
|||||||
placeholder={t('settings.display.custom.css.placeholder')}
|
placeholder={t('settings.display.custom.css.placeholder')}
|
||||||
onChange={(value) => dispatch(setCustomCss(value))}
|
onChange={(value) => dispatch(setCustomCss(value))}
|
||||||
height="60vh"
|
height="60vh"
|
||||||
|
expanded
|
||||||
|
unwrapped={false}
|
||||||
options={{
|
options={{
|
||||||
collapsible: false,
|
|
||||||
wrappable: true,
|
|
||||||
autocompletion: true,
|
autocompletion: true,
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
foldGutter: true,
|
foldGutter: true,
|
||||||
|
|||||||
@ -291,10 +291,10 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
|
|||||||
language="json"
|
language="json"
|
||||||
onChange={handleEditorChange}
|
onChange={handleEditorChange}
|
||||||
maxHeight="300px"
|
maxHeight="300px"
|
||||||
|
expanded
|
||||||
|
unwrapped={false}
|
||||||
options={{
|
options={{
|
||||||
lint: true,
|
lint: true,
|
||||||
collapsible: true,
|
|
||||||
wrappable: true,
|
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
foldGutter: true,
|
foldGutter: true,
|
||||||
highlightActiveLine: true,
|
highlightActiveLine: true,
|
||||||
|
|||||||
@ -134,10 +134,10 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
language="json"
|
language="json"
|
||||||
onChange={(value) => setJsonConfig(value)}
|
onChange={(value) => setJsonConfig(value)}
|
||||||
height="60vh"
|
height="60vh"
|
||||||
|
expanded
|
||||||
|
unwrapped={false}
|
||||||
options={{
|
options={{
|
||||||
lint: true,
|
lint: true,
|
||||||
collapsible: false,
|
|
||||||
wrappable: true,
|
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
foldGutter: true,
|
foldGutter: true,
|
||||||
highlightActiveLine: true,
|
highlightActiveLine: true,
|
||||||
|
|||||||
@ -78,10 +78,10 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
|||||||
language="json"
|
language="json"
|
||||||
onChange={(value) => setHeaderText(value)}
|
onChange={(value) => setHeaderText(value)}
|
||||||
placeholder={`{\n "Header-Name": "Header-Value"\n}`}
|
placeholder={`{\n "Header-Name": "Header-Value"\n}`}
|
||||||
|
expanded
|
||||||
|
unwrapped={false}
|
||||||
options={{
|
options={{
|
||||||
lint: true,
|
lint: true,
|
||||||
collapsible: false,
|
|
||||||
wrappable: true,
|
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
foldGutter: true,
|
foldGutter: true,
|
||||||
highlightActiveLine: true,
|
highlightActiveLine: true,
|
||||||
|
|||||||
87
src/renderer/src/services/ImagePreviewService.ts
Normal file
87
src/renderer/src/services/ImagePreviewService.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { TopView } from '@renderer/components/TopView'
|
||||||
|
import { svgToPngBlob, svgToSvgBlob } from '@renderer/utils/image'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('ImagePreviewService')
|
||||||
|
|
||||||
|
export type ImageInput = SVGElement | HTMLImageElement | string | Blob
|
||||||
|
|
||||||
|
export interface ImagePreviewOptions {
|
||||||
|
format?: 'svg' | 'png' | 'jpeg'
|
||||||
|
scale?: number
|
||||||
|
quality?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图像预览服务
|
||||||
|
* 提供统一的图像预览功能,支持多种输入类型
|
||||||
|
*/
|
||||||
|
export class ImagePreviewService {
|
||||||
|
/**
|
||||||
|
* 显示图像预览
|
||||||
|
* @param input 图像输入源
|
||||||
|
* @param options 预览选项
|
||||||
|
*/
|
||||||
|
static async show(input: ImageInput, options: ImagePreviewOptions = {}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const imageUrl = await this.processInput(input, options)
|
||||||
|
|
||||||
|
// 动态导入 ImageViewer 避免循环依赖
|
||||||
|
const { default: ImageViewer } = await import('@renderer/components/ImageViewer')
|
||||||
|
|
||||||
|
const handleVisibilityChange = (visible: boolean) => {
|
||||||
|
if (!visible) {
|
||||||
|
// 清理创建的 URL
|
||||||
|
if (imageUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(imageUrl)
|
||||||
|
}
|
||||||
|
TopView.hide('image-preview')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TopView.show(
|
||||||
|
() =>
|
||||||
|
React.createElement(ImageViewer, {
|
||||||
|
src: imageUrl,
|
||||||
|
style: { display: 'none' }, // 隐藏图片本身,只显示预览对话框
|
||||||
|
preview: {
|
||||||
|
visible: true,
|
||||||
|
onVisibleChange: handleVisibilityChange
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'image-preview'
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to show image preview:', error as Error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理输入并转换为可预览的 URL
|
||||||
|
* @param input 图像输入源
|
||||||
|
* @param options 处理选项
|
||||||
|
* @returns 图像 URL
|
||||||
|
*/
|
||||||
|
private static async processInput(input: ImageInput, options: ImagePreviewOptions): Promise<string> {
|
||||||
|
if (input instanceof SVGElement) {
|
||||||
|
const blob = options.format === 'svg' ? svgToSvgBlob(input) : await svgToPngBlob(input, options.scale || 3)
|
||||||
|
return URL.createObjectURL(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input instanceof HTMLImageElement) {
|
||||||
|
return input.src
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input instanceof Blob) {
|
||||||
|
return URL.createObjectURL(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported input type')
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/renderer/src/services/__tests__/ImagePreviewService.test.ts
Normal file
119
src/renderer/src/services/__tests__/ImagePreviewService.test.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { ImagePreviewService } from '../ImagePreviewService'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
svgToPngBlob: vi.fn(),
|
||||||
|
svgToSvgBlob: vi.fn(),
|
||||||
|
TopView: {
|
||||||
|
show: vi.fn(),
|
||||||
|
hide: vi.fn()
|
||||||
|
},
|
||||||
|
ImageViewer: vi.fn(() => null),
|
||||||
|
createObjectURL: vi.fn(),
|
||||||
|
revokeObjectURL: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/utils/image', () => ({
|
||||||
|
svgToPngBlob: mocks.svgToPngBlob,
|
||||||
|
svgToSvgBlob: mocks.svgToSvgBlob
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/TopView', () => ({
|
||||||
|
TopView: mocks.TopView
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ImageViewer', () => ({
|
||||||
|
default: mocks.ImageViewer
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock URL.createObjectURL and URL.revokeObjectURL
|
||||||
|
Object.assign(global.URL, {
|
||||||
|
createObjectURL: mocks.createObjectURL,
|
||||||
|
revokeObjectURL: mocks.revokeObjectURL
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ImagePreviewService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mocks.createObjectURL.mockReturnValue('blob:mock-url')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('show', () => {
|
||||||
|
it('should handle SVG element input with PNG format', async () => {
|
||||||
|
const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||||
|
const mockBlob = new Blob(['mock'], { type: 'image/png' })
|
||||||
|
|
||||||
|
mocks.svgToPngBlob.mockResolvedValue(mockBlob)
|
||||||
|
|
||||||
|
await ImagePreviewService.show(mockSvgElement, { format: 'png', scale: 2 })
|
||||||
|
|
||||||
|
expect(mocks.svgToPngBlob).toHaveBeenCalledWith(mockSvgElement, 2)
|
||||||
|
expect(mocks.createObjectURL).toHaveBeenCalledWith(mockBlob)
|
||||||
|
expect(mocks.TopView.show).toHaveBeenCalledWith(expect.any(Function), 'image-preview')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle SVG element input with SVG format', async () => {
|
||||||
|
const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||||
|
const mockBlob = new Blob(['mock'], { type: 'image/svg+xml' })
|
||||||
|
|
||||||
|
mocks.svgToSvgBlob.mockReturnValue(mockBlob)
|
||||||
|
|
||||||
|
await ImagePreviewService.show(mockSvgElement, { format: 'svg' })
|
||||||
|
|
||||||
|
expect(mocks.svgToSvgBlob).toHaveBeenCalledWith(mockSvgElement)
|
||||||
|
expect(mocks.createObjectURL).toHaveBeenCalledWith(mockBlob)
|
||||||
|
expect(mocks.TopView.show).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle string URL input', async () => {
|
||||||
|
const imageUrl = 'https://example.com/image.png'
|
||||||
|
|
||||||
|
await ImagePreviewService.show(imageUrl)
|
||||||
|
|
||||||
|
expect(mocks.TopView.show).toHaveBeenCalled()
|
||||||
|
expect(mocks.createObjectURL).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle Blob input', async () => {
|
||||||
|
const mockBlob = new Blob(['mock'], { type: 'image/png' })
|
||||||
|
|
||||||
|
await ImagePreviewService.show(mockBlob)
|
||||||
|
|
||||||
|
expect(mocks.createObjectURL).toHaveBeenCalledWith(mockBlob)
|
||||||
|
expect(mocks.TopView.show).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle HTMLImageElement input', async () => {
|
||||||
|
const mockImg = document.createElement('img')
|
||||||
|
mockImg.src = 'https://example.com/image.png'
|
||||||
|
|
||||||
|
await ImagePreviewService.show(mockImg)
|
||||||
|
|
||||||
|
expect(mocks.TopView.show).toHaveBeenCalled()
|
||||||
|
expect(mocks.createObjectURL).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error for unsupported input type', async () => {
|
||||||
|
const unsupportedInput = { invalid: 'input' } as any
|
||||||
|
|
||||||
|
await expect(ImagePreviewService.show(unsupportedInput)).rejects.toThrow('Unsupported input type')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use default scale when not provided', async () => {
|
||||||
|
const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||||
|
const mockBlob = new Blob(['mock'], { type: 'image/png' })
|
||||||
|
|
||||||
|
mocks.svgToPngBlob.mockResolvedValue(mockBlob)
|
||||||
|
|
||||||
|
await ImagePreviewService.show(mockSvgElement)
|
||||||
|
|
||||||
|
expect(mocks.svgToPngBlob).toHaveBeenCalledWith(mockSvgElement, 3) // default scale
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user