[251021] fix: VoiceInputOverlay API wating message

🕐 커밋 시간: 2025. 10. 21. 10:48:07

📊 변경 통계:
  • 총 파일: 7개
  • 추가: +319줄
  • 삭제: -53줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/TInput/TInput.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/TInput/TInput.module.less
  ~ 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/VoiceResponse.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceResponse.module.less

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/SearchPanel/TInput/TInput.jsx (javascript):
    🔄 Modified: Spottable()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (javascript):
    🔄 Modified: clearAllTimers()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceResponse.jsx (javascript):
     Added: typeNextChar()
    🔄 Modified: SpotlightContainerDecorator()
This commit is contained in:
2025-10-21 10:48:10 +09:00
parent d0dac0b0b6
commit 815ba31a42
7 changed files with 317 additions and 53 deletions

View File

@@ -330,7 +330,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// ShopperHouse 검색 결과가 들어왔을 때 TInput으로 포커스 이동
const focusTimer = setTimeout(() => {
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
}, 600); // VoiceInputOverlay 닫히는 시간(500ms) + 여유(100ms)
}, 300); // VoiceInputOverlay 닫히는 시간(200ms) + 여유(100ms)
return () => {
clearTimeout(focusTimer);
@@ -661,6 +661,9 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
onFocus={_onFocus}
onBlur={_onBlur}
onInputModeChange={handleInputModeChange}
displayMode={false}
allowModeToggle={true}
placeholder="Search"
/>
</div>
<SpottableMicButton

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useState } from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { InputField } from '@enact/sandstone/Input';
import Spotlight from '@enact/spotlight';
@@ -29,6 +30,10 @@ export default function TInput({
onFocus,
onBlur,
onInputModeChange, // 입력 모드 변경 콜백
displayMode: initialDisplayMode = false, // Display Mode 활성화 여부
allowModeToggle = true, // Enter/Click으로 모드 토글 허용 여부
onModeChange, // 모드 변경 콜백 (display ↔ input)
placeholder,
...rest
}) {
const { handleScrollReset, handleStopScrolling } = useScrollReset(scrollTop);
@@ -36,13 +41,49 @@ export default function TInput({
// 입력 가능 모드 상태 (webOS 키보드가 뜨는 상태)
const [isInputActive, setIsInputActive] = useState(false);
const handleContainerClick = useCallback((e) => {
if (e.key === 'ArrrowLeft' || e.key === 'Left') {
Spotlight.focus('spotlight_search');
} else {
Spotlight.focus('input-field-box');
// 내부 모드 상태 (Display Mode = true, Input Mode = false)
const [isDisplayMode, setIsDisplayMode] = useState(initialDisplayMode);
// Display → Input 모드 전환
const switchToInputMode = useCallback(() => {
if (!allowModeToggle) return;
console.log('[TInput] Display → Input 모드 전환');
setIsDisplayMode(false);
// 부모 컴포넌트에 모드 변경 알림
if (onModeChange) {
onModeChange('input');
}
}, []);
// Input에 포커스를 주어 키보드 활성화
setTimeout(() => {
const inputElement = document.querySelector('[data-spotlight-id="input-field-box"] input');
if (inputElement) {
inputElement.focus();
}
}, 100);
}, [allowModeToggle, onModeChange]);
const handleContainerClick = useCallback(
(e) => {
// Display Mode에서 클릭 시 Input Mode로 전환
if (isDisplayMode) {
e.preventDefault();
e.stopPropagation();
switchToInputMode();
return;
}
// Input Mode에서는 기존 동작
if (e.key === 'ArrrowLeft' || e.key === 'Left') {
Spotlight.focus('spotlight_search');
} else {
Spotlight.focus('input-field-box');
}
},
[isDisplayMode, switchToInputMode]
);
const handleButtonKeyDown = useCallback((e) => {
if (e.key === 'ArrowDown' || e.key === 'Down') {
@@ -56,18 +97,35 @@ export default function TInput({
}
}, []);
const handleBoxKeyDown = useCallback((e) => {
if (e.key === 'ArrowLeft' || e.key === 'Left') {
const currentElement = e.currentTarget;
const inputElement = currentElement.querySelector('input');
if (inputElement.value.length === 0) {
const handleBoxKeyDown = useCallback(
(e) => {
// Display Mode에서 Enter 키 처리
if (isDisplayMode && (e.key === 'Enter' || e.keyCode === 13)) {
e.preventDefault();
e.stopPropagation();
Spotlight.focus('spotlight_search');
switchToInputMode();
return;
}
}
}, []);
// Input Mode에서 기존 동작
if (e.key === 'ArrowLeft' || e.key === 'Left') {
const currentElement = e.currentTarget;
const inputElement = currentElement.querySelector('input');
if (inputElement && inputElement.value.length === 0) {
e.preventDefault();
e.stopPropagation();
Spotlight.focus('spotlight_search');
}
}
// 부모 컴포넌트의 onKeyDown 호출
if (onKeyDown) {
onKeyDown(e);
}
},
[isDisplayMode, switchToInputMode, onKeyDown]
);
//focus,blur 추가
const _onFocus = useCallback(() => {
@@ -103,33 +161,49 @@ export default function TInput({
}
}, [onInputModeChange]);
console.log('[TInput Render] isInputActive:', isInputActive);
console.log('[TInput Render] isDisplayMode:', isDisplayMode, 'isInputActive:', isInputActive);
return (
<Container
className={classNames(
css.container,
className ? className : null,
isDisplayMode && css.displayMode,
isInputActive && css.containerActive
)}
spotlightId={spotlightId}
onClick={handleContainerClick}
onKeyDown={handleBoxKeyDown}
>
<InputField
{...rest}
spotlightDisabled={spotlightDisabled}
className={classNames(css.input, isInputActive && css.inputActive)}
autoFocus
onFocus={_onFocus}
onBlur={_onBlur}
onActivate={_onActivate}
onDeactivate={_onDeactivate}
onKeyDown={onKeyDown}
spotlightId={'input-field-box'}
aria-label="Keyword edit box"
dismissOnEnter
/>
{isDisplayMode ? (
// Display Mode: 읽기 전용 div
<div
className={classNames(css.input, css.displayModeText)}
data-spotlight-id="input-display-box"
data-placeholder={placeholder || ''}
aria-label="Search text display (press Enter to edit)"
aria-readonly="true"
>
{rest.value || ''}
</div>
) : (
// Input Mode: 실제 InputField
<InputField
{...rest}
spotlightDisabled={spotlightDisabled}
className={classNames(css.input, isInputActive && css.inputActive)}
autoFocus
onFocus={_onFocus}
onBlur={_onBlur}
onActivate={_onActivate}
onDeactivate={_onDeactivate}
onKeyDown={handleBoxKeyDown}
spotlightId={'input-field-box'}
aria-label="Keyword edit box"
dismissOnEnter
placeholder={placeholder}
/>
)}
{kind === 'withIcon' && (
<TIconButton
@@ -144,4 +218,24 @@ export default function TInput({
);
}
TInput.propTypes = {
kind: PropTypes.string,
icon: PropTypes.string,
className: PropTypes.string,
spotlightDisabled: PropTypes.bool,
spotlightId: PropTypes.string,
onKeyDown: PropTypes.func,
scrollTop: PropTypes.number,
onIconClick: PropTypes.func,
forcedSpotlight: PropTypes.string,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
onInputModeChange: PropTypes.func,
displayMode: PropTypes.bool,
allowModeToggle: PropTypes.bool,
onModeChange: PropTypes.func,
placeholder: PropTypes.string,
value: PropTypes.string,
};
export { BORDER, COLOR, ICONS, KINDS };

View File

@@ -71,4 +71,45 @@
background-position: center bottom;
}
}
// Display Mode 스타일
&.displayMode {
cursor: pointer;
&:hover {
opacity: 0.95;
}
&:focus {
outline: 2px solid #C70850;
outline-offset: 2px;
border-radius: 50px;
}
}
// Display Mode 텍스트 영역
.displayModeText {
display: flex;
align-items: center;
padding: 15px 40px 15px 50px !important; // 위아래 패딩 축소 (20px → 15px)
min-height: 90px; // 높이 축소 (100px → 90px)
color: @COLOR_GRAY07 !important;
font-family: @baseFont;
font-size: 24px;
font-weight: 700; // Bold 폰트
line-height: 1.5;
background-color: #fff !important;
border-radius: 50px;
border: none !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
// placeholder 효과 (텍스트가 없을 때)
&:empty::before {
content: attr(data-placeholder);
color: rgba(136, 136, 136, 0.6); // @COLOR_GRAY07의 투명도 버전
font-weight: 400; // placeholder는 normal weight
}
}
}

View File

@@ -378,7 +378,7 @@ const VoiceInputOverlay = ({
// 약간의 지연 후 닫기 (사용자가 결과를 인지할 수 있도록)
closeTimerRef.current = setTimeout(() => {
onClose();
}, 500);
}, 200);
}
return () => {
@@ -764,14 +764,24 @@ const VoiceInputOverlay = ({
}
}, []);
// Input 창 포커스 핸들러 - VoiceInputOverlay 닫고 SearchPanel 화면 보이
const handleInputFocus = useCallback(() => {
if (DEBUG_MODE) {
console.log('⌨️ [DEBUG] handleInputFocus called - closing overlay to show SearchPanel');
}
// VoiceInputOverlay 닫기 - SearchPanel이 보이게 됨
handleClose();
}, [handleClose]);
// Input 모드 변경 핸들러 - Input 모드로 전환되면 VoiceInputOverlay 닫기
const handleInputModeChange = useCallback(
(mode) => {
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay] TInput 모드 변경:', mode);
}
if (mode === 'input') {
// Input 모드로 전환되면 Overlay 닫기
// SearchPanel의 TInput으로 자연스럽게 전환됨
if (DEBUG_MODE) {
console.log('⌨️ [DEBUG] Input 모드로 전환됨 - VoiceInputOverlay 닫고 SearchPanel로 이동');
}
handleClose();
}
},
[handleClose]
);
// ⛔ TALK AGAIN 버튼 제거됨 - 더 이상 사용하지 않음
// const handleTalkAgain = useCallback(() => { ... }, []);
@@ -816,10 +826,12 @@ const VoiceInputOverlay = ({
if (DEBUG_MODE) {
console.log(
'💬 [DEBUG][VoiceInputOverlay] MODE = RESPONSE | Rendering VoiceResponse with text:',
sttResponseText
sttResponseText,
'isLoading:',
!shopperHouseData
);
}
return <VoiceResponse responseText={sttResponseText} />;
return <VoiceResponse responseText={sttResponseText} isLoading={!shopperHouseData} />;
case VOICE_MODES.NOINIT:
if (DEBUG_MODE) {
console.log('⚠️ [DEBUG][VoiceInputOverlay] MODE = NOINIT | Rendering VoiceNotRecognized');
@@ -1151,7 +1163,10 @@ const VoiceInputOverlay = ({
onKeyDown={handleInputKeyDown}
onIconClick={handleSearchSubmit}
spotlightId={INPUT_SPOTLIGHT_ID}
onFocus={handleInputFocus}
displayMode={true}
allowModeToggle={true}
onModeChange={handleInputModeChange}
placeholder="Search"
/>
{/* voiceVersion에 따라 하나의 마이크만 표시 */}

View File

@@ -41,8 +41,8 @@
// 입력창과 마이크 버튼 영역 - SearchPanel.inputContainer와 동일 (210px 높이)
.inputWrapper {
width: 100%;
padding-top: 55px;
padding-bottom: 55px;
padding-top: 60px; // 위쪽 패딩 증가 (55px → 60px) - 포커스 테두리 여유 공간
padding-bottom: 60px; // 아래쪽 패딩 증가 (55px → 60px) - 포커스 테두리 여유 공간
padding-left: 60px;
padding-right: 60px;
display: flex;
@@ -52,6 +52,7 @@
z-index: 1003;
position: relative;
pointer-events: all; // 입력 영역은 클릭 가능
overflow: visible; // 포커스 테두리가 잘리지 않도록
.searchInputWrapper {
height: 100px;
@@ -60,6 +61,8 @@
align-items: center;
min-height: 100px;
max-height: 100px;
overflow: visible; // 포커스 테두리가 잘리지 않도록
padding: 5px 0; // 위아래 여유 공간
> * {
margin-right: 15px;

View File

@@ -1,5 +1,5 @@
// src/views/SearchPanel/VoiceInputOverlay/modes/VoiceResponse.jsx
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import Spottable from '@enact/spotlight/Spottable';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
@@ -15,7 +15,14 @@ const ResponseContainer = SpotlightContainerDecorator(
'div'
);
const VoiceResponse = ({ responseText = '', onTalkAgain }) => {
const VoiceResponse = ({ responseText = '', isLoading = true }) => {
// 타이핑 애니메이션 상태
const [typedText, setTypedText] = useState('');
const typingTimerRef = useRef(null);
const LOADING_MESSAGE = 'Just a moment, finding the best matches for you...';
const TYPING_SPEED = 50; // ms per character
// 각 문장의 첫 글자를 대문자로 변환
const capitalizeSentences = (text) => {
if (!text || text.length === 0) return text;
@@ -38,12 +45,63 @@ const VoiceResponse = ({ responseText = '', onTalkAgain }) => {
const displayText = capitalizeSentences(responseText);
// 타이핑 애니메이션 효과 (반복)
useEffect(() => {
if (!isLoading) {
// 로딩이 끝나면 타이핑 애니메이션 정리
if (typingTimerRef.current) {
clearTimeout(typingTimerRef.current);
typingTimerRef.current = null;
}
setTypedText('');
return;
}
// 로딩 중일 때 타이핑 애니메이션 시작 (반복)
let currentIndex = 0;
const typeNextChar = () => {
if (currentIndex < LOADING_MESSAGE.length) {
setTypedText(LOADING_MESSAGE.substring(0, currentIndex + 1));
currentIndex++;
typingTimerRef.current = setTimeout(typeNextChar, TYPING_SPEED);
} else {
// 타이핑이 끝나면 1초 대기 후 처음부터 다시 시작
typingTimerRef.current = setTimeout(() => {
currentIndex = 0;
setTypedText('');
// 200ms 후 다시 타이핑 시작
typingTimerRef.current = setTimeout(typeNextChar, 200);
}, 1000);
}
};
// 초기화 및 시작
setTypedText('');
typingTimerRef.current = setTimeout(typeNextChar, 300); // 300ms 지연 후 시작
// Cleanup
return () => {
if (typingTimerRef.current) {
clearTimeout(typingTimerRef.current);
typingTimerRef.current = null;
}
};
}, [isLoading]);
return (
<ResponseContainer className={css.container} spotlightId="voice-response-container">
<div className={css.responseContainer}>
<SpottableBubble className={css.bubbleMessage} spotlightId="voice-response-text">
<div className={css.bubbleText}>{displayText}</div>
</SpottableBubble>
{/* 입력받은 텍스트 (위쪽, 작게 표시) */}
<div className={css.userQuery}>{displayText}</div>
{/* 로딩 메시지 (타이핑 애니메이션) */}
{isLoading && (
<div className={css.loadingMessage}>
<div className={css.typingText}>{typedText}</div>
<span className={css.cursor}>|</span>
</div>
)}
</div>
</ResponseContainer>
);
@@ -51,12 +109,12 @@ const VoiceResponse = ({ responseText = '', onTalkAgain }) => {
VoiceResponse.propTypes = {
responseText: PropTypes.string,
onTalkAgain: PropTypes.func,
isLoading: PropTypes.bool,
};
VoiceResponse.defaultProps = {
responseText: '',
onTalkAgain: null,
isLoading: true,
};
export default VoiceResponse;

View File

@@ -16,10 +16,60 @@
.responseContainer {
display: flex;
flex-direction: column;
justify-content: center;
justify-content: flex-start;
align-items: center;
width: 100%;
padding: 0 40px;
gap: 40px;
}
// 사용자 입력 텍스트 (위쪽, 작게 표시)
.userQuery {
text-align: center;
color: #ffffff;
font-size: 32px;
font-family: "LG Smart UI";
font-weight: 700;
line-height: 38px;
margin-top: 20px;
opacity: 0.9;
}
// 로딩 메시지 컨테이너
.loadingMessage {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin-top: 20px;
}
// 타이핑 애니메이션 텍스트
.typingText {
color: rgba(234, 234, 234, 0.85);
font-size: 34px;
font-family: "LG Smart UI";
font-weight: 400;
line-height: 40px;
text-align: center;
}
// 깜빡이는 커서
.cursor {
color: rgba(234, 234, 234, 0.85);
font-size: 34px;
font-family: "LG Smart UI";
font-weight: 400;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
.talkAgainButton {