@ihabdevteam/core 0.136.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 +98 -0
- package/dist/components.cjs.js +177 -177
- package/dist/components.esm.css +2 -2
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +182 -182
- package/dist/ihabdevteam-core.d.ts +20 -5
- package/dist/packages/core/src/components/Breadcrumb.js +1 -0
- package/dist/packages/core/src/components/Button.js +10 -1
- package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +3 -2
- package/dist/packages/core/src/components/Icon.js +4 -1
- package/dist/packages/core/src/components/LandingHeader.d.ts +1 -1
- package/dist/packages/core/src/components/LandingHeader.js +5 -1
- package/dist/packages/core/src/components/PageTitle.js +1 -0
- package/dist/packages/core/src/components/Pagination.js +1 -1
- package/dist/packages/core/src/components/Panel.css +3 -0
- package/dist/packages/core/src/components/Panel.d.ts +11 -1
- package/dist/packages/core/src/components/Panel.js +3 -3
- package/dist/packages/core/src/components/PanelRow.js +8 -1
- package/dist/packages/core/src/components/PassCard/PassCard.js +1 -1
- package/dist/packages/core/src/components/PassCard/PassCardBgIHAB.js +1 -0
- package/dist/packages/core/src/components/PassCard/PassCardBgLeftColorGrid.js +1 -0
- package/dist/packages/core/src/components/PassCard/PassCardBgSoundMove.js +1 -0
- package/dist/packages/core/src/components/PassCard/PassCardBgSoundMoveV1.js +1 -0
- package/dist/packages/core/src/components/PassCard/PassCardBgTalk.js +1 -0
- package/dist/packages/core/src/components/PassCard/PassCardBgTalkV1.js +1 -0
- package/dist/packages/core/src/components/PassCard/PassCardBgTwoCircle.js +1 -0
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +8 -1
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -1
- package/dist/packages/core/src/components/Popup/Popup.css +10 -0
- package/dist/packages/core/src/components/Popup/Popup.d.ts +5 -0
- package/dist/packages/core/src/components/Popup/Popup.js +5 -4
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +6 -1
- package/dist/packages/core/src/components/ProgressIndicator.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDark.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDeep.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDiagonal.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDots.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgInstagram.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgMain.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgNaver.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgOutline.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgRadial.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoft.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoftSub.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSub.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSuccess.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgWarm.js +1 -0
- package/dist/packages/core/src/components/Table.js +4 -1
- package/dist/packages/core/src/components/TextField.js +3 -1
- package/dist/packages/core/src/components/Tooltip/DescriptionTooltip.js +1 -0
- 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/TrainingBlockButton.js +10 -4
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +2 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +20 -5
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +60 -3
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +16 -1
- 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/TrainingStepOrderList.d.ts +0 -1
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +16 -1
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +18 -3
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +28 -5
- package/dist/packages/core/src/components/Training/currentSound.d.ts +5 -0
- package/dist/packages/core/src/components/Training/currentSound.js +13 -0
- package/dist/packages/core/src/components/Training/trainingPopupUtils.js +12 -1
- package/dist/packages/core/src/components/Training/useNoiseFade.js +7 -1
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +5 -1
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +3 -1
- package/dist/packages/core/src/components/WordTable.js +25 -7
- package/dist/packages/core/src/hooks/useEditableList.js +13 -5
- package/dist/packages/core/src/hooks/useFontScale.js +13 -4
- package/dist/packages/core/src/hooks/useIcon.js +3 -0
- package/dist/packages/core/src/hooks/useLucideIcon.js +3 -0
- package/dist/packages/core/src/hooks/useMediaQuery.d.ts +2 -1
- package/dist/packages/core/src/hooks/useMediaQuery.js +37 -8
- package/dist/packages/core/src/hooks/usePagedList.js +1 -0
- package/dist/packages/core/src/hooks/usePasswordChange.js +7 -0
- package/dist/packages/core/src/hooks/useTrainingTypeLabels.js +1 -0
- package/dist/packages/core/src/hooks/useVisitHistory.js +1 -0
- package/dist/packages/core/src/hooks/useWordTrainingDataGeneration.js +18 -3
- package/dist/packages/core/src/i18n/locales/en.json +41 -4
- package/dist/packages/core/src/i18n/locales/ko.json +41 -4
- package/dist/packages/core/src/utils/errorMessage.js +7 -1
- 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
|
@@ -8,6 +8,8 @@ import Icon from './Icon';
|
|
|
8
8
|
import Button from './Button';
|
|
9
9
|
import { useComboboxSuggestions } from './useComboboxSuggestions';
|
|
10
10
|
import './TextField.css';
|
|
11
|
+
/* 서버 렌더에서는 layout effect 가 돌지 않고 React 18 은 경고를 찍는다 — 브라우저에서만 layout, 서버에서는 effect */
|
|
12
|
+
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
11
13
|
/** @public */
|
|
12
14
|
export default function TextField(props) {
|
|
13
15
|
const { value, defaultValue, name, inputRef, onChange, label, ariaLabel, required, placeholder, type = 'text', disabled = false, readOnly = false, error, helperText, maxLength, size = 48, variant = 'input', fullWidth = false, newline = false, multiline = false, rows = 3, leftIcon, rightIcon, clearable = false, list, showCount = true, className = '', onFocus, onBlur, onKeyDown, onEnter, inputMode, autoComplete, autoFocus, suggestions, openOnFocus = false, } = props;
|
|
@@ -61,7 +63,7 @@ export default function TextField(props) {
|
|
|
61
63
|
돌아, 부모가 같은 값을 다시 두면(공백을 걷는 등) 화면 칸에 친 글자가 남았다. React 가 일반 칸에
|
|
62
64
|
해 주는 되돌림(restoreControlledState)을 onChange 뒤 한 박자에 흉내 낸다. */
|
|
63
65
|
const valueRef = useRef(value);
|
|
64
|
-
|
|
66
|
+
useIsoLayoutEffect(() => { valueRef.current = value; });
|
|
65
67
|
/*
|
|
66
68
|
* React 는 controlled input 의 값을 node.defaultValue(= HTML value 속성)에 동기화한다.
|
|
67
69
|
* 일반 입력에서는 무해하지만 비밀번호에서는 그 속성이 outerHTML 직렬화 경로
|
|
@@ -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))
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { getTopicTypes, getTrainingTypes } from '../../utils/categories';
|
|
6
|
+
import { TRAINING_TYPE_I18N_KEY } from '../../utils/CategoryData';
|
|
6
7
|
import Icon from '../Icon';
|
|
7
8
|
import Button from '../Button';
|
|
8
9
|
import CategoryIcon from './CategoryIcon';
|
|
@@ -93,9 +94,11 @@ export default function TrainingBlockButton(props) {
|
|
|
93
94
|
const c = topic.iconColor;
|
|
94
95
|
wrapperStyle = { ['--t-item-fg']: c };
|
|
95
96
|
}
|
|
96
|
-
|
|
97
|
+
// 카테고리 이름도 지금 언어로(topic.* · topic.sub.*, 사전에 없으면 등록된 이름)
|
|
98
|
+
mainName = parent ? t(`topic.${parent.key}`, { defaultValue: parent.label || '' }) : '';
|
|
97
99
|
try {
|
|
98
|
-
|
|
100
|
+
const sub = parent?.subs?.find((s) => s.key === subKey);
|
|
101
|
+
subName = sub ? t(`topic.sub.${sub.key}`, { defaultValue: sub.label || '' }) : '';
|
|
99
102
|
}
|
|
100
103
|
catch (_) { }
|
|
101
104
|
}
|
|
@@ -172,8 +175,11 @@ export default function TrainingBlockButton(props) {
|
|
|
172
175
|
const isList = size === 'list' || isRow;
|
|
173
176
|
const isCompact = size === 'compact';
|
|
174
177
|
const isSmall = size === 'small';
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
// 유형 이름은 지금 언어로 — 등록된 원본의 한국어 라벨을 써 영어 화면 계획 바구니에 '소음 하 문장 듣기' 가 떴다
|
|
179
|
+
const 유형원본 = isSmall && type?.key ? getTrainingTypes().find(x => x.key === type.key) : undefined;
|
|
180
|
+
const 유형열쇠 = 유형원본 ? TRAINING_TYPE_I18N_KEY[유형원본.key] : undefined;
|
|
181
|
+
const trainingTypeName = 유형원본
|
|
182
|
+
? (유형열쇠 ? t(`trainingType.${유형열쇠}.title`, { defaultValue: 유형원본.label }) : (유형원본.label ?? ''))
|
|
177
183
|
: '';
|
|
178
184
|
// 타입 라벨이 실제로 렌더되는지(타이틀과 같으면 숨김) — 레이아웃 여백 정리에 사용.
|
|
179
185
|
const typeLabelRendered = (!!showTypeLabel && !!type?.label && type.label !== String(itemTitle)) ||
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
3
|
import { cx } from '../../utils/cx';
|
|
3
4
|
import { useTranslation } from 'react-i18next';
|
|
@@ -10,7 +11,7 @@ export default function TrainingCardGroup({ title, count, collapsed = false, onT
|
|
|
10
11
|
/* 접을 수 있는가 — `onToggle` 유무가 그것을 말한다. 새 prop 을 두지 않는다. */
|
|
11
12
|
const 접을수있다 = onToggle != null;
|
|
12
13
|
const cls = cx('training-card-group', dragOver && 'training-card-group--dragover', collapsed && 'training-card-group--collapsed', 접을수있다 && 'training-card-group--collapsible', className);
|
|
13
|
-
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'
|
|
14
15
|
? _jsx("span", { className: "training-card-group__title h5b", children: title })
|
|
15
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
|
|
16
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) { } });
|
|
@@ -484,6 +509,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
484
509
|
try {
|
|
485
510
|
// 각 URL의 duration을 병렬로 측정 (실패한 URL은 0초로 계속)
|
|
486
511
|
const durations = await Promise.all(ttsUrls.map(url => measureAudioDuration(url, { timeoutMs: 10000 })));
|
|
512
|
+
// 재는 동안 떠났으면 여기서 끝 — 떼어진 <audio> 에 주소를 물리고 불러오기를 시작하던 것
|
|
513
|
+
if (!mountedRef.current)
|
|
514
|
+
return;
|
|
487
515
|
const finalSources = ttsUrls.map((src, i) => ({ src, objectUrl: null, duration: durations[i] || 0 }));
|
|
488
516
|
applySources(finalSources, totalDurationOf(finalSources));
|
|
489
517
|
setFetchDebugStatus('ready');
|
|
@@ -525,11 +553,17 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
525
553
|
catch (e) {
|
|
526
554
|
srcUrl = null;
|
|
527
555
|
}
|
|
556
|
+
if (!mountedRef.current)
|
|
557
|
+
return false;
|
|
528
558
|
// resolveUrl 실패 시 blob 다운로드로 폴백
|
|
529
559
|
if (!srcUrl) {
|
|
530
560
|
try {
|
|
531
561
|
setFetchDebugStatus('downloading');
|
|
532
562
|
const blob = await downloadSound(b.resolvedPath);
|
|
563
|
+
/* 내려받는 동안 떠났으면 주소를 만들지 않고 끝낸다 — useObjectUrls 정리가 이미 끝난 뒤라 track 해도 회수되지
|
|
564
|
+
않고 남았고, 이어서 떼어진 <audio> 에 물려 내려받기까지 시작했다. */
|
|
565
|
+
if (!mountedRef.current)
|
|
566
|
+
return false;
|
|
533
567
|
if (blob) {
|
|
534
568
|
const blobObj = blob instanceof Blob ? blob : new Blob([blob]);
|
|
535
569
|
objUrl = objectUrls.track(URL.createObjectURL(blobObj));
|
|
@@ -557,6 +591,24 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
557
591
|
catch (_) {
|
|
558
592
|
dur = 0;
|
|
559
593
|
}
|
|
594
|
+
if (!mountedRef.current) {
|
|
595
|
+
// 떠났으면 방금 만든 blob 주소만 회수하고 끝(앞서 만든 것은 정리가 이미 회수했다)
|
|
596
|
+
if (objUrl) {
|
|
597
|
+
try {
|
|
598
|
+
URL.revokeObjectURL(objUrl);
|
|
599
|
+
}
|
|
600
|
+
catch (_) { }
|
|
601
|
+
}
|
|
602
|
+
for (const f of finalSources) {
|
|
603
|
+
if (f.objectUrl) {
|
|
604
|
+
try {
|
|
605
|
+
URL.revokeObjectURL(f.objectUrl);
|
|
606
|
+
}
|
|
607
|
+
catch (_) { }
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
560
612
|
total += dur || 0;
|
|
561
613
|
finalSources.push({ ...b, src: srcUrl, objectUrl: objUrl, duration: dur });
|
|
562
614
|
}
|
|
@@ -668,6 +720,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
668
720
|
setPlaybackState('playing');
|
|
669
721
|
}
|
|
670
722
|
else {
|
|
723
|
+
멈춤의도();
|
|
671
724
|
a.pause();
|
|
672
725
|
setPlaybackState('paused');
|
|
673
726
|
}
|
|
@@ -744,6 +797,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
744
797
|
}
|
|
745
798
|
else {
|
|
746
799
|
if (a) {
|
|
800
|
+
멈춤의도();
|
|
747
801
|
a.pause();
|
|
748
802
|
setPlaybackState("paused");
|
|
749
803
|
}
|
|
@@ -783,6 +837,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
783
837
|
catch (e) { }
|
|
784
838
|
};
|
|
785
839
|
const handleStop = () => {
|
|
840
|
+
// 기다리던 트랙 이동·재시도가 정지 뒤에 소리를 내지 않게
|
|
841
|
+
멈춤의도();
|
|
786
842
|
if (ttsMode) {
|
|
787
843
|
stopTTS();
|
|
788
844
|
return;
|
|
@@ -884,6 +940,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
884
940
|
const holdDelay = 200;
|
|
885
941
|
const outDelay = 340;
|
|
886
942
|
markCompletedByEnd(true);
|
|
943
|
+
멈춤의도();
|
|
887
944
|
try {
|
|
888
945
|
setProgress(1);
|
|
889
946
|
}
|
|
@@ -1028,7 +1085,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1028
1085
|
const fillPausedFlag = playbackState === "paused";
|
|
1029
1086
|
const fillLeftPercent = Math.max(0, Math.min(100, (progress || 0) * 100));
|
|
1030
1087
|
const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
|
|
1031
|
-
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: 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,
|
|
1032
1089
|
// 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
|
|
1033
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 {
|
|
1034
1091
|
handleJumpTo(idx);
|
|
@@ -36,6 +36,8 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
|
|
|
36
36
|
// ── 모두 듣기 — 카드별 소리 듣기 버튼을 순서대로 재생 ──
|
|
37
37
|
const cardRefs = useRef([]);
|
|
38
38
|
const [playingAll, setPlayingAll] = useState(false);
|
|
39
|
+
const 단계살아있음 = useRef(true);
|
|
40
|
+
useEffect(() => { 단계살아있음.current = true; return () => { 단계살아있음.current = false; }; }, []);
|
|
39
41
|
// 페이지 전환 시 (items 변경) 이전 선택 상태 및 카드 ref 초기화
|
|
40
42
|
const itemsKey = useMemo(() => items.map((it) => it?.id ?? it?.word ?? '').join('|'), [items]);
|
|
41
43
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
@@ -44,6 +46,15 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
|
|
|
44
46
|
/* 카드 ref 는 비우지 않는다 — 마운트 때도 도는 이 effect 가 막 붙은 ref 를 지워, 따로 쓴 단계에서는 다시 그려질
|
|
45
47
|
일이 없어 '모두 듣기' 가 소리 하나 없이 끝났다. 빠진 카드는 ref 콜백이 null 을 넣고, 재생은 보이는 칸까지만 돈다. */
|
|
46
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]);
|
|
47
58
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
48
59
|
const handlePlayDone = useCallback((item, i) => {
|
|
49
60
|
// selectable 모드에서는 오디오 재생이 step을 advance하지 않음
|
|
@@ -67,7 +78,11 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
|
|
|
67
78
|
return;
|
|
68
79
|
setPlayingAll(true);
|
|
69
80
|
try {
|
|
70
|
-
|
|
81
|
+
/* 매 차례 **지금의** 카드를 읽고, 떠났으면 멈춘다 — 시작할 때 잡아 둔 핸들로 돌면 떠난 화면의 다음 카드가 이어서 울렸다 */
|
|
82
|
+
for (let i = 0; i < shown.length; i++) {
|
|
83
|
+
if (!단계살아있음.current)
|
|
84
|
+
break;
|
|
85
|
+
const cardRef = cardRefs.current[i];
|
|
71
86
|
if (!cardRef)
|
|
72
87
|
continue;
|
|
73
88
|
await withTimeout(cardRef.play(), PLAY_ALL_CARD_TIMEOUT_MS);
|
|
@@ -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() })
|
|
@@ -3,5 +3,4 @@ import "./TrainingStep.css";
|
|
|
3
3
|
import "./TrainingStepOrderList.css";
|
|
4
4
|
import type { TrainingStepOrderListProps } from './trainingStepOrderListTypes';
|
|
5
5
|
export type { TrainingContextItem, ShuffledItem, ChangeResult, TrainingStepOrderListProps } from './trainingStepOrderListTypes';
|
|
6
|
-
/** @public */
|
|
7
6
|
export default function TrainingStepOrderList({ index, stepIndex, isLast, title, onDone, onChange, context, }: TrainingStepOrderListProps): import("react").JSX.Element;
|
|
@@ -9,7 +9,10 @@ import "./TrainingStep.css";
|
|
|
9
9
|
import TrainingStep from "./TrainingStep";
|
|
10
10
|
import "./TrainingStepOrderList.css";
|
|
11
11
|
/** @public */
|
|
12
|
-
|
|
12
|
+
/* 기본 context 는 **모듈에 하나** — 매 렌더 새 `[]` 면 [context] effect 가 매번 다시 돌아 setState·onChange 로 렌더가 끝없이
|
|
13
|
+
반복됐다(자료를 불러오기 전 context 없이 그린 단계에서 CPU 100%). */
|
|
14
|
+
const 빈문맥 = [];
|
|
15
|
+
export default function TrainingStepOrderList({ index, stepIndex, isLast = false, title, onDone, onChange, context = 빈문맥, }) {
|
|
13
16
|
const { t } = useTranslation();
|
|
14
17
|
const resolvedTitle = title ?? t('training.orderListDefault', '문장을 들은 순서대로 배치하세요.');
|
|
15
18
|
const toText = (v) => typeof v === "string"
|
|
@@ -32,7 +35,13 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
|
|
|
32
35
|
const 조작가능 = currentState !== 'disabled';
|
|
33
36
|
// context 변경 시 items/shuffled를 다시 초기화하는 의도된 동기화다
|
|
34
37
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
38
|
+
/* 같은 context 로 다시 도는 effect(StrictMode 의 두 번 실행, 숨김→보임)는 넘긴다 — 다시 섞고 다시 알리면 처음 알림의
|
|
39
|
+
배치가 화면에 한 번도 안 뜬 배치였고 onChange·onDone 이 두 번씩 나갔다. 새 context 일 때만 섞는다. */
|
|
40
|
+
const 섞은문맥 = useRef(undefined);
|
|
35
41
|
useEffect(() => {
|
|
42
|
+
if (섞은문맥.current === context)
|
|
43
|
+
return;
|
|
44
|
+
섞은문맥.current = context;
|
|
36
45
|
const it = Array.isArray(context) ? context : [];
|
|
37
46
|
setItems(it);
|
|
38
47
|
const makeInitial = (src) => {
|
|
@@ -119,6 +128,12 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
|
|
|
119
128
|
if (rafRef.current !== null)
|
|
120
129
|
return;
|
|
121
130
|
const loop = () => {
|
|
131
|
+
/* 끄는 것이 없으면 멈춘다 — dragstart 가 건 첫 프레임이 돌기 전에 dragend·언마운트가 오면 비어 있는 rafRef 만
|
|
132
|
+
취소돼, 뒤늦게 켜진 루프가 60fps 로 영영 돌았다. */
|
|
133
|
+
if (!draggingObjRef.current) {
|
|
134
|
+
rafRef.current = null;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
122
137
|
rafRef.current = requestAnimationFrame(loop);
|
|
123
138
|
const p = lastPosRef.current;
|
|
124
139
|
const off = dragOffsetRef.current || { dx: 0, dy: 0 };
|