@ihabdevteam/core 0.135.0 → 0.137.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 (113) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +3 -3
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +181 -181
  7. package/dist/ihabdevteam-core.d.ts +38 -7
  8. package/dist/packages/core/src/components/Alert.js +1 -1
  9. package/dist/packages/core/src/components/Breadcrumb.js +1 -0
  10. package/dist/packages/core/src/components/Button.css +5 -0
  11. package/dist/packages/core/src/components/Button.js +3 -3
  12. package/dist/packages/core/src/components/Checkbox.js +10 -2
  13. package/dist/packages/core/src/components/CodeBox.js +2 -1
  14. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +5 -2
  15. package/dist/packages/core/src/components/Icon.js +4 -1
  16. package/dist/packages/core/src/components/IconGridMenu.js +25 -22
  17. package/dist/packages/core/src/components/LandingHeader.d.ts +1 -1
  18. package/dist/packages/core/src/components/LandingHeader.js +5 -1
  19. package/dist/packages/core/src/components/PageTitle.js +1 -0
  20. package/dist/packages/core/src/components/Pagination.js +1 -1
  21. package/dist/packages/core/src/components/Panel.css +3 -0
  22. package/dist/packages/core/src/components/Panel.d.ts +11 -1
  23. package/dist/packages/core/src/components/Panel.js +3 -3
  24. package/dist/packages/core/src/components/PanelRow.js +8 -1
  25. package/dist/packages/core/src/components/PassCard/PassCardBgIHAB.js +1 -0
  26. package/dist/packages/core/src/components/PassCard/PassCardBgLeftColorGrid.js +1 -0
  27. package/dist/packages/core/src/components/PassCard/PassCardBgSoundMove.js +1 -0
  28. package/dist/packages/core/src/components/PassCard/PassCardBgSoundMoveV1.js +1 -0
  29. package/dist/packages/core/src/components/PassCard/PassCardBgTalk.js +1 -0
  30. package/dist/packages/core/src/components/PassCard/PassCardBgTalkV1.js +1 -0
  31. package/dist/packages/core/src/components/PassCard/PassCardBgTwoCircle.js +1 -0
  32. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +8 -1
  33. package/dist/packages/core/src/components/Popup/ListPopup.js +3 -1
  34. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +6 -5
  35. package/dist/packages/core/src/components/Popup/Popup.css +19 -0
  36. package/dist/packages/core/src/components/Popup/Popup.d.ts +14 -1
  37. package/dist/packages/core/src/components/Popup/Popup.js +6 -5
  38. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +6 -1
  39. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +5 -3
  40. package/dist/packages/core/src/components/ProgressBar.js +5 -2
  41. package/dist/packages/core/src/components/ProgressIndicator.js +7 -1
  42. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDark.js +1 -0
  43. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDeep.js +1 -0
  44. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDiagonal.js +1 -0
  45. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDots.js +1 -0
  46. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgInstagram.js +1 -0
  47. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgMain.js +1 -0
  48. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgNaver.js +1 -0
  49. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgOutline.js +1 -0
  50. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgRadial.js +1 -0
  51. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoft.js +1 -0
  52. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoftSub.js +1 -0
  53. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSub.js +1 -0
  54. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSuccess.js +1 -0
  55. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgWarm.js +1 -0
  56. package/dist/packages/core/src/components/Rating.js +3 -1
  57. package/dist/packages/core/src/components/SideNav.js +6 -3
  58. package/dist/packages/core/src/components/SplashLogo.js +1 -2
  59. package/dist/packages/core/src/components/StatChart.js +1 -1
  60. package/dist/packages/core/src/components/TextField.js +3 -1
  61. package/dist/packages/core/src/components/Tooltip/DescriptionTooltip.js +1 -0
  62. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +6 -5
  63. package/dist/packages/core/src/components/Training/TrainingBlockButton.css +57 -0
  64. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +15 -7
  65. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -0
  66. package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +2 -1
  67. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +28 -1
  68. package/dist/packages/core/src/components/Training/TrainingStepCard.js +10 -3
  69. package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +36 -26
  70. package/dist/packages/core/src/components/Training/TrainingStepOrderList.d.ts +0 -1
  71. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +16 -1
  72. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +7 -4
  73. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -1
  74. package/dist/packages/core/src/components/Training/TrainingWordCard.js +21 -4
  75. package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -3
  76. package/dist/packages/core/src/components/Training/currentSound.d.ts +5 -0
  77. package/dist/packages/core/src/components/Training/currentSound.js +13 -0
  78. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +3 -1
  79. package/dist/packages/core/src/components/Training/trainingMissStats.js +9 -2
  80. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +12 -1
  81. package/dist/packages/core/src/components/Training/useNoiseFade.js +4 -0
  82. package/dist/packages/core/src/components/Training/useTTSPlayback.js +5 -1
  83. package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
  84. package/dist/packages/core/src/components/Word/WordTableButton.js +3 -0
  85. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +5 -1
  86. package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -2
  87. package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -2
  88. package/dist/packages/core/src/components/WordTable.js +19 -5
  89. package/dist/packages/core/src/components/buttonTypes.d.ts +6 -0
  90. package/dist/packages/core/src/components/useCalendarMonth.js +15 -17
  91. package/dist/packages/core/src/components/useToastLifecycle.js +7 -2
  92. package/dist/packages/core/src/context/I18nProvider.js +12 -3
  93. package/dist/packages/core/src/hooks/useFontScale.js +13 -4
  94. package/dist/packages/core/src/hooks/useIcon.js +3 -0
  95. package/dist/packages/core/src/hooks/useLucideIcon.js +3 -0
  96. package/dist/packages/core/src/hooks/useMediaQuery.d.ts +2 -1
  97. package/dist/packages/core/src/hooks/useMediaQuery.js +37 -8
  98. package/dist/packages/core/src/hooks/usePagedList.js +1 -0
  99. package/dist/packages/core/src/hooks/useSoundCompassExam.js +13 -2
  100. package/dist/packages/core/src/hooks/useTrainingTypeLabels.js +1 -0
  101. package/dist/packages/core/src/hooks/useVisitHistory.js +1 -0
  102. package/dist/packages/core/src/hooks/useWordTrainingDataGeneration.js +18 -3
  103. package/dist/packages/core/src/i18n/locales/en.json +42 -4
  104. package/dist/packages/core/src/i18n/locales/ko.json +42 -4
  105. package/dist/packages/core/src/utils/date.js +26 -5
  106. package/dist/packages/core/src/utils/errorMessage.js +7 -1
  107. package/dist/packages/core/src/utils/speak.js +23 -3
  108. package/dist/packages/core/src/utils/textSort.js +12 -1
  109. package/dist/packages/core/src/utils/ttsConfig.js +10 -8
  110. package/dist/packages/core/src/version.d.ts +1 -1
  111. package/dist/packages/core/src/version.js +1 -1
  112. package/dist/utils.cjs.js +2 -2
  113. package/package.json +1 -1
@@ -484,6 +484,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
484
484
  try {
485
485
  // 각 URL의 duration을 병렬로 측정 (실패한 URL은 0초로 계속)
486
486
  const durations = await Promise.all(ttsUrls.map(url => measureAudioDuration(url, { timeoutMs: 10000 })));
487
+ // 재는 동안 떠났으면 여기서 끝 — 떼어진 <audio> 에 주소를 물리고 불러오기를 시작하던 것
488
+ if (!mountedRef.current)
489
+ return;
487
490
  const finalSources = ttsUrls.map((src, i) => ({ src, objectUrl: null, duration: durations[i] || 0 }));
488
491
  applySources(finalSources, totalDurationOf(finalSources));
489
492
  setFetchDebugStatus('ready');
@@ -525,11 +528,17 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
525
528
  catch (e) {
526
529
  srcUrl = null;
527
530
  }
531
+ if (!mountedRef.current)
532
+ return false;
528
533
  // resolveUrl 실패 시 blob 다운로드로 폴백
529
534
  if (!srcUrl) {
530
535
  try {
531
536
  setFetchDebugStatus('downloading');
532
537
  const blob = await downloadSound(b.resolvedPath);
538
+ /* 내려받는 동안 떠났으면 주소를 만들지 않고 끝낸다 — useObjectUrls 정리가 이미 끝난 뒤라 track 해도 회수되지
539
+ 않고 남았고, 이어서 떼어진 <audio> 에 물려 내려받기까지 시작했다. */
540
+ if (!mountedRef.current)
541
+ return false;
533
542
  if (blob) {
534
543
  const blobObj = blob instanceof Blob ? blob : new Blob([blob]);
535
544
  objUrl = objectUrls.track(URL.createObjectURL(blobObj));
@@ -557,6 +566,24 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
557
566
  catch (_) {
558
567
  dur = 0;
559
568
  }
569
+ if (!mountedRef.current) {
570
+ // 떠났으면 방금 만든 blob 주소만 회수하고 끝(앞서 만든 것은 정리가 이미 회수했다)
571
+ if (objUrl) {
572
+ try {
573
+ URL.revokeObjectURL(objUrl);
574
+ }
575
+ catch (_) { }
576
+ }
577
+ for (const f of finalSources) {
578
+ if (f.objectUrl) {
579
+ try {
580
+ URL.revokeObjectURL(f.objectUrl);
581
+ }
582
+ catch (_) { }
583
+ }
584
+ }
585
+ return false;
586
+ }
560
587
  total += dur || 0;
561
588
  finalSources.push({ ...b, src: srcUrl, objectUrl: objUrl, duration: dur });
562
589
  }
@@ -1028,7 +1055,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1028
1055
  const fillPausedFlag = playbackState === "paused";
1029
1056
  const fillLeftPercent = Math.max(0, Math.min(100, (progress || 0) * 100));
1030
1057
  const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
1031
- return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (_jsxs("div", { className: "audio-player__loading", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: STATUS_MESSAGES[fetchDebugStatus] })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button", onClick: () => { hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1058
+ return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (_jsxs("div", { className: "audio-player__loading", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: t(`training.audioStatus.${fetchDebugStatus}`, { defaultValue: STATUS_MESSAGES[fetchDebugStatus] }) })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button", onClick: () => { hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1032
1059
  // 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
1033
1060
  onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
1034
1061
  handleJumpTo(idx);
@@ -36,12 +36,15 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
36
36
  // ── 모두 듣기 — 카드별 소리 듣기 버튼을 순서대로 재생 ──
37
37
  const cardRefs = useRef([]);
38
38
  const [playingAll, setPlayingAll] = useState(false);
39
+ const 단계살아있음 = useRef(true);
40
+ useEffect(() => { 단계살아있음.current = true; return () => { 단계살아있음.current = false; }; }, []);
39
41
  // 페이지 전환 시 (items 변경) 이전 선택 상태 및 카드 ref 초기화
40
42
  const itemsKey = useMemo(() => items.map((it) => it?.id ?? it?.word ?? '').join('|'), [items]);
41
43
  /* eslint-disable react-hooks/set-state-in-effect */
42
44
  useEffect(() => {
43
45
  setSelectedIndex(null);
44
- cardRefs.current = [];
46
+ /* 카드 ref 는 비우지 않는다 — 마운트 때도 도는 이 effect 가 막 붙은 ref 를 지워, 따로 쓴 단계에서는 다시 그려질
47
+ 일이 없어 '모두 듣기' 가 소리 하나 없이 끝났다. 빠진 카드는 ref 콜백이 null 을 넣고, 재생은 보이는 칸까지만 돈다. */
45
48
  }, [itemsKey]);
46
49
  /* eslint-enable react-hooks/set-state-in-effect */
47
50
  const handlePlayDone = useCallback((item, i) => {
@@ -66,7 +69,11 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
66
69
  return;
67
70
  setPlayingAll(true);
68
71
  try {
69
- for (const cardRef of cardRefs.current) {
72
+ /* 매 차례 **지금의** 카드를 읽고, 떠났으면 멈춘다 — 시작할 때 잡아 둔 핸들로 돌면 떠난 화면의 다음 카드가 이어서 울렸다 */
73
+ for (let i = 0; i < shown.length; i++) {
74
+ if (!단계살아있음.current)
75
+ break;
76
+ const cardRef = cardRefs.current[i];
70
77
  if (!cardRef)
71
78
  continue;
72
79
  await withTimeout(cardRef.play(), PLAY_ALL_CARD_TIMEOUT_MS);
@@ -75,7 +82,7 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
75
82
  finally {
76
83
  setPlayingAll(false);
77
84
  }
78
- }, [playingAll, currentState]);
85
+ }, [playingAll, currentState, shown.length]);
79
86
  const handleSelect = useCallback((item, i) => {
80
87
  /* 끝낸 단계(done)에서는 고를 수 없다 — 단추는 `!== 'disabled'` 로 열려 있었는데
81
88
  기록은 `=== 'active'` 일 때만 나가서, **화면 선택은 바뀌는데 점수는 그대로**였다.
@@ -1,12 +1,14 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useCallback, useMemo, useState, useEffect } from 'react';
4
+ import { useTranslation } from 'react-i18next';
4
5
  import TrainingStep from './TrainingStep';
5
6
  import TrainingWordCard from './TrainingWordCard';
6
7
  import './TrainingStep.css';
7
8
  import './TrainingStepCard.css';
8
9
  /** @public */
9
- export default function TrainingStepCardOrder({ index, stepIndex, isLast = false, title = '들은 순서대로 카드를 선택하세요.', choices = [], onDone, onChange, }) {
10
+ export default function TrainingStepCardOrder({ index, stepIndex, isLast = false, title, choices = [], onDone, onChange, }) {
11
+ const { t } = useTranslation();
10
12
  const idx = typeof index === 'number' ? index : -1;
11
13
  const currentState = typeof stepIndex !== 'number'
12
14
  ? 'disabled'
@@ -17,42 +19,50 @@ export default function TrainingStepCardOrder({ index, stepIndex, isLast = false
17
19
  : 'disabled';
18
20
  // selectedOrder[i] = 선택 순서 (1-based), null = 미선택
19
21
  const [selectedOrder, setSelectedOrder] = useState(() => choices.map(() => null));
20
- // choices 변경 시 초기화 (choicesKey로 내용까지 비교 — 개수만이 아닌 실제 단어 변경도 감지)
21
- const choicesKey = choices.join('|');
22
+ // choices 변경 시 초기화 (choicesKey로 내용까지 비교 — 개수만이 아닌 실제 단어 변경도 감지).
23
+ // 열쇠는 JSON 으로 — '|' 로 잇고 가르면 낱말에 '|' 가 든 경우 칸 수가 늘어 모두 골라도 끝나지 않았다.
24
+ const choicesKey = JSON.stringify(choices);
25
+ const choiceCount = choices.length;
22
26
  /* eslint-disable react-hooks/set-state-in-effect */
23
27
  useEffect(() => {
24
- setSelectedOrder(choicesKey ? choicesKey.split('|').map(() => null) : []);
28
+ setSelectedOrder(Array.from({ length: choiceCount }, () => null));
29
+ // eslint-disable-next-line react-hooks/exhaustive-deps
25
30
  }, [choicesKey]);
26
31
  /* eslint-enable react-hooks/set-state-in-effect */
27
32
  const nextRank = useMemo(() => selectedOrder.filter(v => v !== null).length + 1, [selectedOrder]);
33
+ /* 다음 배열은 updater **밖에서** 셈하고 알린다 — updater 안에서 onChange·onDone 을 부르면 StrictMode 에서 한 번
34
+ 클릭에 두 번씩 나갔다. 풀 때도 알린다(전에는 풀면 부모가 옛 순서를 그대로 들고 있었다). */
28
35
  const handleSelect = useCallback((i) => {
29
36
  if (currentState !== 'active')
30
37
  return;
31
- setSelectedOrder(prev => {
32
- const arr = [...prev];
33
- if (arr[i] !== null) {
34
- // 이미 선택된 카드 → 해당 순서 이후 선택 모두 해제
35
- const rank = arr[i];
36
- return arr.map(v => (v !== null && v >= rank ? null : v));
37
- }
38
+ const prev = selectedOrder;
39
+ let arr;
40
+ if (prev[i] != null) {
41
+ // 이미 선택된 카드 → 해당 순서 이후 선택 모두 해제
42
+ const rank = prev[i];
43
+ arr = prev.map(v => (v !== null && v >= rank ? null : v));
44
+ }
45
+ else {
38
46
  // 미선택 카드 → 다음 순서 부여
47
+ arr = [...prev];
39
48
  arr[i] = nextRank;
40
- const payload = { selectedOrder: arr, words: choices, __ts: Date.now() };
49
+ }
50
+ setSelectedOrder(arr);
51
+ const payload = { selectedOrder: arr, words: choices, __ts: Date.now() };
52
+ try {
53
+ if (typeof onChange === 'function')
54
+ onChange(payload);
55
+ }
56
+ catch { }
57
+ // 새로 골라 모두 선택됐으면 완료
58
+ if (prev[i] == null && arr.length > 0 && arr.every(v => v !== null)) {
41
59
  try {
42
- if (typeof onChange === 'function')
43
- onChange(payload);
60
+ if (typeof onDone === 'function')
61
+ onDone(payload);
44
62
  }
45
63
  catch { }
46
- // 모두 선택됐으면 완료
47
- if (arr.every(v => v !== null)) {
48
- try {
49
- if (typeof onDone === 'function')
50
- onDone(payload);
51
- }
52
- catch { }
53
- }
54
- return arr;
55
- });
56
- }, [currentState, nextRank, onChange, onDone, choices]);
57
- return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: title, onDone: onDone, children: _jsx("div", { className: "training-step__cards", children: choices.map((word, i) => (_jsx(TrainingWordCard, { item: { word }, disabled: currentState === 'disabled', selectable: true, selected: selectedOrder[i] !== null, orderRank: selectedOrder[i], onSelect: () => handleSelect(i), flipDelay: i * 120, hideLabel: false, hideAudio: true }, `${word}-${i}`))) }) }));
64
+ }
65
+ }, [currentState, selectedOrder, nextRank, onChange, onDone, choices]);
66
+ const resolvedTitle = title ?? t('training.selectInOrder', '들은 순서대로 카드를 선택하세요.');
67
+ return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, onDone: onDone, children: _jsx("div", { className: "training-step__cards", children: choices.map((word, i) => (_jsx(TrainingWordCard, { item: { word }, disabled: currentState === 'disabled', selectable: true, selected: selectedOrder[i] !== null, orderRank: selectedOrder[i], onSelect: () => handleSelect(i), flipDelay: i * 120, hideLabel: false, hideAudio: true }, `${word}-${i}`))) }) }));
58
68
  }
@@ -3,5 +3,4 @@ import "./TrainingStep.css";
3
3
  import "./TrainingStepOrderList.css";
4
4
  import type { TrainingStepOrderListProps } from './trainingStepOrderListTypes';
5
5
  export type { TrainingContextItem, ShuffledItem, ChangeResult, TrainingStepOrderListProps } from './trainingStepOrderListTypes';
6
- /** @public */
7
6
  export default function TrainingStepOrderList({ index, stepIndex, isLast, title, onDone, onChange, context, }: TrainingStepOrderListProps): import("react").JSX.Element;
@@ -9,7 +9,10 @@ import "./TrainingStep.css";
9
9
  import TrainingStep from "./TrainingStep";
10
10
  import "./TrainingStepOrderList.css";
11
11
  /** @public */
12
- export default function TrainingStepOrderList({ index, stepIndex, isLast = false, title, onDone, onChange, context = [], }) {
12
+ /* 기본 context 는 **모듈에 하나** — 매 렌더 새 `[]` 면 [context] effect 가 매번 다시 돌아 setState·onChange 로 렌더가 끝없이
13
+ 반복됐다(자료를 불러오기 전 context 없이 그린 단계에서 CPU 100%). */
14
+ const 빈문맥 = [];
15
+ export default function TrainingStepOrderList({ index, stepIndex, isLast = false, title, onDone, onChange, context = 빈문맥, }) {
13
16
  const { t } = useTranslation();
14
17
  const resolvedTitle = title ?? t('training.orderListDefault', '문장을 들은 순서대로 배치하세요.');
15
18
  const toText = (v) => typeof v === "string"
@@ -32,7 +35,13 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
32
35
  const 조작가능 = currentState !== 'disabled';
33
36
  // context 변경 시 items/shuffled를 다시 초기화하는 의도된 동기화다
34
37
  /* eslint-disable react-hooks/set-state-in-effect */
38
+ /* 같은 context 로 다시 도는 effect(StrictMode 의 두 번 실행, 숨김→보임)는 넘긴다 — 다시 섞고 다시 알리면 처음 알림의
39
+ 배치가 화면에 한 번도 안 뜬 배치였고 onChange·onDone 이 두 번씩 나갔다. 새 context 일 때만 섞는다. */
40
+ const 섞은문맥 = useRef(undefined);
35
41
  useEffect(() => {
42
+ if (섞은문맥.current === context)
43
+ return;
44
+ 섞은문맥.current = context;
36
45
  const it = Array.isArray(context) ? context : [];
37
46
  setItems(it);
38
47
  const makeInitial = (src) => {
@@ -119,6 +128,12 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
119
128
  if (rafRef.current !== null)
120
129
  return;
121
130
  const loop = () => {
131
+ /* 끄는 것이 없으면 멈춘다 — dragstart 가 건 첫 프레임이 돌기 전에 dragend·언마운트가 오면 비어 있는 rafRef 만
132
+ 취소돼, 뒤늦게 켜진 루프가 60fps 로 영영 돌았다. */
133
+ if (!draggingObjRef.current) {
134
+ rafRef.current = null;
135
+ return;
136
+ }
122
137
  rafRef.current = requestAnimationFrame(loop);
123
138
  const p = lastPosRef.current;
124
139
  const off = dragOffsetRef.current || { dx: 0, dy: 0 };
@@ -54,10 +54,13 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
54
54
  return context;
55
55
  return "";
56
56
  }, [context]);
57
- useEffect(() => {
58
- const id = setTimeout(() => setLocalStatus("disabled"), 0);
59
- return () => clearTimeout(id);
60
- }, [content]);
57
+ /* 문장이 바뀌면 **그 렌더에서** 가린다 — 타이머로 한 틱 뒤에 가리면, 앞 문제에서 펼쳐 둔 상자에 다음 문제 문장이
58
+ 잠깐 드러났다(정답 문장이 번쩍). 렌더 중 상태 조정은 React 가 권하는 꼴이다. */
59
+ const [보던문장, set보던문장] = useState(content);
60
+ if (보던문장 !== content) {
61
+ set보던문장(content);
62
+ setLocalStatus("disabled");
63
+ }
61
64
  const displayStatus = state === "disabled" ? localStatus : state;
62
65
  const stateClass = displayStatus === "disabled"
63
66
  ? "training-text-box--disabled"
@@ -87,5 +87,8 @@ export default function TrainingStepTitle({ title, children, className = "", sta
87
87
  setPreviewContent(null);
88
88
  }
89
89
  }, [onPreviewTextBtn, previewText, showPreview]);
90
- return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive })), usePreviewTextBtn && (_jsx("div", { ref: btnRef, children: _jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "eye", label: isMobile ? undefined : t('training.sentencePreview', '문장 미리보기'), ariaLabel: t('training.sentencePreview', '문장 미리보기'), selected: showPreview, onClick: handlePreviewButtonClick, variant: state === 'disabled' ? 'disabled' : 'default', disabled: !previewText && typeof onPreviewTextBtn !== 'function' }) }))] }), showPreview && (_jsx(TextBoxTooltip, { text: (previewContent ?? previewText), label: t('training.sentencePreview', '문장 미리보기'), anchorRect: anchorRect, onClose: handlePreviewClose, closeOnOutsideClick: false, isMobilePopup: true }))] })) : null] }));
90
+ return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive,
91
+ // 재생 중에는 native disabled 를 달지 않는다 — 키보드로 누른 단추에 disabled 가 붙으면 초점이 body 로 빠졌다.
92
+ // onDisabledClick 을 주면 Button 은 aria-disabled 로만 막고 클릭은 여기서 버린다.
93
+ onDisabledClick: playAllActive && state !== 'disabled' ? () => { } : undefined })), usePreviewTextBtn && (_jsx("div", { ref: btnRef, children: _jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "eye", label: isMobile ? undefined : t('training.sentencePreview', '문장 미리보기'), ariaLabel: t('training.sentencePreview', '문장 미리보기'), selected: showPreview, onClick: handlePreviewButtonClick, variant: state === 'disabled' ? 'disabled' : 'default', disabled: !previewText && typeof onPreviewTextBtn !== 'function' }) }))] }), showPreview && (_jsx(TextBoxTooltip, { text: (previewContent ?? previewText), label: t('training.sentencePreview', '문장 미리보기'), anchorRect: anchorRect, onClose: handlePreviewClose, closeOnOutsideClick: false, isMobilePopup: true }))] })) : null] }));
91
94
  }
@@ -9,7 +9,7 @@ import useMediaQuery from '../../hooks/useMediaQuery';
9
9
  import { getImageResolver } from '../../utils/imageConfig';
10
10
  import { useWordImage } from '../../hooks/useWordImage';
11
11
  import { getTTSUrlResolver, getTTSFallbackUrlResolver } from '../../utils/ttsConfig';
12
- import { claimSound } from './currentSound';
12
+ import { claimSound, stopSoundIfOwner } from './currentSound';
13
13
  import SymbolSvg from '../../logo/Symbol_speechear_black.svg';
14
14
  import { resolveAssetUrl } from '../../utils/assets';
15
15
  import './TrainingWordCard.css';
@@ -80,8 +80,17 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
80
80
  return () => { cancelled = true; };
81
81
  }, [rawAudioSrc, label, audioFor]);
82
82
  // ── 언마운트 시 오디오 정리 ──
83
+ /* 떠날 때 **이 카드가 쥔 소리** 도 끊는다 — audio 만 멈춰, 음원 없는 낱말(speechSynthesis)을 듣는 중에 떠나면 말이
84
+ 계속 나왔다. 남이 이미 가져간 소리는 건드리지 않는다(stopSoundIfOwner). 떠난 뒤에는 기다리던 합성 주소가 와도 울리지 않는다. */
85
+ const mountedRef = useRef(true);
86
+ const 쥔소리Ref = useRef(null);
83
87
  useEffect(() => {
88
+ mountedRef.current = true;
84
89
  return () => {
90
+ mountedRef.current = false;
91
+ if (쥔소리Ref.current)
92
+ stopSoundIfOwner(쥔소리Ref.current);
93
+ 쥔소리Ref.current = null;
85
94
  try {
86
95
  audioRef.current?.pause();
87
96
  }
@@ -143,7 +152,7 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
143
152
  // 재생 우선순위: ① 정식 음원 URL → ② 앱이 등록한 런타임 합성 폴백(예: Gemini-TTS) →
144
153
  // ③ 브라우저 speechSynthesis. ②가 있으면 아직 음원이 없는 단어도 표준발음으로 들린다.
145
154
  const playAudio = useCallback(async () => {
146
- if (disabled)
155
+ if (disabled || !mountedRef.current)
147
156
  return;
148
157
  /* 이전 재생을 확실히 끊고 시작한다(겹침 방지).
149
158
 
@@ -151,7 +160,7 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
151
160
  것**이라, 옆 카드가 울리는 것은 못 멈췄다 — 변별 훈련에서 1번·2번을 잇달아 누르면
152
161
  둘이 함께 울렸다. speechSynthesis 만 전역이라 음원 없는 낱말에서는 멀쩡해 보였고,
153
162
  그래서 증상이 반만 나타났다. 스피커가 하나이므로 자물쇠도 전역에 둔다. */
154
- const release = claimSound(() => {
163
+ const 멈춤 = () => {
155
164
  try {
156
165
  audioRef.current?.pause();
157
166
  }
@@ -161,7 +170,9 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
161
170
  }
162
171
  catch { /* 미지원 환경 */ }
163
172
  setPlaying(false);
164
- });
173
+ };
174
+ const release = claimSound(멈춤);
175
+ 쥔소리Ref.current = 멈춤;
165
176
  try {
166
177
  window.speechSynthesis?.cancel();
167
178
  }
@@ -178,6 +189,8 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
178
189
  if (fallbackResolver && label) {
179
190
  try {
180
191
  const url = await Promise.resolve(fallbackResolver(label));
192
+ if (!mountedRef.current)
193
+ return;
181
194
  if (url) {
182
195
  setPlaying(true);
183
196
  if (await playUrl(url))
@@ -186,10 +199,14 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
186
199
  }
187
200
  catch { }
188
201
  }
202
+ if (!mountedRef.current)
203
+ return;
189
204
  await playSpeechSynthesis();
190
205
  }
191
206
  finally {
192
207
  release(); // 다음 사람이 이미 가져갔으면 아무 일도 안 한다
208
+ if (쥔소리Ref.current === 멈춤)
209
+ 쥔소리Ref.current = null;
193
210
  }
194
211
  }, [disabled, audioUrl, label, playUrl, playSpeechSynthesis]);
195
212
  useImperativeHandle(ref, () => ({
@@ -93,13 +93,19 @@ export default function WordTablePopup({ items, words, title, subtitle, emptyTex
93
93
  const customFooter = useMemo(() => (renderFooter ? renderFooter({ print: handlePrint, openNewWindow }) : undefined), [renderFooter, handlePrint, openNewWindow]);
94
94
  // 메뉴 '인쇄하기' 진입: 팝업이 열리고 표가 렌더된 직후 인쇄를 1회 자동 시작.
95
95
  // 짧은 지연은 WordTable 레이아웃 확정용 — 인쇄 iframe 쪽이 이미지 로드는 별도로 기다린다(wordTablePrint).
96
+ /* '했다' 는 **타이머가 실제로 울렸을 때** 적는다 — 걸 때 적으면 StrictMode 의 두 번 실행이나 100ms 안에 인쇄 설정이
97
+ 바뀌어(handlePrint 가 새로 만들어져) 앞 타이머가 걷힌 뒤 다시 걸지 못해, 인쇄가 한 번도 안 떴다. 부르는 것은 최신 print. */
96
98
  const autoPrintDone = useRef(false);
99
+ const printRef = useRef(handlePrint);
100
+ useEffect(() => { printRef.current = handlePrint; }, [handlePrint]);
97
101
  useEffect(() => {
98
102
  if (!autoPrint || autoPrintDone.current)
99
103
  return;
100
- autoPrintDone.current = true;
101
- const id = window.setTimeout(() => handlePrint(), 100);
104
+ const id = window.setTimeout(() => {
105
+ autoPrintDone.current = true;
106
+ printRef.current();
107
+ }, 100);
102
108
  return () => window.clearTimeout(id);
103
- }, [autoPrint, handlePrint]);
109
+ }, [autoPrint]);
104
110
  return (_jsxs(Popup, { title: title ?? t('keyWords.title', '주요 단어'), subtitle: subtitle, size: "large", className: cx('word-table-popup', className), buttons: footerButtons, footer: customFooter, footerClassName: footerClassName, beforeClose: beforeClose, onClose: onClose, children: [tableItems.length > 0 ? (_jsxs("div", { className: "word-table-popup__body", children: [showSettings && (_jsx(TableFilterPanel, { sections: filterSections, selected: filterSelected, onSelect: handleSelect })), isPaginated ? (_jsx("div", { className: "word-table-popup__pages", children: pages.map((page, i) => (_jsx(WordTable, { items: page, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition, scoring: scoring, scoreIndexOffset: pageStart[i], marks: 표에줄채점, onMarkClick: handleMarkClick }, i))) })) : (_jsx("div", { className: "word-table-popup__table", children: _jsx(WordTable, { items: tableItems, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: contentRef, fitHeight: true, scoring: scoring, marks: 표에줄채점, onMarkClick: handleMarkClick }) }))] })) : (_jsx("p", { className: "word-table-popup__empty body-3", children: emptyText ?? t('keyWords.empty', '추출된 주요 단어가 없습니다.') })), _jsx(Toast, { message: toastMsg, position: "top", onClose: handleToastClose }, toastKey)] }));
105
111
  }
@@ -22,5 +22,10 @@
22
22
  * 이미 다음 사람이 가져갔으면 그 사람 것을 지우면 안 된다.
23
23
  */
24
24
  export declare function claimSound(stop: () => void): () => void;
25
+ /**
26
+ * **내 소리가 아직 울리고 있을 때만** 멈추고 자리를 비운다 — 소리를 낸 화면이 떠날 때 쓴다.
27
+ * 이미 다음 사람이 가져갔으면 아무것도 안 한다(남의 소리를 끊지 않는다).
28
+ */
29
+ export declare function stopSoundIfOwner(stop: () => void): void;
25
30
  /** 지금 울리는 것을 멈춘다 — 화면을 떠날 때처럼, 새로 울릴 것이 없을 때 쓴다 */
26
31
  export declare function stopCurrentSound(): void;
@@ -34,6 +34,19 @@ export function claimSound(stop) {
34
34
  return () => { if (stopCurrent === stop)
35
35
  stopCurrent = null; };
36
36
  }
37
+ /**
38
+ * **내 소리가 아직 울리고 있을 때만** 멈추고 자리를 비운다 — 소리를 낸 화면이 떠날 때 쓴다.
39
+ * 이미 다음 사람이 가져갔으면 아무것도 안 한다(남의 소리를 끊지 않는다).
40
+ */
41
+ export function stopSoundIfOwner(stop) {
42
+ if (stopCurrent !== stop)
43
+ return;
44
+ stopCurrent = null;
45
+ try {
46
+ stop();
47
+ }
48
+ catch { /* 무시 */ }
49
+ }
37
50
  /** 지금 울리는 것을 멈춘다 — 화면을 떠날 때처럼, 새로 울릴 것이 없을 때 쓴다 */
38
51
  export function stopCurrentSound() {
39
52
  if (!stopCurrent)
@@ -41,8 +41,10 @@ export interface TrainingBlockButtonProps {
41
41
  * - `small`: 120×120px 축소 카드 (행 펼치기 영역 등 좁은 공간용)
42
42
  * - `list`: 너비 200px · 최소 높이 68px 가로형 항목
43
43
  * - `compact`: 툴팁 등 좁은 공간용 소형 리스트 항목 (전체 너비 × 높이 40px, 작은 아이콘)
44
+ * - `row`: 전체 너비 가로 행 — [아이콘][유형·제목][문항수]. 미리 듣기 단추는 없다.
45
+ * 좁은 화면에서 정사각형 카드 대신 쓴다(카드는 폭이 줄면 내용이 눌린다). 유형 줄은 `showTypeLabel` 일 때.
44
46
  */
45
- size?: 'square' | 'small' | 'list' | 'compact';
47
+ size?: 'square' | 'small' | 'list' | 'compact' | 'row';
46
48
  renderPopup?: (props: TrainingBlockButtonPopupProps) => ReactNode;
47
49
  completedIds?: Set<string>;
48
50
  /** 선택 상태 ID Set. 포함된 항목(완료가 아닌 것)에 체크 배지를 표시 — 완료와 같은 배지이고, `hideCheckBadge` 면 숨는다. */
@@ -57,8 +57,15 @@ export function summarizeMisses(records) {
57
57
  cur.lastSelected = r.selected;
58
58
  /* 세트는 **최근 것이 앞**이어야 한다 — 다음 훈련을 짤 때 옛 세트를 먼저 집으면
59
59
  그 사이 세트가 바뀌었을 수 있다. 시간순으로 훑고 있으니 앞에 넣는다. */
60
- if (r.setId && !cur.setIds.includes(r.setId))
61
- cur.setIds.unshift(r.setId);
60
+ /* 이미 있던 세트를 다시 풀었으면 **빼서 다시 앞으로** — 건너뛰기만 하면 A→B→A 순서에서 [B, A] 로 남아
61
+ 복습이 가장 최근 세트(A) 대신 옛 세트(B)로 짜였다. */
62
+ if (r.setId) {
63
+ const at = cur.setIds.indexOf(r.setId);
64
+ if (at > 0)
65
+ cur.setIds.splice(at, 1);
66
+ if (at !== 0)
67
+ cur.setIds.unshift(r.setId);
68
+ }
62
69
  모음.set(key, cur);
63
70
  }
64
71
  const items = [...모음.entries()].map(([key, v]) => ({
@@ -1,4 +1,5 @@
1
1
  'use client';
2
+ import i18n from "i18next";
2
3
  import { getTopicTypes } from "../../utils/categories";
3
4
  /** @public */
4
5
  export function normalizeCats(item) {
@@ -43,6 +44,12 @@ export function splitTrainingVariants(value) {
43
44
  }
44
45
  return [String(value)];
45
46
  }
47
+ const 이름 = (key, 기본) => {
48
+ if (!기본)
49
+ return 기본;
50
+ const s = typeof i18n?.t === "function" ? i18n.t(key, { defaultValue: 기본 }) : undefined;
51
+ return typeof s === "string" && s ? s : 기본;
52
+ };
46
53
  /** @public */
47
54
  export function resolveTrainingPopupMeta(trainingData, depth, fallbackTitle) {
48
55
  const td = trainingData;
@@ -62,7 +69,11 @@ export function resolveTrainingPopupMeta(trainingData, depth, fallbackTitle) {
62
69
  wrapperStyle: category?.color
63
70
  ? { "--t-item-fg": category.color }
64
71
  : undefined,
65
- iconTooltip: [category?.label, subCategory?.label].filter(Boolean).join(" / ") || undefined,
72
+ // 카테고리 이름은 지금 언어로(topic.* · topic.sub.*, 사전에 없으면 등록된 이름)
73
+ iconTooltip: [
74
+ category ? 이름(`topic.${category.key}`, category.label) : undefined,
75
+ subCategory ? 이름(`topic.sub.${subCategory.key}`, subCategory.label) : undefined,
76
+ ].filter(Boolean).join(" / ") || undefined,
66
77
  };
67
78
  }
68
79
  /** @public */
@@ -168,6 +168,10 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
168
168
  }
169
169
  catch (e) { }
170
170
  noiseAudioRef.current = null;
171
+ /* 이 요소에 물린 파일 기록도 함께 걷는다 — 남겨 두면 다시 만든 요소(StrictMode 의 두 번 실행, <Activity> 숨김→보임)에
172
+ 설정 효과가 "이미 불러옴" 으로 보고 주소를 안 물려, 파형은 그려지는데 소음은 끝까지 안 났다. */
173
+ noiseTypeLoadedRef.current = null;
174
+ pendingNoiseUrlRef.current = null;
171
175
  };
172
176
  }, [cancelNoiseFade]);
173
177
  // 소음 감시(watchdog) — 메인이 재생 중이 아닌데 소음만 재생 중이면 강제로 정지한다.
@@ -17,6 +17,8 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
17
17
  end 이벤트를 내서, **정지를 누르면 끝까지 들은 것으로** handleEnd → onDone(true) 가 불렸고(문항 완료로 셈),
18
18
  언마운트 뒤에 온 end 가 떠난 화면에 완료를 알렸다. 번호가 다른 end 는 넘긴다. */
19
19
  const ttsTokenRef = useRef(0);
20
+ /** 이 훅이 말을 건 적이 있나 — 떠날 때 **제 말만** 끊는다(마운트만 한 단계가 다른 곳의 말을 끊던 것) */
21
+ const 말했나Ref = useRef(false);
20
22
  useEffect(() => {
21
23
  // cleanup TTS resources on unmount
22
24
  return () => {
@@ -26,11 +28,12 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
26
28
  cancelAnimationFrame(ttsRafRef.current);
27
29
  ttsRafRef.current = null;
28
30
  }
29
- if (window.speechSynthesis) {
31
+ if (말했나Ref.current && window.speechSynthesis) {
30
32
  try {
31
33
  window.speechSynthesis.cancel();
32
34
  }
33
35
  catch (e) { }
36
+ 말했나Ref.current = false;
34
37
  }
35
38
  }
36
39
  catch (e) { }
@@ -138,6 +141,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
138
141
  catch (e) { }
139
142
  };
140
143
  try {
144
+ 말했나Ref.current = true;
141
145
  synth.speak(utt);
142
146
  }
143
147
  catch (e) {
@@ -20,7 +20,7 @@ import { formatYmd, getTodayKst } from '../../utils/date';
20
20
  import { getSavedTables } from '../../utils/savedWordTables';
21
21
  import { devWarnOnce } from '../../utils/devWarn';
22
22
  import { useWordTablePrint } from './useWordTablePrint';
23
- import { CELL_COUNTS } from './wordTableBlockTypes';
23
+ import { CELL_COUNTS, ITEMS_PER_PAGE_OPTIONS } from './wordTableBlockTypes';
24
24
  // ── 컴포넌트 ───────────────────────────────────────────────
25
25
  /** @public */
26
26
  export default function WordTableBlock({ title, titleIcon, disc, items, onSaveComplete, onLoad, onRemoveItem, onReset, resetLabel, saveButtonLabel, secondarySaveButtonLabel, onReorder, showCellCount, onAddItem, className, renderSavePopup, renderWordSearch, hideControls, saveOnly, tableName, nameValue, onNameChange, descriptionValue, onDescriptionChange, namePlaceholder, descriptionPlaceholder, existingNames, editingName, onCancel }) {
@@ -191,7 +191,10 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
191
191
  setDisplayCellCount(parseInt(value));
192
192
  }
193
193
  else if (sectionLabel === t(hasPairs ? 'wordTable.filter.itemsPerPagePair' : 'wordTable.filter.itemsPerPageWord')) {
194
- setItemsPerPageSetting(parseInt(value));
194
+ /* 목록에 없는 개수(= 그때의 '전체' 칸)를 고르면 **전체** 로 기억한다 — 숫자로 담으면 항목이 늘었을 때 고른 값('10개')이
195
+ 선택지([20, 8, 4, 2, 1])에 없어 켜진 칸이 없는데 표는 10개씩 나뉘었다. */
196
+ const n = parseInt(value);
197
+ setItemsPerPageSetting(ITEMS_PER_PAGE_OPTIONS.includes(n) ? n : Number.POSITIVE_INFINITY);
195
198
  }
196
199
  else if (sectionLabel === t('wordTable.filter.pageNumber', '페이지 번호')) {
197
200
  setPageNumberPosition(parsePageNumberPosition(value));
@@ -16,6 +16,9 @@ export default function WordTableButton({ items, name, description, createdAt, p
16
16
  const renaming = titleNode != null;
17
17
  const hasStoreFooter = publisherName != null || publishedAt != null || likeCount != null || downloadCount != null;
18
18
  const handleKey = (e) => {
19
+ // 카드 안 단추('더보기'·게시자)에서 누른 키는 그 단추 몫이다 — 가로채면 카드가 열리고 단추 클릭은 막혔다(pressable 과 같은 규칙)
20
+ if (e.target !== e.currentTarget)
21
+ return;
19
22
  if (!renaming && (e.key === 'Enter' || e.key === ' ')) {
20
23
  e.preventDefault();
21
24
  onClick?.();
@@ -2,6 +2,7 @@
2
2
  import { useMemo, useState, useCallback, useEffect } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { chunkItems } from '../wordTableLayout';
5
+ import { ITEMS_PER_PAGE_OPTIONS } from './wordTableBlockTypes';
5
6
  import { getImageResolver, checkImageExists } from '../../utils/imageConfig';
6
7
  import { buildCompositionSection, buildOrientationSection, buildBorderSection, buildCutLineSection, buildDivLineSection, buildItemsPerPageSection, buildPageNumberSection, buildTableSettingsSelected, applyTableSettingsSelect, PAGE_NUM_POSITION_VALUE, parsePageNumberPosition, } from './wordTableSettings';
7
8
  /**
@@ -68,7 +69,10 @@ export function useWordTableDisplaySettings(items) {
68
69
  if (applyTableSettingsSelect(t, value, sectionLabel, { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine }))
69
70
  return;
70
71
  if (sectionLabel === t('wordTable.filter.itemsPerPagePair', '페이지당 대립쌍 수') || sectionLabel === t('wordTable.filter.itemsPerPageWord', '페이지당 단어 수')) {
71
- setItemsPerPageSetting(parseInt(value));
72
+ /* 목록에 없는 개수(= 그때의 '전체' 칸)를 고르면 **전체** 로 기억한다 — 숫자로 담으면 항목이 늘었을 때 고른 값('10개')이
73
+ 선택지([20, 8, 4, 2, 1])에 없어 켜진 칸이 없는데 표는 10개씩 나뉘었다. */
74
+ const n = parseInt(value);
75
+ setItemsPerPageSetting(ITEMS_PER_PAGE_OPTIONS.includes(n) ? n : Number.POSITIVE_INFINITY);
72
76
  }
73
77
  else if (sectionLabel === t('wordTable.filter.pageNumber', '페이지 번호')) {
74
78
  setPageNumberPosition(parsePageNumberPosition(value));
@@ -62,12 +62,17 @@ export function tableTrends(records) {
62
62
  }
63
63
  const out = [];
64
64
  for (const v of 모음.values()) {
65
- const rows = [...v.rows].sort((a, b) => a.scoredAt.localeCompare(b.scoredAt));
65
+ /* 매긴 칸이 하나도 없는 기록은 흐름에서 뺀다 — 정답률 0%로 넣으면 [0, 0.5] 처럼 나아진 적 없는 표가 +50%로 읽혔다
66
+ (summarize 는 같은 경우를 null 로 따로 둔다). 모두 비었으면 그 표는 흐름이 없다. */
67
+ const 매긴수 = (r) => Object.values(r.marks).filter(m => m === 'o' || m === 'triangle' || m === 'x').length;
68
+ const rows = v.rows.filter(r => 매긴수(r) > 0).sort((a, b) => a.scoredAt.localeCompare(b.scoredAt));
69
+ if (rows.length === 0)
70
+ continue;
66
71
  const rates = rows.map(r => {
67
72
  const vals = Object.values(r.marks);
68
73
  const o = vals.filter(m => m === 'o').length;
69
74
  const tri = vals.filter(m => m === 'triangle').length;
70
- return 점수(o, tri, vals.filter(m => m === 'o' || m === 'triangle' || m === 'x').length);
75
+ return 점수(o, tri, 매긴수(r));
71
76
  });
72
77
  out.push({
73
78
  tableName: v.tableName,