@ihabdevteam/core 0.132.0 → 0.133.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 (71) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/components.cjs.js +178 -178
  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 +1 -12
  7. package/dist/packages/core/src/components/BarChart.js +2 -2
  8. package/dist/packages/core/src/components/BottomNav.js +3 -3
  9. package/dist/packages/core/src/components/BulkBar.js +1 -1
  10. package/dist/packages/core/src/components/ChoiceChips.js +1 -1
  11. package/dist/packages/core/src/components/CodeBox.js +1 -1
  12. package/dist/packages/core/src/components/DatePicker.js +16 -3
  13. package/dist/packages/core/src/components/Dropdown.js +13 -3
  14. package/dist/packages/core/src/components/FileUpload.js +8 -7
  15. package/dist/packages/core/src/components/FilterPanel.js +8 -5
  16. package/dist/packages/core/src/components/FilterSectionInfo.js +1 -1
  17. package/dist/packages/core/src/components/Header.js +1 -1
  18. package/dist/packages/core/src/components/HearingDeviceInfo.js +1 -1
  19. package/dist/packages/core/src/components/PageTitle.js +1 -1
  20. package/dist/packages/core/src/components/Pagination.js +1 -1
  21. package/dist/packages/core/src/components/PanelRow.js +3 -3
  22. package/dist/packages/core/src/components/PaymentPlan.js +1 -1
  23. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
  24. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +2 -2
  25. package/dist/packages/core/src/components/Popup/Popup.js +15 -3
  26. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +1 -1
  27. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +1 -1
  28. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +3 -3
  29. package/dist/packages/core/src/components/SegmentedControl.js +1 -1
  30. package/dist/packages/core/src/components/SideNav.js +1 -1
  31. package/dist/packages/core/src/components/Table.js +2 -2
  32. package/dist/packages/core/src/components/Tabs.js +7 -2
  33. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +1 -1
  34. package/dist/packages/core/src/components/Tooltip/TextBoxTooltip.js +1 -1
  35. package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -5
  36. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +1 -1
  37. package/dist/packages/core/src/components/Training/TrainingBlock.js +1 -1
  38. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +1 -1
  39. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
  40. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +51 -23
  41. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +1 -1
  42. package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +1 -1
  43. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +26 -5
  44. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +1 -1
  45. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -2
  46. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +1 -1
  47. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
  48. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +1 -1
  49. package/dist/packages/core/src/components/Training/TrainingWordCard.js +11 -6
  50. package/dist/packages/core/src/components/Training/useAudioPreview.js +7 -3
  51. package/dist/packages/core/src/components/UpdateAlert.js +1 -1
  52. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +2 -2
  53. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -3
  54. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
  55. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  56. package/dist/packages/core/src/components/WordTable.js +6 -6
  57. package/dist/packages/core/src/components/useToastLifecycle.js +8 -1
  58. package/dist/packages/core/src/hooks/useAnchoredMenu.js +10 -9
  59. package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +0 -11
  60. package/dist/packages/core/src/hooks/useDocumentTitle.js +16 -3
  61. package/dist/packages/core/src/hooks/useFocusTrap.js +6 -2
  62. package/dist/packages/core/src/utils/audioPeaks.js +13 -5
  63. package/dist/packages/core/src/utils/csv.js +3 -1
  64. package/dist/packages/core/src/utils/koreanSyllable.js +2 -1
  65. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  66. package/dist/packages/core/src/utils/textSort.js +7 -2
  67. package/dist/packages/core/src/utils/wordTrainingRuntime.js +28 -4
  68. package/dist/packages/core/src/version.d.ts +1 -1
  69. package/dist/packages/core/src/version.js +1 -1
  70. package/dist/utils.cjs.js +1 -1
  71. package/package.json +1 -1
@@ -36,7 +36,12 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
36
36
  const audioRef = useRef(null);
37
37
  const isMobile = useMediaQuery('(max-width: 767px)');
38
38
  const [playing, setPlaying] = useState(false);
39
- const [audioUrl, setAudioUrl] = useState('');
39
+ /* 준비한 URL 은 **어느 낱말의 것인지와 함께** 둔다. 카드는 번호 key 로 재사용되는데(TrainingStepCard),
40
+ 전에는 낱말이 바뀌어도 URL 을 비우지 않아 새 낱말의 리졸버가 기다리는 동안·실패했을 때 누르면
41
+ **이전 낱말 소리**가 났다 — 변별 훈련에서 틀린 자극이다. 지금 낱말의 것이 아니면 없는 것으로 본다. */
42
+ const audioFor = `${typeof rawAudioSrc === 'string' ? rawAudioSrc : rawAudioSrc?.src ?? ''}\u0000${label}`;
43
+ const [prepared, setPrepared] = useState({ for: '', url: '' });
44
+ const audioUrl = prepared.for === audioFor ? prepared.url : '';
40
45
  // ── 이미지 ── (앞면 유지값 기준 — 플립 동안 이미지도 유지)
41
46
  const imageUrl = getImageResolver()?.(front.label, front.homonum) ?? item.image ?? '';
42
47
  const { imgRef, loading: imgLoading, hasImage, onLoad: handleImgLoad, onError: handleImgError } = useWordImage(imageUrl);
@@ -48,14 +53,14 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
48
53
  if (rawAudioSrc) {
49
54
  if (typeof rawAudioSrc === 'string') {
50
55
  if (rawAudioSrc.startsWith('http') || rawAudioSrc.includes('/')) {
51
- setAudioUrl(rawAudioSrc);
56
+ setPrepared({ for: audioFor, url: rawAudioSrc });
52
57
  return;
53
58
  }
54
59
  }
55
60
  else if (typeof rawAudioSrc === 'object' && !Array.isArray(rawAudioSrc)) {
56
61
  const src = rawAudioSrc.src;
57
62
  if (src) {
58
- setAudioUrl(src);
63
+ setPrepared({ for: audioFor, url: src });
59
64
  return;
60
65
  }
61
66
  }
@@ -66,14 +71,14 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
66
71
  try {
67
72
  const url = await Promise.resolve(resolver(label));
68
73
  if (!cancelled && url)
69
- setAudioUrl(url);
74
+ setPrepared({ for: audioFor, url });
70
75
  }
71
76
  catch { }
72
77
  }
73
78
  };
74
79
  resolveAudio();
75
80
  return () => { cancelled = true; };
76
- }, [rawAudioSrc, label]);
81
+ }, [rawAudioSrc, label, audioFor]);
77
82
  // ── 언마운트 시 오디오 정리 ──
78
83
  useEffect(() => {
79
84
  return () => {
@@ -224,6 +229,6 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
224
229
  onSelect?.();
225
230
  }
226
231
  }
227
- : undefined, children: _jsxs("div", { className: "training-word-card__flipper", style: { transitionDelay: showBack ? '0ms' : `${flipDelay}ms` }, children: [_jsxs("div", { className: "training-word-card__front", children: [selected && (_jsx("div", { className: "training-word-card__selected-badge", onClick: handleStopProp, children: _jsx(Badge, { type: "dot", size: 40, position: "inline", animate: true, icon: orderRank != null ? undefined : 'check', number: orderRank ?? undefined, "aria-label": orderRank != null ? t('training.nthSelected', { n: orderRank }) : t('training.selected', '선택됨') }) })), _jsx("div", { className: "training-word-card__image-wrap", children: hasImage ? (_jsxs(_Fragment, { children: [imgLoading && _jsx("div", { className: "training-word-card__spinner" }), _jsx("img", { ref: imgRef, className: "training-word-card__image", src: imageUrl, alt: front.label || t('common.image', '이미지'), style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })) : (_jsx("div", { className: "training-word-card__placeholder" })) }), !hideLabel && (_jsxs("div", { className: "training-word-card__body", children: [_jsx("div", { className: "training-word-card__word", children: front.label }), front.subLabel && (_jsxs("div", { className: "training-word-card__pronunciation", children: ["[", front.subLabel, "]"] })), front.colloquialLabel && (_jsxs("div", { className: "training-word-card__colloquial caption-1", children: ["[", front.colloquialLabel, "]"] }))] })), !hideAudio && (_jsx("div", { className: "training-word-card__bottom", onClick: selectable ? handleStopProp : undefined, children: _jsx(Button, { size: isMobile ? 40 : 48, type: isMobile ? 'dot' : 'bar', icon: playing ? 'pause' : 'play', label: isMobile ? undefined : t('training.playSound', '소리 듣기'), selected: playing, onClick: handlePlay, disabled: disabled, ariaLabel: t('training.playSound', '소리 듣기') }) }))] }), _jsx("div", { className: "training-word-card__back", "aria-hidden": "true", children: _jsx("img", { src: symbolSrc, className: "training-word-card__disabled-symbol", alt: "", draggable: false }) })] }) }));
232
+ : undefined, children: _jsxs("div", { className: "training-word-card__flipper", style: { transitionDelay: showBack ? '0ms' : `${flipDelay}ms` }, children: [_jsxs("div", { className: "training-word-card__front", children: [selected && (_jsx("div", { className: "training-word-card__selected-badge", onClick: handleStopProp, children: _jsx(Badge, { type: "dot", size: 40, position: "inline", animate: true, icon: orderRank != null ? undefined : 'check', number: orderRank ?? undefined, "aria-label": orderRank != null ? t('training.nthSelected', { n: orderRank }) : t('training.selected', '선택됨') }) })), _jsx("div", { className: "training-word-card__image-wrap", children: hasImage ? (_jsxs(_Fragment, { children: [imgLoading && _jsx("div", { className: "training-word-card__spinner" }), _jsx("img", { ref: imgRef, className: "training-word-card__image", src: imageUrl, alt: front.label || t('common.image', '이미지'), style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })) : (_jsx("div", { className: "training-word-card__placeholder" })) }), !hideLabel && (_jsxs("div", { className: "training-word-card__body", children: [_jsx("div", { className: "training-word-card__word", children: front.label }), front.subLabel && (_jsxs("div", { className: "training-word-card__pronunciation", children: ["[", front.subLabel, "]"] })), front.colloquialLabel && (_jsxs("div", { className: "training-word-card__colloquial caption-1", children: ["[", front.colloquialLabel, "]"] }))] })), !hideAudio && (_jsx("div", { className: "training-word-card__bottom", onClick: selectable ? handleStopProp : undefined, children: _jsx(Button, { htmlType: "button", size: isMobile ? 40 : 48, type: isMobile ? 'dot' : 'bar', icon: playing ? 'pause' : 'play', label: isMobile ? undefined : t('training.playSound', '소리 듣기'), selected: playing, onClick: handlePlay, disabled: disabled, ariaLabel: t('training.playSound', '소리 듣기') }) }))] }), _jsx("div", { className: "training-word-card__back", "aria-hidden": "true", children: _jsx("img", { src: symbolSrc, className: "training-word-card__disabled-symbol", alt: "", draggable: false }) })] }) }));
228
233
  });
229
234
  export default TrainingWordCard;
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { resolveSoundPath, getSignedOrPublicUrl, downloadSound } from '../../utils/storage';
4
4
  function derivePreviewMeta(trainingData, typeKey) {
5
5
  if (!trainingData)
@@ -46,7 +46,9 @@ function derivePreviewMeta(trainingData, typeKey) {
46
46
  export function useAudioPreview(trainingData, typeKey) {
47
47
  const [playing, setPlaying] = useState(false);
48
48
  const [playingIndex, setPlayingIndex] = useState(-1);
49
- const [previewUrls, setPreviewUrls] = useState([]);
49
+ /* 받은 주소는 **어느 코드 묶음의 것인지와 함께** 둔다. 코드가 없는 훈련으로 바뀌면 아래 효과가 일찍 빠져나가
50
+ 이전 훈련 주소가 남았고, 새 주소를 받는 동안에도 이전 주소로 미리듣기가 났다. 지금 묶음의 것만 쓴다. */
51
+ const [resolved, setResolved] = useState({ key: '', urls: [] });
50
52
  const previewAudioRef = useRef(null);
51
53
  const playingRef = useRef(false);
52
54
  const previewBtnRef = useRef(null);
@@ -55,6 +57,8 @@ export function useAudioPreview(trainingData, typeKey) {
55
57
  // 우리가 만든 게 아니므로 revoke 대상이 아니다
56
58
  const ownedBlobUrlsRef = useRef([]);
57
59
  const { previewCodes, previewBucket } = derivePreviewMeta(trainingData, typeKey);
60
+ const previewKey = `${previewCodes.join(',')}|${previewBucket ?? ''}`;
61
+ const previewUrls = useMemo(() => (resolved.key === previewKey ? resolved.urls : []), [resolved, previewKey]);
58
62
  // Resolve audio URLs upfront when training data changes
59
63
  useEffect(() => {
60
64
  if (!previewCodes.length)
@@ -98,7 +102,7 @@ export function useAudioPreview(trainingData, typeKey) {
98
102
  }
99
103
  catch { } });
100
104
  ownedBlobUrlsRef.current = newBlobUrls;
101
- setPreviewUrls(resolvedUrls);
105
+ setResolved({ key: previewKey, urls: resolvedUrls });
102
106
  }
103
107
  else {
104
108
  // 이 effect가 취소된 사이 새로 만든 blob은 ref에 등록되지 못하므로 즉시 해제
@@ -21,5 +21,5 @@ import './UpdateAlert.css';
21
21
  export default function UpdateAlert({ title, message, actionLabel, onAction, icon, onClose, className = '', }) {
22
22
  const { t } = useTranslation();
23
23
  const resolvedActionLabel = actionLabel ?? t('common.update', '업데이트');
24
- return (_jsxs("div", { className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: onClose, "aria-label": t('common.close', '닫기'), children: "\u00D7" })), _jsxs("div", { className: "update-alert__top", children: [_jsx("div", { className: "update-alert__icon", children: icon ?? _jsx(Download, { size: 20, strokeWidth: 2 }) }), _jsxs("div", { className: "update-alert__body", children: [_jsx("div", { className: "update-alert__title h4", children: title }), _jsx("div", { className: "update-alert__message body-3", children: message })] })] }), onAction && (_jsx(Button, { size: 32, variant: "main", shape: "pill", className: "update-alert__action", onClick: onAction, children: resolvedActionLabel }))] }));
24
+ return (_jsxs("div", { className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: onClose, "aria-label": t('common.close', '닫기'), children: "\u00D7" })), _jsxs("div", { className: "update-alert__top", children: [_jsx("div", { className: "update-alert__icon", children: icon ?? _jsx(Download, { size: 20, strokeWidth: 2 }) }), _jsxs("div", { className: "update-alert__body", children: [_jsx("div", { className: "update-alert__title h4", children: title }), _jsx("div", { className: "update-alert__message body-3", children: message })] })] }), onAction && (_jsx(Button, { htmlType: "button", size: 32, variant: "main", shape: "pill", className: "update-alert__action", onClick: onAction, children: resolvedActionLabel }))] }));
25
25
  }
@@ -45,7 +45,7 @@ export default function PhonemeSelectBlock({ sections, selectedList, count, onCo
45
45
  const hasLabel = !!value && value !== '없음';
46
46
  return (_jsxs("span", { className: cx('phoneme-select-block__cell-jamo-part', value ? '' : 'phoneme-select-block__cell-jamo-part--empty', hasLabel ? '' : 'phoneme-select-block__cell-jamo-part--no-label'), children: [_jsx("span", { className: `phoneme-select-block__cell-jamo-char h1${value === '없음' ? ' phoneme-select-block__cell-jamo-char--excluded' : ''}`, children: value === '없음' ? 'x' : (value || placeholder) }), hasLabel && _jsx("span", { className: "phoneme-select-block__cell-jamo-label caption-2", children: displayLabel ?? label })] }, label));
47
47
  }), [sections, placeholder]);
48
- const infoButton = (_jsxs(_Fragment, { children: [_jsx("div", { "data-tooltip-ignore": true, children: _jsx(Button, { size: 40, type: isMobile ? 'dot' : undefined, icon: "info", label: isMobile ? undefined : t('wordPairs.caution', '유의사항'), selected: !!infoAnchorRect, onClick: (e) => toggleInfo(e.currentTarget) }) }), infoAnchorRect && (_jsx(DescriptionTooltip, { title: t('wordPairs.caution', '유의사항'), items: infoNotes, anchorRect: infoAnchorRect, onClose: handleCloseInfo }))] }));
48
+ const infoButton = (_jsxs(_Fragment, { children: [_jsx("div", { "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 40, type: isMobile ? 'dot' : undefined, icon: "info", label: isMobile ? undefined : t('wordPairs.caution', '유의사항'), selected: !!infoAnchorRect, onClick: (e) => toggleInfo(e.currentTarget) }) }), infoAnchorRect && (_jsx(DescriptionTooltip, { title: t('wordPairs.caution', '유의사항'), items: infoNotes, anchorRect: infoAnchorRect, onClose: handleCloseInfo }))] }));
49
49
  return (_jsxs(Section, { type: sectionType, title: title, icon: titleIcon, disc: disc, action: infoButton, className: cx('phoneme-select-block', className), children: [beforeContent, (!hideCountControl || (showInfoButton && !(title || disc))) && (_jsxs("div", { className: "phoneme-select-block__controls", children: [!hideCountControl && (_jsx(SegmentedControl, { options: countOptions, value: String(count), onChange: handleCountChange, size: 40, className: "phoneme-select-block__count-control" })), showInfoButton && !(title || disc) && infoButton] })), _jsx("div", { className: "phoneme-select-block__cells", children: pairMode
50
50
  ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "phoneme-select-block__cell-group", children: Array.from({ length: count }).map((_, pairIdx) => {
51
51
  const sel = selectedList[pairIdx * 2] ?? {};
@@ -66,6 +66,6 @@ export default function PhonemeSelectBlock({ sections, selectedList, count, onCo
66
66
  // 종성 '제외'(없음)만 선택된 슬롯은 실제 음소를 고른 것으로 치지 않는다 —
67
67
  // "받침 없음"은 단독으로는 검색 조건이 되기엔 너무 느슨한 보조 조건이라서다.
68
68
  const isEmpty = selectedList.every(sel => Object.entries(sel).every(([k, v]) => !v || (k === '종성' && v === '없음')));
69
- return (_jsxs("div", { className: "phoneme-select-block__search-row", onClick: isEmpty ? onSearchEmpty : undefined, children: [onClearPhoneme && (_jsx(Button, { size: "56", type: "bar", label: t('wordPairs.resetOptions', '검색 옵션 초기화'), selected: false, disabled: isEmpty, onClick: isEmpty ? undefined : onClearPhoneme })), searchChecking ? (_jsxs("div", { className: "phoneme-select-block__checking", children: [_jsx("div", { className: "phoneme-select-block__spinner" }), _jsx("span", { className: "button-1 phoneme-select-block__checking-label", children: searchLabel ?? t('wordSearch.searchButton', '검색하기') })] })) : onSearch && (_jsx(Button, { size: "56", type: "bar", variant: "main", label: searchLabel ?? t('wordSearch.searchButton', '검색하기'), disabled: isEmpty || searchDisabled, onClick: isEmpty || searchDisabled ? undefined : onSearch }))] }));
69
+ return (_jsxs("div", { className: "phoneme-select-block__search-row", onClick: isEmpty ? onSearchEmpty : undefined, children: [onClearPhoneme && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordPairs.resetOptions', '검색 옵션 초기화'), selected: false, disabled: isEmpty, onClick: isEmpty ? undefined : onClearPhoneme })), searchChecking ? (_jsxs("div", { className: "phoneme-select-block__checking", children: [_jsx("div", { className: "phoneme-select-block__spinner" }), _jsx("span", { className: "button-1 phoneme-select-block__checking-label", children: searchLabel ?? t('wordSearch.searchButton', '검색하기') })] })) : onSearch && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: searchLabel ?? t('wordSearch.searchButton', '검색하기'), disabled: isEmpty || searchDisabled, onClick: isEmpty || searchDisabled ? undefined : onSearch }))] }));
70
70
  })(), afterContent] }));
71
71
  }
@@ -135,7 +135,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
135
135
  return (_jsx("div", { onMouseEnter: e => {
136
136
  openHover(e.currentTarget);
137
137
  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
138
+ }, 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
139
  ? item.words.map(w => w.subLabel)
140
140
  : undefined, selected: isSelected, onClick: () => handleClick(item.id), minWidth: 200 }) }, item.id));
141
141
  }) }) }, group.label)))) : (_jsx("div", { className: "word-search-result-block__items", children: pagedItems.map((item) => {
@@ -143,8 +143,8 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
143
143
  return (_jsx("div", { onMouseEnter: e => {
144
144
  openHover(e.currentTarget);
145
145
  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
146
+ }, 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
147
  ? item.words.map(w => w.subLabel)
148
148
  : 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 }) }))] }))] }));
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, { 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
150
  }
@@ -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
  }
@@ -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
  }
@@ -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;
@@ -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
  }
@@ -203,12 +203,36 @@ async function genStartsWithSame(opts) {
203
203
  * 오답(distractors)은 같은 원단어의 다른 최소대립쌍(e.usable)에서 뽑아 러너가 무관 단어로 채우지 않게 한다
204
204
  * (genChangeWholeSyll과 동일 철학: 같은-base 대립쌍. e.usable은 buildUsablePool이 이미 1음소 대립쌍만 담음). */
205
205
  function renderSyllableSwapItems(pool, count, idPrefix) {
206
- return sample(pool, count).map((e, i) => {
207
- const changedWord = pickOne(e.usable) ?? '';
206
+ const pronMap = getPronMap();
207
+ const pronOf = (w) => pronMap.get(w) || w;
208
+ /* 대립쌍은 **발음**으로 만든 것이라 철자로는 두 자리 이상이 다를 수 있다(같이[가치]↔가지). 그런 쌍을
209
+ 바꾼 단어로 쓰면 '같이에서 같을 가로' 처럼 문제 글과 정답이 어긋났다 — 철자가 **한 자리만** 다른
210
+ 쌍만 바꾼 단어로 쓴다. 발음이 원단어와 같은 쌍(낫↔낮)도 뺀다. */
211
+ const oneSyllableDiff = (a, b) => {
212
+ const A = [...a], B = [...b];
213
+ if (A.length !== B.length)
214
+ return -1;
215
+ let at = -1;
216
+ for (let k = 0; k < A.length; k++) {
217
+ if (A[k] === B[k])
218
+ continue;
219
+ if (at >= 0)
220
+ return -1;
221
+ at = k;
222
+ }
223
+ return at;
224
+ };
225
+ const ready = pool
226
+ .map(e => ({ e, swaps: e.usable.filter(u => !!u && oneSyllableDiff(e.word.w, u) >= 0 && pronOf(u) !== pronOf(e.word.w)) }))
227
+ .filter(x => x.swaps.length > 0);
228
+ return sample(ready, count).map(({ e, swaps }, i) => {
229
+ const changedWord = pickOne(swaps) ?? '';
208
230
  const origSylls = [...e.word.w];
209
231
  const changedSylls = [...changedWord];
210
- const diffIdx = origSylls.findIndex((s, k) => s !== changedSylls[k]);
211
- const distractors = shuffle(e.usable.filter(u => u && u !== changedWord)).slice(0, 2);
232
+ const diffIdx = oneSyllableDiff(e.word.w, changedWord);
233
+ /* 오답은 정답(바꾼 단어)·원단어와 소리가 다르고 **서로도** 소리가 다르게 — 전에는 그냥 둘을 뽑아
234
+ 낮·낯([낟]) 같은 같은 소리 보기 둘이 함께 나왔다. */
235
+ const distractors = sampleDistinctPron(e.usable.filter(u => u && u !== changedWord), 2, new Set([pronOf(changedWord), pronOf(e.word.w)]));
212
236
  return {
213
237
  id: nextId(idPrefix),
214
238
  index: i + 1,
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.132.0";
49
+ export declare const CORE_VERSION = "0.133.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.132.0';
49
+ export const CORE_VERSION = '0.133.0';