import React, { useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { ImageThumbnail, getImageSrc } from './ImageThumbnail'; import { ImageAnnotator } from './ImageAnnotator'; import type { ImageAttachment } from '../types'; import { modKey } from '../utils/platform'; import { getUploadTransport } from '../utils/upload'; /** * Derive a clean, human-readable name from an original filename. * "Login Mockup.png" → "login-mockup" * "annotated.png" or generic names → "image-N" */ export function deriveImageName(originalName: string, existingNames: string[]): string { const base = originalName.replace(/\.[^.]+$/, ''); const generic = ['annotated', 'image', 'screenshot', 'paste', 'clipboard', 'untitled']; if (generic.includes(base.toLowerCase())) { let n = 1; while (existingNames.includes(`image-${n}`)) n++; return `image-${n}`; } let name = base.toLowerCase() .replace(/[_\s]+/g, '-') .replace(/[^a-z0-9-]/g, '') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); if (!name) { let n = 1; while (existingNames.includes(`image-${n}`)) n++; return `image-${n}`; } if (existingNames.includes(name)) { let n = 2; while (existingNames.includes(`${name}-${n}`)) n++; name = `${name}-${n}`; } return name; } interface AttachmentsButtonProps { images: ImageAttachment[]; onAdd: (image: ImageAttachment) => void; onRemove: (path: string) => void; variant?: 'toolbar' | 'inline'; /** Hide the "Images" label (icon-only). When images.length > 0 the * numeric badge still shows so the user can see the count. */ hideLabel?: boolean; } export const AttachmentsButton: React.FC = ({ images, onAdd, onRemove, variant = 'toolbar', hideLabel = false, }) => { const [isOpen, setIsOpen] = useState(false); const [manualPath, setManualPath] = useState(''); const [uploading, setUploading] = useState(false); const [dragOver, setDragOver] = useState(false); const buttonRef = useRef(null); const fileInputRef = useRef(null); const [position, setPosition] = useState({ top: 0, left: 0 }); // Annotator state const [annotatorImage, setAnnotatorImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null); const [editingImage, setEditingImage] = useState<{ path: string; name: string } | null>(null); // Update popover position when opened useEffect(() => { if (isOpen && buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); setPosition({ top: rect.bottom + 8, left: Math.max(8, rect.left - 100), // Center-ish, but keep on screen }); } }, [isOpen]); // Close on escape useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && isOpen) { setIsOpen(false); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isOpen]); // Paste image from clipboard when popover is open (per-annotation attachments). // Uses capture phase + stopPropagation to prevent the global paste handler in // App.tsx from also processing the same event. useEffect(() => { if (!isOpen || annotatorImage) return; const handlePaste = (e: ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type.startsWith('image/')) { e.preventDefault(); e.stopPropagation(); const file = item.getAsFile(); if (file) handleFileSelect(file); return; } } }; document.addEventListener('paste', handlePaste, true); return () => document.removeEventListener('paste', handlePaste, true); }, [isOpen, annotatorImage]); const handleFileSelect = (file: File) => { // Derive name before opening annotator so user sees it immediately const initialName = deriveImageName(file.name, images.map(i => i.name)); const blobUrl = URL.createObjectURL(file); setAnnotatorImage({ file, blobUrl, initialName }); setIsOpen(false); // Close popover when annotator opens }; const handleAnnotatorAccept = async (blob: Blob, hasDrawings: boolean, name: string) => { setUploading(true); try { // Use annotated blob if drawings exist, otherwise original file const fileToUpload = annotatorImage ? (hasDrawings ? new File([blob], 'annotated.png', { type: 'image/png' }) : annotatorImage.file) : editingImage // Re-editing: always upload the new blob ? new File([blob], 'annotated.png', { type: 'image/png' }) : null; if (fileToUpload) { const data = await getUploadTransport().upload(fileToUpload); if (data.path) { // If re-editing, remove old path first if (editingImage) { onRemove(editingImage.path); } // Use the name from the annotator (user may have edited it) onAdd({ path: data.path, name }); } } } catch (err) { console.error('Upload failed:', err); } finally { setUploading(false); // Cleanup if (annotatorImage) { URL.revokeObjectURL(annotatorImage.blobUrl); setAnnotatorImage(null); } setEditingImage(null); } }; const handleAnnotatorClose = () => { if (annotatorImage) { URL.revokeObjectURL(annotatorImage.blobUrl); setAnnotatorImage(null); } setEditingImage(null); }; const handleFileInputChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { handleFileSelect(file); } e.target.value = ''; // Reset for re-selection }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setDragOver(false); const file = e.dataTransfer.files[0]; if (file && file.type.startsWith('image/')) { handleFileSelect(file); } }; const handleManualAdd = () => { const trimmed = manualPath.trim(); if (trimmed) { const name = deriveImageName( trimmed.split('/').pop() || 'image', images.map(i => i.name) ); onAdd({ path: trimmed, name }); setManualPath(''); } }; const handleClearAll = (e: React.MouseEvent) => { e.stopPropagation(); images.forEach(img => onRemove(img.path)); }; // Determine annotator props const annotatorOpen = !!annotatorImage || !!editingImage; const annotatorSrc = annotatorImage?.blobUrl ?? (editingImage ? getImageSrc(editingImage.path) : ''); const annotatorInitialName = annotatorImage?.initialName ?? editingImage?.name ?? ''; return ( <> {/* Trigger Button */} ) : ( )} {(!hideLabel || images.length > 0) && ( {images.length > 0 ? `${images.length}` : 'Images'} )} {/* Popover */} {isOpen && createPortal( <> {/* Backdrop */}
setIsOpen(false)} /> {/* Popover content */}
e.stopPropagation()} >
{/* Header */}
Attachments
{images.length > 0 && ( {images.length} image{images.length !== 1 ? 's' : ''} )}

Add images to include with your feedback

{/* Drop zone / file picker */}
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={handleDrop} onClick={() => fileInputRef.current?.click()} className={`flex flex-col items-center justify-center gap-2 px-3 py-4 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${ dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground' }`} > {uploading ? (
Uploading...
) : ( <> Drop image or click to browse {modKey}+V to paste from clipboard )}
{/* Manual path input */}
setManualPath(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleManualAdd()} placeholder="Paste path or URL..." className="flex-1 px-2 py-1.5 text-xs bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
{/* Grid of current attachments */} {images.length > 0 && (
Current
{images.map((img) => (
{ setEditingImage({ path: img.path, name: img.name }); setIsOpen(false); }} onRemove={() => onRemove(img.path)} showRemove />
{img.name}
))}
)}
, document.body )} {/* Image Annotator Dialog - portaled to body for correct positioning */} {annotatorOpen && createPortal( , document.body )} ); };