@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
@@ -15,6 +15,25 @@ export { valueToJamos, NO_CODA, SYLLABLE_MIN, syllableToFilter, charToPronValue,
15
15
  // ─── localStorage 지속성 ────────────────────────────────────────────────────
16
16
  const LEGACY_STORAGE_KEY = 'wt_filter';
17
17
  const storageKey = (groupKey) => `wt_filter_${groupKey}`;
18
+ /* 품사·어종은 **고정 코드**로 든다(상태·저장소·스냅샷). 전에는 번역된 라벨('체언(명사, 수사)')을 그대로 담아, 화면을 연 채
19
+ 언어가 바뀌면(말귀: 고객 세션 시작·종료가 그 사람의 표시 언어를 적용) 영어 posMap 에 옛 한국어 값이 없어 **모든 단어가
20
+ 떨어졌고** 칩은 꺼진 채로 보였다. 순서는 useWordAttributeFilterKeys 의 posMap·typeMap 열쇠 순서와 같다. */
21
+ const POS_CODES = ['noun', 'verb', 'modifier'];
22
+ const TYPE_CODES = ['native', 'sino', 'foreign', 'mixed'];
23
+ /** 코드 또는 (옛 저장값·스냅샷의) 어느 언어 라벨이든 코드 배열로 */
24
+ function toAttrCodes(values, codes, kind, labelOf, lngs) {
25
+ if (!Array.isArray(values))
26
+ return [];
27
+ const out = [];
28
+ for (const v of values) {
29
+ if (typeof v !== 'string')
30
+ continue;
31
+ const code = codes.includes(v) ? v : codes.find(c => lngs.some(lng => labelOf(lng, `wordSearch.filter.${kind}.${c}`) === v));
32
+ if (code && !out.includes(code))
33
+ out.push(code);
34
+ }
35
+ return out;
36
+ }
18
37
  function loadSavedFilter(groupKey) {
19
38
  try {
20
39
  // 그룹별 저장값 우선, 없으면 레거시 공통 저장값으로 폴백
@@ -102,8 +121,12 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
102
121
  // 난이도 등급('1'~'4') 필터 — 배열(다중 선택), 빈 배열은 전체(무제약)
103
122
  const [gradeFilter, setGradeFilter] = useState(Array.isArray(saved?.gradeFilter) ? saved.gradeFilter : []);
104
123
  // 품사/어종 필터 — 단어표·대립쌍표와 동일 라벨 배열(다중 선택). caps로 노출 여부 결정(기본 미노출).
105
- const [posFilter, setPosFilter] = useState(Array.isArray(saved?.posFilter) ? saved.posFilter : []);
106
- const [wordTypeFilter, setWordTypeFilter] = useState(Array.isArray(saved?.wordTypeFilter) ? saved.wordTypeFilter : []);
124
+ const 라벨언어들 = Array.from(new Set([i18n.language, 'ko', 'en'].filter(Boolean)));
125
+ const labelOf = (lng, key) => i18n.getFixedT(lng)(key);
126
+ const posCodesOf = (v) => toAttrCodes(v, POS_CODES, 'pos', labelOf, 라벨언어들);
127
+ const typeCodesOf = (v) => toAttrCodes(v, TYPE_CODES, 'type', labelOf, 라벨언어들);
128
+ const [posCodes, setPosCodes] = useState(() => posCodesOf(saved?.posFilter));
129
+ const [typeCodes, setTypeCodes] = useState(() => typeCodesOf(saved?.wordTypeFilter));
107
130
  // 훈련 타입(resetKey) 변경 시 필터를 그 타입 기본값으로 초기화 — 이전 타입의 고정값(음절수/받침/한음절 시드)이
108
131
  // 새 타입으로 새어들어 엉뚱한 결과(예: 3음절 훈련에 1음절 필터가 남아 0건)가 나오는 것을 막는다.
109
132
  // 초기 마운트는 useState 기본값이 이미 처리하므로 ref로 첫 발화를 건너뛴다.
@@ -117,12 +140,17 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
117
140
  setExcludeNoImage(!!opts?.defaultImageOnly);
118
141
  setSameStructureOnly(false);
119
142
  setGradeFilter([]);
120
- setPosFilter([]);
121
- setWordTypeFilter([]);
143
+ setPosCodes([]);
144
+ setTypeCodes([]);
122
145
  // seedComposition은 caps에서 파생 — resetKey와 함께 갱신되므로 deps는 resetKey만 둔다.
123
146
  // eslint-disable-next-line react-hooks/exhaustive-deps
124
147
  }, [opts?.resetKey]);
125
148
  const attrKeys = useWordAttributeFilterKeys();
149
+ // 밖으로는(판 선택값·반환값·판정) 지금 언어의 라벨로 보인다
150
+ const posLabels = useMemo(() => Object.keys(attrKeys.posMap), [attrKeys]);
151
+ const typeLabels = useMemo(() => Object.keys(attrKeys.typeMap), [attrKeys]);
152
+ const posFilter = useMemo(() => posCodes.map(c => posLabels[POS_CODES.indexOf(c)]).filter(Boolean), [posCodes, posLabels]);
153
+ const wordTypeFilter = useMemo(() => typeCodes.map(c => typeLabels[TYPE_CODES.indexOf(c)]).filter(Boolean), [typeCodes, typeLabels]);
126
154
  const [viable, setViable] = useState({ cho: new Set(), jung: new Set(), jong: new Set(), syllable: new Set() });
127
155
  // viable 자모 데이터가 최초 로드되기 전에는 저장된 선택을 해제하지 않는다
128
156
  const [viableReady, setViableReady] = useState(false);
@@ -160,8 +188,8 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
160
188
  // 마운트 때와 같은 규칙 — 여기서만 'direct' 를 'char' 로 떨구고 있었다
161
189
  setPhonemeView(readPhonemeView(s));
162
190
  setGradeFilter(Array.isArray(s?.gradeFilter) ? s.gradeFilter : []);
163
- setPosFilter(Array.isArray(s?.posFilter) ? s.posFilter : []);
164
- setWordTypeFilter(Array.isArray(s?.wordTypeFilter) ? s.wordTypeFilter : []);
191
+ setPosCodes(posCodesOf(s?.posFilter));
192
+ setTypeCodes(typeCodesOf(s?.wordTypeFilter));
165
193
  }
166
194
  setHydratedKey(groupKey);
167
195
  // seedComposition 은 caps 파생이라 groupKey 와 함께 갱신된다
@@ -175,10 +203,10 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
175
203
  if (hydratedKey !== groupKey)
176
204
  return; // 새 열쇠를 아직 안 얹었다 — 지금 쓰면 앞 그룹 값을 덮는다
177
205
  try {
178
- localStorage.setItem(storageKey(groupKey), JSON.stringify({ contrastCount, composition, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter, wordTypeFilter }));
206
+ localStorage.setItem(storageKey(groupKey), JSON.stringify({ contrastCount, composition, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter: posCodes, wordTypeFilter: typeCodes }));
179
207
  }
180
208
  catch { }
181
- }, [opts?.resetOnMount, groupKey, hydratedKey, contrastCount, composition, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter, wordTypeFilter]);
209
+ }, [opts?.resetOnMount, groupKey, hydratedKey, contrastCount, composition, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posCodes, typeCodes]);
182
210
  const [poolVersion, setPoolVersion] = useState(0);
183
211
  // 단어 풀 갱신 이벤트 구독
184
212
  useWindowEvent('wordpool:update', () => setPoolVersion(v => v + 1));
@@ -479,18 +507,24 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
479
507
  else if (sectionLabel === attrKeys.partOfSpeech) {
480
508
  // 품사 — 다중 토글(단어표·대립쌍표와 동일 방식: 라벨 배열)
481
509
  if (!value || value === FILTER_ALL_VALUE) {
482
- setPosFilter([]);
510
+ setPosCodes([]);
483
511
  return;
484
512
  }
485
- setPosFilter(prev => prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value]);
513
+ const code = POS_CODES[posLabels.indexOf(value)] ?? posCodesOf([value])[0];
514
+ if (!code)
515
+ return;
516
+ setPosCodes(prev => prev.includes(code) ? prev.filter(v => v !== code) : [...prev, code]);
486
517
  }
487
518
  else if (sectionLabel === attrKeys.wordType) {
488
519
  // 어종 — 다중 토글(단어표·대립쌍표와 동일 방식: 라벨 배열)
489
520
  if (!value || value === FILTER_ALL_VALUE) {
490
- setWordTypeFilter([]);
521
+ setTypeCodes([]);
491
522
  return;
492
523
  }
493
- setWordTypeFilter(prev => prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value]);
524
+ const code = TYPE_CODES[typeLabels.indexOf(value)] ?? typeCodesOf([value])[0];
525
+ if (!code)
526
+ return;
527
+ setTypeCodes(prev => prev.includes(code) ? prev.filter(v => v !== code) : [...prev, code]);
494
528
  }
495
529
  else {
496
530
  const part = partByLabel[sectionLabel];
@@ -506,7 +540,9 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
506
540
  return { ...prev, [part]: value };
507
541
  });
508
542
  }
509
- }, [LABELS, partByLabel, attrKeys, caps.requireSyllable]);
543
+ // posCodesOf·typeCodesOf 는 i18n 에서만 파생 — 라벨 목록과 함께 바뀐다
544
+ // eslint-disable-next-line react-hooks/exhaustive-deps
545
+ }, [LABELS, partByLabel, attrKeys, posLabels, typeLabels, caps.requireSyllable]);
510
546
  // 품사/어종 판정 — 단어표·대립쌍표가 쓰는 공용 predicate(matchesFilters)를 재사용한다.
511
547
  // filterSelected엔 품사/어종 key만 채워 넘기므로(등급·음절수는 undefined) 그 두 축만 판정한다.
512
548
  // 호출부(TrainingSetPage)는 이 함수로 자신의 후보 단어 필터링(filteredWords)에 품사/어종을 반영한다.
@@ -533,8 +569,8 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
533
569
  // 품사·어종도 함께 뜬다 — 빠져 있어서 설정 팝업에서 취소해도 그 둘만 되돌아가지 않았다.
534
570
  // contrastCount 는 composition 에서 파생되므로 되돌릴 때 따로 세우지 않는다.
535
571
  const getFilterSnapshot = useCallback(() => ({
536
- composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter, wordTypeFilter,
537
- }), [composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter, wordTypeFilter]);
572
+ composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter: posCodes, wordTypeFilter: typeCodes,
573
+ }), [composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posCodes, typeCodes]);
538
574
  const restoreFilterSnapshot = useCallback((s) => {
539
575
  setComposition(s.composition);
540
576
  setExcludeNoImage(s.excludeNoImage);
@@ -542,9 +578,10 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
542
578
  setPhonemeView(s.phonemeView);
543
579
  setGradeFilter(Array.isArray(s.gradeFilter) ? s.gradeFilter : []);
544
580
  if (Array.isArray(s.posFilter))
545
- setPosFilter(s.posFilter);
581
+ setPosCodes(posCodesOf(s.posFilter));
546
582
  if (Array.isArray(s.wordTypeFilter))
547
- setWordTypeFilter(s.wordTypeFilter);
583
+ setTypeCodes(typeCodesOf(s.wordTypeFilter));
584
+ // eslint-disable-next-line react-hooks/exhaustive-deps
548
585
  }, []);
549
586
  return {
550
587
  difficulty,
@@ -28,11 +28,29 @@ export declare function formatDatetimeKo(iso: string): string;
28
28
  export declare function toKstHm(when?: string | number | Date | null): string;
29
29
  /** KST(Asia/Seoul) 기준 오늘 날짜를 'YYYY-MM-DD'로 반환. DB 날짜 비교 기준선으로 사용. */
30
30
  export declare function getTodayKst(): string;
31
+ /**
32
+ * KST 오늘을 **기기 달력의 자정 Date** 로 — 연·월·일 칸을 `getFullYear()/getMonth()/getDate()` 로 세는 곳(BarChart)의
33
+ * '지금'. 칸에 넣는 자료도 같은 모양(`parseLocalYmd(toKstYmd(시각))`)이어야 해외 기기에서도 KST 의 날에 선다.
34
+ *
35
+ * @public
36
+ */
37
+ export declare function getTodayKstDate(): Date;
38
+ /**
39
+ * KST 달력 날짜(연, 0부터 세는 월, 일)의 **00:00 KST 순간.** 일·월은 넘쳐도 된다(`Date.UTC` 처럼 — 32일은 다음 달 1일).
40
+ * 서버에 기간을 물을 때(`toISOString()`) 기기 시간대가 아니라 KST 의 하루로 자르려고 쓴다.
41
+ *
42
+ * @public
43
+ */
44
+ export declare function kstStartOfDay(year: number, month0: number, day: number): Date;
31
45
  /**
32
46
  * 'YYYY-MM-DD' 날짜 전용 문자열(예: 서버가 KST 기준으로 집계해 내려주는 log_date)을
33
47
  * 로컬 타임존 자정 Date로 파싱한다. new Date('YYYY-MM-DD')는 UTC 자정으로 해석되어,
34
48
  * UTC보다 서쪽 시간대(예: 미국)에서 getDate()/getMonth() 같은 로컬 getter로 읽으면
35
49
  * 하루 전으로 밀린다 — 차트를 날짜별로 버킷팅할 때 특히 문제가 된다.
50
+ *
51
+ * 시각(ISO)에서 KST 의 날을 달력 Date 로 얻으려면 `parseLocalYmd(toKstYmd(시각))`.
52
+ *
53
+ * @public
36
54
  */
37
55
  export declare function parseLocalYmd(ymd: string): Date;
38
56
  /**
@@ -98,11 +98,34 @@ export function toKstHm(when) {
98
98
  export function getTodayKst() {
99
99
  return toKstYmd(new Date());
100
100
  }
101
+ /**
102
+ * KST 오늘을 **기기 달력의 자정 Date** 로 — 연·월·일 칸을 `getFullYear()/getMonth()/getDate()` 로 세는 곳(BarChart)의
103
+ * '지금'. 칸에 넣는 자료도 같은 모양(`parseLocalYmd(toKstYmd(시각))`)이어야 해외 기기에서도 KST 의 날에 선다.
104
+ *
105
+ * @public
106
+ */
107
+ export function getTodayKstDate() {
108
+ return parseLocalYmd(getTodayKst());
109
+ }
110
+ const KST_OFFSET_MS = 9 * 60 * 60 * 1000; // KST 는 일광 절약 시간이 없다
111
+ /**
112
+ * KST 달력 날짜(연, 0부터 세는 월, 일)의 **00:00 KST 순간.** 일·월은 넘쳐도 된다(`Date.UTC` 처럼 — 32일은 다음 달 1일).
113
+ * 서버에 기간을 물을 때(`toISOString()`) 기기 시간대가 아니라 KST 의 하루로 자르려고 쓴다.
114
+ *
115
+ * @public
116
+ */
117
+ export function kstStartOfDay(year, month0, day) {
118
+ return new Date(Date.UTC(year, month0, day) - KST_OFFSET_MS);
119
+ }
101
120
  /**
102
121
  * 'YYYY-MM-DD' 날짜 전용 문자열(예: 서버가 KST 기준으로 집계해 내려주는 log_date)을
103
122
  * 로컬 타임존 자정 Date로 파싱한다. new Date('YYYY-MM-DD')는 UTC 자정으로 해석되어,
104
123
  * UTC보다 서쪽 시간대(예: 미국)에서 getDate()/getMonth() 같은 로컬 getter로 읽으면
105
124
  * 하루 전으로 밀린다 — 차트를 날짜별로 버킷팅할 때 특히 문제가 된다.
125
+ *
126
+ * 시각(ISO)에서 KST 의 날을 달력 Date 로 얻으려면 `parseLocalYmd(toKstYmd(시각))`.
127
+ *
128
+ * @public
106
129
  */
107
130
  export function parseLocalYmd(ymd) {
108
131
  const [y, m, d] = ymd.split('-').map(Number);
@@ -9,7 +9,8 @@ import { toKstYmd } from './date';
9
9
  * 입력에 대시가 있든 없든, 어떤 포맷이든 처리.
10
10
  */
11
11
  export function formatPhone(raw) {
12
- const all = raw.replace(/\D/g, '');
12
+ // 전각 숫자(한글 IME 전각 모드 '010')는 \D 에 걸려 통째로 사라졌다 — NFKC 로 반각으로 바꾼 뒤 걸러낸다
13
+ const all = raw.normalize('NFKC').replace(/\D/g, '');
13
14
  /* **앞자리가 네 자리인 번호.** 050X 안심번호·0303 착신전환은 열두 자리까지 가고, 앞자리를 세 자리로 보면
14
15
  `0507-1234-5678` 이 `050-7123-4567` 이 되어 **마지막 한 자리가 사라진 채** 멀쩡한 모양으로 저장됐다
15
16
  (말귀가 formatPhoneKr 로 감싸 두었다 — 이제 걷어도 된다). */
@@ -38,6 +38,8 @@ export declare function getImageExistenceStats(): {
38
38
  total: number;
39
39
  exists: number;
40
40
  } | null;
41
+ /** @internal 동음이의 번호 없이(=1) '이미지 있음' 으로 확인된 낱말 집합 — 캐시 판마다 한 번 만든다. 돌려받은 집합을 고치지 말 것. */
42
+ export declare function getImageOkWordSet(): ReadonlySet<string>;
41
43
  /**
42
44
  * @public
43
45
  * 이미지 존재 여부를 일괄 주입 (storage.list() 결과 등 외부에서 미리 계산한 경우).
@@ -1,3 +1,4 @@
1
+ import { timeoutSignal } from './timeoutSignal';
1
2
  const GLOBAL_KEY = '__ihab_core_image_config__';
2
3
  /** @public */
3
4
  export function configureImageResolver(resolver) {
@@ -10,13 +11,23 @@ export function getImageResolver() {
10
11
  // ─── 로컬 캐싱 (localStorage + 메모리) ───────────────────────────────────────
11
12
  const LS_KEY = 'word_image_existence_map_v1';
12
13
  let memMap = null;
14
+ /* 캐시가 바뀔 때마다 오르는 판 번호 — 통계·'있음' 낱말 집합을 판마다 한 번만 센다(단어훈련 칩 한 번에 유형 수만큼
15
+ 2.3만 칸을 훑었다). 캐시를 바꾸는 곳(확인 결과 담기·일괄 주입·비우기)은 모두 판을 올린다. */
16
+ let 캐시판 = 0;
17
+ let 통계판 = -1;
18
+ let 통계값 = null;
19
+ let 있음판 = -1;
20
+ let 있음집합 = new Set();
13
21
  const inflight = new Map(); // 중복 fetch 방지
14
22
  function ensureLoaded() {
15
23
  if (memMap)
16
24
  return memMap;
17
25
  try {
18
26
  const raw = (typeof localStorage !== 'undefined') ? localStorage.getItem(LS_KEY) : null;
19
- memMap = raw ? JSON.parse(raw) : {};
27
+ const v = raw ? JSON.parse(raw) : null;
28
+ /* 객체가 아닌 JSON(null·"x"·42·배열)이 들어 있으면 `k in map`·Object.values 가 부를 때마다 던지고 memMap 이 null 로 남아
29
+ 매번 다시 파싱했다 — 확장 프로그램·손으로 고친 값이라도 캐시 비우기 전까지 이미지 확인·훈련 생성이 막혔다. */
30
+ memMap = v && typeof v === 'object' && !Array.isArray(v) ? v : {};
20
31
  }
21
32
  catch {
22
33
  memMap = {};
@@ -62,13 +73,14 @@ export async function checkImageExists(word, homonum) {
62
73
  // 검색 버튼(이미지 있음 필터)이 영구 비활성화된다
63
74
  /* **확인이 안 된 것은 캐시하지 않는다.** 망 끊김·타임아웃·5xx 까지 '이미지 없음' 으로 영구 저장해, 한 번 끊긴 단어는 망이
64
75
  돌아와도 '이미지 있음' 필터에서 계속 빠졌다(ttsConfig.checkAudioExists 와 같게). 있음(2xx)·없음(4xx)만 담는다. */
65
- const p = fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(8000) })
76
+ const p = fetch(url, { method: 'HEAD', signal: timeoutSignal(8000) })
66
77
  .then(res => (res.status >= 500 ? null : res.ok), () => null)
67
78
  .then(ok => {
68
79
  inflight.delete(k);
69
80
  if (ok === null)
70
81
  return false;
71
82
  map[k] = ok;
83
+ 캐시판++;
72
84
  scheduleSave();
73
85
  return ok;
74
86
  });
@@ -131,10 +143,25 @@ export async function prewarmImageCache(words, options = {}) {
131
143
  */
132
144
  export function getImageExistenceStats() {
133
145
  const map = ensureLoaded();
146
+ if (통계판 === 캐시판)
147
+ return 통계값 ? { ...통계값 } : null;
134
148
  const values = Object.values(map);
135
- if (values.length === 0)
136
- return null;
137
- return { total: values.length, exists: values.filter(Boolean).length };
149
+ 통계값 = values.length === 0 ? null : { total: values.length, exists: values.filter(Boolean).length };
150
+ 통계판 = 캐시판;
151
+ return 통계값 ? { ...통계값 } : null;
152
+ }
153
+ /** @internal 동음이의 번호 없이(=1) '이미지 있음' 으로 확인된 낱말 집합 — 캐시 판마다 한 번 만든다. 돌려받은 집합을 고치지 말 것. */
154
+ export function getImageOkWordSet() {
155
+ const map = ensureLoaded();
156
+ if (있음판 === 캐시판)
157
+ return 있음집합;
158
+ const s = new Set();
159
+ for (const k in map)
160
+ if (map[k] === true && k.endsWith('_1'))
161
+ s.add(k.slice(0, -2));
162
+ 있음집합 = s;
163
+ 있음판 = 캐시판;
164
+ return s;
138
165
  }
139
166
  /**
140
167
  * @public
@@ -144,6 +171,7 @@ export function getImageExistenceStats() {
144
171
  export function bulkSetImageExistence(entries) {
145
172
  const map = ensureLoaded();
146
173
  Object.assign(map, entries);
174
+ 캐시판++;
147
175
  try {
148
176
  if (typeof localStorage !== 'undefined') {
149
177
  localStorage.setItem(LS_KEY, JSON.stringify(map));
@@ -154,6 +182,7 @@ export function bulkSetImageExistence(entries) {
154
182
  /** @public 캐시 초기화 (이미지 데이터 변경 시 호출) */
155
183
  export function clearImageExistenceCache() {
156
184
  memMap = {};
185
+ 캐시판++;
157
186
  try {
158
187
  if (typeof localStorage !== 'undefined')
159
188
  localStorage.removeItem(LS_KEY);
@@ -5,6 +5,8 @@ const VOWEL_JAMO = /[ㅏ-ㅣ]/;
5
5
  // 문자열 끝의 실제 한글(음절 또는 낱자모) 글자를 찾는다 — 따옴표 등 뒤에 붙는
6
6
  // 문장부호는 건너뛴다(예: "'ㄹ'"의 마지막 문자는 "'"지만 판정에 쓸 글자는 "ㄹ").
7
7
  function lastHangulChar(text) {
8
+ // 조각 한글(NFD) 끝 받침(U+11A8~)은 음절 범위에도 호환 자모에도 안 들어 기본값('가'·'를')으로 떨어졌다
9
+ text = text.normalize('NFC');
8
10
  for (let i = text.length - 1; i >= 0; i--) {
9
11
  const ch = text[i];
10
12
  const code = ch.charCodeAt(0);
@@ -50,6 +50,11 @@ export declare function matchesChoseongSearch(text: string, search: string): boo
50
50
  * 검색어에 종성 겹자모·비한글 등 위 종류가 아닌 문자가 있으면 일반 부분 문자열로 폴백한다.
51
51
  */
52
52
  export declare function matchesWordQuery(word: string, query: string): boolean;
53
+ /**
54
+ * @internal matchesWordQuery 의 검색어 쪽 준비(NFC·글자 분류)를 **한 번만** 해 두고 낱말마다 부르는 판별기.
55
+ * 목록 전체(2.3만 낱말)를 거를 때 낱말마다 검색어를 다시 펼치고 분류해, 키를 누를 때마다 14–36ms 걸렸다.
56
+ */
57
+ export declare function makeWordQueryMatcher(query: string): (word: string) => boolean;
53
58
  /** @public 복합 종성 index → 구성 자모 배열 (예: 9(ㄺ) → ['ㄹ','ㄱ']) */
54
59
  export declare const JONG_DECOMPOSE: Record<number, string[]>;
55
60
  /** @public 단어가 특정 자모(음소)를 어디든 (초성/종성/복합종성) 포함하는지 */
@@ -100,6 +100,9 @@ export function isAllChoseong(search) {
100
100
  export function matchesChoseongSearch(text, search) {
101
101
  if (!search)
102
102
  return true;
103
+ /* 맥·PDF 에서 붙여넣은 글은 조각 한글(NFD, '가' = ᄀ+ᅡ)이라 완성 음절과 한 글자도 같지 않다 — 둘 다 NFC 로 맞춘다. */
104
+ text = text.normalize('NFC');
105
+ search = search.normalize('NFC');
103
106
  if (isAllChoseong(search))
104
107
  return toChoseong(text).includes(search);
105
108
  return text.toLowerCase().includes(search.toLowerCase());
@@ -112,55 +115,69 @@ export function matchesChoseongSearch(text, search) {
112
115
  * 검색어에 종성 겹자모·비한글 등 위 종류가 아닌 문자가 있으면 일반 부분 문자열로 폴백한다.
113
116
  */
114
117
  export function matchesWordQuery(word, query) {
118
+ return makeWordQueryMatcher(query)(word);
119
+ }
120
+ /**
121
+ * @internal matchesWordQuery 의 검색어 쪽 준비(NFC·글자 분류)를 **한 번만** 해 두고 낱말마다 부르는 판별기.
122
+ * 목록 전체(2.3만 낱말)를 거를 때 낱말마다 검색어를 다시 펼치고 분류해, 키를 누를 때마다 14–36ms 걸렸다.
123
+ */
124
+ export function makeWordQueryMatcher(query) {
115
125
  if (!query)
116
- return true;
126
+ return () => true;
127
+ // 조각 한글(NFD) 붙여넣기는 '기타 문자' 로 분류돼 부분 문자열 비교로 새고 0건이 됐다 — 둘 다 NFC 로
128
+ query = query.normalize('NFC');
117
129
  const q = [...query];
118
130
  const isCho = (ch) => CHOSEONG.includes(ch);
119
131
  const isJung = (ch) => JUNGSEONG.includes(ch);
120
132
  // 각 글자 분류: 'cho'(자음/초성) | 'jung'(모음/중성) | 'syl'(완성 음절) | 'other'(그 외)
121
133
  const kinds = q.map(ch => (isCho(ch) ? 'cho' : isJung(ch) ? 'jung' : parseSyllable(ch) ? 'syl' : 'other'));
134
+ const qSyl = q.map(ch => parseSyllable(ch));
122
135
  // 영문은 대소문자를 가리지 않는다 — 옆 matchesChoseongSearch 와 같게('apple' 로 'Apple' 을 찾는다)
123
- if (kinds.includes('other'))
124
- return word.toLowerCase().includes(query.toLowerCase());
125
- const w = [...word];
126
- for (let i = 0; i + q.length <= w.length; i++) {
127
- let ok = true;
128
- for (let j = 0; j < q.length; j++) {
129
- const wc = w[i + j];
130
- const k = kinds[j];
131
- if (k === 'cho') {
132
- if (firstChoseongOf(wc) !== q[j]) {
133
- ok = false;
134
- break;
135
- }
136
- }
137
- else if (k === 'jung') {
138
- const s = parseSyllable(wc);
139
- if (!s || JUNGSEONG[s.jung] !== q[j]) {
140
- ok = false;
141
- break;
136
+ if (kinds.includes('other')) {
137
+ const lower = query.toLowerCase();
138
+ return (word) => word.normalize('NFC').toLowerCase().includes(lower);
139
+ }
140
+ return (word) => {
141
+ const w = [...word.normalize('NFC')];
142
+ for (let i = 0; i + q.length <= w.length; i++) {
143
+ let ok = true;
144
+ for (let j = 0; j < q.length; j++) {
145
+ const wc = w[i + j];
146
+ const k = kinds[j];
147
+ if (k === 'cho') {
148
+ if (firstChoseongOf(wc) !== q[j]) {
149
+ ok = false;
150
+ break;
151
+ }
142
152
  }
143
- }
144
- else { // 'syl' — 완성 음절
145
- const qs = parseSyllable(q[j]);
146
- if (qs && qs.jong === 0) {
147
- // 받침 없는 글자는 초성+중성만 비교(받침 무관): '수' → 수·술·숙·순 …
148
- const ws = parseSyllable(wc);
149
- if (!ws || ws.cho !== qs.cho || ws.jung !== qs.jung) {
153
+ else if (k === 'jung') {
154
+ const s = parseSyllable(wc);
155
+ if (!s || JUNGSEONG[s.jung] !== q[j]) {
150
156
  ok = false;
151
157
  break;
152
158
  }
153
159
  }
154
- else if (wc !== q[j]) {
155
- ok = false;
156
- break;
157
- } // 받침 있으면 정확 일치
160
+ else { // 'syl' — 완성 음절
161
+ const qs = qSyl[j];
162
+ if (qs && qs.jong === 0) {
163
+ // 받침 없는 글자는 초성+중성만 비교(받침 무관): '수' → 수·술·숙·순 …
164
+ const ws = parseSyllable(wc);
165
+ if (!ws || ws.cho !== qs.cho || ws.jung !== qs.jung) {
166
+ ok = false;
167
+ break;
168
+ }
169
+ }
170
+ else if (wc !== q[j]) {
171
+ ok = false;
172
+ break;
173
+ } // 받침 있으면 정확 일치
174
+ }
158
175
  }
176
+ if (ok)
177
+ return true;
159
178
  }
160
- if (ok)
161
- return true;
162
- }
163
- return false;
179
+ return false;
180
+ };
164
181
  }
165
182
  // 복합 종성 자모 분해 (예: ㄺ → ㄹ+ㄱ)
166
183
  /** @public 복합 종성 index → 구성 자모 배열 (예: 9(ㄺ) → ['ㄹ','ㄱ']) */
@@ -418,6 +435,8 @@ function matchesPatternAtom(ch, atom) {
418
435
  * 0건이 된다(조용히 무관 처리하지 않는다 — 제약이 무시된 채 결과가 나오는 오해를 막기 위해).
419
436
  */
420
437
  export function matchesSyllablePattern(word, pattern) {
438
+ word = word.normalize('NFC');
439
+ pattern = pattern.normalize('NFC'); // 조각 한글(NFD) 붙여넣기
421
440
  const atoms = pattern.split(',').flatMap(chunk => parseChunkToAtoms(chunk.trim()));
422
441
  if (atoms.length === 0)
423
442
  return true;
@@ -497,7 +516,8 @@ function isSearchAtomChar(ch) {
497
516
  * @returns 유효하면 `{ ok: true }`, 아니면 `{ ok: false }`(호출부가 안내 문구를 표시).
498
517
  */
499
518
  export function validateSyllableSearchInput(input) {
500
- const trimmed = input.trim();
519
+ // 조각 한글(NFD) 붙여넣기가 '해석할 수 없는 글자' 로 입력 오류 안내를 띄웠다
520
+ const trimmed = input.normalize('NFC').trim();
501
521
  if (!trimmed)
502
522
  return { ok: true };
503
523
  for (const raw of trimmed.split(',')) {
@@ -643,16 +663,22 @@ export function countPhonemeDiff(a, b) {
643
663
  * @public
644
664
  */
645
665
  export function searchAndRankWords(items, query) {
646
- const q = query.trim();
666
+ const q = query.normalize('NFC').trim(); // 조각 한글(NFD) 붙여넣기
647
667
  if (!q || !/[^\s,_]/.test(q))
648
668
  return items;
649
669
  if (isSyllablePattern(q))
650
670
  return items.filter(w => matchesSyllablePattern(w.word, q));
651
- const hit = items.filter(w => matchesWordQuery(w.word, q) || matchesWordQuery(w.pronunciation ?? '', q));
671
+ // 검색어 준비는 한 번 — 낱말마다 다시 하면 키 한 번에 2.3만 번 펼치고 분류했다
672
+ const m = makeWordQueryMatcher(q);
652
673
  const headLen = [...q].length;
653
- const startsWith = (s) => !!s && matchesWordQuery(s.slice(0, headLen), q);
654
- return hit
655
- .map(w => ({ w, rank: (startsWith(w.word) || startsWith(w.pronunciation ?? '')) ? 0 : 1 }))
656
- .sort((a, b) => a.rank - b.rank)
657
- .map(s => s.w);
674
+ const startsWith = (s) => !!s && m(s.normalize('NFC').slice(0, headLen));
675
+ // 순위는 두 갈래로 나눠 잇는다(안정 정렬과 같은 순서)
676
+ const 앞 = [];
677
+ const 뒤 = [];
678
+ for (const w of items) {
679
+ if (!(m(w.word) || m(w.pronunciation ?? '')))
680
+ continue;
681
+ (startsWith(w.word) || startsWith(w.pronunciation ?? '') ? 앞 : 뒤).push(w);
682
+ }
683
+ return 앞.concat(뒤);
658
684
  }
@@ -484,9 +484,26 @@ preferAsTarget) {
484
484
  for (let p = 0; p < target.syls.length; p++) {
485
485
  const key = makeKey(target.syls, p);
486
486
  const candidates = groups.get(key) ?? [];
487
+ const ts = target.syls[p];
487
488
  for (const candidate of candidates) {
488
489
  if (candidate.entry.word === target.entry.word)
489
490
  continue;
491
+ /* 같은 묶음은 p 자리 밖이 모두 같다 — p 자리에서 정확히 한 성분만 달라야 최소대립쌍이다. 1음절 낱말은 전부 한 묶음이라
492
+ 묶음이 커서, 쌍 문자열·발음 분해(analyzeMinimalPair) 전에 먼저 거른다(음소를 고를 때마다 110–320ms 걸렸다). */
493
+ {
494
+ const cs = candidate.syls[p];
495
+ let d = 0;
496
+ if (ts.초성 !== cs.초성)
497
+ d++;
498
+ if (ts.모음 !== cs.모음)
499
+ d++;
500
+ if (ts.종성 !== cs.종성)
501
+ d++;
502
+ if ((ts.활음 ?? '') !== (cs.활음 ?? ''))
503
+ d++;
504
+ if (d !== 1)
505
+ continue;
506
+ }
490
507
  const dedupKey = target.entry.word < candidate.entry.word
491
508
  ? `${target.entry.word}|${candidate.entry.word}`
492
509
  : `${candidate.entry.word}|${target.entry.word}`;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * `ms` 뒤 끊기는 AbortSignal. `AbortSignal.timeout` 은 Safari 16 부터라 iOS/iPadOS 15 이하(치료실 구형 iPad)에서는
3
+ * 없어 fetch 를 부르기도 전에 TypeError 로 거절됐다 — 없으면 AbortController + setTimeout 으로 만든다.
4
+ */
5
+ export declare function timeoutSignal(ms: number): AbortSignal;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `ms` 뒤 끊기는 AbortSignal. `AbortSignal.timeout` 은 Safari 16 부터라 iOS/iPadOS 15 이하(치료실 구형 iPad)에서는
3
+ * 없어 fetch 를 부르기도 전에 TypeError 로 거절됐다 — 없으면 AbortController + setTimeout 으로 만든다.
4
+ */
5
+ export function timeoutSignal(ms) {
6
+ const native = AbortSignal.timeout;
7
+ if (typeof native === 'function')
8
+ return native.call(AbortSignal, ms);
9
+ const c = new AbortController();
10
+ setTimeout(() => c.abort(), ms);
11
+ return c.signal;
12
+ }
@@ -20,6 +20,7 @@
20
20
  *
21
21
  * @public
22
22
  */
23
+ import { timeoutSignal } from './timeoutSignal';
23
24
  const GLOBAL_KEY = '__ihab_core_tts_config__';
24
25
  /**
25
26
  * TTS URL 리졸버를 전역으로 설정합니다.
@@ -84,7 +85,7 @@ export async function checkAudioExists(word) {
84
85
  audioExistsCache.delete(url);
85
86
  return false;
86
87
  };
87
- const promise = fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(8000) })
88
+ const promise = fetch(url, { method: 'HEAD', signal: timeoutSignal(8000) })
88
89
  .then(res => (res.status >= 500 ? 확인안됨() : res.ok), 확인안됨);
89
90
  audioExistsCache.set(url, promise);
90
91
  return promise;
@@ -141,15 +141,17 @@ position) {
141
141
  answersByPos.set(i, [pw]);
142
142
  }
143
143
  for (const [pos, answers] of answersByPos) {
144
- const maskedSlots = new Set(selParts.map(p => pos + ':' + p));
144
+ // 가릴 성분은 대립 자리(pos)의 것뿐 — 낱말·자리·성분마다 'i:part' 문자열을 만들어 Set 에서 찾던 것을 불리언으로(열쇠 모양은 같다)
145
+ const mC = selParts.includes('cho'), mJ = selParts.includes('jung'), mG = selParts.includes('jong');
145
146
  // 마스킹 템플릿은 '발음' 분해 기준 (대립 자리 외 발음이 전부 같은 단어끼리 묶음)
146
147
  const templateOf = (d) => {
147
148
  let key = '';
148
149
  for (let i = 0; i < L; i++) {
149
150
  const s = d[i];
150
- for (const part of ['cho', 'jung', 'jong']) {
151
- key += (maskedSlots.has(i + ':' + part) ? '*' : s[part]) + '.';
152
- }
151
+ if (i === pos)
152
+ key += (mC ? '*' : s.cho) + '.' + (mJ ? '*' : s.jung) + '.' + (mG ? '*' : s.jong) + '.';
153
+ else
154
+ key += s.cho + '.' + s.jung + '.' + s.jong + '.';
153
155
  }
154
156
  return key;
155
157
  };