|
| 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: `` |
| 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 mouse → document 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: idle → loading → success/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