@ihabdevteam/core 0.141.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 (76) hide show
  1. package/CHANGELOG.md +73 -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 +81 -29
  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/BulkBar.js +15 -4
  11. package/dist/packages/core/src/components/Button.js +10 -4
  12. package/dist/packages/core/src/components/DatePicker.js +5 -1
  13. package/dist/packages/core/src/components/Dialog.js +3 -1
  14. package/dist/packages/core/src/components/Dropdown.js +6 -2
  15. package/dist/packages/core/src/components/FileUpload.js +3 -1
  16. package/dist/packages/core/src/components/FilterPanel.js +3 -2
  17. package/dist/packages/core/src/components/PanelRow.js +5 -0
  18. package/dist/packages/core/src/components/PaymentPlan.d.ts +2 -2
  19. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -1
  20. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +5 -2
  21. package/dist/packages/core/src/components/Popup/Popup.d.ts +6 -6
  22. package/dist/packages/core/src/components/Popup/Popup.js +11 -9
  23. package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +1 -1
  24. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +1 -1
  25. package/dist/packages/core/src/components/TextField.js +3 -1
  26. package/dist/packages/core/src/components/Tooltip/Tooltip.js +7 -1
  27. package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -1
  28. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +1 -1
  29. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +7 -1
  30. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +52 -2
  31. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +3 -1
  32. package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -1
  33. package/dist/packages/core/src/components/Training/useAudioPreview.js +28 -3
  34. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +19 -5
  35. package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
  36. package/dist/packages/core/src/components/Word/WordTableButton.d.ts +3 -3
  37. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  38. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +1 -1
  39. package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +2 -2
  40. package/dist/packages/core/src/components/barChartData.d.ts +3 -5
  41. package/dist/packages/core/src/components/barChartData.js +7 -3
  42. package/dist/packages/core/src/components/barChartTypes.d.ts +16 -3
  43. package/dist/packages/core/src/components/barChartTypes.js +27 -13
  44. package/dist/packages/core/src/components.d.ts +1 -0
  45. package/dist/packages/core/src/hooks/focusReturn.d.ts +4 -0
  46. package/dist/packages/core/src/hooks/focusReturn.js +27 -0
  47. package/dist/packages/core/src/hooks/inputModality.d.ts +2 -0
  48. package/dist/packages/core/src/hooks/inputModality.js +31 -0
  49. package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +12 -0
  50. package/dist/packages/core/src/hooks/useDocumentTitle.js +7 -6
  51. package/dist/packages/core/src/hooks/useEditableList.js +3 -3
  52. package/dist/packages/core/src/hooks/useWordMeanings.js +6 -3
  53. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +2 -2
  54. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +54 -17
  55. package/dist/packages/core/src/utils/date.d.ts +18 -0
  56. package/dist/packages/core/src/utils/date.js +23 -0
  57. package/dist/packages/core/src/utils/format.js +2 -1
  58. package/dist/packages/core/src/utils/imageConfig.d.ts +2 -0
  59. package/dist/packages/core/src/utils/imageConfig.js +34 -5
  60. package/dist/packages/core/src/utils/josa.js +2 -0
  61. package/dist/packages/core/src/utils/koreanSyllable.d.ts +5 -0
  62. package/dist/packages/core/src/utils/koreanSyllable.js +69 -43
  63. package/dist/packages/core/src/utils/phonemePairSearch.js +17 -0
  64. package/dist/packages/core/src/utils/textSort.d.ts +10 -0
  65. package/dist/packages/core/src/utils/textSort.js +16 -11
  66. package/dist/packages/core/src/utils/timeoutSignal.d.ts +5 -0
  67. package/dist/packages/core/src/utils/timeoutSignal.js +12 -0
  68. package/dist/packages/core/src/utils/ttsConfig.js +2 -1
  69. package/dist/packages/core/src/utils/wordContrast.js +6 -4
  70. package/dist/packages/core/src/utils/wordTrainingPool.js +14 -5
  71. package/dist/packages/core/src/utils.d.ts +1 -1
  72. package/dist/packages/core/src/utils.js +1 -1
  73. package/dist/packages/core/src/version.d.ts +1 -1
  74. package/dist/packages/core/src/version.js +1 -1
  75. package/dist/utils.cjs.js +2 -2
  76. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ /** 마지막 입력이 키보드였으면 true(모르면 false) */
2
+ export declare function 키보드로눌렀나(): boolean;
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+ /*
3
+ * 마지막 입력이 **키보드였나** — 누른 단추가 사라지는 자리에서 초점을 옮길지 가를 때 쓴다.
4
+ *
5
+ * "초점이 누른 단추에 있었나" 로만 보면 Chrome·Edge·안드로이드는 마우스로 눌러도 단추에 초점이 가서, 마우스 사용자에게도
6
+ * 초점을 옮겨 그 단추의 초점 툴팁이 떴다(듣기 → '재생 / 멈춤', 문장 미리보기 → '글자 크기 감소'). Safari 는 누른 단추에 초점을
7
+ * 안 줘 body 에 남는다. 문서 전체에서 키 누름·포인터 누름을 들어 마지막 것을 기억한다(캡처 단계, 한 번만 붙인다).
8
+ */
9
+ let 마지막 = null;
10
+ let 붙음 = false;
11
+ function 붙이기() {
12
+ if (붙음 || typeof document === 'undefined')
13
+ return;
14
+ 붙음 = true;
15
+ document.addEventListener('keydown', (e) => {
16
+ if (e.metaKey || e.ctrlKey || e.altKey)
17
+ return;
18
+ 마지막 = 'keyboard';
19
+ }, true);
20
+ const 포인터 = () => { 마지막 = 'pointer'; };
21
+ document.addEventListener('pointerdown', 포인터, true);
22
+ document.addEventListener('mousedown', 포인터, true);
23
+ document.addEventListener('touchstart', 포인터, { capture: true, passive: true });
24
+ }
25
+ // 불러오는 즉시 듣는다 — 첫 누름부터 알아야 한다
26
+ 붙이기();
27
+ /** 마지막 입력이 키보드였으면 true(모르면 false) */
28
+ export function 키보드로눌렀나() {
29
+ 붙이기();
30
+ return 마지막 === 'keyboard';
31
+ }
@@ -7,5 +7,17 @@
7
7
  * @public
8
8
  */
9
9
  export declare const DocumentTitleSuppressContext: import("react").Context<boolean>;
10
+ /**
11
+ * 현재 화면의 제목을 `document.title`에 반영한다 — `'<제목> · <앱 이름>'`.
12
+ *
13
+ * SPA는 라우트를 옮겨도 문서 제목이 그대로라, 브라우저 탭·히스토리·북마크로 위치를
14
+ * 구분할 수 없고 화면낭독기도 이동을 인지하지 못한다(WCAG 2.4.2).
15
+ * 여러 화면이 함께 걸면 제목이 쌓여 가장 나중에 건 것이 보이고, 언마운트하면
16
+ * 남은 것 중 가장 나중에 건 제목으로(남은 것이 없으면 처음 제목으로) 되돌린다.
17
+ *
18
+ * @param title 페이지 제목. 비어 있거나 문자열이 아니면 아무것도 하지 않는다.
19
+ *
20
+ * @public
21
+ */
10
22
  export declare function useDocumentTitle(title?: string | null): void;
11
23
  export default useDocumentTitle;
@@ -23,22 +23,23 @@ const BASE_TITLE = (() => {
23
23
  * @public
24
24
  */
25
25
  export const DocumentTitleSuppressContext = createContext(false);
26
+ /* 제목을 건 화면들 — 나중에 건 것이 위. 떠날 때 **남은 맨 위 제목**으로 돌린다.
27
+ 전에는 저마다 건 순간의 제목(prev)을 기억했다가 되돌려, A 가 떠 있는 동안 B 가 붙고 A 가 나중에 떠나면
28
+ A 의 prev 로 돌아가 B 의 제목이 지워졌다(말귀 CustomersPage 가 겪고 우회해 둠). */
29
+ const 걸린제목 = [];
30
+ let 처음제목 = null;
26
31
  /**
27
32
  * 현재 화면의 제목을 `document.title`에 반영한다 — `'<제목> · <앱 이름>'`.
28
33
  *
29
34
  * SPA는 라우트를 옮겨도 문서 제목이 그대로라, 브라우저 탭·히스토리·북마크로 위치를
30
35
  * 구분할 수 없고 화면낭독기도 이동을 인지하지 못한다(WCAG 2.4.2).
31
- * 언마운트하면 이전 제목으로 되돌린다.
36
+ * 여러 화면이 함께 걸면 제목이 쌓여 가장 나중에 건 것이 보이고, 언마운트하면
37
+ * 남은 것 중 가장 나중에 건 제목으로(남은 것이 없으면 처음 제목으로) 되돌린다.
32
38
  *
33
39
  * @param title 페이지 제목. 비어 있거나 문자열이 아니면 아무것도 하지 않는다.
34
40
  *
35
41
  * @public
36
42
  */
37
- /* 제목을 건 화면들 — 나중에 건 것이 위. 떠날 때 **남은 맨 위 제목**으로 돌린다.
38
- 전에는 저마다 건 순간의 제목(prev)을 기억했다가 되돌려, A 가 떠 있는 동안 B 가 붙고 A 가 나중에 떠나면
39
- A 의 prev 로 돌아가 B 의 제목이 지워졌다(말귀 CustomersPage 가 겪고 우회해 둠). */
40
- const 걸린제목 = [];
41
- let 처음제목 = null;
42
43
  export function useDocumentTitle(title) {
43
44
  const suppressed = useContext(DocumentTitleSuppressContext);
44
45
  useEffect(() => {
@@ -27,9 +27,9 @@ export function useEditableList(emptyItem, initialList = []) {
27
27
  // 값만 캡처하므로 이 effect가 없으면 이후 prop 변경이 화면에 전혀 반영되지 않는다.
28
28
  // 단, 사용자가 이 목록의 항목을 수정 중(isEditing)일 때 리셋하면 아직 list에 반영되지
29
29
  // 않은 진행 중인 편집(editForm)이 저장 시 조용히 무시될 수 있으므로 그 사이엔 건너뛴다.
30
- // isEditing은 ref로만 참조해 effect가 initialList 변경 시에만 실행되게 한다 — 편집을
31
- // 끝내는 순간(isEditing만 바뀌는 렌더)에는 재실행되지 않아, 방금 커밋된 편집 결과가
32
- // 곧바로 stale한 initialList로 덮어써지는 것도 막는다.
30
+ // effect 는 initialList 와 isEditing 둘 다에 걸려 있어 편집이 끝나는 렌더에서도 돈다 —
31
+ // 취소(cancelEditing)로 끝났으면 그 사이 건너뛴 목록을 받고, commitEdit 으로 끝났으면
32
+ // 방금 커밋한 편집을 옛 initialList 로 덮지 않도록 한 번 건너뛴다(아래 커밋으로끝남Ref).
33
33
  //
34
34
  // 비교는 참조가 아니라 **내용**으로 한다. 호출부가 배열을 인라인으로 넘기면
35
35
  // (useEditableList(EMPTY, [a, b]) — 가장 자연스러운 호출 방식이고, 인자를 생략해
@@ -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
  /**
@@ -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);