diff --git a/frontend/src/components/Toolbar.jsx b/frontend/src/components/Toolbar.jsx index 6973cd5..952e50e 100644 --- a/frontend/src/components/Toolbar.jsx +++ b/frontend/src/components/Toolbar.jsx @@ -1,10 +1,46 @@ -import { useRef, useState, useEffect } from 'react' +import { useRef, useState, useEffect, useCallback } from 'react' export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeChange }) { const fileRef = useRef() const [isReading, setIsReading] = useState(false) const isReadingRef = useRef(false) // ref so closure in next() always sees current value + // ── TTS voice & speed ───────────────────────────────────────────────────── + const [voices, setVoices] = useState([]) + const [voiceURI, setVoiceURI] = useState(() => localStorage.getItem('sw-tts-voice') || '') + const [ttsRate, setTtsRate] = useState(() => parseFloat(localStorage.getItem('sw-tts-rate') || '1')) + const [showTtsMenu, setShowTtsMenu] = useState(false) + + const loadVoices = useCallback(() => { + const all = window.speechSynthesis?.getVoices() ?? [] + if (!all.length) return + setVoices(all) + // If no preference saved yet, auto-pick first English non-eSpeak voice + setVoiceURI(prev => { + if (prev && all.find(v => v.voiceURI === prev)) return prev + const pick = all.find(v => !v.name.toLowerCase().includes('espeak') && v.lang.startsWith('en')) + || all.find(v => v.lang.startsWith('en')) + || all[0] + return pick ? pick.voiceURI : '' + }) + }, []) + + useEffect(() => { + loadVoices() + window.speechSynthesis.addEventListener('voiceschanged', loadVoices) + return () => window.speechSynthesis.removeEventListener('voiceschanged', loadVoices) + }, [loadVoices]) + + useEffect(() => { localStorage.setItem('sw-tts-voice', voiceURI) }, [voiceURI]) + useEffect(() => { localStorage.setItem('sw-tts-rate', ttsRate) }, [ttsRate]) + + // Keep latest voice/rate in refs so the reading closure always gets current values + const voiceURIRef = useRef(voiceURI) + const ttsRateRef = useRef(ttsRate) + useEffect(() => { voiceURIRef.current = voiceURI }, [voiceURI]) + useEffect(() => { ttsRateRef.current = ttsRate }, [ttsRate]) + // ───────────────────────────────────────────────────────────────────────── + // ── Typewriter mode ─────────────────────────────────────────────────────── const [typewriterMode, setTypewriterMode] = useState(false) const typewriterRef = useRef(false) @@ -79,6 +115,8 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha isReadingRef.current = true setIsReading(true) + const chosenVoice = window.speechSynthesis.getVoices().find(v => v.voiceURI === voiceURIRef.current) + let idx = 0 function next() { if (!isReadingRef.current || idx >= chunks.length) { @@ -87,6 +125,8 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha return } const u = new SpeechSynthesisUtterance(chunks[idx++]) + if (chosenVoice) u.voice = chosenVoice + u.rate = ttsRateRef.current u.onend = next u.onerror = () => { isReadingRef.current = false; setIsReading(false) } speechSynthesis.speak(u) @@ -207,6 +247,38 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha ⌨ +