@ihabdevteam/core 0.150.0 → 0.151.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/components.cjs.js +199 -186
  3. package/dist/components.esm.css +5 -5
  4. package/dist/hooks.cjs.js +5 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core-tokens.css +1 -1
  7. package/dist/ihabdevteam-core.cjs.js +200 -187
  8. package/dist/ihabdevteam-core.css +1 -1
  9. package/dist/ihabdevteam-core.d.ts +405 -67
  10. package/dist/packages/core/src/components/Accordion.d.ts +6 -0
  11. package/dist/packages/core/src/components/Accordion.js +5 -2
  12. package/dist/packages/core/src/components/Alert.d.ts +7 -1
  13. package/dist/packages/core/src/components/Alert.js +12 -3
  14. package/dist/packages/core/src/components/BarChart.js +7 -2
  15. package/dist/packages/core/src/components/Board.d.ts +5 -2
  16. package/dist/packages/core/src/components/Board.js +9 -3
  17. package/dist/packages/core/src/components/BottomNav.js +5 -4
  18. package/dist/packages/core/src/components/Breadcrumb.css +9 -0
  19. package/dist/packages/core/src/components/Breadcrumb.d.ts +12 -1
  20. package/dist/packages/core/src/components/Breadcrumb.js +8 -2
  21. package/dist/packages/core/src/components/BulkBar.css +2 -0
  22. package/dist/packages/core/src/components/BulkBar.js +21 -2
  23. package/dist/packages/core/src/components/Button.d.ts +12 -2
  24. package/dist/packages/core/src/components/Button.js +70 -13
  25. package/dist/packages/core/src/components/Calendar.d.ts +6 -1
  26. package/dist/packages/core/src/components/Calendar.js +3 -3
  27. package/dist/packages/core/src/components/DatePicker.d.ts +7 -1
  28. package/dist/packages/core/src/components/DatePicker.js +25 -15
  29. package/dist/packages/core/src/components/Dropdown.d.ts +1 -0
  30. package/dist/packages/core/src/components/Dropdown.js +7 -5
  31. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.d.ts +12 -1
  32. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +25 -8
  33. package/dist/packages/core/src/components/EmptyState.d.ts +5 -1
  34. package/dist/packages/core/src/components/EmptyState.js +4 -1
  35. package/dist/packages/core/src/components/ErrorState.d.ts +5 -2
  36. package/dist/packages/core/src/components/ErrorState.js +3 -1
  37. package/dist/packages/core/src/components/FileUpload.d.ts +9 -1
  38. package/dist/packages/core/src/components/FileUpload.js +8 -2
  39. package/dist/packages/core/src/components/FilterSectionInfo.js +14 -2
  40. package/dist/packages/core/src/components/Header.js +8 -5
  41. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -1
  42. package/dist/packages/core/src/components/Icon.d.ts +2 -1
  43. package/dist/packages/core/src/components/Icon.js +4 -1
  44. package/dist/packages/core/src/components/Loader.css +28 -0
  45. package/dist/packages/core/src/components/Loader.d.ts +5 -1
  46. package/dist/packages/core/src/components/Loader.js +4 -2
  47. package/dist/packages/core/src/components/NoticeBox.d.ts +1 -1
  48. package/dist/packages/core/src/components/NoticeBox.js +15 -6
  49. package/dist/packages/core/src/components/PanelRow.js +2 -1
  50. package/dist/packages/core/src/components/PassCard/PassCard.d.ts +8 -2
  51. package/dist/packages/core/src/components/PassCard/PassCard.js +25 -10
  52. package/dist/packages/core/src/components/PassCard/passCardProduct.d.ts +1 -1
  53. package/dist/packages/core/src/components/PassCard/passCardProduct.js +11 -2
  54. package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +11 -2
  55. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +18 -3
  56. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +10 -8
  57. package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
  58. package/dist/packages/core/src/components/Popup/LoadTablePopup.css +7 -0
  59. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +27 -5
  60. package/dist/packages/core/src/components/Popup/Popup.d.ts +8 -0
  61. package/dist/packages/core/src/components/Popup/Popup.js +6 -3
  62. package/dist/packages/core/src/components/Popup/SaveTablePopup.d.ts +5 -1
  63. package/dist/packages/core/src/components/Popup/TrainingResultPopup.css +3 -0
  64. package/dist/packages/core/src/components/Popup/TrainingResultPopup.d.ts +5 -0
  65. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +10 -4
  66. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.css +9 -0
  67. package/dist/packages/core/src/components/ProgressIndicator.css +5 -0
  68. package/dist/packages/core/src/components/ProgressIndicator.d.ts +3 -1
  69. package/dist/packages/core/src/components/ProgressIndicator.js +5 -3
  70. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.d.ts +4 -1
  71. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +27 -15
  72. package/dist/packages/core/src/components/Rating.d.ts +8 -2
  73. package/dist/packages/core/src/components/Rating.js +22 -8
  74. package/dist/packages/core/src/components/Report/ReportDocument.d.ts +10 -7
  75. package/dist/packages/core/src/components/Report/ReportDocument.js +39 -13
  76. package/dist/packages/core/src/components/RingChart.d.ts +1 -1
  77. package/dist/packages/core/src/components/RingChart.js +1 -1
  78. package/dist/packages/core/src/components/SegmentedControl.d.ts +4 -0
  79. package/dist/packages/core/src/components/SegmentedControl.js +9 -1
  80. package/dist/packages/core/src/components/ShortcutBadge.css +16 -1
  81. package/dist/packages/core/src/components/ShortcutBadge.d.ts +1 -0
  82. package/dist/packages/core/src/components/SideNav.js +8 -2
  83. package/dist/packages/core/src/components/SummaryStrip.d.ts +1 -1
  84. package/dist/packages/core/src/components/SummaryStrip.js +2 -2
  85. package/dist/packages/core/src/components/Table.d.ts +1 -1
  86. package/dist/packages/core/src/components/Table.js +4 -1
  87. package/dist/packages/core/src/components/Tabs.d.ts +1 -0
  88. package/dist/packages/core/src/components/Tabs.js +4 -1
  89. package/dist/packages/core/src/components/TextField.css +5 -6
  90. package/dist/packages/core/src/components/TextField.js +46 -3
  91. package/dist/packages/core/src/components/Toast.js +16 -2
  92. package/dist/packages/core/src/components/Tooltip/Tooltip.css +3 -1
  93. package/dist/packages/core/src/components/Tooltip/Tooltip.js +5 -2
  94. package/dist/packages/core/src/components/Tooltip/tooltipConstants.d.ts +10 -2
  95. package/dist/packages/core/src/components/Tooltip/tooltipConstants.js +10 -2
  96. package/dist/packages/core/src/components/TopBanner.d.ts +1 -1
  97. package/dist/packages/core/src/components/TopBanner.js +66 -8
  98. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.d.ts +1 -0
  99. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +65 -15
  100. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +3 -2
  101. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +10 -4
  102. package/dist/packages/core/src/components/Training/TrainingBlockButton.css +7 -0
  103. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
  104. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +18 -5
  105. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +93 -28
  106. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +1 -0
  107. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +16 -3
  108. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +25 -9
  109. package/dist/packages/core/src/components/Training/TrainingStepCard.js +19 -3
  110. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +15 -1
  111. package/dist/packages/core/src/components/Training/TrainingWordCard.css +16 -0
  112. package/dist/packages/core/src/components/Training/TrainingWordCard.d.ts +6 -0
  113. package/dist/packages/core/src/components/Training/TrainingWordCard.js +7 -2
  114. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +2 -2
  115. package/dist/packages/core/src/components/Training/trainingMissStats.d.ts +10 -0
  116. package/dist/packages/core/src/components/Training/trainingMissStats.js +19 -1
  117. package/dist/packages/core/src/components/Training/useFloatingFocus.d.ts +13 -0
  118. package/dist/packages/core/src/components/Training/useFloatingFocus.js +59 -0
  119. package/dist/packages/core/src/components/Training/useNoiseFade.js +20 -11
  120. package/dist/packages/core/src/components/Training/useProgressTicker.d.ts +0 -18
  121. package/dist/packages/core/src/components/Training/useProgressTicker.js +59 -14
  122. package/dist/packages/core/src/components/Training/useTTSPlayback.d.ts +1 -1
  123. package/dist/packages/core/src/components/Training/useTTSPlayback.js +56 -25
  124. package/dist/packages/core/src/components/Training/useWaveReady.d.ts +4 -1
  125. package/dist/packages/core/src/components/Training/useWaveReady.js +17 -8
  126. package/dist/packages/core/src/components/UpdateAlert.css +4 -2
  127. package/dist/packages/core/src/components/UpdateAlert.d.ts +3 -1
  128. package/dist/packages/core/src/components/UpdateAlert.js +21 -1
  129. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.d.ts +2 -0
  130. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +12 -4
  131. package/dist/packages/core/src/components/Word/WordSearchResultBlock.d.ts +6 -0
  132. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +26 -13
  133. package/dist/packages/core/src/components/Word/WordTableBlock.js +24 -21
  134. package/dist/packages/core/src/components/Word/WordTableThumbnail.js +1 -1
  135. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -10
  136. package/dist/packages/core/src/components/Word/useWordTablePrint.d.ts +1 -1
  137. package/dist/packages/core/src/components/Word/useWordTablePrint.js +9 -3
  138. package/dist/packages/core/src/components/Word/wordScoreStats.d.ts +14 -1
  139. package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -3
  140. package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -3
  141. package/dist/packages/core/src/components/Word/wordTableReview.d.ts +58 -1
  142. package/dist/packages/core/src/components/Word/wordTableReview.js +75 -17
  143. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +9 -0
  144. package/dist/packages/core/src/components/Word/wordTableSettings.js +11 -0
  145. package/dist/packages/core/src/components/WordTable.css +10 -0
  146. package/dist/packages/core/src/components/WordTable.js +39 -26
  147. package/dist/packages/core/src/components/barChartTypes.d.ts +2 -2
  148. package/dist/packages/core/src/components/buttonTypes.d.ts +8 -0
  149. package/dist/packages/core/src/components/updateAlertStack.d.ts +2 -0
  150. package/dist/packages/core/src/components/updateAlertStack.js +68 -0
  151. package/dist/packages/core/src/components/useCalendarMonth.d.ts +4 -1
  152. package/dist/packages/core/src/components/useCalendarMonth.js +6 -3
  153. package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +7 -0
  154. package/dist/packages/core/src/components/useComboboxSuggestions.js +21 -2
  155. package/dist/packages/core/src/components/wordTableLayout.d.ts +4 -0
  156. package/dist/packages/core/src/components/wordTableLayout.js +6 -0
  157. package/dist/packages/core/src/components.d.ts +2 -1
  158. package/dist/packages/core/src/components.js +1 -1
  159. package/dist/packages/core/src/hooks/bodyScrollLock.js +8 -2
  160. package/dist/packages/core/src/hooks/focusReturn.d.ts +12 -0
  161. package/dist/packages/core/src/hooks/focusReturn.js +49 -0
  162. package/dist/packages/core/src/hooks/touchToggle.d.ts +22 -0
  163. package/dist/packages/core/src/hooks/touchToggle.js +114 -0
  164. package/dist/packages/core/src/hooks/useAnchoredMenu.d.ts +12 -3
  165. package/dist/packages/core/src/hooks/useAnchoredMenu.js +37 -3
  166. package/dist/packages/core/src/hooks/useDailyTrainingSelection.d.ts +4 -2
  167. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +23 -11
  168. package/dist/packages/core/src/hooks/useIcon.js +29 -5
  169. package/dist/packages/core/src/hooks/useLucideIcon.d.ts +4 -0
  170. package/dist/packages/core/src/hooks/useLucideIcon.js +4 -0
  171. package/dist/packages/core/src/hooks/usePendingPress.d.ts +9 -0
  172. package/dist/packages/core/src/hooks/usePendingPress.js +26 -0
  173. package/dist/packages/core/src/hooks/usePrefersReducedMotion.d.ts +15 -0
  174. package/dist/packages/core/src/hooks/usePrefersReducedMotion.js +26 -0
  175. package/dist/packages/core/src/hooks/useTablePaging.d.ts +27 -2
  176. package/dist/packages/core/src/hooks/useTablePaging.js +37 -5
  177. package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +7 -0
  178. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +25 -0
  179. package/dist/packages/core/src/i18n/index.js +9 -1
  180. package/dist/packages/core/src/i18n/locales/en.json +24 -2
  181. package/dist/packages/core/src/i18n/locales/ko.json +24 -2
  182. package/dist/packages/core/src/tokens.css +5 -0
  183. package/dist/packages/core/src/types/wordSearch.d.ts +12 -0
  184. package/dist/packages/core/src/utils/csv.d.ts +7 -2
  185. package/dist/packages/core/src/utils/csv.js +12 -5
  186. package/dist/packages/core/src/utils/dailySelectionUtils.js +44 -14
  187. package/dist/packages/core/src/utils/date.d.ts +8 -0
  188. package/dist/packages/core/src/utils/date.js +44 -10
  189. package/dist/packages/core/src/utils/format.d.ts +1 -1
  190. package/dist/packages/core/src/utils/format.js +6 -7
  191. package/dist/packages/core/src/utils/noiseSettingsStore.d.ts +7 -3
  192. package/dist/packages/core/src/utils/phonemeConvert.d.ts +9 -0
  193. package/dist/packages/core/src/utils/phonemeConvert.js +11 -0
  194. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +7 -1
  195. package/dist/packages/core/src/utils/phonemePairSearch.js +45 -21
  196. package/dist/packages/core/src/utils/safeHref.d.ts +8 -0
  197. package/dist/packages/core/src/utils/safeHref.js +24 -0
  198. package/dist/packages/core/src/utils/savedWordTables.d.ts +19 -3
  199. package/dist/packages/core/src/utils/savedWordTables.js +27 -19
  200. package/dist/packages/core/src/utils/textSort.d.ts +9 -0
  201. package/dist/packages/core/src/utils/textSort.js +37 -1
  202. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +31 -0
  203. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +163 -0
  204. package/dist/packages/core/src/utils/wordContrast.d.ts +4 -2
  205. package/dist/packages/core/src/utils/wordContrast.js +8 -2
  206. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +5 -2
  207. package/dist/packages/core/src/utils/wordTrainingGenerators.js +52 -2
  208. package/dist/packages/core/src/utils/wordTrainingPool.js +2 -2
  209. package/dist/packages/core/src/utils/wordTrainingRuntime.js +15 -7
  210. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.d.ts +1 -1
  211. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.js +5 -4
  212. package/dist/packages/core/src/utils/wornDate.d.ts +12 -0
  213. package/dist/packages/core/src/utils/wornDate.js +15 -0
  214. package/dist/packages/core/src/version.d.ts +1 -1
  215. package/dist/packages/core/src/version.js +1 -1
  216. package/dist/utils.cjs.js +2 -2
  217. package/package.json +1 -1
@@ -33,20 +33,26 @@ export function reviewCells(items, marks) {
33
33
  }
34
34
  return out;
35
35
  }
36
- /**
37
- * 복습표 이름.
38
- *
39
- * 원래 이름을 앞에 두어 **목록에서 나란히 붙어 보이게** 한다(대개 이름순·최신순으로 본다).
40
- * 날짜를 붙이는 것은 같은 표를 여러 번 돌리기 때문이다 — 없으면 같은 이름이 쌓여
41
- * 어느 것이 이번 것인지 알 수 없다.
42
- */
43
- export function reviewTableName(original, today) {
44
- /* 지역 날짜가 아니라 KST 로 읽는다 — 이 날짜는 **표 이름에 저장되고** 같은 이름끼리
45
- 겹치는지 세는 데 쓰인다. 기기 시간대를 따르면 같은 날 만든 표가 다른 날짜를 달고
46
- 남아 회차 세기가 어긋난다. */
47
- const [, 월, 일] = toKstYmd(today).split('-');
48
- const 날짜 = `${월}.${일}`;
49
- /* **복습표를 또 복습하면 꼬리가 쌓인다.** 그대로 두면
36
+ /** 복습 꼬리 — `[N차 ]복습 MM.DD`. '복습' 뒤에 날짜가 따라와야만 꼬리로 본다("복습 자료" 같은 이름을 잘라 먹지 않게). */
37
+ const 복습꼬리 = /\s*(?:(\d+)차\s*)?복습\s*\d{2}\.\d{2}$/;
38
+ /** 원래 표 → (꼬리를 뗀 바탕 이름, 새 복습표의 회차). 메타가 있으면 메타를, 없으면 이름을 읽는다. */
39
+ function 바탕과회차(src) {
40
+ const 이름 = src.name.trim();
41
+ const 메타회차 = src.reviewRound;
42
+ if (typeof 메타회차 === 'number' && Number.isFinite(메타회차) && 메타회차 >= 0) {
43
+ const 지난 = Math.floor(메타회차);
44
+ if (지난 === 0)
45
+ return { 바탕: 이름, 회차: 1 };
46
+ /* 메타가 말하는 **그 회차의 꼬리 하나만** 뗀다 — `수업 3차 복습 09.15`(1회) 에서 `3차` 를 회차로 먹지 않는다.
47
+ N차는 앞이 공백·처음일 때만 — `수업 13차 복습`(3회) 의 `3차` 를 떼지 않게. 사람이 이름을 고쳐 꼬리가 없으면
48
+ 이름을 통째로 바탕으로 둔다. */
49
+ const 그꼬리 = 지난 === 1
50
+ ? /\s*복습\s*\d{2}\.\d{2}$/
51
+ : new RegExp(`(?:^|\\s)${지난}차\\s*복습\\s*\\d{2}\\.\\d{2}$`);
52
+ const m = 그꼬리.exec(이름);
53
+ return { 바탕: m ? 이름.slice(0, m.index).trim() : 이름, 회차: 지난 + 1 };
54
+ }
55
+ /* **메타 없는 옛 표 — 이름을 파싱한다.** 복습표를 또 복습하면 꼬리가 쌓인다. 그대로 두면
50
56
  `검수 대립쌍 복습 08.29 복습 08.30 복습 09.02` 처럼 자란다 — 복습을 거듭하는 것은
51
57
  정상 쓰임이라 금방 읽을 수 없는 이름이 된다. 붙어 있던 복습 꼬리를 떼고 새로 단다.
52
58
 
@@ -57,19 +63,52 @@ export function reviewTableName(original, today) {
57
63
 
58
64
  '복습' 은 뒤에 `MM.DD` 가 따라와야만 꼬리로 본다 — "복습 자료" 같은 사람이 지은
59
65
  이름을 잘라 먹지 않으려는 것이다. */
60
- const 꼬리 = /\s*(?:(\d+)차\s*)?복습\s*\d{2}\.\d{2}$/;
61
- let 바탕 = original.trim();
66
+ let 바탕 = 이름;
62
67
  let 세대 = 1;
63
68
  for (;;) {
64
- const m = 꼬리.exec(바탕);
69
+ const m = 복습꼬리.exec(바탕);
65
70
  if (!m)
66
71
  break;
67
72
  세대 = Math.max(세대, (m[1] ? Number(m[1]) : 1) + 1);
68
73
  바탕 = 바탕.slice(0, m.index).trim();
69
74
  }
75
+ return { 바탕, 회차: 세대 };
76
+ }
77
+ /**
78
+ * 복습표 이름.
79
+ *
80
+ * 원래 이름을 앞에 두어 **목록에서 나란히 붙어 보이게** 한다(대개 이름순·최신순으로 본다).
81
+ * 날짜를 붙이는 것은 같은 표를 여러 번 돌리기 때문이다 — 없으면 같은 이름이 쌓여
82
+ * 어느 것이 이번 것인지 알 수 없다.
83
+ *
84
+ * 이름 형식(`바탕 [N차 ]복습 MM.DD`)은 저장된 이름·파싱과 맞추느라 바꾸지 않는다.
85
+ * 원래 표를 {@link ReviewSource} 로 넘기면 복습 메타(`reviewRound`)를 이름 파싱보다 먼저 쓴다 —
86
+ * 새 복습표를 저장할 때는 {@link reviewMeta} 의 값을 함께 남긴다. 글자만 넘기면 지금처럼 이름을 파싱한다.
87
+ */
88
+ export function reviewTableName(original, today) {
89
+ /* 지역 날짜가 아니라 KST 로 읽는다 — 이 날짜는 **표 이름에 저장되고** 같은 이름끼리
90
+ 겹치는지 세는 데 쓰인다. 기기 시간대를 따르면 같은 날 만든 표가 다른 날짜를 달고
91
+ 남아 회차 세기가 어긋난다. */
92
+ const [, 월, 일] = toKstYmd(today).split('-');
93
+ const 날짜 = `${월}.${일}`;
94
+ const { 바탕, 회차: 세대 } = 바탕과회차(typeof original === 'string' ? { name: original } : original);
70
95
  const 회차 = 세대 >= 2 ? `${세대}차 ` : '';
71
96
  return `${바탕 || '단어표'} ${회차}복습 ${날짜}`;
72
97
  }
98
+ /**
99
+ * 새로 만들 복습표에 **함께 저장할 메타** — 맨 처음 원본 표 id 와 이번이 몇 번째 복습인지.
100
+ *
101
+ * `reviewOf` 는 원래 표가 복습표면 그 표의 `reviewOf` 를, 아니면 원래 표의 `id` 를 잇는다(둘 다 없으면 `undefined`).
102
+ * `reviewRound` 는 {@link reviewTableName} 이 이름에 적는 회차와 같다(1 = 첫 복습).
103
+ *
104
+ * @public
105
+ */
106
+ export function reviewMeta(source) {
107
+ return {
108
+ reviewOf: source.reviewOf ?? source.id ?? undefined,
109
+ reviewRound: 바탕과회차(source).회차,
110
+ };
111
+ }
73
112
  /**
74
113
  * 칸 번호 → 그때 보인 낱말. 채점과 **함께 저장한다.**
75
114
  *
@@ -96,6 +135,25 @@ export function labelsForSave(labels, marks) {
96
135
  export function cellLabel(words) {
97
136
  return words.map(w => w.label).join(' / ');
98
137
  }
138
+ /**
139
+ * 한 칸의 낱말들을 **동형어 번호까지** 한 줄로 — `배#2 / 패`. 번호가 없는 낱말은 {@link cellLabel} 과 같다.
140
+ *
141
+ * 채점과 함께 저장하는 칸 낱말(`labelsForSave` 에 넘길 것)로 쓰면 `weakItems` 가 '배'(1)·'배'(2) 를 따로 센다.
142
+ * 글자만(`cellLabel`) 저장하면 둘의 성적이 한 줄로 합쳐진다. 화면에 보일 글자는 {@link labelOfCellKey} 로 되돌린다.
143
+ *
144
+ * @public
145
+ */
146
+ export function cellKey(words) {
147
+ return words.map(w => (w.homonum != null ? `${w.label}#${w.homonum}` : w.label)).join(' / ');
148
+ }
149
+ /**
150
+ * {@link cellKey} 로 만든 칸 낱말에서 동형어 번호를 뗀 보이는 글자 — `배#2 / 패` → `배 / 패`. 번호가 없으면 그대로.
151
+ *
152
+ * @public
153
+ */
154
+ export function labelOfCellKey(key) {
155
+ return key.split(' / ').map(s => s.replace(/#\d+$/, '')).join(' / ');
156
+ }
99
157
  /**
100
158
  * 채점한 표를 인쇄할 때 종이 맨 위에 찍는 한 줄.
101
159
  *
@@ -26,6 +26,15 @@ export declare function buildItemsPerPageSection(t: TFunction, opts: {
26
26
  hasPairs: boolean;
27
27
  itemCount: number;
28
28
  }): FilterPanelSection;
29
+ /**
30
+ * 페이지당 개수 섹션에서 고른 값 → 기억할 페이지당 개수.
31
+ *
32
+ * **첫 칸(그때의 항목 수 = 한 쪽에 전부)을 고르면 늘 전체(`Infinity`)** 로 기억한다 — 항목 수가 표준값(8·4·2·1)과
33
+ * 같을 때 숫자로 담으면, 4개일 때 '4개(전부)' 를 골라 둔 표에 낱말을 더 담는 순간 4개씩 두 쪽으로 나뉘었다
34
+ * (5개일 때 '5개' 는 목록에 없는 수라 전체로 기억돼 한 쪽에 남았다 — 같은 첫 칸인데 뜻이 갈렸다).
35
+ * 목록에 없는 수(항목이 줄어 사라진 옛 선택)도 전체로 본다. 나머지 표준값은 그 수로 나눈다.
36
+ */
37
+ export declare function itemsPerPageFromChoice(n: number, itemCount: number): number;
29
38
  /** @public 페이지 번호 위치(왼쪽/중앙/오른쪽/미표시) 세그먼트 섹션 — 실제 2페이지 이상일 때만 노출. */
30
39
  export declare function buildPageNumberSection(t: TFunction): FilterPanelSection;
31
40
  /** @public 페이지 번호 위치 → filterSelected 표시값. */
@@ -77,6 +77,17 @@ export function buildItemsPerPageSection(t, opts) {
77
77
  display: 'segmented',
78
78
  };
79
79
  }
80
+ /**
81
+ * 페이지당 개수 섹션에서 고른 값 → 기억할 페이지당 개수.
82
+ *
83
+ * **첫 칸(그때의 항목 수 = 한 쪽에 전부)을 고르면 늘 전체(`Infinity`)** 로 기억한다 — 항목 수가 표준값(8·4·2·1)과
84
+ * 같을 때 숫자로 담으면, 4개일 때 '4개(전부)' 를 골라 둔 표에 낱말을 더 담는 순간 4개씩 두 쪽으로 나뉘었다
85
+ * (5개일 때 '5개' 는 목록에 없는 수라 전체로 기억돼 한 쪽에 남았다 — 같은 첫 칸인데 뜻이 갈렸다).
86
+ * 목록에 없는 수(항목이 줄어 사라진 옛 선택)도 전체로 본다. 나머지 표준값은 그 수로 나눈다.
87
+ */
88
+ export function itemsPerPageFromChoice(n, itemCount) {
89
+ return n === itemCount || !ITEMS_PER_PAGE_OPTIONS.includes(n) ? Number.POSITIVE_INFINITY : n;
90
+ }
80
91
  /** @public 페이지 번호 위치(왼쪽/중앙/오른쪽/미표시) 세그먼트 섹션 — 실제 2페이지 이상일 때만 노출. */
81
92
  export function buildPageNumberSection(t) {
82
93
  return {
@@ -266,6 +266,16 @@
266
266
  opacity: 1;
267
267
  }
268
268
 
269
+ /* 터치 기기에는 hover 가 없다 — 위 규칙들이 :hover 로만 드러내 손가락으로는 빼기·재생·정보 단추가 보이지 않았다
270
+ (보이지 않는 단추를 짐작으로 눌러야 했다). 늘 보이게만 한다 — 색·모양은 그대로. 인쇄에서는 아래 @media print 가 치운다. */
271
+ @media (hover: none) {
272
+ .word-table-block__card-close,
273
+ .word-table-cell__play-button,
274
+ .word-table-cell__info-button {
275
+ opacity: 1;
276
+ }
277
+ }
278
+
269
279
  .word-table-cell__image-wrap {
270
280
  position: relative;
271
281
  width: 240px;
@@ -12,18 +12,16 @@ import { getTTSUrlResolver } from '../utils/ttsConfig';
12
12
  import { getImageResolver } from '../utils/imageConfig';
13
13
  import { getPronunciationResolver } from '../utils/pronunciationConfig';
14
14
  import { useWordImage } from '../hooks/useWordImage';
15
+ import { isTouchPointer, recentlyTouched, useTouchToggle } from '../hooks/touchToggle';
15
16
  import { WORD_TABLE_PAPER, gridFor, CUT_CLASS, POS_LABEL, WORD_TYPE_LABEL, expandSlashLabel, dotDashStyle, REF_W_BOTH, REF_W_TEXT_ONLY, REF_W_IMAGE_ONLY, REF_H_BOTH, REF_H_TEXT_ONLY, REF_H_IMAGE_ONLY, } from './wordTableLayout';
16
17
  import { useFitScale } from './useFitScale';
18
+ import { claimSound } from './Training/currentSound';
17
19
  // 외부(배럴·TodayPage·WordTableBlock 등) import 호환을 위해 재export
18
20
  /* 순수 함수(printWordTable·chunkItems)는 여기서 재수출하지 않는다 — 컴포넌트 파일이
19
21
  컴포넌트 아닌 것을 내보내면 Fast Refresh 가 깨져, 이 파일을 고칠 때마다 화면 상태가
20
22
  통째로 날아간다. 쓰는 쪽은 `wordTableLayout` 에서 바로 가져온다.
21
23
  (상수 하나는 남긴다 — 규칙이 상수는 허용하고, 지우면 공개 API 가 바뀐다.) */
22
24
  export { WORD_TABLE_PAPER } from './wordTableLayout';
23
- // 모듈 레벨 — 단어표 안에는 WordCell이 다수 렌더되는데, 각자 자기 audio만 관리하면
24
- // 여러 셀을 빠르게 연달아 클릭했을 때 발음이 겹쳐 재생된다. 마지막으로 재생을 시작한
25
- // 인스턴스만 currentlyPlaying으로 추적해, 새 재생이 시작될 때 이전 것을 정지시킨다.
26
- let currentlyPlaying = null;
27
25
  function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true, showImage = true, showPronunciation = true, showColloquial = true, buttonSize = 40, wordInfo, }) {
28
26
  const { t } = useTranslation();
29
27
  const outerRef = useRef(null);
@@ -41,6 +39,11 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
41
39
  const { imgRef, loading: imgLoading, failed: imgFailed, onLoad: handleImgLoad, onError: handleImgError } = useWordImage(imageUrl);
42
40
  const handleInfoEnter = useCallback(() => setInfoAnchor(infoRef.current?.getBoundingClientRect() ?? null), []);
43
41
  const handleInfoLeave = useCallback(() => setInfoAnchor(null), []);
42
+ /* 터치 — 톡하면 열렸다 떼자마자 닫혀 낱말 정보를 읽을 수 없었다. 톡으로 열고 닫는다(마우스·키보드는 그대로) */
43
+ const 정보톡 = useTouchToggle(!!infoAnchor, (next) => { if (next)
44
+ handleInfoEnter();
45
+ else
46
+ handleInfoLeave(); }, infoRef);
44
47
  const infoTooltipItems = useMemo(() => {
45
48
  if (!wordInfo)
46
49
  return null;
@@ -72,38 +75,41 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
72
75
  // 컴포넌트 언마운트 시 발음 재생 중단 — audioRef는 <audio> JSX가 아니라 new Audio()로
73
76
  // 직접 만든 객체라 DOM에서 안 사라지므로, 재생 중에 팝업이 닫히는 등으로 언마운트되면
74
77
  // pause() 없이는 화면이 사라진 뒤에도 계속 재생된다.
78
+ /** 지금 내 소리를 멈추는 함수(자물쇠에 맡긴 것)와, 자물쇠 자리만 비우는 함수 */
79
+ const stopRef = useRef(null);
80
+ const releaseRef = useRef(null);
75
81
  useEffect(() => () => {
76
82
  audioRef.current?.pause();
77
- if (currentlyPlaying?.audio === audioRef.current)
78
- currentlyPlaying = null;
83
+ /* 자리만 비운다(내가 아직 주인일 때만) — 멈춤 함수를 부르면 사라진 화면의 상태를 건드린다 */
84
+ releaseRef.current?.();
79
85
  }, []);
80
86
  const handlePlay = useCallback(async (e) => {
81
87
  e.stopPropagation();
82
88
  if (playing) {
83
- audioRef.current?.pause();
84
- setPlaying(false);
85
- currentlyPlaying = null;
89
+ stopRef.current?.();
86
90
  return;
87
91
  }
88
92
  if (!audioUrl)
89
93
  return;
90
- currentlyPlaying?.stop();
91
94
  setPlaying(true);
92
95
  try {
93
96
  const audio = new Audio(audioUrl);
94
97
  audioRef.current = audio;
95
- const clearIfSelf = () => { setPlaying(false); if (currentlyPlaying?.audio === audio)
96
- currentlyPlaying = null; };
97
- currentlyPlaying = { audio, stop: () => { audio.pause(); clearIfSelf(); } };
98
- audio.onended = clearIfSelf;
99
- audio.onerror = clearIfSelf;
98
+ /* 끝나거나 멈추면 자리를 비운다 — 이미 다음 사람이 가져갔으면 release 는 아무것도 안 한다 */
99
+ const done = () => { setPlaying(false); releaseRef.current?.(); };
100
+ const stop = () => { audio.pause(); done(); };
101
+ stopRef.current = stop;
102
+ /* 자물쇠를 잡으면 앞서 울리던 것(다른 칸·훈련 카드 등)이 멈추고, 남이 잡으면 stop 이 불려 내가 멈춘다 */
103
+ const release = claimSound(stop);
104
+ releaseRef.current = release;
105
+ audio.onended = done;
106
+ audio.onerror = done;
100
107
  await audio.play();
101
108
  }
102
109
  catch {
103
110
  setPlaying(false);
104
- if (currentlyPlaying?.audio === audioRef.current)
105
- currentlyPlaying = null;
106
- }
111
+ releaseRef.current?.();
112
+ } // 재생 거절(자동재생 막힘 등) — 울리지 않으니 자리만 비운다
107
113
  }, [playing, audioUrl]);
108
114
  const hasImage = showImage && !!imageUrl && !imgFailed;
109
115
  const refW = hasImage && showWord ? REF_W_BOTH : hasImage ? REF_W_IMAGE_ONLY : REF_W_TEXT_ONLY;
@@ -124,14 +130,17 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
124
130
  obs.observe(el);
125
131
  return () => obs.disconnect();
126
132
  }, [refW, refH]);
127
- return (_jsxs("div", { ref: outerRef, className: "word-table-cell", children: [_jsxs("div", { className: "word-table-cell__inner", style: { transform: `scale(${scale})` }, children: [hasImage && (_jsxs("div", { className: "word-table-cell__image-wrap", children: [imgLoading && _jsx("div", { className: "word-table-cell__spinner" }), _jsx("img", { ref: imgRef, className: "word-table-cell__image", src: imageUrl, alt: label, draggable: false, style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })), showWord && _jsx("span", { className: "word-table-cell__word h0", children: label }), showPronunciation && pron && _jsxs("span", { className: "word-table-cell__sub h3", children: ["[", pron, "]"] }), showColloquial && colloquialLabel && _jsxs("span", { className: "word-table-cell__colloquial h5", children: ["[", colloquialLabel, "]"] })] }), _jsxs("div", { className: "word-table-cell__play-button", "data-audio-src": audioUrl, children: [_jsx("span", { "data-play-state": "play", style: { display: playing ? 'none' : undefined }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "play", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: t('common.play', '재생'), onClick: handlePlay }) }), _jsx("span", { "data-play-state": "pause", style: { display: playing ? undefined : 'none' }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "pause", useBorder: false, selected: true, showTooltipInfo: t('common.pause', '멈춤'), onClick: handlePlay }) })] }), wordInfo && (_jsxs("div", { ref: infoRef, className: "word-table-cell__info-button", onPointerEnter: handleInfoEnter, onPointerLeave: handleInfoLeave,
133
+ return (_jsxs("div", { ref: outerRef, className: "word-table-cell", children: [_jsxs("div", { className: "word-table-cell__inner", style: { transform: `scale(${scale})` }, children: [hasImage && (_jsxs("div", { className: "word-table-cell__image-wrap", children: [imgLoading && _jsx("div", { className: "word-table-cell__spinner" }), _jsx("img", { ref: imgRef, className: "word-table-cell__image", src: imageUrl, alt: label, draggable: false, style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })), showWord && _jsx("span", { className: "word-table-cell__word h0", children: label }), showPronunciation && pron && _jsxs("span", { className: "word-table-cell__sub h3", children: ["[", pron, "]"] }), showColloquial && colloquialLabel && _jsxs("span", { className: "word-table-cell__colloquial h5", children: ["[", colloquialLabel, "]"] })] }), _jsxs("div", { className: "word-table-cell__play-button", "data-audio-src": audioUrl, children: [_jsx("span", { "data-play-state": "play", style: { display: playing ? 'none' : undefined }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "play", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: t('common.play', '재생'), onClick: handlePlay }) }), _jsx("span", { "data-play-state": "pause", style: { display: playing ? undefined : 'none' }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "pause", useBorder: false, selected: true, showTooltipInfo: t('common.pause', '멈춤'), onClick: handlePlay }) })] }), wordInfo && (_jsxs("div", { ref: infoRef, className: "word-table-cell__info-button", onPointerEnter: (e) => { if (!isTouchPointer(e))
134
+ handleInfoEnter(); }, onPointerLeave: (e) => { if (!isTouchPointer(e))
135
+ handleInfoLeave(); },
128
136
  // 이 div 자체는 포커스 대상이 아니지만 안의 Button이 포커스를 받으면
129
- // focusin이 여기로 올라온다 — 그래서 래퍼에 걸어도 동작한다.
130
- onFocus: handleInfoEnter, onBlur: handleInfoLeave, children: [_jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "info", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: false,
137
+ // focusin이 여기로 올라온다 — 그래서 래퍼에 걸어도 동작한다. 톡으로 온 초점은 톡 토글이 정한다.
138
+ onFocus: () => { if (!recentlyTouched())
139
+ handleInfoEnter(); }, onBlur: handleInfoLeave, children: [_jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "info", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: false,
131
140
  /* 아이콘만 있는 버튼이라 이름이 없으면 화면 낭독기가 "버튼"으로만 읽는다.
132
141
  한 표에 칸마다 하나씩 있어 열두 개가 다 "버튼"이 된다 — 어느 낱말의
133
142
  것인지 알 길이 없으므로 낱말을 이름에 넣는다. */
134
- ariaLabel: t('wordTable.wordInfo', '{{word}} 낱말 정보', { word: label }), onClick: (e) => e.stopPropagation() }), infoAnchor && infoTooltipItems && (_jsx(DescriptionTooltip, { anchorRect: infoAnchor, items: infoTooltipItems, showIndex: false }))] }))] }));
143
+ ariaLabel: t('wordTable.wordInfo', '{{word}} 낱말 정보', { word: label }), onClick: (e) => { e.stopPropagation(); 정보톡.touchPress(); } }), infoAnchor && infoTooltipItems && (_jsx(DescriptionTooltip, { anchorRect: infoAnchor, items: infoTooltipItems, showIndex: false }))] }))] }));
135
144
  }
136
145
  // ── WordTable (표 디자인) ──────────────────────────────────────
137
146
  /* 품사·어종 약호 → 지금 언어의 이름. 사전에 없으면 원본 한국어 이름표(POS_LABEL·WORD_TYPE_LABEL) */
@@ -168,6 +177,8 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
168
177
  };
169
178
  const { t } = useTranslation();
170
179
  const isEdit = editCells !== undefined;
180
+ /** title·titleIcon·disc 중 하나라도 있으면 Section 으로 감싼다 */
181
+ const wrapped = title != null || !!titleIcon || !!disc;
171
182
  // 드래그 상태(표시 모드 재정렬)
172
183
  const dragIndex = useRef(null);
173
184
  const [dragOver, setDragOver] = useState(null);
@@ -185,19 +196,21 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
185
196
  };
186
197
  const hasPairs = isEdit
187
198
  ? (editCells ?? []).some(c => c !== null && c.length > 1)
188
- : (items ?? []).some(item => item.words.length > 1);
199
+ : (items ?? []).some(item => (item.words ?? []).length > 1);
189
200
  const contentClass = cx('word-table-block__content', CUT_CLASS[cutLine], showOutline && 'word-table-block__content--outline', showDivLine && hasPairs && 'word-table-block__content--div-line');
190
201
  // 빈칸(추가버튼)은 스케일 역보정으로 실제 크기 유지
191
202
  const counterScale = { transform: `scale(${1 / scale})`, transformOrigin: 'center center' };
192
- const grid = (_jsx("div", { ref: scalerRef, className: "word-table__scaler", style: { height: height * scale }, children: _jsx("div", { className: cx('word-table', className), style: { width, height, transform: `scale(${scale})`, transformOrigin: 'top left', marginLeft: offset }, children: _jsxs("div", { ref: contentRef, className: contentClass, style: { gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }, children: [isEdit ? (_jsx(_Fragment, { children: (editCells ?? []).map((cell, i) => (_jsx("div", { className: cx('word-table-block__edit-cell', cell ? 'word-table-block__edit-cell--filled' : 'word-table-block__edit-cell--empty', activeCell === i ? 'word-table-block__edit-cell--active' : ''), style: dotDashStyle(i, cols, rows, cutLine), children: cell ? (_jsxs(_Fragment, { children: [cell.map((e, wi) => (_jsxs(Fragment, { children: [wi > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: e.word, subLabel: e.pronunciation,
203
+ const grid = (_jsx("div", { ref: scalerRef, className: "word-table__scaler", style: { height: height * scale }, children: _jsx("div", {
204
+ /* title 로 Section 에 감쌀 때는 className 을 바깥 한 곳에만 — 두 곳에 붙어 소비자 스타일이 두 번 걸렸다 */
205
+ className: cx('word-table', !wrapped && className), style: { width, height, transform: `scale(${scale})`, transformOrigin: 'top left', marginLeft: offset }, children: _jsxs("div", { ref: contentRef, className: contentClass, style: { gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }, children: [isEdit ? (_jsx(_Fragment, { children: (editCells ?? []).map((cell, i) => (_jsx("div", { className: cx('word-table-block__edit-cell', cell ? 'word-table-block__edit-cell--filled' : 'word-table-block__edit-cell--empty', activeCell === i ? 'word-table-block__edit-cell--active' : ''), style: dotDashStyle(i, cols, rows, cutLine), children: cell ? (_jsxs(_Fragment, { children: [cell.map((e, wi) => (_jsxs(Fragment, { children: [wi > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: e.word, subLabel: e.pronunciation,
193
206
  // 편집 칸도 구어체 발음을 넘긴다 — 빠져 있어 '구어체 발음'을 켜도 편집 모드에서만 비었다.
194
207
  colloquialLabel: e.colloquialPronunciation, homonum: e.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize })] }, wi))), scoring && renderScore(i, cell.map(e => e.word).join(' / ')), onRemoveCell && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveCell(i) }) }))] })) : (onActivateCell && (_jsx("div", { style: counterScale, children: _jsx(Button, { htmlType: "button", size: "48", type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell(i) }) }))) }, i))) })) : (_jsxs(_Fragment, { children: [(items ?? []).map((item, index) => (_jsxs("div", { className: `word-table-block__card${onReorder ? ' word-table-block__card--draggable' : ''}${dragOver === index ? ' word-table-block__card--drag-over' : ''}`, style: dotDashStyle(index, cols, rows, cutLine), draggable: !!onReorder, onDragStart: onReorder ? () => { dragIndex.current = index; } : undefined, onDragOver: onReorder ? (e) => { e.preventDefault(); setDragOver(index); } : undefined, onDragLeave: onReorder ? () => setDragOver(null) : undefined, onDrop: onReorder ? (e) => { e.preventDefault(); if (dragIndex.current !== null && dragIndex.current !== index) {
195
208
  onReorder(dragIndex.current, index);
196
- } ; dragIndex.current = null; setDragOver(null); } : undefined, onDragEnd: onReorder ? () => { dragIndex.current = null; setDragOver(null); } : undefined, children: [item.words.map((w, i) => (_jsxs(Fragment, { children: [i > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: w.label, subLabel: w.subLabel, colloquialLabel: w.colloquialLabel, homonum: w.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize, wordInfo: (w.grade || w.syllables != null || w.pos || w.wordType) ? { grade: w.grade, syllables: w.syllables, pos: w.pos, wordType: w.wordType } : undefined })] }, i))), scoring && renderScore(index, item.words.map(w => w.label).join(' / ')), onRemoveItem && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: item.words.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: item.words.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveItem(item.id) }) }))] }, item.id))), Array.from({ length: Math.max(0, cols * rows - (items?.length ?? 0)) }).map((_, i) => {
209
+ } ; dragIndex.current = null; setDragOver(null); } : undefined, onDragEnd: onReorder ? () => { dragIndex.current = null; setDragOver(null); } : undefined, children: [(item.words ?? []).map((w, i) => (_jsxs(Fragment, { children: [i > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: w.label, subLabel: w.subLabel, colloquialLabel: w.colloquialLabel, homonum: w.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize, wordInfo: (w.grade || w.syllables != null || w.pos || w.wordType) ? { grade: w.grade, syllables: w.syllables, pos: w.pos, wordType: w.wordType } : undefined })] }, i))), scoring && renderScore(index, (item.words ?? []).map(w => w.label).join(' / ')), onRemoveItem && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: (item.words ?? []).length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: (item.words ?? []).length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveItem(item.id) }) }))] }, item.id))), Array.from({ length: Math.max(0, cols * rows - (items?.length ?? 0)) }).map((_, i) => {
197
210
  const isWithinCount = (items?.length ?? 0) + i < cellCount;
198
211
  return (_jsx("div", { className: "word-table-block__card word-table-block__card--filler", children: showFillerAdd && onActivateCell && isWithinCount && (_jsx("div", { style: counterScale, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell((items?.length ?? 0) + i) }) })) }, `filler-${i}`));
199
212
  })] })), pageNumber && pageNumberPosition !== 'none' && (_jsx("span", { className: `word-table__page-number body-3 word-table__page-number--${pageNumberPosition}`, children: t('wordTable.filter.pageIndicator', { current: pageNumber.current, total: pageNumber.total }) }))] }) }) }));
200
- if (title != null || titleIcon || disc) {
213
+ if (wrapped) {
201
214
  return (_jsx(Section, { type: "page", icon: titleIcon, title: title, disc: disc, className: cx('word-table-block', className), children: grid }));
202
215
  }
203
216
  return grid;
@@ -42,7 +42,7 @@ export interface BarChartProps {
42
42
  *
43
43
  * 굵기만 줄이고 싶을 때 `small` 로 가면 높이·헤더 컨트롤·합계 라벨까지 같이
44
44
  * 줄어든다(2026-09-10 말귀 실측). 이 값은 굵기만 바꾼다. 막대가 많아지면 여기
45
- * 적은 값을 **위 한계**로 두고 점진 축소하는 것은 그대로다.
45
+ * 적은 값을 **위 한계**로 두고 점진 축소하는 것은 그대로다. 아래 한계는 2px(그보다 작은 값은 2 로).
46
46
  *
47
47
  * CSS 로 덮고 싶으면 `.manager-bar-chart-bar`·`-stack` 의 `--bar-width` 를 쓴다 —
48
48
  * 굵기는 인라인이 아니라 그 변수로 박혀 있다.
@@ -58,7 +58,7 @@ export interface BarChartProps {
58
58
  /** 계열 A/B 레이블 i18n 키. labelBKey 미지정 시 단일 계열 */
59
59
  labelAKey: string;
60
60
  labelBKey?: string;
61
- /** 선택된 구간의 합계(a+b)를 헤더 아래 히어로로 표시 */
61
+ /** 선택된 구간의 합계(a+b)를 헤더 아래 히어로로 표시. 월 보기는 그 달 날짜만 센다(주 막대가 달 경계를 넘어도 합계는 달력 달 — 상세 목록과 같은 범위) */
62
62
  showTotal?: boolean;
63
63
  /** 합계 단위 i18n 키 (예: 'trainingStatus.weekly.unit' → '회') */
64
64
  totalUnitKey?: string;
@@ -66,6 +66,14 @@ export interface ButtonProps {
66
66
  align?: 'center' | 'left' | 'right' | 'grid';
67
67
  selected?: boolean;
68
68
  toggleable?: boolean;
69
+ /**
70
+ * 여닫이 단추(설정 판·미리보기·유의사항 펼치기)의 열림 상태. 주면 `aria-expanded` 로 알리고 **`aria-pressed` 는 달지 않는다** —
71
+ * `selected={열림}` 만 주면 "눌림" 으로 읽혀 무엇이 열렸는지 알 수 없었다. 켜진 모양(`is-selected`)은 `selected` 로 따로 준다.
72
+ * @example <Button label="훈련 설정" selected={open} ariaExpanded={open} ariaControls={panelId} />
73
+ */
74
+ ariaExpanded?: boolean;
75
+ /** 이 단추가 여닫는 영역의 id — `aria-controls` 로 단다(`ariaExpanded` 와 함께 쓴다) */
76
+ ariaControls?: string;
69
77
  disabled?: boolean;
70
78
  /** label을 숨기려면 false로 설정 */
71
79
  useLabel?: boolean;
@@ -0,0 +1,2 @@
1
+ /** 자리를 올린다. 돌려준 함수로 내린다. 같은 스택 안의 여러 장은 한 자리를 함께 쓴다 */
2
+ export declare function registerUpdateAlertSlot(el: HTMLElement): () => void;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 우상단 알림 자리들을 한 줄로 쌓는다(내부).
3
+ *
4
+ * 낱장 `UpdateAlert` 와 `.update-alert-stack` 은 **둘 다** 창 우상단(top 16px)에 fixed 로 붙는다. 한 화면에 둘이 함께 뜨면
5
+ * (말귀: 앱 자리의 단어 자료 업데이트 알림 + 이용권 만료 스택) 서로 완전히 겹쳐 아래 것의 글·닫기 단추를 가렸다.
6
+ * 자리(낱장 또는 스택 하나)마다 문서 순서대로 앞 자리들의 높이를 더해 `--update-alert-stack-offset` 으로 내려 준다.
7
+ *
8
+ * 같은 fixed 기준(뷰포트, 또는 transform 을 건 조상 — 놀이터 시연 틀)을 쓰는 자리끼리만 쌓는다.
9
+ */
10
+ const 자리수 = new Map();
11
+ let 관찰 = null;
12
+ const GAP = 12;
13
+ /* fixed 의 기준이 되는 조상 — transform·filter·perspective·contain 이 걸린 가장 가까운 조상, 없으면 문서 */
14
+ function 기준찾기(el) {
15
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
16
+ const cs = window.getComputedStyle(p);
17
+ if ((cs.transform && cs.transform !== 'none') || (cs.filter && cs.filter !== 'none') || (cs.perspective && cs.perspective !== 'none') || /paint|layout|strict|content/.test(cs.contain || ''))
18
+ return p;
19
+ }
20
+ return document;
21
+ }
22
+ function 다시쌓기() {
23
+ const 무리 = new Map();
24
+ for (const el of 자리수.keys()) {
25
+ if (!el.isConnected)
26
+ continue;
27
+ const k = 기준찾기(el);
28
+ const 줄 = 무리.get(k) ?? [];
29
+ 줄.push(el);
30
+ 무리.set(k, 줄);
31
+ }
32
+ for (const 줄 of 무리.values()) {
33
+ 줄.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
34
+ let 아래로 = 0;
35
+ for (const el of 줄) {
36
+ if (아래로 > 0)
37
+ el.style.setProperty('--update-alert-stack-offset', `${아래로}px`);
38
+ else
39
+ el.style.removeProperty('--update-alert-stack-offset');
40
+ const h = el.getBoundingClientRect().height;
41
+ if (h > 0)
42
+ 아래로 += h + GAP;
43
+ }
44
+ }
45
+ }
46
+ /** 자리를 올린다. 돌려준 함수로 내린다. 같은 스택 안의 여러 장은 한 자리를 함께 쓴다 */
47
+ export function registerUpdateAlertSlot(el) {
48
+ if (typeof window === 'undefined')
49
+ return () => { };
50
+ 자리수.set(el, (자리수.get(el) ?? 0) + 1);
51
+ if (자리수.get(el) === 1 && typeof ResizeObserver !== 'undefined') {
52
+ 관찰 ??= new ResizeObserver(() => 다시쌓기());
53
+ 관찰.observe(el);
54
+ }
55
+ 다시쌓기();
56
+ return () => {
57
+ const n = (자리수.get(el) ?? 1) - 1;
58
+ if (n > 0) {
59
+ 자리수.set(el, n);
60
+ 다시쌓기();
61
+ return;
62
+ }
63
+ 자리수.delete(el);
64
+ 관찰?.unobserve(el);
65
+ el.style.removeProperty('--update-alert-stack-offset');
66
+ 다시쌓기();
67
+ };
68
+ }
@@ -7,6 +7,9 @@ export interface UseCalendarMonthResult {
7
7
  }
8
8
  /**
9
9
  * "현재 보고 있는 연/월" 상태 + 이전/다음 달 이동. DatePicker의 달력 네비게이션에서 쓴다.
10
+ *
11
+ * @param value 고른 날 'YYYY-MM-DD'. 비었으면 오늘의 달을 본다.
12
+ * @param today 선택. '오늘' 로 쓸 'YYYY-MM-DD'(로컬 앱·SSR). 없거나 꼴이 틀리면 KST 오늘.
10
13
  * @public
11
14
  */
12
- export declare function useCalendarMonth(value: string): UseCalendarMonthResult;
15
+ export declare function useCalendarMonth(value: string, today?: string): UseCalendarMonthResult;
@@ -1,17 +1,20 @@
1
1
  'use client';
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
- import { getTodayKst } from '../utils/date';
3
+ import { todayYmdOr } from '../utils/date';
4
4
  /**
5
5
  * "현재 보고 있는 연/월" 상태 + 이전/다음 달 이동. DatePicker의 달력 네비게이션에서 쓴다.
6
+ *
7
+ * @param value 고른 날 'YYYY-MM-DD'. 비었으면 오늘의 달을 본다.
8
+ * @param today 선택. '오늘' 로 쓸 'YYYY-MM-DD'(로컬 앱·SSR). 없거나 꼴이 틀리면 KST 오늘.
6
9
  * @public
7
10
  */
8
- export function useCalendarMonth(value) {
11
+ export function useCalendarMonth(value, today) {
9
12
  const initYM = () => {
10
13
  if (value) {
11
14
  const [y, m] = value.split('-');
12
15
  return { y: +y, m: +m - 1 };
13
16
  }
14
- const [y, m] = getTodayKst().split('-'); // 값이 없으면 KST 오늘의 달(해외 기기 공식 지원)
17
+ const [y, m] = todayYmdOr(today).split('-'); // 값이 없으면 오늘(기본 KST — 해외 기기 공식 지원)의 달
15
18
  return { y: +y, m: +m - 1 };
16
19
  };
17
20
  const [viewY, setViewY] = useState(() => initYM().y);
@@ -3,6 +3,9 @@ import { type KeyboardEvent } from 'react';
3
3
  * TextField의 `suggestions` 옵션이 쓰는 자동완성 콤보박스 상태/키보드 로직.
4
4
  * 순수 입력(값 관리)과 분리해서, 자동완성이 필요 없는 TextField 사용처는
5
5
  * 이 훅의 상태가 전혀 관여하지 않는다(항상 호출되지만 open은 기본 false로 시작).
6
+ *
7
+ * 한글 조합 중에는 걸리는 후보가 0 건이 되어도 직전 목록을 유지한다 — 입력칸에 `handleCompositionStart`·
8
+ * `handleCompositionEnd` 를 걸어야 한다(TextField 는 건다). 걸지 않으면 지금처럼 매 값마다 다시 거른다.
6
9
  * @public
7
10
  */
8
11
  export declare function useComboboxSuggestions({ value, onChange, suggestions, openOnFocus, }: {
@@ -20,4 +23,8 @@ export declare function useComboboxSuggestions({ value, onChange, suggestions, o
20
23
  handleInput: () => void;
21
24
  handleKeyDown: (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
22
25
  handleSelect: (item: string) => void;
26
+ /** 입력칸의 onCompositionStart 에 건다 — 조합 중에는 걸리는 후보가 0 건이 되어도 직전 목록을 둔다 */
27
+ handleCompositionStart: () => void;
28
+ /** 입력칸의 onCompositionEnd 에 건다 — 조합이 끝나면 목록을 다시 계산한다 */
29
+ handleCompositionEnd: () => void;
23
30
  };
@@ -4,6 +4,9 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
4
4
  * TextField의 `suggestions` 옵션이 쓰는 자동완성 콤보박스 상태/키보드 로직.
5
5
  * 순수 입력(값 관리)과 분리해서, 자동완성이 필요 없는 TextField 사용처는
6
6
  * 이 훅의 상태가 전혀 관여하지 않는다(항상 호출되지만 open은 기본 false로 시작).
7
+ *
8
+ * 한글 조합 중에는 걸리는 후보가 0 건이 되어도 직전 목록을 유지한다 — 입력칸에 `handleCompositionStart`·
9
+ * `handleCompositionEnd` 를 걸어야 한다(TextField 는 건다). 걸지 않으면 지금처럼 매 값마다 다시 거른다.
7
10
  * @public
8
11
  */
9
12
  export function useComboboxSuggestions({ value, onChange, suggestions, openOnFocus = false, }) {
@@ -11,7 +14,7 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
11
14
  const [open, setOpen] = useState(false);
12
15
  const [activeIdx, setActiveIdx] = useState(-1);
13
16
  const blurTimerRef = useRef(null);
14
- const filtered = useMemo(() => {
17
+ const 계산한것 = useMemo(() => {
15
18
  if (!suggestions.length)
16
19
  return [];
17
20
  const v = value ?? '';
@@ -19,6 +22,16 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
19
22
  return openOnFocus ? suggestions : [];
20
23
  return suggestions.filter(s => s.toLowerCase().includes(v.toLowerCase()));
21
24
  }, [suggestions, value, openOnFocus]);
25
+ /* **한글 조합 중에는 0 건이 되어도 직전 목록을 둔다.** '사'→'삭'→'사고' 로 치는 동안 중간값 '삭' 에 걸리는 후보가 없어
26
+ 목록이 한 박자 사라졌다 돌아왔다(깜빡임·스크린리더가 목록 닫힘을 알림). 조합이 끝나면(compositionend) 다시 계산한다.
27
+ 부르는 쪽이 suggestions 를 값으로 미리 거른 경우(matchesWordQuery 순위 등)도 같다 — 순위 규칙은 건드리지 않는다. */
28
+ const [조합중, set조합중] = useState(false);
29
+ const 직전목록 = useRef([]);
30
+ const filtered = 조합중 && 계산한것.length === 0 ? 직전목록.current : 계산한것;
31
+ useEffect(() => { if (filtered.length)
32
+ 직전목록.current = filtered; }, [filtered]);
33
+ const handleCompositionStart = useCallback(() => set조합중(true), []);
34
+ const handleCompositionEnd = useCallback(() => set조합중(false), []);
22
35
  // 메뉴가 닫히면 활성 인덱스를 리셋하는 의도된 동기화다
23
36
  // eslint-disable-next-line react-hooks/set-state-in-effect
24
37
  useEffect(() => { if (!open)
@@ -66,5 +79,11 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
66
79
  } // 제안이 열렸을 때만 소비(상위 팝업 ESC보다 우선하지 않도록)
67
80
  }
68
81
  }, [filtered, safeIdx, open, handleSelect]);
69
- return { listId, open, activeIdx: safeIdx, filtered, handleFocus, handleBlur, handleInput, handleKeyDown, handleSelect };
82
+ return {
83
+ listId, open, activeIdx: safeIdx, filtered, handleFocus, handleBlur, handleInput, handleKeyDown, handleSelect,
84
+ /** 입력칸의 onCompositionStart 에 건다 — 조합 중에는 걸리는 후보가 0 건이 되어도 직전 목록을 둔다 */
85
+ handleCompositionStart,
86
+ /** 입력칸의 onCompositionEnd 에 건다 — 조합이 끝나면 목록을 다시 계산한다 */
87
+ handleCompositionEnd,
88
+ };
70
89
  }
@@ -29,6 +29,10 @@ export declare const WORD_TABLE_PAPER: Record<WordTableOrientation, {
29
29
  /**
30
30
  * 화면에 렌더된 단어표(.word-table)를 인쇄한다.
31
31
  * @page 방향/크기를 임시 주입한 뒤 window.print()를 호출하고, 인쇄 후 정리한다.
32
+ *
33
+ * @deprecated WordTablePopup 의 `renderButtons` 가 넘기는 `print`(또는 `useWordTablePrint().print`)를 쓴다.
34
+ * 이 함수는 앱 화면 전체를 인쇄하며 전역 print CSS 로 `.word-table` 만 남기는데, 화면에 표가 둘이면 둘 다 찍히고
35
+ * 창·팝업 스타일이 섞인다 — 그쪽은 넘긴 표만 격리 iframe 에서 인쇄한다. 동작은 그대로 두고 개발 모드에서 한 번 알린다.
32
36
  * @public
33
37
  */
34
38
  export declare function printWordTable(orientation?: WordTableOrientation): void;
@@ -1,4 +1,5 @@
1
1
  'use client';
2
+ import { devWarnOnce } from '../utils/devWarn';
2
3
  /** 종이 규격(A4) — 인쇄 캡처에서도 쓰도록 공개 */
3
4
  export const WORD_TABLE_PAPER = {
4
5
  landscape: { width: 1123, height: 794, printSize: 'A4 landscape', printW: '297mm', printH: '210mm' },
@@ -7,9 +8,14 @@ export const WORD_TABLE_PAPER = {
7
8
  /**
8
9
  * 화면에 렌더된 단어표(.word-table)를 인쇄한다.
9
10
  * @page 방향/크기를 임시 주입한 뒤 window.print()를 호출하고, 인쇄 후 정리한다.
11
+ *
12
+ * @deprecated WordTablePopup 의 `renderButtons` 가 넘기는 `print`(또는 `useWordTablePrint().print`)를 쓴다.
13
+ * 이 함수는 앱 화면 전체를 인쇄하며 전역 print CSS 로 `.word-table` 만 남기는데, 화면에 표가 둘이면 둘 다 찍히고
14
+ * 창·팝업 스타일이 섞인다 — 그쪽은 넘긴 표만 격리 iframe 에서 인쇄한다. 동작은 그대로 두고 개발 모드에서 한 번 알린다.
10
15
  * @public
11
16
  */
12
17
  export function printWordTable(orientation = 'landscape') {
18
+ devWarnOnce('print-word-table-deprecated', '[printWordTable] @deprecated — WordTablePopup renderButtons 의 print(또는 useWordTablePrint().print)를 쓰세요. 전역 인쇄는 화면의 모든 단어표를 함께 찍습니다.');
13
19
  const { printSize, printW, printH } = WORD_TABLE_PAPER[orientation];
14
20
  const style = document.createElement('style');
15
21
  style.textContent = `@page { size: ${printSize}; margin: 0; } .word-table { width: ${printW} !important; height: ${printH} !important; }`;
@@ -171,7 +171,8 @@ export { default as WordTablePopup } from './components/Training/WordTablePopup'
171
171
  export type { WordTablePopupProps, WordTablePopupActions } from './components/Training/WordTablePopup';
172
172
  export { MARKS, MARK_ICON, MARK_VARIANT, markAfterClick, marksForSave, pageStartIndexes, scoreKey, shouldConfirmDiscard, summarize } from './components/Word/wordTableScoring';
173
173
  export type { ScoreMark, ScoreSummary } from './components/Word/wordTableScoring';
174
- export { reviewCells, reviewTableName, scoreSheetHeader, labelsForSave, cellLabel } from './components/Word/wordTableReview';
174
+ export { reviewCells, reviewTableName, reviewMeta, scoreSheetHeader, labelsForSave, cellLabel, cellKey, labelOfCellKey } from './components/Word/wordTableReview';
175
+ export type { ReviewSource } from './components/Word/wordTableReview';
175
176
  export { weakItems, tableTrends, nextFocus } from './components/Word/wordScoreStats';
176
177
  export { summarizeMisses, nextMisses, missesByType, reviewSetPlan, SENTENCE_TRAINING_TYPES } from './components/Training/trainingMissStats';
177
178
  export type { TrainingResultRecord, MissedItem, MissSummary, MissGroup, ReviewSetRef, ReviewSetPlan } from './components/Training/trainingMissStats';
@@ -112,7 +112,7 @@ export { default as WordTablePopup } from './components/Training/WordTablePopup'
112
112
  /* 단어표 채점 규칙 — 앱 쪽(저장·요약 표시)에서도 같은 규칙을 써야 한다. */
113
113
  export { MARKS, MARK_ICON, MARK_VARIANT, markAfterClick, marksForSave, pageStartIndexes, scoreKey, shouldConfirmDiscard, summarize } from './components/Word/wordTableScoring';
114
114
  /* 채점 결과로 다음 표를 짜는 규칙 — 앱 쪽에서 복습표를 만들 때 쓴다. */
115
- export { reviewCells, reviewTableName, scoreSheetHeader, labelsForSave, cellLabel } from './components/Word/wordTableReview';
115
+ export { reviewCells, reviewTableName, reviewMeta, scoreSheetHeader, labelsForSave, cellLabel, cellKey, labelOfCellKey } from './components/Word/wordTableReview';
116
116
  /* 쌓인 채점을 읽는 셈 — 무엇이 부족하고 다음에 무엇을 할지. */
117
117
  export { weakItems, tableTrends, nextFocus } from './components/Word/wordScoreStats';
118
118
  /* 앱에서 푼 훈련의 오답을 읽는 셈 — 단어표 채점과 같은 결. */