@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.
Files changed (90) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +182 -182
  7. package/dist/ihabdevteam-core.d.ts +20 -5
  8. package/dist/packages/core/src/components/Breadcrumb.js +1 -0
  9. package/dist/packages/core/src/components/Button.js +10 -1
  10. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +3 -2
  11. package/dist/packages/core/src/components/Icon.js +4 -1
  12. package/dist/packages/core/src/components/LandingHeader.d.ts +1 -1
  13. package/dist/packages/core/src/components/LandingHeader.js +5 -1
  14. package/dist/packages/core/src/components/PageTitle.js +1 -0
  15. package/dist/packages/core/src/components/Pagination.js +1 -1
  16. package/dist/packages/core/src/components/Panel.css +3 -0
  17. package/dist/packages/core/src/components/Panel.d.ts +11 -1
  18. package/dist/packages/core/src/components/Panel.js +3 -3
  19. package/dist/packages/core/src/components/PanelRow.js +8 -1
  20. package/dist/packages/core/src/components/PassCard/PassCard.js +1 -1
  21. package/dist/packages/core/src/components/PassCard/PassCardBgIHAB.js +1 -0
  22. package/dist/packages/core/src/components/PassCard/PassCardBgLeftColorGrid.js +1 -0
  23. package/dist/packages/core/src/components/PassCard/PassCardBgSoundMove.js +1 -0
  24. package/dist/packages/core/src/components/PassCard/PassCardBgSoundMoveV1.js +1 -0
  25. package/dist/packages/core/src/components/PassCard/PassCardBgTalk.js +1 -0
  26. package/dist/packages/core/src/components/PassCard/PassCardBgTalkV1.js +1 -0
  27. package/dist/packages/core/src/components/PassCard/PassCardBgTwoCircle.js +1 -0
  28. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +8 -1
  29. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -1
  30. package/dist/packages/core/src/components/Popup/Popup.css +10 -0
  31. package/dist/packages/core/src/components/Popup/Popup.d.ts +5 -0
  32. package/dist/packages/core/src/components/Popup/Popup.js +5 -4
  33. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +6 -1
  34. package/dist/packages/core/src/components/ProgressIndicator.js +1 -0
  35. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDark.js +1 -0
  36. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDeep.js +1 -0
  37. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDiagonal.js +1 -0
  38. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDots.js +1 -0
  39. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgInstagram.js +1 -0
  40. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgMain.js +1 -0
  41. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgNaver.js +1 -0
  42. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgOutline.js +1 -0
  43. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgRadial.js +1 -0
  44. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoft.js +1 -0
  45. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoftSub.js +1 -0
  46. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSub.js +1 -0
  47. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSuccess.js +1 -0
  48. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgWarm.js +1 -0
  49. package/dist/packages/core/src/components/Table.js +4 -1
  50. package/dist/packages/core/src/components/TextField.js +3 -1
  51. package/dist/packages/core/src/components/Tooltip/DescriptionTooltip.js +1 -0
  52. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +23 -26
  53. package/dist/packages/core/src/components/Training/TrainingBlock.js +20 -3
  54. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -4
  55. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +2 -1
  56. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +20 -5
  57. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +60 -3
  58. package/dist/packages/core/src/components/Training/TrainingStepCard.js +16 -1
  59. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +2 -1
  60. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +26 -6
  61. package/dist/packages/core/src/components/Training/TrainingStepOrderList.d.ts +0 -1
  62. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +16 -1
  63. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +18 -3
  64. package/dist/packages/core/src/components/Training/TrainingWordCard.js +28 -5
  65. package/dist/packages/core/src/components/Training/currentSound.d.ts +5 -0
  66. package/dist/packages/core/src/components/Training/currentSound.js +13 -0
  67. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +12 -1
  68. package/dist/packages/core/src/components/Training/useNoiseFade.js +7 -1
  69. package/dist/packages/core/src/components/Training/useTTSPlayback.js +5 -1
  70. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +3 -1
  71. package/dist/packages/core/src/components/WordTable.js +25 -7
  72. package/dist/packages/core/src/hooks/useEditableList.js +13 -5
  73. package/dist/packages/core/src/hooks/useFontScale.js +13 -4
  74. package/dist/packages/core/src/hooks/useIcon.js +3 -0
  75. package/dist/packages/core/src/hooks/useLucideIcon.js +3 -0
  76. package/dist/packages/core/src/hooks/useMediaQuery.d.ts +2 -1
  77. package/dist/packages/core/src/hooks/useMediaQuery.js +37 -8
  78. package/dist/packages/core/src/hooks/usePagedList.js +1 -0
  79. package/dist/packages/core/src/hooks/usePasswordChange.js +7 -0
  80. package/dist/packages/core/src/hooks/useTrainingTypeLabels.js +1 -0
  81. package/dist/packages/core/src/hooks/useVisitHistory.js +1 -0
  82. package/dist/packages/core/src/hooks/useWordTrainingDataGeneration.js +18 -3
  83. package/dist/packages/core/src/i18n/locales/en.json +41 -4
  84. package/dist/packages/core/src/i18n/locales/ko.json +41 -4
  85. package/dist/packages/core/src/utils/errorMessage.js +7 -1
  86. package/dist/packages/core/src/utils/wordPairIndex.js +11 -3
  87. package/dist/packages/core/src/version.d.ts +1 -1
  88. package/dist/packages/core/src/version.js +1 -1
  89. package/dist/utils.cjs.js +2 -2
  90. 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
- useLayoutEffect(() => { valueRef.current = value; });
66
+ useIsoLayoutEffect(() => { valueRef.current = value; });
65
67
  /*
66
68
  * React 는 controlled input 의 값을 node.defaultValue(= HTML value 속성)에 동기화한다.
67
69
  * 일반 입력에서는 무해하지만 비밀번호에서는 그 속성이 outerHTML 직렬화 경로
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import Tooltip from './Tooltip';
3
4
  import { useTranslation } from 'react-i18next';
@@ -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
- const [stack, setStack] = useState([{ title: null, columns }]);
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
- // 부모에서 전달되는 `columns`가 변경될 때 루트 컬럼을 동기화합니다.
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
- setStack((s) => [...s, { title: item.label || null, columns: [{ title: null, items: (item.children || []) }] }]);
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
- function loadMore() { setVisible((v) => Math.min(total, v + BTN_COUNT_MAX)); }
33
- return (_jsxs(Section, { type: sectionType, title: type?.label, icon: type?.icon, disc: type?.desc, className: "training-block", children: [_jsx("div", { className: "training-list", children: (itemsAll || []).slice(0, visible).map((item, idx) => {
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
- mainName = parent?.label || '';
97
+ // 카테고리 이름도 지금 언어로(topic.* · topic.sub.*, 사전에 없으면 등록된 이름)
98
+ mainName = parent ? t(`topic.${parent.key}`, { defaultValue: parent.label || '' }) : '';
97
99
  try {
98
- subName = parent?.subs?.find((s) => s.key === subKey)?.label || '';
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
- const trainingTypeName = isSmall && type?.key
176
- ? (getTrainingTypes().find(t => t.key === type.key)?.label ?? '')
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
- setResultOverlay(null);
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
- setResultOverlay(null);
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
- setResultOverlay(null);
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
- void routeElementAudio(a).then((an) => { try {
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
- for (const cardRef of cardRefs.current.slice(0, shown.length)) {
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
- // 문장이 **바뀔 때만** 선택을 푼다(렌더 중 상태 조정). 예전 effect 는 selectedAnswer 를 의존성에 두고
18
+ // 문항이 **바뀔 때만** 선택을 푼다(렌더 중 상태 조정). 예전 effect 는 selectedAnswer 를 의존성에 두고
19
19
  // "null 이 아니면 null 로" 를 해서, 답을 누르면 한 박자 뒤 강조가 곧바로 사라졌다.
20
- // 데모 강제 선택 시에는 문장 변경 리셋을 건너뛴다.
21
- const [prevSentence, setPrevSentence] = useState(sentence);
22
- if (prevSentence !== sentence) {
23
- setPrevSentence(sentence);
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
- export default function TrainingStepOrderList({ index, stepIndex, isLast = false, title, onDone, onChange, context = [], }) {
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 };