@ihabdevteam/core 0.149.0 → 0.151.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 +60 -0
- package/dist/components.cjs.js +199 -186
- package/dist/components.esm.css +5 -5
- package/dist/hooks.cjs.js +5 -1
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core-tokens.css +1 -1
- package/dist/ihabdevteam-core.cjs.js +200 -187
- package/dist/ihabdevteam-core.css +1 -1
- package/dist/ihabdevteam-core.d.ts +407 -69
- package/dist/packages/core/src/components/Accordion.d.ts +6 -0
- package/dist/packages/core/src/components/Accordion.js +5 -2
- package/dist/packages/core/src/components/Alert.d.ts +7 -1
- package/dist/packages/core/src/components/Alert.js +12 -3
- package/dist/packages/core/src/components/BarChart.js +7 -2
- package/dist/packages/core/src/components/Board.d.ts +5 -2
- package/dist/packages/core/src/components/Board.js +9 -3
- package/dist/packages/core/src/components/BottomNav.js +5 -4
- package/dist/packages/core/src/components/Breadcrumb.css +9 -0
- package/dist/packages/core/src/components/Breadcrumb.d.ts +12 -1
- package/dist/packages/core/src/components/Breadcrumb.js +8 -2
- package/dist/packages/core/src/components/BulkBar.css +2 -0
- package/dist/packages/core/src/components/BulkBar.js +21 -2
- package/dist/packages/core/src/components/Button.d.ts +12 -2
- package/dist/packages/core/src/components/Button.js +70 -13
- package/dist/packages/core/src/components/Calendar.d.ts +6 -1
- package/dist/packages/core/src/components/Calendar.js +3 -3
- package/dist/packages/core/src/components/DatePicker.d.ts +7 -1
- package/dist/packages/core/src/components/DatePicker.js +25 -15
- package/dist/packages/core/src/components/Dropdown.d.ts +1 -0
- package/dist/packages/core/src/components/Dropdown.js +7 -5
- package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.d.ts +12 -1
- package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +25 -8
- package/dist/packages/core/src/components/EmptyState.d.ts +5 -1
- package/dist/packages/core/src/components/EmptyState.js +4 -1
- package/dist/packages/core/src/components/ErrorState.d.ts +5 -2
- package/dist/packages/core/src/components/ErrorState.js +3 -1
- package/dist/packages/core/src/components/FileUpload.d.ts +9 -1
- package/dist/packages/core/src/components/FileUpload.js +8 -2
- package/dist/packages/core/src/components/FilterSectionInfo.js +14 -2
- package/dist/packages/core/src/components/Header.js +8 -5
- package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -1
- package/dist/packages/core/src/components/Icon.d.ts +2 -1
- package/dist/packages/core/src/components/Icon.js +4 -1
- package/dist/packages/core/src/components/Loader.css +28 -0
- package/dist/packages/core/src/components/Loader.d.ts +5 -1
- package/dist/packages/core/src/components/Loader.js +4 -2
- package/dist/packages/core/src/components/NoticeBox.d.ts +1 -1
- package/dist/packages/core/src/components/NoticeBox.js +15 -6
- package/dist/packages/core/src/components/PanelRow.js +2 -1
- package/dist/packages/core/src/components/PassCard/PassCard.d.ts +8 -2
- package/dist/packages/core/src/components/PassCard/PassCard.js +25 -10
- package/dist/packages/core/src/components/PassCard/passCardProduct.d.ts +1 -1
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +11 -2
- package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +11 -2
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +18 -3
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +10 -8
- package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
- package/dist/packages/core/src/components/Popup/LoadTablePopup.css +7 -0
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +27 -5
- package/dist/packages/core/src/components/Popup/Popup.d.ts +8 -0
- package/dist/packages/core/src/components/Popup/Popup.js +6 -3
- package/dist/packages/core/src/components/Popup/SaveTablePopup.d.ts +5 -1
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.css +3 -0
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.d.ts +5 -0
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +10 -4
- package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.css +9 -0
- package/dist/packages/core/src/components/ProgressIndicator.css +5 -0
- package/dist/packages/core/src/components/ProgressIndicator.d.ts +3 -1
- package/dist/packages/core/src/components/ProgressIndicator.js +5 -3
- package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.d.ts +4 -1
- package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +27 -15
- package/dist/packages/core/src/components/Rating.d.ts +8 -2
- package/dist/packages/core/src/components/Rating.js +22 -8
- package/dist/packages/core/src/components/Report/ReportDocument.d.ts +10 -7
- package/dist/packages/core/src/components/Report/ReportDocument.js +39 -13
- package/dist/packages/core/src/components/RingChart.d.ts +1 -1
- package/dist/packages/core/src/components/RingChart.js +1 -1
- package/dist/packages/core/src/components/SegmentedControl.d.ts +4 -0
- package/dist/packages/core/src/components/SegmentedControl.js +9 -1
- package/dist/packages/core/src/components/ShortcutBadge.css +16 -1
- package/dist/packages/core/src/components/ShortcutBadge.d.ts +1 -0
- package/dist/packages/core/src/components/SideNav.js +8 -2
- package/dist/packages/core/src/components/SummaryStrip.d.ts +1 -1
- package/dist/packages/core/src/components/SummaryStrip.js +2 -2
- package/dist/packages/core/src/components/Table.d.ts +1 -1
- package/dist/packages/core/src/components/Table.js +4 -1
- package/dist/packages/core/src/components/Tabs.d.ts +1 -0
- package/dist/packages/core/src/components/Tabs.js +4 -1
- package/dist/packages/core/src/components/TextField.css +5 -6
- package/dist/packages/core/src/components/TextField.js +46 -3
- package/dist/packages/core/src/components/Toast.js +16 -2
- package/dist/packages/core/src/components/Tooltip/Tooltip.css +3 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +5 -2
- package/dist/packages/core/src/components/Tooltip/tooltipConstants.d.ts +10 -2
- package/dist/packages/core/src/components/Tooltip/tooltipConstants.js +10 -2
- package/dist/packages/core/src/components/TopBanner.d.ts +1 -1
- package/dist/packages/core/src/components/TopBanner.js +66 -8
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.d.ts +1 -0
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +65 -15
- package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +3 -2
- package/dist/packages/core/src/components/Training/NoisePlayerContext.js +10 -4
- package/dist/packages/core/src/components/Training/TrainingBlockButton.css +7 -0
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +18 -5
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +93 -28
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +1 -0
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +16 -3
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +29 -11
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +19 -3
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +15 -1
- package/dist/packages/core/src/components/Training/TrainingWordCard.css +16 -0
- package/dist/packages/core/src/components/Training/TrainingWordCard.d.ts +6 -0
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +7 -2
- package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +2 -2
- package/dist/packages/core/src/components/Training/trainingMissStats.d.ts +10 -0
- package/dist/packages/core/src/components/Training/trainingMissStats.js +19 -1
- package/dist/packages/core/src/components/Training/useFloatingFocus.d.ts +13 -0
- package/dist/packages/core/src/components/Training/useFloatingFocus.js +59 -0
- package/dist/packages/core/src/components/Training/useNoiseFade.js +20 -11
- package/dist/packages/core/src/components/Training/useProgressTicker.d.ts +0 -18
- package/dist/packages/core/src/components/Training/useProgressTicker.js +59 -14
- package/dist/packages/core/src/components/Training/useTTSPlayback.d.ts +1 -1
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +56 -25
- package/dist/packages/core/src/components/Training/useWaveReady.d.ts +4 -1
- package/dist/packages/core/src/components/Training/useWaveReady.js +17 -8
- package/dist/packages/core/src/components/UpdateAlert.css +4 -2
- package/dist/packages/core/src/components/UpdateAlert.d.ts +3 -1
- package/dist/packages/core/src/components/UpdateAlert.js +21 -1
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.d.ts +2 -0
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +12 -4
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.d.ts +6 -0
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +26 -13
- package/dist/packages/core/src/components/Word/WordTableBlock.js +24 -21
- package/dist/packages/core/src/components/Word/WordTableThumbnail.js +1 -1
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -10
- package/dist/packages/core/src/components/Word/useWordTablePrint.d.ts +1 -1
- package/dist/packages/core/src/components/Word/useWordTablePrint.js +9 -3
- package/dist/packages/core/src/components/Word/wordScoreStats.d.ts +14 -1
- package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -3
- package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -3
- package/dist/packages/core/src/components/Word/wordTableReview.d.ts +58 -1
- package/dist/packages/core/src/components/Word/wordTableReview.js +75 -17
- package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +9 -0
- package/dist/packages/core/src/components/Word/wordTableSettings.js +11 -0
- package/dist/packages/core/src/components/WordTable.css +10 -0
- package/dist/packages/core/src/components/WordTable.js +39 -26
- package/dist/packages/core/src/components/barChartTypes.d.ts +2 -2
- package/dist/packages/core/src/components/buttonTypes.d.ts +8 -0
- package/dist/packages/core/src/components/updateAlertStack.d.ts +2 -0
- package/dist/packages/core/src/components/updateAlertStack.js +68 -0
- package/dist/packages/core/src/components/useCalendarMonth.d.ts +4 -1
- package/dist/packages/core/src/components/useCalendarMonth.js +6 -3
- package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +7 -0
- package/dist/packages/core/src/components/useComboboxSuggestions.js +21 -2
- package/dist/packages/core/src/components/wordTableLayout.d.ts +4 -0
- package/dist/packages/core/src/components/wordTableLayout.js +6 -0
- package/dist/packages/core/src/components.d.ts +2 -1
- package/dist/packages/core/src/components.js +1 -1
- package/dist/packages/core/src/hooks/bodyScrollLock.js +8 -2
- package/dist/packages/core/src/hooks/focusReturn.d.ts +12 -0
- package/dist/packages/core/src/hooks/focusReturn.js +49 -0
- package/dist/packages/core/src/hooks/touchToggle.d.ts +22 -0
- package/dist/packages/core/src/hooks/touchToggle.js +114 -0
- package/dist/packages/core/src/hooks/useAnchoredMenu.d.ts +12 -3
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +37 -3
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.d.ts +4 -2
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +23 -11
- package/dist/packages/core/src/hooks/useIcon.js +29 -5
- package/dist/packages/core/src/hooks/useLucideIcon.d.ts +4 -0
- package/dist/packages/core/src/hooks/useLucideIcon.js +4 -0
- package/dist/packages/core/src/hooks/usePendingPress.d.ts +9 -0
- package/dist/packages/core/src/hooks/usePendingPress.js +26 -0
- package/dist/packages/core/src/hooks/usePrefersReducedMotion.d.ts +15 -0
- package/dist/packages/core/src/hooks/usePrefersReducedMotion.js +26 -0
- package/dist/packages/core/src/hooks/useRelativeDay.d.ts +2 -2
- package/dist/packages/core/src/hooks/useRelativeDay.js +12 -5
- package/dist/packages/core/src/hooks/useTablePaging.d.ts +27 -2
- package/dist/packages/core/src/hooks/useTablePaging.js +37 -5
- package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +7 -0
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +25 -0
- package/dist/packages/core/src/i18n/index.js +9 -1
- package/dist/packages/core/src/i18n/locales/en.json +24 -2
- package/dist/packages/core/src/i18n/locales/ko.json +24 -2
- package/dist/packages/core/src/tokens.css +5 -0
- package/dist/packages/core/src/types/wordSearch.d.ts +12 -0
- package/dist/packages/core/src/utils/csv.d.ts +7 -2
- package/dist/packages/core/src/utils/csv.js +12 -5
- package/dist/packages/core/src/utils/dailySelectionUtils.js +44 -14
- package/dist/packages/core/src/utils/date.d.ts +8 -0
- package/dist/packages/core/src/utils/date.js +44 -10
- package/dist/packages/core/src/utils/format.d.ts +1 -1
- package/dist/packages/core/src/utils/format.js +6 -7
- package/dist/packages/core/src/utils/noiseSettingsStore.d.ts +7 -3
- package/dist/packages/core/src/utils/phonemeConvert.d.ts +9 -0
- package/dist/packages/core/src/utils/phonemeConvert.js +11 -0
- package/dist/packages/core/src/utils/phonemePairSearch.d.ts +7 -1
- package/dist/packages/core/src/utils/phonemePairSearch.js +45 -21
- package/dist/packages/core/src/utils/safeHref.d.ts +8 -0
- package/dist/packages/core/src/utils/safeHref.js +24 -0
- package/dist/packages/core/src/utils/savedWordTables.d.ts +19 -3
- package/dist/packages/core/src/utils/savedWordTables.js +27 -19
- package/dist/packages/core/src/utils/textSort.d.ts +9 -0
- package/dist/packages/core/src/utils/textSort.js +37 -1
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +31 -0
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +163 -0
- package/dist/packages/core/src/utils/wordContrast.d.ts +4 -2
- package/dist/packages/core/src/utils/wordContrast.js +8 -2
- package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +5 -2
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +52 -2
- package/dist/packages/core/src/utils/wordTrainingPool.js +2 -2
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +15 -7
- package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.d.ts +1 -1
- package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.js +5 -4
- package/dist/packages/core/src/utils/wornDate.d.ts +12 -0
- package/dist/packages/core/src/utils/wornDate.js +15 -0
- 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
|
@@ -26,11 +26,35 @@ import useTrainingSessionLog from '../../hooks/useTrainingSessionLog';
|
|
|
26
26
|
import { logTrainingCompletion } from '../../utils/trainingLogger';
|
|
27
27
|
import { toItemResults } from '../../utils/trainingItemResults';
|
|
28
28
|
import { loadNoiseSettings, saveNoiseSettings, reportAppliedNoiseSettings } from '../../utils/noiseSettingsStore';
|
|
29
|
+
/** 판 중 소음 설정 변경을 저장하기 전 기다리는 시간(ms) — 멈춘 뒤 이만큼 지나면 마지막 값만 저장한다. */
|
|
30
|
+
const NOISE_SAVE_DEBOUNCE_MS = 300;
|
|
31
|
+
/* 움직임 최소화면 닫힘 대기를 0 으로 — 사라지는 애니메이션을 보여 줄 까닭이 없다. (공용 훅이 생기면 그것으로 합친다.) */
|
|
32
|
+
const 움직임최소화 = () => {
|
|
33
|
+
try {
|
|
34
|
+
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/** 결과를 계산하는 중인 결과 창 자리 — 이 객체와 **같은 것**이면 계산 중이다(결과 창의 '완료' 를 막는다). */
|
|
41
|
+
const 결과계산중 = Object.freeze({});
|
|
29
42
|
import { 키보드로눌렀나 } from '../../hooks/inputModality';
|
|
30
43
|
// 문장 4종은 각 PagePopup 래퍼에서 useTrainingSessionLog로 이미 기록 → 베이스에서 중복 방지
|
|
31
44
|
const SENTENCE_TIME_LOGGED_TYPES = new Set(['LISTEN_IN_NOISE', 'SHORT_STORY', 'LONG_STORY', 'ORDERING_SENTENCE']);
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
/** 결과 창 자료에서 정답/전체를 센다 — 결과 항목 형태가 훈련 타입마다 달라(소음 하 듣기/문장 순서화는 correct 대신
|
|
46
|
+
* heard/judgement 만 채움 — TrainingResultPopup 의 resolveDecision 과 같은 기준) 이 필드들도 본다. */
|
|
47
|
+
function 판결과셈(resultOverlay) {
|
|
48
|
+
const ro = resultOverlay;
|
|
49
|
+
const results = (ro?.results || ro?.answers || []);
|
|
50
|
+
const total = Array.isArray(results) && results.length > 0 ? results.length : null;
|
|
51
|
+
const correct = Array.isArray(results)
|
|
52
|
+
? results.filter(r => r?.correct === true || r?.isCorrect === true || r?.score === 1 ||
|
|
53
|
+
r?.heard === true || r?.judgement === true).length
|
|
54
|
+
: null;
|
|
55
|
+
return { results, correct: total != null ? correct : null, total };
|
|
56
|
+
}
|
|
57
|
+
const defaultRenderResult = (p) => _jsx(TrainingResultPopup, { score: p.score, contextsLength: p.contextsLength, results: p.results, onReplay: p.onReplay, onClose: p.onClose, pending: p.pending });
|
|
34
58
|
import { getScenarioPageId, getScenarioStepKey, createScenarioFlowState, resolveScenarioStepProps, } from './trainingScenario';
|
|
35
59
|
function TrainingPagePopupInner(props) {
|
|
36
60
|
const { scenario, title, subTitle, depth, icon, wrapperStyle, iconTooltip, pageIndex, pageCount, stepIndex, stepCount, onBack, onComplete, onNext, onSettings, useNoiseSetting = true, children, ariaLabel, label, buildResults, onResult: externalOnResultProp, typeKey, entryPoint, dailyTotal, itemRef, renderResultPopup, noiseOverride, } = props;
|
|
@@ -171,6 +195,33 @@ function TrainingPagePopupInner(props) {
|
|
|
171
195
|
else
|
|
172
196
|
setNoiseAnalyser(analyser);
|
|
173
197
|
}, []);
|
|
198
|
+
/* 판 중 변경의 저장은 **멈춘 뒤 300ms 에 마지막 값만** 보낸다 — 전에는 슬라이더를 끄는 동안 움직일 때마다 저장이 나갔다
|
|
199
|
+
(한 번 끌면 수십 번의 네트워크 쓰기, 늦게 도착한 앞 값이 뒤 값을 덮을 수도). 떠날 때·pagehide·탭 숨김에는 남은 값을 즉시 보낸다. */
|
|
200
|
+
const 저장대기Ref = useRef(null);
|
|
201
|
+
const 저장타이머Ref = useRef(null);
|
|
202
|
+
const flushNoiseSave = useCallback(() => {
|
|
203
|
+
if (저장타이머Ref.current !== null) {
|
|
204
|
+
clearTimeout(저장타이머Ref.current);
|
|
205
|
+
저장타이머Ref.current = null;
|
|
206
|
+
}
|
|
207
|
+
const 남은값 = 저장대기Ref.current;
|
|
208
|
+
저장대기Ref.current = null;
|
|
209
|
+
if (남은값)
|
|
210
|
+
saveNoiseSettings(남은값);
|
|
211
|
+
}, []);
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (typeof window === 'undefined')
|
|
214
|
+
return;
|
|
215
|
+
const 숨김 = () => { if (document.visibilityState === 'hidden')
|
|
216
|
+
flushNoiseSave(); };
|
|
217
|
+
window.addEventListener('pagehide', flushNoiseSave);
|
|
218
|
+
document.addEventListener('visibilitychange', 숨김);
|
|
219
|
+
return () => {
|
|
220
|
+
window.removeEventListener('pagehide', flushNoiseSave);
|
|
221
|
+
document.removeEventListener('visibilitychange', 숨김);
|
|
222
|
+
flushNoiseSave();
|
|
223
|
+
};
|
|
224
|
+
}, [flushNoiseSave]);
|
|
174
225
|
// noiseSettings에 병합 + 영속화(fire-and-forget) — setSettings/setPlaybackRate/setMainVolume가 공유
|
|
175
226
|
const mergeAndSaveSettings = useCallback((next) => {
|
|
176
227
|
const merged = mergeNoiseSettings(noiseSettingsRef.current, next);
|
|
@@ -183,10 +234,14 @@ function TrainingPagePopupInner(props) {
|
|
|
183
234
|
return;
|
|
184
235
|
}
|
|
185
236
|
// noiseOverride 판은 저장하지 않는다 — 판 중에 바꾼 것도 이 판에만.
|
|
186
|
-
if (!noiseOverrideRef.current)
|
|
187
|
-
|
|
237
|
+
if (!noiseOverrideRef.current) {
|
|
238
|
+
저장대기Ref.current = merged;
|
|
239
|
+
if (저장타이머Ref.current !== null)
|
|
240
|
+
clearTimeout(저장타이머Ref.current);
|
|
241
|
+
저장타이머Ref.current = setTimeout(flushNoiseSave, NOISE_SAVE_DEBOUNCE_MS);
|
|
242
|
+
}
|
|
188
243
|
reportAppliedNoiseSettings(merged);
|
|
189
|
-
}, []);
|
|
244
|
+
}, [flushNoiseSave]);
|
|
190
245
|
// 문장(메인) 재생 컨트롤 — 툴팁(하단 설정)과 오디오 플레이어가 공유. 계정 설정("훈련" 패널)의
|
|
191
246
|
// 기본값과 동일한 noiseSettings(mainVolume/mainSpeed)에 함께 영속화된다.
|
|
192
247
|
const playbackRate = noiseSettings.mainSpeed;
|
|
@@ -208,6 +263,8 @@ function TrainingPagePopupInner(props) {
|
|
|
208
263
|
/* '다시하기' 횟수 — 단계들의 key 에 넣어 **처음부터 다시 그린다.** 단계 컴포넌트는 내용(문장·보기)이 바뀔 때만 제 상태를
|
|
209
264
|
비워, 같은 문항을 다시 풀면 옛 선택 강조·옛 배치(순서화는 정답 배치일 수도)가 그대로 남았다. */
|
|
210
265
|
const [replayCount, setReplayCount] = useState(0);
|
|
266
|
+
// 다시하기 전 판들의 정답/전체 — 완료 때 onComplete 에 함께 넘긴다
|
|
267
|
+
const 앞판들Ref = useRef([]);
|
|
211
268
|
const [mode, setMode] = useState('center');
|
|
212
269
|
const [closing, setClosing] = useState(false);
|
|
213
270
|
// 닫힘 애니메이션(PAGE_POPUP_CLOSE_MS) 동안 다른 닫기 경로(ESC/닫기 버튼/완료)가 또 실행되면
|
|
@@ -229,7 +286,7 @@ function TrainingPagePopupInner(props) {
|
|
|
229
286
|
closingRef.current = false;
|
|
230
287
|
setClosing(false);
|
|
231
288
|
}
|
|
232
|
-
}, PAGE_POPUP_CLOSE_MS);
|
|
289
|
+
}, 움직임최소화() ? 0 : PAGE_POPUP_CLOSE_MS);
|
|
233
290
|
}, []);
|
|
234
291
|
useEffect(() => () => { if (closeTimerRef.current)
|
|
235
292
|
clearTimeout(closeTimerRef.current); }, []);
|
|
@@ -576,7 +633,7 @@ function TrainingPagePopupInner(props) {
|
|
|
576
633
|
catch (e) { }
|
|
577
634
|
if (hasScenario) {
|
|
578
635
|
const maybe = buildScenarioResult();
|
|
579
|
-
setResultOverlay(
|
|
636
|
+
setResultOverlay(결과계산중);
|
|
580
637
|
const maybeAny = maybe;
|
|
581
638
|
Promise.resolve(maybeAny)
|
|
582
639
|
.then((data) => {
|
|
@@ -598,7 +655,7 @@ function TrainingPagePopupInner(props) {
|
|
|
598
655
|
}
|
|
599
656
|
if (isChildrenMode) {
|
|
600
657
|
const maybe = buildChildrenResult();
|
|
601
|
-
setResultOverlay(
|
|
658
|
+
setResultOverlay(결과계산중);
|
|
602
659
|
const maybeAny = maybe;
|
|
603
660
|
Promise.resolve(maybeAny)
|
|
604
661
|
.then((data) => {
|
|
@@ -639,11 +696,16 @@ function TrainingPagePopupInner(props) {
|
|
|
639
696
|
setResultOverlay(maybeAny);
|
|
640
697
|
}
|
|
641
698
|
else {
|
|
642
|
-
setResultOverlay(maybeAny && typeof maybeAny.then === 'function' ?
|
|
699
|
+
setResultOverlay(maybeAny && typeof maybeAny.then === 'function' ? 결과계산중 : {});
|
|
643
700
|
}
|
|
701
|
+
// 결과가 비었거나 실패하면 막지 않는다(전과 같이 빈 창) — 계산 중 표시만 걷는다
|
|
702
|
+
const 계산중걷기 = () => { if (!늦음())
|
|
703
|
+
setResultOverlay((cur) => (cur === 결과계산중 ? {} : cur)); };
|
|
644
704
|
Promise.resolve(maybeAny).then((data) => { if (data && !늦음())
|
|
645
|
-
setResultOverlay(data);
|
|
646
|
-
|
|
705
|
+
setResultOverlay(data);
|
|
706
|
+
else
|
|
707
|
+
계산중걷기(); }).catch((err) => { if (import.meta.env?.DEV)
|
|
708
|
+
console.error('viewResults child handler failed', err); 계산중걷기(); });
|
|
647
709
|
return;
|
|
648
710
|
}
|
|
649
711
|
if (typeof externalOnResult === 'function') {
|
|
@@ -658,14 +720,18 @@ function TrainingPagePopupInner(props) {
|
|
|
658
720
|
setResultOverlay(maybeAny);
|
|
659
721
|
}
|
|
660
722
|
else {
|
|
661
|
-
setResultOverlay(maybeAny && typeof maybeAny.then === 'function' ?
|
|
723
|
+
setResultOverlay(maybeAny && typeof maybeAny.then === 'function' ? 결과계산중 : {});
|
|
662
724
|
}
|
|
725
|
+
const 계산중걷기 = () => { if (!늦음())
|
|
726
|
+
setResultOverlay((cur) => (cur === 결과계산중 ? {} : cur)); };
|
|
663
727
|
Promise.resolve(maybeAny).then((data) => { try {
|
|
664
728
|
if (import.meta && import.meta.env && import.meta.env.DEV)
|
|
665
729
|
console.debug('TrainingPagePopup: externalOnResult resolved data:', data);
|
|
666
730
|
}
|
|
667
731
|
catch (e) { } if (data && !늦음())
|
|
668
|
-
setResultOverlay(data);
|
|
732
|
+
setResultOverlay(data);
|
|
733
|
+
else
|
|
734
|
+
계산중걷기(); }).catch((err) => { console.error('viewResults external handler failed', err); 계산중걷기(); });
|
|
669
735
|
return;
|
|
670
736
|
}
|
|
671
737
|
};
|
|
@@ -716,16 +782,19 @@ function TrainingPagePopupInner(props) {
|
|
|
716
782
|
return;
|
|
717
783
|
}
|
|
718
784
|
};
|
|
719
|
-
|
|
785
|
+
// 보이는 제목의 폴백(헤더용) — 전과 같다
|
|
786
|
+
const visibleTitle = hasScenario
|
|
720
787
|
? scenarioMeta.title || scenarioMeta.label || ariaLabel || t('common.popup', '팝업')
|
|
721
788
|
: title || label || (Array.isArray(depth) ? depth[depth.length - 1] : undefined) || ariaLabel || t('common.popup', '팝업');
|
|
789
|
+
/* 대화상자 이름 — ariaLabel 을 주면 그것이 이긴다. 전에는 제목이 있으면 ariaLabel 이 무시됐다(제목이 없을 때만 썼다). */
|
|
790
|
+
const resolvedTitle = ariaLabel || visibleTitle;
|
|
722
791
|
// 헤더에 넘길 제목 — title이 '빈 문자열'로 명시돼 오면(이름 미입력) 그대로 넘긴다.
|
|
723
792
|
// ||로 폴백하면 타입 라벨이 채워져 depth 라벨과 같은 문구가 두 줄로 반복되므로,
|
|
724
793
|
// 비어 있다는 사실은 타이틀 컴포넌트가 안내 문구로 알린다.
|
|
725
|
-
// (resolvedTitle은 dialog aria-label용이라 그쪽은 폴백을 그대로
|
|
794
|
+
// (resolvedTitle은 dialog aria-label용이라 그쪽은 폴백을 그대로 유지한다 — 헤더는 visibleTitle 로.)
|
|
726
795
|
const titleForHeader = hasScenario
|
|
727
|
-
? (scenarioMeta.title ?? scenarioMeta.label ??
|
|
728
|
-
: (title ?? label ??
|
|
796
|
+
? (scenarioMeta.title ?? scenarioMeta.label ?? visibleTitle)
|
|
797
|
+
: (title ?? label ?? visibleTitle);
|
|
729
798
|
const renderScenarioStep = (step, index) => {
|
|
730
799
|
const pageId = getScenarioPageId(currentScenarioPage, effectivePageIndex);
|
|
731
800
|
const stepKey = getScenarioStepKey(step, index);
|
|
@@ -800,16 +869,7 @@ function TrainingPagePopupInner(props) {
|
|
|
800
869
|
let completeResult;
|
|
801
870
|
if (typeKey) {
|
|
802
871
|
const ro = resultOverlay;
|
|
803
|
-
const results
|
|
804
|
-
const total = Array.isArray(results) && results.length > 0 ? results.length : null;
|
|
805
|
-
// 결과 항목 형태가 훈련 타입마다 달라(예: 소음 하 듣기/문장 순서화는 correct 대신
|
|
806
|
-
// heard/judgement만 채움 — TrainingResultPopup의 resolveDecision과 동일 기준) 이
|
|
807
|
-
// 필드들도 확인해야 그 타입들의 정답 수가 항상 0으로 기록되는 것을 막을 수 있다.
|
|
808
|
-
const correct = Array.isArray(results)
|
|
809
|
-
? results.filter(r => r?.correct === true || r?.isCorrect === true || r?.score === 1 ||
|
|
810
|
-
r?.heard === true || r?.judgement === true).length
|
|
811
|
-
: null;
|
|
812
|
-
const finalCorrect = total != null ? correct : null;
|
|
872
|
+
const { results, correct: finalCorrect, total } = 판결과셈(resultOverlay);
|
|
813
873
|
/* 문항 하나하나를 함께 남긴다 — 여기서 접으면 어느 낱말을 틀렸는지 되찾을 길이 없다.
|
|
814
874
|
'음소별 오류'(08-26 회의)가 이걸 원재료로 쓴다. */
|
|
815
875
|
logTrainingCompletion({
|
|
@@ -824,7 +884,7 @@ function TrainingPagePopupInner(props) {
|
|
|
824
884
|
durationSeconds: Math.max(0, Math.round((Date.now() - completionStartRef.current) / 1000)),
|
|
825
885
|
dailyTotal: dailyTotal ?? null,
|
|
826
886
|
});
|
|
827
|
-
completeResult = { correct: finalCorrect, total };
|
|
887
|
+
completeResult = { correct: finalCorrect, total, attempt: 앞판들Ref.current.length + 1, previousAttempts: [...앞판들Ref.current] };
|
|
828
888
|
}
|
|
829
889
|
결과창닫기();
|
|
830
890
|
scheduleClose(() => {
|
|
@@ -852,6 +912,10 @@ function TrainingPagePopupInner(props) {
|
|
|
852
912
|
onReplay: () => {
|
|
853
913
|
// 다시 푸는 판의 소요 시간은 지금부터 — 마운트 때 한 번만 찍어 앞 판 시간·결과 창을 보던 시간까지 섞였다(60초가 660초)
|
|
854
914
|
completionStartRef.current = Date.now();
|
|
915
|
+
if (resultOverlay !== 결과계산중) {
|
|
916
|
+
const { correct, total } = 판결과셈(resultOverlay);
|
|
917
|
+
앞판들Ref.current.push({ correct, total });
|
|
918
|
+
}
|
|
855
919
|
결과창닫기();
|
|
856
920
|
setReplayCount((n) => n + 1);
|
|
857
921
|
if (hasScenario) {
|
|
@@ -876,6 +940,7 @@ function TrainingPagePopupInner(props) {
|
|
|
876
940
|
catch (e) { }
|
|
877
941
|
},
|
|
878
942
|
onClose: handleResultDone,
|
|
943
|
+
pending: resultOverlay === 결과계산중,
|
|
879
944
|
})), (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 })] }));
|
|
880
945
|
const portalContent = (_jsx(NoisePlayerContext.Provider, { value: noiseContextValue, children: jsx }));
|
|
881
946
|
if (typeof document !== 'undefined' && document.body)
|
|
@@ -6,6 +6,7 @@ import Button from '../Button';
|
|
|
6
6
|
import AudioMeter from '../AudioMeter';
|
|
7
7
|
import NoiseSettingsTooltip from './NoiseSettingsTooltip';
|
|
8
8
|
import { useNoisePlayer } from './NoisePlayerContext';
|
|
9
|
+
import { useFloatingFocus } from './useFloatingFocus';
|
|
9
10
|
import "./TrainingPagePopupBottom.css";
|
|
10
11
|
/** @public */
|
|
11
12
|
export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, onSettings, onNext, onResult, stepIndex = 0, stepCount = 0, pageIndex = 0, pageCount = 1, }) {
|
|
@@ -16,8 +17,10 @@ export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, on
|
|
|
16
17
|
const desktopRef = useRef(null);
|
|
17
18
|
const mobileRef = useRef(null);
|
|
18
19
|
const noisePlayer = useNoisePlayer();
|
|
19
|
-
|
|
20
|
-
const
|
|
20
|
+
/* 쪽이 없으면(0) 쪽 이동만 숨기고 끝내는 단추는 둔다 — 전에는 0 === -1 이라 단추가 하나도 없어 판을 끝낼 수 없었다 */
|
|
21
|
+
const 쪽있음 = pageCount > 0;
|
|
22
|
+
const useNextBtn = 쪽있음 && pageIndex < pageCount - 1;
|
|
23
|
+
const useResultBtn = !쪽있음 || pageIndex >= pageCount - 1;
|
|
21
24
|
const isBtnActive = stepIndex === Math.max(0, stepCount);
|
|
22
25
|
// 노이즈 설정 버튼이 비활성화되면 열려 있던 팝업을 닫는 의도된 동기화다
|
|
23
26
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
@@ -56,5 +59,15 @@ export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, on
|
|
|
56
59
|
setOpen(true);
|
|
57
60
|
}, [open, onSettings]);
|
|
58
61
|
const handleToggleMeters = useCallback(() => setShowMeters(prev => !prev), []);
|
|
59
|
-
|
|
62
|
+
/* 설정 창은 판 밖(body)으로 포털된다 — 열리면 초점을 창 첫 칸으로, 닫히면 연 단추로(useFloatingFocus 참고) */
|
|
63
|
+
const 창열림 = useNoiseSettingBtn && open;
|
|
64
|
+
useFloatingFocus(창열림, () => (typeof document !== 'undefined' ? document.querySelector('.noise-settings__header')?.closest('.tooltip-content') ?? null : null), () => {
|
|
65
|
+
const 보이는단추 = [desktopRef.current, mobileRef.current]
|
|
66
|
+
.map((w) => w?.querySelector('button') ?? null)
|
|
67
|
+
.find((b) => !!b && b.getClientRects().length > 0);
|
|
68
|
+
return 보이는단추 ?? desktopRef.current?.querySelector('button') ?? null;
|
|
69
|
+
});
|
|
70
|
+
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,
|
|
71
|
+
/* 여닫이 — aria-pressed(selected) 대신 aria-expanded. 보이는 선택 모양은 같은 클래스로 */
|
|
72
|
+
className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", 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, className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", 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
73
|
}
|
|
@@ -175,7 +175,20 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
175
175
|
handleEndRef,
|
|
176
176
|
});
|
|
177
177
|
useEffect(() => { stopTTSRef.current = stopTTS; });
|
|
178
|
-
const startTTS = (text) => {
|
|
178
|
+
const startTTS = (text) => {
|
|
179
|
+
/* 음성 합성이 없는 기기 — 훅이 onDone(false) 로 흐름에 알리고(훈련은 다음 단계로 간다), 여기서는 사용자에게 까닭을 보인다.
|
|
180
|
+
전에는 아무 표시 없이 들은 것으로 셌다. */
|
|
181
|
+
if (typeof window === 'undefined' || !window.speechSynthesis) {
|
|
182
|
+
try {
|
|
183
|
+
setNoticeMessage(t('training.ttsUnavailable', '이 기기에서는 음성을 들려줄 수 없습니다.'));
|
|
184
|
+
}
|
|
185
|
+
catch (_) { }
|
|
186
|
+
합성시작(text);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
소리잡기('tts');
|
|
190
|
+
합성시작(text);
|
|
191
|
+
};
|
|
179
192
|
const requestedSources = Array.isArray(soundSrc)
|
|
180
193
|
? soundSrc.filter(Boolean)
|
|
181
194
|
: (soundSrc ? [soundSrc] : []);
|
|
@@ -516,7 +529,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
516
529
|
a.src = src;
|
|
517
530
|
a.load();
|
|
518
531
|
await waitForCanPlay(a);
|
|
519
|
-
|
|
532
|
+
// 기다리는 사이 떠났으면(false) 이어 가지 않는다 — 떼어진 <audio> 를 틀던 것
|
|
533
|
+
if ((await waitForWaveReady()) === false)
|
|
534
|
+
return;
|
|
520
535
|
setIsFetchingUrl(false);
|
|
521
536
|
await tryPlayWithRetry(a);
|
|
522
537
|
setStateLocal('playMode');
|
|
@@ -674,7 +689,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
674
689
|
// (같은 기다림이 이 파일 위쪽에 waitForCanPlay 로 이미 있었다. 여기에 한 벌
|
|
675
690
|
// 더 적혀 있어, 기본 대기 시간을 고치면 한쪽만 바뀌는 자리였다.)
|
|
676
691
|
await waitForCanPlay(a);
|
|
677
|
-
await waitForWaveReady()
|
|
692
|
+
if ((await waitForWaveReady()) === false)
|
|
693
|
+
return false;
|
|
678
694
|
// 스피너는 waveform까지 준비된 뒤(재생 직전)에 해제 — 짧은 단어 파일의 경우
|
|
679
695
|
// 재생 중/후까지 스피너가 보이는 현상도, 스피너가 사라진 뒤 waveform이
|
|
680
696
|
// 뒤늦게 나타나는 빈 화면 구간도 생기지 않도록 한다.
|
|
@@ -739,18 +755,20 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
739
755
|
fetchingRef.current = false;
|
|
740
756
|
setIsFetchingUrl(false);
|
|
741
757
|
setStateLocal('active');
|
|
742
|
-
startTTS(ttsArr
|
|
758
|
+
startTTS(ttsArr.length > 0 ? ttsArr : undefined);
|
|
743
759
|
}
|
|
744
760
|
return;
|
|
745
761
|
}
|
|
746
|
-
// 리졸버 미설정 → 기존 speechSynthesis
|
|
747
|
-
startTTS(ttsArr
|
|
762
|
+
// 리졸버 미설정 → 기존 speechSynthesis(여러 문장이면 차례로 모두)
|
|
763
|
+
startTTS(ttsArr.length > 0 ? ttsArr : undefined);
|
|
748
764
|
return;
|
|
749
765
|
}
|
|
750
766
|
// playMode 중 일시정지/재개
|
|
751
767
|
if (stateLocal === 'playMode') {
|
|
752
|
-
|
|
753
|
-
|
|
768
|
+
/* 오디오 파일로 변환된 경우 audioRef로 제어. <audio> 는 늘 있으므로 그것만 보면 음성 합성으로 말하는 중(리졸버 없음·실패)에도
|
|
769
|
+
이 길로 들어가 소리 없는 <audio> 를 재생하려 들고 말은 멈추지 않았다 — 합성기가 말하는 중이고 <audio> 에 소리가 없으면 합성기를 멈춘다. */
|
|
770
|
+
const 합성으로말함 = !!synth && (synth.speaking || synth.paused) && !(a && (a.currentSrc || a.getAttribute('src')));
|
|
771
|
+
if (a && !합성으로말함) {
|
|
754
772
|
if (a.paused) {
|
|
755
773
|
// 전부 들은 뒤 다시 재생 — 마지막 트랙 이어듣기가 아니라 처음(1번)부터
|
|
756
774
|
// 다시 시작한다(일반 오디오 분기와 동일한 처리).
|
|
@@ -945,8 +963,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
945
963
|
markWaveNotReady();
|
|
946
964
|
a.src = src;
|
|
947
965
|
a.load();
|
|
948
|
-
if (!sameSegment)
|
|
949
|
-
|
|
966
|
+
if (!sameSegment && (await waitForWaveReady()) === false)
|
|
967
|
+
return;
|
|
950
968
|
if (myToken !== jumpTokenRef.current)
|
|
951
969
|
return;
|
|
952
970
|
await tryPlayWithRetry(a);
|
|
@@ -1147,7 +1165,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1147
1165
|
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" : ""}`, inert: !(stateLocal === "playMode" || displayedPlaying) || undefined, 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', '재생 / 멈춤'), "aria-pressed": displayedPlaying, 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 {
|
|
1148
1166
|
handleJumpTo(idx);
|
|
1149
1167
|
}
|
|
1150
|
-
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,
|
|
1168
|
+
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, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
|
|
1151
1169
|
/* 그림은 "문장 = 기준" — 문제 소리(M÷R)가 아니라 전체 소리 M 으로 문장을 그리고 소음을 R 배로 키운다 */
|
|
1152
1170
|
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 })] }));
|
|
1153
1171
|
}
|
|
@@ -36,6 +36,9 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
|
|
|
36
36
|
// ── 모두 듣기 — 카드별 소리 듣기 버튼을 순서대로 재생 ──
|
|
37
37
|
const cardRefs = useRef([]);
|
|
38
38
|
const [playingAll, setPlayingAll] = useState(false);
|
|
39
|
+
/* 모두 듣기를 멈추는 함수 — 도는 동안만 있다. 사용자가 카드의 '소리 듣기' 를 누르면 부른다: 전에는 누른 카드가 울리던 카드를
|
|
40
|
+
멈추게 했지만(currentSound) 멈춘 카드는 끝 신호가 안 와 차례가 8초 시한까지 기다렸다가 **다음 카드가 누른 카드를 끊고** 이어 울렸다. */
|
|
41
|
+
const 모두듣기멈춤Ref = useRef(null);
|
|
39
42
|
const 단계살아있음 = useRef(true);
|
|
40
43
|
useEffect(() => { 단계살아있음.current = true; return () => { 단계살아있음.current = false; }; }, []);
|
|
41
44
|
// 페이지 전환 시 (items 변경) 이전 선택 상태 및 카드 ref 초기화
|
|
@@ -77,18 +80,21 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
|
|
|
77
80
|
if (playingAll || currentState === 'disabled')
|
|
78
81
|
return;
|
|
79
82
|
setPlayingAll(true);
|
|
83
|
+
let 멈춤 = false;
|
|
84
|
+
const 멈춤신호 = new Promise((resolve) => { 모두듣기멈춤Ref.current = () => { 멈춤 = true; resolve(); }; });
|
|
80
85
|
try {
|
|
81
86
|
/* 매 차례 **지금의** 카드를 읽고, 떠났으면 멈춘다 — 시작할 때 잡아 둔 핸들로 돌면 떠난 화면의 다음 카드가 이어서 울렸다 */
|
|
82
87
|
for (let i = 0; i < shown.length; i++) {
|
|
83
|
-
if (!단계살아있음.current)
|
|
88
|
+
if (!단계살아있음.current || 멈춤)
|
|
84
89
|
break;
|
|
85
90
|
const cardRef = cardRefs.current[i];
|
|
86
91
|
if (!cardRef)
|
|
87
92
|
continue;
|
|
88
|
-
await withTimeout(cardRef.play(), PLAY_ALL_CARD_TIMEOUT_MS);
|
|
93
|
+
await Promise.race([withTimeout(cardRef.play(), PLAY_ALL_CARD_TIMEOUT_MS), 멈춤신호]);
|
|
89
94
|
}
|
|
90
95
|
}
|
|
91
96
|
finally {
|
|
97
|
+
모두듣기멈춤Ref.current = null;
|
|
92
98
|
setPlayingAll(false);
|
|
93
99
|
}
|
|
94
100
|
}, [playingAll, currentState, shown.length]);
|
|
@@ -122,5 +128,15 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
|
|
|
122
128
|
usePlayAllBtn: !hideAudio && shown.length > 0,
|
|
123
129
|
onPlayAll: handlePlayAll,
|
|
124
130
|
playAllActive: playingAll,
|
|
125
|
-
}, children: _jsx("div", { className: "training-step__cards",
|
|
131
|
+
}, children: _jsx("div", { className: "training-step__cards",
|
|
132
|
+
/* 모두 듣기 중 카드의 '소리 듣기' 를 누르면(마우스·Enter 모두 click) 모두 듣기를 멈춘다 — 누른 카드는 제 처리로 울린다.
|
|
133
|
+
캡처 단계에서 본다: 카드 단추는 선택 모드에서 전파를 막는다. */
|
|
134
|
+
onClickCapture: (e) => {
|
|
135
|
+
const 멈추기 = 모두듣기멈춤Ref.current;
|
|
136
|
+
if (!멈추기)
|
|
137
|
+
return;
|
|
138
|
+
const 누른곳 = e.target;
|
|
139
|
+
if (누른곳 && typeof 누른곳.closest === 'function' && 누른곳.closest('.training-word-card__bottom'))
|
|
140
|
+
멈추기();
|
|
141
|
+
}, children: shown.length === 0 ? (_jsx("div", { className: "training-step__empty", children: t('training.noCards', '카드가 없습니다.') })) : (shown.map((it, i) => (_jsx(TrainingWordCard, { ref: (el) => { cardRefs.current[i] = el; }, item: it, disabled: currentState === 'disabled', onPlayDone: () => handlePlayDone(it, i), selectable: selectable, selected: selectable && selectedIndex === i, onSelect: selectable ? () => handleSelect(it, i) : undefined, flipDelay: i * 120, hideLabel: hideLabel, hideAudio: hideAudio }, (it && it.id) || i)))) }) }));
|
|
126
142
|
}
|
|
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
5
5
|
import Button from '../Button';
|
|
6
6
|
import useMediaQuery from '../../hooks/useMediaQuery';
|
|
7
7
|
import TextBoxTooltip from '../Tooltip/TextBoxTooltip';
|
|
8
|
+
import { useFloatingFocus } from './useFloatingFocus';
|
|
8
9
|
import './TrainingStepTitle.css';
|
|
9
10
|
/** @public */
|
|
10
11
|
export default function TrainingStepTitle({ title, children, className = "", state = "disabled", usePreviewTextBtn = false, onPreviewTextBtn, previewText = null, useFaded, usePlayAllBtn = false, onPlayAll, playAllActive = false, }) {
|
|
@@ -17,6 +18,17 @@ export default function TrainingStepTitle({ title, children, className = "", sta
|
|
|
17
18
|
const [showPreview, setShowPreview] = useState(false);
|
|
18
19
|
const [anchorRect, setAnchorRect] = useState(null);
|
|
19
20
|
const [previewContent, setPreviewContent] = useState(null);
|
|
21
|
+
/* 미리보기 창은 판 밖(body)으로 포털된다 — 열리면 초점을 창 첫 칸으로, 닫히면 미리보기 단추로(useFloatingFocus 참고).
|
|
22
|
+
창은 늦게 연 것이 body 끝에 붙으므로 뒤에서부터 찾는다. */
|
|
23
|
+
useFloatingFocus(showPreview && !!anchorRect, () => {
|
|
24
|
+
if (typeof document === 'undefined')
|
|
25
|
+
return null;
|
|
26
|
+
const 창들 = Array.from(document.querySelectorAll('.tooltip-content'));
|
|
27
|
+
for (let i = 창들.length - 1; i >= 0; i--)
|
|
28
|
+
if (창들[i].querySelector('.tooltip-textbox__root'))
|
|
29
|
+
return 창들[i];
|
|
30
|
+
return null;
|
|
31
|
+
}, () => btnRef.current?.querySelector('button') ?? null);
|
|
20
32
|
// 인라인 화살표 함수로 넘어오는 경우가 있어 매 렌더 identity가 바뀐다.
|
|
21
33
|
// 아래 동기화 effect가 그때마다 재실행되지 않도록 ref로 최신 값만 들고 간다.
|
|
22
34
|
/* 갱신은 렌더가 아니라 효과에서 한다 — 렌더 중에 쓰면 **커밋되지 않을 렌더의 값**이
|
|
@@ -90,5 +102,7 @@ export default function TrainingStepTitle({ title, children, className = "", sta
|
|
|
90
102
|
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,
|
|
91
103
|
// 재생 중에는 native disabled 를 달지 않는다 — 키보드로 누른 단추에 disabled 가 붙으면 초점이 body 로 빠졌다.
|
|
92
104
|
// onDisabledClick 을 주면 Button 은 aria-disabled 로만 막고 클릭은 여기서 버린다.
|
|
93
|
-
onDisabledClick: playAllActive && state !== 'disabled' ? () => { } : undefined })), 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', '문장 미리보기'),
|
|
105
|
+
onDisabledClick: playAllActive && state !== 'disabled' ? () => { } : undefined })), 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', '문장 미리보기'), onClick: handlePreviewButtonClick,
|
|
106
|
+
/* 여닫이 — aria-pressed(selected) 대신 aria-expanded. 보이는 선택 모양은 같은 클래스로 */
|
|
107
|
+
className: showPreview ? 'is-selected' : undefined, "aria-expanded": showPreview, "aria-haspopup": "dialog", 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] }));
|
|
94
108
|
}
|
|
@@ -162,6 +162,21 @@
|
|
|
162
162
|
background: linear-gradient(160deg, #f0f2f4 0%, #e4e8ea 100%);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/* 글자 카드 — 이미지가 있을 수 없는 글자(사전에 없는 음절)를 그림 자리에 크게. 색은 낱말 글자와 같다 */
|
|
166
|
+
.training-word-card__text-face {
|
|
167
|
+
width: 100%;
|
|
168
|
+
height: 100%;
|
|
169
|
+
display: flex;
|
|
170
|
+
align-items: center;
|
|
171
|
+
justify-content: center;
|
|
172
|
+
font-size: 48px;
|
|
173
|
+
font-weight: 700;
|
|
174
|
+
line-height: 1;
|
|
175
|
+
color: var(--color-black, #0b2b35);
|
|
176
|
+
word-break: keep-all;
|
|
177
|
+
text-align: center;
|
|
178
|
+
}
|
|
179
|
+
|
|
165
180
|
/* ── Loading spinner ── */
|
|
166
181
|
.training-word-card__spinner {
|
|
167
182
|
position: absolute;
|
|
@@ -260,6 +275,7 @@
|
|
|
260
275
|
aspect-ratio: 1 / 1;
|
|
261
276
|
}
|
|
262
277
|
.training-word-card__word { font-size: 14px; }
|
|
278
|
+
.training-word-card__text-face { font-size: 32px; }
|
|
263
279
|
.training-word-card__pronunciation { font-size: 11px; }
|
|
264
280
|
.training-word-card__body { gap: 2px; }
|
|
265
281
|
.training-word-card__front { gap: 4px; }
|
|
@@ -14,6 +14,12 @@ export type TrainingWordCardItem = {
|
|
|
14
14
|
subLabel?: string;
|
|
15
15
|
colloquialLabel?: string;
|
|
16
16
|
homonum?: number;
|
|
17
|
+
/**
|
|
18
|
+
* 이미지 없이 **글자 카드**로 보인다(이미지를 청하지 않고, `hideLabel` 이어도 글자를 그림 자리에 크게 보인다).
|
|
19
|
+
* 안 주면 단어 풀(setWordPool)이 불려 있고 그 풀에 없는 낱말일 때 저절로 글자 카드다 — 받침·자음 탈락의 만든 음절('다'·'애')처럼
|
|
20
|
+
* 사전에 없는 글자는 이미지가 있을 수 없다. `false` 면 풀에 없어도 이미지를 청한다. `image` 를 직접 주면 그것을 쓴다.
|
|
21
|
+
*/
|
|
22
|
+
textOnly?: boolean;
|
|
17
23
|
[k: string]: unknown;
|
|
18
24
|
};
|
|
19
25
|
/** @public */
|
|
@@ -7,6 +7,7 @@ import Button from '../Button';
|
|
|
7
7
|
import Badge from '../Badge';
|
|
8
8
|
import useMediaQuery from '../../hooks/useMediaQuery';
|
|
9
9
|
import { getImageResolver } from '../../utils/imageConfig';
|
|
10
|
+
import { getWordPool } from '../../utils/wordPool';
|
|
10
11
|
import { useWordImage } from '../../hooks/useWordImage';
|
|
11
12
|
import { getTTSUrlResolver, getTTSFallbackUrlResolver } from '../../utils/ttsConfig';
|
|
12
13
|
import { claimSound, stopSoundIfOwner } from './currentSound';
|
|
@@ -43,8 +44,12 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
43
44
|
const audioFor = `${typeof rawAudioSrc === 'string' ? rawAudioSrc : rawAudioSrc?.src ?? ''}\u0000${label}`;
|
|
44
45
|
const [prepared, setPrepared] = useState({ for: '', url: '' });
|
|
45
46
|
const audioUrl = prepared.for === audioFor ? prepared.url : '';
|
|
47
|
+
// ── 글자 카드 ── 사전(단어 풀)에 없는 글자는 이미지가 있을 수 없다. 전에는 없는 이미지를 받으려다 실패해,
|
|
48
|
+
// 글자까지 숨긴(hideLabel) 탈락 카드가 빈 칸이 됐다(받침·자음 탈락의 만든 음절 '다'·'애').
|
|
49
|
+
const pool = getWordPool();
|
|
50
|
+
const textCard = item.textOnly ?? (!item.image && !!front.label && !!pool && pool.entries.length > 0 && !pool.byWord.has(front.label));
|
|
46
51
|
// ── 이미지 ── (앞면 유지값 기준 — 플립 동안 이미지도 유지)
|
|
47
|
-
const imageUrl = getImageResolver()?.(front.label, front.homonum) ?? item.image ?? '';
|
|
52
|
+
const imageUrl = textCard ? '' : (getImageResolver()?.(front.label, front.homonum) ?? item.image ?? '');
|
|
48
53
|
const { imgRef, loading: imgLoading, hasImage, onLoad: handleImgLoad, onError: handleImgError } = useWordImage(imageUrl);
|
|
49
54
|
// ── 오디오 URL 준비 ──
|
|
50
55
|
useEffect(() => {
|
|
@@ -256,6 +261,6 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
256
261
|
onSelect?.();
|
|
257
262
|
}
|
|
258
263
|
}
|
|
259
|
-
: 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 }) })] }) }));
|
|
264
|
+
: 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: textCard ? (_jsx("div", { className: "training-word-card__text-face", children: front.label })) : 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: [!textCard && _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 }) })] }) }));
|
|
260
265
|
});
|
|
261
266
|
export default TrainingWordCard;
|
|
@@ -49,11 +49,11 @@ export interface TrainingBlockButtonProps {
|
|
|
49
49
|
completedIds?: Set<string>;
|
|
50
50
|
/** 선택 상태 ID Set. 포함된 항목(완료가 아닌 것)에 체크 배지를 표시 — 완료와 같은 배지이고, `hideCheckBadge` 면 숨는다. */
|
|
51
51
|
selectedIds?: Set<string>;
|
|
52
|
-
/** 호버 시 표시할 문제/문장 텍스트.
|
|
52
|
+
/** 호버 시 표시할 문제/문장 텍스트. `useDescriptionTooltip` 이면 DescriptionTooltip 으로, 아니면 블록의 `title`(가리키면 뜨고 낭독기엔 설명)로 준다. */
|
|
53
53
|
tooltipText?: string | string[];
|
|
54
54
|
/** true 이면 호버 시 DescriptionTooltip 으로 문제 텍스트를 표시. 기본값 false. */
|
|
55
55
|
useDescriptionTooltip?: boolean;
|
|
56
|
-
/** 제공 시
|
|
56
|
+
/** 제공 시 뱃지 자리에 X 버튼 표시 — 가리킬 수 있는 기기는 hover 중에만, 터치 기기(hover: none)는 늘. 클릭하면 호출됨. */
|
|
57
57
|
onRemove?: () => void;
|
|
58
58
|
/** true 이면 완료/선택 체크 뱃지를 표시하지 않음 */
|
|
59
59
|
hideCheckBadge?: boolean;
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
* 소요가 있다) · **세션 시간**(둘 다 없다). 무엇을 볼지는 읽는 쪽이 정한다.
|
|
23
23
|
*/
|
|
24
24
|
export interface TrainingResultRecord {
|
|
25
|
+
/**
|
|
26
|
+
* 행 식별자(선택). `answeredAt` 이 같은 행(한 판을 한꺼번에 저장해 시각이 같은 행)의 **앞뒤를 가르는 데만** 쓴다 —
|
|
27
|
+
* 숫자면 수가 큰 쪽, 글이면 사전순으로 뒤인 쪽이 최근이다. 없으면 넘어온 배열에서 뒤에 있는 쪽이 최근이다.
|
|
28
|
+
*/
|
|
29
|
+
id?: string | number | null;
|
|
25
30
|
answeredAt: string;
|
|
26
31
|
trainingType: string;
|
|
27
32
|
/** 문항 식별자 — 코드가 있는 유형은 코드, 없는 유형(낱말)은 낱말 자체 */
|
|
@@ -77,6 +82,11 @@ export interface MissSummary {
|
|
|
77
82
|
* 정렬은 세 겹이다. 정답률이 낮은 것이 먼저이되, **같은 정답률이면 여러 번 푼 것을
|
|
78
83
|
* 앞에 둔다** — 한 번 틀린 것과 다섯 번 내리 틀린 것은 무게가 다르다. 그래도 같으면
|
|
79
84
|
* 글자순으로 못 박는다(그러지 않으면 다시 읽을 때마다 차례가 흔들려 보던 줄을 놓친다).
|
|
85
|
+
*
|
|
86
|
+
* **"최근"(latestCorrect·latestAt·lastSelected·setIds 앞자리)은 `answeredAt` 으로 가린다.** 시각이 같은 행끼리는
|
|
87
|
+
* 둘 다 `id` 가 있으면 `id` 가 큰 쪽(숫자는 수 크기, 글은 사전순)이 최근이고, 그렇지 않으면 **넘어온 배열에서
|
|
88
|
+
* 뒤에 있는 쪽**이 최근이다 — 즉 `id` 없이 넘길 때는 **시간순(옛것 먼저)** 으로 넘겨야 같은 시각의 앞뒤가 맞다.
|
|
89
|
+
* (최신 먼저 넘기면 같은 시각 행의 최근이 뒤집힌다. 시각이 다른 행은 차례와 상관없이 맞다.)
|
|
80
90
|
*/
|
|
81
91
|
export declare function summarizeMisses(records: readonly TrainingResultRecord[]): MissSummary;
|
|
82
92
|
/**
|