@ihabdevteam/core 0.137.0 → 0.139.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/components.cjs.js +175 -175
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +182 -182
  6. package/dist/ihabdevteam-core.d.ts +1 -1
  7. package/dist/packages/core/src/components/Button.js +10 -1
  8. package/dist/packages/core/src/components/PassCard/PassCard.js +1 -1
  9. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -1
  10. package/dist/packages/core/src/components/Popup/Popup.js +26 -3
  11. package/dist/packages/core/src/components/Table.js +4 -1
  12. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +23 -26
  13. package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -3
  14. package/dist/packages/core/src/components/Training/TrainingBlock.js +20 -3
  15. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
  16. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +30 -6
  17. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +41 -4
  18. package/dist/packages/core/src/components/Training/TrainingStepCard.js +9 -0
  19. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +2 -1
  20. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +26 -6
  21. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +18 -3
  22. package/dist/packages/core/src/components/Training/TrainingWordCard.js +9 -3
  23. package/dist/packages/core/src/components/Training/useNoiseFade.js +3 -1
  24. package/dist/packages/core/src/components/Training/useTTSPlayback.js +4 -0
  25. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +3 -1
  26. package/dist/packages/core/src/components/WordTable.js +6 -2
  27. package/dist/packages/core/src/hooks/useEditableList.js +13 -5
  28. package/dist/packages/core/src/hooks/usePasswordChange.js +7 -0
  29. package/dist/packages/core/src/utils/speak.js +9 -0
  30. package/dist/packages/core/src/utils/wordPairIndex.js +11 -3
  31. package/dist/packages/core/src/version.d.ts +1 -1
  32. package/dist/packages/core/src/version.js +1 -1
  33. package/dist/utils.cjs.js +2 -2
  34. 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.137.0";
1379
+ export declare const CORE_VERSION = "0.139.0";
1380
1380
 
1381
1381
  /** @public */
1382
1382
  declare const coreDefault: Record<string, unknown>;
@@ -68,7 +68,16 @@ export default function Button(props) {
68
68
  useEffect(() => {
69
69
  if (prevDisabledRef.current && !disabledProp && btnRef.current) {
70
70
  const btn = btnRef.current;
71
- btn.blur();
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", disabled: !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
85
+ return (_jsx(Popup, { className: "popup--load-table", title: t('wordTable.loadTable.title', '저장된 단어 표 불러오기'), headerActions: headerActions, onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "load-table-popup__footer-actions", children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation, width: "fit-content" }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: t('wordTable.loadTable.loadButton', '불러오기'), width: "fit-content",
86
+ // 고른 표가 지금 목록에 있을 때만 — 부모 목록에서 빠진 표를 가리키면 눌러도 아무 일이 없었다
87
+ disabled: !selected || !tables.some(t => t.id === selected), onClick: () => handleLoadSelected(animateThen) })] })), children: _jsx("div", { className: "load-table-popup__body", children: tables.length === 0 ? (_jsx("p", { className: "load-table-popup__empty body-2", children: t('wordTable.loadTable.empty', '저장된 단어 표가 없습니다.') })) : (_jsxs(_Fragment, { children: [viewMode === 'grid' && (_jsx("ul", { className: "load-table-popup__grid", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__card${selected === table.id ? ' load-table-popup__card--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') }), _jsx(TableThumbnail, { table: table }), _jsxs("div", { className: "load-table-popup__card-info", children: [_jsx("span", { className: "load-table-popup__card-name body-2", children: table.name }), _jsxs("span", { className: "load-table-popup__card-meta caption-1", children: [formatDate(table.savedAt), " \u00B7 ", t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' })] })] })] }, table.id))) })), viewMode === 'list' && (_jsx("ul", { className: "load-table-popup__list", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__row${selected === table.id ? ' load-table-popup__row--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx("span", { className: "load-table-popup__row-check", children: selected === table.id && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", children: _jsx("polyline", { points: "4 13 9 18 20 6", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) }), _jsxs("div", { className: "load-table-popup__row-main", children: [_jsx("span", { className: "load-table-popup__row-name body-2", children: table.name }), _jsx("div", { className: "load-table-popup__row-words", children: table.items.map((item, i) => (_jsx("span", { className: "load-table-popup__row-word caption-1", children: (item.words ?? []).length >= 2
86
88
  ? `${item.words[0].label} · ${item.words[1].label}`
87
89
  : item.words?.[0]?.label ?? item.id }, i))) })] }), _jsx("span", { className: "load-table-popup__row-count caption-1", children: t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' }) }), _jsx("span", { className: "load-table-popup__row-date caption-1", children: formatDate(table.savedAt) }), _jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') })] }, table.id))) }))] })) }) }));
88
90
  }
@@ -105,18 +105,41 @@ export default function Popup(props) {
105
105
  /* Tab 순환을 패널 안으로 가둔다 — 네 곳에 있던 같은 열두 줄을 훅 한 벌로 모았다.
106
106
  inline 으로 쓸 때는 다이얼로그가 아니라 걸지 않는다(예전과 같다). */
107
107
  useFocusTrap(panelRef, !inline);
108
+ /* 닫힘 애니메이션 뒤 콜백을 부른다. **콜백이 던지거나 거절되면, 또는 콜백 뒤에도 이 창이 그대로 있으면 되살린다** —
109
+ 전에는 닫는 중 표시만 남아 투명한 막이 화면을 덮고(클릭을 가로챔) ESC·X·바탕·다시 저장이 모두 무시돼 페이지를 쓸 수
110
+ 없었다(말귀 업체 정보·훈련 묶음 수정 저장이 실패해 부모가 창을 열어 둘 때). 성공하면 부모가 곧 치우므로 되살릴 일이 없다. */
111
+ const 살아있음Ref = useRef(true);
112
+ useEffect(() => { 살아있음Ref.current = true; return () => { 살아있음Ref.current = false; }; }, []);
113
+ const 되살리기 = useCallback(() => {
114
+ if (!살아있음Ref.current || !closingRef.current)
115
+ return;
116
+ closingRef.current = false;
117
+ setClosing(false);
118
+ }, []);
108
119
  const animateThen = useCallback((cb) => {
109
120
  if (closingRef.current)
110
121
  return;
111
122
  closingRef.current = true;
112
123
  setClosing(true);
113
124
  setTimeout(() => {
125
+ let 결과;
114
126
  try {
115
- cb && cb();
127
+ 결과 = cb ? cb() : undefined;
128
+ }
129
+ catch (e) {
130
+ 되살리기();
131
+ return;
132
+ }
133
+ // 부모가 치울 틈(렌더·커밋)을 준 뒤에도 남아 있으면 되살린다
134
+ const 남았으면 = () => { setTimeout(되살리기, 120); };
135
+ if (결과 && typeof 결과.then === 'function') {
136
+ 결과.then(남았으면, 되살리기);
137
+ }
138
+ else {
139
+ 남았으면();
116
140
  }
117
- catch (e) { }
118
141
  }, ANIM_MS);
119
- }, []);
142
+ }, [되살리기]);
120
143
  const closeWithAnimation = useCallback(() => {
121
144
  if (closeDisabled)
122
145
  return;
@@ -299,7 +299,10 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
299
299
  ? { '--table-grid-cols': effectiveGrid }
300
300
  : undefined;
301
301
  const hasHeader = title !== undefined;
302
- return (_jsx("div", { className: `table-wrap table-wrap--${size}${폭조절 ? ' table-wrap--resizable' : ''}${dragOver ? ' table-wrap--dragover' : ''}${collapsed ? ' table-wrap--collapsed' : ''}`, style: gridStyle, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: _jsxs("div", { className: "table body-2", children: [hasHeader && (_jsxs("div", { className: "table-header", onClick: onToggleCollapse, role: onToggleCollapse ? 'button' : undefined, tabIndex: onToggleCollapse ? 0 : undefined, "aria-expanded": onToggleCollapse ? !collapsed : undefined, onKeyDown: onToggleCollapse ? (e) => { if (e.key === 'Enter' || e.key === ' ') {
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
- 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
  }
@@ -193,17 +193,27 @@ export default function Tooltip(props) {
193
193
  if (immediate) {
194
194
  // Synchronously close without waiting for the closing animation.
195
195
  setClosing(true);
196
+ // onClose 가 던지면 닫는 중 표시를 되돌린다 — 투명한 채 남아 이후 닫기가 모두 무시됐다
196
197
  try {
197
198
  if (onClose)
198
199
  onClose();
199
200
  }
200
- catch (err) { }
201
+ catch (err) {
202
+ closingRef.current = false;
203
+ setClosing(false);
204
+ }
201
205
  return;
202
206
  }
203
207
  setClosing(true);
204
208
  setTimeout(() => {
205
- if (onClose)
206
- onClose();
209
+ try {
210
+ if (onClose)
211
+ onClose();
212
+ }
213
+ catch (err) {
214
+ closingRef.current = false;
215
+ setClosing(false);
216
+ }
207
217
  }, 200);
208
218
  }, [closing, onClose, setClosing]);
209
219
  useEffect(() => {
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useState, useMemo } from 'react';
3
+ import { useEffect, useRef, useState, useMemo } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import Button from '../Button';
6
6
  import Section from '../Section';
@@ -29,8 +29,25 @@ export default function TrainingBlock(props) {
29
29
  }, [total]);
30
30
  /* eslint-enable react-hooks/set-state-in-effect */
31
31
  useWindowEvent('speechear.data.updated', () => setDataTick((v) => v + 1));
32
- 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))
@@ -11,7 +11,7 @@ export default function TrainingCardGroup({ title, count, collapsed = false, onT
11
11
  /* 접을 수 있는가 — `onToggle` 유무가 그것을 말한다. 새 prop 을 두지 않는다. */
12
12
  const 접을수있다 = onToggle != null;
13
13
  const cls = cx('training-card-group', dragOver && 'training-card-group--dragover', collapsed && 'training-card-group--collapsed', 접을수있다 && 'training-card-group--collapsible', className);
14
- return (_jsxs("div", { className: cls, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: [_jsxs("header", { className: "training-card-group__header", children: [접을수있다 && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "training-card-group__chevron", onClick: onToggle })), typeof title === 'string'
14
+ return (_jsxs("div", { className: cls, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: [_jsxs("header", { className: "training-card-group__header", children: [접을수있다 && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "training-card-group__chevron", "aria-expanded": !collapsed, onClick: onToggle })), typeof title === 'string'
15
15
  ? _jsx("span", { className: "training-card-group__title h5b", children: title })
16
16
  : title, count !== undefined && _jsx("span", { className: "training-card-group__count body-3b", children: count }), (sort || actions != null) && (_jsxs("span", { className: "training-card-group__actions", onClick: e => e.stopPropagation(), children: [sort && (_jsx(Dropdown, { size: 40, options: sort.options, value: sort.value, placeholder: sort.placeholder, onChange: sort.onChange })), actions] }))] }), !collapsed && (empty
17
17
  ? _jsx("div", { className: "training-card-group__empty body-3", children: emptyContent })
@@ -218,7 +218,16 @@ function TrainingPagePopupInner(props) {
218
218
  return;
219
219
  closingRef.current = true;
220
220
  setClosing(true);
221
- closeTimerRef.current = setTimeout(fn, PAGE_POPUP_CLOSE_MS);
221
+ // 닫기 콜백(onBack 등)이 던지면 닫는 중 표시를 되돌린다 — 굳은 채 Tab 가둠·ESC 가 남고 다시 닫을 수 없었다
222
+ closeTimerRef.current = setTimeout(() => {
223
+ try {
224
+ fn();
225
+ }
226
+ catch (e) {
227
+ closingRef.current = false;
228
+ setClosing(false);
229
+ }
230
+ }, PAGE_POPUP_CLOSE_MS);
222
231
  }, []);
223
232
  useEffect(() => () => { if (closeTimerRef.current)
224
233
  clearTimeout(closeTimerRef.current); }, []);
@@ -265,6 +274,10 @@ function TrainingPagePopupInner(props) {
265
274
  // 최초 공개(reveal) 이후엔 mode 변경을 막는 플래그 (MutationObserver 루프 방지)
266
275
  const revealedRef = useRef(false);
267
276
  const [resultOverlay, setResultOverlay] = useState(null);
277
+ /* 결과 요청 세대 — 결과보기가 올리고, 창을 닫거나(다시하기·완료·시나리오 초기화) 새로 요청하면 앞 요청은 늦은 것이 된다.
278
+ 결과 계산이 비동기일 때 늦게 온 옛 결과가 새 판 위에 결과 창을 다시 띄우거나 2회차 결과를 1회차 것으로 덮었다. */
279
+ const 결과세대Ref = useRef(0);
280
+ const 결과창닫기 = () => { 결과세대Ref.current += 1; setResultOverlay(null); };
268
281
  const [noticeMessage, setNoticeMessage] = useState('');
269
282
  const [flowState, setFlowState] = useState(() => createScenarioFlowState());
270
283
  const prevPageRef = useRef(typeof pageIndex === 'number' ? pageIndex : null);
@@ -274,7 +287,8 @@ function TrainingPagePopupInner(props) {
274
287
  if (!hasScenario && !isChildrenMode)
275
288
  return;
276
289
  setFlowState(createScenarioFlowState());
277
- setResultOverlay(null);
290
+ 결과창닫기();
291
+ // eslint-disable-next-line react-hooks/exhaustive-deps
278
292
  }, [hasScenario, scenarioId, isChildrenMode]);
279
293
  /* eslint-enable react-hooks/set-state-in-effect */
280
294
  // 페이지 개수가 줄어들면 flowState.pageIndex를 유효 범위로 보정하는 의도된 동기화다
@@ -544,6 +558,8 @@ function TrainingPagePopupInner(props) {
544
558
  return baseResult;
545
559
  };
546
560
  const handleViewResults = () => {
561
+ const 내결과 = ++결과세대Ref.current;
562
+ const 늦음 = () => 내결과 !== 결과세대Ref.current;
547
563
  try {
548
564
  if (typeof document !== 'undefined' && document.body) {
549
565
  try {
@@ -561,6 +577,8 @@ function TrainingPagePopupInner(props) {
561
577
  const maybeAny = maybe;
562
578
  Promise.resolve(maybeAny)
563
579
  .then((data) => {
580
+ if (늦음())
581
+ return;
564
582
  if (data)
565
583
  setResultOverlay(data);
566
584
  else
@@ -569,6 +587,8 @@ function TrainingPagePopupInner(props) {
569
587
  .catch((err) => {
570
588
  if (import.meta.env?.DEV)
571
589
  console.error('viewResults scenario handler failed', err);
590
+ if (늦음())
591
+ return;
572
592
  setResultOverlay({ isEmpty: true });
573
593
  });
574
594
  return;
@@ -579,6 +599,8 @@ function TrainingPagePopupInner(props) {
579
599
  const maybeAny = maybe;
580
600
  Promise.resolve(maybeAny)
581
601
  .then((data) => {
602
+ if (늦음())
603
+ return;
582
604
  if (data)
583
605
  setResultOverlay(data);
584
606
  else
@@ -587,6 +609,8 @@ function TrainingPagePopupInner(props) {
587
609
  .catch((err) => {
588
610
  if (import.meta.env?.DEV)
589
611
  console.error('viewResults children handler failed', err);
612
+ if (늦음())
613
+ return;
590
614
  setResultOverlay({ isEmpty: true });
591
615
  });
592
616
  return;
@@ -614,7 +638,7 @@ function TrainingPagePopupInner(props) {
614
638
  else {
615
639
  setResultOverlay(maybeAny && typeof maybeAny.then === 'function' ? {} : {});
616
640
  }
617
- Promise.resolve(maybeAny).then((data) => { if (data)
641
+ Promise.resolve(maybeAny).then((data) => { if (data && !늦음())
618
642
  setResultOverlay(data); }).catch((err) => { if (import.meta.env?.DEV)
619
643
  console.error('viewResults child handler failed', err); });
620
644
  return;
@@ -637,7 +661,7 @@ function TrainingPagePopupInner(props) {
637
661
  if (import.meta && import.meta.env && import.meta.env.DEV)
638
662
  console.debug('TrainingPagePopup: externalOnResult resolved data:', data);
639
663
  }
640
- catch (e) { } if (data)
664
+ catch (e) { } if (data && !늦음())
641
665
  setResultOverlay(data); }).catch((err) => { console.error('viewResults external handler failed', err); });
642
666
  return;
643
667
  }
@@ -796,7 +820,7 @@ function TrainingPagePopupInner(props) {
796
820
  });
797
821
  completeResult = { correct: finalCorrect, total };
798
822
  }
799
- setResultOverlay(null);
823
+ 결과창닫기();
800
824
  scheduleClose(() => {
801
825
  if (typeof onComplete === 'function')
802
826
  onComplete(completeResult);
@@ -820,7 +844,7 @@ function TrainingPagePopupInner(props) {
820
844
  results: (resultOverlay.results || resultOverlay.answers || []),
821
845
  contextsLength: resultOverlay.results ? resultOverlay.results.length : (resultOverlay.answers ? resultOverlay.answers.length : 0),
822
846
  onReplay: () => {
823
- setResultOverlay(null);
847
+ 결과창닫기();
824
848
  setReplayCount((n) => n + 1);
825
849
  if (hasScenario) {
826
850
  setFlowState(createScenarioFlowState());
@@ -82,6 +82,22 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
82
82
 
83
83
  대신 아래에서 `uiState` 로 겹쳐 읽는다 — 상태 기계는 그대로 두고 화면만 가린다. */
84
84
  const [stateLocal, setStateLocal] = useState('active');
85
+ /* 초점을 가진 큰 '듣기' 단추를 누르면 그 단추가 사라진다 — 재생 모드가 되면 초점을 '재생 / 멈춤' 으로 옮긴다
86
+ (옮기지 않으면 초점이 body 로 떨어져 키보드 사용자가 멈출 단추를 다시 찾아야 했다). */
87
+ const 듣기초점옮기기Ref = useRef(false);
88
+ useEffect(() => {
89
+ if (stateLocal !== 'playMode' || !듣기초점옮기기Ref.current)
90
+ return;
91
+ 듣기초점옮기기Ref.current = false;
92
+ const 지금 = typeof document !== 'undefined' ? document.activeElement : null;
93
+ if (지금 && 지금 !== document.body)
94
+ return;
95
+ const 재생멈춤 = btnsRef.current?.querySelectorAll('button')[1];
96
+ try {
97
+ 재생멈춤?.focus();
98
+ }
99
+ catch (e) { }
100
+ }, [stateLocal]);
85
101
  const [sources, setSources] = useState(null);
86
102
  const [segments, setSegments] = useState(null);
87
103
  const [currentIndex, setCurrentIndex] = useState(0);
@@ -171,9 +187,14 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
171
187
  mountedRef.current = true;
172
188
  return () => { mountedRef.current = false; };
173
189
  }, []);
190
+ /* 재생 의도 — 멈춤·정지·완료·트랙 이동이 올린다. 대기 중이던 play() 는 멈추면 AbortError 로 거절되는데, 그것을 "다시 시도할
191
+ 오류" 로만 보고 다시 부르면 **정지를 눌렀는데 소리가 다시 났다**. 시작할 때의 의도가 그대로일 때만 다시 시도한다. */
192
+ const 재생의도Ref = useRef(0);
193
+ const 멈춤의도 = () => { 재생의도Ref.current += 1; jumpTokenRef.current += 1; };
174
194
  const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
195
+ const 의도 = 재생의도Ref.current;
175
196
  for (let i = 0; i < attempts; i++) {
176
- if (!mountedRef.current)
197
+ if (!mountedRef.current || 의도 !== 재생의도Ref.current)
177
198
  return false;
178
199
  try {
179
200
  await a.play();
@@ -182,6 +203,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
182
203
  catch (err) {
183
204
  const errName = err instanceof Error ? err.name : (typeof err === 'object' && err && 'name' in err ? err.name : undefined);
184
205
  if (errName === 'AbortError') {
206
+ if (의도 !== 재생의도Ref.current)
207
+ return false;
185
208
  await new Promise((res) => setTimeout(res, delayMs));
186
209
  continue;
187
210
  }
@@ -218,7 +241,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
218
241
  // 태우므로 그 전엔 요소가 제 볼륨으로 직접 울린다(무음 방지). 태운 뒤 볼륨은 GainNode 로 걸린다.
219
242
  try {
220
243
  resumeAudioMeter();
221
- 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) { } });
@@ -695,6 +720,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
695
720
  setPlaybackState('playing');
696
721
  }
697
722
  else {
723
+ 멈춤의도();
698
724
  a.pause();
699
725
  setPlaybackState('paused');
700
726
  }
@@ -744,7 +770,14 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
744
770
  catch (e) { }
745
771
  }
746
772
  setStateLocal("load");
747
- const 얻음 = await fetchAndPrepare(true);
773
+ // 앱이 준 경로 규칙(resolvePath 등)이 던져도 못 얻은 것과 같이 다룬다 — 거절이 흘러 'load' 에 갇혔다
774
+ let 얻음;
775
+ try {
776
+ 얻음 = await fetchAndPrepare(true);
777
+ }
778
+ catch (e) {
779
+ 얻음 = false;
780
+ }
748
781
  /* 쓸 소스를 하나도 못 얻으면(경로를 못 정한 코드뿐 등) 'load' 에 갇혀 듣기 단추가 사라지고 아무 조작도
749
782
  안 됐다 — 알리고 누를 수 있는 상태로 되돌린다. */
750
783
  if (얻음 === false && mountedRef.current) {
@@ -771,6 +804,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
771
804
  }
772
805
  else {
773
806
  if (a) {
807
+ 멈춤의도();
774
808
  a.pause();
775
809
  setPlaybackState("paused");
776
810
  }
@@ -810,6 +844,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
810
844
  catch (e) { }
811
845
  };
812
846
  const handleStop = () => {
847
+ // 기다리던 트랙 이동·재시도가 정지 뒤에 소리를 내지 않게
848
+ 멈춤의도();
813
849
  if (ttsMode) {
814
850
  stopTTS();
815
851
  return;
@@ -911,6 +947,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
911
947
  const holdDelay = 200;
912
948
  const outDelay = 340;
913
949
  markCompletedByEnd(true);
950
+ 멈춤의도();
914
951
  try {
915
952
  setProgress(1);
916
953
  }
@@ -1055,7 +1092,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1055
1092
  const fillPausedFlag = playbackState === "paused";
1056
1093
  const fillLeftPercent = Math.max(0, Math.min(100, (progress || 0) * 100));
1057
1094
  const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
1058
- return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (_jsxs("div", { className: "audio-player__loading", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: t(`training.audioStatus.${fetchDebugStatus}`, { defaultValue: STATUS_MESSAGES[fetchDebugStatus] }) })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button", onClick: () => { hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1095
+ return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (_jsxs("div", { className: "audio-player__loading", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: t(`training.audioStatus.${fetchDebugStatus}`, { defaultValue: STATUS_MESSAGES[fetchDebugStatus] }) })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button", onClick: (e) => { 듣기초점옮기기Ref.current = document.activeElement === e.currentTarget; hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1059
1096
  // 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
1060
1097
  onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
1061
1098
  handleJumpTo(idx);
@@ -46,6 +46,15 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
46
46
  /* 카드 ref 는 비우지 않는다 — 마운트 때도 도는 이 effect 가 막 붙은 ref 를 지워, 따로 쓴 단계에서는 다시 그려질
47
47
  일이 없어 '모두 듣기' 가 소리 하나 없이 끝났다. 빠진 카드는 ref 콜백이 null 을 넣고, 재생은 보이는 칸까지만 돈다. */
48
48
  }, [itemsKey]);
49
+ // 쪽 넘김 되돌리기 — 다음 쪽 카드 묶음이 같으면 카드 열쇠가 그대로라 앞 쪽 선택이 남았다(TrainingStepConfirm 과 같은 규칙)
50
+ const prevStepIndexRef = useRef(undefined);
51
+ useEffect(() => {
52
+ const prev = prevStepIndexRef.current;
53
+ if (typeof prev === 'number' && typeof stepIndex === 'number' && typeof index === 'number' && prev >= index && stepIndex < index) {
54
+ setSelectedIndex(null);
55
+ }
56
+ prevStepIndexRef.current = stepIndex;
57
+ }, [stepIndex, index]);
49
58
  /* eslint-enable react-hooks/set-state-in-effect */
50
59
  const handlePlayDone = useCallback((item, i) => {
51
60
  // selectable 모드에서는 오디오 재생이 step을 advance하지 않음
@@ -30,10 +30,11 @@ export default function TrainingStepConfirm({ index, stepIndex, isLast = false,
30
30
  const [selectedAnswer, setSelectedAnswer] = useState(null);
31
31
  const [selectedSet, setSelectedSet] = useState(() => new Set());
32
32
  /* eslint-disable react-hooks/set-state-in-effect */
33
+ // 보기를 answer·wrongAnswers 로 만드는 경우도 문항이 바뀌면 푼다 — choices 열쇠만 보면 사과/배 → 배/사과 에서 앞 강조가 남았다
33
34
  useEffect(() => {
34
35
  setSelectedAnswer(null);
35
36
  setSelectedSet(new Set());
36
- }, [propChoicesKey]);
37
+ }, [propChoicesKey, answer, wrongAnswersKey]);
37
38
  /* eslint-enable react-hooks/set-state-in-effect */
38
39
  // 페이지 전환 감지: stepIndex가 이 step의 index 이상이었다가 다시 미만으로 돌아오면
39
40
  // (다음 페이지가 시작되어 stepIndex가 리셋된 경우) 선택 상태도 초기화한다.
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useCallback, useState } from "react";
3
+ import { useCallback, useRef, useEffect, useState } from "react";
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import Button from '../Button';
6
6
  import "./TrainingStep.css";
@@ -15,15 +15,35 @@ export default function TrainingStepConfirmWithText({ index, stepIndex, isLast =
15
15
  const sentence = context && typeof context === 'object' ? (context.sentence || '') : (typeof context === 'string' ? context : '');
16
16
  const choices = context && typeof context === 'object' ? context.choices : undefined;
17
17
  const [selectedAnswer, setSelectedAnswer] = useState(demoSelected ?? null);
18
- // 문장이 **바뀔 때만** 선택을 푼다(렌더 중 상태 조정). 예전 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() })