@ihabdevteam/core 0.142.0 → 0.143.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 (63) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/components.cjs.js +176 -176
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +182 -182
  6. package/dist/ihabdevteam-core.d.ts +61 -21
  7. package/dist/packages/core/src/components/BarChart.d.ts +1 -1
  8. package/dist/packages/core/src/components/BarChart.js +4 -3
  9. package/dist/packages/core/src/components/BottomNav.js +4 -0
  10. package/dist/packages/core/src/components/Button.js +10 -4
  11. package/dist/packages/core/src/components/DatePicker.js +5 -1
  12. package/dist/packages/core/src/components/Dialog.js +3 -1
  13. package/dist/packages/core/src/components/Dropdown.js +6 -2
  14. package/dist/packages/core/src/components/FileUpload.js +3 -1
  15. package/dist/packages/core/src/components/FilterPanel.js +3 -2
  16. package/dist/packages/core/src/components/PanelRow.js +5 -0
  17. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -1
  18. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +5 -2
  19. package/dist/packages/core/src/components/Popup/Popup.d.ts +1 -1
  20. package/dist/packages/core/src/components/Popup/Popup.js +5 -2
  21. package/dist/packages/core/src/components/TextField.js +3 -1
  22. package/dist/packages/core/src/components/Tooltip/Tooltip.js +1 -1
  23. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +1 -1
  24. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +3 -1
  25. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +48 -1
  26. package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -1
  27. package/dist/packages/core/src/components/Training/useAudioPreview.js +28 -3
  28. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +19 -5
  29. package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
  30. package/dist/packages/core/src/components/Word/WordTableButton.d.ts +3 -3
  31. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  32. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +1 -1
  33. package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +2 -2
  34. package/dist/packages/core/src/components/barChartData.d.ts +3 -5
  35. package/dist/packages/core/src/components/barChartData.js +7 -3
  36. package/dist/packages/core/src/components/barChartTypes.d.ts +16 -3
  37. package/dist/packages/core/src/components/barChartTypes.js +27 -13
  38. package/dist/packages/core/src/components.d.ts +1 -0
  39. package/dist/packages/core/src/hooks/focusReturn.d.ts +4 -0
  40. package/dist/packages/core/src/hooks/focusReturn.js +27 -0
  41. package/dist/packages/core/src/hooks/useWordMeanings.js +6 -3
  42. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +2 -2
  43. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +54 -17
  44. package/dist/packages/core/src/utils/date.d.ts +18 -0
  45. package/dist/packages/core/src/utils/date.js +23 -0
  46. package/dist/packages/core/src/utils/format.js +2 -1
  47. package/dist/packages/core/src/utils/imageConfig.d.ts +2 -0
  48. package/dist/packages/core/src/utils/imageConfig.js +34 -5
  49. package/dist/packages/core/src/utils/josa.js +2 -0
  50. package/dist/packages/core/src/utils/koreanSyllable.d.ts +5 -0
  51. package/dist/packages/core/src/utils/koreanSyllable.js +69 -43
  52. package/dist/packages/core/src/utils/phonemePairSearch.js +17 -0
  53. package/dist/packages/core/src/utils/timeoutSignal.d.ts +5 -0
  54. package/dist/packages/core/src/utils/timeoutSignal.js +12 -0
  55. package/dist/packages/core/src/utils/ttsConfig.js +2 -1
  56. package/dist/packages/core/src/utils/wordContrast.js +6 -4
  57. package/dist/packages/core/src/utils/wordTrainingPool.js +14 -5
  58. package/dist/packages/core/src/utils.d.ts +1 -1
  59. package/dist/packages/core/src/utils.js +1 -1
  60. package/dist/packages/core/src/version.d.ts +1 -1
  61. package/dist/packages/core/src/version.js +1 -1
  62. package/dist/utils.cjs.js +2 -2
  63. package/package.json +1 -1
@@ -10,6 +10,7 @@ import { resumeAudioMeter, routeElementAudio, setElementVolume, getElementVolume
10
10
  import { useNoisePlayer, effectiveNoiseVolume, effectiveSpeechVolume, noiseRatioOf } from './NoisePlayerContext';
11
11
  import { useNoiseFade } from './useNoiseFade';
12
12
  import { useTTSPlayback } from './useTTSPlayback';
13
+ import { claimSound, stopSoundIfOwner } from './currentSound';
13
14
  import './TrainingStepAudioPlayer.css';
14
15
  import WaveForm from './AudioPlayerWaveForm';
15
16
  import Toast from '../Toast';
@@ -157,7 +158,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
157
158
  audioRef,
158
159
  playbackStateRef,
159
160
  });
160
- const { startTTS, stopTTS } = useTTSPlayback({
161
+ const { startTTS: 합성시작, stopTTS } = useTTSPlayback({
161
162
  ttsText,
162
163
  playbackRate,
163
164
  // TTS 음성 = 문제 소리 = 전체 소리
@@ -173,6 +174,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
173
174
  onDoneRef,
174
175
  handleEndRef,
175
176
  });
177
+ useEffect(() => { stopTTSRef.current = stopTTS; });
178
+ const startTTS = (text) => { 소리잡기('tts'); 합성시작(text); };
176
179
  const requestedSources = Array.isArray(soundSrc)
177
180
  ? soundSrc.filter(Boolean)
178
181
  : (soundSrc ? [soundSrc] : []);
@@ -192,12 +195,50 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
192
195
  오류" 로만 보고 다시 부르면 **정지를 눌렀는데 소리가 다시 났다**. 시작할 때의 의도가 그대로일 때만 다시 시도한다. */
193
196
  const 재생의도Ref = useRef(0);
194
197
  const 멈춤의도 = () => { 재생의도Ref.current += 1; jumpTokenRef.current += 1; };
198
+ /* 스피커는 하나(currentSound.ts) — 한 화면의 다른 재생기(문장 듣기·문제 듣기)·낱말 카드·미리 듣기가 울리면 이 재생기는
199
+ 멈춘다. 전에는 낱말 카드만 자물쇠를 잡아, 듣기 단계가 둘인 화면·듣기+카드 단계에서 두 소리가 함께 울렸다.
200
+ 멈춤 단추와 같게 멈춘다(이어 듣기 가능). 함수는 한 벌이어야 자물쇠가 '내 것' 을 알아본다. */
201
+ const stopTTSRef = useRef(null);
202
+ const 소리종류Ref = useRef(null);
203
+ const 소리놓기Ref = useRef(null);
204
+ const 소리멈춤Ref = useRef(null);
205
+ if (!소리멈춤Ref.current) {
206
+ 소리멈춤Ref.current = () => {
207
+ 소리놓기Ref.current = null;
208
+ const a = audioRef.current;
209
+ if (소리종류Ref.current === 'audio' && a && !a.paused) {
210
+ 재생의도Ref.current += 1;
211
+ jumpTokenRef.current += 1;
212
+ try {
213
+ a.pause();
214
+ }
215
+ catch (e) { }
216
+ if (mountedRef.current)
217
+ setPlaybackState('paused');
218
+ }
219
+ else if (소리종류Ref.current === 'tts') {
220
+ try {
221
+ stopTTSRef.current?.();
222
+ }
223
+ catch (e) { }
224
+ }
225
+ };
226
+ }
227
+ const 소리잡기 = (종류) => {
228
+ 소리종류Ref.current = 종류;
229
+ 소리놓기Ref.current = claimSound(소리멈춤Ref.current);
230
+ };
231
+ // 멈춤·정지·완료 뒤에는 자리를 비운다 — 남이 울릴 때 끝난 재생기의 상태를 건드리지 않게
232
+ const 소리놓기 = () => { 소리놓기Ref.current?.(); 소리놓기Ref.current = null; };
233
+ useEffect(() => () => { if (소리멈춤Ref.current)
234
+ stopSoundIfOwner(소리멈춤Ref.current); }, []);
195
235
  const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
196
236
  const 의도 = 재생의도Ref.current;
197
237
  for (let i = 0; i < attempts; i++) {
198
238
  if (!mountedRef.current || 의도 !== 재생의도Ref.current)
199
239
  return false;
200
240
  try {
241
+ 소리잡기('audio');
201
242
  await a.play();
202
243
  return true;
203
244
  }
@@ -731,6 +772,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
731
772
  try {
732
773
  if (synth && synth.paused) {
733
774
  try {
775
+ 소리잡기('tts');
734
776
  synth.resume();
735
777
  setPlaybackState('playing');
736
778
  }
@@ -847,6 +889,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
847
889
  const handleStop = () => {
848
890
  // 기다리던 트랙 이동·재시도가 정지 뒤에 소리를 내지 않게
849
891
  멈춤의도();
892
+ 소리놓기();
850
893
  if (ttsMode) {
851
894
  stopTTS();
852
895
  return;
@@ -949,6 +992,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
949
992
  const outDelay = 340;
950
993
  markCompletedByEnd(true);
951
994
  멈춤의도();
995
+ 소리놓기();
952
996
  try {
953
997
  setProgress(1);
954
998
  }
@@ -1094,6 +1138,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1094
1138
  const fillLeftPercent = Math.max(0, Math.min(100, (progress || 0) * 100));
1095
1139
  const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
1096
1140
  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",
1141
+ /* 막힌 단계(아직 차례가 아닌 듣기·합성 주소를 받는 중)는 CSS pointer-events 로만 막아, Tab 으로 와서 Enter 를
1142
+ 누르면 소리를 받으러 가거나 "가져오지 못했습니다" 를 띄웠다 — 마우스와 같게 키보드도 막는다 */
1143
+ disabled: isDisabled,
1097
1144
  // 키보드로 눌렀을 때만 초점을 옮긴다 — 마우스로 누른 사람에게 '재생 / 멈춤' 초점 툴팁이 뜨지 않게
1098
1145
  onClick: () => { 듣기초점옮기기Ref.current = 키보드로눌렀나(); hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1099
1146
  // 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
@@ -51,7 +51,15 @@ export default function WordTablePopup({ items, words, title, subtitle, emptyTex
51
51
  새 함수 → 알림 이 끝없이 돈다. 부모가 어떻게 짜든 안 돌게 한다. */
52
52
  const notifyRef = useRef(onScoreChange);
53
53
  useEffect(() => { notifyRef.current = onScoreChange; });
54
- useEffect(() => { notifyRef.current?.(marksForSave(marks)); }, [marks]);
54
+ // 처음 열 때는 알리지 않는다 — 아무것도 안 매겼는데 onScoreChange({}) 가 나갔다
55
+ const 첫알림Ref = useRef(true);
56
+ useEffect(() => {
57
+ if (첫알림Ref.current) {
58
+ 첫알림Ref.current = false;
59
+ return;
60
+ }
61
+ notifyRef.current?.(marksForSave(marks));
62
+ }, [marks]);
55
63
  /* 표에는 지운 칸(null)이 빠진 것만 넘긴다 — 표는 "매겨진 것"만 알면 된다. */
56
64
  const 표에줄채점 = useMemo(() => marksForSave(marks), [marks]);
57
65
  // 인쇄 — WordTable 내부 콘텐츠(.word-table 아래 그리드)를 격리 iframe으로 출력한다.
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { resolveSoundPath, getSignedOrPublicUrl, downloadSound } from '../../utils/storage';
4
+ import { claimSound, stopSoundIfOwner } from './currentSound';
4
5
  function derivePreviewMeta(trainingData, typeKey) {
5
6
  if (!trainingData)
6
7
  return { previewCodes: [], previewBucket: null };
@@ -51,6 +52,9 @@ export function useAudioPreview(trainingData, typeKey) {
51
52
  const [resolved, setResolved] = useState({ key: '', urls: [] });
52
53
  const previewAudioRef = useRef(null);
53
54
  const playingRef = useRef(false);
55
+ /* 지금 미리 듣기를 멈추는 함수 — 스피커 자물쇠(currentSound.ts)에 건다. 다른 블록 미리 듣기·훈련 소리가 울리면 이것이 불린다.
56
+ 전에는 자물쇠에 참여하지 않아 바구니의 블록 둘을 잇달아 누르면 두 문장이 함께 울렸다. */
57
+ const 멈춤Ref = useRef(null);
54
58
  const previewBtnRef = useRef(null);
55
59
  const [previewHover, setPreviewHover] = useState(false);
56
60
  // downloadSound()로 만든 blob URL만 추적 — getSignedOrPublicUrl()이 반환하는 http(s) URL은
@@ -129,6 +133,8 @@ export function useAudioPreview(trainingData, typeKey) {
129
133
  e.stopPropagation();
130
134
  e.preventDefault();
131
135
  if (playingRef.current) {
136
+ if (멈춤Ref.current)
137
+ stopSoundIfOwner(멈춤Ref.current);
132
138
  playingRef.current = false;
133
139
  try {
134
140
  previewAudioRef.current?.pause();
@@ -141,6 +147,18 @@ export function useAudioPreview(trainingData, typeKey) {
141
147
  const urls = previewUrls.filter(Boolean);
142
148
  if (!urls.length)
143
149
  return;
150
+ // pause() 만으로는 ended 가 오지 않아 기다리던 약속이 풀리지 않는다 — 멈출 때 풀어 반복을 끝낸다
151
+ let 기다림풀기 = null;
152
+ const 멈춤 = () => {
153
+ playingRef.current = false;
154
+ try {
155
+ previewAudioRef.current?.pause();
156
+ }
157
+ catch { }
158
+ 기다림풀기?.();
159
+ };
160
+ 멈춤Ref.current = 멈춤;
161
+ const release = claimSound(멈춤);
144
162
  playingRef.current = true;
145
163
  setPlaying(true);
146
164
  for (let i = 0; i < previewUrls.length; i++) {
@@ -152,6 +170,7 @@ export function useAudioPreview(trainingData, typeKey) {
152
170
  setPlayingIndex(i);
153
171
  try {
154
172
  await new Promise((resolve, reject) => {
173
+ 기다림풀기 = resolve;
155
174
  const audio = new Audio(url);
156
175
  previewAudioRef.current = audio;
157
176
  audio.onended = () => resolve();
@@ -163,13 +182,19 @@ export function useAudioPreview(trainingData, typeKey) {
163
182
  break;
164
183
  }
165
184
  }
166
- playingRef.current = false;
167
- setPlaying(false);
168
- setPlayingIndex(-1);
185
+ release();
186
+ if (멈춤Ref.current === 멈춤) {
187
+ 멈춤Ref.current = null;
188
+ playingRef.current = false;
189
+ setPlaying(false);
190
+ setPlayingIndex(-1);
191
+ }
169
192
  }, [previewUrls]);
170
193
  // Pause and release audio on unmount
171
194
  useEffect(() => {
172
195
  return () => {
196
+ if (멈춤Ref.current)
197
+ stopSoundIfOwner(멈춤Ref.current);
173
198
  playingRef.current = false;
174
199
  try {
175
200
  previewAudioRef.current?.pause();
@@ -11,7 +11,8 @@ import Dropdown from '../Dropdown';
11
11
  import Pagination from '../Pagination';
12
12
  import WordImageTooltip from '../Tooltip/WordImageTooltip';
13
13
  import { useHoverAnchor } from '../../hooks/useHoverAnchor';
14
- import { matchesWordQuery } from '../../utils/koreanSyllable';
14
+ import { makeWordQueryMatcher } from '../../utils/koreanSyllable';
15
+ const 가나다 = new Intl.Collator('ko');
15
16
  import { withIga, withEulReul } from '../../utils/josa';
16
17
  /** @public */
17
18
  export default function WordSearchResultBlock({ titleIcon, resultCount, resultUnit, searchQuery, items, groups, selected = [], onSelect, maxSelect, onClearSelection, onPrint, onPrintEmpty, className, emptyHint, sectionType = 'page', disc, notice, }) {
@@ -61,6 +62,21 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
61
62
  value: v, label: t('wordSearch.pageSize', { count: Number(v), defaultValue: `${v}개씩 보기` }),
62
63
  })), [t]);
63
64
  const firstWord = (it) => it.words[0];
65
+ /* '시작 우선' 순위는 검색어가 바뀔 때 판별기를 한 번 만들고 항목마다 한 번만 센다 — 비교할 때마다 검색어를 다시 펼치고
66
+ localeCompare 를 불러 결과가 바뀔 때 23k 개에 38ms 걸렸다. */
67
+ const 시작순위 = useMemo(() => {
68
+ const qLen = [...q].length;
69
+ const m = makeWordQueryMatcher(q);
70
+ const memo = new Map();
71
+ return (it) => {
72
+ let r = memo.get(it);
73
+ if (r === undefined) {
74
+ r = m((it.words[0]?.label ?? '').slice(0, qLen)) ? 0 : 1;
75
+ memo.set(it, r);
76
+ }
77
+ return r;
78
+ };
79
+ }, [q]);
64
80
  // 정렬 비교자 — flat/그룹 뷰가 공유한다.
65
81
  const sortCompare = useCallback((a, b) => {
66
82
  const label = (it) => firstWord(it)?.label ?? '';
@@ -70,9 +86,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
70
86
  // 시작 우선 — 검색어로 시작하는 단어를 위로. 앞 q 글자만 잘라 매칭하면 "첫 음절부터 일치"만
71
87
  // 남는다(초성 검색도 동일: 'ㄴ' → '나무'가 위). 동순위는 가나다.
72
88
  case 'match': {
73
- const qLen = [...q].length;
74
- const rank = (it) => matchesWordQuery(label(it).slice(0, qLen), q) ? 0 : 1;
75
- return rank(a) - rank(b) || label(a).localeCompare(label(b), 'ko');
89
+ return 시작순위(a) - 시작순위(b) || 가나다.compare(label(a), label(b));
76
90
  }
77
91
  case 'ganada': return label(a).localeCompare(label(b), 'ko');
78
92
  case 'ganada_desc': return label(b).localeCompare(label(a), 'ko');
@@ -82,7 +96,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
82
96
  case 'grade_desc': return grade(b) - grade(a) || label(a).localeCompare(label(b), 'ko');
83
97
  default: return 0;
84
98
  }
85
- }, [effectiveSortKey, q]);
99
+ }, [effectiveSortKey, 시작순위]);
86
100
  const sortedItems = useMemo(() => items.slice().sort(sortCompare), [items, sortCompare]);
87
101
  const pageSize = Number(pageSizeStr) || 10;
88
102
  // 그룹 뷰(대립쌍표 음소대립)도 flat과 동일하게 정렬·페이지네이션한다 — 그룹 순서를 유지하고 그룹 내부를
@@ -166,7 +166,7 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
166
166
  Promise.all(imagePairs.map(([w, h]) => checkImageExists(w, h))).then(results => {
167
167
  if (!cancelled)
168
168
  setImageFound(results.some(Boolean));
169
- });
169
+ }, () => { }); // 확인이 거절돼도(리졸버 예외 등) 처리되지 않은 거절로 새지 않게 — 이미지 없음으로 둔다
170
170
  return () => { cancelled = true; };
171
171
  }, [imagePairs]);
172
172
  const hasImage = imagePairs.length > 0 && imageFound;
@@ -288,7 +288,10 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
288
288
  setActiveCell(null);
289
289
  },
290
290
  onClose: handleCloseWordSearch,
291
- }), !hideControls && (_jsx(TableFilterPanel, { hasImage: hasImage, hasColloquial: hasColloquial, contentTypes: contentTypes, orientation: orientation, showOutline: showOutline, cutLine: cutLine, showDivLine: showDivLine, setters: { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine }, showCutLine: (pagesEnabled ? itemsPerPage : count) > 1, showDivLine2: hasPairs, middleSections: middleSections, extraSelected: middleSelected, onExtraSelect: handleMiddleSelect })), isPaginated && pages ? (_jsx("div", { className: "word-table-block__pages", children: pages.map((page, i) => (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: itemsPerPage, items: page, onRemoveItem: onRemoveItem, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? cells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined, showFillerAdd: !!onAddItem, contentRef: contentRef })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
291
+ }), !hideControls && (_jsx(TableFilterPanel, { hasImage: hasImage, hasColloquial: hasColloquial, contentTypes: contentTypes, orientation: orientation, showOutline: showOutline, cutLine: cutLine, showDivLine: showDivLine, setters: { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine }, showCutLine: (pagesEnabled ? itemsPerPage : count) > 1, showDivLine2: hasPairs, middleSections: middleSections, extraSelected: middleSelected, onExtraSelect: handleMiddleSelect })), isPaginated && pages ? (_jsx("div", { className: "word-table-block__pages", children: pages.map((page, i) => (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: itemsPerPage, items: page, onRemoveItem: onRemoveItem,
292
+ /* 쪽이 나뉘면 끌어 옮기기가 통째로 꺼졌다(말귀 단어 찾기: 9개부터). 쪽 안의 자리를 전체 자리로 옮겨 넘긴다 —
293
+ 쪽을 넘는 이동은 하지 않는다. */
294
+ onReorder: onReorder && ((from, to) => onReorder(from + i * itemsPerPage, to + i * itemsPerPage)), contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? cells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined, showFillerAdd: !!onAddItem, contentRef: contentRef })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
292
295
  /* PanelRow의 라벨은 시각적으로만 붙어 있고 입력과 연결돼 있지 않아, 낭독기에는
293
296
  이름 없는 입력으로 읽혔다. placeholder는 자동완성 안내문("Tab 또는 space…")이
294
297
  섞여 있어 이름으로 부적절하므로 같은 라벨 문구를 넘긴다. */
@@ -3,13 +3,13 @@ import type { WordSearchResultItem } from '../../types/wordSearch';
3
3
  import './WordTableButton.css';
4
4
  /** @public */
5
5
  export interface WordTableButtonProps {
6
- /** 단어표 내용(썸네일 미리보기 + 개수) */
6
+ /** 단어표 내용(썸네일 미리보기) */
7
7
  items: WordSearchResultItem[];
8
8
  /** 단어표 이름 */
9
9
  name: string;
10
10
  /** 설명 — 제목 아래 body-3로 표시(있을 때만) */
11
11
  description?: string;
12
- /** 생성일(ISO) — 메타(날짜)에 표시. 없으면 개수만. */
12
+ /** 생성일(ISO) — 메타(날짜)에 표시. */
13
13
  createdAt?: string;
14
14
  /** 스토어 발행 배지 표시 */
15
15
  published?: boolean;
@@ -43,6 +43,6 @@ export interface WordTableButtonProps {
43
43
  }
44
44
  /**
45
45
  * @public 단어표를 '문서형' 미리보기 카드로 표현하는 컴포넌트.
46
- * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜·개수 + (발행 배지 / 더보기 / 스토어 푸터).
46
+ * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜 + (발행 배지 / 더보기 / 스토어 푸터).
47
47
  */
48
48
  export default function WordTableButton({ items, name, description, createdAt, published, publishedLabel, shared, sharedLabel, isNew, publisherName, publishedAt, likeCount, downloadCount, onPublisherClick, selected, onClick, onMore, titleNode, className, }: WordTableButtonProps): import("react").JSX.Element;
@@ -9,7 +9,7 @@ import { formatRelativeTime } from '../../utils/date';
9
9
  import './WordTableButton.css';
10
10
  /**
11
11
  * @public 단어표를 '문서형' 미리보기 카드로 표현하는 컴포넌트.
12
- * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜·개수 + (발행 배지 / 더보기 / 스토어 푸터).
12
+ * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜 + (발행 배지 / 더보기 / 스토어 푸터).
13
13
  */
14
14
  export default function WordTableButton({ items, name, description, createdAt, published, publishedLabel, shared, sharedLabel, isNew, publisherName, publishedAt, likeCount, downloadCount, onPublisherClick, selected, onClick, onMore, titleNode, className, }) {
15
15
  const { t } = useTranslation();
@@ -43,7 +43,7 @@ export function useWordTableDisplaySettings(items) {
43
43
  }
44
44
  Promise.all(items.flatMap(item => item.words.map(w => checkImageExists(w.label, w.homonum))))
45
45
  .then(results => { if (!cancelled)
46
- setHasImage(results.some(Boolean)); });
46
+ setHasImage(results.some(Boolean)); }, () => { }); // 거절은 이미지 없음으로 둔다
47
47
  return () => { cancelled = true; };
48
48
  }, [items]);
49
49
  /* eslint-enable react-hooks/set-state-in-effect */
@@ -23,7 +23,7 @@ export interface WordTableBlockProps {
23
23
  tableName?: string;
24
24
  /** 전달하면 외부 데이터 표시 모드, 없으면 편집 모드 */
25
25
  items?: WordSearchResultItem[];
26
- /** 전달하면 저장 팝업을 내부에서 열고 확인 후 name·items·description을 전달.
26
+ /** 전달하면 저장하기가 저장 팝업(`renderSavePopup` 이 그림 — 없으면 아무것도 뜨지 않는다)을 열고 확인 후 name·items·description을 전달.
27
27
  * saveAsNew=true면 '다른 이름으로 저장'(수정 대신 새 표 생성)을 뜻한다. */
28
28
  onSaveComplete?: (name: string, items: WordSearchResultItem[], description?: string, saveAsNew?: boolean) => void;
29
29
  onLoad?: () => void;
@@ -49,7 +49,7 @@ export interface WordTableBlockProps {
49
49
  editingName?: string;
50
50
  /** 지정하면 인쇄하기 왼쪽에 '취소' 버튼을 표시(PagePopup 닫기 등). */
51
51
  onCancel?: () => void;
52
- /** 전달하면 카드 드래그로 순서 변경 가능 */
52
+ /** 전달하면 카드 드래그로 순서 변경 가능. 쪽이 나뉘면 같은 쪽 안에서만 옮기며, 자리는 전체 목록 기준이다. */
53
53
  onReorder?: (fromIndex: number, toIndex: number) => void;
54
54
  /** display 모드에서도 칸 수 조절 허용 */
55
55
  showCellCount?: boolean;
@@ -1,7 +1,3 @@
1
- /**
2
- * barChartData.ts
3
- * BarChart의 순수 데이터 계층 — 날짜→월/주/일 막대 데이터 집계 (React 무관).
4
- */
5
1
  /** 막대 하나의 데이터 — a/b 두 계열. a === -1 이면 미래(데이터 없음)로 표시하지 않음.
6
2
  * parts: 다계열 stacked용 계열별 값(있으면 a는 합계로 사용). */
7
3
  export interface BarData {
@@ -19,7 +15,9 @@ export type MonthBarData = {
19
15
  weekly: BarData[];
20
16
  daily: BarData[];
21
17
  };
22
- /** 날짜별 카운트 포인트 — a/b 2계열 또는 parts(다계열 stacked, a는 합계) */
18
+ /** 날짜별 카운트 포인트 — a/b 2계열 또는 parts(다계열 stacked, a는 합계).
19
+ * `date` 는 **KST 달력 날짜를 기기 달력 자정 Date 로** 옮긴 것: 시각이면 `parseLocalYmd(toKstYmd(createdAt))`,
20
+ * 서버가 KST 로 집계한 'YYYY-MM-DD' 면 `parseLocalYmd(log_date)`. */
23
21
  export interface ChartPoint {
24
22
  date: Date;
25
23
  a: number;
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * barChartData.ts
3
3
  * BarChart의 순수 데이터 계층 — 날짜→월/주/일 막대 데이터 집계 (React 무관).
4
+ *
5
+ * **날은 KST 달력으로 센다.** 칸은 `getFullYear()/getMonth()/getDate()` 로 나누므로 '지금'도 KST 오늘을 기기 달력 Date 로
6
+ * 옮긴 것(`getTodayKstDate`)을 쓴다 — 전에는 기기 시각이라 해외 기기에서 오늘·이번 주·이번 달이 KST 와 하루 어긋났다.
4
7
  */
8
+ import { getTodayKstDate } from '../utils/date';
5
9
  /** 해당 월(0-based)의 첫 월요일 날짜(1~7). 예: 2026-05(금요일 시작) → 4 */
6
10
  export function firstMondayOfMonth(year, month0) {
7
11
  const dow = new Date(year, month0, 1).getDay(); // 0=일 … 6=토
@@ -10,7 +14,7 @@ export function firstMondayOfMonth(year, month0) {
10
14
  /** 오늘이 속한 (월키, 주값) 계산 — 진입 시 기본 선택을 오늘 기준으로 맞춤.
11
15
  * 월요일 시작 버킷 기준이며, 첫 월요일 이전 날짜는 이전 달 마지막 주에 속한다. */
12
16
  export function todayWeekValue() {
13
- const now = new Date();
17
+ const now = getTodayKstDate();
14
18
  let y = now.getFullYear(), m0 = now.getMonth(), date = now.getDate();
15
19
  if (date < firstMondayOfMonth(y, m0)) {
16
20
  const prevLast = new Date(y, m0, 0); // 이전 달 마지막 날
@@ -35,7 +39,7 @@ export function todayWeekValue() {
35
39
  * 다음 달의 오늘 뒤 날은 세지 않는다. 일별(`daily`)은 그 달 날만이다.
36
40
  */
37
41
  export function buildMonthChartData(points) {
38
- const now = new Date();
42
+ const now = getTodayKstDate();
39
43
  const curKey = `${now.getFullYear()}년 ${now.getMonth() + 1}월`;
40
44
  const 달키 = (y, m0) => `${y}년 ${m0 + 1}월`;
41
45
  const byMonth = new Map();
@@ -142,7 +146,7 @@ export function buildMonthChartData(points) {
142
146
  }
143
147
  // 드롭다운에 노출할 월 옵션 (2024년 1월 ~ 현재, 최신순)
144
148
  function genMonthOptions() {
145
- const now = new Date();
149
+ const now = getTodayKstDate();
146
150
  const curY = now.getFullYear();
147
151
  const curM = now.getMonth() + 1;
148
152
  const opts = [];
@@ -88,8 +88,21 @@ export interface BarChartProps {
88
88
  /** CSS 격자 칸 안에 세운다 — 아래 여백·최소 폭을 풀고 막대를 카드 바닥에 맞춘다. ChartSection `inGrid` 참고. */
89
89
  inGrid?: boolean;
90
90
  }
91
- /** 현재 컨트롤 선택(주/월)의 날짜 범위 — 상세보기(기간별 리스트)용 */
92
- export declare function getControlDateRange(control: BarChartControl): {
91
+ /** @public getControlDateRange 의 결과 — 기간의 첫날 00:00 KST ~ 끝날 23:59:59.999 KST 순간과, 그 두 날의 KST 달력 날짜 */
92
+ export interface ControlDateRange {
93
+ /** 첫날 00:00:00.000 KST 순간 — 서버에 물을 때 `from.toISOString()` */
93
94
  from: Date;
95
+ /** 끝날 23:59:59.999 KST 순간 */
94
96
  to: Date;
95
- };
97
+ /** 첫날 KST 'YYYY-MM-DD' — 이름표·달력 셈은 `parseLocalYmd(fromYmd)` 로(순간 `from` 을 기기 getter 로 읽으면 해외에서 하루 어긋난다) */
98
+ fromYmd: string;
99
+ /** 끝날 KST 'YYYY-MM-DD' */
100
+ toYmd: string;
101
+ }
102
+ /**
103
+ * 현재 컨트롤 선택(주/월)의 기간 — 상세보기(기간별 리스트)용. **KST 의 날로 자른다**(해외 기기도 같은 기간).
104
+ *
105
+ * @param offset 몇 기간 앞·뒤인가 — `-1` 이면 앞 달 한 달(달력 달 그대로, 30일 앞이 아니다) · 앞 주 한 주.
106
+ * @public
107
+ */
108
+ export declare function getControlDateRange(control: BarChartControl, offset?: number): ControlDateRange;
@@ -1,23 +1,37 @@
1
1
  import { firstMondayOfMonth } from './barChartData';
2
- /** 현재 컨트롤 선택(주/월)의 날짜 범위 — 상세보기(기간별 리스트)용 */
3
- export function getControlDateRange(control) {
2
+ import { getTodayKst, getTodayKstDate, kstStartOfDay } from '../utils/date';
3
+ const ymdOf = (y, m0, d) => {
4
+ const t = new Date(Date.UTC(y, m0, d));
5
+ return `${t.getUTCFullYear()}-${String(t.getUTCMonth() + 1).padStart(2, '0')}-${String(t.getUTCDate()).padStart(2, '0')}`;
6
+ };
7
+ const 범위 = (y, m0, 첫날, 날수) => ({
8
+ from: kstStartOfDay(y, m0, 첫날),
9
+ // 끝은 그날의 **마지막 밀리초까지** — 23:59:59.000 이면 마지막 999ms 에 남은 기록이 목록에서 빠졌다
10
+ to: new Date(kstStartOfDay(y, m0, 첫날 + 날수).getTime() - 1),
11
+ fromYmd: ymdOf(y, m0, 첫날),
12
+ toYmd: ymdOf(y, m0, 첫날 + 날수 - 1),
13
+ });
14
+ /**
15
+ * 현재 컨트롤 선택(주/월)의 기간 — 상세보기(기간별 리스트)용. **KST 의 날로 자른다**(해외 기기도 같은 기간).
16
+ *
17
+ * @param offset 몇 기간 앞·뒤인가 — `-1` 이면 앞 달 한 달(달력 달 그대로, 30일 앞이 아니다) · 앞 주 한 주.
18
+ * @public
19
+ */
20
+ export function getControlDateRange(control, offset = 0) {
4
21
  if (control.period === 'month') {
5
22
  const m = control.monthRange.match(/^(\d+)년 (\d+)월$/);
6
- const now = new Date();
7
- const y = m ? Number(m[1]) : now.getFullYear();
8
- const mo = m ? Number(m[2]) : now.getMonth() + 1;
9
- // 끝은 그날의 **마지막 밀리초까지** — 23:59:59.000 이면 마지막 999ms 에 남은 기록이 목록에서 빠졌다
10
- return { from: new Date(y, mo - 1, 1, 0, 0, 0), to: new Date(y, mo, 0, 23, 59, 59, 999) };
23
+ const today = getTodayKstDate();
24
+ const y = m ? Number(m[1]) : today.getFullYear();
25
+ const m0 = (m ? Number(m[2]) : today.getMonth() + 1) - 1 + offset;
26
+ const 날수 = new Date(Date.UTC(y, m0 + 1, 0)).getUTCDate();
27
+ return 범위(y, m0, 1, 날수);
11
28
  }
12
29
  const opt = control.weekRanges.find(o => o.value === control.weekRange);
13
30
  if (!opt) {
14
31
  const n = new Date();
15
- return { from: n, to: n };
32
+ const ymd = getTodayKst();
33
+ return { from: n, to: n, fromYmd: ymd, toYmd: ymd };
16
34
  }
17
35
  const firstMon = firstMondayOfMonth(opt.year, opt.month - 1);
18
- const monday = new Date(opt.year, opt.month - 1, firstMon + opt.week * 7, 0, 0, 0);
19
- const sunday = new Date(monday);
20
- sunday.setDate(monday.getDate() + 6);
21
- sunday.setHours(23, 59, 59, 999);
22
- return { from: monday, to: sunday };
36
+ return 범위(opt.year, opt.month - 1, firstMon + (opt.week + offset) * 7, 7);
23
37
  }
@@ -142,6 +142,7 @@ export { default as BarChart, BarChartControls } from './components/BarChart';
142
142
  export { useBarChartControl } from './components/useBarChartControl';
143
143
  export { buildMonthChartData } from './components/barChartData';
144
144
  export { getControlDateRange } from './components/barChartTypes';
145
+ export type { ControlDateRange } from './components/barChartTypes';
145
146
  export type { BarChartProps, BarChartControl, BarChartSize, BarData, MonthBarData, ChartPoint } from './components/BarChart';
146
147
  export { default as RingChart } from './components/RingChart';
147
148
  export type { RingChartProps, RingChartSize, RingChartSegment } from './components/RingChart';
@@ -0,0 +1,4 @@
1
+ /** 창 판과, 그 창이 열릴 때 포커스였던 요소를 적는다 */
2
+ export declare function rememberFocusReturn(panel: Element | null, previouslyFocused: HTMLElement | null): void;
3
+ /** 실제로 돌려줄 곳 — 문서에 붙어 있으면 그대로, 떼어졌으면 그것을 품었던 창이 적어 둔 곳을 따라간다 */
4
+ export declare function resolveFocusReturn(previouslyFocused: HTMLElement | null): HTMLElement | null;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 창(Popup·Dialog·TrainingPagePopup)이 닫힐 때 포커스를 돌려줄 곳 — 창끼리 한 표를 쓴다.
3
+ *
4
+ * 창은 열릴 때 그때 포커스였던 요소를 기억했다가 닫힐 때 돌려준다. 그런데 **닫으면서 다음 창을 여는** 흐름
5
+ * (미리보기 → 삭제 확인)에서는 확인 창이 기억한 것이 미리보기 안의 삭제 단추이고, 그 단추는 260ms 뒤
6
+ * 미리보기와 함께 사라진다. 확인 창을 닫으면 떼어진 단추에 focus() 해 아무 일도 없이 body 에 떨어졌다.
7
+ * 각 창의 판(panel) → 그 창이 돌려줄 곳을 적어 두고, 돌려줄 곳이 떼어졌으면 그것을 품었던 창의 돌려줄 곳을 따라 올라간다.
8
+ */
9
+ const 돌아갈곳표 = new WeakMap();
10
+ /** 창 판과, 그 창이 열릴 때 포커스였던 요소를 적는다 */
11
+ export function rememberFocusReturn(panel, previouslyFocused) {
12
+ if (panel)
13
+ 돌아갈곳표.set(panel, previouslyFocused);
14
+ }
15
+ /** 실제로 돌려줄 곳 — 문서에 붙어 있으면 그대로, 떼어졌으면 그것을 품었던 창이 적어 둔 곳을 따라간다 */
16
+ export function resolveFocusReturn(previouslyFocused) {
17
+ let 대상 = previouslyFocused;
18
+ for (let 번 = 0; 대상 && !대상.isConnected && 번 < 10; 번++) {
19
+ let 창 = 대상;
20
+ while (창 && !돌아갈곳표.has(창))
21
+ 창 = 창.parentElement;
22
+ if (!창)
23
+ return 대상;
24
+ 대상 = 돌아갈곳표.get(창) ?? null;
25
+ }
26
+ return 대상;
27
+ }
@@ -47,9 +47,12 @@ export function useWordMeanings(rows, fetchMeanings) {
47
47
  if (row.Meaning)
48
48
  memCache.set(meaningKey(row.Word, row.Homonum ?? undefined), row.Meaning);
49
49
  }
50
- for (const k of missing)
51
- if (!memCache.has(k))
52
- noMeaning.add(k);
50
+ /* 한 줄도 못 받았으면 '뜻 없음' 으로 굳히지 않는다 — 조회 함수는 실패를 빈 배열로 돌려주는 약속이라, 잠깐 끊긴 망이
51
+ 그 쪽 낱말들을 새로고침 전까지 '뜻 없음' 으로 묶었다. 받은 게 있으면 그 응답에 빠진 낱말만 없는 것으로 본다. */
52
+ if (got.length > 0)
53
+ for (const k of missing)
54
+ if (!memCache.has(k))
55
+ noMeaning.add(k);
53
56
  if (cancelled || got.length === 0)
54
57
  return;
55
58
  setFetched(v => v + 1);
@@ -11,9 +11,9 @@ export interface WordFilterSnapshot {
11
11
  sameStructureOnly: boolean;
12
12
  phonemeView: 'char' | 'pron' | 'direct';
13
13
  gradeFilter: string[];
14
- /** 품사 필터. 없으면(예전에 뜬 스냅샷) 되돌릴 때 지금 값을 그대로 둔다. */
14
+ /** 품사 필터 — 고정 코드(noun·verb·modifier). 라벨(옛 스냅샷)도 받는다. 없으면 되돌릴 때 지금 값을 그대로 둔다. */
15
15
  posFilter?: string[];
16
- /** 어종 필터. 없으면(예전에 뜬 스냅샷) 되돌릴 때 지금 값을 그대로 둔다. */
16
+ /** 어종 필터 — 고정 코드(native·sino·foreign·mixed). 라벨(옛 스냅샷)도 받는다. 없으면 되돌릴 때 지금 값을 그대로 둔다. */
17
17
  wordTypeFilter?: string[];
18
18
  }
19
19
  /**