@ihabdevteam/core 0.136.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 (76) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/components.cjs.js +176 -176
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +182 -182
  7. package/dist/ihabdevteam-core.d.ts +20 -5
  8. package/dist/packages/core/src/components/Breadcrumb.js +1 -0
  9. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +3 -2
  10. package/dist/packages/core/src/components/Icon.js +4 -1
  11. package/dist/packages/core/src/components/LandingHeader.d.ts +1 -1
  12. package/dist/packages/core/src/components/LandingHeader.js +5 -1
  13. package/dist/packages/core/src/components/PageTitle.js +1 -0
  14. package/dist/packages/core/src/components/Pagination.js +1 -1
  15. package/dist/packages/core/src/components/Panel.css +3 -0
  16. package/dist/packages/core/src/components/Panel.d.ts +11 -1
  17. package/dist/packages/core/src/components/Panel.js +3 -3
  18. package/dist/packages/core/src/components/PanelRow.js +8 -1
  19. package/dist/packages/core/src/components/PassCard/PassCardBgIHAB.js +1 -0
  20. package/dist/packages/core/src/components/PassCard/PassCardBgLeftColorGrid.js +1 -0
  21. package/dist/packages/core/src/components/PassCard/PassCardBgSoundMove.js +1 -0
  22. package/dist/packages/core/src/components/PassCard/PassCardBgSoundMoveV1.js +1 -0
  23. package/dist/packages/core/src/components/PassCard/PassCardBgTalk.js +1 -0
  24. package/dist/packages/core/src/components/PassCard/PassCardBgTalkV1.js +1 -0
  25. package/dist/packages/core/src/components/PassCard/PassCardBgTwoCircle.js +1 -0
  26. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +8 -1
  27. package/dist/packages/core/src/components/Popup/Popup.css +10 -0
  28. package/dist/packages/core/src/components/Popup/Popup.d.ts +5 -0
  29. package/dist/packages/core/src/components/Popup/Popup.js +5 -4
  30. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +6 -1
  31. package/dist/packages/core/src/components/ProgressIndicator.js +1 -0
  32. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDark.js +1 -0
  33. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDeep.js +1 -0
  34. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDiagonal.js +1 -0
  35. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDots.js +1 -0
  36. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgInstagram.js +1 -0
  37. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgMain.js +1 -0
  38. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgNaver.js +1 -0
  39. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgOutline.js +1 -0
  40. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgRadial.js +1 -0
  41. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoft.js +1 -0
  42. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoftSub.js +1 -0
  43. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSub.js +1 -0
  44. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSuccess.js +1 -0
  45. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgWarm.js +1 -0
  46. package/dist/packages/core/src/components/TextField.js +3 -1
  47. package/dist/packages/core/src/components/Tooltip/DescriptionTooltip.js +1 -0
  48. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -4
  49. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -0
  50. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +28 -1
  51. package/dist/packages/core/src/components/Training/TrainingStepCard.js +7 -1
  52. package/dist/packages/core/src/components/Training/TrainingStepOrderList.d.ts +0 -1
  53. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +16 -1
  54. package/dist/packages/core/src/components/Training/TrainingWordCard.js +21 -4
  55. package/dist/packages/core/src/components/Training/currentSound.d.ts +5 -0
  56. package/dist/packages/core/src/components/Training/currentSound.js +13 -0
  57. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +12 -1
  58. package/dist/packages/core/src/components/Training/useNoiseFade.js +4 -0
  59. package/dist/packages/core/src/components/Training/useTTSPlayback.js +5 -1
  60. package/dist/packages/core/src/components/WordTable.js +19 -5
  61. package/dist/packages/core/src/hooks/useFontScale.js +13 -4
  62. package/dist/packages/core/src/hooks/useIcon.js +3 -0
  63. package/dist/packages/core/src/hooks/useLucideIcon.js +3 -0
  64. package/dist/packages/core/src/hooks/useMediaQuery.d.ts +2 -1
  65. package/dist/packages/core/src/hooks/useMediaQuery.js +37 -8
  66. package/dist/packages/core/src/hooks/usePagedList.js +1 -0
  67. package/dist/packages/core/src/hooks/useTrainingTypeLabels.js +1 -0
  68. package/dist/packages/core/src/hooks/useVisitHistory.js +1 -0
  69. package/dist/packages/core/src/hooks/useWordTrainingDataGeneration.js +18 -3
  70. package/dist/packages/core/src/i18n/locales/en.json +41 -4
  71. package/dist/packages/core/src/i18n/locales/ko.json +41 -4
  72. package/dist/packages/core/src/utils/errorMessage.js +7 -1
  73. package/dist/packages/core/src/version.d.ts +1 -1
  74. package/dist/packages/core/src/version.js +1 -1
  75. package/dist/utils.cjs.js +2 -2
  76. 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,6 +36,8 @@ 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 */
@@ -67,7 +69,11 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
67
69
  return;
68
70
  setPlayingAll(true);
69
71
  try {
70
- for (const cardRef of cardRefs.current.slice(0, shown.length)) {
72
+ /* 매 차례 **지금의** 카드를 읽고, 떠났으면 멈춘다 — 시작할 때 잡아 둔 핸들로 돌면 떠난 화면의 다음 카드가 이어서 울렸다 */
73
+ for (let i = 0; i < shown.length; i++) {
74
+ if (!단계살아있음.current)
75
+ break;
76
+ const cardRef = cardRefs.current[i];
71
77
  if (!cardRef)
72
78
  continue;
73
79
  await withTimeout(cardRef.play(), PLAY_ALL_CARD_TIMEOUT_MS);
@@ -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 };
@@ -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, () => ({
@@ -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)
@@ -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) {
@@ -43,17 +43,18 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
43
43
  if (!wordInfo)
44
44
  return null;
45
45
  const arr = [];
46
+ // 지금 언어로 — 템플릿 글자·약호 이름표가 한국어로 박혀 영어 화면에도 "난이도: 2등급 / 품사: 명사" 가 떴다
46
47
  if (wordInfo.grade)
47
- arr.push(`난이도: ${wordInfo.grade}등급`);
48
+ arr.push(t('wordTable.info.grade', { n: wordInfo.grade, defaultValue: '난이도: {{n}}등급' }));
48
49
  if (wordInfo.syllables)
49
- arr.push(`음절 수: ${wordInfo.syllables}음절`);
50
+ arr.push(t('wordTable.info.syllables', { n: wordInfo.syllables, defaultValue: '음절 수: {{n}}음절' }));
50
51
  // 축약 코드를 전체 표기로 — 복합값('명/관' → '명사/관형사')도 각각 펼친다(단어 관리 표와 동일 규칙).
51
52
  if (wordInfo.pos)
52
- arr.push(`품사: ${expandSlashLabel(wordInfo.pos, POS_LABEL)}`);
53
+ arr.push(t('wordTable.info.pos', { v: expandSlashLabel(wordInfo.pos, 품사이름(t)), defaultValue: '품사: {{v}}' }));
53
54
  if (wordInfo.wordType)
54
- arr.push(`유형: ${expandSlashLabel(wordInfo.wordType, WORD_TYPE_LABEL)}`);
55
+ arr.push(t('wordTable.info.wordType', { v: expandSlashLabel(wordInfo.wordType, 어종이름(t)), defaultValue: '유형: {{v}}' }));
55
56
  return arr.length ? arr : null;
56
- }, [wordInfo]);
57
+ }, [wordInfo, t]);
57
58
  useEffect(() => {
58
59
  const resolver = getTTSUrlResolver();
59
60
  const ttsText = pron || label;
@@ -128,6 +129,19 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
128
129
  것인지 알 길이 없으므로 낱말을 이름에 넣는다. */
129
130
  ariaLabel: t('wordTable.wordInfo', '{{word}} 낱말 정보', { word: label }), onClick: (e) => e.stopPropagation() }), infoAnchor && infoTooltipItems && (_jsx(DescriptionTooltip, { anchorRect: infoAnchor, items: infoTooltipItems, showIndex: false }))] }))] }));
130
131
  }
132
+ // ── WordTable (표 디자인) ──────────────────────────────────────
133
+ /* 품사·어종 약호 → 지금 언어의 이름. 사전에 없으면 원본 한국어 이름표(POS_LABEL·WORD_TYPE_LABEL) */
134
+ const 품사열쇠 = {
135
+ '명': 'noun', '동': 'verb', '형': 'adjective', '부': 'adverb', '수': 'numeral', '관': 'determiner', '감': 'interjection',
136
+ '조': 'particle', '접': 'affix', '의': 'dependentNoun', '대': 'pronoun', '보동': 'auxVerb', '보형': 'auxAdjective',
137
+ };
138
+ const 어종열쇠 = { '고유': 'native', '한자': 'sino', '외래': 'foreign', '혼종': 'mixed' };
139
+ function 품사이름(t) {
140
+ return Object.fromEntries(Object.entries(POS_LABEL).map(([k, v]) => [k, 품사열쇠[k] ? t(`wordTable.pos.${품사열쇠[k]}`, { defaultValue: v }) : v]));
141
+ }
142
+ function 어종이름(t) {
143
+ return Object.fromEntries(Object.entries(WORD_TYPE_LABEL).map(([k, v]) => [k, 어종열쇠[k] ? t(`wordSearch.filter.type.${어종열쇠[k]}`, { defaultValue: v }) : v]));
144
+ }
131
145
  /**
132
146
  * 단어표(표 디자인) — A4 종이를 컨테이너에 맞춰 축소(scale)하고,
133
147
  * 칸 그리드와 단어 셀을 그린다. 컨트롤(필터·저장·인쇄)은 포함하지 않는다.
@@ -5,9 +5,9 @@ export default function useFontScale({ storageKey = 'fontScale',
5
5
  // 기본값을 중앙화: Training/Tooltip 텍스트 박스 공통값
6
6
  baseFont = 20, minScale = 0.8, maxScale = 1.4, step = 0.1, lineHeightRatio = 1.4, } = {}) {
7
7
  const clamp = (v) => Math.max(minScale, Math.min(maxScale, Number(v)));
8
- const [scale, setScaleRaw] = useState(() => {
8
+ const 읽기 = (key) => {
9
9
  try {
10
- const raw = localStorage.getItem(storageKey);
10
+ const raw = localStorage.getItem(key);
11
11
  const parsed = raw ? Number(raw) : 1;
12
12
  // 손상된/비숫자 저장값(Number(raw) === NaN)이 그대로 초기값이 되면 scale이 NaN으로
13
13
  // 고정되어 canIncrease/canDecrease 비교가 항상 false가 되고 fontSize/lineHeight도
@@ -17,9 +17,18 @@ baseFont = 20, minScale = 0.8, maxScale = 1.4, step = 0.1, lineHeightRatio = 1.4
17
17
  return Number.isFinite(parsed) ? clamp(parsed) : clamp(1);
18
18
  }
19
19
  catch (e) {
20
- return 1;
20
+ // 저장소를 못 읽어도 범위는 지킨다(minScale 이 1 보다 크면 1 은 범위 밖이다)
21
+ return clamp(1);
21
22
  }
22
- });
23
+ };
24
+ const [scale, setScaleRaw] = useState(() => 읽기(storageKey));
25
+ /* 열쇠가 바뀌면 **그 열쇠의 저장값** 으로 — 전에는 앞 열쇠의 값을 들고 있다가 아래 쓰기가 새 열쇠의 저장값을 덮었다.
26
+ 렌더 중에 맞추므로 쓰기 effect 는 새 값으로만 돈다. */
27
+ const [읽은열쇠, set읽은열쇠] = useState(storageKey);
28
+ if (읽은열쇠 !== storageKey) {
29
+ set읽은열쇠(storageKey);
30
+ setScaleRaw(읽기(storageKey));
31
+ }
23
32
  useEffect(() => {
24
33
  try {
25
34
  localStorage.setItem(storageKey, String(scale));
@@ -42,6 +42,9 @@ export default function useIcon(icon) {
42
42
  normalized = icon;
43
43
  }
44
44
  }
45
+ // 다른 파일로 바뀌면 앞 아이콘의 판정·SVG 를 걷는다 — 안 걷으면 새 파일을 받는 동안 앞 아이콘이 그대로 그려졌다
46
+ setMode(null);
47
+ setSvgText(null);
45
48
  setIconToUse(normalized);
46
49
  }, [icon]);
47
50
  /* eslint-enable react-hooks/set-state-in-effect */
@@ -20,6 +20,9 @@ function toRawKey(icon) {
20
20
  return null;
21
21
  if (icon.includes('/assets/'))
22
22
  return null;
23
+ // 그림 파일(.png·.jpg 등)은 아이콘 이름이 아니다 — 이름으로 찾아 개발 모드에 헛 경고를 냈다
24
+ if (/\.(png|jpe?g|gif|webp|avif|bmp|ico)$/i.test(lower))
25
+ return null;
23
26
  const baseFile = icon.split('/').pop() || '';
24
27
  // 주의: lucide 아이콘명 규약("trash-2", "edit-2", "link-2" 등)은 끝의 숫자가
25
28
  // "-2/-3" 변형 아이콘을 가리키는 유의미한 부분이라 잘라내면 안 됨(예: trash-2 ≠ trash).
@@ -1,3 +1,4 @@
1
+ import * as React from 'react';
1
2
  /**
2
3
  * 미디어쿼리 결과를 강제로 덮어쓰기 위한 컨텍스트.
3
4
  * 랜딩페이지 폰 목업처럼 데스크탑 뷰포트에서 모바일 화면을 재현해야 할 때,
@@ -12,6 +13,6 @@ export interface MediaQueryOverride {
12
13
  mobile?: boolean;
13
14
  }
14
15
  /** @public */
15
- export declare const MediaQueryOverrideContext: import("react").Context<MediaQueryOverride | null>;
16
+ export declare const MediaQueryOverrideContext: React.Context<MediaQueryOverride | null>;
16
17
  /** @public */
17
18
  export default function useMediaQuery(query: string): boolean;
@@ -1,19 +1,40 @@
1
1
  'use client';
2
- import { useEffect, useState, useContext, createContext } from 'react';
2
+ import * as React from 'react';
3
+ import { useCallback, useEffect, useState, useContext, createContext } from 'react';
3
4
  /** @public */
4
5
  export const MediaQueryOverrideContext = createContext(null);
5
6
  // '(max-width: 767px)' / '(max-width: 768px)' 같은 모바일 판정 쿼리인지 추정
6
7
  function isMobileQuery(query) {
7
8
  return /max-width:\s*7[0-9][0-9]px/.test(query);
8
9
  }
9
- /** @public */
10
- export default function useMediaQuery(query) {
11
- const override = useContext(MediaQueryOverrideContext);
12
- const getMatches = (q) => {
10
+ const getMatches = (q) => {
11
+ if (typeof window === 'undefined' || !window.matchMedia)
12
+ return false;
13
+ return window.matchMedia(q).matches;
14
+ };
15
+ /* React 18+ — 외부 저장소 구독으로 읽는다. **서버 스냅숏은 false** 라, 서버에서 그린 HTML 을 붙일 때(kscan·Next) 첫 렌더가
16
+ 서버와 같고 붙인 뒤 곧바로 실제 값으로 다시 그린다. 전에는 첫 렌더에서 matchMedia 를 읽어 모바일 기기에서 hydration
17
+ 불일치로 트리를 통째로 다시 만들었다. 서버 렌더가 아닌 첫 렌더는 그대로 실제 값이다. */
18
+ function useMatchesStore(query) {
19
+ const subscribe = useCallback((onChange) => {
13
20
  if (typeof window === 'undefined' || !window.matchMedia)
14
- return false;
15
- return window.matchMedia(q).matches;
16
- };
21
+ return () => { };
22
+ const mql = window.matchMedia(query);
23
+ if (mql.addEventListener)
24
+ mql.addEventListener('change', onChange);
25
+ else if (mql.addListener)
26
+ mql.addListener(onChange);
27
+ return () => {
28
+ if (mql.removeEventListener)
29
+ mql.removeEventListener('change', onChange);
30
+ else if (mql.removeListener)
31
+ mql.removeListener(onChange);
32
+ };
33
+ }, [query]);
34
+ return React.useSyncExternalStore(subscribe, () => getMatches(query), () => false);
35
+ }
36
+ /* React 16.8·17 — useSyncExternalStore 가 없다. 예전 방식 그대로. */
37
+ function useMatchesState(query) {
17
38
  const [matches, setMatches] = useState(getMatches(query));
18
39
  // query가 바뀌면 새 matchMedia 결과로 즉시 동기화(구독 시작 전 값이 stale할 수 있음) —
19
40
  // window.matchMedia(외부 시스템) 구독의 표준 패턴이다
@@ -37,6 +58,14 @@ export default function useMediaQuery(query) {
37
58
  };
38
59
  }, [query]);
39
60
  /* eslint-enable react-hooks/set-state-in-effect */
61
+ return matches;
62
+ }
63
+ // 모듈을 불러올 때 한 번 정한다 — 렌더마다 같은 훅을 부르므로 훅 순서 규칙을 어기지 않는다
64
+ const useMatches = typeof React.useSyncExternalStore === 'function' ? useMatchesStore : useMatchesState;
65
+ /** @public */
66
+ export default function useMediaQuery(query) {
67
+ const override = useContext(MediaQueryOverrideContext);
68
+ const matches = useMatches(query);
40
69
  // 오버라이드가 있고 모바일 판정 쿼리이면 강제값 사용
41
70
  if (override && typeof override.mobile === 'boolean' && isMobileQuery(query)) {
42
71
  return override.mobile;
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { useMemo } from 'react';
2
3
  /**
3
4
  * 클라이언트 페이지네이션 — 목록·페이지크기·현재 페이지로 현재 쪽 항목·총 쪽수·보정된
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { useMemo } from 'react';
2
3
  import { useTranslation } from 'react-i18next';
3
4
  import { translateTrainingTypes } from '../utils/CategoryData';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { useState } from 'react';
2
3
  import { useEditableList, isSameEditableItem } from './useEditableList';
3
4
  /**
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect, useMemo, useState } from 'react';
3
3
  import { CAPABILITY, generateRuntimeItems, precomputeCommonCombinations, } from '../utils/wordTrainingRuntime';
4
4
  import { valueToJamos, syllableToFilter, NO_CODA } from './useWordTrainingFilter';
5
5
  /** 선택된 단어 구성을 실제 매칭용 CompositionFilter로 변환. 선택 없으면 null.
@@ -36,6 +36,9 @@ export function useWordTrainingDataGeneration(types, difficulty, contrastCount,
36
36
  // 배열인 이쪽만 빠져 있었다. effect 안에서 어차피 정렬해 문자열로 쓰고 있으니
37
37
  // 그 문자열을 의존성으로 삼는다. 내용이 같으면 다시 만들지 않는다.
38
38
  const gradeKey = gradeFilter.length > 0 ? gradeFilter.slice().sort().join(',') : '';
39
+ /* types 도 **내용** 으로 — 만드는 일은 어떤 유형이 있느냐(key)에만 달려 있다. 빠져 있어 늦게 채워진 목록([] → [A,B])으로는
40
+ 끝까지 아무것도 안 만들었다. 이름·색·부제는 아래에서 최신 types 로 덧입혀, 언어를 바꿔도 다시 뽑지 않고 이름만 따라간다. */
41
+ const typesKey = types.map(t => t.key).join('|');
39
42
  // 단어풀 갱신 시 사전 계산 (백그라운드 fire-and-forget)
40
43
  useEffect(() => {
41
44
  if (poolVersion > 0) {
@@ -100,7 +103,19 @@ export function useWordTrainingDataGeneration(types, difficulty, contrastCount,
100
103
  }
101
104
  })();
102
105
  return () => { cancelled = true; };
103
- }, [difficulty, contrastCount, cho, jung, jong, syllable, excludeNoImage, sameStructureOnly, phonemeView, poolVersion, gradeKey]); // eslint-disable-line react-hooks/exhaustive-deps
106
+ }, [difficulty, contrastCount, cho, jung, jong, syllable, excludeNoImage, sameStructureOnly, phonemeView, poolVersion, gradeKey, typesKey]); // eslint-disable-line react-hooks/exhaustive-deps
104
107
  /* eslint-enable react-hooks/set-state-in-effect */
105
- return { perTypeData, generating };
108
+ // 이름·색·부제는 지금 types 로 — 만든 때의 label 을 들고 있어 언어를 바꿔도 단추 제목이 옛 언어로 남았다
109
+ // 부르는 쪽이 types 를 매번 새 배열로 줘도 결과 객체가 그대로이도록 **내용** 으로 기억한다
110
+ const typesLabelKey = JSON.stringify(types.map(t => [t.key, t.label, t.color, t.subTitle]));
111
+ const labeled = useMemo(() => {
112
+ const out = {};
113
+ for (const [key, v] of Object.entries(perTypeData)) {
114
+ const t = types.find(x => x.key === key);
115
+ out[key] = v && t ? { ...v, name: t.label, iconColor: t.color, subTitle: t.subTitle } : v;
116
+ }
117
+ return out;
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ }, [perTypeData, typesLabelKey]);
120
+ return { perTypeData: labeled, generating };
106
121
  }