feat: add image preview with drag-and-drop support
- Add ImagePreview component for displaying embedded image thumbnails - Enable drag-and-drop functionality for images in FloatingPromptInput - Configure Tauri asset protocol to properly serve local image files - Support image mentions via @path syntax in prompts - Add visual feedback for drag hover states - Implement full-size image preview dialog with navigation - Handle duplicate images and limit preview to 10 thumbnails
This commit is contained in:
7
src-tauri/Cargo.lock
generated
7
src-tauri/Cargo.lock
generated
@@ -1768,6 +1768,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -4075,6 +4081,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"http-range",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
|
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["protocol-asset"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-dialog = "2.0.3"
|
tauri-plugin-dialog = "2.0.3"
|
||||||
|
@@ -18,7 +18,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'",
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": ["**"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
@@ -13,7 +13,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Popover } from "@/components/ui/popover";
|
import { Popover } from "@/components/ui/popover";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { FilePicker } from "./FilePicker";
|
import { FilePicker } from "./FilePicker";
|
||||||
|
import { ImagePreview } from "./ImagePreview";
|
||||||
import { type FileEntry } from "@/lib/api";
|
import { type FileEntry } from "@/lib/api";
|
||||||
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
|
|
||||||
interface FloatingPromptInputProps {
|
interface FloatingPromptInputProps {
|
||||||
/**
|
/**
|
||||||
@@ -88,9 +90,121 @@ export const FloatingPromptInput: React.FC<FloatingPromptInputProps> = ({
|
|||||||
const [showFilePicker, setShowFilePicker] = useState(false);
|
const [showFilePicker, setShowFilePicker] = useState(false);
|
||||||
const [filePickerQuery, setFilePickerQuery] = useState("");
|
const [filePickerQuery, setFilePickerQuery] = useState("");
|
||||||
const [cursorPosition, setCursorPosition] = useState(0);
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
|
const [embeddedImages, setEmbeddedImages] = useState<string[]>([]);
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const expandedTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const expandedTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const unlistenDragDropRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
// Helper function to check if a file is an image
|
||||||
|
const isImageFile = (path: string): boolean => {
|
||||||
|
const ext = path.split('.').pop()?.toLowerCase();
|
||||||
|
return ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'].includes(ext || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract image paths from prompt text
|
||||||
|
const extractImagePaths = (text: string): string[] => {
|
||||||
|
console.log('[extractImagePaths] Input text:', text);
|
||||||
|
const regex = /@([^\s]+)/g;
|
||||||
|
const matches = Array.from(text.matchAll(regex));
|
||||||
|
console.log('[extractImagePaths] Regex matches:', matches.map(m => m[0]));
|
||||||
|
const pathsSet = new Set<string>(); // Use Set to ensure uniqueness
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const path = match[1];
|
||||||
|
console.log('[extractImagePaths] Processing path:', path);
|
||||||
|
// Convert relative path to absolute if needed
|
||||||
|
const fullPath = path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path);
|
||||||
|
console.log('[extractImagePaths] Full path:', fullPath, 'Is image:', isImageFile(fullPath));
|
||||||
|
if (isImageFile(fullPath)) {
|
||||||
|
pathsSet.add(fullPath); // Add to Set (automatically handles duplicates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniquePaths = Array.from(pathsSet); // Convert Set back to Array
|
||||||
|
console.log('[extractImagePaths] Final extracted paths (unique):', uniquePaths);
|
||||||
|
return uniquePaths;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update embedded images when prompt changes
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[useEffect] Prompt changed:', prompt);
|
||||||
|
const imagePaths = extractImagePaths(prompt);
|
||||||
|
console.log('[useEffect] Setting embeddedImages to:', imagePaths);
|
||||||
|
setEmbeddedImages(imagePaths);
|
||||||
|
}, [prompt, projectPath]);
|
||||||
|
|
||||||
|
// Set up Tauri drag-drop event listener
|
||||||
|
useEffect(() => {
|
||||||
|
// This effect runs only once on component mount to set up the listener.
|
||||||
|
let lastDropTime = 0;
|
||||||
|
|
||||||
|
const setupListener = async () => {
|
||||||
|
try {
|
||||||
|
// If a listener from a previous mount/render is still around, clean it up.
|
||||||
|
if (unlistenDragDropRef.current) {
|
||||||
|
unlistenDragDropRef.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
const webview = getCurrentWebviewWindow();
|
||||||
|
unlistenDragDropRef.current = await webview.onDragDropEvent((event) => {
|
||||||
|
if (event.payload.type === 'enter' || event.payload.type === 'over') {
|
||||||
|
setDragActive(true);
|
||||||
|
} else if (event.payload.type === 'leave') {
|
||||||
|
setDragActive(false);
|
||||||
|
} else if (event.payload.type === 'drop' && event.payload.paths) {
|
||||||
|
setDragActive(false);
|
||||||
|
|
||||||
|
const currentTime = Date.now();
|
||||||
|
if (currentTime - lastDropTime < 200) {
|
||||||
|
// This debounce is crucial to handle the storm of drop events
|
||||||
|
// that Tauri/OS can fire for a single user action.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastDropTime = currentTime;
|
||||||
|
|
||||||
|
const droppedPaths = event.payload.paths as string[];
|
||||||
|
const imagePaths = droppedPaths.filter(isImageFile);
|
||||||
|
|
||||||
|
if (imagePaths.length > 0) {
|
||||||
|
setPrompt(currentPrompt => {
|
||||||
|
const existingPaths = extractImagePaths(currentPrompt);
|
||||||
|
const newPaths = imagePaths.filter(p => !existingPaths.includes(p));
|
||||||
|
|
||||||
|
if (newPaths.length === 0) {
|
||||||
|
return currentPrompt; // All dropped images are already in the prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionsToAdd = newPaths.map(p => `@${p}`).join(' ');
|
||||||
|
const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mentionsToAdd + ' ';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const target = isExpanded ? expandedTextareaRef.current : textareaRef.current;
|
||||||
|
target?.focus();
|
||||||
|
target?.setSelectionRange(newPrompt.length, newPrompt.length);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return newPrompt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set up Tauri drag-drop listener:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setupListener();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// On unmount, ensure we clean up the listener.
|
||||||
|
if (unlistenDragDropRef.current) {
|
||||||
|
unlistenDragDropRef.current();
|
||||||
|
unlistenDragDropRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []); // Empty dependency array ensures this runs only on mount/unmount.
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Focus the appropriate textarea when expanded state changes
|
// Focus the appropriate textarea when expanded state changes
|
||||||
@@ -105,6 +219,7 @@ export const FloatingPromptInput: React.FC<FloatingPromptInputProps> = ({
|
|||||||
if (prompt.trim() && !isLoading && !disabled) {
|
if (prompt.trim() && !isLoading && !disabled) {
|
||||||
onSend(prompt.trim(), selectedModel);
|
onSend(prompt.trim(), selectedModel);
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
|
setEmbeddedImages([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,6 +311,36 @@ export const FloatingPromptInput: React.FC<FloatingPromptInputProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Browser drag and drop handlers - just prevent default behavior
|
||||||
|
// Actual file handling is done via Tauri's window-level drag-drop events
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
// Visual feedback is handled by Tauri events
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
// File processing is handled by Tauri's onDragDropEvent
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveImage = (index: number) => {
|
||||||
|
// Remove the corresponding @mention from the prompt
|
||||||
|
const imagePath = embeddedImages[index];
|
||||||
|
const patterns = [
|
||||||
|
new RegExp(`@${imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g'),
|
||||||
|
new RegExp(`@${imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g')
|
||||||
|
];
|
||||||
|
|
||||||
|
let newPrompt = prompt;
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
newPrompt = newPrompt.replace(pattern, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrompt(newPrompt.trim());
|
||||||
|
};
|
||||||
|
|
||||||
const selectedModelData = MODELS.find(m => m.id === selectedModel) || MODELS[0];
|
const selectedModelData = MODELS.find(m => m.id === selectedModel) || MODELS[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -229,6 +374,15 @@ export const FloatingPromptInput: React.FC<FloatingPromptInputProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Image previews in expanded mode */}
|
||||||
|
{embeddedImages.length > 0 && (
|
||||||
|
<ImagePreview
|
||||||
|
images={embeddedImages}
|
||||||
|
onRemove={handleRemoveImage}
|
||||||
|
className="border-t border-border pt-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={expandedTextareaRef}
|
ref={expandedTextareaRef}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
@@ -236,6 +390,10 @@ export const FloatingPromptInput: React.FC<FloatingPromptInputProps> = ({
|
|||||||
placeholder="Type your prompt here..."
|
placeholder="Type your prompt here..."
|
||||||
className="min-h-[200px] resize-none"
|
className="min-h-[200px] resize-none"
|
||||||
disabled={isLoading || disabled}
|
disabled={isLoading || disabled}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -274,111 +432,132 @@ export const FloatingPromptInput: React.FC<FloatingPromptInputProps> = ({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Fixed Position Input Bar */}
|
{/* Fixed Position Input Bar */}
|
||||||
<div className={cn(
|
<div
|
||||||
"fixed bottom-0 left-0 right-0 z-40 bg-background border-t border-border",
|
className={cn(
|
||||||
className
|
"fixed bottom-0 left-0 right-0 z-40 bg-background border-t border-border",
|
||||||
)}>
|
dragActive && "ring-2 ring-primary ring-offset-2",
|
||||||
<div className="max-w-5xl mx-auto p-4">
|
className
|
||||||
<div className="flex items-end gap-3">
|
)}
|
||||||
{/* Model Picker */}
|
onDragEnter={handleDrag}
|
||||||
<Popover
|
onDragLeave={handleDrag}
|
||||||
trigger={
|
onDragOver={handleDrag}
|
||||||
<Button
|
onDrop={handleDrop}
|
||||||
variant="outline"
|
>
|
||||||
size="default"
|
<div className="max-w-5xl mx-auto">
|
||||||
disabled={isLoading || disabled}
|
{/* Image previews */}
|
||||||
className="gap-2 min-w-[180px] justify-start"
|
{embeddedImages.length > 0 && (
|
||||||
>
|
<ImagePreview
|
||||||
{selectedModelData.icon}
|
images={embeddedImages}
|
||||||
<span className="flex-1 text-left">{selectedModelData.name}</span>
|
onRemove={handleRemoveImage}
|
||||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
className="border-b border-border"
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
content={
|
|
||||||
<div className="w-[300px] p-1">
|
|
||||||
{MODELS.map((model) => (
|
|
||||||
<button
|
|
||||||
key={model.id}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedModel(model.id);
|
|
||||||
setModelPickerOpen(false);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-start gap-3 p-3 rounded-md transition-colors text-left",
|
|
||||||
"hover:bg-accent",
|
|
||||||
selectedModel === model.id && "bg-accent"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="mt-0.5">{model.icon}</div>
|
|
||||||
<div className="flex-1 space-y-1">
|
|
||||||
<div className="font-medium text-sm">{model.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{model.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
open={modelPickerOpen}
|
|
||||||
onOpenChange={setModelPickerOpen}
|
|
||||||
align="start"
|
|
||||||
side="top"
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{/* Prompt Input */}
|
|
||||||
<div className="flex-1 relative">
|
<div className="p-4">
|
||||||
<Textarea
|
<div className="flex items-end gap-3">
|
||||||
ref={textareaRef}
|
{/* Model Picker */}
|
||||||
value={prompt}
|
<Popover
|
||||||
onChange={handleTextChange}
|
trigger={
|
||||||
onKeyDown={handleKeyDown}
|
<Button
|
||||||
placeholder="Ask Claude anything..."
|
variant="outline"
|
||||||
disabled={isLoading || disabled}
|
size="default"
|
||||||
className="min-h-[44px] max-h-[120px] resize-none pr-10"
|
disabled={isLoading || disabled}
|
||||||
rows={1}
|
className="gap-2 min-w-[180px] justify-start"
|
||||||
|
>
|
||||||
|
{selectedModelData.icon}
|
||||||
|
<span className="flex-1 text-left">{selectedModelData.name}</span>
|
||||||
|
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
<div className="w-[300px] p-1">
|
||||||
|
{MODELS.map((model) => (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedModel(model.id);
|
||||||
|
setModelPickerOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-start gap-3 p-3 rounded-md transition-colors text-left",
|
||||||
|
"hover:bg-accent",
|
||||||
|
selectedModel === model.id && "bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5">{model.icon}</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="font-medium text-sm">{model.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{model.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={modelPickerOpen}
|
||||||
|
onOpenChange={setModelPickerOpen}
|
||||||
|
align="start"
|
||||||
|
side="top"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
{/* Prompt Input */}
|
||||||
variant="ghost"
|
<div className="flex-1 relative">
|
||||||
size="icon"
|
<Textarea
|
||||||
onClick={() => setIsExpanded(true)}
|
ref={textareaRef}
|
||||||
disabled={isLoading || disabled}
|
value={prompt}
|
||||||
className="absolute right-1 bottom-1 h-8 w-8"
|
onChange={handleTextChange}
|
||||||
>
|
onKeyDown={handleKeyDown}
|
||||||
<Maximize2 className="h-4 w-4" />
|
placeholder={dragActive ? "Drop images here..." : "Ask Claude anything..."}
|
||||||
</Button>
|
disabled={isLoading || disabled}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[44px] max-h-[120px] resize-none pr-10",
|
||||||
|
dragActive && "border-primary"
|
||||||
|
)}
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
disabled={isLoading || disabled}
|
||||||
|
className="absolute right-1 bottom-1 h-8 w-8"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* File Picker */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showFilePicker && projectPath && projectPath.trim() && (
|
||||||
|
<FilePicker
|
||||||
|
basePath={projectPath.trim()}
|
||||||
|
onSelect={handleFileSelect}
|
||||||
|
onClose={handleFilePickerClose}
|
||||||
|
initialQuery={filePickerQuery}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* File Picker */}
|
{/* Send Button */}
|
||||||
<AnimatePresence>
|
<Button
|
||||||
{showFilePicker && projectPath && projectPath.trim() && (
|
onClick={handleSend}
|
||||||
<FilePicker
|
disabled={!prompt.trim() || isLoading || disabled}
|
||||||
basePath={projectPath.trim()}
|
size="default"
|
||||||
onSelect={handleFileSelect}
|
className="min-w-[60px]"
|
||||||
onClose={handleFilePickerClose}
|
>
|
||||||
initialQuery={filePickerQuery}
|
{isLoading ? (
|
||||||
/>
|
<div className="rotating-symbol text-primary-foreground"></div>
|
||||||
|
) : (
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Send Button */}
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
<Button
|
Press Enter to send, Shift+Enter for new line{projectPath?.trim() && ", @ to mention files, drag & drop images"}
|
||||||
onClick={handleSend}
|
</div>
|
||||||
disabled={!prompt.trim() || isLoading || disabled}
|
|
||||||
size="default"
|
|
||||||
className="min-w-[60px]"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="rotating-symbol text-primary-foreground"></div>
|
|
||||||
) : (
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
|
||||||
Press Enter to send, Shift+Enter for new line{projectPath?.trim() && ", @ to mention files"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
167
src/components/ImagePreview.tsx
Normal file
167
src/components/ImagePreview.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { X, Maximize2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
interface ImagePreviewProps {
|
||||||
|
/**
|
||||||
|
* Array of image file paths to preview
|
||||||
|
*/
|
||||||
|
images: string[];
|
||||||
|
/**
|
||||||
|
* Callback to remove an image from the preview
|
||||||
|
*/
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
/**
|
||||||
|
* Optional className for styling
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ImagePreview component - Shows thumbnail previews of embedded images
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Shows up to 10 image thumbnails in a row
|
||||||
|
* - Click on thumbnail to see full-size preview
|
||||||
|
* - Hover to show remove button
|
||||||
|
* - Smooth animations
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <ImagePreview
|
||||||
|
* images={["/path/to/image1.png", "/path/to/image2.jpg"]}
|
||||||
|
* onRemove={(index) => console.log('Remove image at', index)}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
export const ImagePreview: React.FC<ImagePreviewProps> = ({
|
||||||
|
images,
|
||||||
|
onRemove,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(null);
|
||||||
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||||
|
const [imageErrors, setImageErrors] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Limit to 10 images
|
||||||
|
const displayImages = images.slice(0, 10);
|
||||||
|
|
||||||
|
const handleImageError = (index: number) => {
|
||||||
|
setImageErrors(prev => new Set(prev).add(index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (e: React.MouseEvent, index: number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (displayImages.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={cn("flex gap-2 p-2 overflow-x-auto", className)}>
|
||||||
|
<AnimatePresence>
|
||||||
|
{displayImages.map((imagePath, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={`${imagePath}-${index}`}
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="relative flex-shrink-0 group"
|
||||||
|
onMouseEnter={() => setHoveredIndex(index)}
|
||||||
|
onMouseLeave={() => setHoveredIndex(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-16 h-16 rounded-md overflow-hidden border border-border cursor-pointer hover:border-primary transition-colors"
|
||||||
|
onClick={() => setSelectedImageIndex(index)}
|
||||||
|
>
|
||||||
|
{imageErrors.has(index) ? (
|
||||||
|
<div className="w-full h-full bg-muted flex items-center justify-center">
|
||||||
|
<span className="text-xs text-muted-foreground">Error</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={convertFileSrc(imagePath)}
|
||||||
|
alt={`Preview ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={() => handleImageError(index)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hover overlay with maximize icon */}
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<Maximize2 className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove button */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{hoveredIndex === index && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
className="absolute -top-1 -right-1 w-5 h-5 bg-destructive text-destructive-foreground rounded-full flex items-center justify-center hover:bg-destructive/90 transition-colors"
|
||||||
|
onClick={(e) => handleRemove(e, index)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{images.length > 10 && (
|
||||||
|
<div className="flex-shrink-0 w-16 h-16 rounded-md border border-border bg-muted flex items-center justify-center">
|
||||||
|
<span className="text-xs text-muted-foreground">+{images.length - 10}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full-size preview dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={selectedImageIndex !== null}
|
||||||
|
onOpenChange={(open) => !open && setSelectedImageIndex(null)}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
|
||||||
|
<DialogTitle className="sr-only">Image Preview</DialogTitle>
|
||||||
|
{selectedImageIndex !== null && (
|
||||||
|
<div className="relative w-full h-full flex items-center justify-center p-4">
|
||||||
|
<img
|
||||||
|
src={convertFileSrc(displayImages[selectedImageIndex])}
|
||||||
|
alt={`Full preview ${selectedImageIndex + 1}`}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
onError={() => handleImageError(selectedImageIndex)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Navigation buttons if multiple images */}
|
||||||
|
{displayImages.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-black/50 text-white rounded-full flex items-center justify-center hover:bg-black/70 transition-colors"
|
||||||
|
onClick={() => setSelectedImageIndex((prev) =>
|
||||||
|
prev !== null ? (prev - 1 + displayImages.length) % displayImages.length : 0
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-black/50 text-white rounded-full flex items-center justify-center hover:bg-black/70 transition-colors"
|
||||||
|
onClick={() => setSelectedImageIndex((prev) =>
|
||||||
|
prev !== null ? (prev + 1) % displayImages.length : 0
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
Reference in New Issue
Block a user