StudioAmico / src /components /InputArea.jsx
outshine84
fix registrazione vocale
01f6955
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