@ihabdevteam/core 0.133.0 → 0.135.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 (69) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/dist/components.cjs.js +176 -176
  3. package/dist/components.esm.css +1 -1
  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 +8 -2
  8. package/dist/packages/core/src/components/BarChart.js +3 -1
  9. package/dist/packages/core/src/components/Breadcrumb.js +5 -1
  10. package/dist/packages/core/src/components/Dialog.js +32 -9
  11. package/dist/packages/core/src/components/Header.css +2 -0
  12. package/dist/packages/core/src/components/Header.js +4 -1
  13. package/dist/packages/core/src/components/LandingHeader.js +18 -1
  14. package/dist/packages/core/src/components/Pagination.js +5 -2
  15. package/dist/packages/core/src/components/Panel.d.ts +1 -1
  16. package/dist/packages/core/src/components/Panel.js +4 -1
  17. package/dist/packages/core/src/components/PassCard/passCardProduct.js +6 -0
  18. package/dist/packages/core/src/components/PaymentPlan.d.ts +6 -0
  19. package/dist/packages/core/src/components/PaymentPlan.js +5 -3
  20. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +34 -21
  21. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +4 -1
  22. package/dist/packages/core/src/components/Popup/ListPopup.js +7 -2
  23. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +13 -3
  24. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +5 -1
  25. package/dist/packages/core/src/components/PromoBanner.js +1 -1
  26. package/dist/packages/core/src/components/Report/ReportDocument.js +35 -10
  27. package/dist/packages/core/src/components/RingChart.js +14 -3
  28. package/dist/packages/core/src/components/SideNav.js +1 -1
  29. package/dist/packages/core/src/components/SoundCompass.js +10 -1
  30. package/dist/packages/core/src/components/Table.js +15 -5
  31. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +7 -2
  32. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
  33. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +28 -2
  34. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +11 -7
  35. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
  36. package/dist/packages/core/src/components/Training/TrainingWordCard.js +4 -0
  37. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -1
  38. package/dist/packages/core/src/components/Training/useTTSPlayback.js +9 -0
  39. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -2
  40. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -1
  41. package/dist/packages/core/src/components/barChartData.d.ts +8 -0
  42. package/dist/packages/core/src/components/barChartData.js +21 -0
  43. package/dist/packages/core/src/components/useBarChartControl.js +5 -3
  44. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +17 -1
  45. package/dist/packages/core/src/hooks/useEditableList.d.ts +6 -0
  46. package/dist/packages/core/src/hooks/useEditableList.js +20 -1
  47. package/dist/packages/core/src/hooks/useTablePaging.js +4 -0
  48. package/dist/packages/core/src/hooks/useVisitHistory.js +2 -2
  49. package/dist/packages/core/src/hooks/useVisitSchedule.js +5 -4
  50. package/dist/packages/core/src/hooks/useWordMeanings.js +13 -4
  51. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +9 -4
  52. package/dist/packages/core/src/hooks/wordTrainingFilterModel.d.ts +1 -1
  53. package/dist/packages/core/src/hooks/wordTrainingFilterModel.js +7 -2
  54. package/dist/packages/core/src/i18n/locales/en.json +13 -1
  55. package/dist/packages/core/src/i18n/locales/ko.json +13 -1
  56. package/dist/packages/core/src/utils/format.js +17 -1
  57. package/dist/packages/core/src/utils/getTrainingData.js +11 -4
  58. package/dist/packages/core/src/utils/imageConfig.js +6 -3
  59. package/dist/packages/core/src/utils/koreanPronunciation.js +7 -0
  60. package/dist/packages/core/src/utils/pressable.js +4 -0
  61. package/dist/packages/core/src/utils/savedWordTables.js +2 -1
  62. package/dist/packages/core/src/utils/wordTrainingPool.d.ts +7 -0
  63. package/dist/packages/core/src/utils/wordTrainingPool.js +30 -0
  64. package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +1 -1
  65. package/dist/packages/core/src/utils/wordTrainingRuntime.js +38 -17
  66. package/dist/packages/core/src/version.d.ts +1 -1
  67. package/dist/packages/core/src/version.js +1 -1
  68. package/dist/utils.cjs.js +2 -2
  69. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react';
2
- import { useEditableList } from './useEditableList';
2
+ import { useEditableList, isSameEditableItem } from './useEditableList';
3
3
  /**
4
4
  * @public
5
5
  */
@@ -24,7 +24,7 @@ export function useVisitHistory(initialHistory = []) {
24
24
  closeSubPopup?.();
25
25
  };
26
26
  const handleVisitEditSave = () => {
27
- commitEdit(prev => prev.map(v => v === selectedVisit ? editVisitForm : v), editVisitForm);
27
+ commitEdit(prev => prev.map(v => (selectedVisit != null && isSameEditableItem(v, selectedVisit)) ? editVisitForm : v), editVisitForm);
28
28
  };
29
29
  /** 외부(스케줄 훅 등)에서 방문이력에 항목을 추가할 때 사용 */
30
30
  const addVisit = (item) => {
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { useCallback, useMemo, useState } from 'react';
3
3
  import { today } from './useVisitHistory';
4
- import { useEditableList } from './useEditableList';
4
+ import { useEditableList, isSameEditableItem } from './useEditableList';
5
5
  /**
6
6
  * 차례를 정하는 열쇠 — **가까운 것이 위**.
7
7
  *
@@ -48,17 +48,18 @@ initialSchedule = []) {
48
48
  closeSubPopup?.();
49
49
  };
50
50
  const handleScheduleEditSave = () => {
51
- commitEdit(prev => sortByDate(prev.map(v => v === selectedSchedule ? editScheduleForm : v)), editScheduleForm);
51
+ commitEdit(prev => sortByDate(prev.map(v => (selectedSchedule != null && isSameEditableItem(v, selectedSchedule)) ? editScheduleForm : v)), editScheduleForm);
52
52
  };
53
53
  const handleScheduleDelete = useCallback((item) => {
54
54
  remove(item);
55
55
  }, [remove]);
56
56
  const handleScheduleToVisit = useCallback((item) => {
57
- addVisit({ year: item.year, month: item.month, day: item.day, hour: item.hour, minute: item.minute, memo: item.memo });
57
+ // id 도 넘긴다 — 빠지면 그 방문을 이어서 수정할 때 합성 키로 넘어가 서버가 행을 못 찾았다
58
+ addVisit({ year: item.year, month: item.month, day: item.day, hour: item.hour, minute: item.minute, memo: item.memo, ...(item.id != null ? { id: item.id } : {}) });
58
59
  remove(item);
59
60
  }, [addVisit, remove]);
60
61
  const handleScheduleNoVisit = useCallback((item) => {
61
- setScheduleList(prev => prev.filter(v => v !== item));
62
+ setScheduleList(prev => prev.filter(v => !isSameEditableItem(v, item)));
62
63
  setNoShowList(prev => [item, ...prev]);
63
64
  setSelectedSchedule(null);
64
65
  }, [setScheduleList, setSelectedSchedule]);
@@ -23,26 +23,35 @@ import { useState, useEffect, useMemo } from 'react';
23
23
  export const meaningKey = (word, homonum) => `${word}:${homonum ?? 1}`;
24
24
  // 세션 동안 유지되는 메모리 캐시 — 페이지를 오가도 재조회하지 않는다
25
25
  const memCache = new Map();
26
+ /* 받아 봤는데 뜻이 없던 열쇠 — 다시 묻지 않는다. 전에는 뜻 없는 낱말이 캐시에 안 남아 표를 다시 정렬하거나 쪽을
27
+ 넘길 때마다 같은 낱말을 또 조회했다. (조회가 실패한 것은 여기 넣지 않는다 — 다음에 다시 받는다.) */
28
+ const noMeaning = new Set();
26
29
  export function useWordMeanings(rows, fetchMeanings) {
27
30
  /* 캐시는 모듈 스코프 Map 이라 갱신이 렌더에 자동 반영되지 않는다 — 조회가 끝나면 이 값을
28
31
  올려 아래 스냅샷을 다시 계산시킨다(캐시 히트만으로 리렌더가 연쇄되지 않도록 분리). */
29
32
  const [fetched, setFetched] = useState(0);
30
33
  // rows 는 매 렌더 새 배열이라 의존성으로 쓸 수 없다 — 조회 키만 뽑아 문자열로 비교한다
31
- const wantedKey = rows.map(r => meaningKey(r.word, r.homonum)).join('|');
34
+ // 차례는 조회와 무관하다 — 정렬해 두어야 같은 낱말들을 다시 정렬만 해도 새 조회로 보지 않는다
35
+ const wantedKey = Array.from(new Set(rows.map(r => meaningKey(r.word, r.homonum)))).sort().join('|');
32
36
  useEffect(() => {
33
37
  const wanted = wantedKey ? wantedKey.split('|') : [];
34
- const missing = wanted.filter(k => !memCache.has(k));
38
+ const missing = wanted.filter(k => !memCache.has(k) && !noMeaning.has(k));
35
39
  if (missing.length === 0)
36
40
  return; // 전부 캐시에 있음 — 네트워크·상태 갱신 불필요
37
41
  let cancelled = false;
38
42
  const words = Array.from(new Set(missing.map(k => k.slice(0, k.lastIndexOf(':')))));
39
43
  void fetchMeanings(words).then(got => {
40
- if (cancelled || got.length === 0)
41
- return;
44
+ /* 받은 것은 **취소됐어도 캐시에 담는다** — 쪽을 빨리 넘기면 앞 조회가 취소되며 받은 결과까지 버려, 돌아오면 또
45
+ 조회했다. 화면 갱신(setFetched)만 취소된 쪽에서 건너뛴다. */
42
46
  for (const row of got) {
43
47
  if (row.Meaning)
44
48
  memCache.set(meaningKey(row.Word, row.Homonum ?? undefined), row.Meaning);
45
49
  }
50
+ for (const k of missing)
51
+ if (!memCache.has(k))
52
+ noMeaning.add(k);
53
+ if (cancelled || got.length === 0)
54
+ return;
46
55
  setFetched(v => v + 1);
47
56
  }).catch(() => {
48
57
  // 조회 함수가 약속을 어기고 거절해도(망 끊김 등) 처리되지 않은 거절로 새지 않게 삼킨다 —
@@ -85,10 +85,14 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
85
85
  // 글자 모드에서 마지막으로 선택돼 있던 모음 자모 — 발음↔글자 왕복 시 병합 버튼(ㅔㅐ 등)의
86
86
  // 대표 자모로 뭉개지지 않고 원래 자모를 정확히 복원하기 위해 별도로 기억해 둔다.
87
87
  const lastCharJungRef = useRef(phonemeView === 'char' ? composition.jung : null);
88
+ // 종성도 — 발음은 대표음(ㅅ→ㄷ)으로 모으므로 되돌릴 원래 자모를 기억한다
89
+ const lastCharJongRef = useRef(phonemeView === 'char' ? composition.jong : null);
88
90
  useEffect(() => {
89
- if (phonemeView === 'char')
91
+ if (phonemeView === 'char') {
90
92
  lastCharJungRef.current = composition.jung;
91
- }, [phonemeView, composition.jung]);
93
+ lastCharJongRef.current = composition.jong;
94
+ }
95
+ }, [phonemeView, composition.jung, composition.jong]);
92
96
  // resetOnMount(단어훈련 만들기): '초기화'는 필터를 모두 비운 중립 상태여야 하므로 선택 토글도 OFF로 시작한다.
93
97
  // (기본 ON이면 단음절 음소 합치기 등에서 '이미지 있는 단어만'에 걸려 첫 진입 결과가 0이 된다.)
94
98
  // 단, 카드에 이미지가 필요한 타입(듣고 순서대로 기억하기 등)은 defaultImageOnly로 ON을 요청할 수 있다.
@@ -109,7 +113,8 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
109
113
  return;
110
114
  resetKeyRef.current = opts?.resetKey;
111
115
  setComposition(seedComposition());
112
- setExcludeNoImage(false);
116
+ // 이미지 토글도 그 타입의 기본값으로 — 무조건 false 로 두어 defaultImageOnly 를 요청한 타입(듣고 순서대로 기억하기)이 꺼졌다
117
+ setExcludeNoImage(!!opts?.defaultImageOnly);
113
118
  setSameStructureOnly(false);
114
119
  setGradeFilter([]);
115
120
  setPosFilter([]);
@@ -520,7 +525,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
520
525
  if (next === phonemeView)
521
526
  return;
522
527
  if (next !== 'direct' && phonemeView !== 'direct') {
523
- setComposition(c => convertComposition(c, next, lastCharJungRef.current));
528
+ setComposition(c => convertComposition(c, next, lastCharJungRef.current, lastCharJongRef.current));
524
529
  }
525
530
  setPhonemeView(next);
526
531
  }, [phonemeView]);
@@ -51,7 +51,7 @@ export declare function charToPronValue(part: CompositionPart, jamo: string): st
51
51
  */
52
52
  export declare function pronToCharValue(part: CompositionPart, value: string, preferredJamo?: string | null): string;
53
53
  /** 모드 전환 시 composition의 초성/모음/종성을 대상 모드 값으로 변환. 종성 '없음'(NO_CODA)은 유지. */
54
- export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null): WordComposition;
54
+ export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null, preferredCharJong?: string | null): WordComposition;
55
55
  /** 정답 단어 구성 선택 상태 (정답 단어에만 적용 — 오답은 대립 관계로 결정) */
56
56
  export interface WordComposition {
57
57
  cho: string | null;
@@ -83,10 +83,15 @@ export function pronToCharValue(part, value, preferredJamo) {
83
83
  return preferredJamo;
84
84
  return jamos[0] ?? value;
85
85
  }
86
+ /* 종성도 같다 — 글자→발음은 대표음(ㅅ·ㅈ·ㅊ·ㅌ·ㅎ → ㄷ, ㄳ → ㄱ)으로 모으므로, 되돌릴 때 전환 직전 글자 모드의 종성이
87
+ 그 대표음에 속하면 그것을 복원한다. 전에는 대표음 그대로 돌아와 보기만 바꿔도 훈련 대상이 옷·낫에서 곧·받으로
88
+ 바뀌어 저장됐다. */
89
+ if (part === 'jong' && preferredJamo && finalToPhoneme(preferredJamo) === value)
90
+ return preferredJamo;
86
91
  return value;
87
92
  }
88
93
  /** 모드 전환 시 composition의 초성/모음/종성을 대상 모드 값으로 변환. 종성 '없음'(NO_CODA)은 유지. */
89
- export function convertComposition(c, to, preferredCharJung) {
94
+ export function convertComposition(c, to, preferredCharJung, preferredCharJong) {
90
95
  const conv = (part, v) => {
91
96
  if (!v)
92
97
  return null;
@@ -94,7 +99,7 @@ export function convertComposition(c, to, preferredCharJung) {
94
99
  return NO_CODA;
95
100
  if (to === 'pron')
96
101
  return charToPronValue(part, v);
97
- return pronToCharValue(part, v, part === 'jung' ? preferredCharJung : null);
102
+ return pronToCharValue(part, v, part === 'jung' ? preferredCharJung : part === 'jong' ? preferredCharJong : null);
98
103
  };
99
104
  return { ...c, cho: conv('cho', c.cho), jung: conv('jung', c.jung), jong: conv('jong', c.jong) };
100
105
  }
@@ -742,7 +742,8 @@
742
742
  "descriptionPlaceholder": "Enter a description for this word table.",
743
743
  "duplicateName": "This name is already in use.",
744
744
  "nameRequired": "Please enter a table name.",
745
- "autoFillHint": " (Press Tab or Space to autofill)"
745
+ "autoFillHint": " (Press Tab or Space to autofill)",
746
+ "copyName": "{{name}} (copy)"
746
747
  },
747
748
  "loadTable": {
748
749
  "title": "Load Saved Word Table",
@@ -1390,5 +1391,16 @@
1390
1391
  },
1391
1392
  "loader": {
1392
1393
  "loading": "Loading"
1394
+ },
1395
+ "soundCompass": {
1396
+ "state": {
1397
+ "active": "playing",
1398
+ "played": "heard",
1399
+ "correct": "correct",
1400
+ "wrong": "wrong"
1401
+ }
1402
+ },
1403
+ "breadcrumb": {
1404
+ "ariaLabel": "Breadcrumb"
1393
1405
  }
1394
1406
  }
@@ -740,7 +740,8 @@
740
740
  "descriptionPlaceholder": "이 단어표에 대한 설명을 입력하세요.",
741
741
  "duplicateName": "이미 사용 중인 이름입니다.",
742
742
  "nameRequired": "표 이름을 입력해 주세요.",
743
- "autoFillHint": " (Tab 또는 space 입력시 자동 입력)"
743
+ "autoFillHint": " (Tab 또는 space 입력시 자동 입력)",
744
+ "copyName": "{{name}} (복사)"
744
745
  },
745
746
  "loadTable": {
746
747
  "title": "저장된 단어 표 불러오기",
@@ -1388,5 +1389,16 @@
1388
1389
  },
1389
1390
  "loader": {
1390
1391
  "loading": "불러오는 중…"
1392
+ },
1393
+ "soundCompass": {
1394
+ "state": {
1395
+ "active": "소리 나는 중",
1396
+ "played": "들음",
1397
+ "correct": "정답",
1398
+ "wrong": "오답"
1399
+ }
1400
+ },
1401
+ "breadcrumb": {
1402
+ "ariaLabel": "이동 경로"
1391
1403
  }
1392
1404
  }
@@ -9,7 +9,23 @@ import { toKstYmd } from './date';
9
9
  * 입력에 대시가 있든 없든, 어떤 포맷이든 처리.
10
10
  */
11
11
  export function formatPhone(raw) {
12
- const d = raw.replace(/\D/g, '').slice(0, 11);
12
+ const all = raw.replace(/\D/g, '');
13
+ /* **앞자리가 네 자리인 번호.** 050X 안심번호·0303 착신전환은 열두 자리까지 가고, 앞자리를 세 자리로 보면
14
+ `0507-1234-5678` 이 `050-7123-4567` 이 되어 **마지막 한 자리가 사라진 채** 멀쩡한 모양으로 저장됐다
15
+ (말귀가 formatPhoneKr 로 감싸 두었다 — 이제 걷어도 된다). */
16
+ if (/^(050\d|0303)/.test(all)) {
17
+ const d = all.slice(0, 12);
18
+ if (d.length <= 4)
19
+ return d;
20
+ if (d.length <= 8)
21
+ return `${d.slice(0, 4)}-${d.slice(4)}`;
22
+ return `${d.slice(0, 4)}-${d.slice(4, d.length - 4)}-${d.slice(d.length - 4)}`;
23
+ }
24
+ /* **대표번호**(1588·1544·1600·1899 …)는 여덟 자리 4-4 다 — 세 자리 앞자리로 보면 `158-8-1234` 가 됐다. */
25
+ if (/^1[5-9]\d{2}/.test(all) && all.length <= 8) {
26
+ return all.length <= 4 ? all : `${all.slice(0, 4)}-${all.slice(4)}`;
27
+ }
28
+ const d = all.slice(0, 11);
13
29
  if (d.startsWith('02')) {
14
30
  if (d.length <= 2)
15
31
  return d;
@@ -104,6 +104,9 @@ export async function loadTrainingData() {
104
104
  catch (e) { }
105
105
  return;
106
106
  }
107
+ /* 다 읽은 뒤에 한꺼번에 넣는다 — 도중에 형식 오류로 멈추면 이미 넣은 것이 남은 채 재시도가 또 넣어, 부를 때마다 풀에
108
+ 중복이 쌓였다(하루 선택이 중복 문항을 뽑고 기기마다 세트가 달라짐). */
109
+ const 받음 = { noise: [], short: [], long: [], order: [] };
107
110
  try {
108
111
  for (const key of Object.keys(map || {})) {
109
112
  const groups = map[key] || [];
@@ -115,16 +118,16 @@ export async function loadTrainingData() {
115
118
  // Preserve original group structure from allTrainingData.json
116
119
  const groupObj = Object.assign({}, gr, { training_type: t, category_0: cat0, category_1: cat1 });
117
120
  if (t === 'LISTEN_IN_NOISE') {
118
- NOISE_DATAS.push(groupObj);
121
+ 받음.noise.push(groupObj);
119
122
  }
120
123
  else if (t === 'SHORT_STORY') {
121
- SHORT_STORIES.push(groupObj);
124
+ 받음.short.push(groupObj);
122
125
  }
123
126
  else if (t === 'LONG_STORY') {
124
- LONG_STORIES.push(groupObj);
127
+ 받음.long.push(groupObj);
125
128
  }
126
129
  else if (t === 'ORDERING_SENTENCE') {
127
- ORDER_SETS.push(groupObj);
130
+ 받음.order.push(groupObj);
128
131
  }
129
132
  }
130
133
  }
@@ -142,6 +145,10 @@ export async function loadTrainingData() {
142
145
  catch { /* SSR 등 */ }
143
146
  return;
144
147
  }
148
+ NOISE_DATAS.push(...받음.noise);
149
+ SHORT_STORIES.push(...받음.short);
150
+ LONG_STORIES.push(...받음.long);
151
+ ORDER_SETS.push(...받음.order);
145
152
  try {
146
153
  window.dispatchEvent(new Event('speechear.data.updated'));
147
154
  }
@@ -60,13 +60,16 @@ export async function checkImageExists(word, homonum) {
60
60
  return false;
61
61
  // 타임아웃 없으면 HEAD 요청 하나가 응답 없이 걸릴 때 이 확인을 기다리는
62
62
  // 검색 버튼(이미지 있음 필터)이 영구 비활성화된다
63
+ /* **확인이 안 된 것은 캐시하지 않는다.** 망 끊김·타임아웃·5xx 까지 '이미지 없음' 으로 영구 저장해, 한 번 끊긴 단어는 망이
64
+ 돌아와도 '이미지 있음' 필터에서 계속 빠졌다(ttsConfig.checkAudioExists 와 같게). 있음(2xx)·없음(4xx)만 담는다. */
63
65
  const p = fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(8000) })
64
- .then(res => res.ok)
65
- .catch(() => false)
66
+ .then(res => (res.status >= 500 ? null : res.ok), () => null)
66
67
  .then(ok => {
68
+ inflight.delete(k);
69
+ if (ok === null)
70
+ return false;
67
71
  map[k] = ok;
68
72
  scheduleSave();
69
- inflight.delete(k);
70
73
  return ok;
71
74
  });
72
75
  inflight.set(k, p);
@@ -147,6 +147,13 @@ export function toStandardPronunciation(word) {
147
147
  cur.jong = cur.jong === JONG_NH ? JONG_N : cur.jong === JONG_RH ? JONG_R : JONG_NONE;
148
148
  continue;
149
149
  }
150
+ // 받침 ㅎ 계열 + 초성 ㅅ — ㅎ 은 떨어지고 ㅅ 은 된소리(표준발음법 제12항 2: 닿소 → 다쏘, 많소 → 만쏘, 싫소 → 실쏘).
151
+ // 전에는 이 갈래가 없어 끝소리 규칙으로 ㅎ 이 ㄷ 이 되어 '놓소 → 녿쏘' 가 나왔다.
152
+ if ((cur.jong === JONG_H || cur.jong === JONG_NH || cur.jong === JONG_RH) && next.cho === CHO_S) {
153
+ next.cho = CHO_SS;
154
+ cur.jong = cur.jong === JONG_NH ? JONG_N : cur.jong === JONG_RH ? JONG_R : JONG_NONE;
155
+ continue;
156
+ }
150
157
  // 받침 ㄱㄷㅂㅈ 계열 + 초성 ㅎ (먹히다 → 머키다, 앉히다 → 안치다, 읽히다 → 일키다)
151
158
  if (next.cho === CHO_H) {
152
159
  if (cur.jong === JONG_NJ) {
@@ -22,6 +22,10 @@ export function pressable(onPress, 옵션) {
22
22
  tabIndex: 0,
23
23
  ...(옵션?.selected === undefined ? {} : { 'aria-selected': 옵션.selected }),
24
24
  onKeyDown: (e) => {
25
+ /* **이 자리 자신에서 난 키만** 받는다. 안에 든 진짜 단추(랜딩 띠의 action 등)에서 누른 Enter 가 올라오면
26
+ 전에는 여기서 preventDefault 해 그 단추의 click 을 막고 이 자리의 onPress 를 대신 불렀다. */
27
+ if (e.target !== e.currentTarget)
28
+ return;
25
29
  if (e.key !== 'Enter' && e.key !== ' ')
26
30
  return;
27
31
  /* Space 는 그냥 두면 화면이 굴러간다 — 누름으로 바꾼다 */
@@ -56,11 +56,12 @@ export function duplicateTable(id) {
56
56
  const original = tables.find(t => t.id === id);
57
57
  if (!original)
58
58
  return null;
59
+ /* 사본도 설명·검색 구성·묶음을 가진다 — 전에는 items 만 옮겨, 사본을 '수정' 하면 검색 패널이 복원되지 않았다. */
59
60
  const copy = {
61
+ ...original,
60
62
  id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
61
63
  name: `${original.name} (복사)`,
62
64
  savedAt: new Date().toISOString(),
63
- items: original.items,
64
65
  };
65
66
  try {
66
67
  localStorage.setItem(STORAGE_KEY, JSON.stringify([copy, ...tables]));
@@ -49,3 +49,10 @@ export declare function buildUsablePool(opts: GenOptions): Promise<Array<{
49
49
  word: MvpWord;
50
50
  usable: string[];
51
51
  }>>;
52
+ /**
53
+ * 정답(문제) 단어 거르개 — **난이도 등급 · 단어 구성(초성/중성/종성/받침없음 · 음절 수)**.
54
+ * buildUsablePool 이 후보에 거는 것과 같은 규칙이다(글자입력=철자, 발음입력=표준발음으로 본다).
55
+ * 실단어를 직접 고르는 생성기(음소 합성·받침/자음 탈락·음절 바꾸기)가 이것을 안 걸어, 설정한 필터와 무관한
56
+ * 문제를 냈다(초성 ㄱ·1등급인데 '카·라·파…'). 거를 것이 없으면 null.
57
+ */
58
+ export declare function answerWordFilter(opts: GenOptions): ((w: MvpWord) => boolean) | null;
@@ -216,6 +216,36 @@ function _positionalPredicate(composition) {
216
216
  }
217
217
  // 대립(오답) 엔진은 공용 모듈 wordContrast.ts 로 분리 — hasStructureContrast, buildMultiPartPosContrast,
218
218
  // minimalPairDistractors 등은 거기서 import 한다(런타임과 훈련/단어표 만들기가 공유).
219
+ /**
220
+ * 정답(문제) 단어 거르개 — **난이도 등급 · 단어 구성(초성/중성/종성/받침없음 · 음절 수)**.
221
+ * buildUsablePool 이 후보에 거는 것과 같은 규칙이다(글자입력=철자, 발음입력=표준발음으로 본다).
222
+ * 실단어를 직접 고르는 생성기(음소 합성·받침/자음 탈락·음절 바꾸기)가 이것을 안 걸어, 설정한 필터와 무관한
223
+ * 문제를 냈다(초성 ㄱ·1등급인데 '카·라·파…'). 거를 것이 없으면 null.
224
+ */
225
+ export function answerWordFilter(opts) {
226
+ const gradeSet = opts.grade && opts.grade.length > 0 ? new Set(opts.grade) : null;
227
+ const composition = opts.composition ?? null;
228
+ const syllableSet = composition?.syllable != null
229
+ ? new Set(Array.isArray(composition.syllable) ? composition.syllable : [composition.syllable])
230
+ : null;
231
+ const preds = composition
232
+ ? [
233
+ _positionalPredicate(composition),
234
+ syllableSet ? (word) => syllableSet.has(syllableLength(word)) : null,
235
+ ].filter((p) => p != null)
236
+ : [];
237
+ if (!gradeSet && preds.length === 0)
238
+ return null;
239
+ const pron = opts.phonemeView === 'pron';
240
+ return (w) => {
241
+ if (gradeSet && (!w.grade || !gradeSet.has(w.grade)))
242
+ return false;
243
+ if (preds.length === 0)
244
+ return true;
245
+ const target = pron ? pronOfWord(w.w) : w.w;
246
+ return preds.every(pred => pred(target));
247
+ };
248
+ }
219
249
  async function _buildUsablePool(opts) {
220
250
  const { difficulty = null, choseong = null, composition = null, targetSyllable = null, excludeNoImage = false } = opts;
221
251
  // 난이도 등급('1'~'4') 필터 — 정답(후보) 단어에만 적용(구성 필터와 동일 원칙, 오답 풀은 무관)
@@ -9,7 +9,7 @@ export declare function generateRuntimeItems(typeKey: WordTrainingTypeKey, opts?
9
9
  * 공통 조합을 백그라운드에서 사전 계산해 결과 캐시를 미리 채운다.
10
10
  * 이후 동일 조합으로 generateRuntimeItems 호출 시 즉시(<5ms) 반환.
11
11
  * fire-and-forget — 완료 여부에 관계없이 UI 블로킹 없음.
12
- * UI가 실제로 쓰는 difficulty(cap.difficulty→'count', 그 외 null)만 예열한다.
12
+ * UI가 실제로 쓰는 열쇠(count 5 · 난이도 'random'/'count' · 필터 기본값)로 예열한다.
13
13
  * (과거 대립종류 cho/jung/jong 예열은 UI 미사용이고 정작 count는 누락돼 첫 생성이 느렸음)
14
14
  */
15
15
  export declare function precomputeCommonCombinations(excludeNoImage: boolean): Promise<void>;
@@ -17,7 +17,7 @@ export { CAPABILITY, cardGroupOf, isValidQuestionWord, hasQuestionWordConstraint
17
17
  import { CAPABILITY, QUESTION_WORD_RULE } from './wordTrainingRuntimeTypes';
18
18
  import { buildDropContrastProblem, buildDropContrast3Problem, buildDropCompoundProblem, buildDropFinalConsonantProblem, buildRemoveConsonantProblem, buildChangeWholeSyllableProblem, buildSyllableMaskIndex } from './wordTrainingGenerators';
19
19
  /* 재료 고르기(풀 추리기·캐시·오답 샘플링)는 wordTrainingPool 로 갈라 두었다. */
20
- import { MVP, buildUsablePool, filterByCachedImage, getPronMap, nextId, pickOne, sample, sampleDistinctPron, _poolCacheKey, _EMPTY_SET, } from './wordTrainingPool';
20
+ import { MVP, buildUsablePool, filterByCachedImage, getPronMap, nextId, pickOne, sample, sampleDistinctPron, _poolCacheKey, _EMPTY_SET, answerWordFilter, } from './wordTrainingPool';
21
21
  // ────────────────────────────────────────────────────────────────────────────
22
22
  // 카테고리별 generator
23
23
  // ────────────────────────────────────────────────────────────────────────────
@@ -100,11 +100,17 @@ async function genIncluded(opts) {
100
100
  async function genSameOrDiff(opts) {
101
101
  const { count = 10 } = opts;
102
102
  const pool = (await buildUsablePool(opts)).filter(e => e.usable.length >= 1);
103
- return Array.from({ length: count }, (_, i) => {
104
- const entry = pickOne(pool);
105
- if (!entry)
106
- return null;
107
- const isSame = i % 2 === 0;
103
+ if (!pool.length)
104
+ return [];
105
+ /* 같음·다름을 **절반씩 섞어** 둔다. 전에는 i % 2 로 정해 늘 같음·다름·같음… 순서라 듣지 않고도 답을 맞힐 수
106
+ 있었고, 단어를 복원추출(pickOne)해 풀이 작지 않아도 같은 단어가 여러 문항에 나왔다. 풀이 문항 수보다 크면
107
+ 겹치지 않게 뽑는다. */
108
+ const entries = pool.length >= count
109
+ ? sample(pool, count)
110
+ : shuffle(Array.from({ length: count }, (_, i) => pool[i % pool.length]));
111
+ const sameFlags = shuffle(Array.from({ length: count }, (_, i) => i < Math.ceil(count / 2)));
112
+ return entries.map((entry, i) => {
113
+ const isSame = sameFlags[i];
108
114
  let pair;
109
115
  if (isSame)
110
116
  pair = [entry.word.w, entry.word.w];
@@ -119,7 +125,7 @@ async function genSameOrDiff(opts) {
119
125
  pair,
120
126
  same: isSame,
121
127
  };
122
- }).filter((x) => Boolean(x));
128
+ });
123
129
  }
124
130
  /**
125
131
  * STARTS_WITH_SAME_SOUND
@@ -260,10 +266,14 @@ async function genChangeWholeSyll(N, opts, idPrefix) {
260
266
  const maskIndex = buildSyllableMaskIndex(wordSet);
261
267
  const phonemeView = opts.phonemeView ?? 'char';
262
268
  const comp = opts.composition ?? null;
269
+ // 등급은 정답(문제) 단어에만 건다 — 오답 탐색 풀(maskIndex)은 그대로(buildUsablePool 과 같은 원칙). 전에는 등급이 빠졌다.
270
+ const answerOk = answerWordFilter({ grade: opts.grade });
263
271
  const out = [];
264
272
  for (const w of shuffle(words)) {
265
273
  if (out.length >= count)
266
274
  break;
275
+ if (answerOk && !answerOk(w))
276
+ continue;
267
277
  const prob = buildChangeWholeSyllableProblem(w.w, comp, pronMap, phonemeView, maskIndex, null, null);
268
278
  if (!prob)
269
279
  continue;
@@ -449,13 +459,17 @@ async function genListenAndRememberOrder(opts) {
449
459
  // 음소 단위(합성/탈락) — 실단어 데이터 기반. 이미지 필터 적용.
450
460
  // ────────────────────────────────────────────────────────────────────────────
451
461
  /** N음절 실단어(이미지 필터 적용) 중 조건(pred: 첫 음절 분해값 기준)을 만족하는 것 */
452
- function realWordsOfLen(N, excludeNoImage, pred) {
462
+ function realWordsOfLen(N, opts, pred) {
453
463
  const all = MVP.filter(w => w.w && w.s === N);
454
- const ok = excludeNoImage ? filterByCachedImage(all.map(w => w.w)) : null;
464
+ const ok = opts.excludeNoImage ? filterByCachedImage(all.map(w => w.w)) : null;
465
+ // 설정한 등급·단어 구성도 건다 — 전에는 음절 수·이미지만 봐서 필터와 무관한 문제가 나왔다
466
+ const answerOk = answerWordFilter(opts);
455
467
  const out = [];
456
468
  for (const mw of all) {
457
469
  if (ok && !ok.has(mw.w))
458
470
  continue;
471
+ if (answerOk && !answerOk(mw))
472
+ continue;
459
473
  if (pred) {
460
474
  const s = parseSyllable(mw.w);
461
475
  if (!s || !pred(s))
@@ -467,7 +481,7 @@ function realWordsOfLen(N, excludeNoImage, pred) {
467
481
  }
468
482
  /** MERGE_2_PHONEMES — 초성+모음 합쳐 1음절(받침 없음). 실단어 데이터 기반 */
469
483
  function genMerge2Phonemes(opts) {
470
- const pool = realWordsOfLen(1, opts.excludeNoImage ?? false, s => s.jong === 0);
484
+ const pool = realWordsOfLen(1, opts, s => s.jong === 0);
471
485
  return sample(pool, opts.count ?? 10).map((e, i) => {
472
486
  const s = parseSyllable(e.w);
473
487
  return { id: nextId('M2P-item'), index: i + 1, parts: [CHOSEONG[s.cho], JUNGSEONG[s.jung]], answer: e.w, word: e.w };
@@ -476,7 +490,7 @@ function genMerge2Phonemes(opts) {
476
490
  /** MERGE_3_PHONEMES — 초성+모음+종성 합쳐 1음절(홑받침). 실단어 데이터 기반 */
477
491
  function genMerge3Phonemes(opts) {
478
492
  // 홑받침만(겹받침은 음소 2개라 '3음소'가 아니게 됨)
479
- const pool = realWordsOfLen(1, opts.excludeNoImage ?? false, s => s.jong > 0 && !JONG_DECOMPOSE[s.jong]);
493
+ const pool = realWordsOfLen(1, opts, s => s.jong > 0 && !JONG_DECOMPOSE[s.jong]);
480
494
  return sample(pool, opts.count ?? 10).map((e, i) => {
481
495
  const s = parseSyllable(e.w);
482
496
  return { id: nextId('M3P-item'), index: i + 1, parts: [CHOSEONG[s.cho], JUNGSEONG[s.jung], JONGSEONG[s.jong]], answer: e.w, word: e.w };
@@ -486,7 +500,7 @@ function genMerge3Phonemes(opts) {
486
500
  * 풀 조건은 QUESTION_WORD_RULE와 공유 — 편집기 무작위 선택/검증이 이 규칙을 그대로 쓴다. */
487
501
  function genDropFinalConsonant(opts) {
488
502
  const rule = QUESTION_WORD_RULE.DROP_FINAL_CONSONANT_FROM_1SYLL;
489
- const pool = realWordsOfLen(rule.syllables, opts.excludeNoImage ?? false, rule.syllable);
503
+ const pool = realWordsOfLen(rule.syllables, opts, rule.syllable);
490
504
  const pronMap = getPronMap();
491
505
  const out = [];
492
506
  for (const e of shuffle(pool)) {
@@ -506,7 +520,7 @@ function genDropFinalConsonant(opts) {
506
520
  function genRemoveConsonant(opts) {
507
521
  // 받침 없는 자음 초성 1음절만 → 자음을 빼면 순수 모음 음절(ㅇ+모음)이 남는다.
508
522
  const rule = QUESTION_WORD_RULE.REMOVE_CONSONANT_AND_IDENTIFY_PHONEME;
509
- const pool = realWordsOfLen(rule.syllables, opts.excludeNoImage ?? false, rule.syllable);
523
+ const pool = realWordsOfLen(rule.syllables, opts, rule.syllable);
510
524
  const pronMap = getPronMap();
511
525
  const out = [];
512
526
  for (const e of shuffle(pool)) {
@@ -585,11 +599,14 @@ async function _generateRuntimeItems(typeKey, opts = {}) {
585
599
  * 공통 조합을 백그라운드에서 사전 계산해 결과 캐시를 미리 채운다.
586
600
  * 이후 동일 조합으로 generateRuntimeItems 호출 시 즉시(<5ms) 반환.
587
601
  * fire-and-forget — 완료 여부에 관계없이 UI 블로킹 없음.
588
- * UI가 실제로 쓰는 difficulty(cap.difficulty→'count', 그 외 null)만 예열한다.
602
+ * UI가 실제로 쓰는 열쇠(count 5 · 난이도 'random'/'count' · 필터 기본값)로 예열한다.
589
603
  * (과거 대립종류 cho/jung/jong 예열은 UI 미사용이고 정작 count는 누락돼 첫 생성이 느렸음)
590
604
  */
591
605
  export async function precomputeCommonCombinations(excludeNoImage) {
592
- const difficulties = [null, 'count'];
606
+ /* **화면이 실제로 부르는 열쇠와 똑같이** 예열한다. 전에는 count:10 · 난이도 null/'count' 로 돌았는데 화면
607
+ (useWordTrainingDataGeneration)은 count:5 · 필터 기본 난이도 'random'(음소를 고르면 'count')으로 불러 캐시 열쇠가
608
+ 달랐다 — 예열 계산을 전부 버리고 첫 생성이 다시 계산했다. */
609
+ const difficulties = ['random', 'count'];
593
610
  const types = Object.keys(CAPABILITY);
594
611
  let tick = 0;
595
612
  for (const diff of difficulties) {
@@ -597,9 +614,13 @@ export async function precomputeCommonCombinations(excludeNoImage) {
597
614
  const cap = CAPABILITY[t];
598
615
  generateRuntimeItems(t, {
599
616
  difficulty: cap.difficulty ? diff : null,
600
- choseong: null,
601
- count: 10,
617
+ contrastCount: 1,
618
+ composition: null,
619
+ count: 5,
602
620
  excludeNoImage,
621
+ sameStructureOnly: true,
622
+ phonemeView: 'char',
623
+ grade: null,
603
624
  }).catch(() => { });
604
625
  // 6 타입마다 마이크로태스크 양보 → 메인 스레드 프레임 블로킹 방지
605
626
  if (++tick % 6 === 0)
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.133.0";
49
+ export declare const CORE_VERSION = "0.135.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.133.0';
49
+ export const CORE_VERSION = '0.135.0';