[251020] fix: VoiceInputOverlay Focus
🕐 커밋 시간: 2025. 10. 20. 19:38:24 📊 변경 통계: • 총 파일: 5개 • 추가: +118줄 • 삭제: -22줄 📝 수정된 파일: ~ com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less 🔧 주요 변경 내용: • 중간 규모 기능 개선
This commit is contained in:
@@ -524,6 +524,11 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
const handleVoiceOverlayClose = useCallback(() => {
|
||||
console.log('🚪 [DEBUG][SearchPanel] handleVoiceOverlayClose called, setting isVoiceOverlayVisible to FALSE');
|
||||
setIsVoiceOverlayVisible(false);
|
||||
|
||||
// ✅ VoiceOverlay가 닫힐 때 항상 TInput으로 포커스 이동
|
||||
setTimeout(() => {
|
||||
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
|
||||
}, 150); // Overlay 닫히는 시간을 고려한 지연
|
||||
}, []);
|
||||
|
||||
// Search overlay close handler
|
||||
@@ -839,18 +844,16 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
onClose={() => setShowVirtualKeyboard(false)}
|
||||
/> */}
|
||||
|
||||
{/* Voice Input Overlay */}
|
||||
{isVoiceOverlayVisible && (
|
||||
<VoiceInputOverlay
|
||||
isVisible={isVoiceOverlayVisible}
|
||||
onClose={handleVoiceOverlayClose}
|
||||
mode={VOICE_MODES.PROMPT}
|
||||
suggestions={voiceSuggestions}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearchSubmit={handleSearchSubmit}
|
||||
/>
|
||||
)}
|
||||
{/* Voice Input Overlay - 항상 마운트하고 isVisible prop으로만 제어 */}
|
||||
<VoiceInputOverlay
|
||||
isVisible={isVoiceOverlayVisible}
|
||||
onClose={handleVoiceOverlayClose}
|
||||
mode={VOICE_MODES.PROMPT}
|
||||
suggestions={voiceSuggestions}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearchSubmit={handleSearchSubmit}
|
||||
/>
|
||||
{isSearchOverlayVisible && (
|
||||
<SearchInputOverlay
|
||||
isVisible={isSearchOverlayVisible}
|
||||
|
||||
@@ -85,7 +85,7 @@ const ENABLE_WAKE_WORD = false;
|
||||
// false로 설정하면 Beep 소리가 재생되지 않습니다
|
||||
const ENABLE_BEEP_SOUND = true;
|
||||
|
||||
// Utility function to clear a single timer ref
|
||||
// Utility function to clear a single timer ref (timeout)
|
||||
const clearTimerRef = (timerRef) => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
@@ -93,9 +93,23 @@ const clearTimerRef = (timerRef) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function to clear a single interval ref
|
||||
const clearIntervalRef = (intervalRef) => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function to clear all timers at once
|
||||
const clearAllTimers = (timerRefs) => {
|
||||
timerRefs.forEach((timerRef) => clearTimerRef(timerRef));
|
||||
timerRefs.forEach((timerRef) => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const VoiceInputOverlay = ({
|
||||
@@ -121,6 +135,7 @@ const VoiceInputOverlay = ({
|
||||
const focusRestoreTimerRef = useRef(null);
|
||||
const searchSubmitFocusTimerRef = useRef(null);
|
||||
const wakeWordRestartTimerRef = useRef(null);
|
||||
const countdownIntervalRef = useRef(null);
|
||||
|
||||
// All timer refs array for batch cleanup
|
||||
const allTimerRefs = [
|
||||
@@ -130,6 +145,7 @@ const VoiceInputOverlay = ({
|
||||
focusRestoreTimerRef,
|
||||
searchSubmitFocusTimerRef,
|
||||
wakeWordRestartTimerRef,
|
||||
countdownIntervalRef,
|
||||
];
|
||||
|
||||
const [micFocused, setMicFocused] = useState(false);
|
||||
@@ -139,6 +155,8 @@ const VoiceInputOverlay = ({
|
||||
const [voiceInputMode, setVoiceInputMode] = useState(null);
|
||||
// STT 응답 텍스트 저장
|
||||
const [sttResponseText, setSttResponseText] = useState('');
|
||||
// 카운트다운 타이머 (15 -> 1)
|
||||
const [countdown, setCountdown] = useState(15);
|
||||
// 검색 기록 관리 (localStorage 기반, 최근 5개)
|
||||
const [searchHistory, setSearchHistory] = useState(() => {
|
||||
const history = readLocalStorage(SEARCH_HISTORY_KEY, DEFAULT_SUGGESTIONS);
|
||||
@@ -309,14 +327,20 @@ const VoiceInputOverlay = ({
|
||||
[dispatch, addToSearchHistory, onSearchChange]
|
||||
);
|
||||
|
||||
const { isListening, interimText, startListening, stopListening, isSupported } = useWebSpeech(
|
||||
isVisible, // Overlay가 열려있을 때만 활성화 (voiceInputMode와 무관하게 초기화)
|
||||
handleWebSpeechSTT,
|
||||
{
|
||||
// WebSpeech config 메모이제이션 (불필요한 재초기화 방지)
|
||||
const webSpeechConfig = useMemo(
|
||||
() => ({
|
||||
lang: 'en-US',
|
||||
continuous: false, // 침묵 감지 후 자동 종료
|
||||
interimResults: true,
|
||||
}
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const { isListening, interimText, startListening, stopListening, isSupported } = useWebSpeech(
|
||||
isVisible, // Overlay가 열려있을 때만 활성화 (voiceInputMode와 무관하게 초기화)
|
||||
handleWebSpeechSTT,
|
||||
webSpeechConfig
|
||||
);
|
||||
|
||||
// ⛔ VUI 테스트 비활성화: VoicePanel 독립 테스트 시 충돌 방지
|
||||
@@ -515,6 +539,42 @@ const VoiceInputOverlay = ({
|
||||
};
|
||||
}, [isVisible, currentMode, startListening, stopListening]);
|
||||
|
||||
// ⏱️ Countdown timer: LISTENING 모드에서 15초부터 1초까지 카운트다운
|
||||
useEffect(() => {
|
||||
if (currentMode === VOICE_MODES.LISTENING && voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH) {
|
||||
// 카운트다운 초기화
|
||||
setCountdown(15);
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
console.log('⏱️ [VoiceInputOverlay.v2] Starting countdown from 15');
|
||||
}
|
||||
|
||||
// 1초마다 카운트다운
|
||||
countdownIntervalRef.current = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
const next = prev - 1;
|
||||
if (DEBUG_MODE) {
|
||||
console.log('⏱️ [VoiceInputOverlay.v2] Countdown:', next);
|
||||
}
|
||||
if (next <= 0) {
|
||||
clearIntervalRef(countdownIntervalRef);
|
||||
return 0;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
clearIntervalRef(countdownIntervalRef);
|
||||
};
|
||||
} else {
|
||||
// LISTENING 모드가 아니면 카운트다운 정리
|
||||
clearIntervalRef(countdownIntervalRef);
|
||||
setCountdown(15);
|
||||
}
|
||||
}, [currentMode, voiceInputMode]);
|
||||
|
||||
// Overlay가 열릴 때 포커스를 overlay 내부로 이동
|
||||
useEffect(() => {
|
||||
if (DEBUG_MODE) {
|
||||
@@ -726,7 +786,7 @@ const VoiceInputOverlay = ({
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🎤 [DEBUG][VoiceInputOverlay] MODE = LISTENING | Rendering VoiceListening (15초 타이머)');
|
||||
}
|
||||
return <VoiceListening interimText={interimText} />;
|
||||
return <VoiceListening interimText={interimText} countdown={countdown} />;
|
||||
case VOICE_MODES.RESPONSE:
|
||||
if (DEBUG_MODE) {
|
||||
console.log('💬 [DEBUG][VoiceInputOverlay] MODE = RESPONSE | Rendering VoiceResponse with text:', sttResponseText);
|
||||
@@ -767,6 +827,7 @@ const VoiceInputOverlay = ({
|
||||
handleSuggestionClick,
|
||||
interimText,
|
||||
sttResponseText,
|
||||
countdown,
|
||||
]);
|
||||
|
||||
// 마이크 버튼 포커스 핸들러 (VUI)
|
||||
@@ -871,6 +932,11 @@ const VoiceInputOverlay = ({
|
||||
const microphoneButton = useMemo(() => {
|
||||
if (voiceVersion !== VOICE_VERSION.WEB_SPEECH) return null;
|
||||
|
||||
// 프로그레스 원 계산: countdown에 따라 원이 채워짐
|
||||
const circumference = 295.3; // 2 * PI * 47
|
||||
const progress = countdown / 15; // 15 -> 0으로 감소
|
||||
const strokeDashoffset = circumference * progress; // 295.3 -> 0으로 감소
|
||||
|
||||
return (
|
||||
<SpottableMicButton
|
||||
className={classNames(
|
||||
@@ -893,13 +959,16 @@ const VoiceInputOverlay = ({
|
||||
{currentMode === VOICE_MODES.LISTENING && voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH && (
|
||||
<svg className={css.rippleSvg} width="100" height="100">
|
||||
<circle
|
||||
className={css.rippleCircle}
|
||||
className={css.rippleCircleProgress}
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="47"
|
||||
fill="none"
|
||||
stroke="#C70850"
|
||||
strokeWidth="6"
|
||||
style={{
|
||||
strokeDashoffset: strokeDashoffset,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
@@ -910,6 +979,7 @@ const VoiceInputOverlay = ({
|
||||
currentMode,
|
||||
voiceInputMode,
|
||||
micFocused,
|
||||
countdown,
|
||||
handleWebSpeechMicClick,
|
||||
handleMicKeyDown,
|
||||
handleMicFocus,
|
||||
|
||||
@@ -287,6 +287,15 @@
|
||||
animation: drawCircle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// 15초 프로그레스 원 (countdown 기반)
|
||||
.rippleCircleProgress {
|
||||
stroke-dasharray: 295.3; // 2 * PI * 47 (원의 둘레)
|
||||
stroke-dashoffset: 295.3; // 초기값: 완전히 숨김
|
||||
transform-origin: center;
|
||||
transform: rotate(-90deg); // 12시 방향에서 시작
|
||||
transition: stroke-dashoffset 1s linear; // 부드러운 전환
|
||||
}
|
||||
|
||||
@keyframes drawCircle {
|
||||
0% {
|
||||
stroke-dashoffset: 295.3; // 점에서 시작
|
||||
|
||||
@@ -8,8 +8,9 @@ import css from './VoiceListening.module.less';
|
||||
* 화면 중앙에 표시되는 음성 입력 시각화 막대
|
||||
* 포커스를 받지 않으며 순수하게 시각적 피드백만 제공
|
||||
* @param {string} interimText - 실시간 중간 음성 인식 텍스트
|
||||
* @param {number} countdown - 카운트다운 숫자 (15 -> 1)
|
||||
*/
|
||||
const VoiceListening = ({ interimText }) => {
|
||||
const VoiceListening = ({ interimText, countdown }) => {
|
||||
// 각 문장의 첫 글자를 대문자로 변환
|
||||
const capitalizeSentences = (text) => {
|
||||
if (!text || text.length === 0) return text;
|
||||
@@ -42,6 +43,7 @@ const VoiceListening = ({ interimText }) => {
|
||||
<span className={`${css.dot} ${css.dot2}`}>.</span>
|
||||
<span className={`${css.dot} ${css.dot3}`}>.</span>
|
||||
</span>
|
||||
{countdown > 0 && <span className={css.countdown}>{countdown}</span>}
|
||||
</div>
|
||||
{isReceivingInput && (
|
||||
<div className={css.visualizer}>
|
||||
@@ -68,10 +70,12 @@ const VoiceListening = ({ interimText }) => {
|
||||
|
||||
VoiceListening.propTypes = {
|
||||
interimText: PropTypes.string,
|
||||
countdown: PropTypes.number,
|
||||
};
|
||||
|
||||
VoiceListening.defaultProps = {
|
||||
interimText: '',
|
||||
countdown: 15,
|
||||
};
|
||||
|
||||
export default VoiceListening;
|
||||
|
||||
@@ -43,6 +43,16 @@
|
||||
.dot3 {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
display: inline-block;
|
||||
margin-left: 20px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #ff4d4d;
|
||||
min-width: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.visualizer {
|
||||
|
||||
Reference in New Issue
Block a user