@ihabdevteam/core 0.315.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 +425 -0
- package/dist/components.cjs.js +183 -183
- 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 +187 -187
- package/dist/ihabdevteam-core.d.ts +114 -15
- package/dist/packages/core/src/components/BarChart.js +16 -1
- package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
- package/dist/packages/core/src/components/PanelRow.js +2 -2
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +4 -2
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
- package/dist/packages/core/src/components/Popup/ListPopup.d.ts +12 -2
- 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/TrainingStepCard.js +11 -6
- 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/TrainingStepConfirmWithText.js +13 -8
- package/dist/packages/core/src/components/Training/audioWaits.js +22 -4
- package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +82 -0
- package/dist/packages/core/src/components/Training/stepAnswerable.js +4 -0
- package/dist/packages/core/src/components/Training/useNoiseFade.js +5 -1
- 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/wordTableLayout.d.ts +13 -1
- package/dist/packages/core/src/components/wordTableLayout.js +13 -1
- 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 +46 -6
- package/dist/packages/core/src/i18n/locales/en.json +9 -1
- package/dist/packages/core/src/i18n/locales/ko.json +9 -1
- 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/utils/wornDate.d.ts +14 -2
- package/dist/packages/core/src/utils/wornDate.js +38 -3
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +7 -3
- package/package.json +2 -1
|
@@ -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, useEffect, useMemo, useRef, useState } 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';
|
|
@@ -108,11 +109,12 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, loc
|
|
|
108
109
|
}
|
|
109
110
|
}, [playingAll, currentState, shown.length]);
|
|
110
111
|
const handleSelect = useCallback((item, i) => {
|
|
111
|
-
/*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
/* **한 번 고른 뒤에도 고칠 수 있다**(말귀 요청 (201), QA 버그 2026-10-12).
|
|
113
|
+
전에는 `!== 'active'` 로 막았는데, 고르는 순간 `onDone` 이 나가 단계가 넘어가므로
|
|
114
|
+
**자기 첫 고름에 자기가 막혔다** — 보기 셋에서 잘못 눌러도 바꿀 길이 없었다.
|
|
115
|
+
그 막이의 까닭(화면 선택은 바뀌는데 점수는 그대로)은 **아래에서 `onDone` 을 늘 보내
|
|
116
|
+
메운다** — 고치면 점수가 따라간다. 닿지 않은 단계만 막는다(`stepAnswerable`). */
|
|
117
|
+
if (!canAnswerStep(currentState))
|
|
116
118
|
return;
|
|
117
119
|
setSelectedIndex(i);
|
|
118
120
|
const payload = {
|
|
@@ -126,7 +128,10 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, loc
|
|
|
126
128
|
onChange(payload);
|
|
127
129
|
}
|
|
128
130
|
catch (e) { }
|
|
129
|
-
|
|
131
|
+
/* **고친 답도 보낸다** — 이것이 옛 막이의 까닭(화면만 바뀌고 점수는 그대로)을 메우는
|
|
132
|
+
자리다. 러너가 차례를 `Math.max(prev.stepIndex, index + 1)` 로 올리므로 이미 지난
|
|
133
|
+
단계가 다시 알려도 **앞으로 안 뛴다**(재서 확인했다 — `stepAnswerable` 머리말). */
|
|
134
|
+
if (typeof onDone === 'function') {
|
|
130
135
|
try {
|
|
131
136
|
onDone(payload);
|
|
132
137
|
}
|
|
@@ -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
|
}
|
|
@@ -5,6 +5,7 @@ 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 TrainingStepTextBox from "./TrainingStepTextBox";
|
|
9
10
|
/** @public */
|
|
10
11
|
export default function TrainingStepConfirmWithText({ index, stepIndex, isLast = false, lockedHint, title, onDone, onChange, context, demoSelected }) {
|
|
@@ -51,27 +52,31 @@ export default function TrainingStepConfirmWithText({ index, stepIndex, isLast =
|
|
|
51
52
|
: { value: payload, __ts: Date.now() };
|
|
52
53
|
if (typeof onChange === 'function')
|
|
53
54
|
onChange(p);
|
|
54
|
-
|
|
55
|
+
/* **고친 답도 보낸다**((201) 을 이 자리까지, 말귀 실측 2026-10-12). 부르는 곳은 아래 답 길
|
|
56
|
+
셋뿐이고(소리 끝남에는 안 걸려 있다) 러너가 차례를 `Math.max` 로 올리므로 앞으로 안 뛴다. */
|
|
57
|
+
if (canAnswerStep(currentState) && typeof onDone === 'function')
|
|
55
58
|
onDone(p);
|
|
56
59
|
}, [onChange, currentState, onDone]);
|
|
57
60
|
const handleSelectFalse = useCallback(() => {
|
|
58
|
-
/*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
/* **한 번 고른 뒤에도 고칠 수 있다** — (201) 과 같은 꼴이고 **이 자리는 한 가지가 더 나빴다**
|
|
62
|
+
(말귀 실측 2026-10-12, 소음 하 문장 듣기): `disabled` 가 `false` 라 **막혀 보이지도 않는다** —
|
|
63
|
+
누를 수 있게 생긴 단추를 눌렀는데 아무 일도 안 나면 쓰는 사람은 **자기가 잘못 눌렀다고 여기고
|
|
64
|
+
몇 번 더 누른다.** 그리고 여기는 `확인` 단추가 없어 *둘 중 하나를 누르는 것이 곧 답*이라,
|
|
65
|
+
여럿 고르기(`TrainingStepConfirm`)의 면제가 안 걸린다. 오늘의 듣기 **첫 칸**이다.
|
|
66
|
+
옛 막이의 까닭(화면만 바뀌고 점수는 그대로)은 `handleEndedLocal` 이 **고친 답도 보내** 메운다. */
|
|
67
|
+
if (!canAnswerStep(currentState))
|
|
63
68
|
return;
|
|
64
69
|
setSelectedAnswer(false);
|
|
65
70
|
handleEndedLocal(false);
|
|
66
71
|
}, [handleEndedLocal, currentState]);
|
|
67
72
|
const handleSelectTrue = useCallback(() => {
|
|
68
|
-
if (currentState
|
|
73
|
+
if (!canAnswerStep(currentState))
|
|
69
74
|
return;
|
|
70
75
|
setSelectedAnswer(true);
|
|
71
76
|
handleEndedLocal(true);
|
|
72
77
|
}, [handleEndedLocal, currentState]);
|
|
73
78
|
return (_jsxs(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, lockedHint: lockedHint, title: resolvedTitle, onDone: onDone, useTitleFaded: false, children: [_jsx(TrainingStepTextBox, { context: context, state: currentState }), _jsx("div", { className: "training-step__buttons", children: Array.isArray(choices) && choices.length > 0 ? (choices.slice(0, 6).map((c, i) => (_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: () => {
|
|
74
|
-
if (currentState
|
|
79
|
+
if (!canAnswerStep(currentState))
|
|
75
80
|
return;
|
|
76
81
|
setSelectedAnswer(i);
|
|
77
82
|
if (handleEndedLocal)
|
|
@@ -16,10 +16,28 @@ export function measureAudioDuration(url, opts) {
|
|
|
16
16
|
return new Promise((resolve, reject) => {
|
|
17
17
|
const ta = document.createElement('audio');
|
|
18
18
|
let done = false;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
/**
|
|
20
|
+
* **소스를 비우는 바른 꼴**(말귀 요청 (301), 실측 2026-10-12).
|
|
21
|
+
*
|
|
22
|
+
* `src = ''` 는 *"소스 없음"* 이 아니라 **"쪽 주소를 소스로 삼아라"** 다. 그래서 뒷정리가
|
|
23
|
+
* 끝난 뒤 브라우저가 쪽을 한 번 더 받으러 가고 `error code 4 "Empty src attribute"` 를 낸다 —
|
|
24
|
+
* **실패가 아니라 뒷정리가 낸 오류**이고, `done` 깃발 덕에 사람에게는 아무 일도 없지만
|
|
25
|
+
* **문장을 불러올 때마다 한 번씩** 쌓인다. 말귀 실측:
|
|
26
|
+
*
|
|
27
|
+
* 17888 loadedmetadata src=F03023.mp3 ← 길이 재기는 성공
|
|
28
|
+
* 17888 abort src=(없음) ← 뒷정리 직후
|
|
29
|
+
* 17894 error code 4 "MEDIA_ELEMENT_ERROR: Empty src attribute"
|
|
30
|
+
*
|
|
31
|
+
* **값은 콘솔이 깨끗해지는 것만이 아니다** — 말귀가 밤새 소리를 못 쟀던 까닭 중 하나가 이 줄이다.
|
|
32
|
+
* 미디어 오류가 이미 쌓여 있으니 *"원래 이런가 보다"* 로 지나갔다. **가짜 오류는 진짜 오류를 가린다.**
|
|
33
|
+
*/
|
|
34
|
+
const cleanup = () => {
|
|
35
|
+
try {
|
|
36
|
+
ta.removeAttribute('src');
|
|
37
|
+
ta.load();
|
|
38
|
+
}
|
|
39
|
+
catch (_) { }
|
|
40
|
+
};
|
|
23
41
|
const finishOk = () => {
|
|
24
42
|
if (done)
|
|
25
43
|
return;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **지금 답을 고칠 수 있나** — 단계 상태 하나로 가른다.
|
|
3
|
+
*
|
|
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
|
+
* > 단계에서 또 옮겨 차례가 바뀌는 것을 확인했다) — 이 집안이 아니다.
|
|
14
|
+
*
|
|
15
|
+
* 그런데 그 막이가 **자기 첫 고름에 자기를 막는다**(말귀 요청 (201), QA 버그 보고 2026-10-12):
|
|
16
|
+
* 고르면 `onDone` 이 나가고 단계가 넘어가 `currentState` 가 `'done'` 이 된다 — 그 뒤로는
|
|
17
|
+
* **잘못 눌러도 바꿀 길이 없다.** 보기 셋에서 손이 미끄러지면 그 판은 그대로 끝난다.
|
|
18
|
+
*
|
|
19
|
+
* **둘 다 틀렸고 바라는 것은 셋째다 — 선택도 바뀌고 점수도 따라간다.** 그래서 가름을
|
|
20
|
+
* *"지금 서 있는 단계인가"* 에서 *"아직 닿지 않은 단계인가"* 로 옮긴다:
|
|
21
|
+
*
|
|
22
|
+
* active 고를 수 있다 — 처음 고르는 자리
|
|
23
|
+
* done 고칠 수 있다 — 고치면 `onDone` 이 다시 나가 점수가 따라간다
|
|
24
|
+
* disabled 막는다 — 아직 닿지 않았거나 되돌아본 판이다
|
|
25
|
+
*
|
|
26
|
+
* **두 번째 `onDone` 이 앞으로 안 뛰는 것을 확인했다**(그래서 이 고침이 안전하다):
|
|
27
|
+
* 러너가 `stepIndex: Math.max(prev.stepIndex, index + 1)` 로 올리므로, 이미 지난 단계가 다시
|
|
28
|
+
* 알려도 차례는 그대로다(`TrainingPagePopup.tsx`). 재지 않고 고쳤으면 **고친 답이 다음 단계를
|
|
29
|
+
* 한 칸 더 밀** 수도 있었다.
|
|
30
|
+
*
|
|
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
|
+
* > **되돌릴 것이 없었다.**
|
|
79
|
+
*/
|
|
80
|
+
export type 단계상태 = 'disabled' | 'done' | 'active';
|
|
81
|
+
/** 답을 고를 수 있나 — 닿지 않은 단계(`disabled`)만 막는다. @public */
|
|
82
|
+
export declare function canAnswerStep(state: 단계상태 | undefined): boolean;
|
|
@@ -168,9 +168,13 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, sequence
|
|
|
168
168
|
noiseAudioRef.current = noiseEl;
|
|
169
169
|
return () => {
|
|
170
170
|
cancelNoiseFade();
|
|
171
|
+
/* **`src = ''` 가 아니다** — 그것은 "쪽 주소를 소스로 삼아라" 라서 `error code 4` 를 낸다((301)).
|
|
172
|
+
소음은 훈련마다 서고 걷히니 그 가짜 오류도 그만큼 쌓인다 — 말귀가 소리를 재려는데 콘솔이
|
|
173
|
+
이미 미디어 오류로 덮여 있던 자리다. */
|
|
171
174
|
try {
|
|
172
175
|
noiseEl.pause();
|
|
173
|
-
noiseEl.src
|
|
176
|
+
noiseEl.removeAttribute('src');
|
|
177
|
+
noiseEl.load();
|
|
174
178
|
}
|
|
175
179
|
catch (e) { }
|
|
176
180
|
noiseAudioRef.current = null;
|
|
@@ -7,6 +7,19 @@
|
|
|
7
7
|
background: transparent;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/* ── 표를 손보는 단추들 (표 바로 아래) — (210) ───────────
|
|
11
|
+
간격은 Section 의 flex gap 이 맡는다(`__meta` 와 같은 규칙 — 제 margin 을 더하면 이중 여백).
|
|
12
|
+
아래 `__actions` 와 같은 꼴로 선다: 모자라면 아랫줄로, 폰에서는 두 칸 격자. */
|
|
13
|
+
.word-table-block__table-actions {
|
|
14
|
+
display: flex;
|
|
15
|
+
gap: var(--spacing-xs);
|
|
16
|
+
flex-wrap: wrap;
|
|
17
|
+
/* **가운데**((211), 사장님 지시 *"가운데 배치"*). 종이(표)는 가운데 서는데 그 아래 줄만
|
|
18
|
+
왼쪽 끝에 붙어 한 덩이로 안 읽혔다(말귀 실측 1403 폭: 줄 폭 1272, 단추 둘이 왼끝).
|
|
19
|
+
아래 액션 줄은 `space-between` 이라 그대로 둔다 — 거기는 양끝에 갈라 세우는 줄이다. */
|
|
20
|
+
justify-content: center;
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
/* ── 액션 버튼 ─────────────────────────────────────────── */
|
|
11
24
|
.word-table-block__actions {
|
|
12
25
|
display: flex;
|
|
@@ -53,6 +66,18 @@
|
|
|
53
66
|
min-width: 0;
|
|
54
67
|
width: 100%;
|
|
55
68
|
}
|
|
69
|
+
/* (210) 표 밑 줄도 같은 꼴 — **@media 를 따로 열지 않는다.** 처음 위에 따로 열었다가
|
|
70
|
+
`wordTableBlockActionsLeftExtra` 가 울었다: 그 못은 `css.slice(0, indexOf('@media'))` 로
|
|
71
|
+
넓은 쪽을 자르는데, 앞에 `@media` 가 하나 더 생기면 **자르는 자리가 앞당겨져** 넓은 쪽
|
|
72
|
+
규칙을 못 찾는다. 못이 글의 차례에 기대고 있었고, 내가 그 차례를 바꿨다. */
|
|
73
|
+
.word-table-block__table-actions {
|
|
74
|
+
display: grid;
|
|
75
|
+
grid-template-columns: repeat(2, 1fr);
|
|
76
|
+
}
|
|
77
|
+
.word-table-block__table-actions .button {
|
|
78
|
+
min-width: 0;
|
|
79
|
+
width: 100%;
|
|
80
|
+
}
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
/* ── 팝업 내 미리보기 ───────────────────────────────────── */
|
|
@@ -334,7 +334,7 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
|
|
|
334
334
|
(위 `onReorder` 와 같은 셈이다. 쪽이 몇으로 나뉘었는지는 부르는 쪽 일이 아니다). */
|
|
335
335
|
renderCellAction: renderCellAction && ((n, item) => renderCellAction(n + i * itemsPerPage, item)), brand: 띠, code: code, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? visibleCells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined,
|
|
336
336
|
/* 편집 모드에는 얹을 항목이 없다 — 칸을 손으로 채우는 꼴이다 */
|
|
337
|
-
renderCellAction: !isEditable ? renderCellAction : undefined, brand: 띠, code: code, showFillerAdd: !!onAddItem, contentRef: contentRef })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
|
|
337
|
+
renderCellAction: !isEditable ? renderCellAction : undefined, brand: 띠, code: code, showFillerAdd: !!onAddItem, contentRef: contentRef })), !hideControls && !saveOnly && (onReset || actionsLeftExtra != null) && (_jsxs("div", { className: "word-table-block__table-actions", children: [onReset && (_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: resolvedResetLabel, onClick: onReset })), actionsLeftExtra] })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
|
|
338
338
|
/* PanelRow의 라벨은 시각적으로만 붙어 있고 입력과 연결돼 있지 않아, 낭독기에는
|
|
339
339
|
이름 없는 입력으로 읽혔다. placeholder는 자동완성 안내문("Tab 또는 space…")이
|
|
340
340
|
섞여 있어 이름으로 부적절하므로 같은 라벨 문구를 넘긴다. */
|
|
@@ -349,5 +349,5 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
|
|
|
349
349
|
e.preventDefault();
|
|
350
350
|
onDescriptionChange?.(descriptionPlaceholder ?? '');
|
|
351
351
|
}
|
|
352
|
-
} }) })] }))] }), !hideControls && (_jsxs("div", { className: "word-table-block__actions", children: [_jsxs("div", { className: "word-table-block__actions-left", children: [!saveOnly &&
|
|
352
|
+
} }) })] }))] }), !hideControls && (_jsxs("div", { className: "word-table-block__actions", children: [_jsxs("div", { className: "word-table-block__actions-left", children: [!saveOnly && onLoad && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.load', '불러오기'), onClick: onLoad })), !saveOnly && _jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.openNewWindow', '새 창으로 보기'), onClick: handleOpenNewWindow })] }), _jsxs("div", { className: "word-table-block__actions-right", children: [onCancel && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('common.cancel', '취소'), onClick: onCancel }), !saveOnly && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordTable.print', '인쇄하기'), onClick: handlePrint }), secondarySaveButtonLabel && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: secondarySaveButtonLabel, onClick: () => inlineNameFields ? openSaveAsPopup() : handleSave(true), disabled: noItems, onDisabledClick: () => { showWarning(t('wordTable.emptyItems', '단어를 1개 이상 추가해주세요.')); } })), _jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: saveButtonLabel ?? t('wordTable.saveButton', '저장하기'), onClick: () => handleSave(false), disabled: mainSaveDisabled, onDisabledClick: handleMainDisabledClick })] })] })), _jsx(Toast, { message: toastMessage, type: toastType, onClose: clearToast })] }));
|
|
353
353
|
}
|
|
@@ -82,7 +82,19 @@ export declare function posLabels(t: 번역): Record<string, string>;
|
|
|
82
82
|
* @public
|
|
83
83
|
*/
|
|
84
84
|
export declare function wordTypeLabels(t: 번역): Record<string, string>;
|
|
85
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* 약호 → i18n 열쇠 조각 표(품사·어종).
|
|
87
|
+
*
|
|
88
|
+
* **차례가 뜻을 갖는다**(2026-10-10, 말귀_2 가 쓰기 시작한 뒤 적음). 처음에는 *사전 덮임을 세는*
|
|
89
|
+
* 데 쓰라고 내보냈는데(약호마다 말이 있나), 말귀가 **제안 폼 고르개의 차례**를 여기서 가져갔다 —
|
|
90
|
+
* *"말과 무관한 것은 말에서 안 꺼낸다"* 는 가름이고 맞다. 그래서 이 표는 이제 **두 가지를 뜻한다**:
|
|
91
|
+
* *어떤 약호가 있나* 와 *어떤 차례로 보이나.*
|
|
92
|
+
*
|
|
93
|
+
* **차례는 한국어 사전 차례다**(명 · 동 · 형 · 부 · 수 · 관 · 감 · 조 · 접 · 의 · 대 · 보동 · 보형).
|
|
94
|
+
* 약호를 **더하는 것은 뒤에 붙이면** 쓰는 쪽이 안 흔들리지만, **차례를 바꾸는 것은 쓰는 쪽 화면을
|
|
95
|
+
* 바꾼다** — 판 올릴 때 알려야 한다. *한 칸이 두 가지를 뜻하는 것은 어긋나는 날까지 안 보인다.*
|
|
96
|
+
* @public
|
|
97
|
+
*/
|
|
86
98
|
export declare const WORD_LABEL_I18N_KEYS: {
|
|
87
99
|
readonly pos: Record<string, string>;
|
|
88
100
|
readonly wordType: Record<string, string>;
|
|
@@ -151,7 +151,19 @@ export function posLabels(t) {
|
|
|
151
151
|
export function wordTypeLabels(t) {
|
|
152
152
|
return Object.fromEntries(Object.entries(WORD_TYPE_LABEL).map(([k, v]) => [k, WORD_TYPE_I18N_KEY[k] ? t(`wordSearch.filter.type.${WORD_TYPE_I18N_KEY[k]}`, { defaultValue: v }) : v]));
|
|
153
153
|
}
|
|
154
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* 약호 → i18n 열쇠 조각 표(품사·어종).
|
|
156
|
+
*
|
|
157
|
+
* **차례가 뜻을 갖는다**(2026-10-10, 말귀_2 가 쓰기 시작한 뒤 적음). 처음에는 *사전 덮임을 세는*
|
|
158
|
+
* 데 쓰라고 내보냈는데(약호마다 말이 있나), 말귀가 **제안 폼 고르개의 차례**를 여기서 가져갔다 —
|
|
159
|
+
* *"말과 무관한 것은 말에서 안 꺼낸다"* 는 가름이고 맞다. 그래서 이 표는 이제 **두 가지를 뜻한다**:
|
|
160
|
+
* *어떤 약호가 있나* 와 *어떤 차례로 보이나.*
|
|
161
|
+
*
|
|
162
|
+
* **차례는 한국어 사전 차례다**(명 · 동 · 형 · 부 · 수 · 관 · 감 · 조 · 접 · 의 · 대 · 보동 · 보형).
|
|
163
|
+
* 약호를 **더하는 것은 뒤에 붙이면** 쓰는 쪽이 안 흔들리지만, **차례를 바꾸는 것은 쓰는 쪽 화면을
|
|
164
|
+
* 바꾼다** — 판 올릴 때 알려야 한다. *한 칸이 두 가지를 뜻하는 것은 어긋나는 날까지 안 보인다.*
|
|
165
|
+
* @public
|
|
166
|
+
*/
|
|
155
167
|
export const WORD_LABEL_I18N_KEYS = { pos: POS_I18N_KEY, wordType: WORD_TYPE_I18N_KEY };
|
|
156
168
|
/**
|
|
157
169
|
* 슬래시로 묶인 복합 약호(예: '명/관', '동/보동')를 라벨맵으로 각각 펼쳐 다시 '/'로 잇는다.
|
|
@@ -24,7 +24,16 @@ export interface WordAttributeFilterKeys {
|
|
|
24
24
|
* 이 훅을 함께 호출해 라벨 문구와 posMap/typeMap(품사·어종 판정 규칙)을 어긋나지 않게 공유한다.
|
|
25
25
|
*/
|
|
26
26
|
export declare function useWordAttributeFilterKeys(): WordAttributeFilterKeys;
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* @public 등급 FilterPanelSection — 세 화면 공용. labelAction/allLabel은 호출부가 자신의 i18n으로 구성해 넘긴다.
|
|
29
|
+
*
|
|
30
|
+
* **맨 끝에 `없음` 이 선다**((205), 2026-10-12) — 등급이 비어 있는 단어도 고를 수 있어야 한다.
|
|
31
|
+
* 맨 끝인 까닭은 **1~4 가 센 순서**이고 `없음` 은 그 줄 밖이기 때문이다(앞에 두면 `1등급` 앞에
|
|
32
|
+
* 숫자 아닌 것이 끼어 읽는 차례가 흔들린다).
|
|
33
|
+
*
|
|
34
|
+
* 이 줄의 글자는 `wordAttributeFilterCodes` 의 사전과 **같아야** 한다 — 거기서 코드로 못 읽으면
|
|
35
|
+
* 필터는 켜져 보이는데 한 단어도 안 걸린다. `test/gradeNoneOption.test.ts` 가 둘을 맞춰 센다.
|
|
36
|
+
*/
|
|
28
37
|
export declare function buildGradeSection(t: TFunction, keys: WordAttributeFilterKeys, opts?: {
|
|
29
38
|
allLabel?: string;
|
|
30
39
|
labelAction?: ReactNode;
|
|
@@ -31,11 +31,20 @@ export function useWordAttributeFilterKeys() {
|
|
|
31
31
|
},
|
|
32
32
|
}), [t]);
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* @public 등급 FilterPanelSection — 세 화면 공용. labelAction/allLabel은 호출부가 자신의 i18n으로 구성해 넘긴다.
|
|
36
|
+
*
|
|
37
|
+
* **맨 끝에 `없음` 이 선다**((205), 2026-10-12) — 등급이 비어 있는 단어도 고를 수 있어야 한다.
|
|
38
|
+
* 맨 끝인 까닭은 **1~4 가 센 순서**이고 `없음` 은 그 줄 밖이기 때문이다(앞에 두면 `1등급` 앞에
|
|
39
|
+
* 숫자 아닌 것이 끼어 읽는 차례가 흔들린다).
|
|
40
|
+
*
|
|
41
|
+
* 이 줄의 글자는 `wordAttributeFilterCodes` 의 사전과 **같아야** 한다 — 거기서 코드로 못 읽으면
|
|
42
|
+
* 필터는 켜져 보이는데 한 단어도 안 걸린다. `test/gradeNoneOption.test.ts` 가 둘을 맞춰 센다.
|
|
43
|
+
*/
|
|
35
44
|
export function buildGradeSection(t, keys, opts) {
|
|
36
45
|
return {
|
|
37
46
|
label: keys.difficulty,
|
|
38
|
-
items: [1, 2, 3, 4].map(n => t('wordSearch.filter.grade', { n })),
|
|
47
|
+
items: [...[1, 2, 3, 4].map(n => t('wordSearch.filter.grade', { n })), t('wordSearch.filter.gradeNone', '없음')],
|
|
39
48
|
multiple: true,
|
|
40
49
|
allOption: true,
|
|
41
50
|
allLabel: opts?.allLabel,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TFunction } from 'i18next';
|
|
1
2
|
import { type FilterPanelSection } from '../types/filterPanel';
|
|
2
3
|
import { type WordComposition } from './wordTrainingFilterModel';
|
|
3
4
|
export { valueToJamos, NO_CODA, SYLLABLE_MIN, syllableToFilter, charToPronValue, pronToCharValue, convertComposition, EMPTY_COMPOSITION, } from './wordTrainingFilterModel';
|
|
@@ -52,11 +53,12 @@ export interface WordTrainingFilterCaps {
|
|
|
52
53
|
requireCoda?: boolean;
|
|
53
54
|
}
|
|
54
55
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
56
|
+
* 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
|
|
57
|
+
*
|
|
58
|
+
* **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
|
|
59
|
+
* 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
|
|
59
60
|
*/
|
|
61
|
+
export declare function 등급설명(t: TFunction): string[];
|
|
60
62
|
export declare function useWordTrainingFilter(groupKey: string, caps: WordTrainingFilterCaps, opts?: {
|
|
61
63
|
resetOnMount?: boolean;
|
|
62
64
|
resetKey?: string;
|