@ihabdevteam/core 0.318.0 → 0.322.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 +336 -0
- package/dist/components.cjs.js +22 -22
- package/dist/components.esm.css +2 -2
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +188 -188
- package/dist/ihabdevteam-core.d.ts +144 -63
- package/dist/packages/core/src/components/HearingDeviceInfo.d.ts +5 -5
- package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
- package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
- package/dist/packages/core/src/components/PanelRow.js +2 -2
- package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
- package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -3
- package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -3
- package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.css +19 -0
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.d.ts +16 -1
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +8 -6
- package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingCardGroup.d.ts +20 -1
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +15 -0
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +16 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +14 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirm.d.ts +3 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +42 -30
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +2 -2
- package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
- package/dist/packages/core/src/components/Training/oneShotSound.js +3 -3
- package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +60 -10
- package/dist/packages/core/src/components/Training/trainingMissStats.js +2 -2
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +9 -9
- package/dist/packages/core/src/components/Word/WordTableBlock.css +25 -0
- package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
- package/dist/packages/core/src/components/Word/wordScoreStats.js +2 -2
- package/dist/packages/core/src/components/Word/wordTableRecommend.js +2 -2
- package/dist/packages/core/src/components/wordTableLayout.d.ts +5 -3
- package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
- package/dist/packages/core/src/hooks/inputModality.js +1 -1
- package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
- package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
- package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
- package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
- package/dist/packages/core/src/hooks/useTablePaging.d.ts +16 -5
- package/dist/packages/core/src/hooks/useTablePaging.js +20 -9
- package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +10 -1
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +11 -2
- package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +8 -4
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +44 -7
- package/dist/packages/core/src/i18n/locales/en.json +8 -5
- package/dist/packages/core/src/i18n/locales/ko.json +8 -5
- package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
- package/dist/packages/core/src/utils/devWarn.js +5 -5
- package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
- package/dist/packages/core/src/utils/filterPanelSummary.js +4 -4
- package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
- package/dist/packages/core/src/utils/navScroll.js +4 -4
- package/dist/packages/core/src/utils/phonemePairSearch.js +10 -3
- package/dist/packages/core/src/utils/pressable.d.ts +1 -1
- package/dist/packages/core/src/utils/pressable.js +3 -3
- package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
- package/dist/packages/core/src/utils/sessionTab.js +9 -9
- package/dist/packages/core/src/utils/textSort.d.ts +15 -5
- package/dist/packages/core/src/utils/textSort.js +19 -9
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +37 -0
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +60 -3
- package/dist/packages/core/src/utils/wordTrainingPool.js +3 -2
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +7 -3
- package/package.json +2 -1
|
@@ -4,10 +4,25 @@ export interface NoiseSettingsTooltipProps {
|
|
|
4
4
|
/** 앵커(소음 설정 버튼) 위치. */
|
|
5
5
|
anchorRect: DOMRect | null;
|
|
6
6
|
onClose: () => void;
|
|
7
|
+
/**
|
|
8
|
+
* **소음 두 줄을 안 그린다** — `소음 종류`·`소음 비율`(사장님 지시 (207), 2026-10-12).
|
|
9
|
+
*
|
|
10
|
+
* 소음이 아예 없는 훈련(짧은 이야기 듣기 · 긴 이야기 듣기 · 문장 순서화)에서 쓴다.
|
|
11
|
+
* `전체 소리`·`재생 속도` 는 그대로 선다.
|
|
12
|
+
*
|
|
13
|
+
* **흐리게가 아니라 안 그린다.** 흐린 줄은 *"지금은 못 바꾼다"* 를 말하는데, 여기서 할 말은
|
|
14
|
+
* *"이 훈련에는 소음이 없다"* 다. 흐린 채로 두면 치료사가 **바꿀 수 있는 줄을 찾아 헤맨다**
|
|
15
|
+
* (사장님이 그 줄을 짚으며 *"왜 이거 수정요청 했는데 계속 나오고 있지?"* 라 하셨다).
|
|
16
|
+
*
|
|
17
|
+
* **`noiseOverride` 와 따로 둔다 — 덮개는 *값*, 이 칸은 *보임*.** 묶으면 *"덮개는 걸되 줄은
|
|
18
|
+
* 보이게"* 를 못 한다 — 소음 하 문장 듣기의 제안 갈래가 그 꼴이다(덮개를 걸고도 치료사가
|
|
19
|
+
* 창에서 고친다). **한 칸이 두 가지를 뜻하게 하지 않는다.**
|
|
20
|
+
*/
|
|
21
|
+
hideNoiseRows?: boolean;
|
|
7
22
|
}
|
|
8
23
|
/**
|
|
9
24
|
* 소음 설정 툴팁 — 각 항목을 행으로 세로 배치, 슬라이더는 가로 방향.
|
|
10
25
|
* 설정값은 NoisePlayerContext(useNoisePlayer)로 읽고 쓴다.
|
|
11
26
|
* @public
|
|
12
27
|
*/
|
|
13
|
-
export default function NoiseSettingsTooltip({ anchorRect, onClose }: NoiseSettingsTooltipProps): import("react").JSX.Element;
|
|
28
|
+
export default function NoiseSettingsTooltip({ anchorRect, onClose, hideNoiseRows }: NoiseSettingsTooltipProps): import("react").JSX.Element;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback } from 'react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import Tooltip from '../Tooltip/Tooltip';
|
|
@@ -29,7 +29,7 @@ function SliderPanelRow({ title, valueLabel, value, min, max, step, tickStep, si
|
|
|
29
29
|
* 설정값은 NoisePlayerContext(useNoisePlayer)로 읽고 쓴다.
|
|
30
30
|
* @public
|
|
31
31
|
*/
|
|
32
|
-
export default function NoiseSettingsTooltip({ anchorRect, onClose }) {
|
|
32
|
+
export default function NoiseSettingsTooltip({ anchorRect, onClose, hideNoiseRows = false }) {
|
|
33
33
|
const { t } = useTranslation();
|
|
34
34
|
// 모바일에서는 터치 영역 확보를 위해 슬라이더를 더 크게(40) 표시
|
|
35
35
|
const isMobile = useMediaQuery('(max-width: 767px)');
|
|
@@ -75,8 +75,10 @@ export default function NoiseSettingsTooltip({ anchorRect, onClose }) {
|
|
|
75
75
|
}
|
|
76
76
|
catch (e) { }
|
|
77
77
|
}, [noisePlayer]);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
/* 소음 종류 보기 — **한 곳**이다. 아래 참 줄과 폭 그림자 줄이 같은 배열을 쓴다 */
|
|
79
|
+
const 종류옵션 = [
|
|
80
|
+
{ key: NONE_KEY, label: t('training.noise.type.none', '없음') },
|
|
81
|
+
...getNoiseTypeOptions(t).map(opt => ({ key: opt.value, label: opt.label })),
|
|
82
|
+
];
|
|
83
|
+
return (_jsx(Tooltip, { anchorRect: anchorRect, offset: 12, onClose: onClose, ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), isMobilePopup: false, closeOnOutsideClick: false, header: _jsxs("div", { className: "noise-settings__header", children: [_jsx("span", { children: t('training.trainingSettingsTitle', '훈련 설정') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, ariaLabel: t('common.close', '닫기'), onClick: onClose })] }), children: _jsxs(Panel, { className: "noise-settings", useBorder: false, labelWidth: 72, children: [_jsx(SliderPanelRow, { title: t('training.playVolumeTitle', '전체 소리'), valueLabel: `${volPct}%`, value: volPct, min: 0, max: 100, step: 10, size: sliderSize, onChange: handlePlayVolumeChange }), _jsx(SliderPanelRow, { title: t('training.playSpeedTitle', '재생 속도'), valueLabel: `${playbackRate.toFixed(2)}x`, value: playbackRate, min: PLAY_SPEED_MIN, max: PLAY_SPEED_MAX, step: PLAY_SPEED_STEP, size: sliderSize, onChange: handlePlaySpeedChange }), !hideNoiseRows && (_jsxs(_Fragment, { children: [_jsx(PanelRow, { variant: "custom", label: t('training.noiseTypeTitle', '소음 종류'), useFullWidth: true, children: _jsx(SegmentedControl, { ariaLabel: t('training.noiseTypeTitle', '소음 종류'), size: 40, shape: "rounded", options: 종류옵션, value: noiseEnabled ? noiseType : NONE_KEY, onChange: handleTypeChange }) }), _jsx(SliderPanelRow, { title: t('training.noiseRatioTitle', '소음 비율'), valueLabel: t('training.noiseRatioValue', '{{ratio}}배', { ratio: ratio.toFixed(1) }), value: ratio, min: 0, max: NOISE_RATIO_MAX, step: NOISE_RATIO_STEP, size: sliderSize, disabled: !noiseEnabled, onChange: handleRatioChange })] })), hideNoiseRows && (_jsx("div", { className: "noise-settings__width-ghost", "aria-hidden": "true", inert: true, children: _jsx(PanelRow, { variant: "custom", label: '\u00a0', useFullWidth: true, children: _jsx(SegmentedControl, { ariaLabel: "width-ghost", size: 40, shape: "rounded", options: 종류옵션, value: NONE_KEY, onChange: () => { } }) }) }))] }) }));
|
|
82
84
|
}
|
|
@@ -7,7 +7,7 @@ import Section from '../Section';
|
|
|
7
7
|
import './TrainingBlock.css';
|
|
8
8
|
import TrainingBlockButton from './TrainingBlockButton';
|
|
9
9
|
import { useWindowEvent } from '../../hooks/useWindowEvent';
|
|
10
|
-
import {
|
|
10
|
+
import { wasKeyboardPress } from '../../hooks/inputModality';
|
|
11
11
|
const BTN_COUNT_MAX = 20;
|
|
12
12
|
/** @public */
|
|
13
13
|
export default function TrainingBlock(props) {
|
|
@@ -35,7 +35,7 @@ export default function TrainingBlock(props) {
|
|
|
35
35
|
const 더보기초점Ref = useRef(null);
|
|
36
36
|
function loadMore() {
|
|
37
37
|
const 단추 = document.activeElement;
|
|
38
|
-
더보기초점Ref.current =
|
|
38
|
+
더보기초점Ref.current = wasKeyboardPress() && 단추 && 단추.closest?.('.training-block__more') ? visible : null;
|
|
39
39
|
setVisible((v) => Math.min(total, v + BTN_COUNT_MAX));
|
|
40
40
|
}
|
|
41
41
|
useEffect(() => {
|
|
@@ -43,6 +43,25 @@ export type TrainingCardGroupProps = {
|
|
|
43
43
|
* "접기, 단추" 로 읽혔다. 소비자 아홉 자리 중 여덟이 그랬다.)
|
|
44
44
|
*/
|
|
45
45
|
onToggle?: () => void;
|
|
46
|
+
/**
|
|
47
|
+
* **정렬 드롭다운 바로 왼쪽 자리**((209), 사장님 지시 2026-10-12 — 노션 QA *"단어표, 대립쌍표의
|
|
48
|
+
* 그룹 순서 변경 기능 필요"*). 그룹 **차례를 바꾸는 손잡이**가 설 자리다.
|
|
49
|
+
*
|
|
50
|
+
* 사장님이 길 둘을 말씀하셨다 — *"그룹 자체를 드래그 하거나 정렬 왼쪽에 올림 내림 버튼을
|
|
51
|
+
* 넣어서"*. **둘 다 이 한 자리에 든다**(끄는 손잡이든 ↑↓ 단추든). 그래서 코어는 자리만 열고
|
|
52
|
+
* **무엇을 세울지는 부르는 쪽이 고른다** — 차례를 알고 간수하는 것이 그쪽이다.
|
|
53
|
+
*
|
|
54
|
+
* `actions` 와 **같은 대접**이다: 같은 `__actions` 감싸개 안이라 누름이 **접기 토글로 안
|
|
55
|
+
* 번진다.** 밖에 두면 ↑ 를 누를 때마다 그 그룹이 접힌다.
|
|
56
|
+
*
|
|
57
|
+
* **낭독기 이름은 부르는 쪽이 단다** — `접기`·`정렬` 은 이 조각이 묶음 이름을 달지만(위
|
|
58
|
+
* `titleLevel` 글), 넘겨받은 마디의 속을 이 조각이 고쳐 쓸 수는 없다. `actions` 와 같은
|
|
59
|
+
* 규칙이다: **`위로` 가 그룹 수만큼 서면 단추 목록에서 못 가린다** — `위로` 가 아니라
|
|
60
|
+
* `'sdfa' 그룹 위로` 꼴로 지어 넘긴다.
|
|
61
|
+
*
|
|
62
|
+
* 안 주면 자리를 안 만든다(지금과 같다).
|
|
63
|
+
*/
|
|
64
|
+
beforeSort?: ReactNode;
|
|
46
65
|
/** 헤더 우측, actions 앞에 놓이는 그룹 내 정렬 드롭다운 */
|
|
47
66
|
sort?: TrainingCardGroupSort;
|
|
48
67
|
/** 헤더 우측 액션 영역 (이름변경·추가·삭제 등) — 클릭이 토글로 전파되지 않음 */
|
|
@@ -60,4 +79,4 @@ export type TrainingCardGroupProps = {
|
|
|
60
79
|
className?: string;
|
|
61
80
|
};
|
|
62
81
|
/** @public */
|
|
63
|
-
export default function TrainingCardGroup({ title, titleLevel, count, collapsed, onToggle, sort, actions, empty, emptyContent, dragOver, onDragOver, onDragLeave, onDrop, children, className, }: TrainingCardGroupProps): import("react").JSX.Element;
|
|
82
|
+
export default function TrainingCardGroup({ title, titleLevel, count, collapsed, onToggle, beforeSort, sort, actions, empty, emptyContent, dragOver, onDragOver, onDragLeave, onDrop, children, className, }: TrainingCardGroupProps): import("react").JSX.Element;
|
|
@@ -7,7 +7,7 @@ import Button from '../Button';
|
|
|
7
7
|
import Dropdown from '../Dropdown';
|
|
8
8
|
import { useId } from 'react';
|
|
9
9
|
/** @public */
|
|
10
|
-
export default function TrainingCardGroup({ title, titleLevel = 3, count, collapsed = false, onToggle, sort, actions, empty = false, emptyContent, dragOver = false, onDragOver, onDragLeave, onDrop, children, className, }) {
|
|
10
|
+
export default function TrainingCardGroup({ title, titleLevel = 3, count, collapsed = false, onToggle, beforeSort, sort, actions, empty = false, emptyContent, dragOver = false, onDragOver, onDragLeave, onDrop, children, className, }) {
|
|
11
11
|
const { t } = useTranslation();
|
|
12
12
|
const 제목id = useId();
|
|
13
13
|
const 제목태그 = titleLevel ? `h${titleLevel}` : 'span';
|
|
@@ -27,7 +27,7 @@ export default function TrainingCardGroup({ title, titleLevel = 3, count, collap
|
|
|
27
27
|
? (collapsed ? t('common.expandFor', { name: title }) : t('common.collapseFor', { name: title }))
|
|
28
28
|
: (collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기')), className: "training-card-group__chevron", "aria-expanded": !collapsed, onClick: onToggle })), typeof title === 'string'
|
|
29
29
|
? _jsx(제목태그, { id: 제목id, className: "training-card-group__title h5b", children: title })
|
|
30
|
-
: 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,
|
|
30
|
+
: title, count !== undefined && _jsx("span", { className: "training-card-group__count body-3b", children: count }), (beforeSort != null || sort || actions != null) && (_jsxs("span", { className: "training-card-group__actions", onClick: e => e.stopPropagation(), children: [beforeSort, sort && (_jsx(Dropdown, { size: 40, options: sort.options, value: sort.value, placeholder: sort.placeholder, onChange: sort.onChange,
|
|
31
31
|
/* 접기와 같은 까닭으로 묶음 이름을 단다 — 그룹마다 `정렬` 이 하나씩 선다 */
|
|
32
32
|
ariaLabel: typeof title === 'string' && title ? t('common.sortFor', { name: title }) : undefined })), actions] }))] }), !collapsed && (empty
|
|
33
33
|
? _jsx("div", { className: "training-card-group__empty body-3", children: emptyContent })
|
|
@@ -116,6 +116,21 @@ export type TrainingPagePopupProps = {
|
|
|
116
116
|
* 판에 실제로 걸린 값은 `NoiseSettingsStore.applied` 로 받는다.
|
|
117
117
|
*/
|
|
118
118
|
noiseOverride?: Partial<CoreNoiseSettings> | ((saved: CoreNoiseSettings) => Partial<CoreNoiseSettings>);
|
|
119
|
+
/**
|
|
120
|
+
* **소음 두 줄을 안 그린다** — `소음 종류`·`소음 비율`(사장님 지시 (207), 2026-10-12).
|
|
121
|
+
*
|
|
122
|
+
* 소음이 아예 없는 훈련(짧은 이야기 듣기 · 긴 이야기 듣기 · 문장 순서화)에서 쓴다.
|
|
123
|
+
* `전체 소리`·`재생 속도` 는 그대로 선다.
|
|
124
|
+
*
|
|
125
|
+
* **흐리게가 아니라 안 그린다.** 흐린 줄은 *"지금은 못 바꾼다"* 를 말하는데, 여기서 할 말은
|
|
126
|
+
* *"이 훈련에는 소음이 없다"* 다. 흐린 채로 두면 치료사가 **바꿀 수 있는 줄을 찾아 헤맨다**
|
|
127
|
+
* (사장님이 그 줄을 짚으며 *"왜 이거 수정요청 했는데 계속 나오고 있지?"* 라 하셨다).
|
|
128
|
+
*
|
|
129
|
+
* **`noiseOverride` 와 따로 둔다 — 덮개는 *값*, 이 칸은 *보임*.** 묶으면 *"덮개는 걸되 줄은
|
|
130
|
+
* 보이게"* 를 못 한다 — 소음 하 문장 듣기의 제안 갈래가 그 꼴이다(덮개를 걸고도 치료사가
|
|
131
|
+
* 창에서 고친다). **한 칸이 두 가지를 뜻하게 하지 않는다.**
|
|
132
|
+
*/
|
|
133
|
+
hideNoiseRows?: boolean;
|
|
119
134
|
/** 훈련 결과 팝업 렌더러 — 없으면 기본 결과 팝업(TrainingResultPopup)을 띄운다 */
|
|
120
135
|
renderResultPopup?: (props: {
|
|
121
136
|
score?: number | null;
|
|
@@ -39,7 +39,7 @@ const 움직임최소화 = () => {
|
|
|
39
39
|
};
|
|
40
40
|
/** 결과를 계산하는 중인 결과 창 자리 — 이 객체와 **같은 것**이면 계산 중이다(결과 창의 '완료' 를 막는다). */
|
|
41
41
|
const 결과계산중 = Object.freeze({});
|
|
42
|
-
import {
|
|
42
|
+
import { wasKeyboardPress } from '../../hooks/inputModality';
|
|
43
43
|
// 문장 4종은 각 PagePopup 래퍼에서 useTrainingSessionLog로 이미 기록 → 베이스에서 중복 방지
|
|
44
44
|
const SENTENCE_TIME_LOGGED_TYPES = new Set(['LISTEN_IN_NOISE', 'SHORT_STORY', 'LONG_STORY', 'ORDERING_SENTENCE']);
|
|
45
45
|
/** 결과 창 자료에서 정답/전체를 센다 — 결과 항목 형태가 훈련 타입마다 달라(소음 하 듣기/문장 순서화는 correct 대신
|
|
@@ -71,7 +71,7 @@ function 판결과셈(resultOverlay) {
|
|
|
71
71
|
const defaultRenderResult = (p) => _jsx(TrainingResultPopup, { score: p.score, contextsLength: p.contextsLength, results: p.results, onReplay: p.onReplay, onClose: p.onClose, pending: p.pending });
|
|
72
72
|
import { getScenarioPageId, getScenarioStepKey, createScenarioFlowState, resolveScenarioStepProps, } from './trainingScenario';
|
|
73
73
|
function TrainingPagePopupInner(props) {
|
|
74
|
-
const { scenario, title, subTitle, depth, icon, wrapperStyle, iconTooltip, pageIndex, pageCount, stepIndex, stepCount, onBack, onComplete, onRoundFinished, onNext, onSettings, useNoiseSetting = true, children, ariaLabel, label, buildResults, onResult: externalOnResultProp, typeKey, entryPoint, dailyTotal, itemRef, renderResultPopup, noiseOverride, } = props;
|
|
74
|
+
const { scenario, title, subTitle, depth, icon, wrapperStyle, iconTooltip, pageIndex, pageCount, stepIndex, stepCount, onBack, onComplete, onRoundFinished, onNext, onSettings, useNoiseSetting = true, children, ariaLabel, label, buildResults, onResult: externalOnResultProp, typeKey, entryPoint, dailyTotal, itemRef, renderResultPopup, noiseOverride, hideNoiseRows = false, } = props;
|
|
75
75
|
// 시간 기록 — 문장 4종은 각 래퍼(useTrainingSessionLog)에서 기록하므로 제외, 그 외(단어 등) 공통 기록
|
|
76
76
|
useTrainingSessionLog(typeKey && !SENTENCE_TIME_LOGGED_TYPES.has(typeKey) ? typeKey : '');
|
|
77
77
|
// 팝업 진입 시각 — 완료 정본의 소요 시간 계산 (마운트 시 1회만 effect에서 초기화)
|
|
@@ -909,7 +909,7 @@ function TrainingPagePopupInner(props) {
|
|
|
909
909
|
if (앞 === undefined || 앞 === effectivePageIndex)
|
|
910
910
|
return;
|
|
911
911
|
// 키보드로 넘겼을 때만 — 마우스로 누른 사람에게 매 문제 '듣기' 초점 안내가 뜨지 않게
|
|
912
|
-
if (
|
|
912
|
+
if (!wasKeyboardPress())
|
|
913
913
|
return;
|
|
914
914
|
const panel = panelRef.current;
|
|
915
915
|
if (!panel)
|
|
@@ -1020,7 +1020,7 @@ function TrainingPagePopupInner(props) {
|
|
|
1020
1020
|
둘을 한 요소에 모은다. */
|
|
1021
1021
|
_jsxs("div", { className: `page-popup${closing ? ' page-popup--closing' : ''}`, role: "dialog", "aria-modal": "true", "aria-label": resolvedTitle, children: [_jsxs("div", { ref: panelRef, className: `page-popup__panel ${!measured ? 'page-popup__panel--measuring' : ''} ${mode === 'center' ? 'page-popup__panel--center' : mode === 'top' ? 'page-popup__panel--top' : ''}`, children: [_jsx(TrainingPagePopupTitle, { title: titleForHeader, subtitle: hasScenario ? (scenarioMeta.subtitle || scenarioMeta.label) : (Array.isArray(depth) ? depth.join(' / ') : undefined), desc: !hasScenario ? subTitle : undefined, depth: (hasScenario ? scenarioMeta.depth : depth), icon: hasScenario ? scenarioMeta.icon : icon, wrapperStyle: hasScenario ? scenarioMeta.wrapperStyle : wrapperStyle, iconTooltip: hasScenario ? scenarioMeta.iconTooltip : iconTooltip, progressMax: effectivePageCount, progressIndex: effectivePageIndex, pageIndex: effectivePageIndex, pageCount: effectivePageCount, onBack: handleCloseBack, onClose: handleCloseBack }), _jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: `page-popup__content ${mode === "center" ? "page-popup__content--center" : "page-popup__content--top"}${props.contentFull ? ' page-popup__content--full' : ''}`, children: isChildrenMode ? childrenContent : (hasScenario ? scenarioContent : (typeof children === 'function' ? children({ registerOnNext, registerOnResult: registerOnResult, panelRef, setResultOverlay }) : children)) }) }), _jsx(TrainingPagePopupBottom, { onSettings: onSettings, onNext: handleNext, onResult: handleViewResults, useNoiseSettingBtn: Boolean(hasScenario ? (scenarioMeta.useNoiseSetting ?? true) : useNoiseSetting), stepIndex: effectiveStepIndex, stepCount: effectiveStepCount, pageIndex: effectivePageIndex, pageCount: effectivePageCount,
|
|
1022
1022
|
/* 결과 창이 뜨면 바닥의 오디오 미터를 끈다 — 결과를 보는 동안 떠 있을 까닭이 없다 */
|
|
1023
|
-
resultOpen: Boolean(resultOverlay && !resultOverlay.isEmpty) })] }), (resultOverlay && !resultOverlay.isEmpty && (renderResultPopup ?? defaultRenderResult)({
|
|
1023
|
+
resultOpen: Boolean(resultOverlay && !resultOverlay.isEmpty), hideNoiseRows: hideNoiseRows })] }), (resultOverlay && !resultOverlay.isEmpty && (renderResultPopup ?? defaultRenderResult)({
|
|
1024
1024
|
score: resultOverlay.score,
|
|
1025
1025
|
results: (resultOverlay.results || resultOverlay.answers || []),
|
|
1026
1026
|
contextsLength: resultOverlay.results ? resultOverlay.results.length : (resultOverlay.answers ? resultOverlay.answers.length : 0),
|
|
@@ -12,6 +12,21 @@ export type TrainingPagePopupBottomProps = {
|
|
|
12
12
|
pageIndex?: number;
|
|
13
13
|
/** 쪽 수. 0(쪽이 없음)이어도 '결과보기'(완료) 단추는 보이고, '다음 문제'(쪽 이동)만 숨는다. 기본 1. */
|
|
14
14
|
pageCount?: number;
|
|
15
|
+
/**
|
|
16
|
+
* **소음 두 줄을 안 그린다** — `소음 종류`·`소음 비율`(사장님 지시 (207), 2026-10-12).
|
|
17
|
+
*
|
|
18
|
+
* 소음이 아예 없는 훈련(짧은 이야기 듣기 · 긴 이야기 듣기 · 문장 순서화)에서 쓴다.
|
|
19
|
+
* `전체 소리`·`재생 속도` 는 그대로 선다.
|
|
20
|
+
*
|
|
21
|
+
* **흐리게가 아니라 안 그린다.** 흐린 줄은 *"지금은 못 바꾼다"* 를 말하는데, 여기서 할 말은
|
|
22
|
+
* *"이 훈련에는 소음이 없다"* 다. 흐린 채로 두면 치료사가 **바꿀 수 있는 줄을 찾아 헤맨다**
|
|
23
|
+
* (사장님이 그 줄을 짚으며 *"왜 이거 수정요청 했는데 계속 나오고 있지?"* 라 하셨다).
|
|
24
|
+
*
|
|
25
|
+
* **`noiseOverride` 와 따로 둔다 — 덮개는 *값*, 이 칸은 *보임*.** 묶으면 *"덮개는 걸되 줄은
|
|
26
|
+
* 보이게"* 를 못 한다 — 소음 하 문장 듣기의 제안 갈래가 그 꼴이다(덮개를 걸고도 치료사가
|
|
27
|
+
* 창에서 고친다). **한 칸이 두 가지를 뜻하게 하지 않는다.**
|
|
28
|
+
*/
|
|
29
|
+
hideNoiseRows?: boolean;
|
|
15
30
|
};
|
|
16
31
|
/** @public */
|
|
17
|
-
export default function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, resultOpen, }: TrainingPagePopupBottomProps): import("react").JSX.Element;
|
|
32
|
+
export default function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, resultOpen, hideNoiseRows, }: TrainingPagePopupBottomProps): import("react").JSX.Element;
|
|
@@ -11,7 +11,7 @@ import { useNoisePlayer } from './NoisePlayerContext';
|
|
|
11
11
|
import { useFloatingFocus } from './useFloatingFocus';
|
|
12
12
|
import "./TrainingPagePopupBottom.css";
|
|
13
13
|
/** @public */
|
|
14
|
-
export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, onSettings, onNext, onResult, stepIndex = 0, stepCount = 0, pageIndex = 0, pageCount = 1, resultOpen = false, }) {
|
|
14
|
+
export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, onSettings, onNext, onResult, stepIndex = 0, stepCount = 0, pageIndex = 0, pageCount = 1, resultOpen = false, hideNoiseRows = false, }) {
|
|
15
15
|
const { t } = useTranslation();
|
|
16
16
|
const [open, setOpen] = useState(false);
|
|
17
17
|
const [anchorRect, setAnchorRect] = useState(null);
|
|
@@ -95,5 +95,5 @@ export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, on
|
|
|
95
95
|
});
|
|
96
96
|
return (_jsxs("div", { className: "pp-bottom", children: [_jsxs("div", { className: "pp-bottom__left", children: [useNoiseSettingBtn ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "pp-bottom__settings--desktop", children: _jsx("div", { ref: desktopRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: 'settings', onClick: handleDesktopClick,
|
|
97
97
|
/* 여닫이 — aria-pressed(selected) 대신 aria-expanded. 보이는 선택 모양은 같은 클래스로 */
|
|
98
|
-
className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "dot", shape: "pill", icon: 'settings', onClick: handleMobileClick, className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.settings', '설정') }) }) })] })) : null, useNoiseSettingBtn && (_jsx("div", { ref: 미터토글ref, className: "pp-bottom__meter-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 40, type: "dot", shape: "pill", icon: "audio-lines", onClick: handleToggleMeters, selected: showMeters, "aria-expanded": 폰 ? showMeters : undefined, ariaLabel: t('training.meter.toggle', '오디오 미터 표시') }) })), useNoiseSettingBtn && showMeters && !폰 && (_jsx("div", { className: "pp-bottom__meters", children: 미터들 }))] }), useNoiseSettingBtn && showMeters && 폰 && 미터자리 && (_jsx(Tooltip, { anchorRect: 미터자리, onClose: 미터닫기, ariaLabel: t('training.meter.toggle', '오디오 미터 표시'), children: _jsx("div", { className: "pp-bottom__meters pp-bottom__meters--floating", children: 미터들 }) })), _jsxs("div", { className: "pp-bottom__right", children: [useResultBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: "check", onClick: typeof onResult === "function" ? onResult : onNext, ariaLabel: t('training.viewResult', '결과보기'), label: t('training.viewResult', '결과보기'), variant: "main", disabled: !isBtnActive })), useNextBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", rightIcon: "chevron-right", align: "right", onClick: onNext, ariaLabel: t('training.nextQuestion', '다음 문제'), label: t('training.nextQuestion', '다음 문제'), variant: "main", disabled: !isBtnActive }))] }), useNoiseSettingBtn && open && (_jsx(NoiseSettingsTooltip, { anchorRect: anchorRect, onClose: handleCloseTooltip }))] }));
|
|
98
|
+
className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "dot", shape: "pill", icon: 'settings', onClick: handleMobileClick, className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.settings', '설정') }) }) })] })) : null, useNoiseSettingBtn && (_jsx("div", { ref: 미터토글ref, className: "pp-bottom__meter-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 40, type: "dot", shape: "pill", icon: "audio-lines", onClick: handleToggleMeters, selected: showMeters, "aria-expanded": 폰 ? showMeters : undefined, ariaLabel: t('training.meter.toggle', '오디오 미터 표시') }) })), useNoiseSettingBtn && showMeters && !폰 && (_jsx("div", { className: "pp-bottom__meters", children: 미터들 }))] }), useNoiseSettingBtn && showMeters && 폰 && 미터자리 && (_jsx(Tooltip, { anchorRect: 미터자리, onClose: 미터닫기, ariaLabel: t('training.meter.toggle', '오디오 미터 표시'), children: _jsx("div", { className: "pp-bottom__meters pp-bottom__meters--floating", children: 미터들 }) })), _jsxs("div", { className: "pp-bottom__right", children: [useResultBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: "check", onClick: typeof onResult === "function" ? onResult : onNext, ariaLabel: t('training.viewResult', '결과보기'), label: t('training.viewResult', '결과보기'), variant: "main", disabled: !isBtnActive })), useNextBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", rightIcon: "chevron-right", align: "right", onClick: onNext, ariaLabel: t('training.nextQuestion', '다음 문제'), label: t('training.nextQuestion', '다음 문제'), variant: "main", disabled: !isBtnActive }))] }), useNoiseSettingBtn && open && (_jsx(NoiseSettingsTooltip, { anchorRect: anchorRect, onClose: handleCloseTooltip, hideNoiseRows: hideNoiseRows }))] }));
|
|
99
99
|
}
|
|
@@ -19,7 +19,7 @@ import Button from '../Button';
|
|
|
19
19
|
import Icon from '../Icon';
|
|
20
20
|
import InfoTooltip from '../Tooltip/InfoTooltip';
|
|
21
21
|
import { STATUS_MESSAGES, ANNOUNCED_STATUSES } from './trainingStepAudioPlayerTypes';
|
|
22
|
-
import {
|
|
22
|
+
import { wasKeyboardPress } from '../../hooks/inputModality';
|
|
23
23
|
import { measureAudioDuration, waitForCanPlay } from './audioWaits';
|
|
24
24
|
import { useWaveReady } from './useWaveReady';
|
|
25
25
|
import { useObjectUrls } from './useObjectUrls';
|
|
@@ -1426,7 +1426,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1426
1426
|
누르면 소리를 받으러 가거나 "가져오지 못했습니다" 를 띄웠다 — 마우스와 같게 키보드도 막는다 */
|
|
1427
1427
|
disabled: isDisabled,
|
|
1428
1428
|
// 키보드로 눌렀을 때만 초점을 옮긴다 — 마우스로 누른 사람에게 '재생 / 멈춤' 초점 툴팁이 뜨지 않게
|
|
1429
|
-
onClick: () => { 듣기초점옮기기Ref.current =
|
|
1429
|
+
onClick: () => { 듣기초점옮기기Ref.current = wasKeyboardPress(); hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
|
|
1430
1430
|
// 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
|
|
1431
1431
|
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" : ""}`, inert: !(stateLocal === "playMode" || displayedPlaying) || undefined, 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', '재생 / 멈춤'), "aria-pressed": displayedPlaying, 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 {
|
|
1432
1432
|
handleJumpTo(idx);
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import TrainingStep from './TrainingStep';
|
|
6
|
+
import { canAnswerStep } from './stepAnswerable';
|
|
6
7
|
import TrainingWordCard from './TrainingWordCard';
|
|
7
8
|
import './TrainingStep.css';
|
|
8
9
|
import './TrainingStepCard.css';
|
|
@@ -43,7 +44,19 @@ export default function TrainingStepCardOrder({ index, stepIndex, isLast = false
|
|
|
43
44
|
/* 다음 배열은 updater **밖에서** 셈하고 알린다 — updater 안에서 onChange·onDone 을 부르면 StrictMode 에서 한 번
|
|
44
45
|
클릭에 두 번씩 나갔다. 풀 때도 알린다(전에는 풀면 부모가 옛 순서를 그대로 들고 있었다). */
|
|
45
46
|
const handleSelect = useCallback((i) => {
|
|
46
|
-
|
|
47
|
+
/**
|
|
48
|
+
* **끝낸 뒤에도 풀고 다시 고를 수 있다**(말귀 요청 (201), 실측 2026-10-12).
|
|
49
|
+
*
|
|
50
|
+
* 이 갈래는 **마지막 한 장이 단계를 넘긴다**(아래 `arr.every(v => v !== null)`). 그래서 옛
|
|
51
|
+
* 막이(`!== 'active'`)로는 **세 번째를 잘못 누르는 순간 그 판이 끝**이었다 — 앞의 둘은 다시
|
|
52
|
+
* 눌러 풀리는데 **마지막만 못 푸는**, 손에 제일 안 맞는 꼴이다(말귀 실측: 3번을 다시 눌러도
|
|
53
|
+
* `--selected` 와 번호 `3` 이 그대로).
|
|
54
|
+
*
|
|
55
|
+
* **아래 `onDone` 조건은 그대로 둔다**(새로 채워 다 찼을 때만). 그래서 **미완성 답이 점수로
|
|
56
|
+
* 남지 않는다** — 풀면 화면만 비고(점수는 옛 차례 그대로), 고쳐 다시 채우는 순간 `onDone` 이
|
|
57
|
+
* **고친 차례**로 나간다. 고치는 동안 화면이 점수보다 앞서 있는 것은 `active` 때와 같은 상태다.
|
|
58
|
+
*/
|
|
59
|
+
if (!canAnswerStep(currentState))
|
|
47
60
|
return;
|
|
48
61
|
const prev = selectedOrder;
|
|
49
62
|
let arr;
|
|
@@ -24,7 +24,9 @@ export type TrainingStepConfirmProps = {
|
|
|
24
24
|
* 그 판이 통째로 사라졌다(말귀 실측: 「3대 영양소」 보기 여섯·정답 넷, 셋을 골라도 아무
|
|
25
25
|
* 일도 안 남). **기억을 묻는 문제에서 덜 기억한 답도 그 사람의 답이다.**
|
|
26
26
|
*
|
|
27
|
-
*
|
|
27
|
+
* 내는 자리는 **`결과보기`** 다 — (102) 때 세운 `확인` 단추는 (206) 으로 걷혔다. 고르는
|
|
28
|
+
* 그 자리에서 매번 보고하되 **개수로는 넘어가지 않으므로** 위 두 까닭이 그대로 선다.
|
|
29
|
+
* 이 값은 채점 쪽이 쓰라고 그대로 받아 둔다 — **이 조각은 이 수로 아무 가름도 하지 않는다.**
|
|
28
30
|
*/
|
|
29
31
|
correctCount?: number;
|
|
30
32
|
useDisabledCovered?: boolean;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import Button from '../Button';
|
|
6
6
|
import "./TrainingStep.css";
|
|
7
7
|
import TrainingStep from "./TrainingStep";
|
|
8
|
+
import { canAnswerStep } from './stepAnswerable';
|
|
8
9
|
import { shuffle as shuffleArray } from '../../utils/array';
|
|
9
10
|
/** @public */
|
|
10
11
|
export default function TrainingStepConfirm({ index, stepIndex, isLast = false, lockedHint, title, useTitleFaded, onDone, onChange, answer, wrongAnswers, choices: propChoices, multiSelect = false, correctCount = 0, useDisabledCovered = false, choiceIcons, shuffleChoices = true }) {
|
|
@@ -49,18 +50,23 @@ export default function TrainingStepConfirm({ index, stepIndex, isLast = false,
|
|
|
49
50
|
prevStepIndexRef.current = stepIndex;
|
|
50
51
|
}, [stepIndex, index]);
|
|
51
52
|
const handleSelect = useCallback((i) => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
/**
|
|
54
|
+
* **두 갈래가 이제 한 규칙을 쓴다 — 내기 전까지는 고칠 수 있다**(사장님 지시 (206), 2026-10-12).
|
|
55
|
+
*
|
|
56
|
+
* 옛 막이(`!== 'active'`)의 까닭은 참이었다 — 단추는 `!== 'disabled'` 로 열려 있는데 기록은
|
|
57
|
+
* `=== 'active'` 일 때만 나가서 **화면 선택은 바뀌는데 점수는 그대로**였다. 고르는 쪽을
|
|
58
|
+
* 열면서 **기록도 함께** 열어 그 구멍을 메운다(아래 두 갈래 다 `onDone` 을 부른다).
|
|
59
|
+
*
|
|
60
|
+
* 한때 여럿 고르기만 갈라 막아 두었다 — `확인` 이 *"이제 냅니다"* 를 뜻하니 낸 뒤에는
|
|
61
|
+
* 잠겨야 한다고 보았다. **그 단추가 (206) 으로 걷혔다.** 내는 자리가 `결과보기` 하나가
|
|
62
|
+
* 되었으니 **그 전까지는 둘 다 고칠 수 있다.** 갈래를 가를 까닭이 없어졌다.
|
|
63
|
+
*/
|
|
64
|
+
if (!canAnswerStep(currentState))
|
|
57
65
|
return;
|
|
58
66
|
const valRaw = choices?.[i];
|
|
59
67
|
if (valRaw == null)
|
|
60
68
|
return;
|
|
61
69
|
const val = valRaw;
|
|
62
|
-
/* 여기 오면 늘 active 다 — canNotify 는 아래 흐름을 그대로 두기 위해 남긴다. */
|
|
63
|
-
const canNotify = currentState === 'active' || currentState === 'done';
|
|
64
70
|
if (multiSelect) {
|
|
65
71
|
/* 다음 선택 집합을 **updater 밖에서** 만든다. 예전에는 updater 안에서 만들면서
|
|
66
72
|
거기서 onChange/onDone 까지 불렀는데, updater 는 순수해야 하고 React 가 렌더
|
|
@@ -77,41 +83,47 @@ export default function TrainingStepConfirm({ index, stepIndex, isLast = false,
|
|
|
77
83
|
if (typeof onChange === 'function') {
|
|
78
84
|
onChange({ selectedValues: [...next], __ts: Date.now() });
|
|
79
85
|
}
|
|
80
|
-
/*
|
|
86
|
+
/* **고른 그 자리에서 보고한다 — 내는 것은 `결과보기` 다**((206), 2026-10-12).
|
|
81
87
|
|
|
82
|
-
|
|
88
|
+
(102) 로 세운 두 까닭은 **그대로 산다.** 그때 고친 것은 *"고른 수가 `correctCount` 에
|
|
89
|
+
닿는 순간 저절로 나가는 것"* 이었다:
|
|
83
90
|
**①** 몇 개인지 맞히는 것이 과제의 일부가 된다(원래 묻던 것이 아니다).
|
|
84
91
|
**②** 덜 기억한 사람은 **낼 길이 아예 없다** — 셋을 골라 두고 아무 일도 안 나면
|
|
85
92
|
창을 닫는 수밖에 없고, 그러면 그 판이 통째로 사라진다.
|
|
86
93
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
**둘 다 지금도 안 깨진다.** 여기서 매번 보고해도 **개수로 넘어가지 않으므로** ① 이
|
|
95
|
+
서고, 셋만 고른 사람의 셋이 그대로 기록되므로 ② 가 선다. 바뀐 것은 **내는 자리**
|
|
96
|
+
뿐이다 — `확인` 에서 `결과보기` 로. (102) 가 틀렸던 것이 아니다.
|
|
97
|
+
|
|
98
|
+
개수를 화면에 적지 않는 것도 그대로다(기억 과제에서 그것은 답의 일부다). 고른 수를
|
|
99
|
+
가두지도 않는다(다섯째를 막으면 "넷뿐" 이 새어 나간다).
|
|
100
|
+
|
|
101
|
+
**빈 집합도 보고한다 — `next.size > 0` 으로 막으면 안 된다.** 처음 그렇게 적었다가
|
|
102
|
+
제 못에 걸렸다: `물` 을 골랐다 되눌러 빼면 화면은 비는데 기록은 `['물']` 로 남았다.
|
|
103
|
+
**옛 결함과 똑같은 꼴**이다(화면은 바뀌는데 점수는 그대로).
|
|
104
|
+
|
|
105
|
+
*"기억이 안 난다"* 와 *"안 풀었다"* 는 **부름 횟수**가 가른다 — 한 번도 안 누르면
|
|
106
|
+
`onDone` 이 **아예 안 나가고**(이 길은 클릭 손잡이 안이다), 골랐다 뺀 사람은 **빈
|
|
107
|
+
집합으로** 나간다. (102) 때는 `확인` 의 `disabled` 가 그 구실을 했는데, 단추가 없는
|
|
108
|
+
지금은 **부른 적이 있는가**가 그 구실을 한다. 값으로 가르지 않고 **자취로 가른다.** */
|
|
109
|
+
if (typeof onDone === 'function') {
|
|
110
|
+
onDone({ selectedValues: [...next], __ts: Date.now() });
|
|
111
|
+
}
|
|
90
112
|
}
|
|
91
113
|
else {
|
|
92
114
|
setSelectedAnswer(val);
|
|
93
115
|
if (typeof onChange === 'function') {
|
|
94
116
|
onChange({ selectedIndex: i, selectedValue: val, __ts: Date.now() });
|
|
95
117
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
118
|
+
/* **고친 답도 보낸다** — 옛 막이의 까닭을 메우는 자리다(러너가 차례를 `Math.max` 로
|
|
119
|
+
올리므로 이미 지난 단계가 다시 알려도 앞으로 안 뛴다). 전에는 이 안에 `canNotify` 가
|
|
120
|
+
`active || done` 으로 적혀 있었는데 위 막이 때문에 **닿을 수 없는 문**이었다 —
|
|
121
|
+
문을 열어 둔 사람의 뜻이 이제 산다. */
|
|
122
|
+
if (typeof onDone === 'function') {
|
|
123
|
+
onDone({ selectedIndex: i, selectedValue: val, __ts: Date.now() });
|
|
100
124
|
}
|
|
101
125
|
}
|
|
102
126
|
}, [choices, currentState, multiSelect, onChange, selectedSet]);
|
|
103
|
-
/** **여럿 고르기를 내는 단추** — 하나라도 골랐으면 열린다(말귀 요청 (102)) */
|
|
104
|
-
const handleMultiSubmit = useCallback(() => {
|
|
105
|
-
if (currentState !== 'active')
|
|
106
|
-
return;
|
|
107
|
-
/* **겹옷이다** — 단추가 이미 `disabled` 라 빈 채로는 눌리지 않는다(깨 봐도 못이 안 운다).
|
|
108
|
-
그래도 둔다: 부르는 쪽이 이 손잡이를 다른 데서 쓰면 그때는 이것만 남는다. */
|
|
109
|
-
if (selectedSet.size === 0)
|
|
110
|
-
return;
|
|
111
|
-
if (typeof onDone === 'function') {
|
|
112
|
-
onDone({ selectedValues: [...selectedSet], __ts: Date.now() });
|
|
113
|
-
}
|
|
114
|
-
}, [currentState, selectedSet, onDone]);
|
|
115
127
|
/* 여기 있던 effect 는 selectedSet 이 바뀔 때마다 onChange/onDone 을 **한 번 더**
|
|
116
128
|
불렀다. 클릭 핸들러가 이미 같은 일을 하고 있어 한 번 고를 때마다 두 번 보고됐다.
|
|
117
129
|
게다가 콜백이 그 effect 의 의존성에 있어서, 부르는 쪽이 인라인 화살표를 주고
|
|
@@ -123,5 +135,5 @@ export default function TrainingStepConfirm({ index, stepIndex, isLast = false,
|
|
|
123
135
|
때문이다 — 나머지 둘(choices 바뀜·페이지 전환)은 빈 Set 으로 되돌리는 자리라
|
|
124
136
|
저 effect 의 `size === 0` 가드에 걸려 어차피 아무것도 안 했다. */
|
|
125
137
|
const btnsClassName = `training-step__buttons${Array.isArray(choices) && choices.length > 3 ? ' training-step__buttons--wrap-3' : ''}`;
|
|
126
|
-
return (
|
|
138
|
+
return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, lockedHint: lockedHint, title: resolvedTitle, useTitleFaded: useTitleFaded, children: _jsx("div", { className: btnsClassName, children: Array.isArray(choices) && choices.length > 0 ? (choices.map((c, i) => (_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: () => handleSelect(i), ariaLabel: String(c), label: c, useLabel: true, icon: choiceIcons ? choiceIcons[String(c)] : undefined, variant: 'default', disabled: currentState === 'disabled', selected: multiSelect ? selectedSet.has(c) : selectedAnswer === c, useDisabledCovered: useDisabledCovered, minWidth: 0 }, String(c))))) : null }) }));
|
|
127
139
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
|
-
import {
|
|
5
|
+
import { wasKeyboardPress } from '../../hooks/inputModality';
|
|
6
6
|
import Button from '../Button';
|
|
7
7
|
import Toast from '../Toast';
|
|
8
8
|
import { useFontScale } from '../../hooks';
|
|
@@ -36,7 +36,7 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
|
|
|
36
36
|
const 펼친뒤초점Ref = useRef(false);
|
|
37
37
|
const handleActivate = useCallback(() => {
|
|
38
38
|
// 키보드로 눌렀을 때만 — 마우스로 눌렀는데 옮기면 '글자 크기 감소' 초점 툴팁이 떴다
|
|
39
|
-
펼친뒤초점Ref.current =
|
|
39
|
+
펼친뒤초점Ref.current = wasKeyboardPress() && !!상자Ref.current && 상자Ref.current.contains(document.activeElement);
|
|
40
40
|
setLocalStatus("active");
|
|
41
41
|
}, []);
|
|
42
42
|
const handleNoticeClose = useCallback(() => setNoticeMessage(''), []);
|
|
@@ -51,4 +51,4 @@ export interface OneShotSound {
|
|
|
51
51
|
* 풀린다. 부르는 쪽이 `try` 로 감싸지 않아도 되게 하려는 것이다. 넷 가운데 셋이 저마다
|
|
52
52
|
* `try` 를 적고 있었고, 그 안에서 하는 일이 조금씩 달랐다.
|
|
53
53
|
*/
|
|
54
|
-
export declare function playOneShot(url: string, onEnd?: (
|
|
54
|
+
export declare function playOneShot(url: string, onEnd?: (played: boolean) => void): OneShotSound;
|
|
@@ -41,15 +41,15 @@ export function playOneShot(url, onEnd) {
|
|
|
41
41
|
const done = new Promise((res) => { 풀기 = res; });
|
|
42
42
|
/** 끝은 한 번이다 — 거절과 `error` 가 함께 오는 일이 실제로 있다 */
|
|
43
43
|
let 끝났나 = false;
|
|
44
|
-
const 끝 = (
|
|
44
|
+
const 끝 = (played) => {
|
|
45
45
|
if (끝났나)
|
|
46
46
|
return;
|
|
47
47
|
끝났나 = true;
|
|
48
48
|
try {
|
|
49
|
-
onEnd?.(
|
|
49
|
+
onEnd?.(played);
|
|
50
50
|
}
|
|
51
51
|
catch { /* 부르는 쪽의 실패로 약속을 묶어 두지 않는다 */ }
|
|
52
|
-
풀기(
|
|
52
|
+
풀기(played);
|
|
53
53
|
};
|
|
54
54
|
let el;
|
|
55
55
|
try {
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* **지금 답을 고칠 수 있나** — 단계 상태 하나로 가른다.
|
|
3
3
|
*
|
|
4
|
-
* 이
|
|
5
|
-
* `TrainingStepConfirm
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* 이 결정은 **네 조각**에 `currentState !== 'active'` 로 적혀 있었다(`TrainingStepCard` ·
|
|
5
|
+
* `TrainingStepConfirm` · `TrainingStepConfirmWithText` · `TrainingStepCardOrder`). 그 막이는
|
|
6
|
+
* **까닭이 있어 들어왔다** — 단추는 `!== 'disabled'` 로 열려 있는데 기록은 `=== 'active'` 일 때만
|
|
7
|
+
* 나가서, **화면 선택은 바뀌는데 점수는 그대로**였다.
|
|
8
|
+
*
|
|
9
|
+
* > **처음에 여섯으로 셌는데 틀렸다**(말귀 실측이 바로잡음 2026-10-12). `TrainingStepOrderList` 의
|
|
10
|
+
* > `!== 'active'` 는 **조작 막이가 아니라** `useEffect` 안의 **첫 보고 문**이다((103) 의 `initial`).
|
|
11
|
+
* > 글자가 같아서 같은 꼴로 세어졌다 — **글로 자리를 집을 때는 그 글이 하나인지 먼저 센다** 를
|
|
12
|
+
* > 내가 적어 두고 내 셈에서 밟았다. `OrderList` 의 `move` 는 **원래 안 막혀 있다**(말귀가 끝낸
|
|
13
|
+
* > 단계에서 또 옮겨 차례가 바뀌는 것을 확인했다) — 이 집안이 아니다.
|
|
8
14
|
*
|
|
9
15
|
* 그런데 그 막이가 **자기 첫 고름에 자기를 막는다**(말귀 요청 (201), QA 버그 보고 2026-10-12):
|
|
10
16
|
* 고르면 `onDone` 이 나가고 단계가 넘어가 `currentState` 가 `'done'` 이 된다 — 그 뒤로는
|
|
@@ -22,11 +28,55 @@
|
|
|
22
28
|
* 알려도 차례는 그대로다(`TrainingPagePopup.tsx`). 재지 않고 고쳤으면 **고친 답이 다음 단계를
|
|
23
29
|
* 한 칸 더 밀** 수도 있었다.
|
|
24
30
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
31
|
+
* **옮긴 곳 넷**(말귀가 **화면에서 다 재고** 나서 옮겼다 — 2026-10-12):
|
|
32
|
+
*
|
|
33
|
+
* Card 변별훈련 › 같은소리고르기 QA 버그 보고
|
|
34
|
+
* ConfirmWithText 오늘의 듣기 › 소음 하 문장 듣기 `disabled:false` 라 막혀 보이지도 않았다
|
|
35
|
+
* Confirm(한 개) 오늘의 듣기 › 짧은 이야기 고르는 순간이 곧 답
|
|
36
|
+
* CardOrder 단어훈련 › 듣고 순서대로 기억하기 **마지막 한 장만 못 푸는** 꼴
|
|
37
|
+
*
|
|
38
|
+
* **한때 안 옮긴 곳이 하나 있었다** — `Confirm` 의 **여럿 고르기** 갈래다. 말귀가 재현했다
|
|
39
|
+
* (2026-10-12): `오늘의 듣기 › 긴 이야기 듣기 › 페트라`, *"기억나는 단어를 나열해 보세요."* —
|
|
40
|
+
* 둘을 골라 `확인` 을 누른 뒤에는 **선택도 안 바뀌고 `확인` 도 다시 안 눌렸다.** 그 갈래가
|
|
41
|
+
* 켜지는 자리는 앱에 하나이고 **정답이 둘 이상인 문항에서만** 켠다
|
|
42
|
+
* (`multiSelect={_corrects.length > 1}`) — 같은 훈련 안에서도 문항마다 갈려서 찾기 어려웠다.
|
|
43
|
+
*
|
|
44
|
+
* **그 까닭이 세 번 바뀌었고, 네 번째에 갈래가 없어졌다:**
|
|
45
|
+
*
|
|
46
|
+
* ① 못 쟀다 여럿 고르기를 쓰는 화면을 못 찾았다
|
|
47
|
+
* ② 뜻을 기다린다 재현됐다 — 낸 뒤에도 고치게 할지는 **뜻**에 달렸다
|
|
48
|
+
* ③ 내는 자리가 다르다 `확인` 이 곧 냄이다 → 그대로 둔다 (코드는 안 바뀜)
|
|
49
|
+
* ④ 내는 자리가 하나다 **`확인` 이 걷혔다** → 여럿 고르기도 열린다
|
|
50
|
+
*
|
|
51
|
+
* ## 규칙은 하나다 — **내기 전까지는 고칠 수 있다**
|
|
52
|
+
*
|
|
53
|
+
* ③ 때는 *내는 자리*가 갈래마다 달라서 가름이 섰다(사장님 지시를 말귀가 전함, 2026-10-12 —
|
|
54
|
+
* *"확인, 결과보기 누르기 전까지는 수정할 수 있어야 해. UX 바꾸지 마."*):
|
|
55
|
+
*
|
|
56
|
+
* 여럿 고르기 내는 자리 = **`확인`** → 낸 뒤 잠긴다
|
|
57
|
+
* 그 밖 모두 내는 자리 = **`결과보기`** → 그 전까지 고칠 수 있다
|
|
58
|
+
*
|
|
59
|
+
* 그런데 같은 날 사장님이 **그 `확인` 단추를 걷으라** 하셨다((206) — *"확인버튼 제거 하라고
|
|
60
|
+
* 했어"* · *"UX 바꾸지 마"*, 뒤 말은 **고치는 방식**을 바꾸지 말라는 뜻이다). 내는 자리가
|
|
61
|
+
* `결과보기` 하나가 되었으니 **가를 까닭도 같이 없어졌다.** 이제 **다섯 자리가 이 하나를
|
|
62
|
+
* 쓴다** — `Card` · `ConfirmWithText` · `Confirm`(두 갈래 다) · `CardOrder`.
|
|
63
|
+
*
|
|
64
|
+
* `확인` 을 걷을 때 `multiSelect && currentState !== 'active'` 막이를 **함께** 열었다.
|
|
65
|
+
* 하나만 하면 **반대쪽 어긋남**이 난다 — 단추만 걷으면 고름이 **첫 한 번에 얼어붙어**,
|
|
66
|
+
* 지키라 하신 *"수정할 수 있어야 해"* 를 **단추를 걷어서 어기는** 꼴이 된다.
|
|
67
|
+
*
|
|
68
|
+
* `canAnswerStep` 이 *"닿지 않은 단계만 막는다"* 로 적힌 것보다 **내는 자리**가 까닭이다 —
|
|
69
|
+
* 다음에 갈래가 늘면 *"이 갈래는 무엇으로 내는가"* 를 먼저 묻는다.
|
|
70
|
+
*
|
|
71
|
+
* > **까닭이 바뀐 자취를 지우지 않는다.** ③에서 ④로 간 것은 **재서가 아니라 뜻이 바뀌어서**다.
|
|
72
|
+
* > 그 둘을 섞지 않는다 — 다시 볼 때 묻는 물음이 다르다(실측이 뒤집은 가름은 *"또 재 보자"*,
|
|
73
|
+
* > 결정이 뒤집은 가름은 *"아직 그 뜻인가"*).
|
|
74
|
+
* >
|
|
75
|
+
* > (206) 은 하루에 **청함 → 철회 → 되살림**으로 세 번 흔들렸다. 두 번째 때 *"지시의
|
|
76
|
+
* > 뒷문장이 까닭인지 지시인지"* 가 갈렸고, 세 번째는 *"UX 바꾸지 마"* 가 **단추**를 말하는지
|
|
77
|
+
* > **고치는 방식**을 말하는지가 갈렸다. 그 세 번 동안 전해 받은 말로 짓지 않은 덕에
|
|
78
|
+
* > **되돌릴 것이 없었다.**
|
|
29
79
|
*/
|
|
30
|
-
export type
|
|
80
|
+
export type StepState = 'disabled' | 'done' | 'active';
|
|
31
81
|
/** 답을 고를 수 있나 — 닿지 않은 단계(`disabled`)만 막는다. @public */
|
|
32
|
-
export declare function canAnswerStep(state:
|
|
82
|
+
export declare function canAnswerStep(state: StepState | undefined): boolean;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* 비어 있어 **무엇을 틀렸는지 보여 줄 수가 없다.** 조용히 빼면 화면의 수가 전부인 줄
|
|
16
16
|
* 알게 되므로 몇 건을 뺐는지 함께 돌려준다.
|
|
17
17
|
*/
|
|
18
|
-
import {
|
|
18
|
+
import { compareKorean } from '../../utils/textSort';
|
|
19
19
|
/** 같은 시각 두 행의 차례 — 둘 다 id 가 있을 때만 가린다(숫자는 수 크기, 그 밖은 글자 사전순). 못 가리면 0. */
|
|
20
20
|
function 같은시각차례(a, b) {
|
|
21
21
|
if (a == null || b == null || a === '' || b === '')
|
|
@@ -99,7 +99,7 @@ export function summarizeMisses(records) {
|
|
|
99
99
|
lastSelected: v.lastSelected,
|
|
100
100
|
setIds: v.setIds,
|
|
101
101
|
}));
|
|
102
|
-
items.sort((a, b) => a.rate - b.rate || b.tried - a.tried ||
|
|
102
|
+
items.sort((a, b) => a.rate - b.rate || b.tried - a.tried || compareKorean(a.title, b.title));
|
|
103
103
|
return { items, counted, skippedNoTitle };
|
|
104
104
|
}
|
|
105
105
|
/**
|