@ihabdevteam/core 0.150.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.
Files changed (217) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/components.cjs.js +199 -186
  3. package/dist/components.esm.css +5 -5
  4. package/dist/hooks.cjs.js +5 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core-tokens.css +1 -1
  7. package/dist/ihabdevteam-core.cjs.js +200 -187
  8. package/dist/ihabdevteam-core.css +1 -1
  9. package/dist/ihabdevteam-core.d.ts +405 -67
  10. package/dist/packages/core/src/components/Accordion.d.ts +6 -0
  11. package/dist/packages/core/src/components/Accordion.js +5 -2
  12. package/dist/packages/core/src/components/Alert.d.ts +7 -1
  13. package/dist/packages/core/src/components/Alert.js +12 -3
  14. package/dist/packages/core/src/components/BarChart.js +7 -2
  15. package/dist/packages/core/src/components/Board.d.ts +5 -2
  16. package/dist/packages/core/src/components/Board.js +9 -3
  17. package/dist/packages/core/src/components/BottomNav.js +5 -4
  18. package/dist/packages/core/src/components/Breadcrumb.css +9 -0
  19. package/dist/packages/core/src/components/Breadcrumb.d.ts +12 -1
  20. package/dist/packages/core/src/components/Breadcrumb.js +8 -2
  21. package/dist/packages/core/src/components/BulkBar.css +2 -0
  22. package/dist/packages/core/src/components/BulkBar.js +21 -2
  23. package/dist/packages/core/src/components/Button.d.ts +12 -2
  24. package/dist/packages/core/src/components/Button.js +70 -13
  25. package/dist/packages/core/src/components/Calendar.d.ts +6 -1
  26. package/dist/packages/core/src/components/Calendar.js +3 -3
  27. package/dist/packages/core/src/components/DatePicker.d.ts +7 -1
  28. package/dist/packages/core/src/components/DatePicker.js +25 -15
  29. package/dist/packages/core/src/components/Dropdown.d.ts +1 -0
  30. package/dist/packages/core/src/components/Dropdown.js +7 -5
  31. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.d.ts +12 -1
  32. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +25 -8
  33. package/dist/packages/core/src/components/EmptyState.d.ts +5 -1
  34. package/dist/packages/core/src/components/EmptyState.js +4 -1
  35. package/dist/packages/core/src/components/ErrorState.d.ts +5 -2
  36. package/dist/packages/core/src/components/ErrorState.js +3 -1
  37. package/dist/packages/core/src/components/FileUpload.d.ts +9 -1
  38. package/dist/packages/core/src/components/FileUpload.js +8 -2
  39. package/dist/packages/core/src/components/FilterSectionInfo.js +14 -2
  40. package/dist/packages/core/src/components/Header.js +8 -5
  41. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -1
  42. package/dist/packages/core/src/components/Icon.d.ts +2 -1
  43. package/dist/packages/core/src/components/Icon.js +4 -1
  44. package/dist/packages/core/src/components/Loader.css +28 -0
  45. package/dist/packages/core/src/components/Loader.d.ts +5 -1
  46. package/dist/packages/core/src/components/Loader.js +4 -2
  47. package/dist/packages/core/src/components/NoticeBox.d.ts +1 -1
  48. package/dist/packages/core/src/components/NoticeBox.js +15 -6
  49. package/dist/packages/core/src/components/PanelRow.js +2 -1
  50. package/dist/packages/core/src/components/PassCard/PassCard.d.ts +8 -2
  51. package/dist/packages/core/src/components/PassCard/PassCard.js +25 -10
  52. package/dist/packages/core/src/components/PassCard/passCardProduct.d.ts +1 -1
  53. package/dist/packages/core/src/components/PassCard/passCardProduct.js +11 -2
  54. package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +11 -2
  55. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +18 -3
  56. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +10 -8
  57. package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
  58. package/dist/packages/core/src/components/Popup/LoadTablePopup.css +7 -0
  59. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +27 -5
  60. package/dist/packages/core/src/components/Popup/Popup.d.ts +8 -0
  61. package/dist/packages/core/src/components/Popup/Popup.js +6 -3
  62. package/dist/packages/core/src/components/Popup/SaveTablePopup.d.ts +5 -1
  63. package/dist/packages/core/src/components/Popup/TrainingResultPopup.css +3 -0
  64. package/dist/packages/core/src/components/Popup/TrainingResultPopup.d.ts +5 -0
  65. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +10 -4
  66. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.css +9 -0
  67. package/dist/packages/core/src/components/ProgressIndicator.css +5 -0
  68. package/dist/packages/core/src/components/ProgressIndicator.d.ts +3 -1
  69. package/dist/packages/core/src/components/ProgressIndicator.js +5 -3
  70. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.d.ts +4 -1
  71. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +27 -15
  72. package/dist/packages/core/src/components/Rating.d.ts +8 -2
  73. package/dist/packages/core/src/components/Rating.js +22 -8
  74. package/dist/packages/core/src/components/Report/ReportDocument.d.ts +10 -7
  75. package/dist/packages/core/src/components/Report/ReportDocument.js +39 -13
  76. package/dist/packages/core/src/components/RingChart.d.ts +1 -1
  77. package/dist/packages/core/src/components/RingChart.js +1 -1
  78. package/dist/packages/core/src/components/SegmentedControl.d.ts +4 -0
  79. package/dist/packages/core/src/components/SegmentedControl.js +9 -1
  80. package/dist/packages/core/src/components/ShortcutBadge.css +16 -1
  81. package/dist/packages/core/src/components/ShortcutBadge.d.ts +1 -0
  82. package/dist/packages/core/src/components/SideNav.js +8 -2
  83. package/dist/packages/core/src/components/SummaryStrip.d.ts +1 -1
  84. package/dist/packages/core/src/components/SummaryStrip.js +2 -2
  85. package/dist/packages/core/src/components/Table.d.ts +1 -1
  86. package/dist/packages/core/src/components/Table.js +4 -1
  87. package/dist/packages/core/src/components/Tabs.d.ts +1 -0
  88. package/dist/packages/core/src/components/Tabs.js +4 -1
  89. package/dist/packages/core/src/components/TextField.css +5 -6
  90. package/dist/packages/core/src/components/TextField.js +46 -3
  91. package/dist/packages/core/src/components/Toast.js +16 -2
  92. package/dist/packages/core/src/components/Tooltip/Tooltip.css +3 -1
  93. package/dist/packages/core/src/components/Tooltip/Tooltip.js +5 -2
  94. package/dist/packages/core/src/components/Tooltip/tooltipConstants.d.ts +10 -2
  95. package/dist/packages/core/src/components/Tooltip/tooltipConstants.js +10 -2
  96. package/dist/packages/core/src/components/TopBanner.d.ts +1 -1
  97. package/dist/packages/core/src/components/TopBanner.js +66 -8
  98. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.d.ts +1 -0
  99. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +65 -15
  100. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +3 -2
  101. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +10 -4
  102. package/dist/packages/core/src/components/Training/TrainingBlockButton.css +7 -0
  103. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
  104. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +18 -5
  105. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +93 -28
  106. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +1 -0
  107. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +16 -3
  108. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +25 -9
  109. package/dist/packages/core/src/components/Training/TrainingStepCard.js +19 -3
  110. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +15 -1
  111. package/dist/packages/core/src/components/Training/TrainingWordCard.css +16 -0
  112. package/dist/packages/core/src/components/Training/TrainingWordCard.d.ts +6 -0
  113. package/dist/packages/core/src/components/Training/TrainingWordCard.js +7 -2
  114. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +2 -2
  115. package/dist/packages/core/src/components/Training/trainingMissStats.d.ts +10 -0
  116. package/dist/packages/core/src/components/Training/trainingMissStats.js +19 -1
  117. package/dist/packages/core/src/components/Training/useFloatingFocus.d.ts +13 -0
  118. package/dist/packages/core/src/components/Training/useFloatingFocus.js +59 -0
  119. package/dist/packages/core/src/components/Training/useNoiseFade.js +20 -11
  120. package/dist/packages/core/src/components/Training/useProgressTicker.d.ts +0 -18
  121. package/dist/packages/core/src/components/Training/useProgressTicker.js +59 -14
  122. package/dist/packages/core/src/components/Training/useTTSPlayback.d.ts +1 -1
  123. package/dist/packages/core/src/components/Training/useTTSPlayback.js +56 -25
  124. package/dist/packages/core/src/components/Training/useWaveReady.d.ts +4 -1
  125. package/dist/packages/core/src/components/Training/useWaveReady.js +17 -8
  126. package/dist/packages/core/src/components/UpdateAlert.css +4 -2
  127. package/dist/packages/core/src/components/UpdateAlert.d.ts +3 -1
  128. package/dist/packages/core/src/components/UpdateAlert.js +21 -1
  129. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.d.ts +2 -0
  130. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +12 -4
  131. package/dist/packages/core/src/components/Word/WordSearchResultBlock.d.ts +6 -0
  132. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +26 -13
  133. package/dist/packages/core/src/components/Word/WordTableBlock.js +24 -21
  134. package/dist/packages/core/src/components/Word/WordTableThumbnail.js +1 -1
  135. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -10
  136. package/dist/packages/core/src/components/Word/useWordTablePrint.d.ts +1 -1
  137. package/dist/packages/core/src/components/Word/useWordTablePrint.js +9 -3
  138. package/dist/packages/core/src/components/Word/wordScoreStats.d.ts +14 -1
  139. package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -3
  140. package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -3
  141. package/dist/packages/core/src/components/Word/wordTableReview.d.ts +58 -1
  142. package/dist/packages/core/src/components/Word/wordTableReview.js +75 -17
  143. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +9 -0
  144. package/dist/packages/core/src/components/Word/wordTableSettings.js +11 -0
  145. package/dist/packages/core/src/components/WordTable.css +10 -0
  146. package/dist/packages/core/src/components/WordTable.js +39 -26
  147. package/dist/packages/core/src/components/barChartTypes.d.ts +2 -2
  148. package/dist/packages/core/src/components/buttonTypes.d.ts +8 -0
  149. package/dist/packages/core/src/components/updateAlertStack.d.ts +2 -0
  150. package/dist/packages/core/src/components/updateAlertStack.js +68 -0
  151. package/dist/packages/core/src/components/useCalendarMonth.d.ts +4 -1
  152. package/dist/packages/core/src/components/useCalendarMonth.js +6 -3
  153. package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +7 -0
  154. package/dist/packages/core/src/components/useComboboxSuggestions.js +21 -2
  155. package/dist/packages/core/src/components/wordTableLayout.d.ts +4 -0
  156. package/dist/packages/core/src/components/wordTableLayout.js +6 -0
  157. package/dist/packages/core/src/components.d.ts +2 -1
  158. package/dist/packages/core/src/components.js +1 -1
  159. package/dist/packages/core/src/hooks/bodyScrollLock.js +8 -2
  160. package/dist/packages/core/src/hooks/focusReturn.d.ts +12 -0
  161. package/dist/packages/core/src/hooks/focusReturn.js +49 -0
  162. package/dist/packages/core/src/hooks/touchToggle.d.ts +22 -0
  163. package/dist/packages/core/src/hooks/touchToggle.js +114 -0
  164. package/dist/packages/core/src/hooks/useAnchoredMenu.d.ts +12 -3
  165. package/dist/packages/core/src/hooks/useAnchoredMenu.js +37 -3
  166. package/dist/packages/core/src/hooks/useDailyTrainingSelection.d.ts +4 -2
  167. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +23 -11
  168. package/dist/packages/core/src/hooks/useIcon.js +29 -5
  169. package/dist/packages/core/src/hooks/useLucideIcon.d.ts +4 -0
  170. package/dist/packages/core/src/hooks/useLucideIcon.js +4 -0
  171. package/dist/packages/core/src/hooks/usePendingPress.d.ts +9 -0
  172. package/dist/packages/core/src/hooks/usePendingPress.js +26 -0
  173. package/dist/packages/core/src/hooks/usePrefersReducedMotion.d.ts +15 -0
  174. package/dist/packages/core/src/hooks/usePrefersReducedMotion.js +26 -0
  175. package/dist/packages/core/src/hooks/useTablePaging.d.ts +27 -2
  176. package/dist/packages/core/src/hooks/useTablePaging.js +37 -5
  177. package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +7 -0
  178. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +25 -0
  179. package/dist/packages/core/src/i18n/index.js +9 -1
  180. package/dist/packages/core/src/i18n/locales/en.json +24 -2
  181. package/dist/packages/core/src/i18n/locales/ko.json +24 -2
  182. package/dist/packages/core/src/tokens.css +5 -0
  183. package/dist/packages/core/src/types/wordSearch.d.ts +12 -0
  184. package/dist/packages/core/src/utils/csv.d.ts +7 -2
  185. package/dist/packages/core/src/utils/csv.js +12 -5
  186. package/dist/packages/core/src/utils/dailySelectionUtils.js +44 -14
  187. package/dist/packages/core/src/utils/date.d.ts +8 -0
  188. package/dist/packages/core/src/utils/date.js +44 -10
  189. package/dist/packages/core/src/utils/format.d.ts +1 -1
  190. package/dist/packages/core/src/utils/format.js +6 -7
  191. package/dist/packages/core/src/utils/noiseSettingsStore.d.ts +7 -3
  192. package/dist/packages/core/src/utils/phonemeConvert.d.ts +9 -0
  193. package/dist/packages/core/src/utils/phonemeConvert.js +11 -0
  194. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +7 -1
  195. package/dist/packages/core/src/utils/phonemePairSearch.js +45 -21
  196. package/dist/packages/core/src/utils/safeHref.d.ts +8 -0
  197. package/dist/packages/core/src/utils/safeHref.js +24 -0
  198. package/dist/packages/core/src/utils/savedWordTables.d.ts +19 -3
  199. package/dist/packages/core/src/utils/savedWordTables.js +27 -19
  200. package/dist/packages/core/src/utils/textSort.d.ts +9 -0
  201. package/dist/packages/core/src/utils/textSort.js +37 -1
  202. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +31 -0
  203. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +163 -0
  204. package/dist/packages/core/src/utils/wordContrast.d.ts +4 -2
  205. package/dist/packages/core/src/utils/wordContrast.js +8 -2
  206. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +5 -2
  207. package/dist/packages/core/src/utils/wordTrainingGenerators.js +52 -2
  208. package/dist/packages/core/src/utils/wordTrainingPool.js +2 -2
  209. package/dist/packages/core/src/utils/wordTrainingRuntime.js +15 -7
  210. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.d.ts +1 -1
  211. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.js +5 -4
  212. package/dist/packages/core/src/utils/wornDate.d.ts +12 -0
  213. package/dist/packages/core/src/utils/wornDate.js +15 -0
  214. package/dist/packages/core/src/version.d.ts +1 -1
  215. package/dist/packages/core/src/version.js +1 -1
  216. package/dist/utils.cjs.js +2 -2
  217. 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
- // renderResultPopup 미지정 시 기본 결과 팝업 — 모든 훈련 타입 공통 폴백
33
- const defaultRenderResult = (p) => _jsx(TrainingResultPopup, { score: p.score, contextsLength: p.contextsLength, results: p.results, onReplay: p.onReplay, onClose: p.onClose });
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
- saveNoiseSettings(merged);
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); }).catch((err) => { if (import.meta.env?.DEV)
646
- console.error('viewResults child handler failed', err); });
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); }).catch((err) => { console.error('viewResults external handler failed', err); });
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
- const resolvedTitle = hasScenario
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 ?? resolvedTitle)
728
- : (title ?? label ?? resolvedTitle);
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 = (ro?.results || ro?.answers || []);
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)
@@ -8,6 +8,7 @@ export type TrainingPagePopupBottomProps = {
8
8
  stepIndex?: number;
9
9
  stepCount?: number;
10
10
  pageIndex?: number;
11
+ /** 쪽 수. 0(쪽이 없음)이어도 '결과보기'(완료) 단추는 보이고, '다음 문제'(쪽 이동)만 숨는다. 기본 1. */
11
12
  pageCount?: number;
12
13
  };
13
14
  /** @public */
@@ -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
- const useNextBtn = pageIndex < pageCount - 1;
20
- const useResultBtn = pageIndex === pageCount - 1;
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
- 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 }))] }));
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) => { 소리잡기('tts'); 합성시작(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
- await waitForWaveReady();
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,12 +755,12 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
739
755
  fetchingRef.current = false;
740
756
  setIsFetchingUrl(false);
741
757
  setStateLocal('active');
742
- startTTS(ttsArr[0] || undefined);
758
+ startTTS(ttsArr.length > 0 ? ttsArr : undefined);
743
759
  }
744
760
  return;
745
761
  }
746
- // 리졸버 미설정 → 기존 speechSynthesis
747
- startTTS(ttsArr[0] || undefined);
762
+ // 리졸버 미설정 → 기존 speechSynthesis(여러 문장이면 차례로 모두)
763
+ startTTS(ttsArr.length > 0 ? ttsArr : undefined);
748
764
  return;
749
765
  }
750
766
  // playMode 중 일시정지/재개
@@ -947,8 +963,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
947
963
  markWaveNotReady();
948
964
  a.src = src;
949
965
  a.load();
950
- if (!sameSegment)
951
- await waitForWaveReady();
966
+ if (!sameSegment && (await waitForWaveReady()) === false)
967
+ return;
952
968
  if (myToken !== jumpTokenRef.current)
953
969
  return;
954
970
  await tryPlayWithRetry(a);
@@ -1149,7 +1165,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1149
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 {
1150
1166
  handleJumpTo(idx);
1151
1167
  }
1152
- 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,
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,
1153
1169
  /* 그림은 "문장 = 기준" — 문제 소리(M÷R)가 아니라 전체 소리 M 으로 문장을 그리고 소음을 R 배로 키운다 */
1154
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 })] }));
1155
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", 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)))) }) }));
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', '문장 미리보기'), 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] }));
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
- /** 호버 시 표시할 문제/문장 텍스트. 제공 시 DescriptionTooltip 으로 렌더. */
52
+ /** 호버 시 표시할 문제/문장 텍스트. `useDescriptionTooltip` 이면 DescriptionTooltip 으로, 아니면 블록의 `title`(가리키면 뜨고 낭독기엔 설명)로 준다. */
53
53
  tooltipText?: string | string[];
54
54
  /** true 이면 호버 시 DescriptionTooltip 으로 문제 텍스트를 표시. 기본값 false. */
55
55
  useDescriptionTooltip?: boolean;
56
- /** 제공 시 hover 중 뱃지 자리에 X 버튼 표시 — 클릭하면 호출됨. */
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
  /**