@ihabdevteam/core 0.137.0 → 0.138.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 +45 -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/Table.js +4 -1
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +23 -26
- 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 +20 -5
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +33 -3
- 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/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/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.138.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
|
}
|
|
@@ -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
|
}
|
|
@@ -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 })
|
|
@@ -265,6 +265,10 @@ function TrainingPagePopupInner(props) {
|
|
|
265
265
|
// 최초 공개(reveal) 이후엔 mode 변경을 막는 플래그 (MutationObserver 루프 방지)
|
|
266
266
|
const revealedRef = useRef(false);
|
|
267
267
|
const [resultOverlay, setResultOverlay] = useState(null);
|
|
268
|
+
/* 결과 요청 세대 — 결과보기가 올리고, 창을 닫거나(다시하기·완료·시나리오 초기화) 새로 요청하면 앞 요청은 늦은 것이 된다.
|
|
269
|
+
결과 계산이 비동기일 때 늦게 온 옛 결과가 새 판 위에 결과 창을 다시 띄우거나 2회차 결과를 1회차 것으로 덮었다. */
|
|
270
|
+
const 결과세대Ref = useRef(0);
|
|
271
|
+
const 결과창닫기 = () => { 결과세대Ref.current += 1; setResultOverlay(null); };
|
|
268
272
|
const [noticeMessage, setNoticeMessage] = useState('');
|
|
269
273
|
const [flowState, setFlowState] = useState(() => createScenarioFlowState());
|
|
270
274
|
const prevPageRef = useRef(typeof pageIndex === 'number' ? pageIndex : null);
|
|
@@ -274,7 +278,8 @@ function TrainingPagePopupInner(props) {
|
|
|
274
278
|
if (!hasScenario && !isChildrenMode)
|
|
275
279
|
return;
|
|
276
280
|
setFlowState(createScenarioFlowState());
|
|
277
|
-
|
|
281
|
+
결과창닫기();
|
|
282
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
278
283
|
}, [hasScenario, scenarioId, isChildrenMode]);
|
|
279
284
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
280
285
|
// 페이지 개수가 줄어들면 flowState.pageIndex를 유효 범위로 보정하는 의도된 동기화다
|
|
@@ -544,6 +549,8 @@ function TrainingPagePopupInner(props) {
|
|
|
544
549
|
return baseResult;
|
|
545
550
|
};
|
|
546
551
|
const handleViewResults = () => {
|
|
552
|
+
const 내결과 = ++결과세대Ref.current;
|
|
553
|
+
const 늦음 = () => 내결과 !== 결과세대Ref.current;
|
|
547
554
|
try {
|
|
548
555
|
if (typeof document !== 'undefined' && document.body) {
|
|
549
556
|
try {
|
|
@@ -561,6 +568,8 @@ function TrainingPagePopupInner(props) {
|
|
|
561
568
|
const maybeAny = maybe;
|
|
562
569
|
Promise.resolve(maybeAny)
|
|
563
570
|
.then((data) => {
|
|
571
|
+
if (늦음())
|
|
572
|
+
return;
|
|
564
573
|
if (data)
|
|
565
574
|
setResultOverlay(data);
|
|
566
575
|
else
|
|
@@ -569,6 +578,8 @@ function TrainingPagePopupInner(props) {
|
|
|
569
578
|
.catch((err) => {
|
|
570
579
|
if (import.meta.env?.DEV)
|
|
571
580
|
console.error('viewResults scenario handler failed', err);
|
|
581
|
+
if (늦음())
|
|
582
|
+
return;
|
|
572
583
|
setResultOverlay({ isEmpty: true });
|
|
573
584
|
});
|
|
574
585
|
return;
|
|
@@ -579,6 +590,8 @@ function TrainingPagePopupInner(props) {
|
|
|
579
590
|
const maybeAny = maybe;
|
|
580
591
|
Promise.resolve(maybeAny)
|
|
581
592
|
.then((data) => {
|
|
593
|
+
if (늦음())
|
|
594
|
+
return;
|
|
582
595
|
if (data)
|
|
583
596
|
setResultOverlay(data);
|
|
584
597
|
else
|
|
@@ -587,6 +600,8 @@ function TrainingPagePopupInner(props) {
|
|
|
587
600
|
.catch((err) => {
|
|
588
601
|
if (import.meta.env?.DEV)
|
|
589
602
|
console.error('viewResults children handler failed', err);
|
|
603
|
+
if (늦음())
|
|
604
|
+
return;
|
|
590
605
|
setResultOverlay({ isEmpty: true });
|
|
591
606
|
});
|
|
592
607
|
return;
|
|
@@ -614,7 +629,7 @@ function TrainingPagePopupInner(props) {
|
|
|
614
629
|
else {
|
|
615
630
|
setResultOverlay(maybeAny && typeof maybeAny.then === 'function' ? {} : {});
|
|
616
631
|
}
|
|
617
|
-
Promise.resolve(maybeAny).then((data) => { if (data)
|
|
632
|
+
Promise.resolve(maybeAny).then((data) => { if (data && !늦음())
|
|
618
633
|
setResultOverlay(data); }).catch((err) => { if (import.meta.env?.DEV)
|
|
619
634
|
console.error('viewResults child handler failed', err); });
|
|
620
635
|
return;
|
|
@@ -637,7 +652,7 @@ function TrainingPagePopupInner(props) {
|
|
|
637
652
|
if (import.meta && import.meta.env && import.meta.env.DEV)
|
|
638
653
|
console.debug('TrainingPagePopup: externalOnResult resolved data:', data);
|
|
639
654
|
}
|
|
640
|
-
catch (e) { } if (data)
|
|
655
|
+
catch (e) { } if (data && !늦음())
|
|
641
656
|
setResultOverlay(data); }).catch((err) => { console.error('viewResults external handler failed', err); });
|
|
642
657
|
return;
|
|
643
658
|
}
|
|
@@ -796,7 +811,7 @@ function TrainingPagePopupInner(props) {
|
|
|
796
811
|
});
|
|
797
812
|
completeResult = { correct: finalCorrect, total };
|
|
798
813
|
}
|
|
799
|
-
|
|
814
|
+
결과창닫기();
|
|
800
815
|
scheduleClose(() => {
|
|
801
816
|
if (typeof onComplete === 'function')
|
|
802
817
|
onComplete(completeResult);
|
|
@@ -820,7 +835,7 @@ function TrainingPagePopupInner(props) {
|
|
|
820
835
|
results: (resultOverlay.results || resultOverlay.answers || []),
|
|
821
836
|
contextsLength: resultOverlay.results ? resultOverlay.results.length : (resultOverlay.answers ? resultOverlay.answers.length : 0),
|
|
822
837
|
onReplay: () => {
|
|
823
|
-
|
|
838
|
+
결과창닫기();
|
|
824
839
|
setReplayCount((n) => n + 1);
|
|
825
840
|
if (hasScenario) {
|
|
826
841
|
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
|
}
|
|
@@ -771,6 +797,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
771
797
|
}
|
|
772
798
|
else {
|
|
773
799
|
if (a) {
|
|
800
|
+
멈춤의도();
|
|
774
801
|
a.pause();
|
|
775
802
|
setPlaybackState("paused");
|
|
776
803
|
}
|
|
@@ -810,6 +837,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
810
837
|
catch (e) { }
|
|
811
838
|
};
|
|
812
839
|
const handleStop = () => {
|
|
840
|
+
// 기다리던 트랙 이동·재시도가 정지 뒤에 소리를 내지 않게
|
|
841
|
+
멈춤의도();
|
|
813
842
|
if (ttsMode) {
|
|
814
843
|
stopTTS();
|
|
815
844
|
return;
|
|
@@ -911,6 +940,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
911
940
|
const holdDelay = 200;
|
|
912
941
|
const outDelay = 340;
|
|
913
942
|
markCompletedByEnd(true);
|
|
943
|
+
멈춤의도();
|
|
914
944
|
try {
|
|
915
945
|
setProgress(1);
|
|
916
946
|
}
|
|
@@ -1055,7 +1085,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1055
1085
|
const fillPausedFlag = playbackState === "paused";
|
|
1056
1086
|
const fillLeftPercent = Math.max(0, Math.min(100, (progress || 0) * 100));
|
|
1057
1087
|
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,
|
|
1088
|
+
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
1089
|
// 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
|
|
1060
1090
|
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
1091
|
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() })
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import Button from '../Button';
|
|
6
6
|
import Toast from '../Toast';
|
|
@@ -30,7 +30,13 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
|
|
|
30
30
|
storageKey: 'textbox_fontScale',
|
|
31
31
|
});
|
|
32
32
|
const [noticeMessage, setNoticeMessage] = useState('');
|
|
33
|
-
|
|
33
|
+
/* 초점을 가진 '문장 미리보기' 를 누르면 그 단추가 사라진다 — 펼친 상자의 첫 조작 단추로 초점을 옮긴다 */
|
|
34
|
+
const 상자Ref = useRef(null);
|
|
35
|
+
const 펼친뒤초점Ref = useRef(false);
|
|
36
|
+
const handleActivate = useCallback(() => {
|
|
37
|
+
펼친뒤초점Ref.current = !!상자Ref.current && 상자Ref.current.contains(document.activeElement);
|
|
38
|
+
setLocalStatus("active");
|
|
39
|
+
}, []);
|
|
34
40
|
const handleNoticeClose = useCallback(() => setNoticeMessage(''), []);
|
|
35
41
|
const showNotice = (msg) => {
|
|
36
42
|
try {
|
|
@@ -73,5 +79,14 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
|
|
|
73
79
|
fontSize: `${fontSize}px`,
|
|
74
80
|
lineHeight: `${lineHeight}px`,
|
|
75
81
|
}), [fontSize, lineHeight]);
|
|
76
|
-
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (displayStatus === 'disabled' || !펼친뒤초점Ref.current)
|
|
84
|
+
return;
|
|
85
|
+
펼친뒤초점Ref.current = false;
|
|
86
|
+
const 지금 = document.activeElement;
|
|
87
|
+
if (지금 && 지금 !== document.body)
|
|
88
|
+
return;
|
|
89
|
+
상자Ref.current?.querySelector('.training-text-box__buttons button')?.focus();
|
|
90
|
+
}, [displayStatus]);
|
|
91
|
+
return (_jsxs("div", { ref: 상자Ref, className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
|
|
77
92
|
}
|
|
@@ -29,7 +29,8 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
29
29
|
// 단어가 비면 뒷면으로 플립되는데, 그 애니메이션 동안 앞면이 '단어·발음 없이' 돌아가면 어색하다.
|
|
30
30
|
// 플립 트리거(showBack)는 현재 값으로 두되, 앞면에 그릴 내용은 '마지막으로 채워졌던' 값을 유지한다.
|
|
31
31
|
const [front, setFront] = useState({ label, subLabel, colloquialLabel, homonum: item.homonum });
|
|
32
|
-
|
|
32
|
+
// 낱말이 있으면 넷을 **통째로** 견준다 — 낱말만 보면 같은 낱말에 발음이 새로 오거나 동음이의어 번호가 바뀌어도 앞 값이 남았다
|
|
33
|
+
if (label && (front.label !== label || front.subLabel !== subLabel || front.colloquialLabel !== colloquialLabel || front.homonum !== item.homonum)) {
|
|
33
34
|
setFront({ label, subLabel, colloquialLabel, homonum: item.homonum });
|
|
34
35
|
}
|
|
35
36
|
// ── 재생 상태 ──
|
|
@@ -161,6 +162,9 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
161
162
|
둘이 함께 울렸다. speechSynthesis 만 전역이라 음원 없는 낱말에서는 멀쩡해 보였고,
|
|
162
163
|
그래서 증상이 반만 나타났다. 스피커가 하나이므로 자물쇠도 전역에 둔다. */
|
|
163
164
|
const 멈춤 = () => {
|
|
165
|
+
// 남(다른 카드·다음 누름)이 자리를 가져가며 부르면 이 카드는 더 이상 쥐고 있지 않다 — 기다리던 흐름이 이것을 보고 물러선다
|
|
166
|
+
if (쥔소리Ref.current === 멈춤)
|
|
167
|
+
쥔소리Ref.current = null;
|
|
164
168
|
try {
|
|
165
169
|
audioRef.current?.pause();
|
|
166
170
|
}
|
|
@@ -189,7 +193,9 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
189
193
|
if (fallbackResolver && label) {
|
|
190
194
|
try {
|
|
191
195
|
const url = await Promise.resolve(fallbackResolver(label));
|
|
192
|
-
|
|
196
|
+
/* 기다리는 사이 떠났거나 **소리 자리를 남에게 넘겼으면**(다른 카드를 눌렀거나 같은 카드를 다시 눌렀으면) 끝낸다 —
|
|
197
|
+
늦게 온 합성 주소로 틀어 두 소리가 겹쳤고, 같은 카드를 두 번 누르면 두 번 울려 onPlayDone 도 두 번 나갔다. */
|
|
198
|
+
if (!mountedRef.current || 쥔소리Ref.current !== 멈춤)
|
|
193
199
|
return;
|
|
194
200
|
if (url) {
|
|
195
201
|
setPlaying(true);
|
|
@@ -199,7 +205,7 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
199
205
|
}
|
|
200
206
|
catch { }
|
|
201
207
|
}
|
|
202
|
-
if (!mountedRef.current)
|
|
208
|
+
if (!mountedRef.current || 쥔소리Ref.current !== 멈춤)
|
|
203
209
|
return;
|
|
204
210
|
await playSpeechSynthesis();
|
|
205
211
|
}
|
|
@@ -67,7 +67,9 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
67
67
|
// 볼륨은 setElementVolume 이 그래프면 GainNode, 아니면 요소 volume 에 건다(iOS 실기기는 요소 volume 이 안 먹는다).
|
|
68
68
|
try {
|
|
69
69
|
resumeAudioMeter();
|
|
70
|
-
|
|
70
|
+
// 늦게 resume 돼도 버려진 소음 요소의 미터를 다시 등록하지 않는다
|
|
71
|
+
void routeElementAudio(noiseEl).then((an) => { if (noiseAudioRef.current !== noiseEl)
|
|
72
|
+
return; try {
|
|
71
73
|
registerAnalyserRef.current?.('noise', an);
|
|
72
74
|
}
|
|
73
75
|
catch (e) { } });
|