@ihabdevteam/core 0.322.0 → 0.323.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 (196) hide show
  1. package/CHANGELOG.md +517 -0
  2. package/dist/components.cjs.js +183 -183
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +187 -187
  7. package/dist/ihabdevteam-core.d.ts +54 -16
  8. package/dist/packages/core/src/components/Accordion.js +3 -3
  9. package/dist/packages/core/src/components/Alert.js +2 -2
  10. package/dist/packages/core/src/components/BarChart.js +120 -119
  11. package/dist/packages/core/src/components/BottomNav.js +18 -18
  12. package/dist/packages/core/src/components/BulkBar.js +25 -25
  13. package/dist/packages/core/src/components/Button.js +16 -16
  14. package/dist/packages/core/src/components/Calendar.js +17 -17
  15. package/dist/packages/core/src/components/ChartSection.js +30 -30
  16. package/dist/packages/core/src/components/CountTile.js +38 -38
  17. package/dist/packages/core/src/components/DatePicker.js +59 -59
  18. package/dist/packages/core/src/components/Dialog.js +14 -14
  19. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
  20. package/dist/packages/core/src/components/FilterPanel.js +6 -6
  21. package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
  22. package/dist/packages/core/src/components/GameIntro.js +1 -1
  23. package/dist/packages/core/src/components/Icon.js +2 -2
  24. package/dist/packages/core/src/components/ItemActions.js +16 -16
  25. package/dist/packages/core/src/components/LandingHeader.js +6 -6
  26. package/dist/packages/core/src/components/LineChart.js +24 -24
  27. package/dist/packages/core/src/components/Loader.js +5 -5
  28. package/dist/packages/core/src/components/NoticeBox.js +3 -3
  29. package/dist/packages/core/src/components/PageTitle.js +2 -2
  30. package/dist/packages/core/src/components/Pagination.js +4 -4
  31. package/dist/packages/core/src/components/Panel.js +2 -2
  32. package/dist/packages/core/src/components/PanelRow.js +6 -13
  33. package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
  34. package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
  35. package/dist/packages/core/src/components/PaymentPlan.js +5 -5
  36. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
  37. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
  38. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +18 -25
  39. package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
  40. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +7 -7
  41. package/dist/packages/core/src/components/Popup/Popup.js +27 -27
  42. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
  43. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
  44. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
  45. package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
  46. package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
  47. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
  48. package/dist/packages/core/src/components/Rating.js +20 -20
  49. package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
  50. package/dist/packages/core/src/components/RingChart.js +5 -5
  51. package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
  52. package/dist/packages/core/src/components/SegmentedControl.js +34 -34
  53. package/dist/packages/core/src/components/SideNav.js +37 -37
  54. package/dist/packages/core/src/components/Slider.js +2 -2
  55. package/dist/packages/core/src/components/SoundCompass.js +5 -5
  56. package/dist/packages/core/src/components/Table.js +257 -98
  57. package/dist/packages/core/src/components/Tabs.js +12 -12
  58. package/dist/packages/core/src/components/TextField.js +34 -34
  59. package/dist/packages/core/src/components/Toast.js +6 -6
  60. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
  61. package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
  62. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
  63. package/dist/packages/core/src/components/TopBanner.js +28 -28
  64. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
  65. package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
  66. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
  67. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
  68. package/dist/packages/core/src/components/Training/TrainingBlock.js +11 -11
  69. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
  70. package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
  71. package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
  72. package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
  73. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
  74. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +81 -81
  75. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
  76. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +127 -127
  77. package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
  78. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
  79. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
  80. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +12 -12
  81. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
  82. package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
  83. package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
  84. package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
  85. package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
  86. package/dist/packages/core/src/components/Training/oneShotSound.js +13 -13
  87. package/dist/packages/core/src/components/Training/trainingMissStats.js +20 -20
  88. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
  89. package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
  90. package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
  91. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
  92. package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
  93. package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
  94. package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
  95. package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
  96. package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
  97. package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
  98. package/dist/packages/core/src/components/UpdateAlert.js +6 -6
  99. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
  100. package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
  101. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +10 -10
  102. package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
  103. package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
  104. package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
  105. package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
  106. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
  107. package/dist/packages/core/src/components/Word/wordScoreStats.js +15 -15
  108. package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
  109. package/dist/packages/core/src/components/Word/wordTableRecommend.js +11 -11
  110. package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
  111. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
  112. package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
  113. package/dist/packages/core/src/components/WordTable.js +130 -130
  114. package/dist/packages/core/src/components/barChartData.d.ts +16 -1
  115. package/dist/packages/core/src/components/barChartData.js +49 -29
  116. package/dist/packages/core/src/components/barChartTypes.js +8 -8
  117. package/dist/packages/core/src/components/tableTrackList.js +11 -11
  118. package/dist/packages/core/src/components/updateAlertStack.js +28 -28
  119. package/dist/packages/core/src/components/useBarChartControl.js +4 -6
  120. package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
  121. package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
  122. package/dist/packages/core/src/components/wordTableLayout.d.ts +17 -4
  123. package/dist/packages/core/src/components/wordTableLayout.js +5 -5
  124. package/dist/packages/core/src/context/I18nProvider.js +6 -6
  125. package/dist/packages/core/src/hooks/focusReturn.js +27 -27
  126. package/dist/packages/core/src/hooks/inputModality.js +13 -13
  127. package/dist/packages/core/src/hooks/touchToggle.js +39 -39
  128. package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
  129. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
  130. package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
  131. package/dist/packages/core/src/hooks/useEditableList.js +4 -4
  132. package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
  133. package/dist/packages/core/src/hooks/useFontScale.js +6 -6
  134. package/dist/packages/core/src/hooks/useIcon.js +7 -7
  135. package/dist/packages/core/src/hooks/useListControls.js +2 -2
  136. package/dist/packages/core/src/hooks/usePageResetOnChange.js +3 -3
  137. package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
  138. package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
  139. package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
  140. package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
  141. package/dist/packages/core/src/hooks/useTablePaging.d.ts +0 -11
  142. package/dist/packages/core/src/hooks/useTablePaging.js +29 -34
  143. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
  144. package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
  145. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
  146. package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
  147. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -7
  148. package/dist/packages/core/src/i18n/locales/en.json +13 -2
  149. package/dist/packages/core/src/i18n/locales/ko.json +12 -1
  150. package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
  151. package/dist/packages/core/src/utils/audioMeter.js +5 -5
  152. package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
  153. package/dist/packages/core/src/utils/audioPeaks.js +18 -18
  154. package/dist/packages/core/src/utils/blockGrid.js +4 -4
  155. package/dist/packages/core/src/utils/chunkSkew.js +9 -9
  156. package/dist/packages/core/src/utils/clipboard.js +3 -3
  157. package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
  158. package/dist/packages/core/src/utils/date.js +12 -12
  159. package/dist/packages/core/src/utils/devWarn.js +4 -4
  160. package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
  161. package/dist/packages/core/src/utils/deviceKind.js +53 -10
  162. package/dist/packages/core/src/utils/errorMessage.js +7 -7
  163. package/dist/packages/core/src/utils/filterPanelSummary.js +14 -14
  164. package/dist/packages/core/src/utils/format.js +4 -4
  165. package/dist/packages/core/src/utils/getTrainingData.js +9 -9
  166. package/dist/packages/core/src/utils/imageConfig.js +17 -17
  167. package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
  168. package/dist/packages/core/src/utils/navScroll.js +2 -2
  169. package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
  170. package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
  171. package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
  172. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
  173. package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
  174. package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
  175. package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
  176. package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
  177. package/dist/packages/core/src/utils/safeHref.js +4 -4
  178. package/dist/packages/core/src/utils/savedWordTables.js +5 -5
  179. package/dist/packages/core/src/utils/speak.js +12 -12
  180. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  181. package/dist/packages/core/src/utils/textSort.js +38 -48
  182. package/dist/packages/core/src/utils/theme.js +3 -3
  183. package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
  184. package/dist/packages/core/src/utils/ttsConfig.js +2 -2
  185. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +45 -45
  186. package/dist/packages/core/src/utils/wordContrast.js +3 -3
  187. package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
  188. package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
  189. package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
  190. package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
  191. package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
  192. package/dist/packages/core/src/utils/wornDate.js +14 -14
  193. package/dist/packages/core/src/version.d.ts +1 -1
  194. package/dist/packages/core/src/version.js +1 -1
  195. package/dist/utils.cjs.js +4 -4
  196. package/package.json +1 -1
@@ -39,9 +39,9 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, loc
39
39
  const [playingAll, setPlayingAll] = useState(false);
40
40
  /* 모두 듣기를 멈추는 함수 — 도는 동안만 있다. 사용자가 카드의 '소리 듣기' 를 누르면 부른다: 전에는 누른 카드가 울리던 카드를
41
41
  멈추게 했지만(currentSound) 멈춘 카드는 끝 신호가 안 와 차례가 8초 시한까지 기다렸다가 **다음 카드가 누른 카드를 끊고** 이어 울렸다. */
42
- const 모두듣기멈춤Ref = useRef(null);
43
- const 단계살아있음 = useRef(true);
44
- useEffect(() => { 단계살아있음.current = true; return () => { 단계살아있음.current = false; }; }, []);
42
+ const stopPlayAllRef = useRef(null);
43
+ const stepAliveRef = useRef(true);
44
+ useEffect(() => { stepAliveRef.current = true; return () => { stepAliveRef.current = false; }; }, []);
45
45
  // 페이지 전환 시 (items 변경) 이전 선택 상태 및 카드 ref 초기화
46
46
  const itemsKey = useMemo(() => items.map((it) => it?.id ?? it?.word ?? '').join('|'), [items]);
47
47
  /* eslint-disable react-hooks/set-state-in-effect */
@@ -78,33 +78,33 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, loc
78
78
  }
79
79
  }, [selectable, onChange, currentState, onDone]);
80
80
  /* 손잡이가 memo 라 **지금 값**을 ref 로 읽는다 — 이 파일이 `단계살아있음` 에 쓰는 것과 같은 결 */
81
- const 사이뜸Ref = useRef(playAllGapMs);
82
- useEffect(() => { 사이뜸Ref.current = Math.max(0, playAllGapMs || 0); }, [playAllGapMs]);
81
+ const gapMsRef = useRef(playAllGapMs);
82
+ useEffect(() => { gapMsRef.current = Math.max(0, playAllGapMs || 0); }, [playAllGapMs]);
83
83
  const handlePlayAll = useCallback(async () => {
84
84
  if (playingAll || currentState === 'disabled')
85
85
  return;
86
86
  setPlayingAll(true);
87
- let 멈춤 = false;
88
- const 멈춤신호 = new Promise((resolve) => { 모두듣기멈춤Ref.current = () => { 멈춤 = true; resolve(); }; });
87
+ let stopped = false;
88
+ const stopSignal = new Promise((resolve) => { stopPlayAllRef.current = () => { stopped = true; resolve(); }; });
89
89
  try {
90
90
  /* 매 차례 **지금의** 카드를 읽고, 떠났으면 멈춘다 — 시작할 때 잡아 둔 핸들로 돌면 떠난 화면의 다음 카드가 이어서 울렸다 */
91
91
  for (let i = 0; i < shown.length; i++) {
92
- if (!단계살아있음.current || 멈춤)
92
+ if (!stepAliveRef.current || stopped)
93
93
  break;
94
94
  const cardRef = cardRefs.current[i];
95
95
  if (!cardRef)
96
96
  continue;
97
- await Promise.race([withTimeout(cardRef.play(), PLAY_ALL_CARD_TIMEOUT_MS), 멈춤신호]);
97
+ await Promise.race([withTimeout(cardRef.play(), PLAY_ALL_CARD_TIMEOUT_MS), stopSignal]);
98
98
  /* **사이 뜸**(말귀 요청 (79), 2026-10-06) — 앞 카드가 **끝난 뒤부터** 센다.
99
99
  마지막 뒤에는 안 둔다. 멈춤 신호와 함께 기다려, 누르는 순간 바로 걷힌다. */
100
- const 사이 = Math.max(0, 사이뜸Ref.current || 0);
101
- if (사이 > 0 && i < shown.length - 1 && !멈춤 && 단계살아있음.current) {
102
- await Promise.race([new Promise((r) => { setTimeout(r, 사이); }), 멈춤신호]);
100
+ const gapMs = Math.max(0, gapMsRef.current || 0);
101
+ if (gapMs > 0 && i < shown.length - 1 && !stopped && stepAliveRef.current) {
102
+ await Promise.race([new Promise((r) => { setTimeout(r, gapMs); }), stopSignal]);
103
103
  }
104
104
  }
105
105
  }
106
106
  finally {
107
- 모두듣기멈춤Ref.current = null;
107
+ stopPlayAllRef.current = null;
108
108
  setPlayingAll(false);
109
109
  }
110
110
  }, [playingAll, currentState, shown.length]);
@@ -146,11 +146,11 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, loc
146
146
  /* 모두 듣기 중 카드의 '소리 듣기' 를 누르면(마우스·Enter 모두 click) 모두 듣기를 멈춘다 — 누른 카드는 제 처리로 울린다.
147
147
  캡처 단계에서 본다: 카드 단추는 선택 모드에서 전파를 막는다. */
148
148
  onClickCapture: (e) => {
149
- const 멈추기 = 모두듣기멈춤Ref.current;
150
- if (!멈추기)
149
+ const stop = stopPlayAllRef.current;
150
+ if (!stop)
151
151
  return;
152
- const 누른곳 = e.target;
153
- if (누른곳 && typeof 누른곳.closest === 'function' && 누른곳.closest('.training-word-card__bottom'))
154
- 멈추기();
152
+ const pressedAt = e.target;
153
+ if (pressedAt && typeof pressedAt.closest === 'function' && pressedAt.closest('.training-word-card__bottom'))
154
+ stop();
155
155
  }, 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)))) }) }));
156
156
  }
@@ -20,10 +20,10 @@ export default function TrainingStepConfirmWithText({ index, stepIndex, isLast =
20
20
  // "null 이 아니면 null 로" 를 해서, 답을 누르면 한 박자 뒤 강조가 곧바로 사라졌다.
21
21
  // 문항 열쇠는 문장 + 보기 — 문장만 보면 보기만 바뀐 문항에서 같은 번호의 새 보기가 골라진 채 보였다.
22
22
  // 데모 강제 선택 시에는 문항 변경 리셋을 건너뛴다.
23
- const 문항열쇠 = `${String(sentence)}\u0000${JSON.stringify(choices ?? null)}`;
24
- const [prevKey, setPrevKey] = useState(문항열쇠);
25
- if (prevKey !== 문항열쇠) {
26
- setPrevKey(문항열쇠);
23
+ const questionKey = `${String(sentence)}\u0000${JSON.stringify(choices ?? null)}`;
24
+ const [prevKey, setPrevKey] = useState(questionKey);
25
+ if (prevKey !== questionKey) {
26
+ setPrevKey(questionKey);
27
27
  if (demoSelected == null)
28
28
  setSelectedAnswer(null);
29
29
  }
@@ -11,8 +11,8 @@ import "./TrainingStepOrderList.css";
11
11
  /** @public */
12
12
  /* 기본 context 는 **모듈에 하나** — 매 렌더 새 `[]` 면 [context] effect 가 매번 다시 돌아 setState·onChange 로 렌더가 끝없이
13
13
  반복됐다(자료를 불러오기 전 context 없이 그린 단계에서 CPU 100%). */
14
- const 빈문맥 = [];
15
- export default function TrainingStepOrderList({ index, stepIndex, isLast = false, lockedHint, title, onDone, onChange, context = 빈문맥, }) {
14
+ const EMPTY_CONTEXT = [];
15
+ export default function TrainingStepOrderList({ index, stepIndex, isLast = false, lockedHint, title, onDone, onChange, context = EMPTY_CONTEXT, }) {
16
16
  const { t } = useTranslation();
17
17
  const resolvedTitle = title ?? t('training.orderListDefault', '문장을 들은 순서대로 배치하세요.');
18
18
  const toText = (v) => typeof v === "string"
@@ -32,18 +32,18 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
32
32
  return "disabled";
33
33
  }, [stepIndex, index]);
34
34
  /** 옮기기 단추·끌어놓기를 여는가 — 진행 중이거나 끝낸 단계(아래 move 머리말) */
35
- const 조작가능 = currentState !== 'disabled';
35
+ const interactive = currentState !== 'disabled';
36
36
  // context 변경 시 items/shuffled를 다시 초기화하는 의도된 동기화다
37
37
  /* eslint-disable react-hooks/set-state-in-effect */
38
38
  /* 같은 context 로 다시 도는 effect(StrictMode 의 두 번 실행, 숨김→보임)는 넘긴다 — 다시 섞고 다시 알리면 처음 알림의
39
39
  배치가 화면에 한 번도 안 뜬 배치였고 onChange·onDone 이 두 번씩 나갔다. 새 context 일 때만 섞는다. */
40
- const 섞은문맥 = useRef(undefined);
40
+ const shuffledRef = useRef(undefined);
41
41
  /** 첫 `onDone` 을 **어느 문맥에** 냈는가 — 두 번 안 내려고 쥔다(아래 '열릴 때' 효과 참고) */
42
- const 첫보고Ref = useRef(undefined);
42
+ const firstReportRef = useRef(undefined);
43
43
  useEffect(() => {
44
- if (섞은문맥.current === context)
44
+ if (shuffledRef.current === context)
45
45
  return;
46
- 섞은문맥.current = context;
46
+ shuffledRef.current = context;
47
47
  const it = Array.isArray(context) ? context : [];
48
48
  setItems(it);
49
49
  const makeInitial = (src) => {
@@ -76,7 +76,7 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
76
76
  }
77
77
  if (typeof onDone === "function" && currentState === "active") {
78
78
  const sc2 = initial.filter((s, i) => s.text === toText(it[i])).length;
79
- 첫보고Ref.current = context;
79
+ firstReportRef.current = context;
80
80
  onDone({ shuffled: initial, score: sc2, total: (it || []).length, initial: true, __ts: Date.now() });
81
81
  }
82
82
  }
@@ -104,9 +104,9 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
104
104
  useEffect(() => {
105
105
  if (currentState !== 'active')
106
106
  return;
107
- if (첫보고Ref.current === context)
107
+ if (firstReportRef.current === context)
108
108
  return;
109
- 첫보고Ref.current = context;
109
+ firstReportRef.current = context;
110
110
  if (typeof onDone !== 'function')
111
111
  return;
112
112
  const it = Array.isArray(context) ? context : [];
@@ -474,7 +474,7 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
474
474
  map.set(it.id, el);
475
475
  else
476
476
  map.delete(it.id);
477
- }, className: `order-list__draggable ${draggedIndex === idx ? "dragging" : ""}`, "data-disabled": currentState === 'disabled', "aria-disabled": currentState === 'disabled', tabIndex: currentState === 'disabled' ? -1 : 0, draggable: 조작가능, onDragStart: 조작가능 ? (e) => handleDragStart(idx, e) : undefined, onDragEnter: 조작가능 ? (e) => handleDragOverWithClass(idx, e) : undefined, onDragOver: 조작가능 ? (e) => handleDragOverWithClass(idx, e) : undefined, onDragLeave: 조작가능 ? () => {
477
+ }, className: `order-list__draggable ${draggedIndex === idx ? "dragging" : ""}`, "data-disabled": currentState === 'disabled', "aria-disabled": currentState === 'disabled', tabIndex: currentState === 'disabled' ? -1 : 0, draggable: interactive, onDragStart: interactive ? (e) => handleDragStart(idx, e) : undefined, onDragEnter: interactive ? (e) => handleDragOverWithClass(idx, e) : undefined, onDragOver: interactive ? (e) => handleDragOverWithClass(idx, e) : undefined, onDragLeave: interactive ? () => {
478
478
  try {
479
479
  if (dragLeaveTimeoutRef.current !== null)
480
480
  clearTimeout(dragLeaveTimeoutRef.current);
@@ -491,6 +491,6 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
491
491
  }, 80);
492
492
  }
493
493
  catch (err) { }
494
- } : undefined, onDrop: 조작가능 ? (e) => handleDrop(idx, e) : undefined, onDragEnd: 조작가능 ? handleDragEnd : undefined, children: [_jsx("span", { className: "order-list__item-text body-2", children: it.text }), 조작가능 ? (_jsxs("span", { className: "order-list__item-buttons", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, -1), ariaLabel: t("training.up"), icon: 'chevron-up', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === 0, useBorder: false, useDisabledInvisible: true }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, 1), ariaLabel: t("training.down"), icon: 'chevron-down', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === shuffled.length - 1, useBorder: false, useDisabledInvisible: true })] })) : null] })] }) }, it.id));
494
+ } : undefined, onDrop: interactive ? (e) => handleDrop(idx, e) : undefined, onDragEnd: interactive ? handleDragEnd : undefined, children: [_jsx("span", { className: "order-list__item-text body-2", children: it.text }), interactive ? (_jsxs("span", { className: "order-list__item-buttons", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, -1), ariaLabel: t("training.up"), icon: 'chevron-up', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === 0, useBorder: false, useDisabledInvisible: true }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, 1), ariaLabel: t("training.down"), icon: 'chevron-down', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === shuffled.length - 1, useBorder: false, useDisabledInvisible: true })] })) : null] })] }) }, it.id));
495
495
  }) }) }));
496
496
  }
@@ -32,11 +32,11 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
32
32
  });
33
33
  const [noticeMessage, setNoticeMessage] = useState('');
34
34
  /* 초점을 가진 '문장 미리보기' 를 누르면 그 단추가 사라진다 — 펼친 상자의 첫 조작 단추로 초점을 옮긴다 */
35
- const 상자Ref = useRef(null);
36
- const 펼친뒤초점Ref = useRef(false);
35
+ const boxRef = useRef(null);
36
+ const focusAfterExpandRef = useRef(false);
37
37
  const handleActivate = useCallback(() => {
38
38
  // 키보드로 눌렀을 때만 — 마우스로 눌렀는데 옮기면 '글자 크기 감소' 초점 툴팁이 떴다
39
- 펼친뒤초점Ref.current = wasKeyboardPress() && !!상자Ref.current && 상자Ref.current.contains(document.activeElement);
39
+ focusAfterExpandRef.current = wasKeyboardPress() && !!boxRef.current && boxRef.current.contains(document.activeElement);
40
40
  setLocalStatus("active");
41
41
  }, []);
42
42
  const handleNoticeClose = useCallback(() => setNoticeMessage(''), []);
@@ -64,9 +64,9 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
64
64
  }, [context]);
65
65
  /* 문장이 바뀌면 **그 렌더에서** 가린다 — 타이머로 한 틱 뒤에 가리면, 앞 문제에서 펼쳐 둔 상자에 다음 문제 문장이
66
66
  잠깐 드러났다(정답 문장이 번쩍). 렌더 중 상태 조정은 React 가 권하는 꼴이다. */
67
- const [보던문장, set보던문장] = useState(content);
68
- if (보던문장 !== content) {
69
- set보던문장(content);
67
+ const [shownContent, setShownContent] = useState(content);
68
+ if (shownContent !== content) {
69
+ setShownContent(content);
70
70
  setLocalStatus("disabled");
71
71
  }
72
72
  const displayStatus = state === "disabled" ? localStatus : state;
@@ -82,13 +82,13 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
82
82
  lineHeight: `${lineHeight}px`,
83
83
  }), [fontSize, lineHeight]);
84
84
  useEffect(() => {
85
- if (displayStatus === 'disabled' || !펼친뒤초점Ref.current)
85
+ if (displayStatus === 'disabled' || !focusAfterExpandRef.current)
86
86
  return;
87
- 펼친뒤초점Ref.current = false;
88
- const 지금 = document.activeElement;
89
- if (지금 && 지금 !== document.body)
87
+ focusAfterExpandRef.current = false;
88
+ const active = document.activeElement;
89
+ if (active && active !== document.body)
90
90
  return;
91
- 상자Ref.current?.querySelector('.training-text-box__buttons button')?.focus();
91
+ boxRef.current?.querySelector('.training-text-box__buttons button')?.focus();
92
92
  }, [displayStatus]);
93
- return (_jsxs("div", { ref: 상자Ref, className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
93
+ return (_jsxs("div", { ref: boxRef, className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
94
94
  }
@@ -23,10 +23,10 @@ export default function TrainingStepTitle({ title, children, className = "", sta
23
23
  useFloatingFocus(showPreview && !!anchorRect, () => {
24
24
  if (typeof document === 'undefined')
25
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];
26
+ const tooltipEls = Array.from(document.querySelectorAll('.tooltip-content'));
27
+ for (let i = tooltipEls.length - 1; i >= 0; i--)
28
+ if (tooltipEls[i].querySelector('.tooltip-textbox__root'))
29
+ return tooltipEls[i];
30
30
  return null;
31
31
  }, () => btnRef.current?.querySelector('button') ?? null);
32
32
  // 인라인 화살표 함수로 넘어오는 경우가 있어 매 렌더 identity가 바뀐다.
@@ -90,14 +90,14 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
90
90
  /* 떠날 때 **이 카드가 쥔 소리** 도 끊는다 — audio 만 멈춰, 음원 없는 낱말(speechSynthesis)을 듣는 중에 떠나면 말이
91
91
  계속 나왔다. 남이 이미 가져간 소리는 건드리지 않는다(stopSoundIfOwner). 떠난 뒤에는 기다리던 합성 주소가 와도 울리지 않는다. */
92
92
  const mountedRef = useRef(true);
93
- const 쥔소리Ref = useRef(null);
93
+ const heldSoundRef = useRef(null);
94
94
  useEffect(() => {
95
95
  mountedRef.current = true;
96
96
  return () => {
97
97
  mountedRef.current = false;
98
- if (쥔소리Ref.current)
99
- stopSoundIfOwner(쥔소리Ref.current);
100
- 쥔소리Ref.current = null;
98
+ if (heldSoundRef.current)
99
+ stopSoundIfOwner(heldSoundRef.current);
100
+ heldSoundRef.current = null;
101
101
  try {
102
102
  audioRef.current?.pause();
103
103
  }
@@ -138,17 +138,17 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
138
138
  const playUrl = useCallback((url) => {
139
139
  /* 끝은 **그 자리에서** 받는다(약속은 한 틱 늦다) — 옛 코드도 `onended`·`fail` 안에서
140
140
  바로 되돌렸고, 그 사이에 남이 상태를 읽는다. */
141
- const 소리 = playOneShot(url, (들었나) => {
141
+ const sound = playOneShot(url, (played) => {
142
142
  setPlaying(false);
143
- if (들었나) {
143
+ if (played) {
144
144
  try {
145
145
  onPlayDone?.();
146
146
  }
147
147
  catch { }
148
148
  }
149
149
  });
150
- audioRef.current = 소리.el;
151
- return 소리.done;
150
+ audioRef.current = sound.el;
151
+ return sound.done;
152
152
  }, [onPlayDone]);
153
153
  // 오디오 재생 핵심 로직 — 종료 시 resolve. 클릭 핸들러와 ref로 노출되는 play() 양쪽에서 공유.
154
154
  // 재생 우선순위: ① 정식 음원 URL → ② 앱이 등록한 런타임 합성 폴백(예: Gemini-TTS) →
@@ -162,10 +162,10 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
162
162
  것**이라, 옆 카드가 울리는 것은 못 멈췄다 — 변별 훈련에서 1번·2번을 잇달아 누르면
163
163
  둘이 함께 울렸다. speechSynthesis 만 전역이라 음원 없는 낱말에서는 멀쩡해 보였고,
164
164
  그래서 증상이 반만 나타났다. 스피커가 하나이므로 자물쇠도 전역에 둔다. */
165
- const 멈춤 = () => {
165
+ const stop = () => {
166
166
  // 남(다른 카드·다음 누름)이 자리를 가져가며 부르면 이 카드는 더 이상 쥐고 있지 않다 — 기다리던 흐름이 이것을 보고 물러선다
167
- if (쥔소리Ref.current === 멈춤)
168
- 쥔소리Ref.current = null;
167
+ if (heldSoundRef.current === stop)
168
+ heldSoundRef.current = null;
169
169
  try {
170
170
  audioRef.current?.pause();
171
171
  }
@@ -176,8 +176,8 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
176
176
  catch { /* 미지원 환경 */ }
177
177
  setPlaying(false);
178
178
  };
179
- const release = claimSound(멈춤);
180
- 쥔소리Ref.current = 멈춤;
179
+ const release = claimSound(stop);
180
+ heldSoundRef.current = stop;
181
181
  try {
182
182
  window.speechSynthesis?.cancel();
183
183
  }
@@ -196,7 +196,7 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
196
196
  const url = await Promise.resolve(fallbackResolver(label));
197
197
  /* 기다리는 사이 떠났거나 **소리 자리를 남에게 넘겼으면**(다른 카드를 눌렀거나 같은 카드를 다시 눌렀으면) 끝낸다 —
198
198
  늦게 온 합성 주소로 틀어 두 소리가 겹쳤고, 같은 카드를 두 번 누르면 두 번 울려 onPlayDone 도 두 번 나갔다. */
199
- if (!mountedRef.current || 쥔소리Ref.current !== 멈춤)
199
+ if (!mountedRef.current || heldSoundRef.current !== stop)
200
200
  return;
201
201
  if (url) {
202
202
  setPlaying(true);
@@ -206,14 +206,14 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
206
206
  }
207
207
  catch { }
208
208
  }
209
- if (!mountedRef.current || 쥔소리Ref.current !== 멈춤)
209
+ if (!mountedRef.current || heldSoundRef.current !== stop)
210
210
  return;
211
211
  await playSpeechSynthesis();
212
212
  }
213
213
  finally {
214
214
  release(); // 다음 사람이 이미 가져갔으면 아무 일도 안 한다
215
- if (쥔소리Ref.current === 멈춤)
216
- 쥔소리Ref.current = null;
215
+ if (heldSoundRef.current === stop)
216
+ heldSoundRef.current = null;
217
217
  }
218
218
  }, [disabled, audioUrl, label, playUrl, playSpeechSynthesis]);
219
219
  useImperativeHandle(ref, () => ({
@@ -53,15 +53,15 @@ export default function WordTablePopup({ items, words, title, subtitle, emptyTex
53
53
  useEffect(() => { notifyRef.current = onScoreChange; });
54
54
  /* 매긴 것이 **바뀌었을 때만** 알린다 — 처음 열 때 onScoreChange({}) 가 나갔다. '첫 실행 건너뛰기' 는 StrictMode 의 두 번째 실행에서
55
55
  새어 이전 값과 견준다. */
56
- const 알린marksRef = useRef(marks);
56
+ const reportedMarksRef = useRef(marks);
57
57
  useEffect(() => {
58
- if (알린marksRef.current === marks)
58
+ if (reportedMarksRef.current === marks)
59
59
  return;
60
- 알린marksRef.current = marks;
60
+ reportedMarksRef.current = marks;
61
61
  notifyRef.current?.(marksForSave(marks));
62
62
  }, [marks]);
63
63
  /* 표에는 지운 칸(null)이 빠진 것만 넘긴다 — 표는 "매겨진 것"만 알면 된다. */
64
- const 표에줄채점 = useMemo(() => marksForSave(marks), [marks]);
64
+ const marksForTable = useMemo(() => marksForSave(marks), [marks]);
65
65
  // 인쇄 — WordTable 내부 콘텐츠(.word-table 아래 그리드)를 격리 iframe으로 출력한다.
66
66
  // (전역 print CSS는 모든 .word-table을 노출하므로 window.print 직접 호출은 다른 표까지 인쇄될 수 있다)
67
67
  // 페이지 분할 시엔 페이지별 ref, 아니면 단일 콘텐츠 ref를 넘긴다.
@@ -115,5 +115,5 @@ export default function WordTablePopup({ items, words, title, subtitle, emptyTex
115
115
  }, 100);
116
116
  return () => window.clearTimeout(id);
117
117
  }, [autoPrint]);
118
- return (_jsxs(Popup, { title: title ?? t('keyWords.title', '주요 단어'), subtitle: subtitle, size: "large", className: cx('word-table-popup', className), buttons: footerButtons, footer: customFooter, footerClassName: footerClassName, footerLayout: footerLayout, beforeClose: beforeClose, onClose: onClose, children: [tableItems.length > 0 ? (_jsxs("div", { className: "word-table-popup__body", children: [showSettings && (_jsx(TableFilterPanel, { sections: filterSections, selected: filterSelected, onSelect: handleSelect })), isPaginated ? (_jsx("div", { className: "word-table-popup__pages", children: pages.map((page, i) => (_jsx(WordTable, { items: page, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition, brand: brand && { ...brand, top: settings.showTopInfo, bottom: settings.showBottomInfo }, code: code, scoring: scoring, scoreIndexOffset: pageStart[i], marks: 표에줄채점, onMarkClick: handleMarkClick }, i))) })) : (_jsx("div", { className: "word-table-popup__table", children: _jsx(WordTable, { items: tableItems, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: contentRef, fitHeight: true, scoring: scoring, marks: 표에줄채점, onMarkClick: handleMarkClick, brand: brand && { ...brand, top: settings.showTopInfo, bottom: settings.showBottomInfo }, code: code }) }))] })) : (_jsx("p", { className: "word-table-popup__empty body-3", children: emptyText ?? t('keyWords.empty', '추출된 주요 단어가 없습니다.') })), _jsx(Toast, { message: toastMsg, position: "top", onClose: handleToastClose }, toastKey)] }));
118
+ return (_jsxs(Popup, { title: title ?? t('keyWords.title', '주요 단어'), subtitle: subtitle, size: "large", className: cx('word-table-popup', className), buttons: footerButtons, footer: customFooter, footerClassName: footerClassName, footerLayout: footerLayout, beforeClose: beforeClose, onClose: onClose, children: [tableItems.length > 0 ? (_jsxs("div", { className: "word-table-popup__body", children: [showSettings && (_jsx(TableFilterPanel, { sections: filterSections, selected: filterSelected, onSelect: handleSelect })), isPaginated ? (_jsx("div", { className: "word-table-popup__pages", children: pages.map((page, i) => (_jsx(WordTable, { items: page, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition, brand: brand && { ...brand, top: settings.showTopInfo, bottom: settings.showBottomInfo }, code: code, scoring: scoring, scoreIndexOffset: pageStart[i], marks: marksForTable, onMarkClick: handleMarkClick }, i))) })) : (_jsx("div", { className: "word-table-popup__table", children: _jsx(WordTable, { items: tableItems, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: contentRef, fitHeight: true, scoring: scoring, marks: marksForTable, onMarkClick: handleMarkClick, brand: brand && { ...brand, top: settings.showTopInfo, bottom: settings.showBottomInfo }, code: code }) }))] })) : (_jsx("p", { className: "word-table-popup__empty body-3", children: emptyText ?? t('keyWords.empty', '추출된 주요 단어가 없습니다.') })), _jsx(Toast, { message: toastMsg, position: "top", onClose: handleToastClose }, toastKey)] }));
119
119
  }
@@ -23,15 +23,15 @@ export function progressOf({ currentTime, sources, index, audioDuration, }) {
23
23
  /* `index || 0` 은 원본에서 옮겨 온 그대로다 — 아직 번호를 못 정한 상태(undefined·NaN)를
24
24
  0번으로 본다는 뜻이다. 음수는 그대로 통과하지만 그러면 `sources[음수]` 가 없어서
25
25
  아래 else 쪽으로 떨어진다. 부르는 쪽이 음수를 줄 일은 없다. */
26
- const 지금 = index || 0;
27
- const 이번트랙 = Array.isArray(sources) && sources.length > 0 ? sources[지금] : undefined;
28
- const 길이 = 이번트랙
29
- ? (이번트랙.duration || audioDuration || 0)
26
+ const at = index || 0;
27
+ const track = Array.isArray(sources) && sources.length > 0 ? sources[at] : undefined;
28
+ const duration = track
29
+ ? (track.duration || audioDuration || 0)
30
30
  : (audioDuration || 0);
31
- if (!(길이 > 0))
31
+ if (!(duration > 0))
32
32
  return 0;
33
33
  const t = Number.isFinite(currentTime) ? currentTime : 0;
34
- return Math.max(0, Math.min(1, t / 길이));
34
+ return Math.max(0, Math.min(1, t / duration));
35
35
  }
36
36
  /**
37
37
  * **앞뒤 뜸을 포함한 한 트랙의 진행** — 0~1(말귀 요청 (74), 2026-10-05).
@@ -79,8 +79,8 @@ export function padBarSpans({ bars, trackDurationSec, leadInMs, tailMs, }) {
79
79
  export function nextTrackIndex(sources, index) {
80
80
  if (!Array.isArray(sources))
81
81
  return null;
82
- const 지금 = index || 0; // progressOf 와 같은 규칙 — 원본에서 옮겨 왔다
83
- return 지금 < sources.length - 1 ? 지금 + 1 : null;
82
+ const at = index || 0; // progressOf 와 같은 규칙 — 원본에서 옮겨 왔다
83
+ return at < sources.length - 1 ? at + 1 : null;
84
84
  }
85
85
  /**
86
86
  * `pause` 이벤트를 흘려보내야 하는가.
@@ -1,18 +1,18 @@
1
- const 밀리 = (초) => Math.max(0, (typeof 초 === 'number' && Number.isFinite(초) ? 초 : 0) * 1000);
2
- const 뜸값 = (ms) => Math.max(0, typeof ms === 'number' && Number.isFinite(ms) ? ms : 0);
1
+ const toMs = (sec) => Math.max(0, (typeof sec === 'number' && Number.isFinite(sec) ? sec : 0) * 1000);
2
+ const padMs = (ms) => Math.max(0, typeof ms === 'number' && Number.isFinite(ms) ? ms : 0);
3
3
  /** 소리 길이들과 뜸으로 시간표를 짓는다. 길이를 모르는 소리는 0 으로 둔다. */
4
4
  export function buildTimeline({ durationsSec, leadInMs = 0, tailMs = 0, }) {
5
- const 앞 = 뜸값(leadInMs);
6
- const 뒤 = 뜸값(tailMs);
5
+ const lead = padMs(leadInMs);
6
+ const tail = padMs(tailMs);
7
7
  const segments = [];
8
- let 지금 = 0;
9
- durationsSec.forEach((초, track) => {
10
- for (const [kind, durMs] of [['lead', 앞], ['sound', 밀리(초)], ['tail', 뒤]]) {
11
- segments.push({ kind, track, startMs: 지금, durMs });
12
- 지금 += durMs;
8
+ let cursor = 0;
9
+ durationsSec.forEach((sec, track) => {
10
+ for (const [kind, durMs] of [['lead', lead], ['sound', toMs(sec)], ['tail', tail]]) {
11
+ segments.push({ kind, track, startMs: cursor, durMs });
12
+ cursor += durMs;
13
13
  }
14
14
  });
15
- return { segments, totalMs: 지금, trackCount: durationsSec.length };
15
+ return { segments, totalMs: cursor, trackCount: durationsSec.length };
16
16
  }
17
17
  /** 한 트랙의 세 구간. 없는 트랙이면 `null` */
18
18
  export function trackSegments(tl, track) {
@@ -48,9 +48,9 @@ export function timelineProgress(tl, at) {
48
48
  const t = trackSegments(tl, at.track);
49
49
  if (!t || !(t.totalMs > 0))
50
50
  return 0;
51
- const 구간 = t[at.kind];
52
- const 흐른 = Math.min(Math.max(0, Number.isFinite(at.elapsedMs) ? at.elapsedMs : 0), 구간.durMs);
53
- return Math.max(0, Math.min(1, (구간.startMs - t.lead.startMs + 흐른) / t.totalMs));
51
+ const segment = t[at.kind];
52
+ const elapsed = Math.min(Math.max(0, Number.isFinite(at.elapsedMs) ? at.elapsedMs : 0), segment.durMs);
53
+ return Math.max(0, Math.min(1, (segment.startMs - t.lead.startMs + elapsed) / t.totalMs));
54
54
  }
55
55
  /**
56
56
  * **이 트랙의 막대를 앞뜸 · 소리 · 뒤뜸으로 나눈다.**
@@ -59,27 +59,27 @@ export function timelineProgress(tl, at) {
59
59
  * 남는다** — 반올림이 겹쳐 0 이 되면 파형이 통째로 빈다.
60
60
  */
61
61
  export function timelineBarSpans(tl, track, bars) {
62
- const 칸 = Math.max(0, Math.floor(bars));
62
+ const barCount = Math.max(0, Math.floor(bars));
63
63
  const t = trackSegments(tl, track);
64
- if (!t || 칸 === 0 || !(t.totalMs > 0) || (t.lead.durMs === 0 && t.tail.durMs === 0) || !(t.sound.durMs > 0)) {
65
- return { lead: 0, sound: 칸, tail: 0 };
64
+ if (!t || barCount === 0 || !(t.totalMs > 0) || (t.lead.durMs === 0 && t.tail.durMs === 0) || !(t.sound.durMs > 0)) {
65
+ return { lead: 0, sound: barCount, tail: 0 };
66
66
  }
67
- const 앞칸 = Math.round((t.lead.durMs / t.totalMs) * 칸);
68
- const 뒤칸 = Math.round((t.tail.durMs / t.totalMs) * 칸);
69
- const 소리칸 = Math.max(1, 칸 - 앞칸 - 뒤칸);
70
- const 넘침 = 앞칸 + 소리칸 + 뒤칸 - 칸;
71
- return { lead: Math.max(0, 앞칸 - Math.ceil(넘침 / 2)), sound: 소리칸, tail: Math.max(0, 뒤칸 - Math.floor(넘침 / 2)) };
67
+ const leadBars = Math.round((t.lead.durMs / t.totalMs) * barCount);
68
+ const tailBars = Math.round((t.tail.durMs / t.totalMs) * barCount);
69
+ const soundBars = Math.max(1, barCount - leadBars - tailBars);
70
+ const overflow = leadBars + soundBars + tailBars - barCount;
71
+ return { lead: Math.max(0, leadBars - Math.ceil(overflow / 2)), sound: soundBars, tail: Math.max(0, tailBars - Math.floor(overflow / 2)) };
72
72
  }
73
73
  /**
74
74
  * **앞 소리가 끝나고 다음 소리가 날 때까지**(ms) — 뒤뜸 + 앞뜸.
75
75
  * 마지막 트랙이거나 없는 트랙이면 0.
76
76
  */
77
77
  export function gapAfterTrackMs(tl, track) {
78
- const 지금 = trackSegments(tl, track);
79
- const 다음 = trackSegments(tl, track + 1);
80
- if (!지금 || !다음)
78
+ const cursor = trackSegments(tl, track);
79
+ const nextTrack = trackSegments(tl, track + 1);
80
+ if (!cursor || !nextTrack)
81
81
  return 0;
82
- return 지금.tail.durMs + 다음.lead.durMs;
82
+ return cursor.tail.durMs + nextTrack.lead.durMs;
83
83
  }
84
84
  /** 그 트랙의 소리가 **누른 뒤 몇 ms 에** 나는가(불러오는 시간은 빼고). 없는 트랙이면 `null` */
85
85
  export function soundStartMs(tl, track) {
@@ -37,19 +37,19 @@
37
37
  * `try` 를 적고 있었고, 그 안에서 하는 일이 조금씩 달랐다.
38
38
  */
39
39
  export function playOneShot(url, onEnd) {
40
- let 풀기 = () => { };
41
- const done = new Promise((res) => { 풀기 = res; });
40
+ let settle = () => { };
41
+ const done = new Promise((res) => { settle = res; });
42
42
  /** 끝은 한 번이다 — 거절과 `error` 가 함께 오는 일이 실제로 있다 */
43
- let 끝났나 = false;
44
- const 끝 = (played) => {
45
- if (끝났나)
43
+ let ended = false;
44
+ const finish = (played) => {
45
+ if (ended)
46
46
  return;
47
- 끝났나 = true;
47
+ ended = true;
48
48
  try {
49
49
  onEnd?.(played);
50
50
  }
51
51
  catch { /* 부르는 쪽의 실패로 약속을 묶어 두지 않는다 */ }
52
- 풀기(played);
52
+ settle(played);
53
53
  };
54
54
  let el;
55
55
  try {
@@ -58,17 +58,17 @@ export function playOneShot(url, onEnd) {
58
58
  catch {
59
59
  /* 요소를 못 만들면 울릴 수 없다 — 부르는 쪽은 `done` 하나만 보면 된다.
60
60
  빈 요소를 돌려주는 것은 `el` 을 쥐고 `pause()` 를 부르는 쪽 때문이다. */
61
- 끝(false);
61
+ finish(false);
62
62
  return { el: {}, stop: () => { }, done };
63
63
  }
64
- el.onended = () => 끝(true);
65
- el.onerror = () => 끝(false);
64
+ el.onended = () => finish(true);
65
+ el.onerror = () => finish(false);
66
66
  /* `play()` 의 거절도 "못 틀었다" 다(자동재생 막힘 등) */
67
67
  try {
68
- void el.play().catch(() => 끝(false));
68
+ void el.play().catch(() => finish(false));
69
69
  }
70
70
  catch {
71
- 끝(false);
71
+ finish(false);
72
72
  }
73
73
  return {
74
74
  el,
@@ -79,7 +79,7 @@ export function playOneShot(url, onEnd) {
79
79
  el.pause();
80
80
  }
81
81
  catch { /* 이미 정리됐다 */ }
82
- 끝(false);
82
+ finish(false);
83
83
  },
84
84
  done,
85
85
  };