@ihabdevteam/core 0.321.0 → 0.323.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CHANGELOG.md +617 -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 +108 -66
  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/HearingDeviceInfo.d.ts +5 -5
  24. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
  25. package/dist/packages/core/src/components/Icon.js +2 -2
  26. package/dist/packages/core/src/components/ItemActions.js +16 -16
  27. package/dist/packages/core/src/components/LandingHeader.js +6 -6
  28. package/dist/packages/core/src/components/LineChart.js +24 -24
  29. package/dist/packages/core/src/components/Loader.js +5 -5
  30. package/dist/packages/core/src/components/NoticeBox.js +3 -3
  31. package/dist/packages/core/src/components/PageTitle.js +2 -2
  32. package/dist/packages/core/src/components/Pagination.js +4 -4
  33. package/dist/packages/core/src/components/Panel.js +2 -2
  34. package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
  35. package/dist/packages/core/src/components/PanelRow.js +8 -15
  36. package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
  37. package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
  38. package/dist/packages/core/src/components/PaymentPlan.js +5 -5
  39. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
  40. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
  41. package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
  42. package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
  43. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +19 -26
  44. package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
  45. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +10 -10
  46. package/dist/packages/core/src/components/Popup/Popup.js +27 -27
  47. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
  48. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
  49. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
  50. package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
  51. package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
  52. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
  53. package/dist/packages/core/src/components/Rating.js +20 -20
  54. package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
  55. package/dist/packages/core/src/components/RingChart.js +5 -5
  56. package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
  57. package/dist/packages/core/src/components/SegmentedControl.js +34 -34
  58. package/dist/packages/core/src/components/SideNav.js +37 -37
  59. package/dist/packages/core/src/components/Slider.js +2 -2
  60. package/dist/packages/core/src/components/SoundCompass.js +5 -5
  61. package/dist/packages/core/src/components/Table.js +257 -98
  62. package/dist/packages/core/src/components/Tabs.js +12 -12
  63. package/dist/packages/core/src/components/TextField.js +34 -34
  64. package/dist/packages/core/src/components/Toast.js +6 -6
  65. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
  66. package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
  67. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
  68. package/dist/packages/core/src/components/TopBanner.js +28 -28
  69. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
  70. package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
  71. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
  72. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
  73. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
  74. package/dist/packages/core/src/components/Training/TrainingBlock.js +12 -12
  75. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
  76. package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
  77. package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
  78. package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
  79. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
  80. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +83 -83
  81. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
  82. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +128 -128
  83. package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
  84. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
  85. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
  86. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +13 -13
  87. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
  88. package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
  89. package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
  90. package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
  91. package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
  92. package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
  93. package/dist/packages/core/src/components/Training/oneShotSound.js +14 -14
  94. package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +2 -2
  95. package/dist/packages/core/src/components/Training/trainingMissStats.js +22 -22
  96. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
  97. package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
  98. package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
  99. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
  100. package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
  101. package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
  102. package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
  103. package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
  104. package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
  105. package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
  106. package/dist/packages/core/src/components/UpdateAlert.js +6 -6
  107. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
  108. package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
  109. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +18 -18
  110. package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
  111. package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
  112. package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
  113. package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
  114. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
  115. package/dist/packages/core/src/components/Word/wordScoreStats.js +17 -17
  116. package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
  117. package/dist/packages/core/src/components/Word/wordTableRecommend.js +13 -13
  118. package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
  119. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
  120. package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
  121. package/dist/packages/core/src/components/WordTable.js +130 -130
  122. package/dist/packages/core/src/components/barChartData.d.ts +16 -1
  123. package/dist/packages/core/src/components/barChartData.js +49 -29
  124. package/dist/packages/core/src/components/barChartTypes.js +8 -8
  125. package/dist/packages/core/src/components/tableTrackList.js +11 -11
  126. package/dist/packages/core/src/components/updateAlertStack.js +28 -28
  127. package/dist/packages/core/src/components/useBarChartControl.js +4 -6
  128. package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
  129. package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
  130. package/dist/packages/core/src/components/wordTableLayout.d.ts +22 -7
  131. package/dist/packages/core/src/components/wordTableLayout.js +5 -5
  132. package/dist/packages/core/src/context/I18nProvider.js +6 -6
  133. package/dist/packages/core/src/hooks/focusReturn.js +27 -27
  134. package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
  135. package/dist/packages/core/src/hooks/inputModality.js +14 -14
  136. package/dist/packages/core/src/hooks/touchToggle.js +39 -39
  137. package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
  138. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
  139. package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
  140. package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
  141. package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
  142. package/dist/packages/core/src/hooks/useEditableList.js +4 -4
  143. package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
  144. package/dist/packages/core/src/hooks/useFontScale.js +6 -6
  145. package/dist/packages/core/src/hooks/useIcon.js +7 -7
  146. package/dist/packages/core/src/hooks/useListControls.js +2 -2
  147. package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
  148. package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
  149. package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
  150. package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
  151. package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
  152. package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
  153. package/dist/packages/core/src/hooks/useTablePaging.d.ts +7 -7
  154. package/dist/packages/core/src/hooks/useTablePaging.js +40 -34
  155. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
  156. package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
  157. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
  158. package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
  159. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +3 -1
  160. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +14 -12
  161. package/dist/packages/core/src/i18n/locales/en.json +13 -2
  162. package/dist/packages/core/src/i18n/locales/ko.json +12 -1
  163. package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
  164. package/dist/packages/core/src/utils/audioMeter.js +5 -5
  165. package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
  166. package/dist/packages/core/src/utils/audioPeaks.js +18 -18
  167. package/dist/packages/core/src/utils/blockGrid.js +4 -4
  168. package/dist/packages/core/src/utils/chunkSkew.js +9 -9
  169. package/dist/packages/core/src/utils/clipboard.js +3 -3
  170. package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
  171. package/dist/packages/core/src/utils/date.js +12 -12
  172. package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
  173. package/dist/packages/core/src/utils/devWarn.js +7 -7
  174. package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
  175. package/dist/packages/core/src/utils/deviceKind.js +53 -10
  176. package/dist/packages/core/src/utils/errorMessage.js +7 -7
  177. package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
  178. package/dist/packages/core/src/utils/filterPanelSummary.js +16 -16
  179. package/dist/packages/core/src/utils/format.js +4 -4
  180. package/dist/packages/core/src/utils/getTrainingData.js +9 -9
  181. package/dist/packages/core/src/utils/imageConfig.js +17 -17
  182. package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
  183. package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
  184. package/dist/packages/core/src/utils/navScroll.js +6 -6
  185. package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
  186. package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
  187. package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
  188. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
  189. package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
  190. package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
  191. package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
  192. package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
  193. package/dist/packages/core/src/utils/pressable.d.ts +1 -1
  194. package/dist/packages/core/src/utils/pressable.js +3 -3
  195. package/dist/packages/core/src/utils/safeHref.js +4 -4
  196. package/dist/packages/core/src/utils/savedWordTables.js +5 -5
  197. package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
  198. package/dist/packages/core/src/utils/sessionTab.js +9 -9
  199. package/dist/packages/core/src/utils/speak.js +12 -12
  200. package/dist/packages/core/src/utils/textSort.d.ts +5 -5
  201. package/dist/packages/core/src/utils/textSort.js +47 -47
  202. package/dist/packages/core/src/utils/theme.js +3 -3
  203. package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
  204. package/dist/packages/core/src/utils/ttsConfig.js +2 -2
  205. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +2 -2
  206. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +48 -48
  207. package/dist/packages/core/src/utils/wordContrast.js +3 -3
  208. package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
  209. package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
  210. package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
  211. package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
  212. package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
  213. package/dist/packages/core/src/utils/wornDate.js +14 -14
  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 +4 -4
  217. package/package.json +1 -1
@@ -20,7 +20,7 @@ import { isTouchPointer, recentlyTouched, useTouchToggle } from '../hooks/touchT
20
20
 
21
21
  ihab 은 **흰 판을 마스크로 쓴다**(사용자 지시 2026-09-30: 글자색을 입힌다) — 그쪽은
22
22
  `WordTable.css` 가 제 곁의 파일을 직접 가리키므로 여기서 안 들여온다. */
23
- import 말귀로고 from '../logo/Logo_speechear_kr.svg';
23
+ import speechearLogo from '../logo/Logo_speechear_kr.svg';
24
24
  import { WORD_TABLE_PAPER, gridFor, CUT_CLASS, posLabels, wordTypeLabels, 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';
25
25
  import { useFitScale } from './useFitScale';
26
26
  import { claimSound } from './Training/currentSound';
@@ -32,7 +32,7 @@ import { playOneShot } from './Training/oneShotSound';
32
32
  (상수 하나는 남긴다 — 규칙이 상수는 허용하고, 지우면 공개 API 가 바뀐다.) */
33
33
  export { WORD_TABLE_PAPER } from './wordTableLayout';
34
34
  /** 눈에는 안 보이고 낭독기에는 읽히는 글자(ProgressIndicator 와 같은 규칙) */
35
- const 보이지않게 = {
35
+ const srOnly = {
36
36
  position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden',
37
37
  clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0,
38
38
  };
@@ -42,9 +42,9 @@ const 보이지않게 = {
42
42
  * 300~500ms 가 흔한 값이다(iOS 의 길게 누르기는 500ms, Android 는 400ms 안팎).
43
43
  * 짧으면 살짝 스친 손가락에 칸이 끌려가고, 길면 "안 되네" 하고 손을 뗀다.
44
44
  */
45
- const 길게누름_MS = 400;
45
+ const LONG_PRESS_MS = 400;
46
46
  /** 길게 누르기가 익기 전에 이만큼 움직이면 그건 끌기가 아니라 **스크롤**이라 취소한다 */
47
- const 움직임문턱_PX = 8;
47
+ const MOVE_THRESHOLD_PX = 8;
48
48
  function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true, showImage = true, showPronunciation = true, showColloquial = true, buttonSize = 40, wordInfo, }) {
49
49
  const { t } = useTranslation();
50
50
  const outerRef = useRef(null);
@@ -54,7 +54,7 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
54
54
  const [playing, setPlaying] = useState(false);
55
55
  /* 받은 주소에 **어느 낱말의 것인지** 를 함께 둔다 — 주소만 들고 있으면 같은 칸의 낱말이 바뀐 뒤 새 주소를 받는 동안(또는
56
56
  못 받으면) 누를 때 앞 낱말 소리가 났다(틀린 자극). 지금 낱말의 것일 때만 쓴다. */
57
- const [받은소리, set받은소리] = useState({ for: '', url: '' });
57
+ const [fetchedAudio, setFetchedAudio] = useState({ for: '', url: '' });
58
58
  const [infoAnchor, setInfoAnchor] = useState(null);
59
59
  // 발음(subLabel)이 없으면 단어로 표준발음 조회(앱 주입 resolver)
60
60
  const pron = (subLabel && subLabel.length > 0) ? subLabel : (getPronunciationResolver()?.(label, homonum) ?? '');
@@ -63,7 +63,7 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
63
63
  const handleInfoEnter = useCallback(() => setInfoAnchor(infoRef.current?.getBoundingClientRect() ?? null), []);
64
64
  const handleInfoLeave = useCallback(() => setInfoAnchor(null), []);
65
65
  /* 터치 — 톡하면 열렸다 떼자마자 닫혀 낱말 정보를 읽을 수 없었다. 톡으로 열고 닫는다(마우스·키보드는 그대로) */
66
- const 정보톡 = useTouchToggle(!!infoAnchor, (next) => { if (next)
66
+ const infoToggle = useTouchToggle(!!infoAnchor, (next) => { if (next)
67
67
  handleInfoEnter();
68
68
  else
69
69
  handleInfoLeave(); }, infoRef);
@@ -83,8 +83,8 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
83
83
  arr.push(t('wordTable.info.wordType', { v: expandSlashLabel(wordInfo.wordType, wordTypeLabels(t)), defaultValue: '유형: {{v}}' }));
84
84
  return arr.length ? arr : null;
85
85
  }, [wordInfo, t]);
86
- const 소리열쇠 = pron || label;
87
- const audioUrl = 받은소리.for === 소리열쇠 ? 받은소리.url : '';
86
+ const audioKey = pron || label;
87
+ const audioUrl = fetchedAudio.for === audioKey ? fetchedAudio.url : '';
88
88
  useEffect(() => {
89
89
  const resolver = getTTSUrlResolver();
90
90
  const ttsText = pron || label;
@@ -92,7 +92,7 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
92
92
  return;
93
93
  let cancelled = false;
94
94
  Promise.resolve(resolver(ttsText)).then(url => { if (!cancelled && url)
95
- set받은소리({ for: ttsText, url }); });
95
+ setFetchedAudio({ for: ttsText, url }); });
96
96
  return () => { cancelled = true; };
97
97
  }, [pron, label]);
98
98
  // 컴포넌트 언마운트 시 발음 재생 중단 — audioRef는 <audio> JSX가 아니라 new Audio()로
@@ -122,11 +122,11 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
122
122
  /* 끝나거나 멈추면 **그 자리에서** 자리를 비운다 — 약속으로 받으면 한 틱 늦어, 남이
123
123
  자물쇠를 가져간 순간에 이 칸이 아직 "재생 중" 으로 보인다.
124
124
  이미 다음 사람이 가져갔으면 release 는 아무것도 안 한다. */
125
- const 소리 = playOneShot(audioUrl, () => { setPlaying(false); releaseRef.current?.(); });
126
- audioRef.current = 소리.el;
127
- stopRef.current = 소리.stop;
125
+ const sound = playOneShot(audioUrl, () => { setPlaying(false); releaseRef.current?.(); });
126
+ audioRef.current = sound.el;
127
+ stopRef.current = sound.stop;
128
128
  /* 자물쇠를 잡으면 앞서 울리던 것(다른 칸·훈련 카드 등)이 멈추고, 남이 잡으면 stop 이 불려 내가 멈춘다 */
129
- releaseRef.current = claimSound(소리.stop);
129
+ releaseRef.current = claimSound(sound.stop);
130
130
  }, [playing, audioUrl]);
131
131
  const hasImage = showImage && !!imageUrl && !imgFailed;
132
132
  const refW = hasImage && showWord ? REF_W_BOTH : hasImage ? REF_W_IMAGE_ONLY : REF_W_TEXT_ONLY;
@@ -165,7 +165,7 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
165
165
  /* 아이콘만 있는 버튼이라 이름이 없으면 화면 낭독기가 "버튼"으로만 읽는다.
166
166
  한 표에 칸마다 하나씩 있어 열두 개가 다 "버튼"이 된다 — 어느 낱말의
167
167
  것인지 알 길이 없으므로 낱말을 이름에 넣는다. */
168
- ariaLabel: t('wordTable.wordInfo', '{{word}} 낱말 정보', { word: label }), onClick: (e) => { e.stopPropagation(); 정보톡.touchPress(); } }), infoAnchor && infoTooltipItems && (_jsx(DescriptionTooltip, { anchorRect: infoAnchor, items: infoTooltipItems, showIndex: false }))] }))] }));
168
+ ariaLabel: t('wordTable.wordInfo', '{{word}} 낱말 정보', { word: label }), onClick: (e) => { e.stopPropagation(); infoToggle.touchPress(); } }), infoAnchor && infoTooltipItems && (_jsx(DescriptionTooltip, { anchorRect: infoAnchor, items: infoTooltipItems, showIndex: false }))] }))] }));
169
169
  }
170
170
  /**
171
171
  * 단어표(표 디자인) — A4 종이를 컨테이너에 맞춰 축소(scale)하고,
@@ -173,7 +173,7 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
173
173
  * 표시(items)·편집(editCells) 두 모드를 지원한다.
174
174
  * @public
175
175
  */
176
- export default function WordTable({ title, titleIcon, disc, className, orientation = 'landscape', contentTypes = ['이미지', '단어', '표준 발음'], cutLine = '표시', showOutline = true, showDivLine = true, items, editCells, count, activeCell = null, onActivateCell, onRemoveCell, onRemoveItem, onReorder, renderCellAction, showFillerAdd = false, contentRef, buttonSize = 40, brand, code, pageNumber, pageNumberPosition = 'right', fitHeight = false, scoring = false, marks, onMarkClick, scoreIndexOffset = 0, }) {
176
+ export default function WordTable({ title, titleIcon, disc, className, orientation = 'landscape', contentTypes = ['image', 'word', 'standardPron'], cutLine = 'show', showOutline = true, showDivLine = true, items, editCells, count, activeCell = null, onActivateCell, onRemoveCell, onRemoveItem, onReorder, renderCellAction, showFillerAdd = false, contentRef, buttonSize = 40, brand, code, pageNumber, pageNumberPosition = 'right', fitHeight = false, scoring = false, marks, onMarkClick, scoreIndexOffset = 0, }) {
177
177
  const clickMark = useCallback((cellIndex, clicked) => {
178
178
  onMarkClick?.(scoreIndexOffset + cellIndex, clicked);
179
179
  }, [onMarkClick, scoreIndexOffset]);
@@ -188,7 +188,7 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
188
188
  _jsx("div", { className: cx('word-table-cell__score', mark ? 'word-table-cell__score--marked' : ''),
189
189
  /* CSS 의 가운데 맞춤(translateX(-50%))을 그대로 두고 되돌림만 얹는다. 바닥 가운데를
190
190
  기준으로 키워야 칸 아래쪽에 붙은 자리가 흔들리지 않는다. */
191
- style: 채점되돌림 > 1 ? { transform: `translateX(-50%) scale(${채점되돌림})`, transformOrigin: 'bottom center' } : undefined, children: MARKS.map(m => (_jsx("span", { className: cx('word-table-cell__score-slot', mark === m ? 'word-table-cell__score-slot--on' : ''), children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot",
191
+ style: markUnscale > 1 ? { transform: `translateX(-50%) scale(${markUnscale})`, transformOrigin: 'bottom center' } : undefined, children: MARKS.map(m => (_jsx("span", { className: cx('word-table-cell__score-slot', mark === m ? 'word-table-cell__score-slot--on' : ''), children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot",
192
192
  /* **동그라미다**(말귀 요청 ㊸, 2026-10-01). `Button` 기본은 모서리가 둥근
193
193
  네모(`shape="rounded"`)인데, 같은 평가를 말하는 셈과 낱말 목록은
194
194
  `CircleBadge` 라 한 창 안에서 **같은 뜻이 두 모양**으로 섰다.
@@ -197,7 +197,7 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
197
197
  프롭으로 열지 않는다: 둥근 네모를 원하는 자리가 없고, 열어 두면 **같은
198
198
  결정을 자리마다 다시 적어야** 해서 조용히 갈린다(말귀가 두 창에서 실제로
199
199
  갈렸다 — 회차 창은 동그라미, 채점 창은 둥근 네모). */
200
- shape: "pill", icon: MARK_ICON[m], variant: mark === m ? MARK_VARIANT[m] : 'default', selected: mark === m, useBorder: false, ...(매길수있나 ? {} : { tabIndex: -1, role: 'img' }),
200
+ shape: "pill", icon: MARK_ICON[m], variant: mark === m ? MARK_VARIANT[m] : 'default', selected: mark === m, useBorder: false, ...(canMark ? {} : { tabIndex: -1, role: 'img' }),
201
201
  /* **마우스를 올리면 이름이 뜬다**(말귀 요청 ㊴, 2026-10-01). 이 셋은 아이콘만
202
202
  있어 낭독기에는 `낙지 맞음` 으로 제대로 읽히는데 **눈으로 보는 사람에게는
203
203
  이름이 없었다** — 세모가 '애매' 인지 '반만 맞음' 인지 알 길이 없다.
@@ -205,14 +205,14 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
205
205
  얹은 자리에서 알고 싶은 것은 이 표식이 무엇이냐 하나다.
206
206
  `ariaLabel` 은 그대로 둔다 — 낭독기는 칸을 안 보고 지나므로 거기서는
207
207
  낱말이 있어야 "어느 칸의 맞음" 인지 알 수 있다. 둘이 일부러 다르다. */
208
- showTooltipInfo: t(`wordTable.scoring.markName.${m}`, { defaultValue: m }), ariaLabel: t(`wordTable.scoring.${m}`, { word: cellLabel, defaultValue: `${cellLabel} ${m}` }), onClick: 매길수있나 ? (e) => { e.stopPropagation(); clickMark(cellIndex, m); } : undefined }) }, m))) }));
208
+ showTooltipInfo: t(`wordTable.scoring.markName.${m}`, { defaultValue: m }), ariaLabel: t(`wordTable.scoring.${m}`, { word: cellLabel, defaultValue: `${cellLabel} ${m}` }), onClick: canMark ? (e) => { e.stopPropagation(); clickMark(cellIndex, m); } : undefined }) }, m))) }));
209
209
  };
210
210
  const { t } = useTranslation();
211
211
  const isEdit = editCells !== undefined;
212
212
  /** title·titleIcon·disc 중 하나라도 있으면 Section 으로 감싼다 */
213
213
  const wrapped = title != null || !!titleIcon || !!disc;
214
214
  /* 틀 높이는 화면 폭에 따라 갈린다 — 훅은 늘 부른다(순서 고정) */
215
- const 좁은화면 = useMediaQuery('(max-width: 767px)');
215
+ const isNarrow = useMediaQuery('(max-width: 767px)');
216
216
  // 드래그 상태(표시 모드 재정렬)
217
217
  const dragIndex = useRef(null);
218
218
  const [dragOver, setDragOver] = useState(null);
@@ -221,171 +221,171 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
221
221
  바꿀 길이 아예 없었고(끌 수 있다는 것조차 알 수 없다), 터치 기기에서는 HTML5 DnD 가
222
222
  굴러가지도 않아 손가락으로도 못 바꿨다. 표를 만드는 화면의 핵심 조작인데
223
223
  마우스 있는 사람만 쓸 수 있었다. 마우스 끌기는 그대로 두고 두 길을 더한다. */
224
- const 낱말수 = items?.length ?? 0;
224
+ const wordCount = items?.length ?? 0;
225
225
  /** 지금 손가락에 끌려가는 칸 */
226
- const [끌기, set끌기] = useState(null);
226
+ const [dragging, setDragging] = useState(null);
227
227
  /** 낭독기에 바뀐 자리를 알리는 글. 처음에는 비워 둔다 — 열자마자 뜬금없이 읽지 않게. */
228
- const [옮김알림, set옮김알림] = useState('');
228
+ const [moveAnnounce, setMoveAnnounce] = useState('');
229
229
  /** 다시 그린 뒤 초점을 둘 칸 번호 — 옮긴 칸을 초점이 따라가야 다음 Alt+화살표가 같은 칸을 민다 */
230
- const 초점갈곳 = useRef(null);
230
+ const focusTarget = useRef(null);
231
231
  /** 칸 격자 DOM — 손가락 밑의 칸을 찾고, 옮긴 뒤 손잡이에 초점을 주는 데 쓴다 */
232
- const 격자 = useRef(null);
232
+ const gridRef = useRef(null);
233
233
  /** 지금 떨어뜨릴 자리(상태와 같은 값의 ref) — pointerup 은 마지막 렌더를 기다려 주지 않는다 */
234
- const 떨굴자리 = useRef(null);
234
+ const dropIndex = useRef(null);
235
235
  /** 길게 누르기가 걸어 둔 것들을 한 번에 푸는 함수 */
236
- const 풀기 = useRef(null);
236
+ const release = useRef(null);
237
237
  /** `contentRef`(인쇄·캡처용, 부르는 쪽 것)와 우리 `격자` 를 함께 채운다 */
238
- const 내용ref = useCallback((el) => {
239
- 격자.current = el;
238
+ const gridContentRef = useCallback((el) => {
239
+ gridRef.current = el;
240
240
  if (typeof contentRef === 'function')
241
241
  contentRef(el);
242
242
  else if (contentRef)
243
243
  contentRef.current = el;
244
244
  }, [contentRef]);
245
- const 옮기기 = useCallback((from, to) => {
246
- 초점갈곳.current = to;
245
+ const move = useCallback((from, to) => {
246
+ focusTarget.current = to;
247
247
  onReorder?.(from, to);
248
- set옮김알림(t('wordTable.reorder.moved', {
249
- n: to + 1, total: 낱말수, defaultValue: '{{total}}칸 가운데 {{n}}번째로 옮김',
248
+ setMoveAnnounce(t('wordTable.reorder.moved', {
249
+ n: to + 1, total: wordCount, defaultValue: '{{total}}칸 가운데 {{n}}번째로 옮김',
250
250
  }));
251
- }, [onReorder, t, 낱말수]);
251
+ }, [onReorder, t, wordCount]);
252
252
  /* 옮긴 칸을 초점이 따라간다. 자리를 잃으면 다음 Alt+화살표가 엉뚱한 칸을 민다 —
253
253
  아예 문서 처음으로 튀어 어디였는지도 알 수 없다. 의존성 배열 없이 매 렌더 뒤에
254
254
  보되 `초점갈곳` 이 비어 있으면 곧바로 돌아온다(거의 언제나 그렇다). */
255
255
  useEffect(() => {
256
- const to = 초점갈곳.current;
256
+ const to = focusTarget.current;
257
257
  if (to == null)
258
258
  return;
259
- 초점갈곳.current = null;
260
- const 칸 = 격자.current?.querySelectorAll('.word-table-block__card:not(.word-table-block__card--filler)');
261
- 칸?.[to]?.querySelector('.word-table-block__card-handle button')?.focus();
259
+ focusTarget.current = null;
260
+ const cells = gridRef.current?.querySelectorAll('.word-table-block__card:not(.word-table-block__card--filler)');
261
+ cells?.[to]?.querySelector('.word-table-block__card-handle button')?.focus();
262
262
  });
263
263
  /* 떠날 때 걸어 둔 것을 푼다 — 길게 누르는 중에 팝업이 닫히면 window 리스너와
264
264
  타이머가 남아 사라진 화면의 상태를 건드린다(표의 폭 조절에서 겪은 것과 같다). */
265
- useEffect(() => () => { 풀기.current?.(); }, []);
265
+ useEffect(() => () => { release.current?.(); }, []);
266
266
  /**
267
267
  * **키보드로 옮긴다** — 손잡이에 초점을 두고 `Alt`+`←`/`→`.
268
268
  *
269
269
  * 낱말 카드 안 다른 단추(재생·정보·빼기)는 Enter·Space 로 눌리고, 화살표만으로는
270
270
  * 아무 일도 안 일어난다. `Alt` 를 얹어 그 둘 어느 쪽과도 부딪히지 않게 했다.
271
271
  */
272
- const 키로옮기기 = useCallback((e, index) => {
272
+ const moveByKey = useCallback((e, index) => {
273
273
  if (!onReorder)
274
274
  return;
275
275
  if (!e.altKey || e.ctrlKey || e.metaKey)
276
276
  return;
277
- const 뒤로 = e.key === 'ArrowRight';
278
- if (!뒤로 && e.key !== 'ArrowLeft')
277
+ const forward = e.key === 'ArrowRight';
278
+ if (!forward && e.key !== 'ArrowLeft')
279
279
  return;
280
280
  e.preventDefault();
281
- const to = index + (뒤로 ? 1 : -1);
282
- if (to < 0 || to >= 낱말수)
281
+ const to = index + (forward ? 1 : -1);
282
+ if (to < 0 || to >= wordCount)
283
283
  return; // 양 끝 — 아무 일도 하지 않는다
284
- 옮기기(index, to);
285
- }, [onReorder, 낱말수, 옮기기]);
284
+ move(index, to);
285
+ }, [onReorder, wordCount, move]);
286
286
  /**
287
287
  * **손가락으로 옮긴다** — 길게 누르면(400ms) 끌기가 시작되고 손가락 밑의 칸으로 떨어진다.
288
288
  *
289
289
  * 마우스는 손대지 않는다. 기존 HTML5 드래그 앤 드롭이 그대로 맡는다 —
290
290
  * 두 길이 같은 포인터를 두고 다투면 끌기가 중간에 끊긴다.
291
291
  */
292
- const 길게눌러끌기 = useCallback((e, index) => {
292
+ const longPressDrag = useCallback((e, index) => {
293
293
  if (!onReorder || e.pointerType === 'mouse')
294
294
  return;
295
295
  /* 칸 안 단추를 길게 누른 것은 그 단추 몫이다(재생·정보·빼기) — 옮기기 손잡이만 예외.
296
296
  안 가르면 발음을 들으려고 꾹 누른 손가락에 칸이 끌려간다. */
297
- const 눌린단추 = e.target.closest?.('button');
298
- if (눌린단추 && !눌린단추.closest('.word-table-block__card-handle'))
297
+ const pressedButton = e.target.closest?.('button');
298
+ if (pressedButton && !pressedButton.closest('.word-table-block__card-handle'))
299
299
  return;
300
- 풀기.current?.();
301
- const 시작 = { x: e.clientX, y: e.clientY };
302
- let 익음 = false;
300
+ release.current?.();
301
+ const start = { x: e.clientX, y: e.clientY };
302
+ let armed = false;
303
303
  /* 끌기 중에는 화면이 따라 스크롤되면 안 된다. React 가 다는 touchmove 는 passive 라
304
304
  거기서는 막을 수 없어, 익은 순간 직접 단다(passive:false). */
305
- const 스크롤막기 = (ev) => { if (익음 && ev.cancelable)
305
+ const blockScroll = (ev) => { if (armed && ev.cancelable)
306
306
  ev.preventDefault(); };
307
307
  /* **길이 둘이라 갈라 둔다**(2026-10-10, 코어 자체 조사). *문턱을 넘었나* 는 **안 미룬다** — 그 판정이
308
308
  길게누름을 거두는 일이라, 한 틀 미루면 손가락을 움직였는데도 끌기가 익어 버린다. *밑에 어느 칸인가*
309
309
  는 `elementFromPoint`(레이아웃을 읽는다) + 상태 바꾸기라 **틀에 한 번으로 모은다** — 보이는 일뿐이다. */
310
- let 자리예약 = null;
311
- let 마지막점 = null;
312
- const 자리셈 = () => {
313
- const pt = 마지막점;
310
+ let rafId = null;
311
+ let lastPoint = null;
312
+ const computeIndex = () => {
313
+ const pt = lastPoint;
314
314
  if (!pt)
315
315
  return;
316
316
  /* 손가락 밑의 칸. `elementFromPoint` 가 없는 환경(구형·시험)에서는 자리를 못 고른다 —
317
317
  그때는 떨어뜨려도 아무 일이 없다(끌기가 시작되지 않은 것과 같다). */
318
- const 밑 = document.elementFromPoint?.(pt.x, pt.y);
319
- const 칸 = 밑?.closest?.('[data-wordtable-cell]');
320
- const i = 칸 ? Number(칸.dataset.wordtableCell) : NaN;
321
- const 자리 = Number.isInteger(i) ? i : null;
322
- 떨굴자리.current = 자리;
323
- setDragOver(prev => (prev === 자리 ? prev : 자리));
318
+ const under = document.elementFromPoint?.(pt.x, pt.y);
319
+ const cell = under?.closest?.('[data-wordtable-cell]');
320
+ const i = cell ? Number(cell.dataset.wordtableCell) : NaN;
321
+ const overIndex = Number.isInteger(i) ? i : null;
322
+ dropIndex.current = overIndex;
323
+ setDragOver(prev => (prev === overIndex ? prev : overIndex));
324
324
  };
325
- const 이동 = (ev) => {
326
- if (!익음) {
327
- if (Math.abs(ev.clientX - 시작.x) > 움직임문턱_PX || Math.abs(ev.clientY - 시작.y) > 움직임문턱_PX)
328
- 해제();
325
+ const onMove = (ev) => {
326
+ if (!armed) {
327
+ if (Math.abs(ev.clientX - start.x) > MOVE_THRESHOLD_PX || Math.abs(ev.clientY - start.y) > MOVE_THRESHOLD_PX)
328
+ cleanup();
329
329
  return;
330
330
  }
331
- 마지막점 = { x: ev.clientX, y: ev.clientY };
331
+ lastPoint = { x: ev.clientX, y: ev.clientY };
332
332
  if (typeof requestAnimationFrame === 'undefined') {
333
- 자리셈();
333
+ computeIndex();
334
334
  return;
335
335
  }
336
- if (자리예약 !== null)
336
+ if (rafId !== null)
337
337
  return;
338
- 자리예약 = requestAnimationFrame(() => { 자리예약 = null; 자리셈(); });
338
+ rafId = requestAnimationFrame(() => { rafId = null; computeIndex(); });
339
339
  };
340
- const 뗌 = () => {
340
+ const onUp = () => {
341
341
  /* **떼기 전에 밀린 셈을 치른다.** 자리셈을 틀로 모았으니, 움직이고 **같은 틀 안에** 떼면 틀이
342
342
  오기 전에 여기 닿는다 — 그러면 `떨굴자리` 가 비어 **떨어뜨린 자리를 잃는다.** 옛 시험
343
343
  (`wordTableReorder0916`)이 그것을 짚었다. 모으는 고침을 할 때는 **그 값을 읽는 쪽**도 봐야 한다. */
344
- if (자리예약 !== null) {
345
- cancelAnimationFrame(자리예약);
346
- 자리예약 = null;
347
- 자리셈();
344
+ if (rafId !== null) {
345
+ cancelAnimationFrame(rafId);
346
+ rafId = null;
347
+ computeIndex();
348
348
  }
349
- const 자리 = 떨굴자리.current;
350
- const 끌었다 = 익음;
351
- 해제();
352
- if (끌었다 && 자리 != null && 자리 !== index)
353
- 옮기기(index, 자리);
349
+ const dropAt = dropIndex.current;
350
+ const didDrag = armed;
351
+ cleanup();
352
+ if (didDrag && dropAt != null && dropAt !== index)
353
+ move(index, dropAt);
354
354
  };
355
- const 타이머 = setTimeout(() => {
356
- 익음 = true;
357
- set끌기(index);
358
- 떨굴자리.current = index;
355
+ const timer = setTimeout(() => {
356
+ armed = true;
357
+ setDragging(index);
358
+ dropIndex.current = index;
359
359
  setDragOver(index);
360
360
  /* **안-모음: `preventDefault()` 는 못 미룬다** — 틀로 미루면 이벤트가 이미 지나가 막을 수 없다
361
361
  (`passive: false` 로 다는 까닭이 그것이다). */
362
- window.addEventListener('touchmove', 스크롤막기, { passive: false });
363
- }, 길게누름_MS);
364
- function 해제() {
365
- clearTimeout(타이머);
366
- window.removeEventListener('pointermove', 이동);
367
- window.removeEventListener('pointerup', 뗌);
368
- window.removeEventListener('pointercancel', 뗌);
369
- window.removeEventListener('touchmove', 스크롤막기);
370
- 익음 = false;
371
- if (자리예약 !== null) {
372
- cancelAnimationFrame(자리예약);
373
- 자리예약 = null;
362
+ window.addEventListener('touchmove', blockScroll, { passive: false });
363
+ }, LONG_PRESS_MS);
364
+ function cleanup() {
365
+ clearTimeout(timer);
366
+ window.removeEventListener('pointermove', onMove);
367
+ window.removeEventListener('pointerup', onUp);
368
+ window.removeEventListener('pointercancel', onUp);
369
+ window.removeEventListener('touchmove', blockScroll);
370
+ armed = false;
371
+ if (rafId !== null) {
372
+ cancelAnimationFrame(rafId);
373
+ rafId = null;
374
374
  }
375
- 떨굴자리.current = null;
376
- 풀기.current = null;
377
- set끌기(null);
375
+ dropIndex.current = null;
376
+ release.current = null;
377
+ setDragging(null);
378
378
  setDragOver(null);
379
379
  }
380
- 풀기.current = 해제;
381
- window.addEventListener('pointermove', 이동);
382
- window.addEventListener('pointerup', 뗌);
383
- window.addEventListener('pointercancel', 뗌);
384
- }, [onReorder, 옮기기]);
380
+ release.current = cleanup;
381
+ window.addEventListener('pointermove', onMove);
382
+ window.addEventListener('pointerup', onUp);
383
+ window.addEventListener('pointercancel', onUp);
384
+ }, [onReorder, move]);
385
385
  const { width, height } = WORD_TABLE_PAPER[orientation];
386
386
  /* 담는 틀의 높이 — 수나 `{ wide, narrow }` 를 주면 표가 그 틀을 스스로 두른다(`true` 는 예전대로 부모 몫) */
387
- const 틀높이 = typeof fitHeight === 'number' ? fitHeight
388
- : (fitHeight && typeof fitHeight === 'object') ? (좁은화면 ? fitHeight.narrow : fitHeight.wide)
387
+ const frameHeight = typeof fitHeight === 'number' ? fitHeight
388
+ : (fitHeight && typeof fitHeight === 'object') ? (isNarrow ? fitHeight.narrow : fitHeight.wide)
389
389
  : null;
390
390
  // 스케일러 — 용지(width 고정)를 컨테이너 폭에 맞춰 축소 + 가운데 정렬. fitHeight면 높이도 함께 맞춘다.
391
391
  const { scalerRef, scale, offset } = useFitScale(width, fitHeight ? height : undefined);
@@ -393,10 +393,10 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
393
393
  const cellCount = Math.max(count ?? dataLen, 1);
394
394
  const { cols, rows } = gridFor(orientation, cellCount);
395
395
  const show = {
396
- word: contentTypes.length === 0 || contentTypes.includes('단어'),
397
- image: contentTypes.length === 0 || contentTypes.includes('이미지'),
398
- pron: contentTypes.length === 0 || contentTypes.includes('표준 발음'),
399
- colloq: contentTypes.length === 0 || contentTypes.includes('구어체 발음'),
396
+ word: contentTypes.length === 0 || contentTypes.includes('word'),
397
+ image: contentTypes.length === 0 || contentTypes.includes('image'),
398
+ pron: contentTypes.length === 0 || contentTypes.includes('standardPron'),
399
+ colloq: contentTypes.length === 0 || contentTypes.includes('colloquialPron'),
400
400
  };
401
401
  const hasPairs = isEdit
402
402
  ? (editCells ?? []).some(c => c !== null && c.length > 1)
@@ -405,16 +405,16 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
405
405
  그 값들도 설 자리가 없다 — 켜는 스위치를 하나로 둔다. */
406
406
  /* 띠를 쓰기로 한 종이인가 — 한쪽을 꺼도 이 종이는 '띠를 쓰는 꼴'이다.
407
407
  쪽 번호가 어디에 서는지를 이 값이 가른다(아래를 보라). */
408
- const 브랜드쓰나 = Boolean(brand?.show);
409
- const 위띠쓰나 = Boolean(brand?.show) && brand?.top !== false;
410
- const 아래띠쓰나 = Boolean(brand?.show) && brand?.bottom !== false;
411
- const 저작권 = brand?.copyright ?? '© IHAB, Inc. All rights reserved.';
408
+ const showBrand = Boolean(brand?.show);
409
+ const showTopBand = Boolean(brand?.show) && brand?.top !== false;
410
+ const showBottomBand = Boolean(brand?.show) && brand?.bottom !== false;
411
+ const copyright = brand?.copyright ?? '© IHAB, Inc. All rights reserved.';
412
412
  const contentClass = cx('word-table-block__content', CUT_CLASS[cutLine], showOutline && 'word-table-block__content--outline', showDivLine && hasPairs && 'word-table-block__content--div-line',
413
413
  /* 번호가 설 자리를 비운다 — 번호가 없으면 비우지 않는다(표가 공연히 낮아지지 않게) */
414
- !브랜드쓰나 && pageNumber && pageNumberPosition !== 'none' && 'word-table-block__content--page-number',
414
+ !showBrand && pageNumber && pageNumberPosition !== 'none' && 'word-table-block__content--page-number',
415
415
  /* 띠가 설 자리를 위아래로 비운다 — 같은 까닭으로 띠가 없으면 안 비운다.
416
416
  바닥 띠가 쪽 번호 자리를 겸하므로 둘이 겹쳐도 아래는 한 번만 비운다(CSS 가 덮어쓴다). */
417
- 위띠쓰나 && 'word-table-block__content--brand-top', 아래띠쓰나 && 'word-table-block__content--brand-bottom');
417
+ showTopBand && 'word-table-block__content--brand-top', showBottomBand && 'word-table-block__content--brand-bottom');
418
418
  // 빈칸(추가버튼)은 스케일 역보정으로 실제 크기 유지
419
419
  const counterScale = { transform: `scale(${1 / scale})`, transformOrigin: 'center center' };
420
420
  /* **채점 단추는 표와 함께 줄어들면 안 된다.**
@@ -423,7 +423,7 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
423
423
  다만 끝까지 되돌리면(1/scale) 단추 셋(40×3 + 사이 8 = 128)이 **칸보다 넓어져** 옆 칸을
424
424
  침범한다 — 칸 폭을 천장으로 둔다. 이 견줌은 표 좌표에서 하므로 배율이 양쪽에서 약분된다.
425
425
  넓은 화면(배율 1)에서는 1 이라 아무것도 달라지지 않는다. */
426
- const 채점행폭 = buttonSize * MARKS.length + 4 * (MARKS.length - 1);
426
+ const markRowWidth = buttonSize * MARKS.length + 4 * (MARKS.length - 1);
427
427
  /* **누를 수 없는 표에서는 되돌리지 않는다**(말귀 요청 ㊷, 2026-10-01).
428
428
  위 되돌림은 '손가락으로 누를 수 있게' 하려고 있는 것이다. `onMarkClick` 이 없으면
429
429
  눌러도 아무 일이 없으므로(바로 위 `clickMark` 가 빈 호출이다) 되돌릴 까닭이 없고,
@@ -431,19 +431,19 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
431
431
  배율을 그대로 받아 작아져, 칸이 작을수록 표시가 **낱말 위로 올라탄다**
432
432
  (말귀 실측: 데스크톱 배율 0.54·40px 에서 `사과`·`수박` 을 덮었다).
433
433
  지난 채점을 **매긴 그 표 그대로** 펴 보이는 창이 그 자리다. */
434
- const 매길수있나 = Boolean(onMarkClick);
435
- const 채점되돌림 = 매길수있나 ? Math.max(1, Math.min(1 / scale, (width / cols) / 채점행폭)) : 1;
434
+ const canMark = Boolean(onMarkClick);
435
+ const markUnscale = canMark ? Math.max(1, Math.min(1 / scale, (width / cols) / markRowWidth)) : 1;
436
436
  const grid = (_jsxs("div", { ref: scalerRef, className: "word-table__scaler", style: { height: height * scale }, children: [_jsx("div", {
437
437
  /* title 로 Section 에 감쌀 때는 className 을 바깥 한 곳에만 — 두 곳에 붙어 소비자 스타일이 두 번 걸렸다 */
438
438
  /* 읽기만 하는 채점 표 — 안 매긴 ○△✕ 를 드러내지 않는다(CSS 가 받는다).
439
439
  드러내는 규칙은 '매길 수 있게' 하려고 있는 것이라, 손가락 기기에서 셋이
440
440
  늘 서면 **읽기만 하는 표가 아직 안 매긴 표처럼** 보였다(말귀 요청 ㊷). */
441
- className: cx('word-table', scoring && !매길수있나 && 'word-table--score-readonly', !wrapped && className), style: { width, height, transform: `scale(${scale})`, transformOrigin: 'top left', marginLeft: offset }, children: _jsxs("div", { ref: 내용ref, className: contentClass, style: {
441
+ className: cx('word-table', scoring && !canMark && 'word-table--score-readonly', !wrapped && className), style: { width, height, transform: `scale(${scale})`, transformOrigin: 'top left', marginLeft: offset }, children: _jsxs("div", { ref: gridContentRef, className: contentClass, style: {
442
442
  gridTemplateColumns: `repeat(${cols}, 1fr)`,
443
443
  gridTemplateRows: `repeat(${rows}, 1fr)`,
444
444
  }, 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,
445
445
  // 편집 칸도 구어체 발음을 넘긴다 — 빠져 있어 '구어체 발음'을 켜도 편집 모드에서만 비었다.
446
- 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", { "data-wordtable-cell": onReorder ? index : undefined, className: `word-table-block__card${onReorder ? ' word-table-block__card--draggable' : ''}${dragOver === index ? ' word-table-block__card--drag-over' : ''}${끌기 === index ? ' word-table-block__card--dragging' : ''}`, style: dotDashStyle(index, cols, rows, cutLine), draggable: !!onReorder, onPointerDown: onReorder ? (e) => 길게눌러끌기(e, index) : undefined, onKeyDown: onReorder ? (e) => 키로옮기기(e, index) : undefined, 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) {
446
+ 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", { "data-wordtable-cell": onReorder ? index : undefined, className: `word-table-block__card${onReorder ? ' word-table-block__card--draggable' : ''}${dragOver === index ? ' word-table-block__card--drag-over' : ''}${dragging === index ? ' word-table-block__card--dragging' : ''}`, style: dotDashStyle(index, cols, rows, cutLine), draggable: !!onReorder, onPointerDown: onReorder ? (e) => longPressDrag(e, index) : undefined, onKeyDown: onReorder ? (e) => moveByKey(e, index) : undefined, 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) {
447
447
  onReorder(dragIndex.current, index);
448
448
  } ; 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(' / ')), onReorder && (
449
449
  /* **옮기기 손잡이.** 키보드로 순서를 바꾸려면 초점이 갈 자리가 있어야 하는데,
@@ -460,26 +460,26 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
460
460
  _jsxs("div", { className: "word-table-block__card-actions", children: [renderCellAction?.(index, item), 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) => {
461
461
  const isWithinCount = (items?.length ?? 0) + i < cellCount;
462
462
  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}`));
463
- })] })), 위띠쓰나 && (_jsx(_Fragment, { children: _jsxs("div", { className: "word-table__band word-table__band--top", children: [_jsx("img", { className: "word-table__brand-logo", src: brand?.topLogo?.src ?? 말귀로고, alt: brand?.topLogo?.alt ?? '말귀' }), (() => {
463
+ })] })), showTopBand && (_jsx(_Fragment, { children: _jsxs("div", { className: "word-table__band word-table__band--top", children: [_jsx("img", { className: "word-table__brand-logo", src: brand?.topLogo?.src ?? speechearLogo, alt: brand?.topLogo?.alt ?? '말귀' }), (() => {
464
464
  /* 표 제목 · 기관 이름 · 표 번호 — 있는 것끼리만 가운뎃점으로 잇는다
465
465
  (사용자 지시 2026-09-30). 없는 것 자리에 점이 남으면 안 된다. */
466
- const 조각들 = [
466
+ const pieces = [
467
467
  brand?.title && _jsx("span", { className: "word-table__paper-title", children: brand.title }, "t"),
468
468
  brand?.owner && _jsx("span", { className: "word-table__owner", children: brand.owner }, "o"),
469
469
  code && _jsx("span", { className: "word-table__code", children: code }, "c"),
470
470
  ].filter(Boolean);
471
- if (조각들.length === 0)
471
+ if (pieces.length === 0)
472
472
  return null;
473
- return (_jsx("span", { className: "word-table__ident", children: 조각들.map((조각, i) => (_jsxs(Fragment, { children: [i > 0 && _jsx("span", { className: "word-table__ident-dot", "aria-hidden": "true", children: "\u00B7" }), 조각] }, i))) }));
474
- })()] }) })), 아래띠쓰나 && (_jsx(_Fragment, { children: _jsxs("div", { className: "word-table__band word-table__band--bottom", children: [_jsxs("span", { className: "word-table__brand-by", children: [brand?.bottomLogo
473
+ return (_jsx("span", { className: "word-table__ident", children: pieces.map((piece, i) => (_jsxs(Fragment, { children: [i > 0 && _jsx("span", { className: "word-table__ident-dot", "aria-hidden": "true", children: "\u00B7" }), piece] }, i))) }));
474
+ })()] }) })), showBottomBand && (_jsx(_Fragment, { children: _jsxs("div", { className: "word-table__band word-table__band--bottom", children: [_jsxs("span", { className: "word-table__brand-by", children: [brand?.bottomLogo
475
475
  ? _jsx("img", { className: "word-table__brand-logo--ihab word-table__brand-logo--given", src: brand.bottomLogo.src, alt: brand.bottomLogo.alt })
476
- : _jsx("span", { className: "word-table__brand-logo--ihab", role: "img", "aria-label": "IHAB" }), _jsx("span", { className: "word-table__copyright", children: 저작권 })] }), _jsxs("span", { className: "word-table__band-end", children: [brand?.link && _jsx("span", { className: "word-table__link", children: brand.link }), pageNumber && pageNumberPosition !== 'none' && (_jsx("span", { className: "word-table__page-count", children: t('wordTable.filter.pageIndicator', { current: pageNumber.current, total: pageNumber.total }) }))] })] }) })), !브랜드쓰나 && 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 }) }))] }) }), onReorder && _jsx("span", { role: "status", style: 보이지않게, children: 옮김알림 })] }));
476
+ : _jsx("span", { className: "word-table__brand-logo--ihab", role: "img", "aria-label": "IHAB" }), _jsx("span", { className: "word-table__copyright", children: copyright })] }), _jsxs("span", { className: "word-table__band-end", children: [brand?.link && _jsx("span", { className: "word-table__link", children: brand.link }), pageNumber && pageNumberPosition !== 'none' && (_jsx("span", { className: "word-table__page-count", children: t('wordTable.filter.pageIndicator', { current: pageNumber.current, total: pageNumber.total }) }))] })] }) })), !showBrand && 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 }) }))] }) }), onReorder && _jsx("span", { role: "status", style: srOnly, children: moveAnnounce })] }));
477
477
  /* 담는 틀 — 높이를 받은 자리에서만 두른다. `useFitScale` 은 **스케일러의 부모**를 재므로
478
478
  이 틀이 곧 그 자(척도)가 된다. 가운데 맞춤은 남는 세로를 위아래로 나눈다 — 표가 상자보다
479
479
  납작할 때 아래로만 쏠려 보이지 않게. */
480
- const 담긴것 = 틀높이 == null ? grid : (_jsx("div", { className: "word-table__frame", style: { height: 틀높이 }, children: grid }));
480
+ const contents = frameHeight == null ? grid : (_jsx("div", { className: "word-table__frame", style: { height: frameHeight }, children: grid }));
481
481
  if (wrapped) {
482
- return (_jsx(Section, { type: "page", icon: titleIcon, title: title, disc: disc, className: cx('word-table-block', className), children: 담긴것 }));
482
+ return (_jsx(Section, { type: "page", icon: titleIcon, title: title, disc: disc, className: cx('word-table-block', className), children: contents }));
483
483
  }
484
- return 담긴것;
484
+ return contents;
485
485
  }
@@ -1,3 +1,4 @@
1
+ import type { TFunction } from 'i18next';
1
2
  /** 막대 하나의 데이터 — a/b 두 계열. a === -1 이면 미래(데이터 없음)로 표시하지 않음.
2
3
  * parts: 다계열용 계열별 값(있으면 a는 합계로 사용).
3
4
  * partsB: `parts` 와 1:1 인 계열별 **분모** — `rate` 에서 계열마다 parts[k]/partsB[k] 를 낸다(a/b 의 계열판). */
@@ -67,4 +68,18 @@ export declare function monthOptionsNow(): {
67
68
  year: number;
68
69
  month: number;
69
70
  }[];
70
- export declare const KO_ORD: string[];
71
+ /**
72
+ * 몇째 주인가를 **지금 언어로** 적는다 — `0` 이 첫째 주다.
73
+ *
74
+ * **언어 가름을 부르는 쪽이 하지 않는다.** 전에는 한글 서수 배열(`KO_ORD`)을 내주고
75
+ * 쓰는 쪽마다 `lang.startsWith('ko') ? KO_ORD[w] + '주' : 'Week ' + (w+1)` 를 되풀이했다 —
76
+ * 코어 두 자리와 말귀 한 자리가 **같은 가름을 각자** 적고 있었다. 말이 하나 늘면 세 자리를
77
+ * 다 고쳐야 하고, 한 자리만 안 고치면 화면에서 갈린다.
78
+ *
79
+ * 글귀는 사전이 쥔다(`calendar.weekLabel`·`calendar.weekOrdinal`) — 한국어는 서수가
80
+ * 규칙으로 안 나와(첫째·둘째…) 표가 필요하고, **그 표의 자리는 코드가 아니라 사전이다.**
81
+ * 서수 표가 없는 말은 `{{n}}` 으로 적는다(영어 `Week 3`).
82
+ *
83
+ * @public
84
+ */
85
+ export declare function weekLabel(t: TFunction, week: number): string;