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:
Mufeed VH
2025-06-22 01:50:29 +05:30
parent 9ab383bbbe
commit 93ce8b656f
5 changed files with 457 additions and 100 deletions

7
src-tauri/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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": {

View File

@@ -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,11 +432,28 @@ export const FloatingPromptInput: React.FC<FloatingPromptInputProps> = ({
</AnimatePresence> </AnimatePresence>
{/* Fixed Position Input Bar */} {/* Fixed Position Input Bar */}
<div className={cn( <div
className={cn(
"fixed bottom-0 left-0 right-0 z-40 bg-background border-t border-border", "fixed bottom-0 left-0 right-0 z-40 bg-background border-t border-border",
dragActive && "ring-2 ring-primary ring-offset-2",
className className
)}> )}
<div className="max-w-5xl mx-auto p-4"> onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<div className="max-w-5xl mx-auto">
{/* Image previews */}
{embeddedImages.length > 0 && (
<ImagePreview
images={embeddedImages}
onRemove={handleRemoveImage}
className="border-b border-border"
/>
)}
<div className="p-4">
<div className="flex items-end gap-3"> <div className="flex items-end gap-3">
{/* Model Picker */} {/* Model Picker */}
<Popover <Popover
@@ -333,9 +508,12 @@ export const FloatingPromptInput: React.FC<FloatingPromptInputProps> = ({
value={prompt} value={prompt}
onChange={handleTextChange} onChange={handleTextChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Ask Claude anything..." placeholder={dragActive ? "Drop images here..." : "Ask Claude anything..."}
disabled={isLoading || disabled} disabled={isLoading || disabled}
className="min-h-[44px] max-h-[120px] resize-none pr-10" className={cn(
"min-h-[44px] max-h-[120px] resize-none pr-10",
dragActive && "border-primary"
)}
rows={1} rows={1}
/> />
@@ -378,7 +556,8 @@ export const FloatingPromptInput: React.FC<FloatingPromptInputProps> = ({
</div> </div>
<div className="mt-2 text-xs text-muted-foreground"> <div className="mt-2 text-xs text-muted-foreground">
Press Enter to send, Shift+Enter for new line{projectPath?.trim() && ", @ to mention files"} Press Enter to send, Shift+Enter for new line{projectPath?.trim() && ", @ to mention files, drag & drop images"}
</div>
</div> </div>
</div> </div>
</div> </div>

View 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>
</>
);
};