Skip to content

Commit a523262

Browse files
authored
Image previews (#38)
Add image previews when hovering over image URLs in the editor, while holding Alt.
1 parent 2b27f8b commit a523262

File tree

16 files changed

+1357
-3
lines changed

16 files changed

+1357
-3
lines changed
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
# Image Preview Implementation
2+
3+
## Overview
4+
5+
Shows image previews in bottom-right corner when hovering over image paths/URLs with Alt key held. Works across markdown, HTML, MDX components, and plain text using syntax-agnostic detection.
6+
7+
**Key Files**:
8+
- `src/components/editor/ImagePreview.tsx` - Preview component
9+
- `src/hooks/editor/useImageHover.ts` - Hover tracking
10+
- `src/lib/editor/urls/detection.ts` - Image detection
11+
- `src-tauri/src/commands/files.rs:1012` - `resolve_image_path` command
12+
- `src/components/editor/Editor.tsx:251-257` - Integration
13+
14+
## Path Resolution
15+
16+
The system handles three path types:
17+
18+
### 1. Remote URLs
19+
```
20+
https://example.com/image.png
21+
```
22+
Used directly without resolution.
23+
24+
### 2. Absolute Paths (from project root)
25+
```
26+
/src/assets/articles/image.png
27+
```
28+
Resolution logic:
29+
- Strip leading `/`
30+
- Join with project root
31+
- Validate with `validate_project_path`
32+
- Return absolute filesystem path
33+
34+
### 3. Relative Paths
35+
```
36+
./image.png
37+
../images/photo.jpg
38+
```
39+
Resolution logic:
40+
- Get directory of current file
41+
- Resolve path relative to that directory
42+
- Validate with `validate_project_path`
43+
- Return absolute filesystem path
44+
45+
### Tauri Command
46+
47+
```rust
48+
pub async fn resolve_image_path(
49+
image_path: String,
50+
project_root: String,
51+
current_file_path: Option<String>,
52+
) -> Result<String, String>
53+
```
54+
55+
Returns validated absolute filesystem path. Security enforced by `validate_project_path` to prevent traversal attacks.
56+
57+
### Frontend Usage
58+
59+
```typescript
60+
// For remote URLs - use directly
61+
if (path.startsWith('http://') || path.startsWith('https://')) {
62+
setImageUrl(path)
63+
return
64+
}
65+
66+
// For local paths - resolve then convert
67+
const absolutePath = await invoke<string>('resolve_image_path', {
68+
imagePath: path,
69+
projectRoot: projectPath,
70+
currentFilePath,
71+
})
72+
73+
// Convert to asset protocol URL
74+
const assetUrl = convertFileSrc(absolutePath)
75+
```
76+
77+
## Architecture
78+
79+
### Image Detection
80+
81+
**Strategy**: Syntax-agnostic regex detection. Finds any path/URL ending with image extension, regardless of surrounding syntax.
82+
83+
```typescript
84+
// From src/lib/editor/urls/detection.ts
85+
export function findImageUrlsAndPathsInText(
86+
text: string,
87+
offset?: number
88+
): UrlMatch[]
89+
```
90+
91+
Works with:
92+
- Markdown: `![alt](./image.png)`
93+
- HTML: `<img src="/src/assets/image.jpg" />`
94+
- MDX: `<Image src="https://example.com/photo.png" />`
95+
- Plain text: Any path ending with `.png`, `.jpg`, etc.
96+
97+
### Component Flow
98+
99+
1. **Hover Tracking** (`useImageHover.ts`)
100+
- Listens for mousemove when Alt pressed
101+
- Uses CodeMirror's `posAtCoords()` to map mousedocument position
102+
- Scans current line for image paths
103+
- Returns `HoveredImage { url, from, to }` or null
104+
105+
2. **Preview Component** (`ImagePreview.tsx`)
106+
- Receives `hoveredImage`, `projectPath`, `currentFilePath`
107+
- Resolves local paths via `resolve_image_path` command
108+
- Converts to asset protocol URL via `convertFileSrc()`
109+
- Manages loading states: idleloadingsuccess/error
110+
111+
3. **Integration** (`Editor.tsx`)
112+
- Gets `hoveredImage` from `useImageHover(viewRef.current, isAltPressed)`
113+
- Passes to `ImagePreview` component with store data
114+
- Conditional render: only shows when `projectPath` available
115+
116+
### Why Editor.tsx?
117+
118+
ImagePreview lives in Editor.tsx (not MainEditor.tsx) because:
119+
- Tight coupling with `viewRef` (CodeMirror EditorView instance)
120+
- Semantically part of editing experience
121+
- First React UI component integrated with editor
122+
- Moving up would break encapsulation
123+
124+
## Performance Patterns
125+
126+
### 1. Specific Store Selectors
127+
```typescript
128+
// ✅ Only subscribe to path changes
129+
const currentFilePath = useEditorStore(state => state.currentFile?.path)
130+
131+
// ❌ Would subscribe to all file property changes
132+
const currentFile = useEditorStore(state => state.currentFile)
133+
```
134+
135+
### 2. Conditional State Updates (Prevents re-renders on mousemove)
136+
```typescript
137+
setHoveredImage(prev => {
138+
if (prev?.url === hoveredUrl.url) {
139+
return prev // Same URL, don't create new object
140+
}
141+
return { url: hoveredUrl.url, from: hoveredUrl.from, to: hoveredUrl.to }
142+
})
143+
```
144+
145+
### 3. URL Caching (Prevents re-fetching same image)
146+
```typescript
147+
const prevUrlRef = React.useRef<string | null>(null)
148+
149+
if (hoveredImage.url === prevUrlRef.current) {
150+
return // Don't reload, just position changed
151+
}
152+
```
153+
154+
### 4. Optimized Dependencies
155+
```typescript
156+
// Only re-run when URL changes, not position
157+
useEffect(() => {
158+
// ...
159+
}, [hoveredImage?.url, projectPath, currentFilePath])
160+
```
161+
162+
### 5. Strategic Memoization
163+
```typescript
164+
export const ImagePreview = React.memo(ImagePreviewComponent)
165+
```
166+
167+
These patterns prevent:
168+
- Unnecessary re-renders on mousemove
169+
- Re-fetching images when hovering over same URL
170+
- Render cascades from store updates
171+
172+
## Error Handling
173+
174+
**Strategy**: Silent failure for better UX.
175+
176+
```typescript
177+
if (!hoveredImage || loadingState === 'error') {
178+
return null // Don't render anything
179+
}
180+
```
181+
182+
**Rationale**: Image preview is an optional enhancement. Errors shouldn't interrupt writing flow.
183+
184+
**Scenarios**:
185+
- Local file not found → No preview
186+
- Path resolution fails → No preview
187+
- Image load fails → No preview
188+
- Remote URL unreachable → Browser's default broken image icon (provides feedback)
189+
190+
## Security
191+
192+
- **Path Validation**: All paths validated by `validate_project_path` in Rust
193+
- **Project Boundary**: Paths must be within project root
194+
- **Asset Protocol**: Tauri's secure file access via `convertFileSrc()`
195+
- **No Path Traversal**: `../../../etc/passwd` rejected by validation
196+
197+
## Configuration
198+
199+
### Tauri (`src-tauri/tauri.conf.json`)
200+
```json
201+
{
202+
"app": {
203+
"security": {
204+
"csp": "img-src 'self' asset: http://asset.localhost data:;",
205+
"assetProtocol": {
206+
"enable": true,
207+
"scope": ["**"]
208+
}
209+
}
210+
}
211+
}
212+
```
213+
214+
### Cargo (`src-tauri/Cargo.toml`)
215+
```toml
216+
tauri = { version = "2", features = ["protocol-asset"] }
217+
```

0 commit comments

Comments
 (0)