/** * Composable for managing file attachments. * Handles file selection, drag-drop, paste, conversion, preview, and removal. */ import { ref, computed } from 'vue'; import type { AgentAttachment } from 'chrome-mcp-shared'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_ATTACHMENTS = 10; // Maximum number of attachments // Allowed image MIME types (exclude SVG for security) const ALLOWED_IMAGE_TYPES = new Set([ 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', ]); /** * Extended attachment type with preview URL support. */ export interface AttachmentWithPreview extends AgentAttachment { /** Data URL for image preview (data:xxx;base64,...) */ previewUrl?: string; } export function useAttachments() { const attachments = ref([]); const fileInputRef = ref(null); const error = ref(null); const isDragOver = ref(false); // Computed: check if we have any image attachments const hasImages = computed(() => attachments.value.some((a) => a.type === 'image')); // Computed: check if we can add more attachments const canAddMore = computed(() => attachments.value.length < MAX_ATTACHMENTS); /** * Open file picker for image selection. */ function openFilePicker(): void { fileInputRef.value?.click(); } /** * Convert file to base64 string. */ function fileToBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result as string; // Remove data:xxx;base64, prefix const base64 = result.split(',')[1]; resolve(base64); }; reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }); } /** * Generate preview URL for image attachments. */ function getPreviewUrl(attachment: AttachmentWithPreview): string { if (attachment.previewUrl) { return attachment.previewUrl; } // Generate data URL from base64 return `data:${attachment.mimeType};base64,${attachment.dataBase64}`; } /** * Process files and add them as attachments. * This is the core method used by file input, drag-drop, and paste handlers. */ async function handleFiles(files: File[]): Promise { error.value = null; // Filter to only allowed image types (exclude SVG for security) const imageFiles = files.filter((file) => ALLOWED_IMAGE_TYPES.has(file.type)); if (imageFiles.length === 0) { error.value = 'Only PNG, JPEG, GIF, and WebP images are supported.'; return; } // Check attachment limit const remaining = MAX_ATTACHMENTS - attachments.value.length; if (remaining <= 0) { error.value = `Maximum ${MAX_ATTACHMENTS} attachments allowed.`; return; } const filesToProcess = imageFiles.slice(0, remaining); if (filesToProcess.length < imageFiles.length) { error.value = `Only ${remaining} more attachment(s) allowed. Some files were skipped.`; } for (const file of filesToProcess) { // Validate file size if (file.size > MAX_FILE_SIZE) { error.value = `File "${file.name}" is too large. Maximum size is 10MB.`; continue; } try { const base64 = await fileToBase64(file); const previewUrl = `data:${file.type};base64,${base64}`; attachments.value.push({ type: 'image', name: file.name, mimeType: file.type || 'image/png', dataBase64: base64, previewUrl, }); } catch (err) { console.error('Failed to read file:', err); error.value = `Failed to read file "${file.name}".`; } } } /** * Handle file selection from input element. */ async function handleFileSelect(event: Event): Promise { const input = event.target as HTMLInputElement; const files = input.files; if (!files || files.length === 0) return; await handleFiles(Array.from(files)); // Clear input to allow selecting the same file again input.value = ''; } /** * Handle drag over event - update visual state. */ function handleDragOver(event: DragEvent): void { event.preventDefault(); event.stopPropagation(); isDragOver.value = true; } /** * Handle drag leave event - reset visual state. */ function handleDragLeave(event: DragEvent): void { event.preventDefault(); event.stopPropagation(); isDragOver.value = false; } /** * Handle drop event - process dropped files. */ async function handleDrop(event: DragEvent): Promise { event.preventDefault(); event.stopPropagation(); isDragOver.value = false; const files = event.dataTransfer?.files; if (!files || files.length === 0) return; await handleFiles(Array.from(files)); } /** * Handle paste event - extract and process pasted images. */ async function handlePaste(event: ClipboardEvent): Promise { const items = event.clipboardData?.items; if (!items) return; const imageFiles: File[] = []; for (const item of items) { // Only allow specific image types (exclude SVG for security) if (ALLOWED_IMAGE_TYPES.has(item.type)) { const file = item.getAsFile(); if (file) { // Generate a name for pasted images (they don't have one) const ext = item.type.split('/')[1] || 'png'; const namedFile = new File([file], `pasted-image-${Date.now()}.${ext}`, { type: file.type, }); imageFiles.push(namedFile); } } } if (imageFiles.length > 0) { // Prevent default paste behavior for images event.preventDefault(); await handleFiles(imageFiles); } // Let text paste through normally } /** * Remove attachment by index. */ function removeAttachment(index: number): void { attachments.value.splice(index, 1); error.value = null; } /** * Clear all attachments. */ function clearAttachments(): void { attachments.value = []; error.value = null; } /** * Get attachments for sending (strips preview URLs). */ function getAttachments(): AgentAttachment[] | undefined { if (attachments.value.length === 0) return undefined; return attachments.value.map(({ type, name, mimeType, dataBase64 }) => ({ type, name, mimeType, dataBase64, })); } return { // State attachments, fileInputRef, error, isDragOver, // Computed hasImages, canAddMore, // Methods openFilePicker, handleFileSelect, handleFiles, handleDragOver, handleDragLeave, handleDrop, handlePaste, removeAttachment, clearAttachments, getAttachments, getPreviewUrl, }; }