@ihabdevteam/core 0.132.0 → 0.134.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +129 -0
- package/dist/components.cjs.js +177 -177
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +2 -2
- package/dist/ihabdevteam-core.cjs.js +182 -182
- package/dist/ihabdevteam-core.d.ts +8 -13
- package/dist/packages/core/src/components/BarChart.js +2 -2
- package/dist/packages/core/src/components/BottomNav.js +3 -3
- package/dist/packages/core/src/components/Breadcrumb.js +5 -1
- package/dist/packages/core/src/components/BulkBar.js +1 -1
- package/dist/packages/core/src/components/ChoiceChips.js +1 -1
- package/dist/packages/core/src/components/CodeBox.js +1 -1
- package/dist/packages/core/src/components/DatePicker.js +16 -3
- package/dist/packages/core/src/components/Dialog.js +32 -9
- package/dist/packages/core/src/components/Dropdown.js +13 -3
- package/dist/packages/core/src/components/FileUpload.js +8 -7
- package/dist/packages/core/src/components/FilterPanel.js +8 -5
- package/dist/packages/core/src/components/FilterSectionInfo.js +1 -1
- package/dist/packages/core/src/components/Header.css +2 -0
- package/dist/packages/core/src/components/Header.js +4 -1
- package/dist/packages/core/src/components/HearingDeviceInfo.js +1 -1
- package/dist/packages/core/src/components/LandingHeader.js +18 -1
- package/dist/packages/core/src/components/PageTitle.js +1 -1
- package/dist/packages/core/src/components/Pagination.js +6 -3
- package/dist/packages/core/src/components/Panel.d.ts +1 -1
- package/dist/packages/core/src/components/Panel.js +4 -1
- package/dist/packages/core/src/components/PanelRow.js +3 -3
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +6 -0
- package/dist/packages/core/src/components/PaymentPlan.d.ts +6 -0
- package/dist/packages/core/src/components/PaymentPlan.js +6 -4
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +2 -2
- package/dist/packages/core/src/components/Popup/Popup.js +15 -3
- package/dist/packages/core/src/components/Popup/SaveTablePopup.js +1 -1
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +1 -1
- package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +3 -3
- package/dist/packages/core/src/components/PromoBanner.js +1 -1
- package/dist/packages/core/src/components/SegmentedControl.js +1 -1
- package/dist/packages/core/src/components/SideNav.js +2 -2
- package/dist/packages/core/src/components/SoundCompass.js +10 -1
- package/dist/packages/core/src/components/Table.js +2 -2
- package/dist/packages/core/src/components/Tabs.js +7 -2
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +8 -3
- package/dist/packages/core/src/components/Tooltip/TextBoxTooltip.js +1 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -5
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingBlock.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +51 -23
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +26 -5
- package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +11 -6
- package/dist/packages/core/src/components/Training/useAudioPreview.js +7 -3
- package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -1
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +9 -0
- package/dist/packages/core/src/components/UpdateAlert.js +1 -1
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +2 -2
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +6 -5
- package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
- package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
- package/dist/packages/core/src/components/WordTable.js +6 -6
- package/dist/packages/core/src/components/useToastLifecycle.js +8 -1
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +10 -9
- package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +0 -11
- package/dist/packages/core/src/hooks/useDocumentTitle.js +16 -3
- package/dist/packages/core/src/hooks/useFocusTrap.js +6 -2
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +9 -4
- package/dist/packages/core/src/hooks/wordTrainingFilterModel.d.ts +1 -1
- package/dist/packages/core/src/hooks/wordTrainingFilterModel.js +7 -2
- package/dist/packages/core/src/i18n/locales/en.json +11 -0
- package/dist/packages/core/src/i18n/locales/ko.json +11 -0
- package/dist/packages/core/src/utils/audioPeaks.js +13 -5
- package/dist/packages/core/src/utils/csv.js +3 -1
- package/dist/packages/core/src/utils/format.js +17 -1
- package/dist/packages/core/src/utils/koreanPronunciation.js +7 -0
- package/dist/packages/core/src/utils/koreanSyllable.js +2 -1
- package/dist/packages/core/src/utils/pressable.js +4 -0
- package/dist/packages/core/src/utils/savedWordTables.js +2 -1
- package/dist/packages/core/src/utils/textSort.d.ts +0 -10
- package/dist/packages/core/src/utils/textSort.js +7 -2
- package/dist/packages/core/src/utils/wordTrainingPool.d.ts +7 -0
- package/dist/packages/core/src/utils/wordTrainingPool.js +30 -0
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +55 -17
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import { PAGE_POPUP_CLOSE_MS } from './pagePopupTiming';
|
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
import { createPortal } from "react-dom";
|
|
7
7
|
import { useFocusTrap, FOCUSABLE_SELECTOR } from '../../hooks/useFocusTrap';
|
|
8
|
+
import { pushEsc } from '../../hooks/escStack';
|
|
8
9
|
import Toast from '../Toast';
|
|
9
10
|
import Button from '../Button';
|
|
10
11
|
import './TrainingPagePopup.css';
|
|
@@ -122,17 +123,29 @@ function TrainingPagePopupInner(props) {
|
|
|
122
123
|
// 이 판에만 거는 설정 — 마운트 때 한 번 붙잡는다(부모가 매 렌더 새 함수를 넘겨도 다시 걸지 않게).
|
|
123
124
|
// 있으면 이 판은 저장을 건너뛴다(mergeAndSaveSettings 참고).
|
|
124
125
|
const noiseOverrideRef = useRef(noiseOverride);
|
|
126
|
+
/* 불러오기가 끝났나 · 끝나기 전에 바뀐 것. 끝나기 전의 변경은 **저장하지 않고 모아 두었다가** 불러온
|
|
127
|
+
값 위에 얹어 한 번 저장한다. 전에는 enabled:false 초기값 위에 합쳐 곧바로 저장해(소음 꺼짐이 DB 에
|
|
128
|
+
남아 다음 판부터 소음이 안 났다) 늦게 온 불러오기가 그 변경을 다시 덮었다. */
|
|
129
|
+
const noiseLoadedRef = useRef(false);
|
|
130
|
+
const pendingNoiseRef = useRef(null);
|
|
125
131
|
// 계정/고객 저장소에서 초기값 로드 (앱이 configureNoiseSettingsStore로 주입)
|
|
126
132
|
useEffect(() => {
|
|
127
133
|
let cancelled = false;
|
|
128
|
-
|
|
134
|
+
const apply = (saved) => {
|
|
129
135
|
if (cancelled)
|
|
130
136
|
return;
|
|
137
|
+
noiseLoadedRef.current = true;
|
|
131
138
|
// 저장된 설정이 있으면 병합, 없으면 진짜 기본값으로 확정 — 어느 쪽이든 이제부터 재생 허용.
|
|
132
139
|
// mergeNoiseSettings — noiseRatio 가 없는 옛 기록은 기본값 비율(1)에 가리지 않고 level 에서 옮긴다.
|
|
133
|
-
|
|
140
|
+
// 바탕은 **처음 값**이다 — 끝나기 전 변경은 pending 으로 따로 얹는다(아래).
|
|
141
|
+
const loaded = saved ? mergeNoiseSettings({ ...DEFAULT_NOISE_SETTINGS, enabled: false }, saved) : { ...DEFAULT_NOISE_SETTINGS };
|
|
142
|
+
const pending = pendingNoiseRef.current;
|
|
143
|
+
pendingNoiseRef.current = null;
|
|
144
|
+
const merged = pending ? mergeNoiseSettings(loaded, pending) : loaded;
|
|
134
145
|
noiseSettingsRef.current = merged;
|
|
135
146
|
setNoisePart(merged);
|
|
147
|
+
if (pending && !noiseOverrideRef.current)
|
|
148
|
+
saveNoiseSettings(merged);
|
|
136
149
|
reportAppliedNoiseSettings(merged);
|
|
137
150
|
// override 는 병합이 끝난 **뒤에** 덮는다 — 로드가 늦게 와도 override 가 이긴다.
|
|
138
151
|
const override = noiseOverrideRef.current;
|
|
@@ -143,7 +156,8 @@ function TrainingPagePopupInner(props) {
|
|
|
143
156
|
setNoisePart(applied);
|
|
144
157
|
reportAppliedNoiseSettings(applied);
|
|
145
158
|
}
|
|
146
|
-
}
|
|
159
|
+
};
|
|
160
|
+
void loadNoiseSettings().then(apply);
|
|
147
161
|
return () => { cancelled = true; };
|
|
148
162
|
}, []);
|
|
149
163
|
// 오디오 레벨 미터용 analyser (오디오 플레이어가 재생 시 등록)
|
|
@@ -160,6 +174,12 @@ function TrainingPagePopupInner(props) {
|
|
|
160
174
|
const merged = mergeNoiseSettings(noiseSettingsRef.current, next);
|
|
161
175
|
noiseSettingsRef.current = merged;
|
|
162
176
|
setNoisePart(merged);
|
|
177
|
+
if (!noiseLoadedRef.current) {
|
|
178
|
+
// 불러오기 전 — 화면에는 곧바로 걸되 저장은 불러온 뒤로 미룬다(위 apply).
|
|
179
|
+
pendingNoiseRef.current = { ...(pendingNoiseRef.current ?? {}), ...next };
|
|
180
|
+
reportAppliedNoiseSettings(merged);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
163
183
|
// noiseOverride 판은 저장하지 않는다 — 판 중에 바꾼 것도 이 판에만.
|
|
164
184
|
if (!noiseOverrideRef.current)
|
|
165
185
|
saveNoiseSettings(merged);
|
|
@@ -199,31 +219,39 @@ function TrainingPagePopupInner(props) {
|
|
|
199
219
|
}, []);
|
|
200
220
|
useEffect(() => () => { if (closeTimerRef.current)
|
|
201
221
|
clearTimeout(closeTimerRef.current); }, []);
|
|
202
|
-
|
|
222
|
+
/* 열 때 첫 칸으로 포커스 · 닫힐 때 연 곳으로 되돌림 — **마운트 때 한 번만.** 가둠은 useFocusTrap 이 한다.
|
|
223
|
+
전에는 이 효과가 onBack 에 걸려 있어 부모가 새 onBack 을 넘길 때마다 다시 돌아 **훈련 중 포커스를 첫 칸으로
|
|
224
|
+
끌어왔고**, 닫을 때 포커스를 되돌리는 코드는 없어 뒤 화면에서 포커스를 잃었다. */
|
|
225
|
+
const onBackRef = useRef(onBack);
|
|
226
|
+
useEffect(() => { onBackRef.current = onBack; });
|
|
203
227
|
useEffect(() => {
|
|
204
228
|
const panel = panelRef.current;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
229
|
+
const previouslyFocused = document.activeElement;
|
|
230
|
+
if (panel) {
|
|
231
|
+
const firstEl = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => !el.closest('[aria-hidden="true"]'))[0];
|
|
232
|
+
if (firstEl) {
|
|
233
|
+
try {
|
|
234
|
+
firstEl.focus();
|
|
235
|
+
}
|
|
236
|
+
catch (e) { }
|
|
213
237
|
}
|
|
214
|
-
catch (e) { }
|
|
215
238
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
239
|
+
return () => {
|
|
240
|
+
// 포커스가 이 창 안이거나 갈 곳을 잃었을 때만 — 이미 다른 창으로 옮겨 갔으면 빼앗지 않는다(Popup 과 같다)
|
|
241
|
+
const active = document.activeElement;
|
|
242
|
+
if (!active || active === document.body || (panel && panel.contains(active))) {
|
|
243
|
+
try {
|
|
244
|
+
previouslyFocused?.focus?.();
|
|
245
|
+
}
|
|
246
|
+
catch (e) { }
|
|
222
247
|
}
|
|
223
248
|
};
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
249
|
+
}, []);
|
|
250
|
+
/* ESC — **공유 ESC 스택**에 올린다(Popup·메뉴·툴팁과 같은 스택). 전에는 판(panel)의 네이티브 keydown 만 들었는데
|
|
251
|
+
판에 tabIndex 가 없어, 문제 글·그림을 눌러 포커스가 body 로 가면 Esc 가 먹지 않았고, 훈련 창이 다른 팝업 위에
|
|
252
|
+
떠 있을 때는 그 팝업의 document 리스너가 가려진 아래 팝업을 닫았다. */
|
|
253
|
+
useEffect(() => pushEsc(() => scheduleClose(() => { const back = onBackRef.current; if (typeof back === 'function')
|
|
254
|
+
back(); })), [scheduleClose]);
|
|
227
255
|
/* Tab 가둠은 훅에 맡긴다 — 여기 있던 열두 줄은 `useFocusTrap` 의 것과 같은
|
|
228
256
|
내용이었는데 **고르개 문자열만 제 안에 따로 박혀 있었다**(공백 없는
|
|
229
257
|
`FOCUSABLE_SELECTORS`). 훅 머리말이 "네 곳을 모았다"고 적어 둔 그 넷 중
|
|
@@ -790,7 +818,7 @@ function TrainingPagePopupInner(props) {
|
|
|
790
818
|
catch (e) { }
|
|
791
819
|
},
|
|
792
820
|
onClose: handleResultDone,
|
|
793
|
-
})), (resultOverlay?.isEmpty && (_jsx("div", { className: "page-popup page-popup--result-empty", role: "dialog", "aria-modal": "true", "aria-label": t('training.result.eyebrow', '훈련 완료'), children: _jsxs("div", { className: "page-popup__panel page-popup__panel--center", children: [_jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: "page-popup__content page-popup__content--center", children: _jsx("div", { className: "page-popup__empty", children: t('training.result.eyebrow', '훈련 완료') }) }) }), _jsx("div", { className: "page-popup__bottom", children: _jsx(Button, { variant: "main", size: 48, onClick: handleResultDone, label: t('training.result.done', '완료') }) })] }) }))), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
|
|
821
|
+
})), (resultOverlay?.isEmpty && (_jsx("div", { className: "page-popup page-popup--result-empty", role: "dialog", "aria-modal": "true", "aria-label": t('training.result.eyebrow', '훈련 완료'), children: _jsxs("div", { className: "page-popup__panel page-popup__panel--center", children: [_jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: "page-popup__content page-popup__content--center", children: _jsx("div", { className: "page-popup__empty", children: t('training.result.eyebrow', '훈련 완료') }) }) }), _jsx("div", { className: "page-popup__bottom", children: _jsx(Button, { htmlType: "button", variant: "main", size: 48, onClick: handleResultDone, label: t('training.result.done', '완료') }) })] }) }))), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
|
|
794
822
|
const portalContent = (_jsx(NoisePlayerContext.Provider, { value: noiseContextValue, children: jsx }));
|
|
795
823
|
if (typeof document !== 'undefined' && document.body)
|
|
796
824
|
return createPortal(portalContent, document.body);
|
|
@@ -56,5 +56,5 @@ export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, on
|
|
|
56
56
|
setOpen(true);
|
|
57
57
|
}, [open, onSettings]);
|
|
58
58
|
const handleToggleMeters = useCallback(() => setShowMeters(prev => !prev), []);
|
|
59
|
-
return (_jsxs("div", { className: "pp-bottom", children: [_jsxs("div", { className: "pp-bottom__left", children: [useNoiseSettingBtn ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "pp-bottom__settings--desktop", children: _jsx("div", { ref: desktopRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { size: 56, type: "bar", icon: 'settings', onClick: handleDesktopClick, selected: open, ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { size: 56, type: "dot", icon: 'settings', onClick: handleMobileClick, selected: open, ariaLabel: t('training.settings', '설정'), minWidth: 52 }) }) })] })) : null, useNoiseSettingBtn && (_jsx(Button, { size: 40, type: "dot", shape: "pill", icon: "audio-lines", onClick: handleToggleMeters, selected: showMeters, ariaLabel: t('training.meter.toggle', '오디오 미터 표시') })), useNoiseSettingBtn && showMeters && (_jsxs("div", { className: "pp-bottom__meters", children: [_jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.sentence', '재생') }), _jsx(AudioMeter, { analyser: noisePlayer?.mainAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.sentence', '재생') })] }), _jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.noise', '소음') }), _jsx(AudioMeter, { analyser: noisePlayer?.noiseAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.noise', '소음') })] })] }))] }), _jsxs("div", { className: "pp-bottom__right", children: [useResultBtn && (_jsx(Button, { size: 56, type: "bar", icon: "check", onClick: typeof onResult === "function" ? onResult : onNext, ariaLabel: t('training.viewResult', '결과보기'), label: t('training.viewResult', '결과보기'), variant: "main", disabled: !isBtnActive })), useNextBtn && (_jsx(Button, { size: 56, type: "bar", rightIcon: "chevron-right", align: "right", onClick: onNext, ariaLabel: t('training.nextQuestion', '다음 문제'), label: t('training.nextQuestion', '다음 문제'), variant: "main", disabled: !isBtnActive }))] }), useNoiseSettingBtn && open && (_jsx(NoiseSettingsTooltip, { anchorRect: anchorRect, onClose: handleCloseTooltip }))] }));
|
|
59
|
+
return (_jsxs("div", { className: "pp-bottom", children: [_jsxs("div", { className: "pp-bottom__left", children: [useNoiseSettingBtn ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "pp-bottom__settings--desktop", children: _jsx("div", { ref: desktopRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: 'settings', onClick: handleDesktopClick, selected: open, ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "dot", icon: 'settings', onClick: handleMobileClick, selected: open, ariaLabel: t('training.settings', '설정'), minWidth: 52 }) }) })] })) : null, useNoiseSettingBtn && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", shape: "pill", icon: "audio-lines", onClick: handleToggleMeters, selected: showMeters, ariaLabel: t('training.meter.toggle', '오디오 미터 표시') })), useNoiseSettingBtn && showMeters && (_jsxs("div", { className: "pp-bottom__meters", children: [_jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.sentence', '재생') }), _jsx(AudioMeter, { analyser: noisePlayer?.mainAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.sentence', '재생') })] }), _jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.noise', '소음') }), _jsx(AudioMeter, { analyser: noisePlayer?.noiseAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.noise', '소음') })] })] }))] }), _jsxs("div", { className: "pp-bottom__right", children: [useResultBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: "check", onClick: typeof onResult === "function" ? onResult : onNext, ariaLabel: t('training.viewResult', '결과보기'), label: t('training.viewResult', '결과보기'), variant: "main", disabled: !isBtnActive })), useNextBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", rightIcon: "chevron-right", align: "right", onClick: onNext, ariaLabel: t('training.nextQuestion', '다음 문제'), label: t('training.nextQuestion', '다음 문제'), variant: "main", disabled: !isBtnActive }))] }), useNoiseSettingBtn && open && (_jsx(NoiseSettingsTooltip, { anchorRect: anchorRect, onClose: handleCloseTooltip }))] }));
|
|
60
60
|
}
|
|
@@ -37,5 +37,5 @@ export default function TrainingPagePopupTitle({ title, subtitle, desc, depth, i
|
|
|
37
37
|
}
|
|
38
38
|
return base;
|
|
39
39
|
}, [wrapperStyle]);
|
|
40
|
-
return (_jsxs("header", { className: "pp-title training-popup-title", children: [_jsxs("div", { className: "pp-title__left", children: [resolvedBack ? (_jsx(Button, { size: 48, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: resolvedBack, useBorder: false, iconSize: 'large', iconColor: "--color-black", variant: "transparent-gray" })) : null, _jsx("div", { children: icon ? (_jsx(CategoryIcon, { icon: icon, label: title, tooltip: iconTooltip, wrapperStyle: iconWrapperStyle })) : null }), _jsxs("div", { className: "pp-title__text", children: [resolvedDepth ? _jsx("span", { className: "pp-title__depth caption-1", children: resolvedDepth }) : null, _jsx("span", { className: `pp-title__title h2${hasName ? '' : ' pp-title__title--empty'}`, children: resolvedTitle }), desc ? _jsx("span", { className: "pp-title__description caption-1", children: desc }) : null] })] }), _jsx(ProgressIndicator, { max: Math.max(0, pageCount ?? 0), index: Math.max(0, pageIndex ?? 0) })] }));
|
|
40
|
+
return (_jsxs("header", { className: "pp-title training-popup-title", children: [_jsxs("div", { className: "pp-title__left", children: [resolvedBack ? (_jsx(Button, { htmlType: "button", size: 48, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: resolvedBack, useBorder: false, iconSize: 'large', iconColor: "--color-black", variant: "transparent-gray" })) : null, _jsx("div", { children: icon ? (_jsx(CategoryIcon, { icon: icon, label: title, tooltip: iconTooltip, wrapperStyle: iconWrapperStyle })) : null }), _jsxs("div", { className: "pp-title__text", children: [resolvedDepth ? _jsx("span", { className: "pp-title__depth caption-1", children: resolvedDepth }) : null, _jsx("span", { className: `pp-title__title h2${hasName ? '' : ' pp-title__title--empty'}`, children: resolvedTitle }), desc ? _jsx("span", { className: "pp-title__description caption-1", children: desc }) : null] })] }), _jsx(ProgressIndicator, { max: Math.max(0, pageCount ?? 0), index: Math.max(0, pageIndex ?? 0) })] }));
|
|
41
41
|
}
|
|
@@ -163,8 +163,18 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
163
163
|
const multiSources = Array.isArray(sources) && sources.length > 0 ? sources : (Array.isArray(segments) && segments.length > 0 ? segments : null);
|
|
164
164
|
const sourceButtonCount = Math.max(Array.isArray(multiSources) ? multiSources.length : 0, requestedSources.length);
|
|
165
165
|
// resolveMapping 제거됨 — JSON 파일 의존성 없이 resolveSoundPath(code, bucketPath) 로 직접 경로 결정
|
|
166
|
+
/* 떠났나 — 불러오기(주소 받기·길이 재기·canplay·파형 대기)는 몇 초 걸리는데 그 사이 다음 문항으로 넘기거나
|
|
167
|
+
팝업을 닫아도 흐름이 계속 돌아, **떼어진 <audio> 에서 play()** 가 불려 이전 문장이 조작 단추 없이 끝까지
|
|
168
|
+
울렸다. 재생 직전에 본다. */
|
|
169
|
+
const mountedRef = useRef(true);
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
mountedRef.current = true;
|
|
172
|
+
return () => { mountedRef.current = false; };
|
|
173
|
+
}, []);
|
|
166
174
|
const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
|
|
167
175
|
for (let i = 0; i < attempts; i++) {
|
|
176
|
+
if (!mountedRef.current)
|
|
177
|
+
return false;
|
|
168
178
|
try {
|
|
169
179
|
await a.play();
|
|
170
180
|
return true;
|
|
@@ -485,13 +495,14 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
485
495
|
setIsFetchingUrl(false);
|
|
486
496
|
}
|
|
487
497
|
};
|
|
498
|
+
/** 소스를 하나라도 얻었으면 true, 못 얻었으면 false, 이미 다른 불러오기가 돌면 null. */
|
|
488
499
|
const fetchAndPrepare = async (playAfterLoad = false, showSpinner = true) => {
|
|
489
500
|
const a = audioRef.current;
|
|
490
501
|
const candidates = soundSrc ? (Array.isArray(soundSrc) ? soundSrc : [soundSrc]) : [];
|
|
491
502
|
if (!candidates || candidates.length === 0 || !candidates.some(Boolean))
|
|
492
|
-
return;
|
|
503
|
+
return false;
|
|
493
504
|
if (fetchingRef.current)
|
|
494
|
-
return;
|
|
505
|
+
return null;
|
|
495
506
|
fetchingRef.current = true;
|
|
496
507
|
if (showSpinner)
|
|
497
508
|
setIsFetchingUrl(true);
|
|
@@ -591,6 +602,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
591
602
|
setCurrentIndex(0);
|
|
592
603
|
set구간표시(true);
|
|
593
604
|
}
|
|
605
|
+
return finalSources.length > 0;
|
|
594
606
|
}
|
|
595
607
|
finally {
|
|
596
608
|
fetchingRef.current = false;
|
|
@@ -705,7 +717,16 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
705
717
|
catch (e) { }
|
|
706
718
|
}
|
|
707
719
|
setStateLocal("load");
|
|
708
|
-
await fetchAndPrepare(true);
|
|
720
|
+
const 얻음 = await fetchAndPrepare(true);
|
|
721
|
+
/* 쓸 소스를 하나도 못 얻으면(경로를 못 정한 코드뿐 등) 'load' 에 갇혀 듣기 단추가 사라지고 아무 조작도
|
|
722
|
+
안 됐다 — 알리고 누를 수 있는 상태로 되돌린다. */
|
|
723
|
+
if (얻음 === false && mountedRef.current) {
|
|
724
|
+
try {
|
|
725
|
+
setNoticeMessage(t('training.audioFetchError', '사운드 소스를 가져오지 못했습니다.'));
|
|
726
|
+
}
|
|
727
|
+
catch (_) { }
|
|
728
|
+
setStateLocal('active');
|
|
729
|
+
}
|
|
709
730
|
return;
|
|
710
731
|
}
|
|
711
732
|
if (stateLocal === "load")
|
|
@@ -1009,10 +1030,10 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1009
1030
|
const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
|
|
1010
1031
|
return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (_jsxs("div", { className: "audio-player__loading", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: STATUS_MESSAGES[fetchDebugStatus] })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button", onClick: () => { hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
|
|
1011
1032
|
// 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
|
|
1012
|
-
onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
|
|
1033
|
+
onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
|
|
1013
1034
|
handleJumpTo(idx);
|
|
1014
1035
|
}
|
|
1015
|
-
catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: 구간표시 && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, onSegmentClick: handleJumpTo, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
|
|
1036
|
+
catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: 구간표시 && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, onSegmentClick: handleJumpTo, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
|
|
1016
1037
|
/* 그림은 "문장 = 기준" — 문제 소리(M÷R)가 아니라 전체 소리 M 으로 문장을 그리고 소음을 R 배로 키운다 */
|
|
1017
1038
|
mainVolume: mainVolume, onReadyChange: handleWaveReadyChange }))] })] }), _jsx("audio", { ref: audioRef, crossOrigin: "anonymous", preload: "auto" })] }), !showSoundWaveForm && (_jsxs("p", { className: "audio-player__no-waveform caption-1", children: [_jsx(Icon, { icon: "triangle-alert", size: 14 }), t('training.noSoundWaveform', '소리 비교 문제는 웨이브폼을 표현하지 않습니다.')] })), _jsx(Toast, { message: noticeMessage, position: noticePosition, onClose: handleClearNotice })] }));
|
|
1018
1039
|
}
|
|
@@ -113,5 +113,5 @@ export default function TrainingStepConfirm({ index, stepIndex, isLast = false,
|
|
|
113
113
|
때문이다 — 나머지 둘(choices 바뀜·페이지 전환)은 빈 Set 으로 되돌리는 자리라
|
|
114
114
|
저 effect 의 `size === 0` 가드에 걸려 어차피 아무것도 안 했다. */
|
|
115
115
|
const btnsClassName = `training-step__buttons${Array.isArray(choices) && choices.length > 3 ? ' training-step__buttons--wrap-3' : ''}`;
|
|
116
|
-
return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, useTitleFaded: useTitleFaded, children: _jsx("div", { className: btnsClassName, children: Array.isArray(choices) && choices.length > 0 ? (choices.map((c, i) => (_jsx(Button, { size: 56, type: "bar", onClick: () => handleSelect(i), ariaLabel: String(c), label: c, useLabel: true, icon: choiceIcons ? choiceIcons[String(c)] : undefined, variant: 'default', disabled: currentState === 'disabled', selected: multiSelect ? selectedSet.has(c) : selectedAnswer === c, useDisabledCovered: useDisabledCovered, minWidth: 0 }, String(c))))) : null }) }));
|
|
116
|
+
return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, useTitleFaded: useTitleFaded, children: _jsx("div", { className: btnsClassName, children: Array.isArray(choices) && choices.length > 0 ? (choices.map((c, i) => (_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: () => handleSelect(i), ariaLabel: String(c), label: c, useLabel: true, icon: choiceIcons ? choiceIcons[String(c)] : undefined, variant: 'default', disabled: currentState === 'disabled', selected: multiSelect ? selectedSet.has(c) : selectedAnswer === c, useDisabledCovered: useDisabledCovered, minWidth: 0 }, String(c))))) : null }) }));
|
|
117
117
|
}
|
|
@@ -49,11 +49,11 @@ export default function TrainingStepConfirmWithText({ index, stepIndex, isLast =
|
|
|
49
49
|
setSelectedAnswer(true);
|
|
50
50
|
handleEndedLocal(true);
|
|
51
51
|
}, [handleEndedLocal, currentState]);
|
|
52
|
-
return (_jsxs(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, onDone: onDone, useTitleFaded: false, children: [_jsx(TrainingStepTextBox, { context: context, state: currentState }), _jsx("div", { className: "training-step__buttons", children: Array.isArray(choices) && choices.length > 0 ? (choices.slice(0, 6).map((c, i) => (_jsx(Button, { size: 56, type: "bar", onClick: () => {
|
|
52
|
+
return (_jsxs(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, onDone: onDone, useTitleFaded: false, children: [_jsx(TrainingStepTextBox, { context: context, state: currentState }), _jsx("div", { className: "training-step__buttons", children: Array.isArray(choices) && choices.length > 0 ? (choices.slice(0, 6).map((c, i) => (_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: () => {
|
|
53
53
|
if (currentState !== 'active')
|
|
54
54
|
return;
|
|
55
55
|
setSelectedAnswer(i);
|
|
56
56
|
if (handleEndedLocal)
|
|
57
57
|
handleEndedLocal(i);
|
|
58
|
-
}, disabled: currentState === 'disabled', ariaLabel: String(c), label: c, variant: currentState, selected: selectedAnswer === i }, i)))) : (_jsxs(_Fragment, { children: [_jsx(Button, { size: 56, type: "bar", onClick: handleSelectFalse, disabled: currentState === 'disabled', icon: 'x', ariaLabel: t('training.notHeard', '안 들렸어요.'), label: t('training.notHeard', '안 들렸어요.'), variant: 'default', selected: selectedAnswer === false }), _jsx(Button, { size: 56, type: "bar", onClick: handleSelectTrue, disabled: currentState === 'disabled', icon: 'circle', ariaLabel: t('training.heardWell', '잘 들렸어요.'), label: t('training.heardWell', '잘 들렸어요.'), variant: 'default', selected: selectedAnswer === true })] })) })] }));
|
|
58
|
+
}, disabled: currentState === 'disabled', ariaLabel: String(c), label: c, variant: currentState, selected: selectedAnswer === i }, i)))) : (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: handleSelectFalse, disabled: currentState === 'disabled', icon: 'x', ariaLabel: t('training.notHeard', '안 들렸어요.'), label: t('training.notHeard', '안 들렸어요.'), variant: 'default', selected: selectedAnswer === false }), _jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: handleSelectTrue, disabled: currentState === 'disabled', icon: 'circle', ariaLabel: t('training.heardWell', '잘 들렸어요.'), label: t('training.heardWell', '잘 들렸어요.'), variant: 'default', selected: selectedAnswer === true })] })) })] }));
|
|
59
59
|
}
|
|
@@ -440,6 +440,6 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
|
|
|
440
440
|
}, 80);
|
|
441
441
|
}
|
|
442
442
|
catch (err) { }
|
|
443
|
-
} : undefined, onDrop: currentState === 'active' ? (e) => handleDrop(idx, e) : undefined, onDragEnd: currentState === 'active' ? handleDragEnd : undefined, children: [_jsx("span", { className: "order-list__item-text body-2", children: it.text }), currentState === 'active' ? (_jsxs("span", { className: "order-list__item-buttons", children: [_jsx(Button, { size: 32, type: "dot", onClick: () => move(idx, -1), ariaLabel: t("training.up"), icon: 'chevron-up', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === 0, useBorder: false, useDisabledInvisible: true }), _jsx(Button, { size: 32, type: "dot", onClick: () => move(idx, 1), ariaLabel: t("training.down"), icon: 'chevron-down', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === shuffled.length - 1, useBorder: false, useDisabledInvisible: true })] })) : null] })] }) }, it.id));
|
|
443
|
+
} : undefined, onDrop: currentState === 'active' ? (e) => handleDrop(idx, e) : undefined, onDragEnd: currentState === 'active' ? handleDragEnd : undefined, children: [_jsx("span", { className: "order-list__item-text body-2", children: it.text }), currentState === 'active' ? (_jsxs("span", { className: "order-list__item-buttons", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, -1), ariaLabel: t("training.up"), icon: 'chevron-up', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === 0, useBorder: false, useDisabledInvisible: true }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, 1), ariaLabel: t("training.down"), icon: 'chevron-down', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === shuffled.length - 1, useBorder: false, useDisabledInvisible: true })] })) : null] })] }) }, it.id));
|
|
444
444
|
}) }) }));
|
|
445
445
|
}
|
|
@@ -70,5 +70,5 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
|
|
|
70
70
|
fontSize: `${fontSize}px`,
|
|
71
71
|
lineHeight: `${lineHeight}px`,
|
|
72
72
|
}), [fontSize, lineHeight]);
|
|
73
|
-
return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
|
|
73
|
+
return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
|
|
74
74
|
}
|
|
@@ -87,5 +87,5 @@ export default function TrainingStepTitle({ title, children, className = "", sta
|
|
|
87
87
|
setPreviewContent(null);
|
|
88
88
|
}
|
|
89
89
|
}, [onPreviewTextBtn, previewText, showPreview]);
|
|
90
|
-
return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive })), usePreviewTextBtn && (_jsx("div", { ref: btnRef, children: _jsx(Button, { size: 32, type: isMobile ? 'dot' : 'bar', icon: "eye", label: isMobile ? undefined : t('training.sentencePreview', '문장 미리보기'), ariaLabel: t('training.sentencePreview', '문장 미리보기'), selected: showPreview, onClick: handlePreviewButtonClick, variant: state === 'disabled' ? 'disabled' : 'default', disabled: !previewText && typeof onPreviewTextBtn !== 'function' }) }))] }), showPreview && (_jsx(TextBoxTooltip, { text: (previewContent ?? previewText), label: t('training.sentencePreview', '문장 미리보기'), anchorRect: anchorRect, onClose: handlePreviewClose, closeOnOutsideClick: false, isMobilePopup: true }))] })) : null] }));
|
|
90
|
+
return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive })), usePreviewTextBtn && (_jsx("div", { ref: btnRef, children: _jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "eye", label: isMobile ? undefined : t('training.sentencePreview', '문장 미리보기'), ariaLabel: t('training.sentencePreview', '문장 미리보기'), selected: showPreview, onClick: handlePreviewButtonClick, variant: state === 'disabled' ? 'disabled' : 'default', disabled: !previewText && typeof onPreviewTextBtn !== 'function' }) }))] }), showPreview && (_jsx(TextBoxTooltip, { text: (previewContent ?? previewText), label: t('training.sentencePreview', '문장 미리보기'), anchorRect: anchorRect, onClose: handlePreviewClose, closeOnOutsideClick: false, isMobilePopup: true }))] })) : null] }));
|
|
91
91
|
}
|
|
@@ -36,7 +36,12 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
36
36
|
const audioRef = useRef(null);
|
|
37
37
|
const isMobile = useMediaQuery('(max-width: 767px)');
|
|
38
38
|
const [playing, setPlaying] = useState(false);
|
|
39
|
-
|
|
39
|
+
/* 준비한 URL 은 **어느 낱말의 것인지와 함께** 둔다. 카드는 번호 key 로 재사용되는데(TrainingStepCard),
|
|
40
|
+
전에는 낱말이 바뀌어도 URL 을 비우지 않아 새 낱말의 리졸버가 기다리는 동안·실패했을 때 누르면
|
|
41
|
+
**이전 낱말 소리**가 났다 — 변별 훈련에서 틀린 자극이다. 지금 낱말의 것이 아니면 없는 것으로 본다. */
|
|
42
|
+
const audioFor = `${typeof rawAudioSrc === 'string' ? rawAudioSrc : rawAudioSrc?.src ?? ''}\u0000${label}`;
|
|
43
|
+
const [prepared, setPrepared] = useState({ for: '', url: '' });
|
|
44
|
+
const audioUrl = prepared.for === audioFor ? prepared.url : '';
|
|
40
45
|
// ── 이미지 ── (앞면 유지값 기준 — 플립 동안 이미지도 유지)
|
|
41
46
|
const imageUrl = getImageResolver()?.(front.label, front.homonum) ?? item.image ?? '';
|
|
42
47
|
const { imgRef, loading: imgLoading, hasImage, onLoad: handleImgLoad, onError: handleImgError } = useWordImage(imageUrl);
|
|
@@ -48,14 +53,14 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
48
53
|
if (rawAudioSrc) {
|
|
49
54
|
if (typeof rawAudioSrc === 'string') {
|
|
50
55
|
if (rawAudioSrc.startsWith('http') || rawAudioSrc.includes('/')) {
|
|
51
|
-
|
|
56
|
+
setPrepared({ for: audioFor, url: rawAudioSrc });
|
|
52
57
|
return;
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
60
|
else if (typeof rawAudioSrc === 'object' && !Array.isArray(rawAudioSrc)) {
|
|
56
61
|
const src = rawAudioSrc.src;
|
|
57
62
|
if (src) {
|
|
58
|
-
|
|
63
|
+
setPrepared({ for: audioFor, url: src });
|
|
59
64
|
return;
|
|
60
65
|
}
|
|
61
66
|
}
|
|
@@ -66,14 +71,14 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
66
71
|
try {
|
|
67
72
|
const url = await Promise.resolve(resolver(label));
|
|
68
73
|
if (!cancelled && url)
|
|
69
|
-
|
|
74
|
+
setPrepared({ for: audioFor, url });
|
|
70
75
|
}
|
|
71
76
|
catch { }
|
|
72
77
|
}
|
|
73
78
|
};
|
|
74
79
|
resolveAudio();
|
|
75
80
|
return () => { cancelled = true; };
|
|
76
|
-
}, [rawAudioSrc, label]);
|
|
81
|
+
}, [rawAudioSrc, label, audioFor]);
|
|
77
82
|
// ── 언마운트 시 오디오 정리 ──
|
|
78
83
|
useEffect(() => {
|
|
79
84
|
return () => {
|
|
@@ -224,6 +229,6 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
224
229
|
onSelect?.();
|
|
225
230
|
}
|
|
226
231
|
}
|
|
227
|
-
: undefined, children: _jsxs("div", { className: "training-word-card__flipper", style: { transitionDelay: showBack ? '0ms' : `${flipDelay}ms` }, children: [_jsxs("div", { className: "training-word-card__front", children: [selected && (_jsx("div", { className: "training-word-card__selected-badge", onClick: handleStopProp, children: _jsx(Badge, { type: "dot", size: 40, position: "inline", animate: true, icon: orderRank != null ? undefined : 'check', number: orderRank ?? undefined, "aria-label": orderRank != null ? t('training.nthSelected', { n: orderRank }) : t('training.selected', '선택됨') }) })), _jsx("div", { className: "training-word-card__image-wrap", children: hasImage ? (_jsxs(_Fragment, { children: [imgLoading && _jsx("div", { className: "training-word-card__spinner" }), _jsx("img", { ref: imgRef, className: "training-word-card__image", src: imageUrl, alt: front.label || t('common.image', '이미지'), style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })) : (_jsx("div", { className: "training-word-card__placeholder" })) }), !hideLabel && (_jsxs("div", { className: "training-word-card__body", children: [_jsx("div", { className: "training-word-card__word", children: front.label }), front.subLabel && (_jsxs("div", { className: "training-word-card__pronunciation", children: ["[", front.subLabel, "]"] })), front.colloquialLabel && (_jsxs("div", { className: "training-word-card__colloquial caption-1", children: ["[", front.colloquialLabel, "]"] }))] })), !hideAudio && (_jsx("div", { className: "training-word-card__bottom", onClick: selectable ? handleStopProp : undefined, children: _jsx(Button, { size: isMobile ? 40 : 48, type: isMobile ? 'dot' : 'bar', icon: playing ? 'pause' : 'play', label: isMobile ? undefined : t('training.playSound', '소리 듣기'), selected: playing, onClick: handlePlay, disabled: disabled, ariaLabel: t('training.playSound', '소리 듣기') }) }))] }), _jsx("div", { className: "training-word-card__back", "aria-hidden": "true", children: _jsx("img", { src: symbolSrc, className: "training-word-card__disabled-symbol", alt: "", draggable: false }) })] }) }));
|
|
232
|
+
: undefined, children: _jsxs("div", { className: "training-word-card__flipper", style: { transitionDelay: showBack ? '0ms' : `${flipDelay}ms` }, children: [_jsxs("div", { className: "training-word-card__front", children: [selected && (_jsx("div", { className: "training-word-card__selected-badge", onClick: handleStopProp, children: _jsx(Badge, { type: "dot", size: 40, position: "inline", animate: true, icon: orderRank != null ? undefined : 'check', number: orderRank ?? undefined, "aria-label": orderRank != null ? t('training.nthSelected', { n: orderRank }) : t('training.selected', '선택됨') }) })), _jsx("div", { className: "training-word-card__image-wrap", children: hasImage ? (_jsxs(_Fragment, { children: [imgLoading && _jsx("div", { className: "training-word-card__spinner" }), _jsx("img", { ref: imgRef, className: "training-word-card__image", src: imageUrl, alt: front.label || t('common.image', '이미지'), style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })) : (_jsx("div", { className: "training-word-card__placeholder" })) }), !hideLabel && (_jsxs("div", { className: "training-word-card__body", children: [_jsx("div", { className: "training-word-card__word", children: front.label }), front.subLabel && (_jsxs("div", { className: "training-word-card__pronunciation", children: ["[", front.subLabel, "]"] })), front.colloquialLabel && (_jsxs("div", { className: "training-word-card__colloquial caption-1", children: ["[", front.colloquialLabel, "]"] }))] })), !hideAudio && (_jsx("div", { className: "training-word-card__bottom", onClick: selectable ? handleStopProp : undefined, children: _jsx(Button, { htmlType: "button", size: isMobile ? 40 : 48, type: isMobile ? 'dot' : 'bar', icon: playing ? 'pause' : 'play', label: isMobile ? undefined : t('training.playSound', '소리 듣기'), selected: playing, onClick: handlePlay, disabled: disabled, ariaLabel: t('training.playSound', '소리 듣기') }) }))] }), _jsx("div", { className: "training-word-card__back", "aria-hidden": "true", children: _jsx("img", { src: symbolSrc, className: "training-word-card__disabled-symbol", alt: "", draggable: false }) })] }) }));
|
|
228
233
|
});
|
|
229
234
|
export default TrainingWordCard;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { resolveSoundPath, getSignedOrPublicUrl, downloadSound } from '../../utils/storage';
|
|
4
4
|
function derivePreviewMeta(trainingData, typeKey) {
|
|
5
5
|
if (!trainingData)
|
|
@@ -46,7 +46,9 @@ function derivePreviewMeta(trainingData, typeKey) {
|
|
|
46
46
|
export function useAudioPreview(trainingData, typeKey) {
|
|
47
47
|
const [playing, setPlaying] = useState(false);
|
|
48
48
|
const [playingIndex, setPlayingIndex] = useState(-1);
|
|
49
|
-
|
|
49
|
+
/* 받은 주소는 **어느 코드 묶음의 것인지와 함께** 둔다. 코드가 없는 훈련으로 바뀌면 아래 효과가 일찍 빠져나가
|
|
50
|
+
이전 훈련 주소가 남았고, 새 주소를 받는 동안에도 이전 주소로 미리듣기가 났다. 지금 묶음의 것만 쓴다. */
|
|
51
|
+
const [resolved, setResolved] = useState({ key: '', urls: [] });
|
|
50
52
|
const previewAudioRef = useRef(null);
|
|
51
53
|
const playingRef = useRef(false);
|
|
52
54
|
const previewBtnRef = useRef(null);
|
|
@@ -55,6 +57,8 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
55
57
|
// 우리가 만든 게 아니므로 revoke 대상이 아니다
|
|
56
58
|
const ownedBlobUrlsRef = useRef([]);
|
|
57
59
|
const { previewCodes, previewBucket } = derivePreviewMeta(trainingData, typeKey);
|
|
60
|
+
const previewKey = `${previewCodes.join(',')}|${previewBucket ?? ''}`;
|
|
61
|
+
const previewUrls = useMemo(() => (resolved.key === previewKey ? resolved.urls : []), [resolved, previewKey]);
|
|
58
62
|
// Resolve audio URLs upfront when training data changes
|
|
59
63
|
useEffect(() => {
|
|
60
64
|
if (!previewCodes.length)
|
|
@@ -98,7 +102,7 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
98
102
|
}
|
|
99
103
|
catch { } });
|
|
100
104
|
ownedBlobUrlsRef.current = newBlobUrls;
|
|
101
|
-
|
|
105
|
+
setResolved({ key: previewKey, urls: resolvedUrls });
|
|
102
106
|
}
|
|
103
107
|
else {
|
|
104
108
|
// 이 effect가 취소된 사이 새로 만든 blob은 ref에 등록되지 못하므로 즉시 해제
|
|
@@ -24,6 +24,10 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
24
24
|
// 실제 로드된(재생되는) 소음 파일 URL — waveform이 화면=청취 일치하도록 이 src를 그린다.
|
|
25
25
|
const [activeNoiseUrl, setActiveNoiseUrl] = useState(null);
|
|
26
26
|
const noiseTypeLoadedRef = useRef(null);
|
|
27
|
+
/* 바꿔 끼울 소음 파일 — 종류를 바꾸면 여기에 두고, 페이드아웃이 끝나거나 **다음 페이드인이 시작될 때** 끼운다.
|
|
28
|
+
전에는 페이드아웃의 완료 콜백에만 걸어 두어, 150ms 안에 다른 페이드(다음 트랙의 fadeInNoise·문장 종료의
|
|
29
|
+
fadeOutNoise·탭 숨김)가 끼면 콜백이 사라졌다 — 종류 표시·파형은 새 것인데 **소리는 이전 파일**이었다. */
|
|
30
|
+
const pendingNoiseUrlRef = useRef(null);
|
|
27
31
|
// volume = 소음 <audio> 볼륨(noiseElementVolume — 전체 소리 × 소음 비율 ÷ 파일 gain, 상한 1)
|
|
28
32
|
const noiseSettingsRef = useRef(null);
|
|
29
33
|
const noiseFadeRafRef = useRef(null);
|
|
@@ -43,7 +47,19 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
43
47
|
}, []);
|
|
44
48
|
const fadeInNoise = useCallback((targetVolume, durationMs = 250) => {
|
|
45
49
|
const noiseEl = noiseAudioRef.current;
|
|
46
|
-
if (!noiseEl
|
|
50
|
+
if (!noiseEl)
|
|
51
|
+
return;
|
|
52
|
+
const pending = pendingNoiseUrlRef.current;
|
|
53
|
+
if (pending) {
|
|
54
|
+
pendingNoiseUrlRef.current = null;
|
|
55
|
+
try {
|
|
56
|
+
noiseEl.pause();
|
|
57
|
+
}
|
|
58
|
+
catch (e) { }
|
|
59
|
+
noiseEl.src = pending;
|
|
60
|
+
noiseEl.load();
|
|
61
|
+
}
|
|
62
|
+
if (!noiseEl.src)
|
|
47
63
|
return;
|
|
48
64
|
noiseFadeTokenRef.current += 1;
|
|
49
65
|
cancelNoiseFade();
|
|
@@ -235,7 +251,12 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
235
251
|
if (url) {
|
|
236
252
|
noiseTypeLoadedRef.current = ns.type;
|
|
237
253
|
setActiveNoiseUrl(url); // waveform이 실제 재생 파일을 그리도록
|
|
254
|
+
pendingNoiseUrlRef.current = url;
|
|
238
255
|
fadeOutNoise(150, () => {
|
|
256
|
+
// 그 사이 다음 페이드인이 이미 끼워 넣었으면(pending 이 비었으면) 다시 끼우지 않는다
|
|
257
|
+
if (pendingNoiseUrlRef.current !== url)
|
|
258
|
+
return;
|
|
259
|
+
pendingNoiseUrlRef.current = null;
|
|
239
260
|
noiseEl.src = url;
|
|
240
261
|
noiseEl.load();
|
|
241
262
|
const mainAudio = audioRef.current;
|
|
@@ -13,9 +13,14 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
13
13
|
const ttsStartRef = useRef(null);
|
|
14
14
|
const ttsEstimatedDurationRef = useRef(0);
|
|
15
15
|
const ttsRafRef = useRef(null);
|
|
16
|
+
/* 지금 말하는 발화의 번호. 정지(cancel)·다시 시작·언마운트에서 올린다 — Chrome·Safari 는 cancel() 한 발화에도
|
|
17
|
+
end 이벤트를 내서, **정지를 누르면 끝까지 들은 것으로** handleEnd → onDone(true) 가 불렸고(문항 완료로 셈),
|
|
18
|
+
언마운트 뒤에 온 end 가 떠난 화면에 완료를 알렸다. 번호가 다른 end 는 넘긴다. */
|
|
19
|
+
const ttsTokenRef = useRef(0);
|
|
16
20
|
useEffect(() => {
|
|
17
21
|
// cleanup TTS resources on unmount
|
|
18
22
|
return () => {
|
|
23
|
+
ttsTokenRef.current++;
|
|
19
24
|
try {
|
|
20
25
|
if (ttsRafRef.current) {
|
|
21
26
|
cancelAnimationFrame(ttsRafRef.current);
|
|
@@ -52,6 +57,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
52
57
|
return;
|
|
53
58
|
}
|
|
54
59
|
const synth = window.speechSynthesis;
|
|
60
|
+
const myToken = ++ttsTokenRef.current;
|
|
55
61
|
try {
|
|
56
62
|
try {
|
|
57
63
|
synth.cancel();
|
|
@@ -99,6 +105,8 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
99
105
|
catch (e) { }
|
|
100
106
|
};
|
|
101
107
|
utt.onend = () => {
|
|
108
|
+
if (myToken !== ttsTokenRef.current)
|
|
109
|
+
return;
|
|
102
110
|
try {
|
|
103
111
|
if (ttsRafRef.current) {
|
|
104
112
|
cancelAnimationFrame(ttsRafRef.current);
|
|
@@ -149,6 +157,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
149
157
|
}
|
|
150
158
|
}, [ttsText, playbackRate, mainVolume, fadeInNoise, fillRef, waveProgressRef, setProgress, setPlaybackState, setStateLocal, noiseSettingsRef, onDoneRef, handleEndRef]);
|
|
151
159
|
const stopTTS = useCallback(() => {
|
|
160
|
+
ttsTokenRef.current++;
|
|
152
161
|
try {
|
|
153
162
|
if (window.speechSynthesis) {
|
|
154
163
|
try {
|
|
@@ -21,5 +21,5 @@ import './UpdateAlert.css';
|
|
|
21
21
|
export default function UpdateAlert({ title, message, actionLabel, onAction, icon, onClose, className = '', }) {
|
|
22
22
|
const { t } = useTranslation();
|
|
23
23
|
const resolvedActionLabel = actionLabel ?? t('common.update', '업데이트');
|
|
24
|
-
return (_jsxs("div", { className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: onClose, "aria-label": t('common.close', '닫기'), children: "\u00D7" })), _jsxs("div", { className: "update-alert__top", children: [_jsx("div", { className: "update-alert__icon", children: icon ?? _jsx(Download, { size: 20, strokeWidth: 2 }) }), _jsxs("div", { className: "update-alert__body", children: [_jsx("div", { className: "update-alert__title h4", children: title }), _jsx("div", { className: "update-alert__message body-3", children: message })] })] }), onAction && (_jsx(Button, { size: 32, variant: "main", shape: "pill", className: "update-alert__action", onClick: onAction, children: resolvedActionLabel }))] }));
|
|
24
|
+
return (_jsxs("div", { className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: onClose, "aria-label": t('common.close', '닫기'), children: "\u00D7" })), _jsxs("div", { className: "update-alert__top", children: [_jsx("div", { className: "update-alert__icon", children: icon ?? _jsx(Download, { size: 20, strokeWidth: 2 }) }), _jsxs("div", { className: "update-alert__body", children: [_jsx("div", { className: "update-alert__title h4", children: title }), _jsx("div", { className: "update-alert__message body-3", children: message })] })] }), onAction && (_jsx(Button, { htmlType: "button", size: 32, variant: "main", shape: "pill", className: "update-alert__action", onClick: onAction, children: resolvedActionLabel }))] }));
|
|
25
25
|
}
|
|
@@ -45,7 +45,7 @@ export default function PhonemeSelectBlock({ sections, selectedList, count, onCo
|
|
|
45
45
|
const hasLabel = !!value && value !== '없음';
|
|
46
46
|
return (_jsxs("span", { className: cx('phoneme-select-block__cell-jamo-part', value ? '' : 'phoneme-select-block__cell-jamo-part--empty', hasLabel ? '' : 'phoneme-select-block__cell-jamo-part--no-label'), children: [_jsx("span", { className: `phoneme-select-block__cell-jamo-char h1${value === '없음' ? ' phoneme-select-block__cell-jamo-char--excluded' : ''}`, children: value === '없음' ? 'x' : (value || placeholder) }), hasLabel && _jsx("span", { className: "phoneme-select-block__cell-jamo-label caption-2", children: displayLabel ?? label })] }, label));
|
|
47
47
|
}), [sections, placeholder]);
|
|
48
|
-
const infoButton = (_jsxs(_Fragment, { children: [_jsx("div", { "data-tooltip-ignore": true, children: _jsx(Button, { size: 40, type: isMobile ? 'dot' : undefined, icon: "info", label: isMobile ? undefined : t('wordPairs.caution', '유의사항'), selected: !!infoAnchorRect, onClick: (e) => toggleInfo(e.currentTarget) }) }), infoAnchorRect && (_jsx(DescriptionTooltip, { title: t('wordPairs.caution', '유의사항'), items: infoNotes, anchorRect: infoAnchorRect, onClose: handleCloseInfo }))] }));
|
|
48
|
+
const infoButton = (_jsxs(_Fragment, { children: [_jsx("div", { "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 40, type: isMobile ? 'dot' : undefined, icon: "info", label: isMobile ? undefined : t('wordPairs.caution', '유의사항'), selected: !!infoAnchorRect, onClick: (e) => toggleInfo(e.currentTarget) }) }), infoAnchorRect && (_jsx(DescriptionTooltip, { title: t('wordPairs.caution', '유의사항'), items: infoNotes, anchorRect: infoAnchorRect, onClose: handleCloseInfo }))] }));
|
|
49
49
|
return (_jsxs(Section, { type: sectionType, title: title, icon: titleIcon, disc: disc, action: infoButton, className: cx('phoneme-select-block', className), children: [beforeContent, (!hideCountControl || (showInfoButton && !(title || disc))) && (_jsxs("div", { className: "phoneme-select-block__controls", children: [!hideCountControl && (_jsx(SegmentedControl, { options: countOptions, value: String(count), onChange: handleCountChange, size: 40, className: "phoneme-select-block__count-control" })), showInfoButton && !(title || disc) && infoButton] })), _jsx("div", { className: "phoneme-select-block__cells", children: pairMode
|
|
50
50
|
? (_jsxs(_Fragment, { children: [_jsx("div", { className: "phoneme-select-block__cell-group", children: Array.from({ length: count }).map((_, pairIdx) => {
|
|
51
51
|
const sel = selectedList[pairIdx * 2] ?? {};
|
|
@@ -66,6 +66,6 @@ export default function PhonemeSelectBlock({ sections, selectedList, count, onCo
|
|
|
66
66
|
// 종성 '제외'(없음)만 선택된 슬롯은 실제 음소를 고른 것으로 치지 않는다 —
|
|
67
67
|
// "받침 없음"은 단독으로는 검색 조건이 되기엔 너무 느슨한 보조 조건이라서다.
|
|
68
68
|
const isEmpty = selectedList.every(sel => Object.entries(sel).every(([k, v]) => !v || (k === '종성' && v === '없음')));
|
|
69
|
-
return (_jsxs("div", { className: "phoneme-select-block__search-row", onClick: isEmpty ? onSearchEmpty : undefined, children: [onClearPhoneme && (_jsx(Button, { size: "56", type: "bar", label: t('wordPairs.resetOptions', '검색 옵션 초기화'), selected: false, disabled: isEmpty, onClick: isEmpty ? undefined : onClearPhoneme })), searchChecking ? (_jsxs("div", { className: "phoneme-select-block__checking", children: [_jsx("div", { className: "phoneme-select-block__spinner" }), _jsx("span", { className: "button-1 phoneme-select-block__checking-label", children: searchLabel ?? t('wordSearch.searchButton', '검색하기') })] })) : onSearch && (_jsx(Button, { size: "56", type: "bar", variant: "main", label: searchLabel ?? t('wordSearch.searchButton', '검색하기'), disabled: isEmpty || searchDisabled, onClick: isEmpty || searchDisabled ? undefined : onSearch }))] }));
|
|
69
|
+
return (_jsxs("div", { className: "phoneme-select-block__search-row", onClick: isEmpty ? onSearchEmpty : undefined, children: [onClearPhoneme && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordPairs.resetOptions', '검색 옵션 초기화'), selected: false, disabled: isEmpty, onClick: isEmpty ? undefined : onClearPhoneme })), searchChecking ? (_jsxs("div", { className: "phoneme-select-block__checking", children: [_jsx("div", { className: "phoneme-select-block__spinner" }), _jsx("span", { className: "button-1 phoneme-select-block__checking-label", children: searchLabel ?? t('wordSearch.searchButton', '검색하기') })] })) : onSearch && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: searchLabel ?? t('wordSearch.searchButton', '검색하기'), disabled: isEmpty || searchDisabled, onClick: isEmpty || searchDisabled ? undefined : onSearch }))] }));
|
|
70
70
|
})(), afterContent] }));
|
|
71
71
|
}
|