@ihabdevteam/core 0.131.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.
- package/CHANGELOG.md +108 -0
- package/dist/components.cjs.js +178 -178
- package/dist/components.esm.css +2 -2
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +181 -181
- package/dist/ihabdevteam-core.d.ts +55 -17
- package/dist/packages/core/src/components/Accordion.js +4 -1
- package/dist/packages/core/src/components/Badge.d.ts +5 -0
- package/dist/packages/core/src/components/Badge.js +3 -3
- package/dist/packages/core/src/components/BarChart.d.ts +1 -1
- package/dist/packages/core/src/components/BarChart.js +25 -14
- package/dist/packages/core/src/components/BottomNav.js +3 -3
- package/dist/packages/core/src/components/BulkBar.js +1 -1
- package/dist/packages/core/src/components/Calendar.js +4 -0
- package/dist/packages/core/src/components/ChartSection.css +16 -0
- package/dist/packages/core/src/components/ChartSection.d.ts +8 -1
- package/dist/packages/core/src/components/ChartSection.js +2 -2
- package/dist/packages/core/src/components/ChoiceChips.js +1 -1
- package/dist/packages/core/src/components/CodeBox.js +1 -1
- package/dist/packages/core/src/components/DatePicker.js +16 -3
- package/dist/packages/core/src/components/Dropdown.js +13 -3
- package/dist/packages/core/src/components/FileUpload.js +8 -7
- package/dist/packages/core/src/components/FilterPanel.js +8 -5
- package/dist/packages/core/src/components/FilterSectionInfo.js +1 -1
- package/dist/packages/core/src/components/Header.js +1 -1
- package/dist/packages/core/src/components/HearingDeviceInfo.js +1 -1
- package/dist/packages/core/src/components/PageTitle.css +6 -0
- package/dist/packages/core/src/components/PageTitle.d.ts +9 -1
- package/dist/packages/core/src/components/PageTitle.js +3 -3
- package/dist/packages/core/src/components/Pagination.js +1 -1
- package/dist/packages/core/src/components/PanelRow.js +3 -3
- package/dist/packages/core/src/components/PaymentPlan.js +1 -1
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +2 -2
- package/dist/packages/core/src/components/Popup/Popup.d.ts +8 -0
- package/dist/packages/core/src/components/Popup/Popup.js +17 -3
- package/dist/packages/core/src/components/Popup/SaveTablePopup.js +1 -1
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +1 -1
- package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +3 -3
- package/dist/packages/core/src/components/RingChart.d.ts +3 -1
- package/dist/packages/core/src/components/RingChart.js +2 -2
- package/dist/packages/core/src/components/SegmentedControl.js +1 -1
- package/dist/packages/core/src/components/SideNav.js +27 -10
- package/dist/packages/core/src/components/Table.d.ts +17 -1
- package/dist/packages/core/src/components/Table.js +15 -9
- package/dist/packages/core/src/components/Tabs.js +7 -2
- package/dist/packages/core/src/components/TextField.js +34 -5
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +1 -1
- package/dist/packages/core/src/components/Tooltip/TextBoxTooltip.js +1 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -5
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingBlock.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +51 -23
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +26 -5
- package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +11 -6
- package/dist/packages/core/src/components/Training/useAudioPreview.js +7 -3
- package/dist/packages/core/src/components/UpdateAlert.js +1 -1
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +2 -2
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -3
- package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
- package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
- package/dist/packages/core/src/components/WordTable.js +6 -6
- package/dist/packages/core/src/components/barChartTypes.d.ts +2 -0
- package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +1 -0
- package/dist/packages/core/src/components/useComboboxSuggestions.js +12 -6
- package/dist/packages/core/src/components/useToastLifecycle.js +8 -1
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +10 -9
- package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +0 -11
- package/dist/packages/core/src/hooks/useDocumentTitle.js +16 -3
- package/dist/packages/core/src/hooks/useFocusTrap.js +6 -2
- package/dist/packages/core/src/utils/audioPeaks.js +13 -5
- package/dist/packages/core/src/utils/csv.js +3 -1
- package/dist/packages/core/src/utils/koreanSyllable.js +2 -1
- package/dist/packages/core/src/utils/textSort.d.ts +0 -10
- package/dist/packages/core/src/utils/textSort.js +7 -2
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +28 -4
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -49,11 +49,11 @@ export default function TrainingStepConfirmWithText({ index, stepIndex, isLast =
|
|
|
49
49
|
setSelectedAnswer(true);
|
|
50
50
|
handleEndedLocal(true);
|
|
51
51
|
}, [handleEndedLocal, currentState]);
|
|
52
|
-
return (_jsxs(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, onDone: onDone, useTitleFaded: false, children: [_jsx(TrainingStepTextBox, { context: context, state: currentState }), _jsx("div", { className: "training-step__buttons", children: Array.isArray(choices) && choices.length > 0 ? (choices.slice(0, 6).map((c, i) => (_jsx(Button, { size: 56, type: "bar", onClick: () => {
|
|
52
|
+
return (_jsxs(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, onDone: onDone, useTitleFaded: false, children: [_jsx(TrainingStepTextBox, { context: context, state: currentState }), _jsx("div", { className: "training-step__buttons", children: Array.isArray(choices) && choices.length > 0 ? (choices.slice(0, 6).map((c, i) => (_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: () => {
|
|
53
53
|
if (currentState !== 'active')
|
|
54
54
|
return;
|
|
55
55
|
setSelectedAnswer(i);
|
|
56
56
|
if (handleEndedLocal)
|
|
57
57
|
handleEndedLocal(i);
|
|
58
|
-
}, disabled: currentState === 'disabled', ariaLabel: String(c), label: c, variant: currentState, selected: selectedAnswer === i }, i)))) : (_jsxs(_Fragment, { children: [_jsx(Button, { size: 56, type: "bar", onClick: handleSelectFalse, disabled: currentState === 'disabled', icon: 'x', ariaLabel: t('training.notHeard', '안 들렸어요.'), label: t('training.notHeard', '안 들렸어요.'), variant: 'default', selected: selectedAnswer === false }), _jsx(Button, { size: 56, type: "bar", onClick: handleSelectTrue, disabled: currentState === 'disabled', icon: 'circle', ariaLabel: t('training.heardWell', '잘 들렸어요.'), label: t('training.heardWell', '잘 들렸어요.'), variant: 'default', selected: selectedAnswer === true })] })) })] }));
|
|
58
|
+
}, disabled: currentState === 'disabled', ariaLabel: String(c), label: c, variant: currentState, selected: selectedAnswer === i }, i)))) : (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: handleSelectFalse, disabled: currentState === 'disabled', icon: 'x', ariaLabel: t('training.notHeard', '안 들렸어요.'), label: t('training.notHeard', '안 들렸어요.'), variant: 'default', selected: selectedAnswer === false }), _jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: handleSelectTrue, disabled: currentState === 'disabled', icon: 'circle', ariaLabel: t('training.heardWell', '잘 들렸어요.'), label: t('training.heardWell', '잘 들렸어요.'), variant: 'default', selected: selectedAnswer === true })] })) })] }));
|
|
59
59
|
}
|
|
@@ -440,6 +440,6 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
|
|
|
440
440
|
}, 80);
|
|
441
441
|
}
|
|
442
442
|
catch (err) { }
|
|
443
|
-
} : undefined, onDrop: currentState === 'active' ? (e) => handleDrop(idx, e) : undefined, onDragEnd: currentState === 'active' ? handleDragEnd : undefined, children: [_jsx("span", { className: "order-list__item-text body-2", children: it.text }), currentState === 'active' ? (_jsxs("span", { className: "order-list__item-buttons", children: [_jsx(Button, { size: 32, type: "dot", onClick: () => move(idx, -1), ariaLabel: t("training.up"), icon: 'chevron-up', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === 0, useBorder: false, useDisabledInvisible: true }), _jsx(Button, { size: 32, type: "dot", onClick: () => move(idx, 1), ariaLabel: t("training.down"), icon: 'chevron-down', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === shuffled.length - 1, useBorder: false, useDisabledInvisible: true })] })) : null] })] }) }, it.id));
|
|
443
|
+
} : undefined, onDrop: currentState === 'active' ? (e) => handleDrop(idx, e) : undefined, onDragEnd: currentState === 'active' ? handleDragEnd : undefined, children: [_jsx("span", { className: "order-list__item-text body-2", children: it.text }), currentState === 'active' ? (_jsxs("span", { className: "order-list__item-buttons", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, -1), ariaLabel: t("training.up"), icon: 'chevron-up', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === 0, useBorder: false, useDisabledInvisible: true }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, 1), ariaLabel: t("training.down"), icon: 'chevron-down', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === shuffled.length - 1, useBorder: false, useDisabledInvisible: true })] })) : null] })] }) }, it.id));
|
|
444
444
|
}) }) }));
|
|
445
445
|
}
|
|
@@ -70,5 +70,5 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
|
|
|
70
70
|
fontSize: `${fontSize}px`,
|
|
71
71
|
lineHeight: `${lineHeight}px`,
|
|
72
72
|
}), [fontSize, lineHeight]);
|
|
73
|
-
return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
|
|
73
|
+
return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
|
|
74
74
|
}
|
|
@@ -87,5 +87,5 @@ export default function TrainingStepTitle({ title, children, className = "", sta
|
|
|
87
87
|
setPreviewContent(null);
|
|
88
88
|
}
|
|
89
89
|
}, [onPreviewTextBtn, previewText, showPreview]);
|
|
90
|
-
return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive })), usePreviewTextBtn && (_jsx("div", { ref: btnRef, children: _jsx(Button, { size: 32, type: isMobile ? 'dot' : 'bar', icon: "eye", label: isMobile ? undefined : t('training.sentencePreview', '문장 미리보기'), ariaLabel: t('training.sentencePreview', '문장 미리보기'), selected: showPreview, onClick: handlePreviewButtonClick, variant: state === 'disabled' ? 'disabled' : 'default', disabled: !previewText && typeof onPreviewTextBtn !== 'function' }) }))] }), showPreview && (_jsx(TextBoxTooltip, { text: (previewContent ?? previewText), label: t('training.sentencePreview', '문장 미리보기'), anchorRect: anchorRect, onClose: handlePreviewClose, closeOnOutsideClick: false, isMobilePopup: true }))] })) : null] }));
|
|
90
|
+
return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive })), usePreviewTextBtn && (_jsx("div", { ref: btnRef, children: _jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "eye", label: isMobile ? undefined : t('training.sentencePreview', '문장 미리보기'), ariaLabel: t('training.sentencePreview', '문장 미리보기'), selected: showPreview, onClick: handlePreviewButtonClick, variant: state === 'disabled' ? 'disabled' : 'default', disabled: !previewText && typeof onPreviewTextBtn !== 'function' }) }))] }), showPreview && (_jsx(TextBoxTooltip, { text: (previewContent ?? previewText), label: t('training.sentencePreview', '문장 미리보기'), anchorRect: anchorRect, onClose: handlePreviewClose, closeOnOutsideClick: false, isMobilePopup: true }))] })) : null] }));
|
|
91
91
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }));
|
|
@@ -85,6 +85,8 @@ export interface BarChartProps {
|
|
|
85
85
|
loading?: boolean;
|
|
86
86
|
/** 지정 시 헤더 우측에 '상세보기' 버튼 표시 → 클릭 콜백 */
|
|
87
87
|
onDetail?: () => void;
|
|
88
|
+
/** CSS 격자 칸 안에 세운다 — 아래 여백·최소 폭을 풀고 막대를 카드 바닥에 맞춘다. ChartSection `inGrid` 참고. */
|
|
89
|
+
inGrid?: boolean;
|
|
88
90
|
}
|
|
89
91
|
/** 현재 컨트롤 선택(주/월)의 날짜 범위 — 상세보기(기간별 리스트)용 */
|
|
90
92
|
export declare function getControlDateRange(control: BarChartControl): {
|
|
@@ -17,6 +17,7 @@ export declare function useComboboxSuggestions({ value, onChange, suggestions, o
|
|
|
17
17
|
filtered: string[];
|
|
18
18
|
handleFocus: () => void;
|
|
19
19
|
handleBlur: () => void;
|
|
20
|
+
handleInput: () => void;
|
|
20
21
|
handleKeyDown: (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
|
21
22
|
handleSelect: (item: string) => void;
|
|
22
23
|
};
|
|
@@ -25,11 +25,17 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
|
|
|
25
25
|
setActiveIdx(-1); }, [open]);
|
|
26
26
|
useEffect(() => () => { if (blurTimerRef.current)
|
|
27
27
|
clearTimeout(blurTimerRef.current); }, []);
|
|
28
|
+
/* 입력으로 후보가 줄면 가리키던 번호가 목록 밖에 남는다 — 그 채로 Enter 를 누르면 filtered[3] 같은
|
|
29
|
+
없는 칸을 골라 onChange(undefined) 가 나갔다. 밖이면 '안 가리킴' 으로 본다. */
|
|
30
|
+
const safeIdx = activeIdx < filtered.length ? activeIdx : -1;
|
|
28
31
|
const handleSelect = useCallback((item) => {
|
|
29
32
|
onChange?.(item);
|
|
30
33
|
setOpen(false);
|
|
31
34
|
}, [onChange]);
|
|
32
35
|
const handleFocus = useCallback(() => setOpen(true), []);
|
|
36
|
+
/* 글자를 치면 목록을 다시 연다 — 고르거나 Esc 로 닫은 뒤에는 포커스를 뺐다 넣어야만 열렸다.
|
|
37
|
+
새 글자에는 새 후보가 서므로 가리킴도 처음으로 돌린다. */
|
|
38
|
+
const handleInput = useCallback(() => { setOpen(true); setActiveIdx(-1); }, []);
|
|
33
39
|
// blur가 리스트 항목의 onMouseDown보다 먼저 발생하는 걸 막기 위한 딜레이
|
|
34
40
|
const handleBlur = useCallback(() => {
|
|
35
41
|
blurTimerRef.current = setTimeout(() => setOpen(false), 120);
|
|
@@ -38,16 +44,16 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
|
|
|
38
44
|
if (e.key === 'ArrowDown') {
|
|
39
45
|
e.preventDefault();
|
|
40
46
|
setOpen(true);
|
|
41
|
-
setActiveIdx(i => Math.min(filtered.length - 1, i + 1));
|
|
47
|
+
setActiveIdx(i => Math.min(filtered.length - 1, (i < filtered.length ? i : -1) + 1));
|
|
42
48
|
}
|
|
43
49
|
else if (e.key === 'ArrowUp') {
|
|
44
50
|
e.preventDefault();
|
|
45
|
-
setActiveIdx(i => Math.max(-1, i - 1));
|
|
51
|
+
setActiveIdx(i => Math.max(-1, (i < filtered.length ? i : filtered.length) - 1));
|
|
46
52
|
}
|
|
47
53
|
else if (e.key === 'Enter') {
|
|
48
54
|
// 한글 조합을 끝내는 Enter는 선택이 아니다 — 조합 중엔 제안을 고르지 않는다
|
|
49
|
-
if (
|
|
50
|
-
handleSelect(filtered[
|
|
55
|
+
if (safeIdx >= 0 && !e.nativeEvent.isComposing)
|
|
56
|
+
handleSelect(filtered[safeIdx]);
|
|
51
57
|
}
|
|
52
58
|
else if (e.key === 'Escape') {
|
|
53
59
|
if (open) {
|
|
@@ -55,6 +61,6 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
|
|
|
55
61
|
setOpen(false);
|
|
56
62
|
} // 제안이 열렸을 때만 소비(상위 팝업 ESC보다 우선하지 않도록)
|
|
57
63
|
}
|
|
58
|
-
}, [filtered,
|
|
59
|
-
return { listId, open, activeIdx, filtered, handleFocus, handleBlur, handleKeyDown, handleSelect };
|
|
64
|
+
}, [filtered, safeIdx, open, handleSelect]);
|
|
65
|
+
return { listId, open, activeIdx: safeIdx, filtered, handleFocus, handleBlur, handleInput, handleKeyDown, handleSelect };
|
|
60
66
|
}
|
|
@@ -87,7 +87,14 @@ export function useToastLifecycle({ message, position, duration, onClose }) {
|
|
|
87
87
|
사라짐걸기.current(DURATION_MS);
|
|
88
88
|
}
|
|
89
89
|
lastMessageRef.current = message;
|
|
90
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
}
|