[251022] fix: VoiceInputOverlay 조기종료 해결

🕐 커밋 시간: 2025. 10. 22. 14:55:17

📊 변경 통계:
  • 총 파일: 2개
  • 추가: +21줄
  • 삭제: -12줄

📝 수정된 파일:
  ~ 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.jsx (javascript):
    🔄 Modified: clearAllTimers()
This commit is contained in:
2025-10-22 14:55:18 +09:00
parent a3a8503842
commit b74f7abf83
2 changed files with 88 additions and 168 deletions

View File

@@ -1,43 +1,27 @@
// src/views/SearchPanel/SearchPanel.new.jsx
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import {
useDispatch,
useSelector,
} from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import micIcon from '../../../assets/images/searchpanel/image-mic.png';
import hotPicksImage from '../../../assets/images/searchpanel/img-hotpicks.png';
import hotPicksBrandImage
from '../../../assets/images/searchpanel/img-search-hotpicks.png';
import {
sendLogGNB,
sendLogTotalRecommend,
} from '../../actions/logActions';
import hotPicksBrandImage from '../../../assets/images/searchpanel/img-search-hotpicks.png';
import { sendLogGNB, sendLogTotalRecommend } from '../../actions/logActions';
import { getMyRecommandedKeyword } from '../../actions/myPageActions';
import {
popPanel,
pushPanel,
updatePanel,
} from '../../actions/panelActions';
import { popPanel, pushPanel, updatePanel } from '../../actions/panelActions';
import {
getSearch,
getSearchMain,
resetSearch,
resetVoiceSearch,
clearShopperHouseData,
} from '../../actions/searchActions';
import { clearSTTText } from '../../actions/webSpeechActions';
// import {
// showErrorToast,
// showInfoToast,
@@ -48,63 +32,42 @@ import {
// } from '../../actions/toastActions';
import TBody from '../../components/TBody/TBody';
import TPanel from '../../components/TPanel/TPanel';
import TVerticalPagenator
from '../../components/TVerticalPagenator/TVerticalPagenator';
import TVirtualGridList
from '../../components/TVirtualGridList/TVirtualGridList';
import TVerticalPagenator from '../../components/TVerticalPagenator/TVerticalPagenator';
import TVirtualGridList from '../../components/TVirtualGridList/TVirtualGridList';
// import VirtualKeyboardContainer from "../../components/TToast/VirtualKeyboardContainer";
import usePrevious from '../../hooks/usePrevious';
import {
LOG_CONTEXT_NAME,
LOG_MENU,
LOG_MESSAGE_ID,
panel_names,
} from '../../utils/Config';
import { LOG_CONTEXT_NAME, LOG_MENU, LOG_MESSAGE_ID, panel_names } from '../../utils/Config';
import SearchInputOverlay from './SearchInpuOverlay';
import css from './SearchPanel.new.module.less';
import SearchResultsNew from './SearchResults.new';
import TInput, {
ICONS,
KINDS,
} from './TInput/TInput';
import VoiceInputOverlay, {
VOICE_MODES,
} from './VoiceInputOverlay/VoiceInputOverlay';
import TInput, { ICONS, KINDS } from './TInput/TInput';
import VoiceInputOverlay, { VOICE_MODES } from './VoiceInputOverlay/VoiceInputOverlay';
const ContainerBasic = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
const ContainerBasic = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
// 검색 입력 영역 컨테이너
const InputContainer = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
const InputContainer = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
// 콘텐츠 섹션 컨테이너
const SectionContainer = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
const SectionContainer = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
// Spottable 컴포넌트들
const SpottableMicButton = Spottable("div");
const SpottableKeyword = Spottable("div");
const SpottableProduct = Spottable("div");
const SpottableMicButton = Spottable('div');
const SpottableKeyword = Spottable('div');
const SpottableProduct = Spottable('div');
const ITEMS_PER_PAGE = 9;
// Spotlight ID 상수
const SPOTLIGHT_IDS = {
SEARCH_INPUT_LAYER: "search-input-layer",
SEARCH_INPUT_BOX: "search-input-box",
MICROPHONE_BUTTON: "microphone-button",
RECENT_SEARCHES_SECTION: "recent-searches-section",
TOP_SEARCHES_SECTION: "top-searches-section",
POPULAR_BRANDS_SECTION: "popular-brands-section",
HOT_PICKS_SECTION: "hot-picks-section",
SEARCH_VERTICAL_PAGENATOR: "search_verticalPagenator",
SEARCH_INPUT_LAYER: 'search-input-layer',
SEARCH_INPUT_BOX: 'search-input-box',
MICROPHONE_BUTTON: 'microphone-button',
RECENT_SEARCHES_SECTION: 'recent-searches-section',
TOP_SEARCHES_SECTION: 'top-searches-section',
POPULAR_BRANDS_SECTION: 'popular-brands-section',
HOT_PICKS_SECTION: 'hot-picks-section',
SEARCH_VERTICAL_PAGENATOR: 'search_verticalPagenator',
};
export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
@@ -116,27 +79,17 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
const { searchDatas: searchDatas } = useSelector((state) => state.search);
const searchPerformed = useSelector((state) => state.search.searchPerformed);
const panels = useSelector((state) => state.panels.panels);
const shopperHouseData = useSelector(
(state) => state.search.shopperHouseData
);
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
//검색 메인
const hotPicksForYou = useSelector(
(state) => state.search.searchMainData.hotPicksForYou
);
const popularBrands = useSelector(
(state) => state.search.searchMainData.popularBrands
);
const topSearchs = useSelector(
(state) => state.search.searchMainData.topSearchs
);
const hotPicksForYou = useSelector((state) => state.search.searchMainData.hotPicksForYou);
const popularBrands = useSelector((state) => state.search.searchMainData.popularBrands);
const topSearchs = useSelector((state) => state.search.searchMainData.topSearchs);
const [firstSpot, setFirstSpot] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [paginatedKeywords, setPaginatedKeywords] = useState([]);
const [pageChanged, setPageChanged] = useState(false);
const [searchQuery, setSearchQuery] = useState(
panelInfo.searchVal ? panelInfo.searchVal : null
);
const [searchQuery, setSearchQuery] = useState(panelInfo.searchVal ? panelInfo.searchVal : null);
const [position, setPosition] = useState(null);
const [showVirtualKeyboard, setShowVirtualKeyboard] = useState(false);
const [isVoiceOverlayVisible, setIsVoiceOverlayVisible] = useState(false);
@@ -144,10 +97,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// isVoiceOverlayVisible 상태 변화 추적
useEffect(() => {
console.log(
"🔄 [DEBUG][SearchPanel] isVoiceOverlayVisible changed to:",
isVoiceOverlayVisible
);
console.log('🔄 [DEBUG][SearchPanel] isVoiceOverlayVisible changed to:', isVoiceOverlayVisible);
}, [isVoiceOverlayVisible]);
//인풋창 포커스 구분을 위함
@@ -163,8 +113,8 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
const [isInputModeActive, setIsInputModeActive] = useState(false);
const handleInputModeChange = useCallback((isActive) => {
console.log(
"[SearchPanel] TInput 입력 모드:",
isActive ? "활성화 (키보드 표시)" : "비활성화 (키보드 숨김)"
'[SearchPanel] TInput 입력 모드:',
isActive ? '활성화 (키보드 표시)' : '비활성화 (키보드 숨김)'
);
setIsInputModeActive(isActive);
}, []);
@@ -172,11 +122,9 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
let searchQueryRef = usePrevious(searchQuery);
let isOnTopRef = usePrevious(isOnTop);
const firstButtonSpotlightId = "first-keyword-button";
const firstButtonSpotlightId = 'first-keyword-button';
const cbChangePageRef = useRef(null);
const [focusedContainerId, setFocusedContainerId] = useState(
panelInfo?.focusedContainerId
);
const [focusedContainerId, setFocusedContainerId] = useState(panelInfo?.focusedContainerId);
const focusedContainerIdRef = usePrevious(focusedContainerId);
// Timer refs for cleanup
@@ -190,10 +138,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// }, [isVoiceOverlayVisible]);
// 가짜 데이터 - 실제로는 Redux store나 API에서 가져와야 함
const recentSearches = useMemo(
() => ["Puppy food", "Dog toy", "Fitness"],
[]
);
const recentSearches = useMemo(() => ['Puppy food', 'Dog toy', 'Fitness'], []);
// Voice overlay suggestions (동적으로 변경 가능)
const voiceSuggestions = useMemo(
@@ -276,7 +221,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
dispatch(
sendLogTotalRecommend({
query: searchQuery,
searchType: searchPerformed ? "query" : "keyword",
searchType: searchPerformed ? 'query' : 'keyword',
result: result,
contextName: LOG_CONTEXT_NAME.SEARCH,
messageId: LOG_MESSAGE_ID.SEARCH_ITEM,
@@ -300,9 +245,9 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
if (query.trim()) {
dispatch(
getSearch({
service: "com.lgshop.app",
service: 'com.lgshop.app',
query: query,
domain: "theme,show,item",
domain: 'theme,show,item',
})
);
@@ -393,7 +338,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
}
// Enter 키 처리
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
if (showVirtualKeyboard) {
// 가상 키보드가 열려있으면 검색 실행하고 키보드 닫기
@@ -407,32 +352,26 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// 방향키 처리 - Spotlight 네비게이션 허용
const arrowKeys = [
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Left",
"Right",
"Up",
"Down",
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Left',
'Right',
'Up',
'Down',
];
if (arrowKeys.includes(e.key)) {
// 입력 필드가 비어있고 왼쪽 화살표인 경우에만 방지
if (
position === 0 &&
(e.key === "Left" || e.key === "ArrowLeft") &&
!searchQuery
) {
if (position === 0 && (e.key === 'Left' || e.key === 'ArrowLeft') && !searchQuery) {
e.preventDefault();
return;
}
// 오른쪽 화살표 키 처리 - 포커스 이동 허용
if (e.key === "ArrowRight" || e.key === "Right") {
if (e.key === 'ArrowRight' || e.key === 'Right') {
// 커서가 텍스트 끝에 있을 때만 포커스 이동 허용
const input = document.querySelector(
`[data-spotlight-id="input-field-box"] > input`
);
const input = document.querySelector(`[data-spotlight-id="input-field-box"] > input`);
if (input && position === input.value.length) {
// 커서가 텍스트 끝에 있으면 포커스 이동 허용
return;
@@ -447,9 +386,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
);
const cursorPosition = useCallback(() => {
const input = document.querySelector(
`[data-spotlight-id="input-field-box"] > input`
);
const input = document.querySelector(`[data-spotlight-id="input-field-box"] > input`);
if (input) {
setPosition(input.selectionStart);
}
@@ -460,7 +397,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
return;
}
console.log(
"🖱️ [DEBUG][SearchPanel] onClickMic called, current isVoiceOverlayVisible:",
'🖱️ [DEBUG][SearchPanel] onClickMic called, current isVoiceOverlayVisible:',
isVoiceOverlayVisible
);
setIsVoiceOverlayVisible(true);
@@ -476,10 +413,10 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
setIsVoiceOverlayVisible(false);
return;
}
if (searchQuery === null || searchQuery === "") {
if (searchQuery === null || searchQuery === '') {
dispatch(popPanel(panel_names.SEARCH_PANEL));
} else {
setSearchQuery("");
setSearchQuery('');
setCurrentPage(1);
dispatch(resetSearch());
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
@@ -499,7 +436,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
Spotlight.resume();
setFirstSpot(true);
if (panelInfo.currentSpot) {
if (panels[panels.length - 1]?.name === "searchpanel") {
if (panels[panels.length - 1]?.name === 'searchpanel') {
Spotlight.focus(panelInfo.currentSpot);
}
}
@@ -550,7 +487,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// Microphone button keydown handler
const handleMicKeyDown = useCallback(
(e) => {
if (e.key === "Enter") {
if (e.key === 'Enter') {
onClickMic();
}
},
@@ -560,15 +497,19 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// Voice overlay close handler
const handleVoiceOverlayClose = useCallback(() => {
console.log(
"🚪 [DEBUG][SearchPanel] handleVoiceOverlayClose called, setting isVoiceOverlayVisible to FALSE"
'🚪 [DEBUG][SearchPanel] handleVoiceOverlayClose called, setting isVoiceOverlayVisible to FALSE'
);
setIsVoiceOverlayVisible(false);
// ✅ Redux 정리 (VoiceInputOverlay도 정리하지만, 이중 정리로 안전성 보장)
dispatch(clearShopperHouseData());
dispatch(clearSTTText());
setIsVoiceOverlayVisible(false);
// ✅ VoiceOverlay가 닫힐 때 항상 TInput으로 포커스 이동
setTimeout(() => {
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
}, 150); // Overlay 닫히는 시간을 고려한 지연
}, []);
}, [dispatch]);
// Search overlay close handler
const handleSearchOverlayClose = useCallback(() => {
@@ -610,27 +551,17 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
{...rest}
>
<div className={css.productImageWrapper}>
<img
src={bgImgPath}
alt={curationNm}
className={css.productImage}
/>
<img src={bgImgPath} alt={curationNm} className={css.productImage} />
</div>
<div className={css.productInfo}>
{showBrandLogo && (
<div className={css.productBrandWrapper}>
<img
src={patncLogoPath}
alt={patncNm}
className={css.brandLogo}
/>
<img src={patncLogoPath} alt={patncNm} className={css.brandLogo} />
</div>
)}
<div className={css.productDetails}>
{showBrandName && <div className={css.brandName}>{patncNm}</div>}
{showProductTitle && (
<div className={css.productTitle}>{curationNm}</div>
)}
{showProductTitle && <div className={css.productTitle}>{curationNm}</div>}
</div>
</div>
</SpottableProduct>
@@ -651,11 +582,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
}, [searchPerformed, searchQuery, inputFocus]);
return (
<TPanel
className={css.container}
handleCancel={onCancel}
spotlightId={spotlightId}
>
<TPanel className={css.container} handleCancel={onCancel} spotlightId={spotlightId}>
<TBody className={css.tBody} scrollable spotlightDisabled={!isOnTop}>
<ContainerBasic>
{isOnTop && (
@@ -675,10 +602,8 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
className={classNames(
css.inputContainer,
inputFocus === true && css.inputFocus,
searchDatas &&
css.searchValue /* 이건 결과값 있을때만. 조건 추가필요 */,
(isVoiceOverlayVisible || isSearchOverlayVisible) &&
css.hidden
searchDatas && css.searchValue /* 이건 결과값 있을때만. 조건 추가필요 */,
(isVoiceOverlayVisible || isSearchOverlayVisible) && css.hidden
)}
data-wheel-point="true"
spotlightId={SPOTLIGHT_IDS.SEARCH_INPUT_LAYER}
@@ -719,11 +644,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
spotlightId={SPOTLIGHT_IDS.MICROPHONE_BUTTON}
>
<div className={css.microphoneCircle}>
<img
src={micIcon}
alt="Microphone"
className={css.microphoneIcon}
/>
<img src={micIcon} alt="Microphone" className={css.microphoneIcon} />
</div>
</SpottableMicButton>
@@ -789,9 +710,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator} />
<div className={css.sectionTitle}>
Your Recent Searches
</div>
<div className={css.sectionTitle}>Your Recent Searches</div>
</div>
<div className={css.keywordList}>
{recentSearches.map((keyword, index) => (
@@ -872,9 +791,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator} />
<div className={css.sectionTitle}>
Hot Picks for You
</div>
<div className={css.sectionTitle}>Hot Picks for You</div>
</div>
<div className={css.productList}>
{hotPicksForYou && hotPicksForYou.length > 0 && (

View File

@@ -526,16 +526,9 @@ const VoiceInputOverlay = ({
}
shopperHouseDataRef.current = shopperHouseData;
// 약간의 지연 후 닫기 (사용자가 결과를 인지할 수 있도록)
closeTimerRef.current = setTimeout(() => {
onClose();
}, 200);
// 직접 닫기 (VoiceResponse 컴포넌트에서 결과를 표시한 후 닫음)
onClose();
}
return () => {
// Cleanup: 컴포넌트 언마운트 또는 의존성 변경 시 타이머 정리
clearTimerRef(closeTimerRef);
};
}, [shopperHouseData, isVisible, onClose]);
// ⛔ VUI 테스트 비활성화: STT 텍스트 수신 처리
@@ -1008,15 +1001,25 @@ const VoiceInputOverlay = ({
if (DEBUG_MODE) {
console.log('🚪 [DEBUG] handleClose called - closing overlay');
}
// 1. 타이머 정리
clearTimerRef(listeningTimerRef);
clearTimerRef(silenceDetectionTimerRef);
// 2. 내부 상태 초기화
setVoiceInputMode(null);
setCurrentMode(VOICE_MODES.PROMPT);
setSttResponseText('');
setErrorMessage('');
setIsBubbleClickSearch(false); // bubble 클릭 상태 초기화
// 3. Redux 정리 (VoiceInputOverlay의 책임)
dispatch(clearShopperHouseData());
dispatch(clearSTTText());
// 4. Parent에 닫기 통지
onClose();
}, [onClose]);
}, [onClose, dispatch]);
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트 설정 + API 자동 호출 + response 모드 전환
const handleSuggestionClick = useCallback(