Spaces:
Running
Running
| import React, { useState, useRef, useEffect } from 'react' | |
| import './InputArea.css' | |
| import sendIcon from '../assets/send-icon.svg' | |
| const MAX_IMAGE_DIMENSION = 1600 | |
| const JPEG_QUALITY = 0.82 | |
| const fileToDataUrl = (file) => new Promise((resolve, reject) => { | |
| const reader = new FileReader() | |
| reader.onload = () => resolve(String(reader.result || '')) | |
| reader.onerror = () => reject(new Error('Impossibile leggere il file selezionato')) | |
| reader.readAsDataURL(file) | |
| }) | |
| const loadImageElement = (file) => new Promise((resolve, reject) => { | |
| const image = new Image() | |
| const objectUrl = URL.createObjectURL(file) | |
| image.onload = () => { | |
| URL.revokeObjectURL(objectUrl) | |
| resolve(image) | |
| } | |
| image.onerror = () => { | |
| URL.revokeObjectURL(objectUrl) | |
| reject(new Error('Impossibile aprire l\'immagine selezionata')) | |
| } | |
| image.src = objectUrl | |
| }) | |
| const resizeImageFile = async (file) => { | |
| if (!file.type.startsWith('image/')) { | |
| return fileToDataUrl(file) | |
| } | |
| try { | |
| const image = await loadImageElement(file) | |
| const longestSide = Math.max(image.width, image.height) | |
| if (longestSide <= MAX_IMAGE_DIMENSION && file.size <= 1_500_000) { | |
| return fileToDataUrl(file) | |
| } | |
| const scale = MAX_IMAGE_DIMENSION / longestSide | |
| const width = Math.max(1, Math.round(image.width * scale)) | |
| const height = Math.max(1, Math.round(image.height * scale)) | |
| const canvas = document.createElement('canvas') | |
| canvas.width = width | |
| canvas.height = height | |
| const context = canvas.getContext('2d') | |
| if (!context) { | |
| return fileToDataUrl(file) | |
| } | |
| context.drawImage(image, 0, 0, width, height) | |
| return canvas.toDataURL('image/jpeg', JPEG_QUALITY) | |
| } catch (error) { | |
| console.error('Errore compressione immagine:', error) | |
| return fileToDataUrl(file) | |
| } | |
| } | |
| function InputArea({ onSendMessage, isLoading, allowPhotoUpload = false }) { | |
| const [input, setInput] = useState('') | |
| const [isListening, setIsListening] = useState(false) | |
| const [selectedPhotos, setSelectedPhotos] = useState([]) | |
| const [micError, setMicError] = useState('') | |
| const recognitionRef = useRef(null) | |
| const shouldKeepListeningRef = useRef(false) | |
| const baseInputRef = useRef('') | |
| const finalTranscriptRef = useRef('') | |
| useEffect(() => () => { | |
| shouldKeepListeningRef.current = false | |
| if (recognitionRef.current) { | |
| recognitionRef.current.onstart = null | |
| recognitionRef.current.onend = null | |
| recognitionRef.current.onresult = null | |
| recognitionRef.current.onerror = null | |
| try { | |
| recognitionRef.current.stop() | |
| } catch (error) { | |
| // Ignora errori quando il riconoscimento e' gia' fermo. | |
| } | |
| recognitionRef.current = null | |
| } | |
| }, []) | |
| const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition | |
| const speechSupported = !!SpeechRecognitionAPI | |
| const stopListening = () => { | |
| shouldKeepListeningRef.current = false | |
| setIsListening(false) | |
| if (recognitionRef.current) { | |
| try { | |
| recognitionRef.current.stop() | |
| } catch (error) { | |
| // Ignora errori quando il riconoscimento e' gia' fermo. | |
| } | |
| } | |
| } | |
| // Crea una nuova istanza ad ogni avvio (necessario su iOS Safari) | |
| const startListening = () => { | |
| if (!SpeechRecognitionAPI) return | |
| setMicError('') | |
| shouldKeepListeningRef.current = true | |
| baseInputRef.current = input | |
| finalTranscriptRef.current = '' | |
| const recognition = new SpeechRecognitionAPI() | |
| recognitionRef.current = recognition | |
| recognition.lang = 'it-IT' | |
| recognition.continuous = false | |
| recognition.interimResults = true | |
| recognition.onstart = () => { | |
| setIsListening(true) | |
| } | |
| recognition.onend = () => { | |
| if (shouldKeepListeningRef.current) { | |
| try { | |
| recognition.start() | |
| } catch (error) { | |
| setTimeout(() => { | |
| if (!shouldKeepListeningRef.current) return | |
| try { | |
| recognition.start() | |
| } catch (restartError) { | |
| console.error('Errore riavvio Speech Recognition:', restartError) | |
| shouldKeepListeningRef.current = false | |
| setIsListening(false) | |
| } | |
| }, 150) | |
| } | |
| } else { | |
| setIsListening(false) | |
| } | |
| } | |
| recognition.onresult = (event) => { | |
| let interimTranscript = '' | |
| for (let i = event.resultIndex; i < event.results.length; i++) { | |
| const chunk = event.results[i][0].transcript | |
| if (event.results[i].isFinal) { | |
| finalTranscriptRef.current += chunk | |
| } else { | |
| interimTranscript += chunk | |
| } | |
| } | |
| setInput(`${baseInputRef.current}${finalTranscriptRef.current}${interimTranscript}`) | |
| } | |
| recognition.onerror = (event) => { | |
| console.error('Errore Speech Recognition:', event.error) | |
| if (event.error === 'not-allowed') { | |
| shouldKeepListeningRef.current = false | |
| setIsListening(false) | |
| setMicError('Permesso microfono negato. Abilitalo nelle impostazioni del browser.') | |
| } else if (event.error === 'network') { | |
| shouldKeepListeningRef.current = false | |
| setIsListening(false) | |
| setMicError('Errore di rete. Controlla la connessione.') | |
| } | |
| } | |
| recognition.start() | |
| } | |
| const handleSubmit = (e) => { | |
| e.preventDefault() | |
| if (isListening) { | |
| stopListening() | |
| } | |
| const text = input.trim() | |
| const hasPhotos = selectedPhotos.length > 0 | |
| if ((text || hasPhotos) && !isLoading) { | |
| onSendMessage({ | |
| text, | |
| photos: selectedPhotos.map((item) => item.dataUrl) | |
| }) | |
| setInput('') | |
| setSelectedPhotos([]) | |
| } | |
| } | |
| const handlePhotoSelect = async (event) => { | |
| const files = Array.from(event.target.files || []).filter((file) => file.type.startsWith('image/')) | |
| if (!files.length) return | |
| try { | |
| const nextPhotos = await Promise.all(files.slice(0, 4).map(async (file) => ({ | |
| name: file.name, | |
| dataUrl: await resizeImageFile(file) | |
| }))) | |
| setSelectedPhotos((prev) => { | |
| const known = new Set(prev.map((item) => item.dataUrl)) | |
| const merged = [...prev] | |
| nextPhotos.forEach((item) => { | |
| if (!known.has(item.dataUrl)) { | |
| known.add(item.dataUrl) | |
| merged.push(item) | |
| } | |
| }) | |
| return merged.slice(-6) | |
| }) | |
| } catch (error) { | |
| console.error('Errore selezione foto:', error) | |
| } | |
| } | |
| const removePhoto = (dataUrl) => { | |
| setSelectedPhotos((prev) => prev.filter((item) => item.dataUrl !== dataUrl)) | |
| } | |
| const handleMicClick = () => { | |
| if (!speechSupported) { | |
| setMicError('Il tuo browser non supporta il riconoscimento vocale.') | |
| return | |
| } | |
| if (isListening) { | |
| stopListening() | |
| } else { | |
| startListening() | |
| } | |
| } | |
| const handleKeyDown = (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault() | |
| handleSubmit(e) | |
| } | |
| } | |
| return ( | |
| <div className="input-area"> | |
| <form onSubmit={handleSubmit} className="input-form"> | |
| <textarea | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder="Scrivi qui o usa il microfono" | |
| className="text-input" | |
| rows={3} | |
| disabled={isLoading} | |
| /> | |
| <div className="input-actions"> | |
| {allowPhotoUpload && ( | |
| <> | |
| {/* Usa label per massima compatibilità mobile (evita .click() programmatico) */} | |
| <label | |
| className={`photo-button${isLoading ? ' disabled' : ''}`} | |
| title="Aggiungi foto di pagine o appunti" | |
| > | |
| <span className="action-icon" aria-hidden="true">🖼️</span> | |
| <span className="action-label">Foto</span> | |
| <input | |
| type="file" | |
| accept="image/*" | |
| multiple | |
| className="photo-input" | |
| onChange={handlePhotoSelect} | |
| disabled={isLoading} | |
| /> | |
| </label> | |
| <label | |
| className={`camera-button${isLoading ? ' disabled' : ''}`} | |
| title="Scatta una foto" | |
| > | |
| <span className="action-icon" aria-hidden="true">📸</span> | |
| <span className="action-label">Scatta</span> | |
| <input | |
| type="file" | |
| accept="image/*" | |
| capture="environment" | |
| className="photo-input" | |
| onChange={handlePhotoSelect} | |
| disabled={isLoading} | |
| /> | |
| </label> | |
| </> | |
| )} | |
| <button | |
| type="button" | |
| onClick={handleMicClick} | |
| className={`mic-button ${isListening ? 'listening' : ''} ${!speechSupported ? 'mic-unsupported' : ''}`} | |
| disabled={isLoading} | |
| title={!speechSupported ? 'Microfono non supportato da questo browser' : isListening ? 'Clicca per interrompere il microfono' : 'Clicca per usare il microfono'} | |
| > | |
| <span className="mic-icon action-icon" aria-hidden="true">🎤</span> | |
| <span className="action-label">Parla</span> | |
| </button> | |
| <button | |
| type="submit" | |
| className="send-button" | |
| disabled={isLoading || (!input.trim() && !selectedPhotos.length)} | |
| > | |
| {isLoading ? '⏳' : ( | |
| <> | |
| <img src={sendIcon} alt="Invia" className="send-icon-img action-icon" /> | |
| <span className="action-label">Invia</span> | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| </form> | |
| {allowPhotoUpload && selectedPhotos.length > 0 && ( | |
| <div className="photo-preview-wrap"> | |
| {selectedPhotos.map((photo, idx) => ( | |
| <div className="photo-preview" key={`${photo.dataUrl.slice(0, 24)}-${idx}`}> | |
| <img src={photo.dataUrl} alt={photo.name || `Foto ${idx + 1}`} /> | |
| <button | |
| type="button" | |
| className="photo-remove" | |
| onClick={() => removePhoto(photo.dataUrl)} | |
| aria-label={`Rimuovi foto ${idx + 1}`} | |
| > | |
| × | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {isListening && ( | |
| <div className="listening-indicator"> | |
| <div className="pulse"></div> | |
| <span>In ascolto...</span> | |
| </div> | |
| )} | |
| {micError && ( | |
| <div className="mic-error" role="alert"> | |
| {micError} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| export default InputArea | |