[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:
2025-10-20 19:38:27 +09:00
parent c25b4a806c
commit 3f79556fdc
5 changed files with 118 additions and 22 deletions

View File

@@ -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}

View File

@@ -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,

View File

@@ -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; // 점에서 시작

View File

@@ -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;

View File

@@ -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 {