@ihabdevteam/core 0.132.0 → 0.134.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 (95) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +2 -2
  6. package/dist/ihabdevteam-core.cjs.js +182 -182
  7. package/dist/ihabdevteam-core.d.ts +8 -13
  8. package/dist/packages/core/src/components/BarChart.js +2 -2
  9. package/dist/packages/core/src/components/BottomNav.js +3 -3
  10. package/dist/packages/core/src/components/Breadcrumb.js +5 -1
  11. package/dist/packages/core/src/components/BulkBar.js +1 -1
  12. package/dist/packages/core/src/components/ChoiceChips.js +1 -1
  13. package/dist/packages/core/src/components/CodeBox.js +1 -1
  14. package/dist/packages/core/src/components/DatePicker.js +16 -3
  15. package/dist/packages/core/src/components/Dialog.js +32 -9
  16. package/dist/packages/core/src/components/Dropdown.js +13 -3
  17. package/dist/packages/core/src/components/FileUpload.js +8 -7
  18. package/dist/packages/core/src/components/FilterPanel.js +8 -5
  19. package/dist/packages/core/src/components/FilterSectionInfo.js +1 -1
  20. package/dist/packages/core/src/components/Header.css +2 -0
  21. package/dist/packages/core/src/components/Header.js +4 -1
  22. package/dist/packages/core/src/components/HearingDeviceInfo.js +1 -1
  23. package/dist/packages/core/src/components/LandingHeader.js +18 -1
  24. package/dist/packages/core/src/components/PageTitle.js +1 -1
  25. package/dist/packages/core/src/components/Pagination.js +6 -3
  26. package/dist/packages/core/src/components/Panel.d.ts +1 -1
  27. package/dist/packages/core/src/components/Panel.js +4 -1
  28. package/dist/packages/core/src/components/PanelRow.js +3 -3
  29. package/dist/packages/core/src/components/PassCard/passCardProduct.js +6 -0
  30. package/dist/packages/core/src/components/PaymentPlan.d.ts +6 -0
  31. package/dist/packages/core/src/components/PaymentPlan.js +6 -4
  32. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
  33. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +2 -2
  34. package/dist/packages/core/src/components/Popup/Popup.js +15 -3
  35. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +1 -1
  36. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +1 -1
  37. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +3 -3
  38. package/dist/packages/core/src/components/PromoBanner.js +1 -1
  39. package/dist/packages/core/src/components/SegmentedControl.js +1 -1
  40. package/dist/packages/core/src/components/SideNav.js +2 -2
  41. package/dist/packages/core/src/components/SoundCompass.js +10 -1
  42. package/dist/packages/core/src/components/Table.js +2 -2
  43. package/dist/packages/core/src/components/Tabs.js +7 -2
  44. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +8 -3
  45. package/dist/packages/core/src/components/Tooltip/TextBoxTooltip.js +1 -1
  46. package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -5
  47. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +1 -1
  48. package/dist/packages/core/src/components/Training/TrainingBlock.js +1 -1
  49. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +1 -1
  50. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
  51. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +51 -23
  52. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +1 -1
  53. package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +1 -1
  54. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +26 -5
  55. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +1 -1
  56. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -2
  57. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +1 -1
  58. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
  59. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +1 -1
  60. package/dist/packages/core/src/components/Training/TrainingWordCard.js +11 -6
  61. package/dist/packages/core/src/components/Training/useAudioPreview.js +7 -3
  62. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -1
  63. package/dist/packages/core/src/components/Training/useTTSPlayback.js +9 -0
  64. package/dist/packages/core/src/components/UpdateAlert.js +1 -1
  65. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +2 -2
  66. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +6 -5
  67. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
  68. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  69. package/dist/packages/core/src/components/WordTable.js +6 -6
  70. package/dist/packages/core/src/components/useToastLifecycle.js +8 -1
  71. package/dist/packages/core/src/hooks/useAnchoredMenu.js +10 -9
  72. package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +0 -11
  73. package/dist/packages/core/src/hooks/useDocumentTitle.js +16 -3
  74. package/dist/packages/core/src/hooks/useFocusTrap.js +6 -2
  75. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +9 -4
  76. package/dist/packages/core/src/hooks/wordTrainingFilterModel.d.ts +1 -1
  77. package/dist/packages/core/src/hooks/wordTrainingFilterModel.js +7 -2
  78. package/dist/packages/core/src/i18n/locales/en.json +11 -0
  79. package/dist/packages/core/src/i18n/locales/ko.json +11 -0
  80. package/dist/packages/core/src/utils/audioPeaks.js +13 -5
  81. package/dist/packages/core/src/utils/csv.js +3 -1
  82. package/dist/packages/core/src/utils/format.js +17 -1
  83. package/dist/packages/core/src/utils/koreanPronunciation.js +7 -0
  84. package/dist/packages/core/src/utils/koreanSyllable.js +2 -1
  85. package/dist/packages/core/src/utils/pressable.js +4 -0
  86. package/dist/packages/core/src/utils/savedWordTables.js +2 -1
  87. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  88. package/dist/packages/core/src/utils/textSort.js +7 -2
  89. package/dist/packages/core/src/utils/wordTrainingPool.d.ts +7 -0
  90. package/dist/packages/core/src/utils/wordTrainingPool.js +30 -0
  91. package/dist/packages/core/src/utils/wordTrainingRuntime.js +55 -17
  92. package/dist/packages/core/src/version.d.ts +1 -1
  93. package/dist/packages/core/src/version.js +1 -1
  94. package/dist/utils.cjs.js +2 -2
  95. package/package.json +1 -1
@@ -105,9 +105,10 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
105
105
  }
106
106
  return [...byLabel.entries()].map(([label, its]) => ({ label, items: its }));
107
107
  }, [groups, orderedGroupItems, pageClamped, pageSize]);
108
- // 정렬/페이지크기 변경·결과 개수 변동 시 1페이지로.
108
+ // 정렬/페이지크기 변경·결과 개수 변동·**검색어 변경** 시 1페이지로. 검색어를 안 보면 결과 수가 같은 다른 검색에서
109
+ // 보던 쪽 번호(3쪽)가 남아 새 결과의 3쪽이 떴다.
109
110
  /* eslint-disable react-hooks/set-state-in-effect */
110
- useEffect(() => { setPage(1); }, [sortKey, pageSizeStr, totalCount]);
111
+ useEffect(() => { setPage(1); }, [sortKey, pageSizeStr, totalCount, searchQuery]);
111
112
  /* eslint-enable react-hooks/set-state-in-effect */
112
113
  // 결과가 있으면(flat/그룹 모두) 툴바(정렬·페이지크기·페이지네이션) 노출.
113
114
  // (그룹 뷰도 flat과 동일하게 페이지네이션 — 카드 4 결정 1안. 그룹은 페이지 단위로 재그룹핑되어 표시.)
@@ -135,7 +136,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
135
136
  return (_jsx("div", { onMouseEnter: e => {
136
137
  openHover(e.currentTarget);
137
138
  setHoverWords(item.words);
138
- }, onMouseLeave: handleHoverLeave, children: _jsx(Button, { size: "word", align: "grid", label: item.words.map(w => w.label), subLabel: item.words.map(w => w.subLabel).filter(Boolean).length > 0
139
+ }, onMouseLeave: handleHoverLeave, children: _jsx(Button, { htmlType: "button", size: "word", align: "grid", label: item.words.map(w => w.label), subLabel: item.words.map(w => w.subLabel).filter(Boolean).length > 0
139
140
  ? item.words.map(w => w.subLabel)
140
141
  : undefined, selected: isSelected, onClick: () => handleClick(item.id), minWidth: 200 }) }, item.id));
141
142
  }) }) }, group.label)))) : (_jsx("div", { className: "word-search-result-block__items", children: pagedItems.map((item) => {
@@ -143,8 +144,8 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
143
144
  return (_jsx("div", { onMouseEnter: e => {
144
145
  openHover(e.currentTarget);
145
146
  setHoverWords(item.words);
146
- }, onMouseLeave: handleHoverLeave, children: _jsx(Button, { size: "word", align: "grid", label: item.words.map(w => w.label), subLabel: item.words.map(w => w.subLabel).filter(Boolean).length > 0
147
+ }, onMouseLeave: handleHoverLeave, children: _jsx(Button, { htmlType: "button", size: "word", align: "grid", label: item.words.map(w => w.label), subLabel: item.words.map(w => w.subLabel).filter(Boolean).length > 0
147
148
  ? item.words.map(w => w.subLabel)
148
149
  : undefined, selected: isSelected, onClick: () => handleClick(item.id), minWidth: 200 }) }, item.id));
149
- }) })), hoverWords && hoverAnchorRect && (_jsx(WordImageTooltip, { words: hoverWords, anchorRect: hoverAnchorRect }))] }), totalPages > 1 && (_jsx("div", { className: "word-search-result-block__pagination-bottom", children: _jsx(Pagination, { page: pageClamped, totalPages: totalPages, onChange: setPage }) })), (onClearSelection || onPrint) && (_jsxs("div", { className: "word-search-result-block__actions", children: [onClearSelection && (_jsx(Button, { size: "56", type: "bar", label: t('common.resetSelection', '선택 초기화'), selected: false, disabled: selected.length === 0, onClick: selected.length === 0 ? undefined : onClearSelection })), onPrint && (_jsx("div", { onClick: selected.length === 0 ? onPrintEmpty : undefined, children: _jsx(Button, { size: "56", type: "bar", variant: "main", label: t('wordTable.title', '단어 표 만들기'), disabled: selected.length === 0, onClick: selected.length === 0 ? undefined : onPrint }) }))] }))] }));
150
+ }) })), hoverWords && hoverAnchorRect && (_jsx(WordImageTooltip, { words: hoverWords, anchorRect: hoverAnchorRect }))] }), totalPages > 1 && (_jsx("div", { className: "word-search-result-block__pagination-bottom", children: _jsx(Pagination, { page: pageClamped, totalPages: totalPages, onChange: setPage }) })), (onClearSelection || onPrint) && (_jsxs("div", { className: "word-search-result-block__actions", children: [onClearSelection && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('common.resetSelection', '선택 초기화'), selected: false, disabled: selected.length === 0, onClick: selected.length === 0 ? undefined : onClearSelection })), onPrint && (_jsx("div", { onClick: selected.length === 0 ? onPrintEmpty : undefined, children: _jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: t('wordTable.title', '단어 표 만들기'), disabled: selected.length === 0, onClick: selected.length === 0 ? undefined : onPrint }) }))] }))] }));
150
151
  }
@@ -259,7 +259,7 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
259
259
  }
260
260
  };
261
261
  const savePreview = (_jsx(WordTable, { items: saveItems, orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count }));
262
- return (_jsxs(_Fragment, { children: [_jsxs(Section, { type: "page", icon: titleIcon, title: title, disc: disc, className: cx('word-table-block', className), children: [showSaveAsPopup && (_jsx(Popup, { size: "small", title: secondarySaveButtonLabel ?? t('wordTable.saveAsNewButton', '다른 이름으로 저장'), onClose: () => setShowSaveAsPopup(false), footer: ({ closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx(Button, { size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation }), _jsx(Button, { size: 48, type: "bar", variant: "main", label: t('common.save', '저장'), disabled: !saveAsTrimmed || saveAsCollides, onClick: handleSaveAsConfirm })] })), children: _jsx(TextField, { label: t('wordTable.save.nameLabel', '이름'), value: saveAsName, onChange: setSaveAsName, placeholder: t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'), maxLength: 40, size: 48, fullWidth: true, autoFocus: true, onEnter: handleSaveAsConfirm, error: saveAsCollides ? t('wordTable.save.duplicateName', '이미 사용 중인 이름입니다.') : undefined }) })), showSavePopup && onSaveComplete && renderSavePopup?.({
262
+ return (_jsxs(_Fragment, { children: [_jsxs(Section, { type: "page", icon: titleIcon, title: title, disc: disc, className: cx('word-table-block', className), children: [showSaveAsPopup && (_jsx(Popup, { size: "small", title: secondarySaveButtonLabel ?? t('wordTable.saveAsNewButton', '다른 이름으로 저장'), onClose: () => setShowSaveAsPopup(false), footer: ({ closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: t('common.save', '저장'), disabled: !saveAsTrimmed || saveAsCollides, onClick: handleSaveAsConfirm })] })), children: _jsx(TextField, { label: t('wordTable.save.nameLabel', '이름'), value: saveAsName, onChange: setSaveAsName, placeholder: t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'), maxLength: 40, size: 48, fullWidth: true, autoFocus: true, onEnter: handleSaveAsConfirm, error: saveAsCollides ? t('wordTable.save.duplicateName', '이미 사용 중인 이름입니다.') : undefined }) })), showSavePopup && onSaveComplete && renderSavePopup?.({
263
263
  defaultName: typeof title === 'string' ? title : t('wordTable.defaultName', '단어 표'),
264
264
  preview: savePreview,
265
265
  previewW: width,
@@ -299,5 +299,5 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
299
299
  e.preventDefault();
300
300
  onDescriptionChange?.(descriptionPlaceholder ?? '');
301
301
  }
302
- } }) })] }))] }), !hideControls && (_jsxs("div", { className: "word-table-block__actions", children: [_jsxs("div", { className: "word-table-block__actions-left", children: [!saveOnly && onReset && (_jsx(Button, { size: 40, type: "bar", label: resolvedResetLabel, onClick: onReset })), !saveOnly && onLoad && (_jsx(Button, { size: 40, type: "bar", label: t('wordTable.load', '불러오기'), onClick: onLoad })), !saveOnly && _jsx(Button, { size: 40, type: "bar", label: t('wordTable.openNewWindow', '새 창으로 보기'), onClick: handleOpenNewWindow })] }), _jsxs("div", { className: "word-table-block__actions-right", children: [onCancel && _jsx(Button, { size: "56", type: "bar", label: t('common.cancel', '취소'), onClick: onCancel }), !saveOnly && _jsx(Button, { size: "56", type: "bar", label: t('wordTable.print', '인쇄하기'), onClick: handlePrint }), secondarySaveButtonLabel && (_jsx(Button, { size: "56", type: "bar", label: secondarySaveButtonLabel, onClick: () => inlineNameFields ? openSaveAsPopup() : handleSave(true), disabled: noItems, onDisabledClick: () => { showWarning(t('wordTable.emptyItems', '단어를 1개 이상 추가해주세요.')); } })), _jsx(Button, { size: "56", type: "bar", variant: "main", label: saveButtonLabel ?? t('wordTable.saveButton', '저장하기'), onClick: () => handleSave(false), disabled: mainSaveDisabled, onDisabledClick: handleMainDisabledClick })] })] })), _jsx(Toast, { message: toastMessage, type: toastType, onClose: clearToast })] }));
302
+ } }) })] }))] }), !hideControls && (_jsxs("div", { className: "word-table-block__actions", children: [_jsxs("div", { className: "word-table-block__actions-left", children: [!saveOnly && onReset && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", label: resolvedResetLabel, onClick: onReset })), !saveOnly && onLoad && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.load', '불러오기'), onClick: onLoad })), !saveOnly && _jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.openNewWindow', '새 창으로 보기'), onClick: handleOpenNewWindow })] }), _jsxs("div", { className: "word-table-block__actions-right", children: [onCancel && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('common.cancel', '취소'), onClick: onCancel }), !saveOnly && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordTable.print', '인쇄하기'), onClick: handlePrint }), secondarySaveButtonLabel && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: secondarySaveButtonLabel, onClick: () => inlineNameFields ? openSaveAsPopup() : handleSave(true), disabled: noItems, onDisabledClick: () => { showWarning(t('wordTable.emptyItems', '단어를 1개 이상 추가해주세요.')); } })), _jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: saveButtonLabel ?? t('wordTable.saveButton', '저장하기'), onClick: () => handleSave(false), disabled: mainSaveDisabled, onDisabledClick: handleMainDisabledClick })] })] })), _jsx(Toast, { message: toastMessage, type: toastType, onClose: clearToast })] }));
303
303
  }
@@ -21,7 +21,7 @@ export default function WordTableButton({ items, name, description, createdAt, p
21
21
  onClick?.();
22
22
  }
23
23
  };
24
- return (_jsxs("div", { className: cx('word-table-button', selected ? 'word-table-button--selected' : '', !hasStoreFooter ? 'word-table-button--no-footer' : '', className), role: "button", tabIndex: 0, "aria-label": name, onClick: renaming ? undefined : onClick, onKeyDown: handleKey, children: [isNew && _jsx("span", { className: "word-table-button__new-dot", "aria-label": t('myWordTable.newBadge', '새로 만든 표') }), _jsx(WordTableThumbnail, { items: items, className: "word-table-button__thumb" }), _jsxs("div", { className: "word-table-button__info", children: [_jsxs("div", { className: "word-table-button__info-text", children: [titleNode ?? _jsx("div", { className: "word-table-button__title h4", children: name }), description && _jsx("div", { className: "word-table-button__desc body-3", children: description })] }), onMore && !renaming && (_jsx(Button, { className: "word-table-button__more", size: 32, type: "dot", variant: "transparent-gray", useBorder: false, icon: "ellipsis",
24
+ return (_jsxs("div", { className: cx('word-table-button', selected ? 'word-table-button--selected' : '', !hasStoreFooter ? 'word-table-button--no-footer' : '', className), role: "button", tabIndex: 0, "aria-label": name, onClick: renaming ? undefined : onClick, onKeyDown: handleKey, children: [isNew && _jsx("span", { className: "word-table-button__new-dot", "aria-label": t('myWordTable.newBadge', '새로 만든 표') }), _jsx(WordTableThumbnail, { items: items, className: "word-table-button__thumb" }), _jsxs("div", { className: "word-table-button__info", children: [_jsxs("div", { className: "word-table-button__info-text", children: [titleNode ?? _jsx("div", { className: "word-table-button__title h4", children: name }), description && _jsx("div", { className: "word-table-button__desc body-3", children: description })] }), onMore && !renaming && (_jsx(Button, { htmlType: "button", className: "word-table-button__more", size: 32, type: "dot", variant: "transparent-gray", useBorder: false, icon: "ellipsis",
25
25
  /* 하드코딩 '더보기'였다(영어 화면에서도 한국어로 남았다). 목록에 표가
26
26
  여러 개면 이름이 같은 버튼이 여러 개이기도 해서 이름을 함께 넣는다. */
27
27
  ariaLabel: t('common.moreFor', { name }), onClick: (e) => { e.stopPropagation(); onMore(e); } }))] }), (createdAt || published || shared) && (_jsxs("div", { className: "word-table-button__meta-row", children: [_jsx("span", { className: "word-table-button__meta caption-1", children: createdAt ? formatRelativeTime(createdAt, t) : '' }), _jsxs("span", { className: "word-table-button__badges", children: [published && (_jsx(Badge, { className: "word-table-button__badge", size: "small", variant: "main", fill: "soft", iconLeft: "book-marked", label: publishedLabel ?? t('myWordTable.publishedBadge', '발행됨') })), shared && (_jsx(Badge, { className: "word-table-button__badge", size: "small", fill: "soft", iconLeft: "link", label: sharedLabel ?? t('myWordTable.sharedBadge', '공유 중') }))] })] })), hasStoreFooter && (_jsxs("div", { className: "word-table-button__footer", children: [_jsxs("span", { className: "word-table-button__store-by", children: [publisherName && (_jsx("button", { className: "training-card__publisher body-4", type: "button", onClick: (e) => { e.stopPropagation(); onPublisherClick?.(); }, children: publisherName })), publishedAt && _jsx("span", { className: "training-card__date body-4", children: formatRelativeTime(publishedAt, t) })] }), _jsxs("span", { className: "word-table-button__store-stats", children: [likeCount != null && _jsx(Badge, { size: "small", fill: "soft", variant: "like", iconLeft: "heart", label: String(likeCount) }), downloadCount != null && _jsx(Badge, { size: "small", fill: "soft", variant: "main", iconLeft: "download", label: String(downloadCount) })] })] }))] }));
@@ -119,10 +119,10 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
119
119
  obs.observe(el);
120
120
  return () => obs.disconnect();
121
121
  }, [refW, refH]);
122
- return (_jsxs("div", { ref: outerRef, className: "word-table-cell", children: [_jsxs("div", { className: "word-table-cell__inner", style: { transform: `scale(${scale})` }, children: [hasImage && (_jsxs("div", { className: "word-table-cell__image-wrap", children: [imgLoading && _jsx("div", { className: "word-table-cell__spinner" }), _jsx("img", { ref: imgRef, className: "word-table-cell__image", src: imageUrl, alt: label, draggable: false, style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })), showWord && _jsx("span", { className: "word-table-cell__word h0", children: label }), showPronunciation && pron && _jsxs("span", { className: "word-table-cell__sub h3", children: ["[", pron, "]"] }), showColloquial && colloquialLabel && _jsxs("span", { className: "word-table-cell__colloquial h5", children: ["[", colloquialLabel, "]"] })] }), _jsxs("div", { className: "word-table-cell__play-button", "data-audio-src": audioUrl, children: [_jsx("span", { "data-play-state": "play", style: { display: playing ? 'none' : undefined }, children: _jsx(Button, { size: buttonSize, type: "dot", icon: "play", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: t('common.play', '재생'), onClick: handlePlay }) }), _jsx("span", { "data-play-state": "pause", style: { display: playing ? undefined : 'none' }, children: _jsx(Button, { size: buttonSize, type: "dot", icon: "pause", useBorder: false, selected: true, showTooltipInfo: t('common.pause', '멈춤'), onClick: handlePlay }) })] }), wordInfo && (_jsxs("div", { ref: infoRef, className: "word-table-cell__info-button", onPointerEnter: handleInfoEnter, onPointerLeave: handleInfoLeave,
122
+ return (_jsxs("div", { ref: outerRef, className: "word-table-cell", children: [_jsxs("div", { className: "word-table-cell__inner", style: { transform: `scale(${scale})` }, children: [hasImage && (_jsxs("div", { className: "word-table-cell__image-wrap", children: [imgLoading && _jsx("div", { className: "word-table-cell__spinner" }), _jsx("img", { ref: imgRef, className: "word-table-cell__image", src: imageUrl, alt: label, draggable: false, style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })), showWord && _jsx("span", { className: "word-table-cell__word h0", children: label }), showPronunciation && pron && _jsxs("span", { className: "word-table-cell__sub h3", children: ["[", pron, "]"] }), showColloquial && colloquialLabel && _jsxs("span", { className: "word-table-cell__colloquial h5", children: ["[", colloquialLabel, "]"] })] }), _jsxs("div", { className: "word-table-cell__play-button", "data-audio-src": audioUrl, children: [_jsx("span", { "data-play-state": "play", style: { display: playing ? 'none' : undefined }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "play", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: t('common.play', '재생'), onClick: handlePlay }) }), _jsx("span", { "data-play-state": "pause", style: { display: playing ? undefined : 'none' }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "pause", useBorder: false, selected: true, showTooltipInfo: t('common.pause', '멈춤'), onClick: handlePlay }) })] }), wordInfo && (_jsxs("div", { ref: infoRef, className: "word-table-cell__info-button", onPointerEnter: handleInfoEnter, onPointerLeave: handleInfoLeave,
123
123
  // 이 div 자체는 포커스 대상이 아니지만 안의 Button이 포커스를 받으면
124
124
  // focusin이 여기로 올라온다 — 그래서 래퍼에 걸어도 동작한다.
125
- onFocus: handleInfoEnter, onBlur: handleInfoLeave, children: [_jsx(Button, { size: buttonSize, type: "dot", icon: "info", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: false,
125
+ onFocus: handleInfoEnter, onBlur: handleInfoLeave, children: [_jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "info", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: false,
126
126
  /* 아이콘만 있는 버튼이라 이름이 없으면 화면 낭독기가 "버튼"으로만 읽는다.
127
127
  한 표에 칸마다 하나씩 있어 열두 개가 다 "버튼"이 된다 — 어느 낱말의
128
128
  것인지 알 길이 없으므로 낱말을 이름에 넣는다. */
@@ -146,7 +146,7 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
146
146
  /* 세 단추를 늘 함께 그린다. 매기지 않은 칸은 통째로 숨어 있다가 마우스를 올리면
147
147
  나타나고, 매긴 칸은 **고른 것만** 남는다(CSS 가 나머지를 감춘다). 그래야 표를
148
148
  훑어 "어디를 어떻게 봤는지"가 한눈에 들어온다. */
149
- _jsx("div", { className: cx('word-table-cell__score', mark ? 'word-table-cell__score--marked' : ''), children: MARKS.map(m => (_jsx("span", { className: cx('word-table-cell__score-slot', mark === m ? 'word-table-cell__score-slot--on' : ''), children: _jsx(Button, { size: buttonSize, type: "dot", icon: MARK_ICON[m], variant: mark === m ? MARK_VARIANT[m] : 'default', selected: mark === m, useBorder: false, showTooltipInfo: false, ariaLabel: t(`wordTable.scoring.${m}`, { word: cellLabel, defaultValue: `${cellLabel} ${m}` }), onClick: (e) => { e.stopPropagation(); clickMark(cellIndex, m); } }) }, m))) }));
149
+ _jsx("div", { className: cx('word-table-cell__score', mark ? 'word-table-cell__score--marked' : ''), children: MARKS.map(m => (_jsx("span", { className: cx('word-table-cell__score-slot', mark === m ? 'word-table-cell__score-slot--on' : ''), children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: MARK_ICON[m], variant: mark === m ? MARK_VARIANT[m] : 'default', selected: mark === m, useBorder: false, showTooltipInfo: false, ariaLabel: t(`wordTable.scoring.${m}`, { word: cellLabel, defaultValue: `${cellLabel} ${m}` }), onClick: (e) => { e.stopPropagation(); clickMark(cellIndex, m); } }) }, m))) }));
150
150
  };
151
151
  const { t } = useTranslation();
152
152
  const isEdit = editCells !== undefined;
@@ -173,11 +173,11 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
173
173
  const counterScale = { transform: `scale(${1 / scale})`, transformOrigin: 'center center' };
174
174
  const grid = (_jsx("div", { ref: scalerRef, className: "word-table__scaler", style: { height: height * scale }, children: _jsx("div", { className: cx('word-table', className), style: { width, height, transform: `scale(${scale})`, transformOrigin: 'top left', marginLeft: offset }, children: _jsxs("div", { ref: contentRef, className: contentClass, style: { gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }, children: [isEdit ? (_jsx(_Fragment, { children: (editCells ?? []).map((cell, i) => (_jsx("div", { className: cx('word-table-block__edit-cell', cell ? 'word-table-block__edit-cell--filled' : 'word-table-block__edit-cell--empty', activeCell === i ? 'word-table-block__edit-cell--active' : ''), style: dotDashStyle(i, cols, rows, cutLine), children: cell ? (_jsxs(_Fragment, { children: [cell.map((e, wi) => (_jsxs(Fragment, { children: [wi > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: e.word, subLabel: e.pronunciation,
175
175
  // 편집 칸도 구어체 발음을 넘긴다 — 빠져 있어 '구어체 발음'을 켜도 편집 모드에서만 비었다.
176
- colloquialLabel: e.colloquialPronunciation, homonum: e.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize })] }, wi))), scoring && renderScore(i, cell.map(e => e.word).join(' / ')), onRemoveCell && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveCell(i) }) }))] })) : (onActivateCell && (_jsx("div", { style: counterScale, children: _jsx(Button, { size: "48", type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell(i) }) }))) }, i))) })) : (_jsxs(_Fragment, { children: [(items ?? []).map((item, index) => (_jsxs("div", { className: `word-table-block__card${onReorder ? ' word-table-block__card--draggable' : ''}${dragOver === index ? ' word-table-block__card--drag-over' : ''}`, style: dotDashStyle(index, cols, rows, cutLine), draggable: !!onReorder, onDragStart: onReorder ? () => { dragIndex.current = index; } : undefined, onDragOver: onReorder ? (e) => { e.preventDefault(); setDragOver(index); } : undefined, onDragLeave: onReorder ? () => setDragOver(null) : undefined, onDrop: onReorder ? (e) => { e.preventDefault(); if (dragIndex.current !== null && dragIndex.current !== index) {
176
+ colloquialLabel: e.colloquialPronunciation, homonum: e.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize })] }, wi))), scoring && renderScore(i, cell.map(e => e.word).join(' / ')), onRemoveCell && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveCell(i) }) }))] })) : (onActivateCell && (_jsx("div", { style: counterScale, children: _jsx(Button, { htmlType: "button", size: "48", type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell(i) }) }))) }, i))) })) : (_jsxs(_Fragment, { children: [(items ?? []).map((item, index) => (_jsxs("div", { className: `word-table-block__card${onReorder ? ' word-table-block__card--draggable' : ''}${dragOver === index ? ' word-table-block__card--drag-over' : ''}`, style: dotDashStyle(index, cols, rows, cutLine), draggable: !!onReorder, onDragStart: onReorder ? () => { dragIndex.current = index; } : undefined, onDragOver: onReorder ? (e) => { e.preventDefault(); setDragOver(index); } : undefined, onDragLeave: onReorder ? () => setDragOver(null) : undefined, onDrop: onReorder ? (e) => { e.preventDefault(); if (dragIndex.current !== null && dragIndex.current !== index) {
177
177
  onReorder(dragIndex.current, index);
178
- } ; dragIndex.current = null; setDragOver(null); } : undefined, onDragEnd: onReorder ? () => { dragIndex.current = null; setDragOver(null); } : undefined, children: [item.words.map((w, i) => (_jsxs(Fragment, { children: [i > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: w.label, subLabel: w.subLabel, colloquialLabel: w.colloquialLabel, homonum: w.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize, wordInfo: (w.grade || w.syllables != null || w.pos || w.wordType) ? { grade: w.grade, syllables: w.syllables, pos: w.pos, wordType: w.wordType } : undefined })] }, i))), scoring && renderScore(index, item.words.map(w => w.label).join(' / ')), onRemoveItem && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: item.words.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: item.words.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveItem(item.id) }) }))] }, item.id))), Array.from({ length: Math.max(0, cols * rows - (items?.length ?? 0)) }).map((_, i) => {
178
+ } ; dragIndex.current = null; setDragOver(null); } : undefined, onDragEnd: onReorder ? () => { dragIndex.current = null; setDragOver(null); } : undefined, children: [item.words.map((w, i) => (_jsxs(Fragment, { children: [i > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: w.label, subLabel: w.subLabel, colloquialLabel: w.colloquialLabel, homonum: w.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize, wordInfo: (w.grade || w.syllables != null || w.pos || w.wordType) ? { grade: w.grade, syllables: w.syllables, pos: w.pos, wordType: w.wordType } : undefined })] }, i))), scoring && renderScore(index, item.words.map(w => w.label).join(' / ')), onRemoveItem && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: item.words.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: item.words.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveItem(item.id) }) }))] }, item.id))), Array.from({ length: Math.max(0, cols * rows - (items?.length ?? 0)) }).map((_, i) => {
179
179
  const isWithinCount = (items?.length ?? 0) + i < cellCount;
180
- return (_jsx("div", { className: "word-table-block__card word-table-block__card--filler", children: showFillerAdd && onActivateCell && isWithinCount && (_jsx("div", { style: counterScale, children: _jsx(Button, { size: buttonSize, type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell((items?.length ?? 0) + i) }) })) }, `filler-${i}`));
180
+ return (_jsx("div", { className: "word-table-block__card word-table-block__card--filler", children: showFillerAdd && onActivateCell && isWithinCount && (_jsx("div", { style: counterScale, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell((items?.length ?? 0) + i) }) })) }, `filler-${i}`));
181
181
  })] })), pageNumber && pageNumberPosition !== 'none' && (_jsx("span", { className: `word-table__page-number body-3 word-table__page-number--${pageNumberPosition}`, children: t('wordTable.filter.pageIndicator', { current: pageNumber.current, total: pageNumber.total }) }))] }) }) }));
182
182
  if (title != null || titleIcon || disc) {
183
183
  return (_jsx(Section, { type: "page", icon: titleIcon, title: title, disc: disc, className: cx('word-table-block', className), children: grid }));
@@ -87,7 +87,14 @@ export function useToastLifecycle({ message, position, duration, onClose }) {
87
87
  사라짐걸기.current(DURATION_MS);
88
88
  }
89
89
  lastMessageRef.current = message;
90
- return () => { };
90
+ /* 여기서 건 사라짐 타이머도 떠날 때 걷는다 — 빈 정리 함수라 문구를 바꾼 뒤 언마운트하면 타이머가 살아
91
+ 떠난 뒤에 onClose 가 불렸다. */
92
+ return () => {
93
+ if (hideTimerRef.current) {
94
+ clearTimeout(hideTimerRef.current);
95
+ hideTimerRef.current = null;
96
+ }
97
+ };
91
98
  }
92
99
  // 처음 등장할 때만 슬라이드인 애니메이션 실행
93
100
  const animateIn = () => {
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { pushEsc } from './escStack';
3
4
  /**
4
5
  * 트리거 요소에 fixed 포지션으로 앵커링되는 메뉴/드롭다운/팝오버의 공용 로직.
5
6
  * 열림상태 + 위치 계산 + 바깥클릭/스크롤/리사이즈 처리를 담당하고, 렌더링(포탈 등)은
@@ -49,23 +50,23 @@ export function useAnchoredMenu({ computeStyle, onScroll = 'close', onResize = '
49
50
  };
50
51
  if (onResize !== 'ignore')
51
52
  window.addEventListener('resize', handleResize);
52
- // ESC로 닫기 — 이 훅을 쓰는 컴포넌트가 자체 키보드 핸들링(예: Dropdown의 화살표 탐색)을
53
- // 따로 두더라도 무해하게 중복 호출될 뿐이므로, 여기서 공통으로 처리해 DatePicker/BottomNav
54
- // 더보기 메뉴처럼 자체 ESC 처리가 없던 소비자도 함께 혜택을 받게 한다.
55
- const handleKeyDown = (e) => {
56
- if (e.key === 'Escape')
57
- setOpen(false);
58
- };
59
- document.addEventListener('keydown', handleKeyDown);
60
53
  return () => {
61
54
  document.removeEventListener('mousedown', handleOutsideClick);
62
55
  if (onScroll !== 'ignore')
63
56
  window.removeEventListener('scroll', handleScroll, true);
64
57
  if (onResize !== 'ignore')
65
58
  window.removeEventListener('resize', handleResize);
66
- document.removeEventListener('keydown', handleKeyDown);
67
59
  };
68
60
  }, [open, recalc, onScroll, onResize, ignoreOutsideClickSelector]);
61
+ /* ESC 로 닫기 — **공유 ESC 스택**에 올린다(Popup 과 같은 스택). 전에는 document 에서 따로 들어,
62
+ 팝업 안의 DatePicker 에서 Esc 를 누르면 달력과 팝업이 함께 닫혔고(스택은 이 리스너를 모른다),
63
+ 달력 안 연월 Dropdown 의 Esc 는 달력까지 닫았다. 스택은 맨 위 하나만 닫는다 — 나중에 연 메뉴가 위다.
64
+ DatePicker·BottomNav 더보기처럼 자체 ESC 처리가 없는 쓰임도 그대로 닫힌다. */
65
+ useEffect(() => {
66
+ if (!open)
67
+ return;
68
+ return pushEsc(() => setOpen(false));
69
+ }, [open]);
69
70
  const toggle = useCallback(() => setOpen(o => !o), []);
70
71
  const close = useCallback(() => setOpen(false), []);
71
72
  return { open, setOpen, toggle, close, style, triggerRef, menuRef };
@@ -7,16 +7,5 @@
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
- * @param title 페이지 제목. 비어 있거나 문자열이 아니면 아무것도 하지 않는다.
18
- *
19
- * @public
20
- */
21
10
  export declare function useDocumentTitle(title?: string | null): void;
22
11
  export default useDocumentTitle;
@@ -34,6 +34,11 @@ export const DocumentTitleSuppressContext = createContext(false);
34
34
  *
35
35
  * @public
36
36
  */
37
+ /* 제목을 건 화면들 — 나중에 건 것이 위. 떠날 때 **남은 맨 위 제목**으로 돌린다.
38
+ 전에는 저마다 건 순간의 제목(prev)을 기억했다가 되돌려, A 가 떠 있는 동안 B 가 붙고 A 가 나중에 떠나면
39
+ A 의 prev 로 돌아가 B 의 제목이 지워졌다(말귀 CustomersPage 가 겪고 우회해 둠). */
40
+ const 걸린제목 = [];
41
+ let 처음제목 = null;
37
42
  export function useDocumentTitle(title) {
38
43
  const suppressed = useContext(DocumentTitleSuppressContext);
39
44
  useEffect(() => {
@@ -41,9 +46,17 @@ export function useDocumentTitle(title) {
41
46
  return;
42
47
  if (typeof title !== 'string' || !title)
43
48
  return;
44
- const prev = document.title;
45
- document.title = BASE_TITLE ? `${title}${SEP}${BASE_TITLE}` : title;
46
- return () => { document.title = prev; };
49
+ if (걸린제목.length === 0)
50
+ 처음제목 = document.title;
51
+ const 칸 = { text: BASE_TITLE ? `${title}${SEP}${BASE_TITLE}` : title };
52
+ 걸린제목.push(칸);
53
+ document.title = 칸.text;
54
+ return () => {
55
+ const i = 걸린제목.lastIndexOf(칸);
56
+ if (i >= 0)
57
+ 걸린제목.splice(i, 1);
58
+ document.title = 걸린제목.length ? 걸린제목[걸린제목.length - 1].text : (처음제목 ?? document.title);
59
+ };
47
60
  }, [title, suppressed]);
48
61
  }
49
62
  export default useDocumentTitle;
@@ -37,11 +37,15 @@ export function useFocusTrap(panelRef, enabled = true) {
37
37
  }
38
38
  const first = focusables[0];
39
39
  const last = focusables[focusables.length - 1];
40
- if (e.shiftKey && document.activeElement === first) {
40
+ const active = document.activeElement;
41
+ /* 포커스가 **판 자체**(tabIndex=-1 — 본문 글자를 누르면 판이 받는다)에 있으면 첫 칸·끝 칸 어느 쪽도 아니라
42
+ 되감지 않아, Shift+Tab 한 번에 뒤 화면으로 나갔다. 판 자체면 Shift+Tab 은 끝 칸으로 보낸다. */
43
+ const 판자체 = active === panel;
44
+ if (e.shiftKey && (active === first || 판자체)) {
41
45
  e.preventDefault();
42
46
  last.focus();
43
47
  }
44
- else if (!e.shiftKey && document.activeElement === last) {
48
+ else if (!e.shiftKey && active === last) {
45
49
  e.preventDefault();
46
50
  first.focus();
47
51
  }
@@ -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
  }
@@ -1390,5 +1390,16 @@
1390
1390
  },
1391
1391
  "loader": {
1392
1392
  "loading": "Loading"
1393
+ },
1394
+ "soundCompass": {
1395
+ "state": {
1396
+ "active": "playing",
1397
+ "played": "heard",
1398
+ "correct": "correct",
1399
+ "wrong": "wrong"
1400
+ }
1401
+ },
1402
+ "breadcrumb": {
1403
+ "ariaLabel": "Breadcrumb"
1393
1404
  }
1394
1405
  }
@@ -1388,5 +1388,16 @@
1388
1388
  },
1389
1389
  "loader": {
1390
1390
  "loading": "불러오는 중…"
1391
+ },
1392
+ "soundCompass": {
1393
+ "state": {
1394
+ "active": "소리 나는 중",
1395
+ "played": "들음",
1396
+ "correct": "정답",
1397
+ "wrong": "오답"
1398
+ }
1399
+ },
1400
+ "breadcrumb": {
1401
+ "ariaLabel": "이동 경로"
1391
1402
  }
1392
1403
  }
@@ -14,9 +14,12 @@ import { getErrorMessage } from './errorMessage';
14
14
  // 컴포넌트 상태를 참조하지 않는 순수 계산이라 모듈 스코프로 분리 — 매 렌더 재생성되지 않는다.
15
15
  /** 내려받은 바이트를 디코딩해 봉우리를 센다 — 아래 `computePeaksFromUrl` 만 쓴다. */
16
16
  async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds) {
17
+ /* 컨텍스트는 **어느 길로 나가든** 닫는다 — 디코딩 실패(404·만료 주소의 HTML)·채널 없음에서 닫지 않아
18
+ 파형 하나 실패할 때마다 AudioContext 가 하나씩 남았다(브라우저 동시 한도에 닿으면 이후 파형이 모두 실패). */
19
+ let audioCtx = null;
17
20
  try {
18
21
  const AudioCtxConstructor = window.AudioContext || window.webkitAudioContext;
19
- const audioCtx = new AudioCtxConstructor();
22
+ audioCtx = new AudioCtxConstructor();
20
23
  const decoded = await audioCtx.decodeAudioData(arrayBuffer.slice(0));
21
24
  const channelData = decoded.numberOfChannels > 0 ? decoded.getChannelData(0) : null;
22
25
  if (!channelData)
@@ -37,21 +40,26 @@ async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds) {
37
40
  }
38
41
  return max;
39
42
  });
40
- try {
41
- audioCtx.close && audioCtx.close();
42
- }
43
- catch (e) { }
44
43
  // 절대 진폭(0~1, full-scale 기준) 반환 — 문장/소음이 같은 기준을 쓰게 해 dBFS 미터와 일치.
45
44
  return peaks;
46
45
  }
47
46
  catch (e) {
48
47
  return null;
49
48
  }
49
+ finally {
50
+ try {
51
+ void audioCtx?.close?.();
52
+ }
53
+ catch (e) { }
54
+ }
50
55
  }
51
56
  /** 주소에서 소리를 받아 파형 막대 높이 배열을 낸다. 실패하면 null. @public */
52
57
  export async function computePeaksFromUrl(url, bars, windowSeconds) {
53
58
  try {
54
59
  const resp = await fetch(url);
60
+ // 404·만료 주소는 소리가 아니다 — 디코딩까지 가지 않는다
61
+ if (resp.ok === false)
62
+ return null;
55
63
  const arrayBuffer = await resp.arrayBuffer();
56
64
  return await decodeAndComputePeaks(arrayBuffer, bars, windowSeconds);
57
65
  }
@@ -16,7 +16,9 @@
16
16
  */
17
17
  export function escapeCsvField(v) {
18
18
  let s = v == null ? '' : String(v);
19
- if (/^[=+\-@\t\r]/.test(s))
19
+ /* 숫자 값은 수식이 아니다 — 음수(환불 금액 등)에 탭을 붙이면 엑셀이 글자 칸으로 읽어 합계가 안 된다.
20
+ 글자 '-2+3' 은 그대로 막는다. */
21
+ if (!(typeof v === 'number' && Number.isFinite(v)) && /^[=+\-@\t\r]/.test(s))
20
22
  s = '\t' + s;
21
23
  return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
22
24
  }
@@ -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;
@@ -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) {
@@ -119,8 +119,9 @@ export function matchesWordQuery(word, query) {
119
119
  const isJung = (ch) => JUNGSEONG.includes(ch);
120
120
  // 각 글자 분류: 'cho'(자음/초성) | 'jung'(모음/중성) | 'syl'(완성 음절) | 'other'(그 외)
121
121
  const kinds = q.map(ch => (isCho(ch) ? 'cho' : isJung(ch) ? 'jung' : parseSyllable(ch) ? 'syl' : 'other'));
122
+ // 영문은 대소문자를 가리지 않는다 — 옆 matchesChoseongSearch 와 같게('apple' 로 'Apple' 을 찾는다)
122
123
  if (kinds.includes('other'))
123
- return word.includes(query);
124
+ return word.toLowerCase().includes(query.toLowerCase());
124
125
  const w = [...word];
125
126
  for (let i = 0; i + q.length <= w.length; i++) {
126
127
  let ok = true;
@@ -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]));
@@ -1,13 +1,3 @@
1
1
  /** 글자 둘을 한국어 차례로 견준다 */
2
2
  export declare function 한글순(a: string, b: string): number;
3
- /**
4
- * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 나머지는 글자로.**
5
- *
6
- * 표의 칸은 무엇이든 올 수 있어서 늘 글자로 견주면 **숫자 칸이 망가진다**
7
- * (`10` 이 `9` 앞에 선다). 그래서 둘 다 진짜 숫자일 때만 숫자로 견주고,
8
- * 그 밖에는 한국어 차례를 쓴다.
9
- *
10
- * 비어 있는 값(`null`·`undefined`)은 빈 글자로 본다 — 부르는 쪽이 이미
11
- * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
12
- */
13
3
  export declare function 칸순(a: unknown, b: unknown): number;
@@ -32,8 +32,13 @@ export function 한글순(a, b) {
32
32
  * 비어 있는 값(`null`·`undefined`)은 빈 글자로 본다 — 부르는 쪽이 이미
33
33
  * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
34
34
  */
35
+ const 숫자글자 = /^-?\d+(\.\d+)?$/;
35
36
  export function 칸순(a, b) {
36
- if (typeof a === 'number' && typeof b === 'number')
37
- return a - b;
37
+ /* NaN 은 숫자가 아닌 것으로 — a - b 가 NaN 이면 정렬 비교가 어긋나 줄이 뒤섞였다. 글자로 온 숫자('10'·'9')도
38
+ 둘 다 온전한 숫자면 숫자로 견준다(전에는 '10' 이 '9' 앞에 섰다). */
39
+ const 수 = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : typeof v === 'string' && 숫자글자.test(v.trim()) ? Number(v) : null);
40
+ const na = 수(a), nb = 수(b);
41
+ if (na !== null && nb !== null)
42
+ return na - nb;
38
43
  return 한글순(a == null ? '' : String(a), b == null ? '' : String(b));
39
44
  }
@@ -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') 필터 — 정답(후보) 단어에만 적용(구성 필터와 동일 원칙, 오답 풀은 무관)