@ihabdevteam/core 0.142.0 → 0.144.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/dist/components.cjs.js +185 -180
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +191 -186
- package/dist/ihabdevteam-core.d.ts +66 -22
- package/dist/packages/core/src/components/Badge.js +3 -1
- package/dist/packages/core/src/components/BarChart.d.ts +1 -1
- package/dist/packages/core/src/components/BarChart.js +4 -3
- package/dist/packages/core/src/components/BottomNav.js +4 -0
- package/dist/packages/core/src/components/BulkBar.js +1 -1
- package/dist/packages/core/src/components/Button.js +12 -5
- package/dist/packages/core/src/components/Calendar.js +9 -6
- package/dist/packages/core/src/components/DatePicker.js +9 -4
- package/dist/packages/core/src/components/Dialog.js +3 -1
- package/dist/packages/core/src/components/Dropdown.d.ts +3 -1
- package/dist/packages/core/src/components/Dropdown.js +10 -5
- package/dist/packages/core/src/components/FileUpload.js +3 -1
- package/dist/packages/core/src/components/FilterPanel.js +3 -2
- package/dist/packages/core/src/components/Loader.js +6 -1
- package/dist/packages/core/src/components/Pagination.js +1 -1
- package/dist/packages/core/src/components/PanelRow.js +13 -4
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -1
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +5 -2
- package/dist/packages/core/src/components/Popup/Popup.d.ts +1 -1
- package/dist/packages/core/src/components/Popup/Popup.js +26 -6
- package/dist/packages/core/src/components/ProgressBar.js +1 -1
- package/dist/packages/core/src/components/SideNav.js +1 -1
- package/dist/packages/core/src/components/Slider.js +10 -1
- package/dist/packages/core/src/components/Table.js +2 -0
- package/dist/packages/core/src/components/TextField.js +3 -1
- package/dist/packages/core/src/components/Toolbar.js +3 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +7 -2
- package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +3 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +49 -2
- package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -1
- package/dist/packages/core/src/components/Training/useAudioPreview.js +28 -3
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +19 -5
- package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
- package/dist/packages/core/src/components/Word/WordTableButton.d.ts +3 -3
- package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +1 -1
- package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +2 -2
- package/dist/packages/core/src/components/Word/wordTablePrint.js +27 -8
- package/dist/packages/core/src/components/barChartData.d.ts +3 -5
- package/dist/packages/core/src/components/barChartData.js +7 -3
- package/dist/packages/core/src/components/barChartTypes.d.ts +16 -3
- package/dist/packages/core/src/components/barChartTypes.js +27 -13
- package/dist/packages/core/src/components/useCalendarMonth.js +3 -2
- package/dist/packages/core/src/components/useToastLifecycle.js +5 -0
- package/dist/packages/core/src/components.d.ts +1 -0
- package/dist/packages/core/src/hooks/focusReturn.d.ts +4 -0
- package/dist/packages/core/src/hooks/focusReturn.js +27 -0
- package/dist/packages/core/src/hooks/useVisitHistory.d.ts +2 -0
- package/dist/packages/core/src/hooks/useVisitHistory.js +5 -6
- package/dist/packages/core/src/hooks/useWordMeanings.js +6 -3
- package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +2 -2
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +57 -17
- package/dist/packages/core/src/utils/date.d.ts +18 -0
- package/dist/packages/core/src/utils/date.js +23 -0
- package/dist/packages/core/src/utils/format.js +2 -1
- package/dist/packages/core/src/utils/imageConfig.d.ts +2 -0
- package/dist/packages/core/src/utils/imageConfig.js +34 -5
- package/dist/packages/core/src/utils/josa.js +2 -0
- package/dist/packages/core/src/utils/koreanSyllable.d.ts +5 -0
- package/dist/packages/core/src/utils/koreanSyllable.js +69 -43
- package/dist/packages/core/src/utils/phonemePairSearch.js +17 -0
- package/dist/packages/core/src/utils/timeoutSignal.d.ts +5 -0
- package/dist/packages/core/src/utils/timeoutSignal.js +12 -0
- package/dist/packages/core/src/utils/ttsConfig.js +2 -1
- package/dist/packages/core/src/utils/wordContrast.js +6 -4
- package/dist/packages/core/src/utils/wordTrainingPool.js +14 -5
- package/dist/packages/core/src/utils.d.ts +1 -1
- package/dist/packages/core/src/utils.js +1 -1
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- package/package.json +1 -1
|
@@ -19,5 +19,5 @@ export default function Pagination({ page, totalPages, onChange, prevLabel, next
|
|
|
19
19
|
const 마지막 = Math.max(1, totalPages);
|
|
20
20
|
const handlePrev = () => onChange(Math.max(1, Math.min(마지막, page - 1)));
|
|
21
21
|
const handleNext = () => onChange(Math.min(마지막, page + 1));
|
|
22
|
-
return (_jsxs("div", { className: cx('pagination', className), children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-left", ariaLabel: resolvedPrev, disabled: page <= 1, onClick: handlePrev }), _jsxs("span", { className: "pagination__info body-3", children: [Math.min(Math.max(1, page), 마지막), " / ", 마지막] }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-right", ariaLabel: resolvedNext, disabled: page >= totalPages, onClick: handleNext })] }));
|
|
22
|
+
return (_jsxs("div", { className: cx('pagination', className), children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-left", ariaLabel: resolvedPrev, disabled: page <= 1, onClick: handlePrev }), _jsxs("span", { className: "pagination__info body-3", "aria-live": "polite", "aria-atomic": "true", children: [Math.min(Math.max(1, page), 마지막), " / ", 마지막] }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-right", ariaLabel: resolvedNext, disabled: page >= totalPages, onClick: handleNext })] }));
|
|
23
23
|
}
|
|
@@ -71,6 +71,8 @@ export function PanelRow(props) {
|
|
|
71
71
|
// 연결한다 — 그 전엔 시각적으로만 옆에 붙어있어 라벨 텍스트를 클릭해도 토글이 안 되고
|
|
72
72
|
// 스크린리더도 이 스위치가 뭘 위한 건지 안내하지 못했다.
|
|
73
73
|
const toggleId = variant === 'toggle' ? `panel-toggle-${uid}` : undefined;
|
|
74
|
+
// 오류 문구 id — 드롭다운 행은 트리거가 이 문구를 설명으로 잇는다(aria-invalid 만으로는 무엇이 틀렸는지 안 들린다)
|
|
75
|
+
const errorId = error ? `panel-error-${uid}` : undefined;
|
|
74
76
|
if (variant === 'toggle') {
|
|
75
77
|
const p = props;
|
|
76
78
|
right = _jsx(Toggle, { checked: p.checked, onChange: p.onChange, id: toggleId });
|
|
@@ -83,15 +85,15 @@ export function PanelRow(props) {
|
|
|
83
85
|
}
|
|
84
86
|
else if (variant === 'dropdown') {
|
|
85
87
|
const p = props;
|
|
86
|
-
const dropdowns = p.dropdowns?.length ? (_jsx("div", { className: `panel-row__dropdown-group${useFullWidth ? ' panel-row__dropdown-group--full' : ''}`, children: p.dropdowns.map((d, i) => (_jsx("div", { className: useFullWidth ? 'panel-row__dropdown-item--full' : undefined, children: _jsx(Dropdown, { options: d.options, value: d.value, onChange: d.onChange, placeholder: d.placeholder, block: useFullWidth, error: !!error,
|
|
88
|
+
const dropdowns = p.dropdowns?.length ? (_jsx("div", { className: `panel-row__dropdown-group${useFullWidth ? ' panel-row__dropdown-group--full' : ''}`, children: p.dropdowns.map((d, i) => (_jsx("div", { className: useFullWidth ? 'panel-row__dropdown-item--full' : undefined, children: _jsx(Dropdown, { options: d.options, value: d.value, onChange: d.onChange, placeholder: d.placeholder, block: useFullWidth, error: !!error, ariaDescribedBy: errorId,
|
|
87
89
|
// 한 행에 여러 개가 놓이므로 행 라벨만 넘기면 셋 다 "생년월일" 이라는 같은 이름이 된다.
|
|
88
90
|
// aria-label 이 있으면 스크린리더는 트리거의 화면 텍스트(년/월/일)를 읽지 않으므로
|
|
89
91
|
// 사용자는 어느 것이 무엇인지 구별할 수 없다 — placeholder 를 붙여 구별되게 한다.
|
|
90
92
|
ariaLabel: d.ariaLabel ?? ([label, d.placeholder].filter(Boolean).join(' ') || undefined) }) }, i))) })) : (
|
|
91
93
|
// PanelRow 왼쪽 라벨(panel-row__label)은 <label htmlFor>가 아니라 그냥 div라서 트리거 버튼과
|
|
92
94
|
// 프로그래밍적으로 연결되지 않는다 — ariaLabel로 직접 이름을 넘긴다 (TextField의 ariaLabel과 동일 패턴)
|
|
93
|
-
_jsx(Dropdown, { options: p.options ?? [], value: p.value ?? '', onChange: p.onChange ?? noop, block: useFullWidth, error: !!error, ariaLabel: label }));
|
|
94
|
-
right = error ? (_jsxs("div", { className: `panel-row__dropdown-error-wrap${useFullWidth ? ' panel-row__dropdown-error-wrap--full' : ''}`, children: [dropdowns, _jsx("span", { className: "caption-1 panel-row__error", role: "alert", children: error })] })) : dropdowns;
|
|
95
|
+
_jsx(Dropdown, { options: p.options ?? [], value: p.value ?? '', onChange: p.onChange ?? noop, block: useFullWidth, error: !!error, ariaLabel: label, ariaDescribedBy: errorId }));
|
|
96
|
+
right = error ? (_jsxs("div", { className: `panel-row__dropdown-error-wrap${useFullWidth ? ' panel-row__dropdown-error-wrap--full' : ''}`, children: [dropdowns, _jsx("span", { id: errorId, className: "caption-1 panel-row__error", role: "alert", children: error })] })) : dropdowns;
|
|
95
97
|
}
|
|
96
98
|
else if (variant === 'button') {
|
|
97
99
|
const p = props;
|
|
@@ -114,6 +116,11 @@ export function PanelRow(props) {
|
|
|
114
116
|
const p = props;
|
|
115
117
|
right = p.children;
|
|
116
118
|
}
|
|
119
|
+
/* error 는 Base 에 있어 **모든 variant** 가 받는다 — 그런데 input(TextField)·dropdown 만 문구를 그리고 나머지는 받아
|
|
120
|
+
버렸다(소리방향성 결과 저장 폴더: custom 행에 error 를 줬는데 빨간 상자만 뜨고 까닭이 안 보였다). dropdown 과 같은 모양으로 붙인다. */
|
|
121
|
+
if (error && variant !== 'input' && variant !== 'dropdown') {
|
|
122
|
+
right = (_jsxs("div", { className: `panel-row__dropdown-error-wrap${useFullWidth ? ' panel-row__dropdown-error-wrap--full' : ''}`, children: [right, _jsx("span", { id: errorId, className: "caption-1 panel-row__error", role: "alert", children: error })] }));
|
|
123
|
+
}
|
|
117
124
|
const isLabelFixed = variant === 'input' || variant === 'dropdown' || ((variant === 'text' || variant === 'custom') && useFullWidth);
|
|
118
125
|
// 위 children 모드와 동일한 키보드 처리 — 이쪽 경로에만 빠져 있어서,
|
|
119
126
|
// variant 모드에 onClick을 주면 조용히 마우스 전용이 된다(현재 사용처는 없지만 예방).
|
|
@@ -150,6 +157,8 @@ export function PanelRowDevice({ devices, onRelease, emptyLabel }) {
|
|
|
150
157
|
/* 표의 줄 끝 단추와 같은 모양·같은 말 — 하는 일도 같다.
|
|
151
158
|
이력에서 지우는 것이 아니라 '지금 끼고 있음' 만 내린다
|
|
152
159
|
(잠시 안 씀·수리 보냄 같은 경우가 실제로 있다). */
|
|
153
|
-
_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "x",
|
|
160
|
+
_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "x",
|
|
161
|
+
// 좌·우 두 단추가 같은 이름이라 어느 쪽 기기를 내리는지 안 들렸다 — 이름에만 쪽을 붙인다(툴팁은 그대로)
|
|
162
|
+
ariaLabel: `${sideLabel} ${t('customers.device.unwear', '착용 해제')}`, showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: 기기종류이름(d.device, t) }), 메타(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: 메타(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), _jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: `${d.wornYear}.${d.wornMonth}.${d.wornDay}` }) })] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: emptyLabel ?? t('customers.device.notRegistered', '미등록') }))] }, side));
|
|
154
163
|
}) }));
|
|
155
164
|
}
|
|
@@ -67,7 +67,9 @@ export default function DeviceSideTable({ rows, onSelect, onDelete, onWear, onRe
|
|
|
67
67
|
말하고, 바꾸려면 줄 오른쪽 끝의 다른 단추를 눌러야 했다. 보는 자리와
|
|
68
68
|
바꾸는 자리가 갈려 있으면 무엇이 무엇을 바꾸는지 짚이지 않는다.
|
|
69
69
|
체크가 눌림을 삼키게 해 줄이 함께 열리지 않도록 막는다. */
|
|
70
|
-
content: (_jsx("span", { className: "dlp-worn-check", onClick: e => e.stopPropagation(), children: _jsx(Checkbox, { checked: row.worn,
|
|
70
|
+
content: (_jsx("span", { className: "dlp-worn-check", onClick: e => e.stopPropagation(), children: _jsx(Checkbox, { checked: row.worn,
|
|
71
|
+
// 켤 수 있는지는 그 줄이 할 일로 본다 — 둘 중 하나만 준 화면(말귀 고객 상세)에서 체크가 통째로 잠겼다
|
|
72
|
+
disabled: row.worn ? !onRelease : !onWear, ariaLabel: row.worn
|
|
71
73
|
? t('customers.device.unwear', '착용 해제')
|
|
72
74
|
: t('customers.device.wear', '착용'), onChange: next => { if (next)
|
|
73
75
|
onWear?.(row);
|
|
@@ -62,6 +62,9 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
|
|
|
62
62
|
return copy.sort((a, b) => b.items.length - a.items.length);
|
|
63
63
|
return copy;
|
|
64
64
|
}, [tables, sortKey]);
|
|
65
|
+
/* 목록을 받아(tables) 보여 주는데 onDelete 가 없으면 지울 길이 없다 — 전에는 브라우저 저장소에서 같은 id 의 표를 지우고
|
|
66
|
+
보이는 목록은 그대로였다(보이지 않는 표를 지움). 그때는 삭제 단추를 두지 않는다. */
|
|
67
|
+
const canDelete = !!onDeleteProp || tablesProp === undefined;
|
|
65
68
|
const handleDelete = useCallback((id, e) => {
|
|
66
69
|
e.stopPropagation();
|
|
67
70
|
if (onDeleteProp) {
|
|
@@ -84,7 +87,7 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
|
|
|
84
87
|
const headerActions = useMemo(() => tables.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Dropdown, { options: VIEW_OPTIONS, value: viewMode, onChange: handleSetViewMode }), _jsx(Dropdown, { options: SORT_OPTIONS, value: sortKey, onChange: handleSetSortKey })] })) : undefined, [tables.length, viewMode, sortKey, handleSetViewMode, handleSetSortKey, VIEW_OPTIONS, SORT_OPTIONS]);
|
|
85
88
|
return (_jsx(Popup, { className: "popup--load-table", title: t('wordTable.loadTable.title', '저장된 단어 표 불러오기'), headerActions: headerActions, onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "load-table-popup__footer-actions", children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation, width: "fit-content" }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: t('wordTable.loadTable.loadButton', '불러오기'), width: "fit-content",
|
|
86
89
|
// 고른 표가 지금 목록에 있을 때만 — 부모 목록에서 빠진 표를 가리키면 눌러도 아무 일이 없었다
|
|
87
|
-
disabled: !selected || !tables.some(t => t.id === selected), onClick: () => handleLoadSelected(animateThen) })] })), children: _jsx("div", { className: "load-table-popup__body", children: tables.length === 0 ? (_jsx("p", { className: "load-table-popup__empty body-2", children: t('wordTable.loadTable.empty', '저장된 단어 표가 없습니다.') })) : (_jsxs(_Fragment, { children: [viewMode === 'grid' && (_jsx("ul", { className: "load-table-popup__grid", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__card${selected === table.id ? ' load-table-popup__card--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') }), _jsx(TableThumbnail, { table: table }), _jsxs("div", { className: "load-table-popup__card-info", children: [_jsx("span", { className: "load-table-popup__card-name body-2", children: table.name }), _jsxs("span", { className: "load-table-popup__card-meta caption-1", children: [formatDate(table.savedAt), " \u00B7 ", t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' })] })] })] }, table.id))) })), viewMode === 'list' && (_jsx("ul", { className: "load-table-popup__list", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__row${selected === table.id ? ' load-table-popup__row--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx("span", { className: "load-table-popup__row-check", children: selected === table.id && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", children: _jsx("polyline", { points: "4 13 9 18 20 6", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) }), _jsxs("div", { className: "load-table-popup__row-main", children: [_jsx("span", { className: "load-table-popup__row-name body-2", children: table.name }), _jsx("div", { className: "load-table-popup__row-words", children: table.items.map((item, i) => (_jsx("span", { className: "load-table-popup__row-word caption-1", children: (item.words ?? []).length >= 2
|
|
90
|
+
disabled: !selected || !tables.some(t => t.id === selected), onClick: () => handleLoadSelected(animateThen) })] })), children: _jsx("div", { className: "load-table-popup__body", children: tables.length === 0 ? (_jsx("p", { className: "load-table-popup__empty body-2", children: t('wordTable.loadTable.empty', '저장된 단어 표가 없습니다.') })) : (_jsxs(_Fragment, { children: [viewMode === 'grid' && (_jsx("ul", { className: "load-table-popup__grid", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__card${selected === table.id ? ' load-table-popup__card--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [canDelete && _jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') }), _jsx(TableThumbnail, { table: table }), _jsxs("div", { className: "load-table-popup__card-info", children: [_jsx("span", { className: "load-table-popup__card-name body-2", children: table.name }), _jsxs("span", { className: "load-table-popup__card-meta caption-1", children: [formatDate(table.savedAt), " \u00B7 ", t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' })] })] })] }, table.id))) })), viewMode === 'list' && (_jsx("ul", { className: "load-table-popup__list", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__row${selected === table.id ? ' load-table-popup__row--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx("span", { className: "load-table-popup__row-check", children: selected === table.id && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", children: _jsx("polyline", { points: "4 13 9 18 20 6", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) }), _jsxs("div", { className: "load-table-popup__row-main", children: [_jsx("span", { className: "load-table-popup__row-name body-2", children: table.name }), _jsx("div", { className: "load-table-popup__row-words", children: table.items.map((item, i) => (_jsx("span", { className: "load-table-popup__row-word caption-1", children: (item.words ?? []).length >= 2
|
|
88
91
|
? `${item.words[0].label} · ${item.words[1].label}`
|
|
89
|
-
: item.words?.[0]?.label ?? item.id }, i))) })] }), _jsx("span", { className: "load-table-popup__row-count caption-1", children: t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' }) }), _jsx("span", { className: "load-table-popup__row-date caption-1", children: formatDate(table.savedAt) }), _jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') })] }, table.id))) }))] })) }) }));
|
|
92
|
+
: item.words?.[0]?.label ?? item.id }, i))) })] }), _jsx("span", { className: "load-table-popup__row-count caption-1", children: t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' }) }), _jsx("span", { className: "load-table-popup__row-date caption-1", children: formatDate(table.savedAt) }), canDelete && _jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') })] }, table.id))) }))] })) }) }));
|
|
90
93
|
}
|
|
@@ -16,7 +16,7 @@ export type PopupButtonConfig = {
|
|
|
16
16
|
/** @public */
|
|
17
17
|
export interface PopupProps {
|
|
18
18
|
title?: ReactNode;
|
|
19
|
-
/** 제목 아래 보조 설명(부제). title
|
|
19
|
+
/** 제목 아래 보조 설명(부제). 제목 줄이 그려질 때(useTitle 이고 title·headerActions·onClose 중 하나가 있을 때) 보인다. */
|
|
20
20
|
subtitle?: ReactNode;
|
|
21
21
|
headerActions?: ReactNode;
|
|
22
22
|
children?: ReactNode | ((opts: PopupOptions) => ReactNode);
|
|
@@ -8,6 +8,7 @@ import { pushEsc } from "../../hooks/escStack";
|
|
|
8
8
|
import { lockBodyScroll } from "../../hooks/bodyScrollLock";
|
|
9
9
|
import "./Popup.css";
|
|
10
10
|
import { useFocusTrap, FOCUSABLE_SELECTOR } from '../../hooks/useFocusTrap';
|
|
11
|
+
import { rememberFocusReturn, resolveFocusReturn } from '../../hooks/focusReturn';
|
|
11
12
|
const ANIM_MS = 260;
|
|
12
13
|
// 패널 안에서 Tab 순환 대상으로 삼을 요소들 — disabled/tabindex="-1"은 제외.
|
|
13
14
|
/** @public */
|
|
@@ -90,6 +91,7 @@ export default function Popup(props) {
|
|
|
90
91
|
return;
|
|
91
92
|
const previouslyFocused = document.activeElement;
|
|
92
93
|
const panel = panelRef.current;
|
|
94
|
+
rememberFocusReturn(panel, previouslyFocused);
|
|
93
95
|
if (panel && !panel.contains(document.activeElement)) {
|
|
94
96
|
const first = panel.querySelector(FOCUSABLE_SELECTOR);
|
|
95
97
|
(first ?? panel).focus();
|
|
@@ -99,8 +101,9 @@ export default function Popup(props) {
|
|
|
99
101
|
(미리보기 → 삭제 확인)에서는 닫힘 애니메이션(260ms) 뒤에 여기가 돌 때 포커스가 이미 새 창에 있는데,
|
|
100
102
|
전에는 그것을 뒤 화면의 여는 단추로 빼앗아 Tab 가둠이 풀리고 Enter 가 뒤 화면을 눌렀다. */
|
|
101
103
|
const active = document.activeElement;
|
|
104
|
+
// 기억한 곳이 앞 창과 함께 사라졌으면(미리보기 → 삭제 확인) 그 창이 돌려줄 곳으로
|
|
102
105
|
if (!active || active === document.body || (panel && panel.contains(active)))
|
|
103
|
-
previouslyFocused?.focus?.();
|
|
106
|
+
resolveFocusReturn(previouslyFocused)?.focus?.();
|
|
104
107
|
};
|
|
105
108
|
}, [inline]);
|
|
106
109
|
/* Tab 순환을 패널 안으로 가둔다 — 네 곳에 있던 같은 열두 줄을 훅 한 벌로 모았다.
|
|
@@ -110,19 +113,34 @@ export default function Popup(props) {
|
|
|
110
113
|
전에는 닫는 중 표시만 남아 투명한 막이 화면을 덮고(클릭을 가로챔) ESC·X·바탕·다시 저장이 모두 무시돼 페이지를 쓸 수
|
|
111
114
|
없었다(말귀 업체 정보·훈련 묶음 수정 저장이 실패해 부모가 창을 열어 둘 때). 성공하면 부모가 곧 치우므로 되살릴 일이 없다. */
|
|
112
115
|
const 살아있음Ref = useRef(true);
|
|
113
|
-
|
|
116
|
+
/* 닫기(X·ESC·바탕)가 걸어 둔 타이머 — 닫는 애니메이션(260ms) 안에 부모가 창을 치우면 **떠난 뒤에 onClose** 가 불렸다(닫으며
|
|
117
|
+
다음 창을 여는 흐름에서 늦은 setX(null) 이 새 창을 닫을 수 있다). 떠날 때 걷는다. 소비자가 넘긴 animateThen(cb) 은
|
|
118
|
+
"삭제·저장 확정" 일 수 있어 걷지 않는다(결정 전). */
|
|
119
|
+
const 닫기타이머Ref = useRef(null);
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
살아있음Ref.current = true;
|
|
122
|
+
return () => {
|
|
123
|
+
살아있음Ref.current = false;
|
|
124
|
+
if (닫기타이머Ref.current) {
|
|
125
|
+
clearTimeout(닫기타이머Ref.current);
|
|
126
|
+
닫기타이머Ref.current = null;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}, []);
|
|
114
130
|
const 되살리기 = useCallback(() => {
|
|
115
131
|
if (!살아있음Ref.current || !closingRef.current)
|
|
116
132
|
return;
|
|
117
133
|
closingRef.current = false;
|
|
118
134
|
setClosing(false);
|
|
119
135
|
}, []);
|
|
120
|
-
const animateThen = useCallback((cb) => {
|
|
136
|
+
const animateThen = useCallback((cb, 닫기뿐 = false) => {
|
|
121
137
|
if (closingRef.current)
|
|
122
138
|
return;
|
|
123
139
|
closingRef.current = true;
|
|
124
140
|
setClosing(true);
|
|
125
|
-
setTimeout(() => {
|
|
141
|
+
const id = setTimeout(() => {
|
|
142
|
+
if (닫기타이머Ref.current === id)
|
|
143
|
+
닫기타이머Ref.current = null;
|
|
126
144
|
let 결과;
|
|
127
145
|
try {
|
|
128
146
|
결과 = cb ? cb() : undefined;
|
|
@@ -138,6 +156,8 @@ export default function Popup(props) {
|
|
|
138
156
|
결과.then(() => { setTimeout(되살리기, 120); }, 되살리기);
|
|
139
157
|
}
|
|
140
158
|
}, ANIM_MS);
|
|
159
|
+
if (닫기뿐)
|
|
160
|
+
닫기타이머Ref.current = id;
|
|
141
161
|
}, [되살리기]);
|
|
142
162
|
const closeWithAnimation = useCallback(() => {
|
|
143
163
|
if (closeDisabled)
|
|
@@ -145,7 +165,7 @@ export default function Popup(props) {
|
|
|
145
165
|
// animateThen보다 먼저 물어본다 — 애니메이션이 시작된 뒤엔 되돌릴 수 없다(위 주석 참고)
|
|
146
166
|
if (beforeClose && beforeClose() === false)
|
|
147
167
|
return;
|
|
148
|
-
animateThen(onClose);
|
|
168
|
+
animateThen(onClose, true);
|
|
149
169
|
}, [animateThen, onClose, closeDisabled, beforeClose]);
|
|
150
170
|
// ESC 닫기 — 공유 스택에 등록(최상단 팝업만 닫힘). onClose가 있고 portal 모드일 때만.
|
|
151
171
|
// 핸들러는 ref로 최신화해 스택 재등록(churn) 없이 1회만 push 한다.
|
|
@@ -184,7 +204,7 @@ export default function Popup(props) {
|
|
|
184
204
|
e.preventDefault(); }, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}${height != null ? ' popup-panel--has-height' : ''}${fillBody ? ' popup-panel--fill-body' : ''}`,
|
|
185
205
|
/* 높이는 **CSS 변수로** 넘긴다 — 인라인 height 로 박으면 앱의 미디어 규칙(좁은 화면만 다른 높이)이 못 이겼다.
|
|
186
206
|
max-height(CSS)가 height 보다 이기므로 화면보다 크게 줘도 화면에 맞춰진다 */
|
|
187
|
-
style: height != null ? { '--popup-height': typeof height === 'number' ? `${height}px` : height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && title ? titleId : undefined, "aria-label": !inline && !
|
|
207
|
+
style: height != null ? { '--popup-height': typeof height === 'number' ? `${height}px` : height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && useTitle && title ? titleId : undefined, "aria-label": !inline && !(useTitle && title) ? (ariaLabel ?? (typeof title === 'string' ? title : undefined)) : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { htmlType: "button", size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
|
|
188
208
|
if (inline || typeof document === 'undefined')
|
|
189
209
|
return el;
|
|
190
210
|
return createPortal(el, document.body);
|
|
@@ -46,7 +46,7 @@ export default function ProgressBar({ value, max = 100, size = 8, color, trackCo
|
|
|
46
46
|
rootStyle['--progress-bar-fill'] = color;
|
|
47
47
|
if (trackColor)
|
|
48
48
|
rootStyle['--progress-bar-track'] = trackColor;
|
|
49
|
-
return (_jsxs("div", { className: cx('progress-bar', `progress-bar--${size}`, `progress-bar--label-${pos}`, label && `progress-bar--align-${labelAlign}`, label && labelOnFill && 'progress-bar--label-on-fill', className), style: rootStyle, children: [_jsx("div", { className: "progress-bar__track", role: "progressbar", "aria-valuenow": clamped, "aria-valuemin": 0, "aria-valuemax": shownMax, "aria-label": ariaLabel, children: _jsx("div", { className: "progress-bar__fill", style: { width: `${pct}%` } }) }), label && (_jsx("span", { className: "progress-bar__label caption-1b", children: formatValue
|
|
49
|
+
return (_jsxs("div", { className: cx('progress-bar', `progress-bar--${size}`, `progress-bar--label-${pos}`, label && `progress-bar--align-${labelAlign}`, label && labelOnFill && 'progress-bar--label-on-fill', className), style: rootStyle, children: [_jsx("div", { className: "progress-bar__track", role: "progressbar", "aria-valuenow": clamped, "aria-valuemin": 0, "aria-valuemax": shownMax, "aria-valuetext": !label ? undefined : formatValue ? formatValue(clamped) : label === 'percent' ? undefined : label === 'raw' ? String(clamped) : `${clamped} / ${shownMax}`, "aria-label": ariaLabel, children: _jsx("div", { className: "progress-bar__fill", style: { width: `${pct}%` } }) }), label && (_jsx("span", { className: "progress-bar__label caption-1b", children: formatValue
|
|
50
50
|
? formatValue(clamped)
|
|
51
51
|
: label === 'percent' ? `${Math.round(pct)}%`
|
|
52
52
|
: label === 'raw' ? String(clamped)
|
|
@@ -51,7 +51,7 @@ function SideNavButton(props) {
|
|
|
51
51
|
}
|
|
52
52
|
onClick?.(key, e.currentTarget, fromPointer);
|
|
53
53
|
}, [isDisabled, key, onClick]);
|
|
54
|
-
return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", role: "img", "aria-label": t('loader.loading', '불러오는 중…') }))] }));
|
|
54
|
+
return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, "aria-current": active ? 'page' : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", role: "img", "aria-label": t('loader.loading', '불러오는 중…') }))] }));
|
|
55
55
|
}
|
|
56
56
|
/** @public */
|
|
57
57
|
export default function SideNav(props) {
|
|
@@ -85,8 +85,17 @@ export default function Slider({ value, onChange, min = 0, max = 100, step = 1,
|
|
|
85
85
|
style.backgroundPosition = positions.join(', ');
|
|
86
86
|
style.backgroundRepeat = 'no-repeat';
|
|
87
87
|
}
|
|
88
|
+
/* 낭독기가 들을 값 글자 — 숫자만 넘기면 "1.25x"·"위"·"3 / 7" 이 "1.25"·"0"·"3" 으로 들렸다. 보이는 라벨과 같은 글자,
|
|
89
|
+
라벨이 없으면 끝값의 minLabel·maxLabel. 둘 다 아니면 숫자 그대로(undefined). */
|
|
90
|
+
const 값글자 = formatValue
|
|
91
|
+
? formatValue(clamped)
|
|
92
|
+
: label === 'percent' ? `${Math.round(max > min ? ((clamped - min) / (max - min)) * 100 : 0)}%`
|
|
93
|
+
: label === 'count' ? `${clamped} / ${max}`
|
|
94
|
+
: clamped === min && minLabel ? minLabel
|
|
95
|
+
: clamped === max && maxLabel ? maxLabel
|
|
96
|
+
: undefined;
|
|
88
97
|
// 루트가 아닐 때(label/showMinMax로 감싸질 때)는 className을 바깥 wrapper가 대신 가져간다
|
|
89
|
-
const input = (_jsx("input", { type: "range", className: cx('slider', `slider--${orientation}`, isStatic && 'slider--static', !label && !showMinMax && className), min: min, max: max, step: step, value: clamped, disabled: disabled, readOnly: isStatic, tabIndex: isStatic ? -1 : undefined, "aria-label": ariaLabel, "aria-orientation": orientation, onChange: handleChange, style: style }));
|
|
98
|
+
const input = (_jsx("input", { type: "range", className: cx('slider', `slider--${orientation}`, isStatic && 'slider--static', !label && !showMinMax && className), min: min, max: max, step: step, value: clamped, disabled: disabled, readOnly: isStatic, tabIndex: isStatic ? -1 : undefined, "aria-label": ariaLabel, "aria-valuetext": 값글자, "aria-orientation": orientation, onChange: handleChange, style: style }));
|
|
90
99
|
if (!label && !showMinMax)
|
|
91
100
|
return input;
|
|
92
101
|
// 트랙 양 끝 최소·최대값 텍스트 — 세로는 채움 방향(아래→위)에 맞춰 위=max/아래=min 순서로 배치
|
|
@@ -248,6 +248,8 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
248
248
|
if (dragRef.current) {
|
|
249
249
|
window.removeEventListener('pointermove', onResizeMove);
|
|
250
250
|
window.removeEventListener('pointerup', onResizeEnd);
|
|
251
|
+
// 나중에 더한 pointercancel 도 뗀다 — 남으면 태블릿 스크롤 손짓(pointercancel)에 떠난 표의 옛 폭이 저장소를 덮었다
|
|
252
|
+
window.removeEventListener('pointercancel', onResizeEnd);
|
|
251
253
|
document.body.style.cursor = '';
|
|
252
254
|
document.body.style.userSelect = '';
|
|
253
255
|
}
|
|
@@ -12,7 +12,9 @@ import './TextField.css';
|
|
|
12
12
|
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
13
13
|
/** @public */
|
|
14
14
|
export default function TextField(props) {
|
|
15
|
-
const { value, defaultValue, name, inputRef, onChange, label, ariaLabel, required, placeholder, type = 'text', disabled = false, readOnly = false, error, helperText, maxLength, size = 48, variant = 'input', fullWidth = false, newline = false, multiline = false, rows = 3, leftIcon, rightIcon, clearable = false, list, showCount = true, className = '', onFocus, onBlur, onKeyDown, onEnter, inputMode, autoComplete, autoFocus, suggestions, openOnFocus = false, } = props;
|
|
15
|
+
const { value, defaultValue, name, inputRef, onChange, label, ariaLabel, required, placeholder, type = 'text', disabled = false, readOnly = false, error, helperText, maxLength, size = 48, variant = 'input', fullWidth = false, newline = false, multiline = false, rows = 3, leftIcon, rightIcon, clearable = false, list, showCount = true, className = '', onFocus, onBlur, onKeyDown, onEnter, inputMode, autoComplete, autoFocus, suggestions: suggestionsProp, openOnFocus = false, } = props;
|
|
16
|
+
// 읽기 전용·꺼진 칸은 후보로 값을 바꾸지 못한다 — 전에는 ↓·Enter·후보 누름으로 onChange 가 나갔다
|
|
17
|
+
const suggestions = readOnly || disabled ? undefined : suggestionsProp;
|
|
16
18
|
const { t } = useTranslation();
|
|
17
19
|
const uid = useId();
|
|
18
20
|
/* **이름 없는 입력칸은 화면을 못 보는 사람에게 빈칸이다.** placeholder 는 이름이
|
|
@@ -9,7 +9,9 @@ import SegmentedControl from './SegmentedControl';
|
|
|
9
9
|
import './Toolbar.css';
|
|
10
10
|
export default function Toolbar({ count, search, segment, filters, pageSize, pagination, actions, className, }) {
|
|
11
11
|
const { t } = useTranslation();
|
|
12
|
-
const countNode = count != null && (
|
|
12
|
+
const countNode = count != null && (
|
|
13
|
+
// 검색어를 칠 때 바뀌는 결과 수를 낭독기에 알린다(전에는 조용했다)
|
|
14
|
+
_jsxs("p", { className: "toolbar__count body-1", role: "status", "aria-atomic": "true", children: [count.prefix ?? t('common.totalPrefix', '총 '), _jsx("span", { className: "toolbar__count-highlight", children: count.value }), count.unit, count.suffix] }));
|
|
13
15
|
// 좌측 그룹: count → segment. 우측 그룹: search → filters(정렬 등) → pageSize → pagination → actions
|
|
14
16
|
const hasRight = !!(search || filters?.length || pageSize || pagination || actions);
|
|
15
17
|
return (_jsxs("div", { className: cx('toolbar', className), children: [_jsxs("div", { className: "toolbar__row", children: [countNode, segment && (Array.isArray(segment) ? segment : [segment]).map((seg, i) => (_jsx(SegmentedControl, { size: 40, ...seg }, seg.ariaLabel ?? i)))] }), hasRight && (_jsx("div", { className: "toolbar__row", children: _jsxs("div", { className: "toolbar__row-end", children: [search && (_jsx(TextField, { className: "toolbar__search", size: 40, value: search.value, onChange: search.onChange, placeholder: search.placeholder ?? t('common.search', '검색'),
|
|
@@ -82,7 +82,7 @@ export default function Tooltip(props) {
|
|
|
82
82
|
top = rect.top + rect.height / 2 - h / 2;
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
const m = Number(edgeMargin)
|
|
85
|
+
const m = Number.isFinite(Number(edgeMargin)) ? Number(edgeMargin) : 8; // 0 도 값이다(`|| 8` 이 0 을 8 로 바꿨다)
|
|
86
86
|
left = Math.min(Math.max(left, m), vw - w - m);
|
|
87
87
|
top = Math.min(Math.max(top, m), vh - h - m);
|
|
88
88
|
const leftRounded = Math.round(left);
|
|
@@ -201,6 +201,7 @@ export default function Tooltip(props) {
|
|
|
201
201
|
// 배경 자신의 onClick 과 document 바깥클릭 감지가 함께 걸려 실제로 onClose 가 두 번
|
|
202
202
|
// 불렸다. ref 로 "이미 닫는 중"을 즉시 표시해 한 번만 지나가게 한다(Popup 과 같은 방식).
|
|
203
203
|
const closingRef = useRef(false);
|
|
204
|
+
const 닫기타이머Ref = useRef(null);
|
|
204
205
|
const startClose = useCallback((immediate = false) => {
|
|
205
206
|
/* 닫기를 넘길 곳(onClose)이 없으면 아무것도 하지 않는다. 예전에는 그래도 is-closing 을
|
|
206
207
|
붙여, 부모가 치우지 않는 툴팁이 **흐려진 채 사라진 것처럼** 그 자리에 남았다
|
|
@@ -225,7 +226,9 @@ export default function Tooltip(props) {
|
|
|
225
226
|
return;
|
|
226
227
|
}
|
|
227
228
|
setClosing(true);
|
|
228
|
-
|
|
229
|
+
// 떠날 때 걷는다 — ESC 로 닫는 200ms 안에 부모가 치우면 떠난 뒤에 onClose 가 불렸다
|
|
230
|
+
닫기타이머Ref.current = setTimeout(() => {
|
|
231
|
+
닫기타이머Ref.current = null;
|
|
229
232
|
try {
|
|
230
233
|
if (onClose)
|
|
231
234
|
onClose();
|
|
@@ -236,6 +239,8 @@ export default function Tooltip(props) {
|
|
|
236
239
|
}
|
|
237
240
|
}, 200);
|
|
238
241
|
}, [closing, onClose, setClosing]);
|
|
242
|
+
useEffect(() => () => { if (닫기타이머Ref.current)
|
|
243
|
+
clearTimeout(닫기타이머Ref.current); }, []);
|
|
239
244
|
useEffect(() => {
|
|
240
245
|
/* 제자리(글 흐름 안)에 그린 툴팁은 떠 있는 층이 아니라 본문의 한 조각이다 — document 의
|
|
241
246
|
바깥 누름·Esc·Tab 을 듣지 않는다. 들으면 화면 아무 데나 누르거나 Esc 를 칠 때마다
|
|
@@ -78,7 +78,7 @@ export type TrainingPagePopupProps = {
|
|
|
78
78
|
* 판에 실제로 걸린 값은 `NoiseSettingsStore.applied` 로 받는다.
|
|
79
79
|
*/
|
|
80
80
|
noiseOverride?: Partial<CoreNoiseSettings> | ((saved: CoreNoiseSettings) => Partial<CoreNoiseSettings>);
|
|
81
|
-
/** 훈련 결과 팝업 렌더러 — 없으면 결과
|
|
81
|
+
/** 훈련 결과 팝업 렌더러 — 없으면 기본 결과 팝업(TrainingResultPopup)을 띄운다 */
|
|
82
82
|
renderResultPopup?: (props: {
|
|
83
83
|
score?: number | null;
|
|
84
84
|
results?: unknown[];
|
|
@@ -5,6 +5,7 @@ import { PAGE_POPUP_CLOSE_MS } from './pagePopupTiming';
|
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
import { createPortal } from "react-dom";
|
|
7
7
|
import { useFocusTrap, FOCUSABLE_SELECTOR } from '../../hooks/useFocusTrap';
|
|
8
|
+
import { rememberFocusReturn, resolveFocusReturn } from '../../hooks/focusReturn';
|
|
8
9
|
import { pushEsc } from '../../hooks/escStack';
|
|
9
10
|
import Toast from '../Toast';
|
|
10
11
|
import Button from '../Button';
|
|
@@ -240,6 +241,7 @@ function TrainingPagePopupInner(props) {
|
|
|
240
241
|
useEffect(() => {
|
|
241
242
|
const panel = panelRef.current;
|
|
242
243
|
const previouslyFocused = document.activeElement;
|
|
244
|
+
rememberFocusReturn(panel, previouslyFocused);
|
|
243
245
|
if (panel) {
|
|
244
246
|
const firstEl = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => !el.closest('[aria-hidden="true"]'))[0];
|
|
245
247
|
if (firstEl) {
|
|
@@ -254,7 +256,7 @@ function TrainingPagePopupInner(props) {
|
|
|
254
256
|
const active = document.activeElement;
|
|
255
257
|
if (!active || active === document.body || (panel && panel.contains(active))) {
|
|
256
258
|
try {
|
|
257
|
-
previouslyFocused?.focus?.();
|
|
259
|
+
resolveFocusReturn(previouslyFocused)?.focus?.();
|
|
258
260
|
}
|
|
259
261
|
catch (e) { }
|
|
260
262
|
}
|
|
@@ -10,6 +10,7 @@ import { resumeAudioMeter, routeElementAudio, setElementVolume, getElementVolume
|
|
|
10
10
|
import { useNoisePlayer, effectiveNoiseVolume, effectiveSpeechVolume, noiseRatioOf } from './NoisePlayerContext';
|
|
11
11
|
import { useNoiseFade } from './useNoiseFade';
|
|
12
12
|
import { useTTSPlayback } from './useTTSPlayback';
|
|
13
|
+
import { claimSound, stopSoundIfOwner } from './currentSound';
|
|
13
14
|
import './TrainingStepAudioPlayer.css';
|
|
14
15
|
import WaveForm from './AudioPlayerWaveForm';
|
|
15
16
|
import Toast from '../Toast';
|
|
@@ -157,7 +158,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
157
158
|
audioRef,
|
|
158
159
|
playbackStateRef,
|
|
159
160
|
});
|
|
160
|
-
const { startTTS
|
|
161
|
+
const { startTTS: 합성시작, stopTTS } = useTTSPlayback({
|
|
161
162
|
ttsText,
|
|
162
163
|
playbackRate,
|
|
163
164
|
// TTS 음성 = 문제 소리 = 전체 소리
|
|
@@ -173,6 +174,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
173
174
|
onDoneRef,
|
|
174
175
|
handleEndRef,
|
|
175
176
|
});
|
|
177
|
+
useEffect(() => { stopTTSRef.current = stopTTS; });
|
|
178
|
+
const startTTS = (text) => { 소리잡기('tts'); 합성시작(text); };
|
|
176
179
|
const requestedSources = Array.isArray(soundSrc)
|
|
177
180
|
? soundSrc.filter(Boolean)
|
|
178
181
|
: (soundSrc ? [soundSrc] : []);
|
|
@@ -192,12 +195,50 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
192
195
|
오류" 로만 보고 다시 부르면 **정지를 눌렀는데 소리가 다시 났다**. 시작할 때의 의도가 그대로일 때만 다시 시도한다. */
|
|
193
196
|
const 재생의도Ref = useRef(0);
|
|
194
197
|
const 멈춤의도 = () => { 재생의도Ref.current += 1; jumpTokenRef.current += 1; };
|
|
198
|
+
/* 스피커는 하나(currentSound.ts) — 한 화면의 다른 재생기(문장 듣기·문제 듣기)·낱말 카드·미리 듣기가 울리면 이 재생기는
|
|
199
|
+
멈춘다. 전에는 낱말 카드만 자물쇠를 잡아, 듣기 단계가 둘인 화면·듣기+카드 단계에서 두 소리가 함께 울렸다.
|
|
200
|
+
멈춤 단추와 같게 멈춘다(이어 듣기 가능). 함수는 한 벌이어야 자물쇠가 '내 것' 을 알아본다. */
|
|
201
|
+
const stopTTSRef = useRef(null);
|
|
202
|
+
const 소리종류Ref = useRef(null);
|
|
203
|
+
const 소리놓기Ref = useRef(null);
|
|
204
|
+
const 소리멈춤Ref = useRef(null);
|
|
205
|
+
if (!소리멈춤Ref.current) {
|
|
206
|
+
소리멈춤Ref.current = () => {
|
|
207
|
+
소리놓기Ref.current = null;
|
|
208
|
+
const a = audioRef.current;
|
|
209
|
+
if (소리종류Ref.current === 'audio' && a && !a.paused) {
|
|
210
|
+
재생의도Ref.current += 1;
|
|
211
|
+
jumpTokenRef.current += 1;
|
|
212
|
+
try {
|
|
213
|
+
a.pause();
|
|
214
|
+
}
|
|
215
|
+
catch (e) { }
|
|
216
|
+
if (mountedRef.current)
|
|
217
|
+
setPlaybackState('paused');
|
|
218
|
+
}
|
|
219
|
+
else if (소리종류Ref.current === 'tts') {
|
|
220
|
+
try {
|
|
221
|
+
stopTTSRef.current?.();
|
|
222
|
+
}
|
|
223
|
+
catch (e) { }
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const 소리잡기 = (종류) => {
|
|
228
|
+
소리종류Ref.current = 종류;
|
|
229
|
+
소리놓기Ref.current = claimSound(소리멈춤Ref.current);
|
|
230
|
+
};
|
|
231
|
+
// 멈춤·정지·완료 뒤에는 자리를 비운다 — 남이 울릴 때 끝난 재생기의 상태를 건드리지 않게
|
|
232
|
+
const 소리놓기 = () => { 소리놓기Ref.current?.(); 소리놓기Ref.current = null; };
|
|
233
|
+
useEffect(() => () => { if (소리멈춤Ref.current)
|
|
234
|
+
stopSoundIfOwner(소리멈춤Ref.current); }, []);
|
|
195
235
|
const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
|
|
196
236
|
const 의도 = 재생의도Ref.current;
|
|
197
237
|
for (let i = 0; i < attempts; i++) {
|
|
198
238
|
if (!mountedRef.current || 의도 !== 재생의도Ref.current)
|
|
199
239
|
return false;
|
|
200
240
|
try {
|
|
241
|
+
소리잡기('audio');
|
|
201
242
|
await a.play();
|
|
202
243
|
return true;
|
|
203
244
|
}
|
|
@@ -731,6 +772,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
731
772
|
try {
|
|
732
773
|
if (synth && synth.paused) {
|
|
733
774
|
try {
|
|
775
|
+
소리잡기('tts');
|
|
734
776
|
synth.resume();
|
|
735
777
|
setPlaybackState('playing');
|
|
736
778
|
}
|
|
@@ -847,6 +889,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
847
889
|
const handleStop = () => {
|
|
848
890
|
// 기다리던 트랙 이동·재시도가 정지 뒤에 소리를 내지 않게
|
|
849
891
|
멈춤의도();
|
|
892
|
+
소리놓기();
|
|
850
893
|
if (ttsMode) {
|
|
851
894
|
stopTTS();
|
|
852
895
|
return;
|
|
@@ -949,6 +992,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
949
992
|
const outDelay = 340;
|
|
950
993
|
markCompletedByEnd(true);
|
|
951
994
|
멈춤의도();
|
|
995
|
+
소리놓기();
|
|
952
996
|
try {
|
|
953
997
|
setProgress(1);
|
|
954
998
|
}
|
|
@@ -1094,10 +1138,13 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1094
1138
|
const fillLeftPercent = Math.max(0, Math.min(100, (progress || 0) * 100));
|
|
1095
1139
|
const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
|
|
1096
1140
|
return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (_jsxs("div", { className: "audio-player__loading", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: t(`training.audioStatus.${fetchDebugStatus}`, { defaultValue: STATUS_MESSAGES[fetchDebugStatus] }) })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button",
|
|
1141
|
+
/* 막힌 단계(아직 차례가 아닌 듣기·합성 주소를 받는 중)는 CSS pointer-events 로만 막아, Tab 으로 와서 Enter 를
|
|
1142
|
+
누르면 소리를 받으러 가거나 "가져오지 못했습니다" 를 띄웠다 — 마우스와 같게 키보드도 막는다 */
|
|
1143
|
+
disabled: isDisabled,
|
|
1097
1144
|
// 키보드로 눌렀을 때만 초점을 옮긴다 — 마우스로 누른 사람에게 '재생 / 멈춤' 초점 툴팁이 뜨지 않게
|
|
1098
1145
|
onClick: () => { 듣기초점옮기기Ref.current = 키보드로눌렀나(); hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
|
|
1099
1146
|
// 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
|
|
1100
|
-
onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
|
|
1147
|
+
onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, inert: !(stateLocal === "playMode" || displayedPlaying) || undefined, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), "aria-pressed": displayedPlaying, width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
|
|
1101
1148
|
handleJumpTo(idx);
|
|
1102
1149
|
}
|
|
1103
1150
|
catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: 구간표시 && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, onSegmentClick: handleJumpTo, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
|
|
@@ -51,7 +51,15 @@ export default function WordTablePopup({ items, words, title, subtitle, emptyTex
|
|
|
51
51
|
새 함수 → 알림 이 끝없이 돈다. 부모가 어떻게 짜든 안 돌게 한다. */
|
|
52
52
|
const notifyRef = useRef(onScoreChange);
|
|
53
53
|
useEffect(() => { notifyRef.current = onScoreChange; });
|
|
54
|
-
|
|
54
|
+
// 처음 열 때는 알리지 않는다 — 아무것도 안 매겼는데 onScoreChange({}) 가 나갔다
|
|
55
|
+
const 첫알림Ref = useRef(true);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (첫알림Ref.current) {
|
|
58
|
+
첫알림Ref.current = false;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
notifyRef.current?.(marksForSave(marks));
|
|
62
|
+
}, [marks]);
|
|
55
63
|
/* 표에는 지운 칸(null)이 빠진 것만 넘긴다 — 표는 "매겨진 것"만 알면 된다. */
|
|
56
64
|
const 표에줄채점 = useMemo(() => marksForSave(marks), [marks]);
|
|
57
65
|
// 인쇄 — WordTable 내부 콘텐츠(.word-table 아래 그리드)를 격리 iframe으로 출력한다.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { resolveSoundPath, getSignedOrPublicUrl, downloadSound } from '../../utils/storage';
|
|
4
|
+
import { claimSound, stopSoundIfOwner } from './currentSound';
|
|
4
5
|
function derivePreviewMeta(trainingData, typeKey) {
|
|
5
6
|
if (!trainingData)
|
|
6
7
|
return { previewCodes: [], previewBucket: null };
|
|
@@ -51,6 +52,9 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
51
52
|
const [resolved, setResolved] = useState({ key: '', urls: [] });
|
|
52
53
|
const previewAudioRef = useRef(null);
|
|
53
54
|
const playingRef = useRef(false);
|
|
55
|
+
/* 지금 미리 듣기를 멈추는 함수 — 스피커 자물쇠(currentSound.ts)에 건다. 다른 블록 미리 듣기·훈련 소리가 울리면 이것이 불린다.
|
|
56
|
+
전에는 자물쇠에 참여하지 않아 바구니의 블록 둘을 잇달아 누르면 두 문장이 함께 울렸다. */
|
|
57
|
+
const 멈춤Ref = useRef(null);
|
|
54
58
|
const previewBtnRef = useRef(null);
|
|
55
59
|
const [previewHover, setPreviewHover] = useState(false);
|
|
56
60
|
// downloadSound()로 만든 blob URL만 추적 — getSignedOrPublicUrl()이 반환하는 http(s) URL은
|
|
@@ -129,6 +133,8 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
129
133
|
e.stopPropagation();
|
|
130
134
|
e.preventDefault();
|
|
131
135
|
if (playingRef.current) {
|
|
136
|
+
if (멈춤Ref.current)
|
|
137
|
+
stopSoundIfOwner(멈춤Ref.current);
|
|
132
138
|
playingRef.current = false;
|
|
133
139
|
try {
|
|
134
140
|
previewAudioRef.current?.pause();
|
|
@@ -141,6 +147,18 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
141
147
|
const urls = previewUrls.filter(Boolean);
|
|
142
148
|
if (!urls.length)
|
|
143
149
|
return;
|
|
150
|
+
// pause() 만으로는 ended 가 오지 않아 기다리던 약속이 풀리지 않는다 — 멈출 때 풀어 반복을 끝낸다
|
|
151
|
+
let 기다림풀기 = null;
|
|
152
|
+
const 멈춤 = () => {
|
|
153
|
+
playingRef.current = false;
|
|
154
|
+
try {
|
|
155
|
+
previewAudioRef.current?.pause();
|
|
156
|
+
}
|
|
157
|
+
catch { }
|
|
158
|
+
기다림풀기?.();
|
|
159
|
+
};
|
|
160
|
+
멈춤Ref.current = 멈춤;
|
|
161
|
+
const release = claimSound(멈춤);
|
|
144
162
|
playingRef.current = true;
|
|
145
163
|
setPlaying(true);
|
|
146
164
|
for (let i = 0; i < previewUrls.length; i++) {
|
|
@@ -152,6 +170,7 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
152
170
|
setPlayingIndex(i);
|
|
153
171
|
try {
|
|
154
172
|
await new Promise((resolve, reject) => {
|
|
173
|
+
기다림풀기 = resolve;
|
|
155
174
|
const audio = new Audio(url);
|
|
156
175
|
previewAudioRef.current = audio;
|
|
157
176
|
audio.onended = () => resolve();
|
|
@@ -163,13 +182,19 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
163
182
|
break;
|
|
164
183
|
}
|
|
165
184
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
185
|
+
release();
|
|
186
|
+
if (멈춤Ref.current === 멈춤) {
|
|
187
|
+
멈춤Ref.current = null;
|
|
188
|
+
playingRef.current = false;
|
|
189
|
+
setPlaying(false);
|
|
190
|
+
setPlayingIndex(-1);
|
|
191
|
+
}
|
|
169
192
|
}, [previewUrls]);
|
|
170
193
|
// Pause and release audio on unmount
|
|
171
194
|
useEffect(() => {
|
|
172
195
|
return () => {
|
|
196
|
+
if (멈춤Ref.current)
|
|
197
|
+
stopSoundIfOwner(멈춤Ref.current);
|
|
173
198
|
playingRef.current = false;
|
|
174
199
|
try {
|
|
175
200
|
previewAudioRef.current?.pause();
|