@ihabdevteam/core 0.137.0 → 0.139.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 +70 -0
- package/dist/components.cjs.js +175 -175
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +182 -182
- package/dist/ihabdevteam-core.d.ts +1 -1
- package/dist/packages/core/src/components/Button.js +10 -1
- package/dist/packages/core/src/components/PassCard/PassCard.js +1 -1
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -1
- package/dist/packages/core/src/components/Popup/Popup.js +26 -3
- package/dist/packages/core/src/components/Table.js +4 -1
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +23 -26
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -3
- package/dist/packages/core/src/components/Training/TrainingBlock.js +20 -3
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +30 -6
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +41 -4
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +9 -0
- package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +2 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +26 -6
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +18 -3
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +9 -3
- package/dist/packages/core/src/components/Training/useNoiseFade.js +3 -1
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +4 -0
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +3 -1
- package/dist/packages/core/src/components/WordTable.js +6 -2
- package/dist/packages/core/src/hooks/useEditableList.js +13 -5
- package/dist/packages/core/src/hooks/usePasswordChange.js +7 -0
- package/dist/packages/core/src/utils/speak.js +9 -0
- package/dist/packages/core/src/utils/wordPairIndex.js +11 -3
- 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
|
@@ -1376,7 +1376,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1376
1376
|
*
|
|
1377
1377
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1378
1378
|
*/
|
|
1379
|
-
export declare const CORE_VERSION = "0.
|
|
1379
|
+
export declare const CORE_VERSION = "0.139.0";
|
|
1380
1380
|
|
|
1381
1381
|
/** @public */
|
|
1382
1382
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -68,7 +68,16 @@ export default function Button(props) {
|
|
|
68
68
|
useEffect(() => {
|
|
69
69
|
if (prevDisabledRef.current && !disabledProp && btnRef.current) {
|
|
70
70
|
const btn = btnRef.current;
|
|
71
|
-
|
|
71
|
+
/* 마우스로 잡힌 초점만 푼다 — 키보드로 누른 단추(모두 듣기 등)가 막혔다 풀리는 순간 무조건 blur 해 초점이 body 로 빠졌다 */
|
|
72
|
+
let 마우스초점 = false;
|
|
73
|
+
try {
|
|
74
|
+
마우스초점 = btn.matches(':focus:not(:focus-visible)');
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
마우스초점 = false;
|
|
78
|
+
}
|
|
79
|
+
if (마우스초점)
|
|
80
|
+
btn.blur();
|
|
72
81
|
btn.style.pointerEvents = 'none';
|
|
73
82
|
const id = requestAnimationFrame(() => {
|
|
74
83
|
requestAnimationFrame(() => {
|
|
@@ -303,7 +303,7 @@ export default function PassCard({ product, showPrice = false, plan, type, endDa
|
|
|
303
303
|
...(onDelete ? [{ label: t('passCard.menu.delete', '삭제하기'), icon: 'trash-2', onClick: () => { onDelete(); setMenuAnchor(null); }, closeOnSelect: true }] : []),
|
|
304
304
|
],
|
|
305
305
|
}];
|
|
306
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "pass-card-frame", children: _jsxs("div", { ref: cardRef, className: `pass-card pass-card--${state}${hasMenu ? ' pass-card--clickable' : ''}`, style: cardStyle, onMouseEnter: handleMouseEnter, onMouseMove: handleMouseMove, onMouseLeave: handleMouseLeave, onClick: handleCardClick, onKeyDown: hasMenu ? handleCardKeyDown : undefined, role: hasMenu ? 'button' : undefined, tabIndex: hasMenu ? 0 : undefined, children: [bg ?? getDefaultBg(rType, rPlan, rLabel), !bgOnly && (_jsxs(_Fragment, { children: [_jsx("div", { className: "pass-card__top", children: _jsxs("div", { className: "pass-card__top-left", children: [_jsx("img", { src: resolvedLogoSrc ?? LOGO[rType], className: "pass-card__logo", alt: rType === 'manager'
|
|
306
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "pass-card-frame", children: _jsxs("div", { ref: cardRef, className: `pass-card pass-card--${state}${hasMenu ? ' pass-card--clickable' : ''}`, style: cardStyle, onMouseEnter: handleMouseEnter, onMouseMove: handleMouseMove, onMouseLeave: handleMouseLeave, onClick: handleCardClick, onKeyDown: hasMenu ? handleCardKeyDown : undefined, role: hasMenu ? 'button' : undefined, tabIndex: hasMenu ? 0 : undefined, "aria-haspopup": hasMenu ? 'menu' : undefined, "aria-expanded": hasMenu ? !!menuAnchor : undefined, children: [bg ?? getDefaultBg(rType, rPlan, rLabel), !bgOnly && (_jsxs(_Fragment, { children: [_jsx("div", { className: "pass-card__top", children: _jsxs("div", { className: "pass-card__top-left", children: [_jsx("img", { src: resolvedLogoSrc ?? LOGO[rType], className: "pass-card__logo", alt: rType === 'manager'
|
|
307
307
|
? t('header.logoManager', '말귀매니저 로고')
|
|
308
308
|
: t('header.logoMalgwi', '말귀 로고') }), _jsxs("div", { className: "pass-card__plan", children: [_jsx("div", { children: planDisplay }), _jsx("span", { className: "h5b pass-card__plan-suffix", children: rLabel ? t('passCard.labeledPlan', { label: rLabel }) : t('passCard.planSuffix', '이용권') })] })] }) }), rPrice
|
|
309
309
|
? (_jsxs("div", { className: "pass-card__price-area", children: [rDiscountLabel && _jsx("span", { className: "pass-card__discount body-3", children: rDiscountLabel }), _jsxs("div", { className: "pass-card__price", children: [rOriginalPrice && _jsx("span", { className: "pass-card__price-original body-3", children: toWonSymbol(rOriginalPrice) }), priceDisplay] })] }))
|
|
@@ -82,7 +82,9 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
|
|
|
82
82
|
animateThen(() => onLoad(table));
|
|
83
83
|
}, [tables, selected, onLoad]);
|
|
84
84
|
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
|
-
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",
|
|
85
|
+
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
|
+
// 고른 표가 지금 목록에 있을 때만 — 부모 목록에서 빠진 표를 가리키면 눌러도 아무 일이 없었다
|
|
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
|
|
86
88
|
? `${item.words[0].label} · ${item.words[1].label}`
|
|
87
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))) }))] })) }) }));
|
|
88
90
|
}
|
|
@@ -105,18 +105,41 @@ export default function Popup(props) {
|
|
|
105
105
|
/* Tab 순환을 패널 안으로 가둔다 — 네 곳에 있던 같은 열두 줄을 훅 한 벌로 모았다.
|
|
106
106
|
inline 으로 쓸 때는 다이얼로그가 아니라 걸지 않는다(예전과 같다). */
|
|
107
107
|
useFocusTrap(panelRef, !inline);
|
|
108
|
+
/* 닫힘 애니메이션 뒤 콜백을 부른다. **콜백이 던지거나 거절되면, 또는 콜백 뒤에도 이 창이 그대로 있으면 되살린다** —
|
|
109
|
+
전에는 닫는 중 표시만 남아 투명한 막이 화면을 덮고(클릭을 가로챔) ESC·X·바탕·다시 저장이 모두 무시돼 페이지를 쓸 수
|
|
110
|
+
없었다(말귀 업체 정보·훈련 묶음 수정 저장이 실패해 부모가 창을 열어 둘 때). 성공하면 부모가 곧 치우므로 되살릴 일이 없다. */
|
|
111
|
+
const 살아있음Ref = useRef(true);
|
|
112
|
+
useEffect(() => { 살아있음Ref.current = true; return () => { 살아있음Ref.current = false; }; }, []);
|
|
113
|
+
const 되살리기 = useCallback(() => {
|
|
114
|
+
if (!살아있음Ref.current || !closingRef.current)
|
|
115
|
+
return;
|
|
116
|
+
closingRef.current = false;
|
|
117
|
+
setClosing(false);
|
|
118
|
+
}, []);
|
|
108
119
|
const animateThen = useCallback((cb) => {
|
|
109
120
|
if (closingRef.current)
|
|
110
121
|
return;
|
|
111
122
|
closingRef.current = true;
|
|
112
123
|
setClosing(true);
|
|
113
124
|
setTimeout(() => {
|
|
125
|
+
let 결과;
|
|
114
126
|
try {
|
|
115
|
-
cb
|
|
127
|
+
결과 = cb ? cb() : undefined;
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
되살리기();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// 부모가 치울 틈(렌더·커밋)을 준 뒤에도 남아 있으면 되살린다
|
|
134
|
+
const 남았으면 = () => { setTimeout(되살리기, 120); };
|
|
135
|
+
if (결과 && typeof 결과.then === 'function') {
|
|
136
|
+
결과.then(남았으면, 되살리기);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
남았으면();
|
|
116
140
|
}
|
|
117
|
-
catch (e) { }
|
|
118
141
|
}, ANIM_MS);
|
|
119
|
-
}, []);
|
|
142
|
+
}, [되살리기]);
|
|
120
143
|
const closeWithAnimation = useCallback(() => {
|
|
121
144
|
if (closeDisabled)
|
|
122
145
|
return;
|
|
@@ -299,7 +299,10 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
299
299
|
? { '--table-grid-cols': effectiveGrid }
|
|
300
300
|
: undefined;
|
|
301
301
|
const hasHeader = title !== undefined;
|
|
302
|
-
return (_jsx("div", { className: `table-wrap table-wrap--${size}${폭조절 ? ' table-wrap--resizable' : ''}${dragOver ? ' table-wrap--dragover' : ''}${collapsed ? ' table-wrap--collapsed' : ''}`, style: gridStyle, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: _jsxs("div", { className: "table body-2", children: [hasHeader && (_jsxs("div", { className: "table-header", onClick: onToggleCollapse, role: onToggleCollapse ? 'button' : undefined, tabIndex: onToggleCollapse ? 0 : undefined, "aria-expanded": onToggleCollapse ? !collapsed : undefined,
|
|
302
|
+
return (_jsx("div", { className: `table-wrap table-wrap--${size}${폭조절 ? ' table-wrap--resizable' : ''}${dragOver ? ' table-wrap--dragover' : ''}${collapsed ? ' table-wrap--collapsed' : ''}`, style: gridStyle, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: _jsxs("div", { className: "table body-2", children: [hasHeader && (_jsxs("div", { className: "table-header", onClick: onToggleCollapse, role: onToggleCollapse ? 'button' : undefined, tabIndex: onToggleCollapse ? 0 : undefined, "aria-expanded": onToggleCollapse ? !collapsed : undefined,
|
|
303
|
+
// 머리 안 단추(headerActions)에서 누른 키는 그 단추 몫이다 — 가로채면 표가 접히고 단추 동작은 막혔다(TableRow 와 같게)
|
|
304
|
+
onKeyDown: onToggleCollapse ? (e) => { if (e.target !== e.currentTarget)
|
|
305
|
+
return; if (e.key === 'Enter' || e.key === ' ') {
|
|
303
306
|
e.preventDefault();
|
|
304
307
|
onToggleCollapse();
|
|
305
308
|
} } : undefined, children: [onToggleCollapse && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "table-header__chevron" })), icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "table-header__icon" })), _jsx("span", { className: "table-header__title h5b", children: title }), count !== undefined && _jsx("span", { className: "table-header__count body-3b", children: count }), headerActions && (_jsx("span", { className: "table-header__actions", onClick: e => e.stopPropagation(), children: headerActions }))] })), !collapsed && (_jsxs(_Fragment, { children: [headerGroups && headerGroups.length > 0 && (_jsx("div", { className: "table-fields table-fields--groups", children: headerGroups.map((g, i) => (_jsx("div", { className: cx('table-field body-3', g.className), style: { gridColumn: `span ${g.span}` }, children: g.label }, i))) })), _jsxs("div", { className: cx('table-fields-strip', 폭조절 && 'table-fields-strip--resizable'), children: [_jsx("div", { className: "table-fields", ref: fieldsRef, children: fields.map((f, i) => (_jsx(TableField, { onClick: f.onClick, className: f.className, sortDir: f.sortDir,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback, useState, useEffect, useRef } from 'react';
|
|
3
|
+
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import Button from '../Button';
|
|
6
6
|
import Badge from '../Badge';
|
|
@@ -9,28 +9,25 @@ import './MenuTooltip.css';
|
|
|
9
9
|
export default function MenuTooltip({ columns = [], onClose, className = '', size = 'medium' }) {
|
|
10
10
|
const { t } = useTranslation();
|
|
11
11
|
const itemSize = size === 'small' ? 40 : 48;
|
|
12
|
-
|
|
12
|
+
/* 하위 메뉴는 **어느 항목으로 들어왔는지(경로)** 만 들고, 목록은 매 렌더 지금의 columns 에서 꺼낸다 — 전에는 들어갈 때
|
|
13
|
+
children 을 복사해 두어, 부모가 선택 표시(이름 → 날짜)를 바꿔도 하위 목록은 옛 체크였다. 경로의 항목이 사라졌으면
|
|
14
|
+
있는 데까지만 보인다. */
|
|
15
|
+
const [path, setPath] = useState([]);
|
|
16
|
+
const stack = useMemo(() => {
|
|
17
|
+
const out = [{ title: null, columns }];
|
|
18
|
+
let cols = columns;
|
|
19
|
+
for (const p of path) {
|
|
20
|
+
const item = cols?.[p.ci]?.items?.[p.ii];
|
|
21
|
+
if (!item || !Array.isArray(item.children) || !item.children.length)
|
|
22
|
+
break;
|
|
23
|
+
cols = [{ title: null, items: item.children }];
|
|
24
|
+
out.push({ title: item.label || null, columns: cols });
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}, [columns, path]);
|
|
13
28
|
const current = stack[stack.length - 1];
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
/* eslint-disable react-hooks/set-state-in-effect */
|
|
17
|
-
useEffect(() => {
|
|
18
|
-
setStack((prev) => {
|
|
19
|
-
try {
|
|
20
|
-
if (!Array.isArray(prev) || prev.length === 0)
|
|
21
|
-
return [{ title: null, columns }];
|
|
22
|
-
const next = prev.slice();
|
|
23
|
-
const rootTitle = next[0] && typeof next[0].title !== 'undefined' ? next[0].title : null;
|
|
24
|
-
next[0] = { title: rootTitle, columns };
|
|
25
|
-
return next;
|
|
26
|
-
}
|
|
27
|
-
catch (e) {
|
|
28
|
-
return [{ title: null, columns }];
|
|
29
|
-
}
|
|
30
|
-
});
|
|
31
|
-
}, [columns]);
|
|
32
|
-
/* eslint-enable react-hooks/set-state-in-effect */
|
|
33
|
-
const goBack = useCallback(() => setStack((s) => (s.length > 1 ? s.slice(0, s.length - 1) : s)), []);
|
|
29
|
+
const 보이는깊이 = stack.length - 1;
|
|
30
|
+
const goBack = useCallback(() => setPath((p) => p.slice(0, Math.max(0, Math.min(p.length, 보이는깊이) - 1))), [보이는깊이]);
|
|
34
31
|
// 키보드 포커스 — Tooltip은 body로 포털되므로, 포커스를 옮겨주지 않으면 ⋯ 버튼을 누른
|
|
35
32
|
// 키보드 사용자가 메뉴에 닿으려면 페이지 끝까지 Tab 해야 한다(사실상 도달 불가).
|
|
36
33
|
// Tooltip에 이미 있는 ESC·Tab 트랩과 짝을 이루도록 첫 항목으로 포커스를 넣고,
|
|
@@ -64,11 +61,11 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
|
|
|
64
61
|
return () => cancelAnimationFrame(raf);
|
|
65
62
|
}, [depth]);
|
|
66
63
|
useEffect(() => () => { openerRef.current?.focus?.({ preventScroll: true }); }, []);
|
|
67
|
-
const handleClick = useCallback((item) => {
|
|
64
|
+
const handleClick = useCallback((item, ci, ii) => {
|
|
68
65
|
if (!item)
|
|
69
66
|
return;
|
|
70
67
|
if (Array.isArray(item.children) && item.children.length) {
|
|
71
|
-
|
|
68
|
+
setPath((p) => [...p.slice(0, 보이는깊이), { ci, ii }]);
|
|
72
69
|
return;
|
|
73
70
|
}
|
|
74
71
|
try {
|
|
@@ -83,7 +80,7 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
|
|
|
83
80
|
onClose();
|
|
84
81
|
}
|
|
85
82
|
catch (err) { }
|
|
86
|
-
}, [onClose]);
|
|
83
|
+
}, [onClose, 보이는깊이]);
|
|
87
84
|
return (_jsxs("div", { className: `tooltip-menu tooltip-menu--${size} ${className}`, children: [(stack.length > 1 || current.title) && (_jsxs("div", { className: "tooltip-menu__header", children: [stack.length > 1 ? (_jsx(Button, { htmlType: "button", size: itemSize, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: goBack, variant: "transparent-gray", useBorder: false })) : null, _jsx("div", { className: "tooltip-menu__title button-1", children: current.title })] })), _jsx("div", { className: "tooltip-menu__columns", ref: itemsWrapRef, children: current.columns && current.columns.map((col, ci) => (_jsxs("div", { className: "tooltip-menu__column", children: [col.title ? _jsx("div", { className: "tooltip-menu__column-title body-3", children: col.title }) : null, _jsx("div", { className: "tooltip-menu__items", children: (col.items || []).map((item, idx) => (_jsx(Button, { htmlType: "button", className: "tooltip-menu__item", size: itemSize, type: item.label ? 'bar' : 'dot', icon: item.selected ? 'check' : (item.icon || 'none'), rightIcon: Array.isArray(item.children) && item.children.length
|
|
88
85
|
? 'chevron-right'
|
|
89
86
|
: item.badgeCount
|
|
@@ -93,5 +90,5 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
|
|
|
93
90
|
(SideNav·BottomNav 와 같은 nav.badgeLabel "알림, 5개"). */
|
|
94
91
|
ariaLabel: item.label && item.badgeCount && item.badgeCount > 0
|
|
95
92
|
? t('nav.badgeLabel', { label: item.label, count: item.badgeCount, defaultValue: '{{label}}, {{count}}개' })
|
|
96
|
-
: item.label, onClick: () => handleClick(item), selected: item.selected, disabled: item.disabled, showTooltipInfo: item.tooltip || false, useBorder: false, align: "left" }, idx))) })] }, ci))) })] }));
|
|
93
|
+
: item.label, onClick: () => handleClick(item, ci, idx), selected: item.selected, disabled: item.disabled, showTooltipInfo: item.tooltip || false, useBorder: false, align: "left" }, idx))) })] }, ci))) })] }));
|
|
97
94
|
}
|
|
@@ -193,17 +193,27 @@ export default function Tooltip(props) {
|
|
|
193
193
|
if (immediate) {
|
|
194
194
|
// Synchronously close without waiting for the closing animation.
|
|
195
195
|
setClosing(true);
|
|
196
|
+
// onClose 가 던지면 닫는 중 표시를 되돌린다 — 투명한 채 남아 이후 닫기가 모두 무시됐다
|
|
196
197
|
try {
|
|
197
198
|
if (onClose)
|
|
198
199
|
onClose();
|
|
199
200
|
}
|
|
200
|
-
catch (err) {
|
|
201
|
+
catch (err) {
|
|
202
|
+
closingRef.current = false;
|
|
203
|
+
setClosing(false);
|
|
204
|
+
}
|
|
201
205
|
return;
|
|
202
206
|
}
|
|
203
207
|
setClosing(true);
|
|
204
208
|
setTimeout(() => {
|
|
205
|
-
|
|
206
|
-
onClose
|
|
209
|
+
try {
|
|
210
|
+
if (onClose)
|
|
211
|
+
onClose();
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
closingRef.current = false;
|
|
215
|
+
setClosing(false);
|
|
216
|
+
}
|
|
207
217
|
}, 200);
|
|
208
218
|
}, [closing, onClose, setClosing]);
|
|
209
219
|
useEffect(() => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useState, useMemo } from 'react';
|
|
3
|
+
import { useEffect, useRef, useState, useMemo } from 'react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import Button from '../Button';
|
|
6
6
|
import Section from '../Section';
|
|
@@ -29,8 +29,25 @@ export default function TrainingBlock(props) {
|
|
|
29
29
|
}, [total]);
|
|
30
30
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
31
31
|
useWindowEvent('speechear.data.updated', () => setDataTick((v) => v + 1));
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
/* 마지막 '더보기' 는 누르면 사라진다 — 초점이 거기 있었으면 새로 붙은 첫 항목으로 옮긴다(안 옮기면 body 로 떨어졌다) */
|
|
33
|
+
const 목록Ref = useRef(null);
|
|
34
|
+
const 더보기초점Ref = useRef(null);
|
|
35
|
+
function loadMore() {
|
|
36
|
+
const 단추 = document.activeElement;
|
|
37
|
+
더보기초점Ref.current = 단추 && 단추.closest?.('.training-block__more') ? visible : null;
|
|
38
|
+
setVisible((v) => Math.min(total, v + BTN_COUNT_MAX));
|
|
39
|
+
}
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const 첫새 = 더보기초점Ref.current;
|
|
42
|
+
if (첫새 == null)
|
|
43
|
+
return;
|
|
44
|
+
더보기초점Ref.current = null;
|
|
45
|
+
const 지금 = document.activeElement;
|
|
46
|
+
if (지금 && 지금 !== document.body)
|
|
47
|
+
return;
|
|
48
|
+
목록Ref.current?.querySelectorAll('.training-block-button')[첫새]?.focus();
|
|
49
|
+
}, [visible]);
|
|
50
|
+
return (_jsxs(Section, { type: sectionType, title: type?.label, icon: type?.icon, disc: type?.desc, className: "training-block", children: [_jsx("div", { className: "training-list", ref: 목록Ref, children: (itemsAll || []).slice(0, visible).map((item, idx) => {
|
|
34
51
|
const itemForBtn = item;
|
|
35
52
|
const depthForBtn = depthLabel;
|
|
36
53
|
return (_jsx(TrainingBlockButton, { index: idx, type: type, trainingData: itemForBtn, depth: depthForBtn, renderPopup: renderPopup, completedIds: completedIds, selectedIds: selectedIds, size: size, useDescriptionTooltip: useDescriptionTooltip, onRemove: typeof onRemove === 'function' && selectedIds != null && itemForBtn?.id != null && selectedIds.has(String(itemForBtn.id))
|
|
@@ -11,7 +11,7 @@ export default function TrainingCardGroup({ title, count, collapsed = false, onT
|
|
|
11
11
|
/* 접을 수 있는가 — `onToggle` 유무가 그것을 말한다. 새 prop 을 두지 않는다. */
|
|
12
12
|
const 접을수있다 = onToggle != null;
|
|
13
13
|
const cls = cx('training-card-group', dragOver && 'training-card-group--dragover', collapsed && 'training-card-group--collapsed', 접을수있다 && 'training-card-group--collapsible', className);
|
|
14
|
-
return (_jsxs("div", { className: cls, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: [_jsxs("header", { className: "training-card-group__header", children: [접을수있다 && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "training-card-group__chevron", onClick: onToggle })), typeof title === 'string'
|
|
14
|
+
return (_jsxs("div", { className: cls, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: [_jsxs("header", { className: "training-card-group__header", children: [접을수있다 && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "training-card-group__chevron", "aria-expanded": !collapsed, onClick: onToggle })), typeof title === 'string'
|
|
15
15
|
? _jsx("span", { className: "training-card-group__title h5b", children: title })
|
|
16
16
|
: title, count !== undefined && _jsx("span", { className: "training-card-group__count body-3b", children: count }), (sort || actions != null) && (_jsxs("span", { className: "training-card-group__actions", onClick: e => e.stopPropagation(), children: [sort && (_jsx(Dropdown, { size: 40, options: sort.options, value: sort.value, placeholder: sort.placeholder, onChange: sort.onChange })), actions] }))] }), !collapsed && (empty
|
|
17
17
|
? _jsx("div", { className: "training-card-group__empty body-3", children: emptyContent })
|
|
@@ -218,7 +218,16 @@ function TrainingPagePopupInner(props) {
|
|
|
218
218
|
return;
|
|
219
219
|
closingRef.current = true;
|
|
220
220
|
setClosing(true);
|
|
221
|
-
|
|
221
|
+
// 닫기 콜백(onBack 등)이 던지면 닫는 중 표시를 되돌린다 — 굳은 채 Tab 가둠·ESC 가 남고 다시 닫을 수 없었다
|
|
222
|
+
closeTimerRef.current = setTimeout(() => {
|
|
223
|
+
try {
|
|
224
|
+
fn();
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
closingRef.current = false;
|
|
228
|
+
setClosing(false);
|
|
229
|
+
}
|
|
230
|
+
}, PAGE_POPUP_CLOSE_MS);
|
|
222
231
|
}, []);
|
|
223
232
|
useEffect(() => () => { if (closeTimerRef.current)
|
|
224
233
|
clearTimeout(closeTimerRef.current); }, []);
|
|
@@ -265,6 +274,10 @@ function TrainingPagePopupInner(props) {
|
|
|
265
274
|
// 최초 공개(reveal) 이후엔 mode 변경을 막는 플래그 (MutationObserver 루프 방지)
|
|
266
275
|
const revealedRef = useRef(false);
|
|
267
276
|
const [resultOverlay, setResultOverlay] = useState(null);
|
|
277
|
+
/* 결과 요청 세대 — 결과보기가 올리고, 창을 닫거나(다시하기·완료·시나리오 초기화) 새로 요청하면 앞 요청은 늦은 것이 된다.
|
|
278
|
+
결과 계산이 비동기일 때 늦게 온 옛 결과가 새 판 위에 결과 창을 다시 띄우거나 2회차 결과를 1회차 것으로 덮었다. */
|
|
279
|
+
const 결과세대Ref = useRef(0);
|
|
280
|
+
const 결과창닫기 = () => { 결과세대Ref.current += 1; setResultOverlay(null); };
|
|
268
281
|
const [noticeMessage, setNoticeMessage] = useState('');
|
|
269
282
|
const [flowState, setFlowState] = useState(() => createScenarioFlowState());
|
|
270
283
|
const prevPageRef = useRef(typeof pageIndex === 'number' ? pageIndex : null);
|
|
@@ -274,7 +287,8 @@ function TrainingPagePopupInner(props) {
|
|
|
274
287
|
if (!hasScenario && !isChildrenMode)
|
|
275
288
|
return;
|
|
276
289
|
setFlowState(createScenarioFlowState());
|
|
277
|
-
|
|
290
|
+
결과창닫기();
|
|
291
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
278
292
|
}, [hasScenario, scenarioId, isChildrenMode]);
|
|
279
293
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
280
294
|
// 페이지 개수가 줄어들면 flowState.pageIndex를 유효 범위로 보정하는 의도된 동기화다
|
|
@@ -544,6 +558,8 @@ function TrainingPagePopupInner(props) {
|
|
|
544
558
|
return baseResult;
|
|
545
559
|
};
|
|
546
560
|
const handleViewResults = () => {
|
|
561
|
+
const 내결과 = ++결과세대Ref.current;
|
|
562
|
+
const 늦음 = () => 내결과 !== 결과세대Ref.current;
|
|
547
563
|
try {
|
|
548
564
|
if (typeof document !== 'undefined' && document.body) {
|
|
549
565
|
try {
|
|
@@ -561,6 +577,8 @@ function TrainingPagePopupInner(props) {
|
|
|
561
577
|
const maybeAny = maybe;
|
|
562
578
|
Promise.resolve(maybeAny)
|
|
563
579
|
.then((data) => {
|
|
580
|
+
if (늦음())
|
|
581
|
+
return;
|
|
564
582
|
if (data)
|
|
565
583
|
setResultOverlay(data);
|
|
566
584
|
else
|
|
@@ -569,6 +587,8 @@ function TrainingPagePopupInner(props) {
|
|
|
569
587
|
.catch((err) => {
|
|
570
588
|
if (import.meta.env?.DEV)
|
|
571
589
|
console.error('viewResults scenario handler failed', err);
|
|
590
|
+
if (늦음())
|
|
591
|
+
return;
|
|
572
592
|
setResultOverlay({ isEmpty: true });
|
|
573
593
|
});
|
|
574
594
|
return;
|
|
@@ -579,6 +599,8 @@ function TrainingPagePopupInner(props) {
|
|
|
579
599
|
const maybeAny = maybe;
|
|
580
600
|
Promise.resolve(maybeAny)
|
|
581
601
|
.then((data) => {
|
|
602
|
+
if (늦음())
|
|
603
|
+
return;
|
|
582
604
|
if (data)
|
|
583
605
|
setResultOverlay(data);
|
|
584
606
|
else
|
|
@@ -587,6 +609,8 @@ function TrainingPagePopupInner(props) {
|
|
|
587
609
|
.catch((err) => {
|
|
588
610
|
if (import.meta.env?.DEV)
|
|
589
611
|
console.error('viewResults children handler failed', err);
|
|
612
|
+
if (늦음())
|
|
613
|
+
return;
|
|
590
614
|
setResultOverlay({ isEmpty: true });
|
|
591
615
|
});
|
|
592
616
|
return;
|
|
@@ -614,7 +638,7 @@ function TrainingPagePopupInner(props) {
|
|
|
614
638
|
else {
|
|
615
639
|
setResultOverlay(maybeAny && typeof maybeAny.then === 'function' ? {} : {});
|
|
616
640
|
}
|
|
617
|
-
Promise.resolve(maybeAny).then((data) => { if (data)
|
|
641
|
+
Promise.resolve(maybeAny).then((data) => { if (data && !늦음())
|
|
618
642
|
setResultOverlay(data); }).catch((err) => { if (import.meta.env?.DEV)
|
|
619
643
|
console.error('viewResults child handler failed', err); });
|
|
620
644
|
return;
|
|
@@ -637,7 +661,7 @@ function TrainingPagePopupInner(props) {
|
|
|
637
661
|
if (import.meta && import.meta.env && import.meta.env.DEV)
|
|
638
662
|
console.debug('TrainingPagePopup: externalOnResult resolved data:', data);
|
|
639
663
|
}
|
|
640
|
-
catch (e) { } if (data)
|
|
664
|
+
catch (e) { } if (data && !늦음())
|
|
641
665
|
setResultOverlay(data); }).catch((err) => { console.error('viewResults external handler failed', err); });
|
|
642
666
|
return;
|
|
643
667
|
}
|
|
@@ -796,7 +820,7 @@ function TrainingPagePopupInner(props) {
|
|
|
796
820
|
});
|
|
797
821
|
completeResult = { correct: finalCorrect, total };
|
|
798
822
|
}
|
|
799
|
-
|
|
823
|
+
결과창닫기();
|
|
800
824
|
scheduleClose(() => {
|
|
801
825
|
if (typeof onComplete === 'function')
|
|
802
826
|
onComplete(completeResult);
|
|
@@ -820,7 +844,7 @@ function TrainingPagePopupInner(props) {
|
|
|
820
844
|
results: (resultOverlay.results || resultOverlay.answers || []),
|
|
821
845
|
contextsLength: resultOverlay.results ? resultOverlay.results.length : (resultOverlay.answers ? resultOverlay.answers.length : 0),
|
|
822
846
|
onReplay: () => {
|
|
823
|
-
|
|
847
|
+
결과창닫기();
|
|
824
848
|
setReplayCount((n) => n + 1);
|
|
825
849
|
if (hasScenario) {
|
|
826
850
|
setFlowState(createScenarioFlowState());
|
|
@@ -82,6 +82,22 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
82
82
|
|
|
83
83
|
대신 아래에서 `uiState` 로 겹쳐 읽는다 — 상태 기계는 그대로 두고 화면만 가린다. */
|
|
84
84
|
const [stateLocal, setStateLocal] = useState('active');
|
|
85
|
+
/* 초점을 가진 큰 '듣기' 단추를 누르면 그 단추가 사라진다 — 재생 모드가 되면 초점을 '재생 / 멈춤' 으로 옮긴다
|
|
86
|
+
(옮기지 않으면 초점이 body 로 떨어져 키보드 사용자가 멈출 단추를 다시 찾아야 했다). */
|
|
87
|
+
const 듣기초점옮기기Ref = useRef(false);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (stateLocal !== 'playMode' || !듣기초점옮기기Ref.current)
|
|
90
|
+
return;
|
|
91
|
+
듣기초점옮기기Ref.current = false;
|
|
92
|
+
const 지금 = typeof document !== 'undefined' ? document.activeElement : null;
|
|
93
|
+
if (지금 && 지금 !== document.body)
|
|
94
|
+
return;
|
|
95
|
+
const 재생멈춤 = btnsRef.current?.querySelectorAll('button')[1];
|
|
96
|
+
try {
|
|
97
|
+
재생멈춤?.focus();
|
|
98
|
+
}
|
|
99
|
+
catch (e) { }
|
|
100
|
+
}, [stateLocal]);
|
|
85
101
|
const [sources, setSources] = useState(null);
|
|
86
102
|
const [segments, setSegments] = useState(null);
|
|
87
103
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
@@ -171,9 +187,14 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
171
187
|
mountedRef.current = true;
|
|
172
188
|
return () => { mountedRef.current = false; };
|
|
173
189
|
}, []);
|
|
190
|
+
/* 재생 의도 — 멈춤·정지·완료·트랙 이동이 올린다. 대기 중이던 play() 는 멈추면 AbortError 로 거절되는데, 그것을 "다시 시도할
|
|
191
|
+
오류" 로만 보고 다시 부르면 **정지를 눌렀는데 소리가 다시 났다**. 시작할 때의 의도가 그대로일 때만 다시 시도한다. */
|
|
192
|
+
const 재생의도Ref = useRef(0);
|
|
193
|
+
const 멈춤의도 = () => { 재생의도Ref.current += 1; jumpTokenRef.current += 1; };
|
|
174
194
|
const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
|
|
195
|
+
const 의도 = 재생의도Ref.current;
|
|
175
196
|
for (let i = 0; i < attempts; i++) {
|
|
176
|
-
if (!mountedRef.current)
|
|
197
|
+
if (!mountedRef.current || 의도 !== 재생의도Ref.current)
|
|
177
198
|
return false;
|
|
178
199
|
try {
|
|
179
200
|
await a.play();
|
|
@@ -182,6 +203,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
182
203
|
catch (err) {
|
|
183
204
|
const errName = err instanceof Error ? err.name : (typeof err === 'object' && err && 'name' in err ? err.name : undefined);
|
|
184
205
|
if (errName === 'AbortError') {
|
|
206
|
+
if (의도 !== 재생의도Ref.current)
|
|
207
|
+
return false;
|
|
185
208
|
await new Promise((res) => setTimeout(res, delayMs));
|
|
186
209
|
continue;
|
|
187
210
|
}
|
|
@@ -218,7 +241,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
218
241
|
// 태우므로 그 전엔 요소가 제 볼륨으로 직접 울린다(무음 방지). 태운 뒤 볼륨은 GainNode 로 걸린다.
|
|
219
242
|
try {
|
|
220
243
|
resumeAudioMeter();
|
|
221
|
-
|
|
244
|
+
// 늦게 resume 돼도 떠난 플레이어의 미터를 다시 등록하지 않는다(언마운트 정리가 null 로 등록한 뒤)
|
|
245
|
+
void routeElementAudio(a).then((an) => { if (!mountedRef.current)
|
|
246
|
+
return; try {
|
|
222
247
|
registerAnalyserRef.current?.('main', an);
|
|
223
248
|
}
|
|
224
249
|
catch (e) { } });
|
|
@@ -695,6 +720,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
695
720
|
setPlaybackState('playing');
|
|
696
721
|
}
|
|
697
722
|
else {
|
|
723
|
+
멈춤의도();
|
|
698
724
|
a.pause();
|
|
699
725
|
setPlaybackState('paused');
|
|
700
726
|
}
|
|
@@ -744,7 +770,14 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
744
770
|
catch (e) { }
|
|
745
771
|
}
|
|
746
772
|
setStateLocal("load");
|
|
747
|
-
|
|
773
|
+
// 앱이 준 경로 규칙(resolvePath 등)이 던져도 못 얻은 것과 같이 다룬다 — 거절이 흘러 'load' 에 갇혔다
|
|
774
|
+
let 얻음;
|
|
775
|
+
try {
|
|
776
|
+
얻음 = await fetchAndPrepare(true);
|
|
777
|
+
}
|
|
778
|
+
catch (e) {
|
|
779
|
+
얻음 = false;
|
|
780
|
+
}
|
|
748
781
|
/* 쓸 소스를 하나도 못 얻으면(경로를 못 정한 코드뿐 등) 'load' 에 갇혀 듣기 단추가 사라지고 아무 조작도
|
|
749
782
|
안 됐다 — 알리고 누를 수 있는 상태로 되돌린다. */
|
|
750
783
|
if (얻음 === false && mountedRef.current) {
|
|
@@ -771,6 +804,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
771
804
|
}
|
|
772
805
|
else {
|
|
773
806
|
if (a) {
|
|
807
|
+
멈춤의도();
|
|
774
808
|
a.pause();
|
|
775
809
|
setPlaybackState("paused");
|
|
776
810
|
}
|
|
@@ -810,6 +844,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
810
844
|
catch (e) { }
|
|
811
845
|
};
|
|
812
846
|
const handleStop = () => {
|
|
847
|
+
// 기다리던 트랙 이동·재시도가 정지 뒤에 소리를 내지 않게
|
|
848
|
+
멈춤의도();
|
|
813
849
|
if (ttsMode) {
|
|
814
850
|
stopTTS();
|
|
815
851
|
return;
|
|
@@ -911,6 +947,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
911
947
|
const holdDelay = 200;
|
|
912
948
|
const outDelay = 340;
|
|
913
949
|
markCompletedByEnd(true);
|
|
950
|
+
멈춤의도();
|
|
914
951
|
try {
|
|
915
952
|
setProgress(1);
|
|
916
953
|
}
|
|
@@ -1055,7 +1092,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1055
1092
|
const fillPausedFlag = playbackState === "paused";
|
|
1056
1093
|
const fillLeftPercent = Math.max(0, Math.min(100, (progress || 0) * 100));
|
|
1057
1094
|
const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
|
|
1058
|
-
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", onClick: () => { hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
|
|
1095
|
+
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", onClick: (e) => { 듣기초점옮기기Ref.current = document.activeElement === e.currentTarget; hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
|
|
1059
1096
|
// 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
|
|
1060
1097
|
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 {
|
|
1061
1098
|
handleJumpTo(idx);
|
|
@@ -46,6 +46,15 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
|
|
|
46
46
|
/* 카드 ref 는 비우지 않는다 — 마운트 때도 도는 이 effect 가 막 붙은 ref 를 지워, 따로 쓴 단계에서는 다시 그려질
|
|
47
47
|
일이 없어 '모두 듣기' 가 소리 하나 없이 끝났다. 빠진 카드는 ref 콜백이 null 을 넣고, 재생은 보이는 칸까지만 돈다. */
|
|
48
48
|
}, [itemsKey]);
|
|
49
|
+
// 쪽 넘김 되돌리기 — 다음 쪽 카드 묶음이 같으면 카드 열쇠가 그대로라 앞 쪽 선택이 남았다(TrainingStepConfirm 과 같은 규칙)
|
|
50
|
+
const prevStepIndexRef = useRef(undefined);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const prev = prevStepIndexRef.current;
|
|
53
|
+
if (typeof prev === 'number' && typeof stepIndex === 'number' && typeof index === 'number' && prev >= index && stepIndex < index) {
|
|
54
|
+
setSelectedIndex(null);
|
|
55
|
+
}
|
|
56
|
+
prevStepIndexRef.current = stepIndex;
|
|
57
|
+
}, [stepIndex, index]);
|
|
49
58
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
50
59
|
const handlePlayDone = useCallback((item, i) => {
|
|
51
60
|
// selectable 모드에서는 오디오 재생이 step을 advance하지 않음
|
|
@@ -30,10 +30,11 @@ export default function TrainingStepConfirm({ index, stepIndex, isLast = false,
|
|
|
30
30
|
const [selectedAnswer, setSelectedAnswer] = useState(null);
|
|
31
31
|
const [selectedSet, setSelectedSet] = useState(() => new Set());
|
|
32
32
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
33
|
+
// 보기를 answer·wrongAnswers 로 만드는 경우도 문항이 바뀌면 푼다 — choices 열쇠만 보면 사과/배 → 배/사과 에서 앞 강조가 남았다
|
|
33
34
|
useEffect(() => {
|
|
34
35
|
setSelectedAnswer(null);
|
|
35
36
|
setSelectedSet(new Set());
|
|
36
|
-
}, [propChoicesKey]);
|
|
37
|
+
}, [propChoicesKey, answer, wrongAnswersKey]);
|
|
37
38
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
38
39
|
// 페이지 전환 감지: stepIndex가 이 step의 index 이상이었다가 다시 미만으로 돌아오면
|
|
39
40
|
// (다음 페이지가 시작되어 stepIndex가 리셋된 경우) 선택 상태도 초기화한다.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback, useState } from "react";
|
|
3
|
+
import { useCallback, useRef, useEffect, useState } from "react";
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import Button from '../Button';
|
|
6
6
|
import "./TrainingStep.css";
|
|
@@ -15,15 +15,35 @@ export default function TrainingStepConfirmWithText({ index, stepIndex, isLast =
|
|
|
15
15
|
const sentence = context && typeof context === 'object' ? (context.sentence || '') : (typeof context === 'string' ? context : '');
|
|
16
16
|
const choices = context && typeof context === 'object' ? context.choices : undefined;
|
|
17
17
|
const [selectedAnswer, setSelectedAnswer] = useState(demoSelected ?? null);
|
|
18
|
-
//
|
|
18
|
+
// 문항이 **바뀔 때만** 선택을 푼다(렌더 중 상태 조정). 예전 effect 는 selectedAnswer 를 의존성에 두고
|
|
19
19
|
// "null 이 아니면 null 로" 를 해서, 답을 누르면 한 박자 뒤 강조가 곧바로 사라졌다.
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
// 문항 열쇠는 문장 + 보기 — 문장만 보면 보기만 바뀐 문항에서 같은 번호의 새 보기가 골라진 채 보였다.
|
|
21
|
+
// 데모 강제 선택 시에는 문항 변경 리셋을 건너뛴다.
|
|
22
|
+
const 문항열쇠 = `${String(sentence)}\u0000${JSON.stringify(choices ?? null)}`;
|
|
23
|
+
const [prevKey, setPrevKey] = useState(문항열쇠);
|
|
24
|
+
if (prevKey !== 문항열쇠) {
|
|
25
|
+
setPrevKey(문항열쇠);
|
|
24
26
|
if (demoSelected == null)
|
|
25
27
|
setSelectedAnswer(null);
|
|
26
28
|
}
|
|
29
|
+
// 데모 선택이 바뀌면 따른다(useState 초기값만 쓰여 undefined → true 로 바꿔도 강조가 안 켜졌다)
|
|
30
|
+
const [prevDemo, setPrevDemo] = useState(demoSelected);
|
|
31
|
+
if (prevDemo !== demoSelected) {
|
|
32
|
+
setPrevDemo(demoSelected);
|
|
33
|
+
setSelectedAnswer(demoSelected ?? null);
|
|
34
|
+
}
|
|
35
|
+
// 쪽 넘김 되돌리기 — stepIndex 가 이 단계 이상이었다가 다시 미만으로 오면(다음 쪽이 시작됨) 선택을 푼다.
|
|
36
|
+
// 다음 쪽 문장이 같으면 문항 열쇠가 그대로라 앞 쪽 답의 강조가 남았다(TrainingStepConfirm 과 같은 규칙).
|
|
37
|
+
const prevStepIndexRef = useRef(undefined);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const prev = prevStepIndexRef.current;
|
|
40
|
+
if (typeof prev === 'number' && typeof stepIndex === 'number' && typeof index === 'number'
|
|
41
|
+
&& prev >= index && stepIndex < index && demoSelected == null) {
|
|
42
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
43
|
+
setSelectedAnswer(null);
|
|
44
|
+
}
|
|
45
|
+
prevStepIndexRef.current = stepIndex;
|
|
46
|
+
}, [stepIndex, index, demoSelected]);
|
|
27
47
|
const handleEndedLocal = useCallback((payload) => {
|
|
28
48
|
const p = (typeof payload === 'object' && payload !== null)
|
|
29
49
|
? Object.assign({}, payload, { __ts: Date.now() })
|