feat:음성입력 실시간 미리보기 + 재클릭 중지 기능
continuous 모드로 변경, interimResults로 실시간 텍스트 표시 녹음 중 버튼 재클릭 시 중지, 아이콘 정지 모양으로 변경 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -54,63 +54,102 @@
|
||||
}
|
||||
|
||||
// --- VoiceInputButton (Web Speech API STT) ---
|
||||
function VoiceInputButton({ onResult, disabled, mode = 'replace' }) {
|
||||
function VoiceInputButton({ onResult, disabled }) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [interim, setInterim] = useState('');
|
||||
const recognitionRef = useRef(null);
|
||||
|
||||
const isSupported = typeof window !== 'undefined' &&
|
||||
(window.SpeechRecognition || window.webkitSpeechRecognition);
|
||||
|
||||
const toggle = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (disabled || !isSupported) return;
|
||||
|
||||
if (recording) {
|
||||
recognitionRef.current?.stop();
|
||||
setRecording(false);
|
||||
return;
|
||||
}
|
||||
const stopRecording = useCallback(() => {
|
||||
recognitionRef.current?.stop();
|
||||
recognitionRef.current = null;
|
||||
setRecording(false);
|
||||
setInterim('');
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(() => {
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const recognition = new SR();
|
||||
recognition.lang = 'ko-KR';
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = false;
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.maxAlternatives = 1;
|
||||
|
||||
let finalText = '';
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
const text = event.results[0][0].transcript;
|
||||
if (text) onResult(text, mode);
|
||||
setRecording(false);
|
||||
let interimText = '';
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const transcript = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
finalText += transcript;
|
||||
if (finalText) onResult(finalText);
|
||||
finalText = '';
|
||||
interimText = '';
|
||||
} else {
|
||||
interimText += transcript;
|
||||
}
|
||||
}
|
||||
setInterim(interimText);
|
||||
};
|
||||
recognition.onerror = () => stopRecording();
|
||||
recognition.onend = () => {
|
||||
setRecording(false);
|
||||
setInterim('');
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
recognition.onerror = () => setRecording(false);
|
||||
recognition.onend = () => setRecording(false);
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
setRecording(true);
|
||||
setInterim('');
|
||||
}, [onResult, stopRecording]);
|
||||
|
||||
const toggle = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (disabled || !isSupported) return;
|
||||
recording ? stopRecording() : startRecording();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => { recognitionRef.current?.stop(); };
|
||||
}, []);
|
||||
|
||||
if (!isSupported) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={disabled}
|
||||
title={recording ? '녹음 중지' : '음성으로 입력'}
|
||||
className={`inline-flex items-center justify-center w-8 h-8 rounded-full transition-all flex-shrink-0
|
||||
${recording
|
||||
? 'bg-red-500 text-white animate-pulse shadow-lg shadow-red-200'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-blue-100 hover:text-blue-600'}
|
||||
${disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={disabled}
|
||||
title={recording ? '녹음 중지 (클릭)' : '음성으로 입력'}
|
||||
className={`inline-flex items-center justify-center w-8 h-8 rounded-full transition-all
|
||||
${recording
|
||||
? 'bg-red-500 text-white animate-pulse shadow-lg shadow-red-200'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-blue-100 hover:text-blue-600'}
|
||||
${disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
{recording ? (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{recording && interim && (
|
||||
<div className="absolute bottom-full mb-2 right-0 bg-gray-900 text-white text-xs px-3 py-1.5 rounded-lg shadow-lg whitespace-nowrap max-w-[250px] truncate z-50">
|
||||
<span className="text-yellow-300 mr-1">...</span>{interim}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user