@ihabdevteam/core 0.318.0 → 0.321.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 +236 -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 +89 -12
- 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/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 +2 -2
- 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/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/stepAnswerable.d.ts +58 -8
- 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/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 +6 -4
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +42 -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/phonemePairSearch.js +10 -3
- 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
|
@@ -672,7 +672,16 @@ export declare function buildDropContrastProblem(word: string, comp: Composition
|
|
|
672
672
|
*/
|
|
673
673
|
export declare function buildDropFinalConsonantProblem(word: string, _comp: CompositionFilter | null, pronMap: Map<string, string>, _pv: 'char' | 'pron', _ws: Set<string>, _dropPos?: number | null): TrainingProblem | null;
|
|
674
674
|
|
|
675
|
-
/**
|
|
675
|
+
/**
|
|
676
|
+
* @public 등급 FilterPanelSection — 세 화면 공용. labelAction/allLabel은 호출부가 자신의 i18n으로 구성해 넘긴다.
|
|
677
|
+
*
|
|
678
|
+
* **맨 끝에 `없음` 이 선다**((205), 2026-10-12) — 등급이 비어 있는 단어도 고를 수 있어야 한다.
|
|
679
|
+
* 맨 끝인 까닭은 **1~4 가 센 순서**이고 `없음` 은 그 줄 밖이기 때문이다(앞에 두면 `1등급` 앞에
|
|
680
|
+
* 숫자 아닌 것이 끼어 읽는 차례가 흔들린다).
|
|
681
|
+
*
|
|
682
|
+
* 이 줄의 글자는 `wordAttributeFilterCodes` 의 사전과 **같아야** 한다 — 거기서 코드로 못 읽으면
|
|
683
|
+
* 필터는 켜져 보이는데 한 단어도 안 걸린다. `test/gradeNoneOption.test.ts` 가 둘을 맞춰 센다.
|
|
684
|
+
*/
|
|
676
685
|
export declare function buildGradeSection(t: TFunction, keys: WordAttributeFilterKeys, opts?: {
|
|
677
686
|
allLabel?: string;
|
|
678
687
|
labelAction?: ReactNode;
|
|
@@ -1669,7 +1678,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1669
1678
|
*
|
|
1670
1679
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1671
1680
|
*/
|
|
1672
|
-
export declare const CORE_VERSION = "0.
|
|
1681
|
+
export declare const CORE_VERSION = "0.321.0";
|
|
1673
1682
|
|
|
1674
1683
|
/** @public */
|
|
1675
1684
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -4040,13 +4049,28 @@ export declare interface NoiseSettingsStore {
|
|
|
4040
4049
|
* 설정값은 NoisePlayerContext(useNoisePlayer)로 읽고 쓴다.
|
|
4041
4050
|
* @public
|
|
4042
4051
|
*/
|
|
4043
|
-
export declare function NoiseSettingsTooltip({ anchorRect, onClose }: NoiseSettingsTooltipProps): JSX.Element;
|
|
4052
|
+
export declare function NoiseSettingsTooltip({ anchorRect, onClose, hideNoiseRows }: NoiseSettingsTooltipProps): JSX.Element;
|
|
4044
4053
|
|
|
4045
4054
|
/** @public */
|
|
4046
4055
|
export declare interface NoiseSettingsTooltipProps {
|
|
4047
4056
|
/** 앵커(소음 설정 버튼) 위치. */
|
|
4048
4057
|
anchorRect: DOMRect | null;
|
|
4049
4058
|
onClose: () => void;
|
|
4059
|
+
/**
|
|
4060
|
+
* **소음 두 줄을 안 그린다** — `소음 종류`·`소음 비율`(사장님 지시 (207), 2026-10-12).
|
|
4061
|
+
*
|
|
4062
|
+
* 소음이 아예 없는 훈련(짧은 이야기 듣기 · 긴 이야기 듣기 · 문장 순서화)에서 쓴다.
|
|
4063
|
+
* `전체 소리`·`재생 속도` 는 그대로 선다.
|
|
4064
|
+
*
|
|
4065
|
+
* **흐리게가 아니라 안 그린다.** 흐린 줄은 *"지금은 못 바꾼다"* 를 말하는데, 여기서 할 말은
|
|
4066
|
+
* *"이 훈련에는 소음이 없다"* 다. 흐린 채로 두면 치료사가 **바꿀 수 있는 줄을 찾아 헤맨다**
|
|
4067
|
+
* (사장님이 그 줄을 짚으며 *"왜 이거 수정요청 했는데 계속 나오고 있지?"* 라 하셨다).
|
|
4068
|
+
*
|
|
4069
|
+
* **`noiseOverride` 와 따로 둔다 — 덮개는 *값*, 이 칸은 *보임*.** 묶으면 *"덮개는 걸되 줄은
|
|
4070
|
+
* 보이게"* 를 못 한다 — 소음 하 문장 듣기의 제안 갈래가 그 꼴이다(덮개를 걸고도 치료사가
|
|
4071
|
+
* 창에서 고친다). **한 칸이 두 가지를 뜻하게 하지 않는다.**
|
|
4072
|
+
*/
|
|
4073
|
+
hideNoiseRows?: boolean;
|
|
4050
4074
|
}
|
|
4051
4075
|
|
|
4052
4076
|
/**
|
|
@@ -8501,7 +8525,7 @@ export declare interface TrainingBlockProps {
|
|
|
8501
8525
|
export declare function TrainingCard({ title, titleLevel, description, topActions, children, footerLeft, footerRight, ariaLabel, onClick, draggable, onDragStart, onDragEnd, className, }: TrainingCardProps): JSX.Element;
|
|
8502
8526
|
|
|
8503
8527
|
/** @public */
|
|
8504
|
-
export declare function TrainingCardGroup({ title, titleLevel, count, collapsed, onToggle, sort, actions, empty, emptyContent, dragOver, onDragOver, onDragLeave, onDrop, children, className, }: TrainingCardGroupProps): JSX.Element;
|
|
8528
|
+
export declare function TrainingCardGroup({ title, titleLevel, count, collapsed, onToggle, beforeSort, sort, actions, empty, emptyContent, dragOver, onDragOver, onDragLeave, onDrop, children, className, }: TrainingCardGroupProps): JSX.Element;
|
|
8505
8529
|
|
|
8506
8530
|
/** @public */
|
|
8507
8531
|
declare type TrainingCardGroupProps = {
|
|
@@ -8535,6 +8559,25 @@ declare type TrainingCardGroupProps = {
|
|
|
8535
8559
|
* "접기, 단추" 로 읽혔다. 소비자 아홉 자리 중 여덟이 그랬다.)
|
|
8536
8560
|
*/
|
|
8537
8561
|
onToggle?: () => void;
|
|
8562
|
+
/**
|
|
8563
|
+
* **정렬 드롭다운 바로 왼쪽 자리**((209), 사장님 지시 2026-10-12 — 노션 QA *"단어표, 대립쌍표의
|
|
8564
|
+
* 그룹 순서 변경 기능 필요"*). 그룹 **차례를 바꾸는 손잡이**가 설 자리다.
|
|
8565
|
+
*
|
|
8566
|
+
* 사장님이 길 둘을 말씀하셨다 — *"그룹 자체를 드래그 하거나 정렬 왼쪽에 올림 내림 버튼을
|
|
8567
|
+
* 넣어서"*. **둘 다 이 한 자리에 든다**(끄는 손잡이든 ↑↓ 단추든). 그래서 코어는 자리만 열고
|
|
8568
|
+
* **무엇을 세울지는 부르는 쪽이 고른다** — 차례를 알고 간수하는 것이 그쪽이다.
|
|
8569
|
+
*
|
|
8570
|
+
* `actions` 와 **같은 대접**이다: 같은 `__actions` 감싸개 안이라 누름이 **접기 토글로 안
|
|
8571
|
+
* 번진다.** 밖에 두면 ↑ 를 누를 때마다 그 그룹이 접힌다.
|
|
8572
|
+
*
|
|
8573
|
+
* **낭독기 이름은 부르는 쪽이 단다** — `접기`·`정렬` 은 이 조각이 묶음 이름을 달지만(위
|
|
8574
|
+
* `titleLevel` 글), 넘겨받은 마디의 속을 이 조각이 고쳐 쓸 수는 없다. `actions` 와 같은
|
|
8575
|
+
* 규칙이다: **`위로` 가 그룹 수만큼 서면 단추 목록에서 못 가린다** — `위로` 가 아니라
|
|
8576
|
+
* `'sdfa' 그룹 위로` 꼴로 지어 넘긴다.
|
|
8577
|
+
*
|
|
8578
|
+
* 안 주면 자리를 안 만든다(지금과 같다).
|
|
8579
|
+
*/
|
|
8580
|
+
beforeSort?: ReactNode;
|
|
8538
8581
|
/** 헤더 우측, actions 앞에 놓이는 그룹 내 정렬 드롭다운 */
|
|
8539
8582
|
sort?: TrainingCardGroupSort;
|
|
8540
8583
|
/** 헤더 우측 액션 영역 (이름변경·추가·삭제 등) — 클릭이 토글로 전파되지 않음 */
|
|
@@ -8823,7 +8866,7 @@ export declare interface TrainingItemResult {
|
|
|
8823
8866
|
export declare const TrainingPagePopup: TrainingPagePopupComponent;
|
|
8824
8867
|
|
|
8825
8868
|
/** @public */
|
|
8826
|
-
export declare function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, resultOpen, }: TrainingPagePopupBottomProps): JSX.Element;
|
|
8869
|
+
export declare function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, resultOpen, hideNoiseRows, }: TrainingPagePopupBottomProps): JSX.Element;
|
|
8827
8870
|
|
|
8828
8871
|
/** @public */
|
|
8829
8872
|
export declare type TrainingPagePopupBottomProps = {
|
|
@@ -8838,6 +8881,21 @@ export declare type TrainingPagePopupBottomProps = {
|
|
|
8838
8881
|
pageIndex?: number;
|
|
8839
8882
|
/** 쪽 수. 0(쪽이 없음)이어도 '결과보기'(완료) 단추는 보이고, '다음 문제'(쪽 이동)만 숨는다. 기본 1. */
|
|
8840
8883
|
pageCount?: number;
|
|
8884
|
+
/**
|
|
8885
|
+
* **소음 두 줄을 안 그린다** — `소음 종류`·`소음 비율`(사장님 지시 (207), 2026-10-12).
|
|
8886
|
+
*
|
|
8887
|
+
* 소음이 아예 없는 훈련(짧은 이야기 듣기 · 긴 이야기 듣기 · 문장 순서화)에서 쓴다.
|
|
8888
|
+
* `전체 소리`·`재생 속도` 는 그대로 선다.
|
|
8889
|
+
*
|
|
8890
|
+
* **흐리게가 아니라 안 그린다.** 흐린 줄은 *"지금은 못 바꾼다"* 를 말하는데, 여기서 할 말은
|
|
8891
|
+
* *"이 훈련에는 소음이 없다"* 다. 흐린 채로 두면 치료사가 **바꿀 수 있는 줄을 찾아 헤맨다**
|
|
8892
|
+
* (사장님이 그 줄을 짚으며 *"왜 이거 수정요청 했는데 계속 나오고 있지?"* 라 하셨다).
|
|
8893
|
+
*
|
|
8894
|
+
* **`noiseOverride` 와 따로 둔다 — 덮개는 *값*, 이 칸은 *보임*.** 묶으면 *"덮개는 걸되 줄은
|
|
8895
|
+
* 보이게"* 를 못 한다 — 소음 하 문장 듣기의 제안 갈래가 그 꼴이다(덮개를 걸고도 치료사가
|
|
8896
|
+
* 창에서 고친다). **한 칸이 두 가지를 뜻하게 하지 않는다.**
|
|
8897
|
+
*/
|
|
8898
|
+
hideNoiseRows?: boolean;
|
|
8841
8899
|
};
|
|
8842
8900
|
|
|
8843
8901
|
/** @public */
|
|
@@ -8922,6 +8980,21 @@ export declare type TrainingPagePopupProps = {
|
|
|
8922
8980
|
* 판에 실제로 걸린 값은 `NoiseSettingsStore.applied` 로 받는다.
|
|
8923
8981
|
*/
|
|
8924
8982
|
noiseOverride?: Partial<CoreNoiseSettings> | ((saved: CoreNoiseSettings) => Partial<CoreNoiseSettings>);
|
|
8983
|
+
/**
|
|
8984
|
+
* **소음 두 줄을 안 그린다** — `소음 종류`·`소음 비율`(사장님 지시 (207), 2026-10-12).
|
|
8985
|
+
*
|
|
8986
|
+
* 소음이 아예 없는 훈련(짧은 이야기 듣기 · 긴 이야기 듣기 · 문장 순서화)에서 쓴다.
|
|
8987
|
+
* `전체 소리`·`재생 속도` 는 그대로 선다.
|
|
8988
|
+
*
|
|
8989
|
+
* **흐리게가 아니라 안 그린다.** 흐린 줄은 *"지금은 못 바꾼다"* 를 말하는데, 여기서 할 말은
|
|
8990
|
+
* *"이 훈련에는 소음이 없다"* 다. 흐린 채로 두면 치료사가 **바꿀 수 있는 줄을 찾아 헤맨다**
|
|
8991
|
+
* (사장님이 그 줄을 짚으며 *"왜 이거 수정요청 했는데 계속 나오고 있지?"* 라 하셨다).
|
|
8992
|
+
*
|
|
8993
|
+
* **`noiseOverride` 와 따로 둔다 — 덮개는 *값*, 이 칸은 *보임*.** 묶으면 *"덮개는 걸되 줄은
|
|
8994
|
+
* 보이게"* 를 못 한다 — 소음 하 문장 듣기의 제안 갈래가 그 꼴이다(덮개를 걸고도 치료사가
|
|
8995
|
+
* 창에서 고친다). **한 칸이 두 가지를 뜻하게 하지 않는다.**
|
|
8996
|
+
*/
|
|
8997
|
+
hideNoiseRows?: boolean;
|
|
8925
8998
|
/** 훈련 결과 팝업 렌더러 — 없으면 기본 결과 팝업(TrainingResultPopup)을 띄운다 */
|
|
8926
8999
|
renderResultPopup?: (props: {
|
|
8927
9000
|
score?: number | null;
|
|
@@ -9205,7 +9278,9 @@ export declare type TrainingStepConfirmProps = {
|
|
|
9205
9278
|
* 그 판이 통째로 사라졌다(말귀 실측: 「3대 영양소」 보기 여섯·정답 넷, 셋을 골라도 아무
|
|
9206
9279
|
* 일도 안 남). **기억을 묻는 문제에서 덜 기억한 답도 그 사람의 답이다.**
|
|
9207
9280
|
*
|
|
9208
|
-
*
|
|
9281
|
+
* 내는 자리는 **`결과보기`** 다 — (102) 때 세운 `확인` 단추는 (206) 으로 걷혔다. 고르는
|
|
9282
|
+
* 그 자리에서 매번 보고하되 **개수로는 넘어가지 않으므로** 위 두 까닭이 그대로 선다.
|
|
9283
|
+
* 이 값은 채점 쪽이 쓰라고 그대로 받아 둔다 — **이 조각은 이 수로 아무 가름도 하지 않는다.**
|
|
9209
9284
|
*/
|
|
9210
9285
|
correctCount?: number;
|
|
9211
9286
|
useDisabledCovered?: boolean;
|
|
@@ -10321,12 +10396,6 @@ export declare function useWordTrainingDataGeneration(types: WordTrainingType[],
|
|
|
10321
10396
|
generating: boolean;
|
|
10322
10397
|
};
|
|
10323
10398
|
|
|
10324
|
-
/**
|
|
10325
|
-
* 단어 훈련 그룹별 필터 상태.
|
|
10326
|
-
* @param groupKey 그룹 식별자 (localStorage 키 및 상태 격리에 사용)
|
|
10327
|
-
* @param caps 그룹이 노출할 설정 (난이도/단어구성). 제외 필터는 항상 노출.
|
|
10328
|
-
* @public
|
|
10329
|
-
*/
|
|
10330
10399
|
export declare function useWordTrainingFilter(groupKey: string, caps: WordTrainingFilterCaps, opts?: {
|
|
10331
10400
|
resetOnMount?: boolean;
|
|
10332
10401
|
resetKey?: string;
|
|
@@ -11541,6 +11610,14 @@ export declare function wordTypeLabels(t: 번역): Record<string, string>;
|
|
|
11541
11610
|
*/
|
|
11542
11611
|
export declare function wrongItems(items: readonly TrainingItemResult[]): TrainingItemResult[];
|
|
11543
11612
|
|
|
11613
|
+
/**
|
|
11614
|
+
* 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
|
|
11615
|
+
*
|
|
11616
|
+
* **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
|
|
11617
|
+
* 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
|
|
11618
|
+
*/
|
|
11619
|
+
export declare function 등급설명(t: TFunction): string[];
|
|
11620
|
+
|
|
11544
11621
|
/** `t` 하나만 받는 작은 꼴 — `TFunction` 을 들여오지 않아 이 모듈이 i18n 에 안 매인다 */
|
|
11545
11622
|
declare type 번역 = (k: string, o: {
|
|
11546
11623
|
defaultValue: string;
|
|
@@ -62,3 +62,22 @@
|
|
|
62
62
|
.noise-settings__value--disabled {
|
|
63
63
|
opacity: 0.4;
|
|
64
64
|
}
|
|
65
|
+
|
|
66
|
+
/* (208) 폭만 빌리는 그림자 줄 — NoiseSettingsTooltip.tsx 의 `noise-settings__width-ghost` 주석을 볼 것.
|
|
67
|
+
가로 폭은 참 줄과 똑같이 내고(그래서 좌우 패딩은 그대로), 높이만 0 으로 접는다.
|
|
68
|
+
`visibility: hidden` 이라 선도 그림자도 안 그린다. 줄 사이 구분선은 `.panel-row + .panel-row` 인데
|
|
69
|
+
이 감싸개는 `.panel-row` 가 아니라 그 규칙에 안 걸린다 — 그래도 안쪽 줄의 선·여백·최소높이를 0 으로 둔다. */
|
|
70
|
+
.noise-settings__width-ghost {
|
|
71
|
+
visibility: hidden;
|
|
72
|
+
height: 0;
|
|
73
|
+
overflow: hidden;
|
|
74
|
+
pointer-events: none;
|
|
75
|
+
}
|
|
76
|
+
.noise-settings__width-ghost .panel-row {
|
|
77
|
+
padding-top: 0;
|
|
78
|
+
padding-bottom: 0;
|
|
79
|
+
margin: 0;
|
|
80
|
+
border: 0;
|
|
81
|
+
min-height: 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
@@ -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
|
}
|
|
@@ -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;
|
|
@@ -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에서 초기화)
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|