@ihabdevteam/core 0.142.0 → 0.144.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 (79) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/components.cjs.js +185 -180
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +191 -186
  6. package/dist/ihabdevteam-core.d.ts +66 -22
  7. package/dist/packages/core/src/components/Badge.js +3 -1
  8. package/dist/packages/core/src/components/BarChart.d.ts +1 -1
  9. package/dist/packages/core/src/components/BarChart.js +4 -3
  10. package/dist/packages/core/src/components/BottomNav.js +4 -0
  11. package/dist/packages/core/src/components/BulkBar.js +1 -1
  12. package/dist/packages/core/src/components/Button.js +12 -5
  13. package/dist/packages/core/src/components/Calendar.js +9 -6
  14. package/dist/packages/core/src/components/DatePicker.js +9 -4
  15. package/dist/packages/core/src/components/Dialog.js +3 -1
  16. package/dist/packages/core/src/components/Dropdown.d.ts +3 -1
  17. package/dist/packages/core/src/components/Dropdown.js +10 -5
  18. package/dist/packages/core/src/components/FileUpload.js +3 -1
  19. package/dist/packages/core/src/components/FilterPanel.js +3 -2
  20. package/dist/packages/core/src/components/Loader.js +6 -1
  21. package/dist/packages/core/src/components/Pagination.js +1 -1
  22. package/dist/packages/core/src/components/PanelRow.js +13 -4
  23. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -1
  24. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +5 -2
  25. package/dist/packages/core/src/components/Popup/Popup.d.ts +1 -1
  26. package/dist/packages/core/src/components/Popup/Popup.js +26 -6
  27. package/dist/packages/core/src/components/ProgressBar.js +1 -1
  28. package/dist/packages/core/src/components/SideNav.js +1 -1
  29. package/dist/packages/core/src/components/Slider.js +10 -1
  30. package/dist/packages/core/src/components/Table.js +2 -0
  31. package/dist/packages/core/src/components/TextField.js +3 -1
  32. package/dist/packages/core/src/components/Toolbar.js +3 -1
  33. package/dist/packages/core/src/components/Tooltip/Tooltip.js +7 -2
  34. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +1 -1
  35. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +3 -1
  36. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +49 -2
  37. package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -1
  38. package/dist/packages/core/src/components/Training/useAudioPreview.js +28 -3
  39. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +19 -5
  40. package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
  41. package/dist/packages/core/src/components/Word/WordTableButton.d.ts +3 -3
  42. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  43. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +1 -1
  44. package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +2 -2
  45. package/dist/packages/core/src/components/Word/wordTablePrint.js +27 -8
  46. package/dist/packages/core/src/components/barChartData.d.ts +3 -5
  47. package/dist/packages/core/src/components/barChartData.js +7 -3
  48. package/dist/packages/core/src/components/barChartTypes.d.ts +16 -3
  49. package/dist/packages/core/src/components/barChartTypes.js +27 -13
  50. package/dist/packages/core/src/components/useCalendarMonth.js +3 -2
  51. package/dist/packages/core/src/components/useToastLifecycle.js +5 -0
  52. package/dist/packages/core/src/components.d.ts +1 -0
  53. package/dist/packages/core/src/hooks/focusReturn.d.ts +4 -0
  54. package/dist/packages/core/src/hooks/focusReturn.js +27 -0
  55. package/dist/packages/core/src/hooks/useVisitHistory.d.ts +2 -0
  56. package/dist/packages/core/src/hooks/useVisitHistory.js +5 -6
  57. package/dist/packages/core/src/hooks/useWordMeanings.js +6 -3
  58. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +2 -2
  59. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +57 -17
  60. package/dist/packages/core/src/utils/date.d.ts +18 -0
  61. package/dist/packages/core/src/utils/date.js +23 -0
  62. package/dist/packages/core/src/utils/format.js +2 -1
  63. package/dist/packages/core/src/utils/imageConfig.d.ts +2 -0
  64. package/dist/packages/core/src/utils/imageConfig.js +34 -5
  65. package/dist/packages/core/src/utils/josa.js +2 -0
  66. package/dist/packages/core/src/utils/koreanSyllable.d.ts +5 -0
  67. package/dist/packages/core/src/utils/koreanSyllable.js +69 -43
  68. package/dist/packages/core/src/utils/phonemePairSearch.js +17 -0
  69. package/dist/packages/core/src/utils/timeoutSignal.d.ts +5 -0
  70. package/dist/packages/core/src/utils/timeoutSignal.js +12 -0
  71. package/dist/packages/core/src/utils/ttsConfig.js +2 -1
  72. package/dist/packages/core/src/utils/wordContrast.js +6 -4
  73. package/dist/packages/core/src/utils/wordTrainingPool.js +14 -5
  74. package/dist/packages/core/src/utils.d.ts +1 -1
  75. package/dist/packages/core/src/utils.js +1 -1
  76. package/dist/packages/core/src/version.d.ts +1 -1
  77. package/dist/packages/core/src/version.js +1 -1
  78. package/dist/utils.cjs.js +2 -2
  79. package/package.json +1 -1
@@ -11,7 +11,8 @@ import Dropdown from '../Dropdown';
11
11
  import Pagination from '../Pagination';
12
12
  import WordImageTooltip from '../Tooltip/WordImageTooltip';
13
13
  import { useHoverAnchor } from '../../hooks/useHoverAnchor';
14
- import { matchesWordQuery } from '../../utils/koreanSyllable';
14
+ import { makeWordQueryMatcher } from '../../utils/koreanSyllable';
15
+ const 가나다 = new Intl.Collator('ko');
15
16
  import { withIga, withEulReul } from '../../utils/josa';
16
17
  /** @public */
17
18
  export default function WordSearchResultBlock({ titleIcon, resultCount, resultUnit, searchQuery, items, groups, selected = [], onSelect, maxSelect, onClearSelection, onPrint, onPrintEmpty, className, emptyHint, sectionType = 'page', disc, notice, }) {
@@ -61,6 +62,21 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
61
62
  value: v, label: t('wordSearch.pageSize', { count: Number(v), defaultValue: `${v}개씩 보기` }),
62
63
  })), [t]);
63
64
  const firstWord = (it) => it.words[0];
65
+ /* '시작 우선' 순위는 검색어가 바뀔 때 판별기를 한 번 만들고 항목마다 한 번만 센다 — 비교할 때마다 검색어를 다시 펼치고
66
+ localeCompare 를 불러 결과가 바뀔 때 23k 개에 38ms 걸렸다. */
67
+ const 시작순위 = useMemo(() => {
68
+ const qLen = [...q].length;
69
+ const m = makeWordQueryMatcher(q);
70
+ const memo = new Map();
71
+ return (it) => {
72
+ let r = memo.get(it);
73
+ if (r === undefined) {
74
+ r = m((it.words[0]?.label ?? '').slice(0, qLen)) ? 0 : 1;
75
+ memo.set(it, r);
76
+ }
77
+ return r;
78
+ };
79
+ }, [q]);
64
80
  // 정렬 비교자 — flat/그룹 뷰가 공유한다.
65
81
  const sortCompare = useCallback((a, b) => {
66
82
  const label = (it) => firstWord(it)?.label ?? '';
@@ -70,9 +86,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
70
86
  // 시작 우선 — 검색어로 시작하는 단어를 위로. 앞 q 글자만 잘라 매칭하면 "첫 음절부터 일치"만
71
87
  // 남는다(초성 검색도 동일: 'ㄴ' → '나무'가 위). 동순위는 가나다.
72
88
  case 'match': {
73
- const qLen = [...q].length;
74
- const rank = (it) => matchesWordQuery(label(it).slice(0, qLen), q) ? 0 : 1;
75
- return rank(a) - rank(b) || label(a).localeCompare(label(b), 'ko');
89
+ return 시작순위(a) - 시작순위(b) || 가나다.compare(label(a), label(b));
76
90
  }
77
91
  case 'ganada': return label(a).localeCompare(label(b), 'ko');
78
92
  case 'ganada_desc': return label(b).localeCompare(label(a), 'ko');
@@ -82,7 +96,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
82
96
  case 'grade_desc': return grade(b) - grade(a) || label(a).localeCompare(label(b), 'ko');
83
97
  default: return 0;
84
98
  }
85
- }, [effectiveSortKey, q]);
99
+ }, [effectiveSortKey, 시작순위]);
86
100
  const sortedItems = useMemo(() => items.slice().sort(sortCompare), [items, sortCompare]);
87
101
  const pageSize = Number(pageSizeStr) || 10;
88
102
  // 그룹 뷰(대립쌍표 음소대립)도 flat과 동일하게 정렬·페이지네이션한다 — 그룹 순서를 유지하고 그룹 내부를
@@ -166,7 +166,7 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
166
166
  Promise.all(imagePairs.map(([w, h]) => checkImageExists(w, h))).then(results => {
167
167
  if (!cancelled)
168
168
  setImageFound(results.some(Boolean));
169
- });
169
+ }, () => { }); // 확인이 거절돼도(리졸버 예외 등) 처리되지 않은 거절로 새지 않게 — 이미지 없음으로 둔다
170
170
  return () => { cancelled = true; };
171
171
  }, [imagePairs]);
172
172
  const hasImage = imagePairs.length > 0 && imageFound;
@@ -288,7 +288,10 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
288
288
  setActiveCell(null);
289
289
  },
290
290
  onClose: handleCloseWordSearch,
291
- }), !hideControls && (_jsx(TableFilterPanel, { hasImage: hasImage, hasColloquial: hasColloquial, contentTypes: contentTypes, orientation: orientation, showOutline: showOutline, cutLine: cutLine, showDivLine: showDivLine, setters: { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine }, showCutLine: (pagesEnabled ? itemsPerPage : count) > 1, showDivLine2: hasPairs, middleSections: middleSections, extraSelected: middleSelected, onExtraSelect: handleMiddleSelect })), isPaginated && pages ? (_jsx("div", { className: "word-table-block__pages", children: pages.map((page, i) => (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: itemsPerPage, items: page, onRemoveItem: onRemoveItem, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? cells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined, showFillerAdd: !!onAddItem, contentRef: contentRef })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
291
+ }), !hideControls && (_jsx(TableFilterPanel, { hasImage: hasImage, hasColloquial: hasColloquial, contentTypes: contentTypes, orientation: orientation, showOutline: showOutline, cutLine: cutLine, showDivLine: showDivLine, setters: { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine }, showCutLine: (pagesEnabled ? itemsPerPage : count) > 1, showDivLine2: hasPairs, middleSections: middleSections, extraSelected: middleSelected, onExtraSelect: handleMiddleSelect })), isPaginated && pages ? (_jsx("div", { className: "word-table-block__pages", children: pages.map((page, i) => (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: itemsPerPage, items: page, onRemoveItem: onRemoveItem,
292
+ /* 쪽이 나뉘면 끌어 옮기기가 통째로 꺼졌다(말귀 단어 찾기: 9개부터). 쪽 안의 자리를 전체 자리로 옮겨 넘긴다 —
293
+ 쪽을 넘는 이동은 하지 않는다. */
294
+ onReorder: onReorder && ((from, to) => onReorder(from + i * itemsPerPage, to + i * itemsPerPage)), contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? cells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined, showFillerAdd: !!onAddItem, contentRef: contentRef })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
292
295
  /* PanelRow의 라벨은 시각적으로만 붙어 있고 입력과 연결돼 있지 않아, 낭독기에는
293
296
  이름 없는 입력으로 읽혔다. placeholder는 자동완성 안내문("Tab 또는 space…")이
294
297
  섞여 있어 이름으로 부적절하므로 같은 라벨 문구를 넘긴다. */
@@ -3,13 +3,13 @@ import type { WordSearchResultItem } from '../../types/wordSearch';
3
3
  import './WordTableButton.css';
4
4
  /** @public */
5
5
  export interface WordTableButtonProps {
6
- /** 단어표 내용(썸네일 미리보기 + 개수) */
6
+ /** 단어표 내용(썸네일 미리보기) */
7
7
  items: WordSearchResultItem[];
8
8
  /** 단어표 이름 */
9
9
  name: string;
10
10
  /** 설명 — 제목 아래 body-3로 표시(있을 때만) */
11
11
  description?: string;
12
- /** 생성일(ISO) — 메타(날짜)에 표시. 없으면 개수만. */
12
+ /** 생성일(ISO) — 메타(날짜)에 표시. */
13
13
  createdAt?: string;
14
14
  /** 스토어 발행 배지 표시 */
15
15
  published?: boolean;
@@ -43,6 +43,6 @@ export interface WordTableButtonProps {
43
43
  }
44
44
  /**
45
45
  * @public 단어표를 '문서형' 미리보기 카드로 표현하는 컴포넌트.
46
- * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜·개수 + (발행 배지 / 더보기 / 스토어 푸터).
46
+ * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜 + (발행 배지 / 더보기 / 스토어 푸터).
47
47
  */
48
48
  export default function WordTableButton({ items, name, description, createdAt, published, publishedLabel, shared, sharedLabel, isNew, publisherName, publishedAt, likeCount, downloadCount, onPublisherClick, selected, onClick, onMore, titleNode, className, }: WordTableButtonProps): import("react").JSX.Element;
@@ -9,7 +9,7 @@ import { formatRelativeTime } from '../../utils/date';
9
9
  import './WordTableButton.css';
10
10
  /**
11
11
  * @public 단어표를 '문서형' 미리보기 카드로 표현하는 컴포넌트.
12
- * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜·개수 + (발행 배지 / 더보기 / 스토어 푸터).
12
+ * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜 + (발행 배지 / 더보기 / 스토어 푸터).
13
13
  */
14
14
  export default function WordTableButton({ items, name, description, createdAt, published, publishedLabel, shared, sharedLabel, isNew, publisherName, publishedAt, likeCount, downloadCount, onPublisherClick, selected, onClick, onMore, titleNode, className, }) {
15
15
  const { t } = useTranslation();
@@ -43,7 +43,7 @@ export function useWordTableDisplaySettings(items) {
43
43
  }
44
44
  Promise.all(items.flatMap(item => item.words.map(w => checkImageExists(w.label, w.homonum))))
45
45
  .then(results => { if (!cancelled)
46
- setHasImage(results.some(Boolean)); });
46
+ setHasImage(results.some(Boolean)); }, () => { }); // 거절은 이미지 없음으로 둔다
47
47
  return () => { cancelled = true; };
48
48
  }, [items]);
49
49
  /* eslint-enable react-hooks/set-state-in-effect */
@@ -23,7 +23,7 @@ export interface WordTableBlockProps {
23
23
  tableName?: string;
24
24
  /** 전달하면 외부 데이터 표시 모드, 없으면 편집 모드 */
25
25
  items?: WordSearchResultItem[];
26
- /** 전달하면 저장 팝업을 내부에서 열고 확인 후 name·items·description을 전달.
26
+ /** 전달하면 저장하기가 저장 팝업(`renderSavePopup` 이 그림 — 없으면 아무것도 뜨지 않는다)을 열고 확인 후 name·items·description을 전달.
27
27
  * saveAsNew=true면 '다른 이름으로 저장'(수정 대신 새 표 생성)을 뜻한다. */
28
28
  onSaveComplete?: (name: string, items: WordSearchResultItem[], description?: string, saveAsNew?: boolean) => void;
29
29
  onLoad?: () => void;
@@ -49,7 +49,7 @@ export interface WordTableBlockProps {
49
49
  editingName?: string;
50
50
  /** 지정하면 인쇄하기 왼쪽에 '취소' 버튼을 표시(PagePopup 닫기 등). */
51
51
  onCancel?: () => void;
52
- /** 전달하면 카드 드래그로 순서 변경 가능 */
52
+ /** 전달하면 카드 드래그로 순서 변경 가능. 쪽이 나뉘면 같은 쪽 안에서만 옮기며, 자리는 전체 목록 기준이다. */
53
53
  onReorder?: (fromIndex: number, toIndex: number) => void;
54
54
  /** display 모드에서도 칸 수 조절 허용 */
55
55
  showCellCount?: boolean;
@@ -19,6 +19,9 @@ export function sanitizeFileName(name) {
19
19
  }
20
20
  /** 아직 되돌리지 못한 탭 제목 되돌리기(인쇄가 끝나기 전 다음 인쇄가 올 때) */
21
21
  let 제목되돌리기 = null;
22
+ /* 진행 중인 인쇄 — 그림을 기다리는(최대 3초) 사이 다시 누르면 숨김 iframe 이 둘 생겨 인쇄 대화상자가 두 번 떴다(autoPrint 대기 중 '인쇄').
23
+ afterprint 가 안 오는 브라우저에서 영영 막히지 않게 10초가 지나면 새 인쇄를 받는다. */
24
+ let 인쇄중 = null;
22
25
  /**
23
26
  * 캡처한 콘텐츠 엘리먼트들을 화면 밖 숨김 iframe에서 인쇄한다. 전역 @media print(모든
24
27
  * .word-table 노출)나 팝업 차단의 간섭 없이 넘긴 엘리먼트만 격리 인쇄한다. WordTableBlock의
@@ -28,6 +31,8 @@ let 제목되돌리기 = null;
28
31
  export function printWordTableElements(elements, paperW, paperH, fileName = '', header = '') {
29
32
  if (elements.length === 0)
30
33
  return;
34
+ if (인쇄중 && 인쇄중.iframe.isConnected && Date.now() - 인쇄중.at < 10_000)
35
+ return;
31
36
  const safeFileName = fileName ? sanitizeFileName(fileName) : '';
32
37
  // "PDF로 저장" 기본 파일명은 iframe이 아니라 최상위 탭(document)의 title에서 온다.
33
38
  /* 탭 제목은 **어느 쪽 afterprint 가 와도** 되돌린다 — 브라우저에 따라 iframe 인쇄의 print 이벤트가 그 프레임에만 가서,
@@ -55,10 +60,15 @@ export function printWordTableElements(elements, paperW, paperH, fileName = '',
55
60
  iframe.remove();
56
61
  return;
57
62
  }
63
+ 인쇄중 = { iframe, at: Date.now() };
58
64
  doc.open();
59
65
  doc.write(captureHtml(elements, paperW, paperH, true, safeFileName, header));
60
66
  doc.close();
61
- iframe.contentWindow?.addEventListener('afterprint', () => { restore?.(); setTimeout(() => iframe.remove(), 0); });
67
+ iframe.contentWindow?.addEventListener('afterprint', () => {
68
+ restore?.();
69
+ setTimeout(() => { iframe.remove(); if (인쇄중?.iframe === iframe)
70
+ 인쇄중 = null; }, 0);
71
+ });
62
72
  }
63
73
  // 인쇄 시 PDF로 저장할 때 OS가 제안하는 기본 파일명은 문서 <title>에서 온다.
64
74
  export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '', header = '') {
@@ -71,11 +81,10 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
71
81
  return [];
72
82
  }
73
83
  }).join('\n');
74
- // styles.css의 다크 테마는 <html data-theme="..."> 속성으로 켜진다. 새 문서는 이 속성이
75
- // 없어 "auto"로 취급되고, OS가 다크 모드면 프로그램상 라이트 테마여도 팝업만 다크로 보였다.
76
- // 속성 값을 문서 글자에 그대로 끼우므로 **아는 세 값만** — 누가 data-theme 에 `"><script>` 를 넣어도 인쇄·새 창·내려받기 문서에 새지 않게
77
- const 읽은테마 = document.documentElement.getAttribute('data-theme');
78
- const theme = 읽은테마 === 'dark' || 읽은테마 === 'auto' ? 읽은테마 : 'light';
84
+ /* 종이는 늘 흰색(#fff)이라 문서 테마는 **light 로 고정**한다. 화면의 dark 를 옮기면 낱말 색(--color-black)이 밝은 색이 되어
85
+ 흰 종이에 흰 글자로 인쇄·새 창·내려받기가 나갔다. 속성이 없으면 "auto" 로 취급돼 OS 다크 모드에서 팝업만 어두워지던 것도
86
+ 이것으로 막힌다. 화면 테마 값은 문서 글자에 끼우지 않는다(주입 걱정도 없다). */
87
+ const theme = 'light';
79
88
  /* 종이 맨 위에 찍는 한 줄(누구의·언제·무슨 표). **격자만 인쇄되므로** 이것이 없으면
80
89
  뽑아 둔 채점지가 며칠 뒤 누구 것인지 알 수 없는 종이가 된다. 쪽마다 찍는다 —
81
90
  여러 장이 섞이면 첫 장에만 있는 이름은 소용이 없다. */
@@ -92,6 +101,11 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
92
101
  복사할 때 지우면 인쇄·새 창·내려받기가 한꺼번에 맞는다. */
93
102
  clone.querySelectorAll('.word-table-cell__score-slot:not(.word-table-cell__score-slot--on)')
94
103
  .forEach(s => s.remove());
104
+ /* 화면에서 아직 불러오는 중인 그림은 스피너 + 인라인 opacity:0 이다 — 그대로 옮기면 인쇄 문서에서 그림이 다 받아져도
105
+ 아무도 opacity 를 되돌리지 않아 **빈칸으로 인쇄**되고, 새 창·내려받은 HTML 에서는 스피너가 영영 돈다(말귀 내 단어표
106
+ '인쇄' 는 열자마자 100ms 뒤 인쇄해 처음 보는 표는 거의 늘 이랬다). */
107
+ clone.querySelectorAll('.word-table-cell__spinner').forEach(s => s.remove());
108
+ clone.querySelectorAll('img').forEach(i => i.style.removeProperty('opacity'));
95
109
  return `<div class="paper-wrap">${headHtml}${clone.outerHTML}</div>`;
96
110
  }).join('\n');
97
111
  /* 이 꾸러미의 CSS 는 전부 `.ihabdevteam-core-scope` **아래**에 산다(styles.css 스코핑).
@@ -144,11 +158,16 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
144
158
  .word-table-block__content 클론) 전부 숨겨진 채 인쇄된다. 여기서 복원한다. */
145
159
  body * { visibility: visible; }
146
160
  html, body { background: #fff; overflow: visible; padding: 0; gap: 0; }
161
+ /* 종이 한 장 = 한 쪽. height:100% 는 앱에 html·body 높이가 없으면 auto 로 풀려 종이가 내용만큼 커지고(2장 → 4쪽),
162
+ 세로·머리글이면 넘친 만큼 끝에 빈 쪽이 붙었다. 뷰포트 높이로 자르고 격자는 남은 칸만 쓴다. 마지막 종이는
163
+ body 의 마지막 자식이 아니라(닫기 단추·script) :last-child 가 안 맞았다 — :last-of-type. (Chromium PDF 로 7가지 확인) */
147
164
  .paper-wrap {
148
- box-shadow: none; border: none; transform: none !important; width: 100% !important; height: 100% !important;
165
+ box-shadow: none; border: none; transform: none !important; width: 100% !important; height: 100vh !important;
166
+ display: flex; flex-direction: column; overflow: hidden;
149
167
  page-break-after: always; break-after: page;
150
168
  }
151
- .paper-wrap:last-child { page-break-after: auto; break-after: auto; }
169
+ .paper-wrap > .word-table-block__content { flex: 1 1 0; min-height: 0; height: auto; }
170
+ .paper-wrap:last-of-type { page-break-after: auto; break-after: auto; }
152
171
  .word-table-cell__play-button { display: none; }
153
172
  .new-window-close { display: none; }
154
173
  }
@@ -1,7 +1,3 @@
1
- /**
2
- * barChartData.ts
3
- * BarChart의 순수 데이터 계층 — 날짜→월/주/일 막대 데이터 집계 (React 무관).
4
- */
5
1
  /** 막대 하나의 데이터 — a/b 두 계열. a === -1 이면 미래(데이터 없음)로 표시하지 않음.
6
2
  * parts: 다계열 stacked용 계열별 값(있으면 a는 합계로 사용). */
7
3
  export interface BarData {
@@ -19,7 +15,9 @@ export type MonthBarData = {
19
15
  weekly: BarData[];
20
16
  daily: BarData[];
21
17
  };
22
- /** 날짜별 카운트 포인트 — a/b 2계열 또는 parts(다계열 stacked, a는 합계) */
18
+ /** 날짜별 카운트 포인트 — a/b 2계열 또는 parts(다계열 stacked, a는 합계).
19
+ * `date` 는 **KST 달력 날짜를 기기 달력 자정 Date 로** 옮긴 것: 시각이면 `parseLocalYmd(toKstYmd(createdAt))`,
20
+ * 서버가 KST 로 집계한 'YYYY-MM-DD' 면 `parseLocalYmd(log_date)`. */
23
21
  export interface ChartPoint {
24
22
  date: Date;
25
23
  a: number;
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * barChartData.ts
3
3
  * BarChart의 순수 데이터 계층 — 날짜→월/주/일 막대 데이터 집계 (React 무관).
4
+ *
5
+ * **날은 KST 달력으로 센다.** 칸은 `getFullYear()/getMonth()/getDate()` 로 나누므로 '지금'도 KST 오늘을 기기 달력 Date 로
6
+ * 옮긴 것(`getTodayKstDate`)을 쓴다 — 전에는 기기 시각이라 해외 기기에서 오늘·이번 주·이번 달이 KST 와 하루 어긋났다.
4
7
  */
8
+ import { getTodayKstDate } from '../utils/date';
5
9
  /** 해당 월(0-based)의 첫 월요일 날짜(1~7). 예: 2026-05(금요일 시작) → 4 */
6
10
  export function firstMondayOfMonth(year, month0) {
7
11
  const dow = new Date(year, month0, 1).getDay(); // 0=일 … 6=토
@@ -10,7 +14,7 @@ export function firstMondayOfMonth(year, month0) {
10
14
  /** 오늘이 속한 (월키, 주값) 계산 — 진입 시 기본 선택을 오늘 기준으로 맞춤.
11
15
  * 월요일 시작 버킷 기준이며, 첫 월요일 이전 날짜는 이전 달 마지막 주에 속한다. */
12
16
  export function todayWeekValue() {
13
- const now = new Date();
17
+ const now = getTodayKstDate();
14
18
  let y = now.getFullYear(), m0 = now.getMonth(), date = now.getDate();
15
19
  if (date < firstMondayOfMonth(y, m0)) {
16
20
  const prevLast = new Date(y, m0, 0); // 이전 달 마지막 날
@@ -35,7 +39,7 @@ export function todayWeekValue() {
35
39
  * 다음 달의 오늘 뒤 날은 세지 않는다. 일별(`daily`)은 그 달 날만이다.
36
40
  */
37
41
  export function buildMonthChartData(points) {
38
- const now = new Date();
42
+ const now = getTodayKstDate();
39
43
  const curKey = `${now.getFullYear()}년 ${now.getMonth() + 1}월`;
40
44
  const 달키 = (y, m0) => `${y}년 ${m0 + 1}월`;
41
45
  const byMonth = new Map();
@@ -142,7 +146,7 @@ export function buildMonthChartData(points) {
142
146
  }
143
147
  // 드롭다운에 노출할 월 옵션 (2024년 1월 ~ 현재, 최신순)
144
148
  function genMonthOptions() {
145
- const now = new Date();
149
+ const now = getTodayKstDate();
146
150
  const curY = now.getFullYear();
147
151
  const curM = now.getMonth() + 1;
148
152
  const opts = [];
@@ -88,8 +88,21 @@ export interface BarChartProps {
88
88
  /** CSS 격자 칸 안에 세운다 — 아래 여백·최소 폭을 풀고 막대를 카드 바닥에 맞춘다. ChartSection `inGrid` 참고. */
89
89
  inGrid?: boolean;
90
90
  }
91
- /** 현재 컨트롤 선택(주/월)의 날짜 범위 — 상세보기(기간별 리스트)용 */
92
- export declare function getControlDateRange(control: BarChartControl): {
91
+ /** @public getControlDateRange 의 결과 — 기간의 첫날 00:00 KST ~ 끝날 23:59:59.999 KST 순간과, 그 두 날의 KST 달력 날짜 */
92
+ export interface ControlDateRange {
93
+ /** 첫날 00:00:00.000 KST 순간 — 서버에 물을 때 `from.toISOString()` */
93
94
  from: Date;
95
+ /** 끝날 23:59:59.999 KST 순간 */
94
96
  to: Date;
95
- };
97
+ /** 첫날 KST 'YYYY-MM-DD' — 이름표·달력 셈은 `parseLocalYmd(fromYmd)` 로(순간 `from` 을 기기 getter 로 읽으면 해외에서 하루 어긋난다) */
98
+ fromYmd: string;
99
+ /** 끝날 KST 'YYYY-MM-DD' */
100
+ toYmd: string;
101
+ }
102
+ /**
103
+ * 현재 컨트롤 선택(주/월)의 기간 — 상세보기(기간별 리스트)용. **KST 의 날로 자른다**(해외 기기도 같은 기간).
104
+ *
105
+ * @param offset 몇 기간 앞·뒤인가 — `-1` 이면 앞 달 한 달(달력 달 그대로, 30일 앞이 아니다) · 앞 주 한 주.
106
+ * @public
107
+ */
108
+ export declare function getControlDateRange(control: BarChartControl, offset?: number): ControlDateRange;
@@ -1,23 +1,37 @@
1
1
  import { firstMondayOfMonth } from './barChartData';
2
- /** 현재 컨트롤 선택(주/월)의 날짜 범위 — 상세보기(기간별 리스트)용 */
3
- export function getControlDateRange(control) {
2
+ import { getTodayKst, getTodayKstDate, kstStartOfDay } from '../utils/date';
3
+ const ymdOf = (y, m0, d) => {
4
+ const t = new Date(Date.UTC(y, m0, d));
5
+ return `${t.getUTCFullYear()}-${String(t.getUTCMonth() + 1).padStart(2, '0')}-${String(t.getUTCDate()).padStart(2, '0')}`;
6
+ };
7
+ const 범위 = (y, m0, 첫날, 날수) => ({
8
+ from: kstStartOfDay(y, m0, 첫날),
9
+ // 끝은 그날의 **마지막 밀리초까지** — 23:59:59.000 이면 마지막 999ms 에 남은 기록이 목록에서 빠졌다
10
+ to: new Date(kstStartOfDay(y, m0, 첫날 + 날수).getTime() - 1),
11
+ fromYmd: ymdOf(y, m0, 첫날),
12
+ toYmd: ymdOf(y, m0, 첫날 + 날수 - 1),
13
+ });
14
+ /**
15
+ * 현재 컨트롤 선택(주/월)의 기간 — 상세보기(기간별 리스트)용. **KST 의 날로 자른다**(해외 기기도 같은 기간).
16
+ *
17
+ * @param offset 몇 기간 앞·뒤인가 — `-1` 이면 앞 달 한 달(달력 달 그대로, 30일 앞이 아니다) · 앞 주 한 주.
18
+ * @public
19
+ */
20
+ export function getControlDateRange(control, offset = 0) {
4
21
  if (control.period === 'month') {
5
22
  const m = control.monthRange.match(/^(\d+)년 (\d+)월$/);
6
- const now = new Date();
7
- const y = m ? Number(m[1]) : now.getFullYear();
8
- const mo = m ? Number(m[2]) : now.getMonth() + 1;
9
- // 끝은 그날의 **마지막 밀리초까지** — 23:59:59.000 이면 마지막 999ms 에 남은 기록이 목록에서 빠졌다
10
- return { from: new Date(y, mo - 1, 1, 0, 0, 0), to: new Date(y, mo, 0, 23, 59, 59, 999) };
23
+ const today = getTodayKstDate();
24
+ const y = m ? Number(m[1]) : today.getFullYear();
25
+ const m0 = (m ? Number(m[2]) : today.getMonth() + 1) - 1 + offset;
26
+ const 날수 = new Date(Date.UTC(y, m0 + 1, 0)).getUTCDate();
27
+ return 범위(y, m0, 1, 날수);
11
28
  }
12
29
  const opt = control.weekRanges.find(o => o.value === control.weekRange);
13
30
  if (!opt) {
14
31
  const n = new Date();
15
- return { from: n, to: n };
32
+ const ymd = getTodayKst();
33
+ return { from: n, to: n, fromYmd: ymd, toYmd: ymd };
16
34
  }
17
35
  const firstMon = firstMondayOfMonth(opt.year, opt.month - 1);
18
- const monday = new Date(opt.year, opt.month - 1, firstMon + opt.week * 7, 0, 0, 0);
19
- const sunday = new Date(monday);
20
- sunday.setDate(monday.getDate() + 6);
21
- sunday.setHours(23, 59, 59, 999);
22
- return { from: monday, to: sunday };
36
+ return 범위(opt.year, opt.month - 1, firstMon + (opt.week + offset) * 7, 7);
23
37
  }
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { getTodayKst } from '../utils/date';
3
4
  /**
4
5
  * "현재 보고 있는 연/월" 상태 + 이전/다음 달 이동. DatePicker의 달력 네비게이션에서 쓴다.
5
6
  * @public
@@ -10,8 +11,8 @@ export function useCalendarMonth(value) {
10
11
  const [y, m] = value.split('-');
11
12
  return { y: +y, m: +m - 1 };
12
13
  }
13
- const today = new Date();
14
- return { y: today.getFullYear(), m: today.getMonth() };
14
+ const [y, m] = getTodayKst().split('-'); // 값이 없으면 KST 오늘의 달(해외 기기 공식 지원)
15
+ return { y: +y, m: +m - 1 };
15
16
  };
16
17
  const [viewY, setViewY] = useState(() => initYM().y);
17
18
  const [viewM, setViewM] = useState(() => initYM().m);
@@ -99,6 +99,11 @@ export function useToastLifecycle({ message, position, duration, onClose }) {
99
99
  clearTimeout(hideTimerRef.current);
100
100
  hideTimerRef.current = null;
101
101
  }
102
+ // 사라지는 중(hide 가 울리며 건 exit)도 — 남으면 떠난 뒤에 onClose 가 불렸다(아래 첫 등장 갈래의 정리와 같게)
103
+ if (exitTimerRef.current) {
104
+ clearTimeout(exitTimerRef.current);
105
+ exitTimerRef.current = null;
106
+ }
102
107
  };
103
108
  }
104
109
  // 처음 등장할 때만 슬라이드인 애니메이션 실행
@@ -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
+ }
@@ -11,6 +11,8 @@ export type VisitItem = {
11
11
  id?: string;
12
12
  };
13
13
  /**
14
+ * 오늘의 **KST** 연·월·일(두 자리 월·일). 방문 폼의 기본 날짜 — 소비자가 이 칸을 KST 벽시계로 읽어 저장하므로, 기기 달력이면
15
+ * 해외 기기에서 손대지 않고 등록한 방문이 하루 전으로 저장됐다(해외 기기 공식 지원).
14
16
  * @public
15
17
  */
16
18
  export declare const today: () => {
@@ -1,16 +1,15 @@
1
1
  'use client';
2
2
  import { useState } from 'react';
3
3
  import { useEditableList, isSameEditableItem } from './useEditableList';
4
+ import { getTodayKst } from '../utils/date';
4
5
  /**
6
+ * 오늘의 **KST** 연·월·일(두 자리 월·일). 방문 폼의 기본 날짜 — 소비자가 이 칸을 KST 벽시계로 읽어 저장하므로, 기기 달력이면
7
+ * 해외 기기에서 손대지 않고 등록한 방문이 하루 전으로 저장됐다(해외 기기 공식 지원).
5
8
  * @public
6
9
  */
7
10
  export const today = () => {
8
- const now = new Date();
9
- return {
10
- year: String(now.getFullYear()),
11
- month: String(now.getMonth() + 1).padStart(2, '0'),
12
- day: String(now.getDate()).padStart(2, '0'),
13
- };
11
+ const [year, month, day] = getTodayKst().split('-');
12
+ return { year, month, day };
14
13
  };
15
14
  const emptyVisitItem = { year: '', month: '', day: '', hour: '09', minute: '00', memo: '' };
16
15
  export function useVisitHistory(initialHistory = []) {
@@ -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
  /**