@ihabdevteam/core 0.322.0 → 0.324.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 (198) hide show
  1. package/CHANGELOG.md +557 -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 +55 -17
  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 +14 -2
  149. package/dist/packages/core/src/i18n/locales/ko.json +13 -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 +129 -105
  174. package/dist/packages/core/src/utils/phonemePairSearchTypes.d.ts +2 -0
  175. package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
  176. package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
  177. package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
  178. package/dist/packages/core/src/utils/safeHref.js +4 -4
  179. package/dist/packages/core/src/utils/savedWordTables.js +5 -5
  180. package/dist/packages/core/src/utils/speak.js +12 -12
  181. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  182. package/dist/packages/core/src/utils/textSort.js +38 -48
  183. package/dist/packages/core/src/utils/theme.js +3 -3
  184. package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
  185. package/dist/packages/core/src/utils/ttsConfig.js +2 -2
  186. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +1 -1
  187. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +65 -45
  188. package/dist/packages/core/src/utils/wordContrast.js +3 -3
  189. package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
  190. package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
  191. package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
  192. package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
  193. package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
  194. package/dist/packages/core/src/utils/wornDate.js +14 -14
  195. package/dist/packages/core/src/version.d.ts +1 -1
  196. package/dist/packages/core/src/version.js +1 -1
  197. package/dist/utils.cjs.js +4 -4
  198. package/package.json +1 -1
@@ -36,9 +36,9 @@ function audioDurationOf(a) {
36
36
  try {
37
37
  const s = a.seekable;
38
38
  if (s && s.length > 0) {
39
- const 끝 = s.end(s.length - 1);
40
- if (Number.isFinite(끝) && 끝 > 0)
41
- return 끝;
39
+ const end = s.end(s.length - 1);
40
+ if (Number.isFinite(end) && end > 0)
41
+ return end;
42
42
  }
43
43
  }
44
44
  catch { /* seekable 을 못 읽는 환경 */ }
@@ -60,11 +60,11 @@ function audioDurationOf(a) {
60
60
  * 틀 사이가 400ms 를 넘으면 **칸이 보이는지와 함께** 한 번 찍는다. 운영 빌드에서는
61
61
  * 아무 일도 안 한다.
62
62
  */
63
- const 느린틀 = 400;
63
+ const SLOW_FRAME_MS = 400;
64
64
  /** 이만큼 멀쩡했다가 다시 느려지면 **새 고비**로 센다(ms) */
65
- const 고비사이 = 2000;
65
+ const SPIKE_GAP_MS = 2000;
66
66
  /** 고비를 이만큼까지만 알린다 — 그 뒤로는 조용히 둔다(콘솔이 넘치면 진짜 봐야 할 것이 묻힌다) */
67
- const 고비한도 = 5;
67
+ const SPIKE_LIMIT = 5;
68
68
  export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, sequencer, leadInMs = 0, tailMs = 0, }) {
69
69
  const rafRef = useRef(null);
70
70
  /**
@@ -78,11 +78,11 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
78
78
  * 그래서 **고비마다** 다시 말한다: 틀이 2초 넘게 멀쩡하다가 다시 느려지면 새 고비로 센다.
79
79
  * 매 틀 찍지는 않는다(한 고비에 한 번) — 콘솔이 넘치면 진짜 봐야 할 것이 묻힌다.
80
80
  */
81
- const 느린고비Ref = useRef(0);
81
+ const slowSpikesRef = useRef(0);
82
82
  /** 마지막으로 느렸던 때. **아직 없으면 `null`** — 0 을 쓰면 안 된다:
83
83
  `performance.now()` 는 쪽이 열린 뒤부터 세므로 첫 1~2초 안에 느려지면
84
84
  `지금 - 0` 이 고비사이를 못 넘어 **첫 고비를 통째로 놓친다**(시험이 잡았다). */
85
- const 마지막느린때Ref = useRef(null);
85
+ const lastSlowAtRef = useRef(null);
86
86
  const stop = useCallback(() => {
87
87
  if (rafRef.current === null)
88
88
  return;
@@ -93,46 +93,46 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
93
93
  // 이미 돌고 있으면 겹쳐 걸지 않는다 — 두 사슬이 같은 값을 다투면 마커가 떤다
94
94
  if (rafRef.current !== null)
95
95
  return;
96
- let 앞틀 = 0;
96
+ let prevFrame = 0;
97
97
  const step = () => {
98
98
  /* 틀 사이가 벌어졌나 — 값을 그리기 전에 본다(그리는 일과 무관하다) */
99
99
  if (isDev) {
100
- const 지금 = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
101
- if (앞틀 > 0 && 지금 - 앞틀 > 느린틀) {
100
+ const now = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
101
+ if (prevFrame > 0 && now - prevFrame > SLOW_FRAME_MS) {
102
102
  /** 멀쩡하다가 다시 느려졌으면 **새 고비**다 — 그때 다시 말한다 */
103
- const 새고비 = 마지막느린때Ref.current === null || 지금 - 마지막느린때Ref.current > 고비사이;
104
- 마지막느린때Ref.current = 지금;
105
- if (새고비 && 느린고비Ref.current < 고비한도) {
106
- 느린고비Ref.current += 1;
107
- const 고비 = 느린고비Ref.current;
108
- let 숨었나 = null;
103
+ const newSpike = lastSlowAtRef.current === null || now - lastSlowAtRef.current > SPIKE_GAP_MS;
104
+ lastSlowAtRef.current = now;
105
+ if (newSpike && slowSpikesRef.current < SPIKE_LIMIT) {
106
+ slowSpikesRef.current += 1;
107
+ const spikes = slowSpikesRef.current;
108
+ let hiddenReason = null;
109
109
  try {
110
- 숨었나 = typeof document !== 'undefined' ? document.visibilityState : null;
110
+ hiddenReason = typeof document !== 'undefined' ? document.visibilityState : null;
111
111
  }
112
112
  catch { /* 문서 없는 데 */ }
113
- devWarnOnce(`progress-ticker-slow-frames#${고비}`, `[ihab-core] 진행 사슬의 틀 사이가 ${Math.round(지금 - 앞틀)}ms 입니다(보통 16ms).`, 숨었나 === 'hidden'
113
+ devWarnOnce(`progress-ticker-slow-frames#${spikes}`, `[ihab-core] 진행 사슬의 틀 사이가 ${Math.round(now - prevFrame)}ms 입니다(보통 16ms).`, hiddenReason === 'hidden'
114
114
  ? '이 칸이 **숨어 있어** 브라우저가 틀을 줄였습니다 — 재생바·파형은 틀마다 그려지므로, 지금 재는 값은 **사람이 보는 화면과 다릅니다.** 칸을 앞으로 꺼내고 다시 재십시오.'
115
- : '칸은 보이는데 틀이 느립니다 — 같은 화면에서 무거운 일이 도는지 보십시오. 재생바·파형이 끊겨 보이는 것은 이 탓일 수 있습니다.', 고비 >= 고비한도 ? `(${고비}번째 — 더는 안 알립니다)` : `(${고비}번째)`);
115
+ : '칸은 보이는데 틀이 느립니다 — 같은 화면에서 무거운 일이 도는지 보십시오. 재생바·파형이 끊겨 보이는 것은 이 탓일 수 있습니다.', spikes >= SPIKE_LIMIT ? `(${spikes}번째 — 더는 안 알립니다)` : `(${spikes}번째)`);
116
116
  }
117
117
  }
118
- else if (앞틀 > 0) {
118
+ else if (prevFrame > 0) {
119
119
  /* 멀쩡한 틀이 이어지는 동안에는 고비가 안 자란다 — 위 `새고비` 가 시계로만 가름하므로
120
120
  여기서 할 일은 없다. 이 가지는 읽는 사람을 위한 자리다. */
121
121
  }
122
- 앞틀 = 지금;
122
+ prevFrame = now;
123
123
  }
124
124
  try {
125
125
  const a = audioRef.current;
126
- const 트랙 = Array.isArray(sourcesRef.current) ? sourcesRef.current[currentIndexRef.current || 0] : undefined;
127
- const 트랙길이알아 = !!트랙 && Number.isFinite(트랙.duration) && (트랙.duration ?? 0) > 0;
126
+ const track = Array.isArray(sourcesRef.current) ? sourcesRef.current[currentIndexRef.current || 0] : undefined;
127
+ const trackDurationKnown = !!track && Number.isFinite(track.duration) && (track.duration ?? 0) > 0;
128
128
  // 트랙 길이를 알면 progressOf 가 그것을 쓴다 — <audio> 길이는 모를 때만 본다
129
- const 총길이 = 트랙길이알아 ? 0 : audioDurationOf(a);
129
+ const totalDuration = trackDurationKnown ? 0 : audioDurationOf(a);
130
130
  const fill = fillRef.current;
131
131
  /* 뜸이 있으면 **뜸을 포함한 시간선**으로 잰다. 없으면(기본 0) 전과 같은 길이다. */
132
- const 뜸있다 = (leadInMs || 0) > 0 || (tailMs || 0) > 0;
132
+ const hasPad = (leadInMs || 0) > 0 || (tailMs || 0) > 0;
133
133
  /* **지금 뜸이 도는가** — 길이를 몰라도 뜸은 제 길이를 안다(아래 참고) */
134
- const 뜸중 = 뜸있다 && (sequencer?.positionRef.current?.phase ?? 'sound') !== 'sound';
135
- if (총길이 === null && !뜸중) {
134
+ const inPad = hasPad && (sequencer?.positionRef.current?.phase ?? 'sound') !== 'sound';
135
+ if (totalDuration === null && !inPad) {
136
136
  /* 길이를 모른다(스트리밍 등으로 duration 이 Infinity·NaN 이고 되감을 수 있는 끝도 없다) —
137
137
  마커를 0 에 붙여 두면 "안 움직이는 재생"으로 읽힌다. 진행을 모르니 마커를 숨긴다. */
138
138
  try {
@@ -148,22 +148,22 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
148
148
  **아무것도 안 그렸다.** 뜸은 제 길이를 아는 구간이다(`leadInMs`·`tailMs` 를
149
149
  여기서 받고 있다). 그래서 길이를 모르면 **소리를 0 으로 둔 시간표**로 잰다 —
150
150
  `timelineProgress` 에 그 사정을 적어 뒀다. 사용자가 (86) 에서 직접 짚은 자리다. */
151
- const 잴길이 = 트랙길이알아 ? 트랙.duration : (총길이 || 0);
151
+ const durationToUse = trackDurationKnown ? track.duration : (totalDuration || 0);
152
152
  /* **어림으로 그리는 자리를 스스로 말하게 한다**(2026-10-06).
153
153
  위 한 줄(`총길이 === null`)만 보던 것을 **길이가 0 인 것 전부**로 넓혔다 —
154
154
  `<audio>` 가 `duration === 0` 을 주는 동안에도 같은 어림을 그리는데 그때는
155
155
  말이 없었다. 말귀가 앱에서 두 번 다 길이를 알고 있었다고 재 왔으므로(0.744 ·
156
156
  0.72, `readyState=4`) **이 가지는 지금 길에서는 안 타야 한다.** 타면 그 자리를
157
157
  알아야 한다 — 안 타는 것을 **재서** 알 수 있게 띄운다. */
158
- if (isDev && 뜸중 && !(잴길이 > 0)) {
158
+ if (isDev && inPad && !(durationToUse > 0)) {
159
159
  devWarnOnce('progress-ticker-pad-without-duration', '[ihab-core] 뜸이 도는데 **소리 길이를 모릅니다** — 재생바가 뜸만으로 어림잡아 움직입니다.', '소리 길이 재기(measureAudioDuration)가 0 을 줬거나 `<audio>` 가 아직 안 실렸습니다. 길이를 알게 되면 마커가 그 순간 **뒤로 물러납니다**(어림은 늘 참보다 큽니다). 이 줄이 보이면 그 자리를 알려 주십시오 — 평소 길에서는 안 나와야 합니다.');
160
160
  }
161
- const pos = 뜸있다
161
+ const pos = hasPad
162
162
  ? progressWithPad({
163
163
  phase: sequencer?.positionRef.current?.phase ?? 'sound',
164
164
  phaseElapsedMs: sequencer?.positionRef.current ? Math.max(0, Date.now() - sequencer.positionRef.current.at) : 0,
165
165
  currentTime: a && typeof a.currentTime === 'number' ? a.currentTime : 0,
166
- trackDurationSec: 잴길이,
166
+ trackDurationSec: durationToUse,
167
167
  leadInMs: leadInMs || 0,
168
168
  tailMs: tailMs || 0,
169
169
  })
@@ -173,7 +173,7 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
173
173
  index: currentIndexRef.current,
174
174
  /* 여기 올 때 `총길이` 는 널이 아니다 — 널이면 위에서 숨기고 왔다(뜸이 없으면 뜸중도 거짓이다).
175
175
  타입만 좁혀 준다. */
176
- audioDuration: 총길이 ?? 0,
176
+ audioDuration: totalDuration ?? 0,
177
177
  });
178
178
  try {
179
179
  if (fill) {
@@ -18,7 +18,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
18
18
  언마운트 뒤에 온 end 가 떠난 화면에 완료를 알렸다. 번호가 다른 end 는 넘긴다. */
19
19
  const ttsTokenRef = useRef(0);
20
20
  /** 이 훅이 말을 건 적이 있나 — 떠날 때 **제 말만** 끊는다(마운트만 한 단계가 다른 곳의 말을 끊던 것) */
21
- const 말했나Ref = useRef(false);
21
+ const spokeRef = useRef(false);
22
22
  useEffect(() => {
23
23
  // cleanup TTS resources on unmount
24
24
  return () => {
@@ -28,12 +28,12 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
28
28
  cancelAnimationFrame(ttsRafRef.current);
29
29
  ttsRafRef.current = null;
30
30
  }
31
- if (말했나Ref.current && window.speechSynthesis) {
31
+ if (spokeRef.current && window.speechSynthesis) {
32
32
  try {
33
33
  window.speechSynthesis.cancel();
34
34
  }
35
35
  catch (e) { }
36
- 말했나Ref.current = false;
36
+ spokeRef.current = false;
37
37
  }
38
38
  }
39
39
  catch (e) { }
@@ -75,11 +75,11 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
75
75
  }
76
76
  catch (e) { }
77
77
  const rawTts = text ?? ttsText;
78
- const 문장들 = (Array.isArray(rawTts) ? rawTts.filter((s) => !!s) : [rawTts || '']);
79
- if (문장들.length === 0)
80
- 문장들.push('');
78
+ const sentences = (Array.isArray(rawTts) ? rawTts.filter((s) => !!s) : [rawTts || '']);
79
+ if (sentences.length === 0)
80
+ sentences.push('');
81
81
  const rate = Math.max(0.3, Math.min(3, playbackRate));
82
- ttsEstimatedDurationRef.current = 문장들.reduce((합, s) => 합 + estimateTTSDuration(s, rate || 1), 0);
82
+ ttsEstimatedDurationRef.current = sentences.reduce((sum, s) => sum + estimateTTSDuration(s, rate || 1), 0);
83
83
  ttsStartRef.current = performance.now();
84
84
  setProgress(0);
85
85
  setPlaybackState('playing');
@@ -110,7 +110,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
110
110
  }
111
111
  catch (e) { }
112
112
  };
113
- const 끝처리 = () => {
113
+ const onDone = () => {
114
114
  if (myToken !== ttsTokenRef.current)
115
115
  return;
116
116
  try {
@@ -133,8 +133,8 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
133
133
  }
134
134
  catch (e) { }
135
135
  };
136
- const 말하기 = (i) => {
137
- const utt = new SpeechSynthesisUtterance(문장들[i]);
136
+ const speakAt = (i) => {
137
+ const utt = new SpeechSynthesisUtterance(sentences[i]);
138
138
  try {
139
139
  utt.rate = rate;
140
140
  }
@@ -147,21 +147,21 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
147
147
  if (myToken !== ttsTokenRef.current)
148
148
  return;
149
149
  // 남은 문장이 있으면 이어 말한다 — 진행 표시는 전체 추정 길이로 계속 흐른다
150
- if (i < 문장들.length - 1) {
150
+ if (i < sentences.length - 1) {
151
151
  try {
152
- 말하기(i + 1);
152
+ speakAt(i + 1);
153
153
  }
154
154
  catch (e) {
155
- 끝처리();
155
+ onDone();
156
156
  }
157
157
  return;
158
158
  }
159
- 끝처리();
159
+ onDone();
160
160
  };
161
161
  /* 합성이 실패하면(synthesis-failed·not-allowed·language-unavailable) end 없이 error 만 온다 — 끝난 것과 같이 정리한다.
162
162
  전에는 재생 상태·소음이 그대로 남고 handleEnd·onDone 이 안 불려 문항이 넘어가지 않았다. 정지·다시 시작이 올린 번호의
163
163
  error(취소)는 onend 와 같이 넘긴다. 중간 문장이 실패하면 남은 문장도 같은 까닭으로 실패하기 쉬워 거기서 끝낸다. */
164
- utt.onerror = () => { 끝처리(); };
164
+ utt.onerror = () => { onDone(); };
165
165
  utt.onstart = () => {
166
166
  if (i > 0)
167
167
  return; // 진행 사슬은 첫 문장에서 한 번만 건다
@@ -174,11 +174,11 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
174
174
  }
175
175
  catch (e) { }
176
176
  };
177
- 말했나Ref.current = true;
177
+ spokeRef.current = true;
178
178
  synth.speak(utt);
179
179
  };
180
180
  try {
181
- 말하기(0);
181
+ speakAt(0);
182
182
  }
183
183
  catch (e) {
184
184
  try {
@@ -38,10 +38,10 @@ timeoutMs = 2500, }) {
38
38
  readyRef.current = ready;
39
39
  if (!ready || !resolversRef.current.length)
40
40
  return;
41
- const 기다리던것 = resolversRef.current;
41
+ const waiting = resolversRef.current;
42
42
  resolversRef.current = [];
43
- for (const 깨우기 of 기다리던것)
44
- 깨우기(true);
43
+ for (const wake of waiting)
44
+ wake(true);
45
45
  }, []);
46
46
  /** 새 트랙을 물렸다 — 이전 트랙의 '준비됨'을 물려받지 않는다 */
47
47
  const markNotReady = useCallback(() => { readyRef.current = false; }, []);
@@ -53,22 +53,22 @@ timeoutMs = 2500, }) {
53
53
  if (readyRef.current)
54
54
  return Promise.resolve(true);
55
55
  return new Promise((resolve) => {
56
- const 풀기 = (계속) => {
56
+ const settle = (go) => {
57
57
  clearTimeout(timer);
58
- resolversRef.current = resolversRef.current.filter((r) => r !== 풀기);
59
- resolve(계속);
58
+ resolversRef.current = resolversRef.current.filter((r) => r !== settle);
59
+ resolve(go);
60
60
  };
61
- const timer = setTimeout(() => 풀기(true), timeoutMs);
62
- resolversRef.current.push(풀기);
61
+ const timer = setTimeout(() => settle(true), timeoutMs);
62
+ resolversRef.current.push(settle);
63
63
  });
64
64
  }, [showSoundWaveForm, showNoiseWaveForm, timeoutMs]);
65
65
  /* 화면을 떠날 때 기다리던 것들을 `false`(취소)로 놓아 준다. 매달린 약속·상한 타이머를 남기지 않되,
66
66
  `true` 로 풀면 부르는 쪽이 사라진 화면의 재생을 이어 갔다(떼어진 <audio> 에 play). 부르는 쪽은 false 면 멈춘다. */
67
67
  useEffect(() => () => {
68
- const 남은것 = resolversRef.current;
68
+ const left = resolversRef.current;
69
69
  resolversRef.current = [];
70
- for (const 깨우기 of 남은것)
71
- 깨우기(false);
70
+ for (const wake of left)
71
+ wake(false);
72
72
  }, []);
73
73
  return { onReadyChange, waitForReady, markNotReady };
74
74
  }
@@ -29,17 +29,17 @@ export default function UpdateAlert({ title, message, actionLabel, onAction, ico
29
29
  /* 닫기(×)를 눌러 부모가 알림을 치우면 그 단추에 있던 포커스가 body 로 떨어졌다. 닫기로 떠날 때만(화면 전체가 내려갈 때는 아님)
30
30
  떼기 직전(레이아웃 효과 정리 — 아직 문서에 붙어 있다) 알림 앞의 포커스 받을 곳으로 옮긴다. */
31
31
  const rootRef = useRef(null);
32
- const 닫기눌림Ref = useRef(false);
32
+ const closePressedRef = useRef(false);
33
33
  useLayoutEffect(() => {
34
34
  const root = rootRef.current;
35
35
  /* 낱장과 스택이 함께 뜨면 둘 다 우상단 16px 에 붙어 겹쳤다 — 자리(낱장 자신 또는 감싼 스택)를 올려 문서 순서대로 아래로 쌓는다 */
36
- const 자리 = root?.parentElement?.closest('.update-alert-stack') ?? root;
37
- const 내리기 = 자리 ? registerUpdateAlertSlot(자리) : null;
36
+ const slotEl = root?.parentElement?.closest('.update-alert-stack') ?? root;
37
+ const unregister = slotEl ? registerUpdateAlertSlot(slotEl) : null;
38
38
  return () => {
39
- 내리기?.();
40
- if (닫기눌림Ref.current && root && root.contains(document.activeElement))
39
+ unregister?.();
40
+ if (closePressedRef.current && root && root.contains(document.activeElement))
41
41
  moveFocusOutside(root);
42
42
  };
43
43
  }, []);
44
- return (_jsxs("div", { ref: rootRef, className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: () => { 닫기눌림Ref.current = true; onClose(); }, "aria-label": t('common.close', '닫기'), children: "\u00D7" })), _jsxs("div", { className: "update-alert__top", children: [_jsx("div", { className: "update-alert__icon", children: icon ?? _jsx(Download, { size: 20, strokeWidth: 2 }) }), _jsxs("div", { className: "update-alert__body", children: [_jsx("div", { className: "update-alert__title h4", children: title }), _jsx("div", { className: "update-alert__message body-3", children: message })] })] }), onAction && (_jsx(Button, { htmlType: "button", size: 32, variant: "main", shape: "pill", className: "update-alert__action", onClick: onAction, children: resolvedActionLabel }))] }));
44
+ return (_jsxs("div", { ref: rootRef, className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: () => { closePressedRef.current = true; onClose(); }, "aria-label": t('common.close', '닫기'), children: "\u00D7" })), _jsxs("div", { className: "update-alert__top", children: [_jsx("div", { className: "update-alert__icon", children: icon ?? _jsx(Download, { size: 20, strokeWidth: 2 }) }), _jsxs("div", { className: "update-alert__body", children: [_jsx("div", { className: "update-alert__title h4", children: title }), _jsx("div", { className: "update-alert__message body-3", children: message })] })] }), onAction && (_jsx(Button, { htmlType: "button", size: 32, variant: "main", shape: "pill", className: "update-alert__action", onClick: onAction, children: resolvedActionLabel }))] }));
45
45
  }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cx } from '../../utils/cx';
4
+ import { toPhonemeCodes, PHONEME_NONE } from '../../utils/phonemeSectionCodes';
4
5
  import { useCallback, useMemo } from 'react';
5
6
  import { useTranslation } from 'react-i18next';
6
7
  import useMediaQuery from '../../hooks/useMediaQuery';
@@ -72,7 +73,12 @@ export default function PhonemeSelectBlock({ sections, selectedList, count, onCo
72
73
  : selectedList.map((selected, i) => (_jsx(FilterPanel, { title: panelTitle(phonemeLabel(i + 1), i === 0), sections: combinationSectionsForSlot ? combinationSectionsForSlot(selected, i) : combinationSections, selected: selected, onSelect: onSelect?.(i), align: "newline" }, i))) }) })), beforeSearchContent && (_jsx("div", { className: "phoneme-select-block__before-search", children: beforeSearchContent })), (onClearPhoneme || onSearch || searchChecking) && (() => {
73
74
  // 종성 '제외'(없음)만 선택된 슬롯은 실제 음소를 고른 것으로 치지 않는다 —
74
75
  // "받침 없음"은 단독으로는 검색 조건이 되기엔 너무 느슨한 보조 조건이라서다.
75
- const isEmpty = selectedList.every(sel => Object.entries(sel).every(([k, v]) => !v || (k === '종성' && v === '없음')));
76
+ /* 열쇠를 **코드로 가려** 본다 — 고른값은 FilterPanel 의 절 이름(지금 언어)으로 들어오고
77
+ 저장된 표는 옛 한글 열쇠다. `toPhonemeCodes` 가 둘 다 받아 코드로 돌린다. */
78
+ const isEmpty = selectedList.every(sel => {
79
+ const coded = toPhonemeCodes(sel);
80
+ return Object.entries(coded).every(([k, v]) => !v || (k === 'coda' && v === PHONEME_NONE));
81
+ });
76
82
  return (_jsxs("div", { className: "phoneme-select-block__search-row", onClick: isEmpty ? onSearchEmpty : undefined, children: [onClearPhoneme && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordPairs.resetOptions', '검색 옵션 초기화'), selected: false, disabled: isEmpty, onClick: isEmpty ? undefined : onClearPhoneme })), onSearch ? (
77
83
  /* 확인 중에도 단추를 **바꿔 끼우지 않는다** — 전에는 스피너 칸으로 갈아 끼워, 키보드로 누른 단추가 사라지며 초점이
78
84
  body 로 빠졌다. 막힘은 aria-disabled(onDisabledClick)로만 걸어 초점을 남기고, aria-busy·스피너로 알린다. */
@@ -18,9 +18,9 @@ export default function TableFilterPanel(props) {
18
18
  const { t } = useTranslation();
19
19
  const { title, className, collapsible, defaultCollapsed, collapsed, onCollapsedChange, headerSummary } = props;
20
20
  /* 두 갈래(껍데기 모드·표 설정 모드)가 같은 접기 값을 쓴다 — 한쪽만 넘기면 화면마다 다르게 접힌다 */
21
- const 접기 = { collapsible, defaultCollapsed, collapsed, onCollapsedChange, headerSummary };
21
+ const collapseProps = { collapsible, defaultCollapsed, collapsed, onCollapsedChange, headerSummary };
22
22
  if (isShellProps(props)) {
23
- return (_jsx(FilterPanel, { className: className, title: title ?? t('wordTable.filterTitle', '표 설정'), sections: props.sections, selected: props.selected, onSelect: props.onSelect, ...접기, align: "inline" }));
23
+ return (_jsx(FilterPanel, { className: className, title: title ?? t('wordTable.filterTitle', '표 설정'), sections: props.sections, selected: props.selected, onSelect: props.onSelect, ...collapseProps, align: "inline" }));
24
24
  }
25
25
  const { hasImage, hasColloquial, contentTypes, orientation, showOutline, cutLine, showDivLine, setters, showCutLine = false, showDivLine2 = false, middleSections = [], extraSelected = {}, onExtraSelect, showPaperBands = false, showTopInfo = true, showBottomInfo = true, } = props;
26
26
  const sections = [
@@ -42,5 +42,5 @@ export default function TableFilterPanel(props) {
42
42
  return;
43
43
  onExtraSelect?.(value, sectionLabel);
44
44
  };
45
- return (_jsx(FilterPanel, { className: className, title: title ?? t('wordTable.filterTitle', '표 설정'), sections: sections, selected: selected, onSelect: onSelect, ...접기, sectionAlign: "horizontal" }));
45
+ return (_jsx(FilterPanel, { className: className, title: title ?? t('wordTable.filterTitle', '표 설정'), sections: sections, selected: selected, onSelect: onSelect, ...collapseProps, sectionAlign: "horizontal" }));
46
46
  }
@@ -41,30 +41,30 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
41
41
  /* **검색어도 결과 수도 없이 items 만 받으면 검색 결과가 아니라 저장된 목록을 보여 주는 것**(말귀 공유 링크 열람 —
42
42
  치료사가 표에서 끌어 옮긴 순서가 저장돼 있다). 그때는 받은 순서를 기본으로 둔다 — 가나다로 다시 늘어서
43
43
  표의 칸 순서가 사라졌다. 검색 화면들은 결과 수(resultCount)를 늘 주므로 지금처럼 가나다 · 시작 우선이다. */
44
- const 열람 = searchQuery === undefined && resultCount === undefined;
45
- const 기본정렬 = hasQuery ? 'match' : 열람 ? 'original' : 'ganada';
44
+ const browsing = searchQuery === undefined && resultCount === undefined;
45
+ const defaultSort = hasQuery ? 'match' : browsing ? 'original' : 'ganada';
46
46
  // 첫 렌더에 이미 검색어가 있어도 '시작 우선'으로 시작한다 — 아래 조정은 검색어 유무가 **바뀔 때**만 돌아서,
47
47
  // 초깃값을 'ganada' 로 고정하면 검색어를 들고 마운트한 화면은 가나다순에 머물렀다.
48
- const [sortKey, setSortKey] = useState(() => 기본정렬);
48
+ const [sortKey, setSortKey] = useState(() => defaultSort);
49
49
  const [pageSizeStr, setPageSizeStr] = useState('50');
50
50
  const [page, setPage] = useState(1);
51
51
  // 검색어 유무가 바뀌면 그에 맞는 기본 정렬로 되돌린다(렌더 중 상태 조정).
52
52
  const [prevHasQuery, setPrevHasQuery] = useState(hasQuery);
53
53
  if (prevHasQuery !== hasQuery) {
54
54
  setPrevHasQuery(hasQuery);
55
- setSortKey(기본정렬);
55
+ setSortKey(defaultSort);
56
56
  }
57
- const effectiveSortKey = (sortKey === 'match' && !hasQuery) || (sortKey === 'original' && !열람) ? 기본정렬 : sortKey;
57
+ const effectiveSortKey = (sortKey === 'match' && !hasQuery) || (sortKey === 'original' && !browsing) ? defaultSort : sortKey;
58
58
  const sortOptions = useMemo(() => [
59
59
  ...(hasQuery ? [{ value: 'match', label: t('wordSearch.sort.match', { defaultValue: '시작 우선' }) }] : []),
60
- ...(열람 ? [{ value: 'original', label: t('wordSearch.sort.original', { defaultValue: '받은 순서' }) }] : []),
60
+ ...(browsing ? [{ value: 'original', label: t('wordSearch.sort.original', { defaultValue: '받은 순서' }) }] : []),
61
61
  { value: 'ganada', label: t('wordSearch.sort.ganada', { defaultValue: '가나다순 ↑' }) },
62
62
  { value: 'ganada_desc', label: t('wordSearch.sort.ganadaDesc', { defaultValue: '가나다순 ↓' }) },
63
63
  { value: 'syllable', label: t('wordSearch.sort.syllable', { defaultValue: '음절 수 ↑' }) },
64
64
  { value: 'syllable_desc', label: t('wordSearch.sort.syllableDesc', { defaultValue: '음절 수 ↓' }) },
65
65
  { value: 'grade', label: t('wordSearch.sort.grade', { defaultValue: '등급순 ↑' }) },
66
66
  { value: 'grade_desc', label: t('wordSearch.sort.gradeDesc', { defaultValue: '등급순 ↓' }) },
67
- ], [t, hasQuery, 열람]);
67
+ ], [t, hasQuery, browsing]);
68
68
  const pageSizeOptions = useMemo(() => ['10', '25', '50', '100'].map(v => ({
69
69
  value: v, label: t('wordSearch.pageSize', { count: Number(v), defaultValue: `${v}개씩 보기` }),
70
70
  })), [t]);
@@ -72,7 +72,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
72
72
  const firstWord = (it) => it.words?.[0];
73
73
  /* '시작 우선' 순위는 검색어가 바뀔 때 판별기를 한 번 만들고 항목마다 한 번만 센다 — 비교할 때마다 검색어를 다시 펼치고
74
74
  localeCompare 를 불러 결과가 바뀔 때 23k 개에 38ms 걸렸다. */
75
- const 시작순위 = useMemo(() => {
75
+ const initialRanks = useMemo(() => {
76
76
  const qLen = [...q].length;
77
77
  const m = makeWordQueryMatcher(q);
78
78
  const memo = new Map();
@@ -104,7 +104,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
104
104
  // 시작 우선 — 검색어로 시작하는 단어를 위로. 앞 q 글자만 잘라 매칭하면 "첫 음절부터 일치"만
105
105
  // 남는다(초성 검색도 동일: 'ㄴ' → '나무'가 위). 동순위는 가나다.
106
106
  case 'match': {
107
- return 시작순위(a) - 시작순위(b) || compareKorean(label(a), label(b));
107
+ return initialRanks(a) - initialRanks(b) || compareKorean(label(a), label(b));
108
108
  }
109
109
  case 'ganada': return compareKorean(label(a), label(b));
110
110
  case 'ganada_desc': return compareKorean(label(b), label(a));
@@ -115,7 +115,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
115
115
  // 받은 순서 — Array.prototype.sort 는 안정 정렬이라 0 이면 받은 차례가 남는다
116
116
  default: return 0;
117
117
  }
118
- }, [effectiveSortKey, 시작순위]);
118
+ }, [effectiveSortKey, initialRanks]);
119
119
  const sortedItems = useMemo(() => items.slice().sort(sortCompare), [items, sortCompare]);
120
120
  const pageSize = Number(pageSizeStr) || 10;
121
121
  // 그룹 뷰(대립쌍표 음소대립)도 flat과 동일하게 정렬·페이지네이션한다 — 그룹 순서를 유지하고 그룹 내부를
@@ -12,7 +12,7 @@ import Popup from '../Popup/Popup';
12
12
  import WordTable, { WORD_TABLE_PAPER } from '../WordTable';
13
13
  import { chunkItems } from '../wordTableLayout';
14
14
  import TableFilterPanel from './TableFilterPanel';
15
- import { buildItemsPerPageSection, buildPageNumberSection, PAGE_NUM_POSITION_VALUE, parsePageNumberPosition, itemsPerPageFromChoice } from './wordTableSettings';
15
+ import { buildItemsPerPageSection, buildPageNumberSection, parsePageNumberPosition, itemsPerPageFromChoice } from './wordTableSettings';
16
16
  import Toast from '../Toast';
17
17
  import { useToastState } from '../../hooks/useToastState';
18
18
  import { getImageResolver, checkImageExists } from '../../utils/imageConfig';
@@ -24,24 +24,24 @@ import { CELL_COUNTS } from './wordTableBlockTypes';
24
24
  // ── 컴포넌트 ───────────────────────────────────────────────
25
25
  /** @public */
26
26
  /** 편집 칸의 낱말 → 저장 항목의 낱말. 편집 칸에서 보이던 구어체 발음·등급·품사·유형을 그대로 옮긴다. */
27
- function 항목낱말(e) {
27
+ function toResultWord(e) {
28
28
  return {
29
29
  label: e.word, subLabel: e.pronunciation, colloquialLabel: e.colloquialPronunciation, homonum: e.homonum,
30
30
  grade: e.grade, syllables: [...e.word].length, pos: e.pos, wordType: e.wordType,
31
31
  };
32
32
  }
33
33
  /** 동음이의어 번호까지 넣은 낱말들로 만든 id — 첫 낱말만 쓰면 '배'(1)·'배'(2), '가다|나다'·'가다|다다' 가 같은 id 였다 */
34
- function 항목id(entries) {
34
+ function itemId(entries) {
35
35
  return entries.map(e => (e.homonum != null ? `${e.word}#${e.homonum}` : e.word)).join('|');
36
36
  }
37
37
  /** 표 안에서 같은 낱말을 두 칸에 넣었을 때도 id 가 겹치지 않게 뒤에 ~2, ~3 을 붙인다 */
38
- function 겹치지않는id(list) {
39
- const 쓴 = new Set();
38
+ function withUniqueIds(list) {
39
+ const used = new Set();
40
40
  return list.map(it => {
41
41
  let id = it.id;
42
- for (let n = 2; 쓴.has(id); n++)
42
+ for (let n = 2; used.has(id); n++)
43
43
  id = `${it.id}~${n}`;
44
- 쓴.add(id);
44
+ used.add(id);
45
45
  return id === it.id ? it : { ...it, id };
46
46
  });
47
47
  }
@@ -78,14 +78,14 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
78
78
  const visibleCells = cells.slice(0, cellCount);
79
79
  /* 띠를 켠 표면 설정 판에 '상단 정보'·'하단 정보' 고르개가 선다 — 스위치를 따로 두지
80
80
  않는다(부르는 쪽이 둘을 어긋나게 줄 일이 없게). 고른 값은 `brand.top`·`bottom` 을 덮는다. */
81
- const 띠쓰나 = Boolean(brand?.show);
81
+ const showBand = Boolean(brand?.show);
82
82
  const [showTopInfo, setShowTopInfo] = useState(true);
83
83
  const [showBottomInfo, setShowBottomInfo] = useState(true);
84
- const 띠 = brand && { ...brand, top: showTopInfo, bottom: showBottomInfo };
84
+ const band = brand && { ...brand, top: showTopInfo, bottom: showBottomInfo };
85
85
  // 공통 단어 표 설정 상태
86
86
  const [orientation, setOrientation] = useState('landscape');
87
- const [contentTypes, setContentTypes] = useState(['이미지', '단어', '표준 발음']);
88
- const [cutLine, setCutLine] = useState('표시');
87
+ const [contentTypes, setContentTypes] = useState(['image', 'word', 'standardPron']);
88
+ const [cutLine, setCutLine] = useState('show');
89
89
  const [showOutline, setShowOutline] = useState(true);
90
90
  const [showDivLine, setShowDivLine] = useState(true);
91
91
  // 인쇄 캡처용 그리드 콘텐츠 ref (단일 페이지)
@@ -209,7 +209,7 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
209
209
  const middleSelected = {
210
210
  ...((isEditable || showCellCount) ? { [t('wordTable.filter.columns', '칸 수')]: `${count}칸` } : {}),
211
211
  ...(pagesEnabled && (items?.length ?? 0) > 0 ? { [t(hasPairs ? 'wordTable.filter.itemsPerPagePair' : 'wordTable.filter.itemsPerPageWord')]: `${effectiveItemsPerPage}개` } : {}),
212
- ...(isPaginated ? { [t('wordTable.filter.pageNumber', '페이지 번호')]: PAGE_NUM_POSITION_VALUE[pageNumberPosition] } : {}),
212
+ ...(isPaginated ? { [t('wordTable.filter.pageNumber', '페이지 번호')]: pageNumberPosition } : {}),
213
213
  };
214
214
  const handleMiddleSelect = (value, sectionLabel) => {
215
215
  if (sectionLabel === t('wordTable.filter.columns', '칸 수')) {
@@ -229,14 +229,14 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
229
229
  };
230
230
  // 저장 팝업용 현재 items / preview
231
231
  const saveItems = isEditable
232
- ? 겹치지않는id(visibleCells.filter((c) => c !== null && c.length > 0).map(c => ({
233
- id: 항목id(c),
234
- words: c.map(항목낱말),
232
+ ? withUniqueIds(visibleCells.filter((c) => c !== null && c.length > 0).map(c => ({
233
+ id: itemId(c),
234
+ words: c.map(toResultWord),
235
235
  })))
236
236
  : items ?? [];
237
237
  /* 저장한 뒤에는 **숨은 칸(cellCount 뒤)을 비운다** — 2칸으로 줄여 저장한 표를 4칸으로 늘리면 저장본에 없던
238
238
  셋째·넷째 낱말이 다시 떠, 저장한 것과 화면이 달랐다. 저장하지 않고 줄였다 늘리는 동안에는 그대로 들고 있는다. */
239
- const 숨은칸비우기 = () => {
239
+ const clearHiddenCells = () => {
240
240
  if (isEditable)
241
241
  setCells(prev => (prev.length > cellCount ? prev.slice(0, cellCount) : prev));
242
242
  };
@@ -250,13 +250,13 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
250
250
  if (savedLower)
251
251
  setSavedNames(prev => prev.includes(savedLower) ? prev : [...prev, savedLower]);
252
252
  onSaveComplete(trimmedName, saveItems, trimmedDescription || undefined, asNew);
253
- 숨은칸비우기();
253
+ clearHiddenCells();
254
254
  return;
255
255
  }
256
256
  // saveOnly: 이름 입력 팝업 없이 바로 저장(이름은 상위에서 처리)
257
257
  if (saveOnly && onSaveComplete) {
258
258
  onSaveComplete('', saveItems, undefined, asNew);
259
- 숨은칸비우기();
259
+ clearHiddenCells();
260
260
  return;
261
261
  }
262
262
  if (onSaveComplete) {
@@ -279,7 +279,7 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
279
279
  if (!saveAsTrimmed || saveAsCollides || !onSaveComplete)
280
280
  return;
281
281
  onSaveComplete(saveAsTrimmed, saveItems, trimmedDescription || undefined, true);
282
- 숨은칸비우기();
282
+ clearHiddenCells();
283
283
  setShowSaveAsPopup(false);
284
284
  };
285
285
  // 저장 버튼 활성 조건 — 인라인 모드에선 이름 필수·중복(others) 불가. 항목이 비어도 불가.
@@ -307,7 +307,7 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
307
307
  items: saveItems,
308
308
  existingNames: getSavedTables().map(tb => tb.name),
309
309
  saveAsNew,
310
- onSave: (name, description) => { onSaveComplete(name, saveItems, description, saveAsNew); 숨은칸비우기(); setShowSavePopup(false); },
310
+ onSave: (name, description) => { onSaveComplete(name, saveItems, description, saveAsNew); clearHiddenCells(); setShowSavePopup(false); },
311
311
  onCancel: () => setShowSavePopup(false),
312
312
  }), (isEditable || onAddItem) && activeCell !== null && renderWordSearch?.({
313
313
  cellIndex: activeCell,
@@ -316,25 +316,25 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
316
316
  const entry = entries[0];
317
317
  if (entry) {
318
318
  // 이미 있는 항목과 id 가 겹치면(같은 낱말의 다른 동음이의어·같은 낱말) onRemoveItem(id) 가 둘을 함께 지운다
319
- const 쓴id = new Set((items ?? []).map(it => it.id));
320
- let id = 항목id([entry]);
321
- for (let n = 2; 쓴id.has(id); n++)
322
- id = `${항목id([entry])}~${n}`;
323
- onAddItem({ id, words: [항목낱말(entry)] });
319
+ const usedIds = new Set((items ?? []).map(it => it.id));
320
+ let id = itemId([entry]);
321
+ for (let n = 2; usedIds.has(id); n++)
322
+ id = `${itemId([entry])}~${n}`;
323
+ onAddItem({ id, words: [toResultWord(entry)] });
324
324
  }
325
325
  }
326
326
  setActiveCell(null);
327
327
  },
328
328
  onClose: handleCloseWordSearch,
329
- }), !hideControls && (_jsx(TableFilterPanel, { title: filterTitle, collapsible: filterCollapsible, defaultCollapsed: filterDefaultCollapsed, collapsed: filterCollapsed, onCollapsedChange: onFilterCollapsedChange, headerSummary: filterHeaderSummary, hasImage: hasImage, hasColloquial: hasColloquial, contentTypes: contentTypes, orientation: orientation, showOutline: showOutline, cutLine: cutLine, showDivLine: showDivLine, setters: { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine, setShowTopInfo, setShowBottomInfo }, showCutLine: (pagesEnabled ? itemsPerPage : count) > 1, showDivLine2: hasPairs, middleSections: middleSections, extraSelected: middleSelected, onExtraSelect: handleMiddleSelect, showPaperBands: 띠쓰나, showTopInfo: showTopInfo, showBottomInfo: showBottomInfo })), isPaginated && pages ? (_jsx("div", { className: "word-table-block__pages", children: pages.map((page, i) => (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: itemsPerPage, items: page, onRemoveItem: onRemoveItem,
329
+ }), !hideControls && (_jsx(TableFilterPanel, { title: filterTitle, collapsible: filterCollapsible, defaultCollapsed: filterDefaultCollapsed, collapsed: filterCollapsed, onCollapsedChange: onFilterCollapsedChange, headerSummary: filterHeaderSummary, hasImage: hasImage, hasColloquial: hasColloquial, contentTypes: contentTypes, orientation: orientation, showOutline: showOutline, cutLine: cutLine, showDivLine: showDivLine, setters: { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine, setShowTopInfo, setShowBottomInfo }, showCutLine: (pagesEnabled ? itemsPerPage : count) > 1, showDivLine2: hasPairs, middleSections: middleSections, extraSelected: middleSelected, onExtraSelect: handleMiddleSelect, showPaperBands: showBand, showTopInfo: showTopInfo, showBottomInfo: showBottomInfo })), isPaginated && pages ? (_jsx("div", { className: "word-table-block__pages", children: pages.map((page, i) => (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: itemsPerPage, items: page, onRemoveItem: onRemoveItem,
330
330
  /* 쪽이 나뉘면 끌어 옮기기가 통째로 꺼졌다(말귀 단어 찾기: 9개부터). 쪽 안의 자리를 전체 자리로 옮겨 넘긴다 —
331
331
  쪽을 넘는 이동은 하지 않는다. */
332
332
  onReorder: onReorder && ((from, to) => onReorder(from + i * itemsPerPage, to + i * itemsPerPage)),
333
333
  /* 안쪽 표는 제 쪽 안의 번호를 준다 — 부르는 쪽에는 **표 전체 기준**으로 넘긴다
334
334
  (위 `onReorder` 와 같은 셈이다. 쪽이 몇으로 나뉘었는지는 부르는 쪽 일이 아니다). */
335
- renderCellAction: renderCellAction && ((n, item) => renderCellAction(n + i * itemsPerPage, item)), brand: 띠, code: code, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? visibleCells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined,
335
+ renderCellAction: renderCellAction && ((n, item) => renderCellAction(n + i * itemsPerPage, item)), brand: band, code: code, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? visibleCells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined,
336
336
  /* 편집 모드에는 얹을 항목이 없다 — 칸을 손으로 채우는 꼴이다 */
337
- renderCellAction: !isEditable ? renderCellAction : undefined, brand: 띠, code: code, showFillerAdd: !!onAddItem, contentRef: contentRef })), !hideControls && !saveOnly && (onReset || actionsLeftExtra != null) && (_jsxs("div", { className: "word-table-block__table-actions", children: [onReset && (_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: resolvedResetLabel, onClick: onReset })), actionsLeftExtra] })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
337
+ renderCellAction: !isEditable ? renderCellAction : undefined, brand: band, code: code, showFillerAdd: !!onAddItem, contentRef: contentRef })), !hideControls && !saveOnly && (onReset || actionsLeftExtra != null) && (_jsxs("div", { className: "word-table-block__table-actions", children: [onReset && (_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: resolvedResetLabel, onClick: onReset })), actionsLeftExtra] })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
338
338
  /* PanelRow의 라벨은 시각적으로만 붙어 있고 입력과 연결돼 있지 않아, 낭독기에는
339
339
  이름 없는 입력으로 읽혔다. placeholder는 자동완성 안내문("Tab 또는 space…")이
340
340
  섞여 있어 이름으로 부적절하므로 같은 라벨 문구를 넘긴다. */