diff --git a/auto-claude-ui/src/renderer/components/TaskCreationWizard.tsx b/auto-claude-ui/src/renderer/components/TaskCreationWizard.tsx index e86f8092..4a7a858b 100644 --- a/auto-claude-ui/src/renderer/components/TaskCreationWizard.tsx +++ b/auto-claude-ui/src/renderer/components/TaskCreationWizard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef, useMemo, type ClipboardEvent } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo, type ClipboardEvent, type DragEvent } from 'react'; import { DndContext, DragOverlay, @@ -124,6 +124,9 @@ export function TaskCreationWizard({ // Ref for the textarea to handle paste events const descriptionRef = useRef(null); + // Drag-and-drop state for images over textarea + const [isDragOverTextarea, setIsDragOverTextarea] = useState(false); + // Setup drag sensors with distance constraint to prevent accidental drags const sensors = useSensors( useSensor(PointerSensor, { @@ -277,6 +280,105 @@ export function TaskCreationWizard({ } }, [images]); + /** + * Handle drag over textarea for image drops + */ + const handleTextareaDragOver = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOverTextarea(true); + }, []); + + /** + * Handle drag leave from textarea + */ + const handleTextareaDragLeave = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOverTextarea(false); + }, []); + + /** + * Handle drop on textarea for image files + */ + const handleTextareaDrop = useCallback( + async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOverTextarea(false); + + if (isCreating) return; + + const files = e.dataTransfer?.files; + if (!files || files.length === 0) return; + + // Filter for image files + const imageFiles: File[] = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.type.startsWith('image/')) { + imageFiles.push(file); + } + } + + if (imageFiles.length === 0) return; + + // Check if we can add more images + const remainingSlots = MAX_IMAGES_PER_TASK - images.length; + if (remainingSlots <= 0) { + setError(`Maximum of ${MAX_IMAGES_PER_TASK} images allowed`); + return; + } + + setError(null); + + // Process image files + const newImages: ImageAttachment[] = []; + const existingFilenames = images.map(img => img.filename); + + for (const file of imageFiles.slice(0, remainingSlots)) { + // Validate image type + if (!isValidImageMimeType(file.type)) { + setError(`Invalid image type. Allowed: ${ALLOWED_IMAGE_TYPES_DISPLAY}`); + continue; + } + + try { + const dataUrl = await blobToBase64(file); + const thumbnail = await createThumbnail(dataUrl); + + // Use original filename or generate one + const baseFilename = file.name || `dropped-image-${Date.now()}.${file.type.split('/')[1] || 'png'}`; + const resolvedFilename = resolveFilename(baseFilename, [ + ...existingFilenames, + ...newImages.map(img => img.filename) + ]); + + newImages.push({ + id: generateImageId(), + filename: resolvedFilename, + mimeType: file.type, + size: file.size, + data: dataUrl.split(',')[1], // Store base64 without data URL prefix + thumbnail + }); + } catch { + setError('Failed to process dropped image'); + } + } + + if (newImages.length > 0) { + setImages(prev => [...prev, ...newImages]); + // Auto-expand images section + setShowImages(true); + // Show success feedback + setPasteSuccess(true); + setTimeout(() => setPasteSuccess(false), 2000); + } + }, + [images, isCreating] + ); + /** * Handle drag start - capture file data for overlay */ @@ -499,9 +601,15 @@ export function TaskCreationWizard({ value={description} onChange={(e) => setDescription(e.target.value)} onPaste={handlePaste} + onDragOver={handleTextareaDragOver} + onDragLeave={handleTextareaDragLeave} + onDrop={handleTextareaDrop} rows={5} disabled={isCreating} - className="resize-y min-h-[120px] max-h-[400px]" + className={cn( + "resize-y min-h-[120px] max-h-[400px]", + isDragOverTextarea && !isCreating && "border-primary bg-primary/5 ring-2 ring-primary/20" + )} />

Tip: Paste screenshots directly with {navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V'} to add reference images. diff --git a/auto-claude-ui/src/renderer/components/TaskEditDialog.tsx b/auto-claude-ui/src/renderer/components/TaskEditDialog.tsx index 2abc32df..d50c7961 100644 --- a/auto-claude-ui/src/renderer/components/TaskEditDialog.tsx +++ b/auto-claude-ui/src/renderer/components/TaskEditDialog.tsx @@ -24,7 +24,7 @@ * /> * ``` */ -import { useState, useEffect, useCallback, useRef, type ClipboardEvent } from 'react'; +import { useState, useEffect, useCallback, useRef, type ClipboardEvent, type DragEvent } from 'react'; import { Loader2, Image as ImageIcon, ChevronDown, ChevronUp, X } from 'lucide-react'; import { Dialog, @@ -106,6 +106,9 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi // Ref for the textarea to handle paste events const descriptionRef = useRef(null); + // Drag-and-drop state for images over textarea + const [isDragOverTextarea, setIsDragOverTextarea] = useState(false); + // Reset form when task changes or dialog opens useEffect(() => { if (open) { @@ -209,6 +212,105 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi } }, [images]); + /** + * Handle drag over textarea for image drops + */ + const handleTextareaDragOver = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOverTextarea(true); + }, []); + + /** + * Handle drag leave from textarea + */ + const handleTextareaDragLeave = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOverTextarea(false); + }, []); + + /** + * Handle drop on textarea for image files + */ + const handleTextareaDrop = useCallback( + async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOverTextarea(false); + + if (isSaving) return; + + const files = e.dataTransfer?.files; + if (!files || files.length === 0) return; + + // Filter for image files + const imageFiles: File[] = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.type.startsWith('image/')) { + imageFiles.push(file); + } + } + + if (imageFiles.length === 0) return; + + // Check if we can add more images + const remainingSlots = MAX_IMAGES_PER_TASK - images.length; + if (remainingSlots <= 0) { + setError(`Maximum of ${MAX_IMAGES_PER_TASK} images allowed`); + return; + } + + setError(null); + + // Process image files + const newImages: ImageAttachment[] = []; + const existingFilenames = images.map(img => img.filename); + + for (const file of imageFiles.slice(0, remainingSlots)) { + // Validate image type + if (!isValidImageMimeType(file.type)) { + setError(`Invalid image type. Allowed: ${ALLOWED_IMAGE_TYPES_DISPLAY}`); + continue; + } + + try { + const dataUrl = await blobToBase64(file); + const thumbnail = await createThumbnail(dataUrl); + + // Use original filename or generate one + const baseFilename = file.name || `dropped-image-${Date.now()}.${file.type.split('/')[1] || 'png'}`; + const resolvedFilename = resolveFilename(baseFilename, [ + ...existingFilenames, + ...newImages.map(img => img.filename) + ]); + + newImages.push({ + id: generateImageId(), + filename: resolvedFilename, + mimeType: file.type, + size: file.size, + data: dataUrl.split(',')[1], // Store base64 without data URL prefix + thumbnail + }); + } catch { + setError('Failed to process dropped image'); + } + } + + if (newImages.length > 0) { + setImages(prev => [...prev, ...newImages]); + // Auto-expand images section + setShowImages(true); + // Show success feedback + setPasteSuccess(true); + setTimeout(() => setPasteSuccess(false), 2000); + } + }, + [images, isSaving] + ); + const handleSave = async () => { // Validate input - only description is required if (!description.trim()) { @@ -296,8 +398,14 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi value={description} onChange={(e) => setDescription(e.target.value)} onPaste={handlePaste} + onDragOver={handleTextareaDragOver} + onDragLeave={handleTextareaDragLeave} + onDrop={handleTextareaDrop} rows={5} disabled={isSaving} + className={cn( + isDragOverTextarea && !isSaving && "border-primary bg-primary/5 ring-2 ring-primary/20" + )} />

Tip: Paste screenshots directly with {navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V'} to add reference images.