@ihabdevteam/core 0.37.0 → 0.39.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/dist/components.cjs.js +19 -19
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +1 -788
- package/dist/i18n.cjs.js +9 -9
- package/dist/ihabdevteam-core.cjs.js +178 -178
- package/dist/ihabdevteam-core.d.ts +74 -8
- package/dist/packages/core/src/components/BottomNav.js +1 -1
- package/dist/packages/core/src/components/DatePicker.js +1 -1
- package/dist/packages/core/src/components/Dropdown.js +1 -1
- package/dist/packages/core/src/components/FilterPanel.js +2 -1
- package/dist/packages/core/src/components/Header.js +1 -1
- package/dist/packages/core/src/components/PageTitle.css +7 -0
- package/dist/packages/core/src/components/PageTitle.d.ts +13 -1
- package/dist/packages/core/src/components/PageTitle.js +11 -2
- package/dist/packages/core/src/components/Panel.d.ts +3 -133
- package/dist/packages/core/src/components/Panel.js +2 -140
- package/dist/packages/core/src/components/PanelRow.d.ts +145 -0
- package/dist/packages/core/src/components/PanelRow.js +144 -0
- package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +16 -2
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +19 -2
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +1 -54
- package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +2 -15
- package/dist/packages/core/src/components/Word/wordTablePrint.js +7 -1
- package/dist/packages/core/src/components/useAnchoredMenu.d.ts +8 -26
- package/dist/packages/core/src/components/useAnchoredMenu.js +7 -70
- package/dist/packages/core/src/components/useSoundCompassExam.d.ts +8 -51
- package/dist/packages/core/src/components/useSoundCompassExam.js +10 -128
- package/dist/packages/core/src/components.d.ts +4 -4
- package/dist/packages/core/src/components.js +2 -2
- package/dist/packages/core/src/hooks/useAnchoredMenu.d.ts +28 -0
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +72 -0
- package/dist/packages/core/src/hooks/useSoundCompassExam.d.ts +54 -0
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +128 -0
- package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +2 -0
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +2 -1
- package/dist/packages/core/src/hooks/useWordTrainingDataGeneration.d.ts +2 -8
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -6
- package/dist/packages/core/src/hooks.d.ts +4 -0
- package/dist/packages/core/src/hooks.js +6 -0
- package/dist/packages/core/src/i18n/locales/en.json +8 -0
- package/dist/packages/core/src/i18n/locales/ko.json +8 -0
- package/dist/packages/core/src/types/filterPanel.d.ts +15 -0
- package/dist/packages/core/src/types/training.d.ts +20 -0
- package/dist/packages/core/src/types/training.js +1 -0
- package/dist/packages/core/src/utils/audioPeaks.d.ts +2 -0
- package/dist/packages/core/src/utils/audioPeaks.js +67 -0
- package/dist/packages/core/src/utils/noiseSettingsStore.d.ts +1 -1
- package/dist/packages/core/src/utils/wordTrainingGroups.d.ts +1 -1
- package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.d.ts +14 -0
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ReactNode, KeyboardEvent } from 'react';
|
|
2
|
+
import type { DeviceEntry, DeviceSide } from './HearingDeviceInfo';
|
|
3
|
+
import './Panel.css';
|
|
4
|
+
/**
|
|
5
|
+
* 패널 **한 줄** — `Panel` 이 담는 그릇이라면 이쪽은 그 안에 서는 줄이다.
|
|
6
|
+
*
|
|
7
|
+
* `Panel.tsx` 에서 갈라 나왔다. 한 파일에 컴포넌트 넷(Panel · PanelGrid · PanelRow ·
|
|
8
|
+
* PanelRowDevice)과 타입 열다섯이 함께 있었는데, 그릇을 고치러 들어온 사람이 줄의
|
|
9
|
+
* 12갈래 판별 합집합을 지나야 했다. 이 저장소가 이미 쓰는 방식이다
|
|
10
|
+
* (`trainingStepAudioPlayerTypes.ts` · `audioPlaybackRules.ts`).
|
|
11
|
+
*
|
|
12
|
+
* `Panel.tsx` 가 이것들을 다시 내보내므로 `@ihabdevteam/core/components/Panel` 에서
|
|
13
|
+
* 가져가던 곳은 그대로 돌아간다.
|
|
14
|
+
*/
|
|
15
|
+
type Base = {
|
|
16
|
+
label?: string;
|
|
17
|
+
sub?: string;
|
|
18
|
+
onClick?: () => void;
|
|
19
|
+
required?: boolean;
|
|
20
|
+
error?: string;
|
|
21
|
+
align?: 'horizontal' | 'vertical';
|
|
22
|
+
useFullWidth?: boolean;
|
|
23
|
+
icon?: ReactNode;
|
|
24
|
+
};
|
|
25
|
+
export type PanelRowToggle = Base & {
|
|
26
|
+
variant: 'toggle';
|
|
27
|
+
checked: boolean;
|
|
28
|
+
onChange: (v: boolean) => void;
|
|
29
|
+
};
|
|
30
|
+
export type PanelRowSegmented = Base & {
|
|
31
|
+
variant: 'segmented';
|
|
32
|
+
options: {
|
|
33
|
+
key: string;
|
|
34
|
+
label: string;
|
|
35
|
+
}[];
|
|
36
|
+
value: string;
|
|
37
|
+
onChange: (v: string) => void;
|
|
38
|
+
size?: 32 | 40 | 48;
|
|
39
|
+
};
|
|
40
|
+
export type DropdownItem = {
|
|
41
|
+
options: {
|
|
42
|
+
value: string;
|
|
43
|
+
label: string;
|
|
44
|
+
}[];
|
|
45
|
+
value: string;
|
|
46
|
+
onChange: (v: string) => void;
|
|
47
|
+
placeholder?: string;
|
|
48
|
+
/**
|
|
49
|
+
* 이 드롭다운 하나의 이름. 미지정 시 "행 라벨 + placeholder" 로 조합한다
|
|
50
|
+
* (생년월일 + 년 → "생년월일 년").
|
|
51
|
+
*/
|
|
52
|
+
ariaLabel?: string;
|
|
53
|
+
};
|
|
54
|
+
export type PanelRowDropdown = Base & {
|
|
55
|
+
variant: 'dropdown';
|
|
56
|
+
options?: {
|
|
57
|
+
value: string;
|
|
58
|
+
label: string;
|
|
59
|
+
}[];
|
|
60
|
+
value?: string;
|
|
61
|
+
onChange?: (v: string) => void;
|
|
62
|
+
dropdowns?: DropdownItem[];
|
|
63
|
+
};
|
|
64
|
+
export type ButtonItem = {
|
|
65
|
+
label: ReactNode;
|
|
66
|
+
btnVariant?: string;
|
|
67
|
+
btnSize?: number;
|
|
68
|
+
disabled?: boolean;
|
|
69
|
+
onClick: () => void;
|
|
70
|
+
};
|
|
71
|
+
export type PanelRowButton = Base & {
|
|
72
|
+
variant: 'button';
|
|
73
|
+
buttonLabel?: ReactNode;
|
|
74
|
+
btnVariant?: string;
|
|
75
|
+
btnSize?: number;
|
|
76
|
+
disabled?: boolean;
|
|
77
|
+
onClick?: () => void;
|
|
78
|
+
buttons?: ButtonItem[];
|
|
79
|
+
};
|
|
80
|
+
export type PanelRowText = Base & {
|
|
81
|
+
variant: 'text';
|
|
82
|
+
value: string;
|
|
83
|
+
valueColor?: string;
|
|
84
|
+
};
|
|
85
|
+
export type PanelRowInput = Base & {
|
|
86
|
+
variant: 'input';
|
|
87
|
+
value: string;
|
|
88
|
+
onChange?: (v: string) => void;
|
|
89
|
+
onKeyDown?: (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
|
90
|
+
/** Enter로 확정(제출 등). TextField가 IME 조합 중 Enter는 걸러 주므로 한글 입력에서도 안전하다. */
|
|
91
|
+
onEnter?: () => void;
|
|
92
|
+
placeholder?: string;
|
|
93
|
+
inputMode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
|
|
94
|
+
readOnly?: boolean;
|
|
95
|
+
disabled?: boolean;
|
|
96
|
+
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
|
|
97
|
+
autoComplete?: string;
|
|
98
|
+
helperText?: string;
|
|
99
|
+
/** 최대 글자 수 — 설정 시 입력 제한 + 박스 안쪽 글자수 카운터 표시 */
|
|
100
|
+
maxLength?: number;
|
|
101
|
+
/** 글자수 카운터 표시 여부 (maxLength 설정 시, 기본 true) */
|
|
102
|
+
showCount?: boolean;
|
|
103
|
+
/** 여러 줄 입력(textarea) 모드 */
|
|
104
|
+
multiline?: boolean;
|
|
105
|
+
/** multiline 모드 기본 행 수 */
|
|
106
|
+
rows?: number;
|
|
107
|
+
};
|
|
108
|
+
export type PanelRowCustom = Base & {
|
|
109
|
+
variant: 'custom';
|
|
110
|
+
children: ReactNode;
|
|
111
|
+
};
|
|
112
|
+
/** 라벨/우측 분리 없이 설명 문단을 통으로 표시하는 행 */
|
|
113
|
+
export type PanelRowDescription = {
|
|
114
|
+
variant: 'description';
|
|
115
|
+
children: ReactNode;
|
|
116
|
+
};
|
|
117
|
+
/** 빈 상태 표현 — Panel의 `empty` prop이 내부적으로 이 variant로 렌더한다. 단독으로도 사용 가능. */
|
|
118
|
+
export type PanelRowEmpty = {
|
|
119
|
+
variant: 'empty';
|
|
120
|
+
children: ReactNode;
|
|
121
|
+
};
|
|
122
|
+
/** 로딩 스피너 1개만 중앙에 표시 — Panel의 `loading` prop이 내부적으로 이 variant로 렌더한다. */
|
|
123
|
+
export type PanelRowLoading = {
|
|
124
|
+
variant: 'loading';
|
|
125
|
+
};
|
|
126
|
+
export type PanelRowChildren = {
|
|
127
|
+
label?: ReactNode;
|
|
128
|
+
sub?: string;
|
|
129
|
+
onClick?: () => void;
|
|
130
|
+
children?: ReactNode;
|
|
131
|
+
align?: 'horizontal' | 'vertical';
|
|
132
|
+
icon?: ReactNode;
|
|
133
|
+
};
|
|
134
|
+
export type PanelRowProps = PanelRowToggle | PanelRowSegmented | PanelRowDropdown | PanelRowButton | PanelRowText | PanelRowInput | PanelRowCustom | PanelRowDescription | PanelRowEmpty | PanelRowLoading | PanelRowChildren;
|
|
135
|
+
export declare function PanelRow(props: PanelRowProps): import("react").JSX.Element;
|
|
136
|
+
export type { DeviceSide };
|
|
137
|
+
export interface PanelRowDeviceProps {
|
|
138
|
+
devices: {
|
|
139
|
+
좌: DeviceEntry | null;
|
|
140
|
+
우: DeviceEntry | null;
|
|
141
|
+
};
|
|
142
|
+
/** 있으면 각 쪽 이름 옆에 '착용 해제'가 선다 — 그 쪽의 '지금 착용중' 만 내린다(이력은 남는다) */
|
|
143
|
+
onRelease?: (side: DeviceSide) => void;
|
|
144
|
+
}
|
|
145
|
+
export declare function PanelRowDevice({ devices, onRelease }: PanelRowDeviceProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cx } from '../utils/cx';
|
|
4
|
+
import { useId } from 'react';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import Toggle from './Toggle';
|
|
7
|
+
import SegmentedControl from './SegmentedControl';
|
|
8
|
+
import Dropdown from './Dropdown';
|
|
9
|
+
import Button from './Button';
|
|
10
|
+
import Badge from './Badge';
|
|
11
|
+
import { deviceKindVariant } from '../utils/deviceKind';
|
|
12
|
+
import TextField from './TextField';
|
|
13
|
+
import Loader from './Loader';
|
|
14
|
+
import './Panel.css';
|
|
15
|
+
const noop = () => { };
|
|
16
|
+
export function PanelRow(props) {
|
|
17
|
+
// toggle 행의 라벨-스위치 연결용 id — 모든 variant에서 훅 호출 순서를 유지하기 위해
|
|
18
|
+
// 조건부 return들보다 앞에서 무조건 호출한다 (사용하지 않는 variant에서는 그냥 버려짐)
|
|
19
|
+
const uid = useId();
|
|
20
|
+
// children 기반 하위 호환 모드
|
|
21
|
+
if (!('variant' in props)) {
|
|
22
|
+
const { label, sub, onClick, children, align = 'horizontal', icon } = props;
|
|
23
|
+
const handleKeyDown = (e) => {
|
|
24
|
+
if (!onClick)
|
|
25
|
+
return;
|
|
26
|
+
// 우측 children 안의 버튼/토글 등이 자체적으로 Enter/Space를 처리하므로,
|
|
27
|
+
// 행 자기 자신에서 발생한 키 입력만 행 클릭으로 취급한다
|
|
28
|
+
if (e.target !== e.currentTarget)
|
|
29
|
+
return;
|
|
30
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
onClick();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
return (_jsxs("div", { className: cx('panel-row', align === 'vertical' ? 'panel-row--vertical' : '', onClick ? 'panel-row--clickable' : ''), onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [icon != null && _jsx("div", { className: "panel-row__icon", children: icon }), label != null && (_jsxs("div", { className: "panel-row__left", children: [_jsx("div", { className: "panel-row__label body-3", children: label }), sub && _jsx("div", { className: "panel-row__sub caption-1", children: sub })] })), _jsx("div", { className: "panel-row__right", children: children })] }));
|
|
36
|
+
}
|
|
37
|
+
// 설명 전용 행 — 라벨/우측 구조 없이 문단만 렌더
|
|
38
|
+
if (props.variant === 'description') {
|
|
39
|
+
const p = props;
|
|
40
|
+
return (_jsx("div", { className: "panel-row panel-row--block", children: _jsx("p", { className: "panel-row__description body-3", children: p.children }) }));
|
|
41
|
+
}
|
|
42
|
+
// 빈 상태 — 라벨 없이 중앙 정렬된 한 줄
|
|
43
|
+
if (props.variant === 'empty') {
|
|
44
|
+
const p = props;
|
|
45
|
+
return _jsx("div", { className: "panel-row panel-row--empty body-3", children: p.children });
|
|
46
|
+
}
|
|
47
|
+
// 로딩 — 중앙 스피너 1개
|
|
48
|
+
if (props.variant === 'loading') {
|
|
49
|
+
return _jsx("div", { className: "panel-row panel-row--loading", children: _jsx(Loader, { size: 28 }) });
|
|
50
|
+
}
|
|
51
|
+
// custom 행에 label이 없으면 좌/우 분할 없이 children을 그대로 꽉 채워 렌더
|
|
52
|
+
// (CodeBox처럼 라벨과 짝지을 수 없는 큰 블록 콘텐츠를 행 구조 안에 넣을 때 사용)
|
|
53
|
+
if (props.variant === 'custom' && !props.label) {
|
|
54
|
+
const p = props;
|
|
55
|
+
return (_jsx("div", { className: "panel-row panel-row--block", children: p.children }));
|
|
56
|
+
}
|
|
57
|
+
const { variant, label, sub, onClick, required, error, align = 'horizontal', useFullWidth = false, icon } = props;
|
|
58
|
+
let right = null;
|
|
59
|
+
// toggle 행만 왼쪽 라벨(panel-row__label)을 <label htmlFor>로 렌더해 스위치와 프로그래밍적으로
|
|
60
|
+
// 연결한다 — 그 전엔 시각적으로만 옆에 붙어있어 라벨 텍스트를 클릭해도 토글이 안 되고
|
|
61
|
+
// 스크린리더도 이 스위치가 뭘 위한 건지 안내하지 못했다.
|
|
62
|
+
const toggleId = variant === 'toggle' ? `panel-toggle-${uid}` : undefined;
|
|
63
|
+
if (variant === 'toggle') {
|
|
64
|
+
const p = props;
|
|
65
|
+
right = _jsx(Toggle, { checked: p.checked, onChange: p.onChange, id: toggleId });
|
|
66
|
+
}
|
|
67
|
+
else if (variant === 'segmented') {
|
|
68
|
+
const p = props;
|
|
69
|
+
// dropdown 행과 동일하게, PanelRow 왼쪽 라벨은 <label htmlFor>가 아닌 일반 div라서
|
|
70
|
+
// SegmentedControl에 ariaLabel로 넘겨야 스크린리더가 이름 없는 라디오그룹으로 안내하지 않는다.
|
|
71
|
+
right = _jsx(SegmentedControl, { options: p.options, value: p.value, onChange: p.onChange, size: p.size ?? 40, ariaLabel: label });
|
|
72
|
+
}
|
|
73
|
+
else if (variant === 'dropdown') {
|
|
74
|
+
const p = props;
|
|
75
|
+
const dropdowns = p.dropdowns?.length ? (_jsx("div", { className: `panel-row__dropdown-group${useFullWidth ? ' panel-row__dropdown-group--full' : ''}`, children: p.dropdowns.map((d, i) => (_jsx("div", { className: useFullWidth ? 'panel-row__dropdown-item--full' : undefined, children: _jsx(Dropdown, { options: d.options, value: d.value, onChange: d.onChange, placeholder: d.placeholder, block: useFullWidth, error: !!error,
|
|
76
|
+
// 한 행에 여러 개가 놓이므로 행 라벨만 넘기면 셋 다 "생년월일" 이라는 같은 이름이 된다.
|
|
77
|
+
// aria-label 이 있으면 스크린리더는 트리거의 화면 텍스트(년/월/일)를 읽지 않으므로
|
|
78
|
+
// 사용자는 어느 것이 무엇인지 구별할 수 없다 — placeholder 를 붙여 구별되게 한다.
|
|
79
|
+
ariaLabel: d.ariaLabel ?? ([label, d.placeholder].filter(Boolean).join(' ') || undefined) }) }, i))) })) : (
|
|
80
|
+
// PanelRow 왼쪽 라벨(panel-row__label)은 <label htmlFor>가 아니라 그냥 div라서 트리거 버튼과
|
|
81
|
+
// 프로그래밍적으로 연결되지 않는다 — ariaLabel로 직접 이름을 넘긴다 (TextField의 ariaLabel과 동일 패턴)
|
|
82
|
+
_jsx(Dropdown, { options: p.options ?? [], value: p.value ?? '', onChange: p.onChange ?? noop, block: useFullWidth, error: !!error, ariaLabel: label }));
|
|
83
|
+
right = error ? (_jsxs("div", { className: `panel-row__dropdown-error-wrap${useFullWidth ? ' panel-row__dropdown-error-wrap--full' : ''}`, children: [dropdowns, _jsx("span", { className: "caption-1 panel-row__error", role: "alert", children: error })] })) : dropdowns;
|
|
84
|
+
}
|
|
85
|
+
else if (variant === 'button') {
|
|
86
|
+
const p = props;
|
|
87
|
+
if (p.buttons?.length) {
|
|
88
|
+
right = (_jsx("div", { className: "panel-row__button-group", children: p.buttons.map((btn, i) => (_jsx(Button, { size: btn.btnSize ?? 40, variant: btn.btnVariant ?? 'default', disabled: btn.disabled, onClick: btn.onClick, children: btn.label }, i))) }));
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
right = (_jsx(Button, { size: p.btnSize ?? 40, variant: p.btnVariant ?? 'default', disabled: p.disabled, onClick: p.onClick, children: p.buttonLabel }));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (variant === 'text') {
|
|
95
|
+
const p = props;
|
|
96
|
+
right = _jsx("span", { className: `panel-row__text body-2${useFullWidth ? ' panel-row__text--full' : ''}`, style: p.valueColor ? { color: p.valueColor } : undefined, children: p.value });
|
|
97
|
+
}
|
|
98
|
+
else if (variant === 'input') {
|
|
99
|
+
const p = props;
|
|
100
|
+
right = (_jsxs("div", { className: `panel-row__input-wrap${useFullWidth ? ' panel-row__input-wrap--full' : ''}`, children: [_jsx(TextField, { value: p.value, onChange: p.onChange ?? noop, onKeyDown: p.onKeyDown, onEnter: p.onEnter, placeholder: p.placeholder, inputMode: p.inputMode, readOnly: p.readOnly, disabled: p.disabled, type: p.type, autoComplete: p.autoComplete, maxLength: p.maxLength, showCount: p.showCount, multiline: p.multiline, rows: p.rows, fullWidth: useFullWidth, error: error, ariaLabel: label, required: required }), p.helperText && _jsx("div", { className: "panel-row__helper caption-1", children: p.helperText })] }));
|
|
101
|
+
}
|
|
102
|
+
else if (variant === 'custom') {
|
|
103
|
+
const p = props;
|
|
104
|
+
right = p.children;
|
|
105
|
+
}
|
|
106
|
+
const isLabelFixed = variant === 'input' || variant === 'dropdown' || ((variant === 'text' || variant === 'custom') && useFullWidth);
|
|
107
|
+
// 위 children 모드와 동일한 키보드 처리 — 이쪽 경로에만 빠져 있어서,
|
|
108
|
+
// variant 모드에 onClick을 주면 조용히 마우스 전용이 된다(현재 사용처는 없지만 예방).
|
|
109
|
+
const handleKeyDown = (e) => {
|
|
110
|
+
if (!onClick)
|
|
111
|
+
return;
|
|
112
|
+
if (e.target !== e.currentTarget)
|
|
113
|
+
return;
|
|
114
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
onClick();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
return (_jsxs("div", { className: cx('panel-row', align === 'vertical' ? 'panel-row--vertical' : '', isLabelFixed ? 'panel-row--label-fixed' : '', variant === 'text' ? 'panel-row--text' : '', onClick ? 'panel-row--clickable' : ''), onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [icon != null && _jsx("div", { className: "panel-row__icon", children: icon }), _jsxs("div", { className: "panel-row__left", children: [toggleId ? (_jsxs("label", { htmlFor: toggleId, className: "panel-row__label body-3", children: [label, required && _jsx("span", { className: "required-mark", children: "*" })] })) : (_jsxs("div", { className: "panel-row__label body-3", children: [label, required && _jsx("span", { className: "required-mark", children: "*" })] })), sub && _jsx("div", { className: "panel-row__sub caption-1", children: sub })] }), _jsx("div", { className: "panel-row__right", children: right })] }));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 제목 아래 한 줄로 세울 곁정보 — 브랜드·착용형태.
|
|
123
|
+
*
|
|
124
|
+
* 제목으로 이미 쓴 값과 빈 값, 그리고 서로 같은 값을 걷어 낸다. 걷어 내지 않으면
|
|
125
|
+
* 브랜드도 형태도 '기타' 인 기기가 **'기타 · 기타'** 로 떴다(같은 말이 두 번).
|
|
126
|
+
*/
|
|
127
|
+
function 메타(d) {
|
|
128
|
+
const 제목 = d.productName || d.brand;
|
|
129
|
+
const 조각 = [d.brand, d.fitType].filter((v) => !!v && v !== 제목);
|
|
130
|
+
return [...new Set(조각)].join(' · ');
|
|
131
|
+
}
|
|
132
|
+
export function PanelRowDevice({ devices, onRelease }) {
|
|
133
|
+
const { t } = useTranslation();
|
|
134
|
+
const SIDES = ['좌', '우'];
|
|
135
|
+
return (_jsx("div", { className: "panel-row panel-row--device", children: SIDES.map(side => {
|
|
136
|
+
const d = devices[side];
|
|
137
|
+
const sideLabel = side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
|
|
138
|
+
return (_jsxs("div", { className: "panel-row__device-column", children: [_jsxs("div", { className: "panel-row__device-head", children: [_jsx("span", { className: "panel-row__device-side body-2b", children: sideLabel }), d && onRelease && (
|
|
139
|
+
/* 표의 줄 끝 단추와 같은 모양·같은 말 — 하는 일도 같다.
|
|
140
|
+
이력에서 지우는 것이 아니라 '지금 끼고 있음' 만 내린다
|
|
141
|
+
(잠시 안 씀·수리 보냄 같은 경우가 실제로 있다). */
|
|
142
|
+
_jsx(Button, { size: 32, type: "dot", icon: "x", ariaLabel: t('customers.device.unwear', '착용 해제'), showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: d.device }), 메타(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: 메타(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), _jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: `${d.wornYear}.${d.wornMonth}.${d.wornDay}` }) })] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: t('customers.device.notRegistered', '미등록') }))] }, side));
|
|
143
|
+
}) }));
|
|
144
|
+
}
|
|
@@ -28,11 +28,25 @@ export interface ConfirmPopupProps {
|
|
|
28
28
|
confirmDisabled?: boolean;
|
|
29
29
|
/** true면 취소 버튼을 숨긴다(단순 안내·닫기 전용). 기본 false */
|
|
30
30
|
hideCancel?: boolean;
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* 확인/취소 왼쪽에 놓는 부가 액션(삭제·연결 해제 등).
|
|
33
|
+
*
|
|
34
|
+
* 계약은 `onConfirm` 과 같다 — `close` 를 받고 `closeOnClick` 으로 닫힘을 고른다.
|
|
35
|
+
* **실패할 수 있는 비동기 동작**이 여기 오기 때문이다. 곁 단추는 파괴적인 일을
|
|
36
|
+
* 맡는 자리인데(주 단추는 '그대로 두기' 여야 한다), 예전에는 누르면 무조건 닫혀
|
|
37
|
+
* 실패해도 까닭을 그 자리에 못 보였다 — 사람은 눌렀는데 아무 말 없이 창이 사라진
|
|
38
|
+
* 것만 본다. 성공했을 때만 닫으려면 `closeOnClick: false` 로 두고 `close()` 를
|
|
39
|
+
* 직접 부른다.
|
|
40
|
+
*/
|
|
32
41
|
extraAction?: {
|
|
33
42
|
label: ReactNode;
|
|
34
43
|
variant?: ButtonVariant;
|
|
35
|
-
|
|
44
|
+
/** `close` 를 받는다 — 성공했을 때만 닫는 식으로 쓴다(async 지원). */
|
|
45
|
+
onClick: (close: () => void) => void | Promise<void>;
|
|
46
|
+
/** false면 자동으로 닫지 않음 — onClick이 받은 close로 닫힘 시점을 제어. 기본 true */
|
|
47
|
+
closeOnClick?: boolean;
|
|
48
|
+
/** 곁 단추만 따로 비활성 (처리 중 등). 팝업 전체의 busy 와는 별개로 얹힌다. */
|
|
49
|
+
disabled?: boolean;
|
|
36
50
|
};
|
|
37
51
|
/**
|
|
38
52
|
* 확인 클릭 시 실행. `close`로 직접 닫을 수 있음(async 지원).
|
|
@@ -26,8 +26,25 @@ export default function ConfirmPopup(props) {
|
|
|
26
26
|
...(extraAction ? [{
|
|
27
27
|
label: extraAction.label,
|
|
28
28
|
variant: extraAction.variant ?? 'default',
|
|
29
|
-
disabled: busy,
|
|
30
|
-
|
|
29
|
+
disabled: extraAction.disabled || busy,
|
|
30
|
+
// 확인 단추와 같은 재진입 방지를 받는다. 여기 오는 것은 파괴적이고 되돌릴 수
|
|
31
|
+
// 없는 일(삭제·연결 해제)이라, 두 번 눌려 두 번 나가면 첫 번째만 뜻이 있고
|
|
32
|
+
// 두 번째는 엉뚱한 실패로 돌아온다.
|
|
33
|
+
onClick: async ({ closeWithAnimation }) => {
|
|
34
|
+
if (busyRef.current)
|
|
35
|
+
return;
|
|
36
|
+
busyRef.current = true;
|
|
37
|
+
setBusy(true);
|
|
38
|
+
try {
|
|
39
|
+
await extraAction.onClick(closeWithAnimation);
|
|
40
|
+
if (extraAction.closeOnClick ?? true)
|
|
41
|
+
closeWithAnimation();
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
busyRef.current = false;
|
|
45
|
+
setBusy(false);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
31
48
|
}] : []),
|
|
32
49
|
...(hideCancel ? [] : [{
|
|
33
50
|
label: cancelLabel ?? t('common.cancel', '취소'),
|
|
@@ -1,66 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
-
import {
|
|
4
|
+
import { computePeaksFromUrl } from '../../utils/audioPeaks';
|
|
5
5
|
import './TrainingStepAudioPlayer.css';
|
|
6
6
|
/**
|
|
7
7
|
* waveform 바 최소 픽셀 — 렌더 높이가 이 값 이하이면 바를 그리지 않는다(문장·소음 공통).
|
|
8
8
|
* 그려지는 바는 항상 이 값을 넘는 높이만 표시하며(가로 폭은 BAR_WIDTH=3px), 그 이하는 숨긴다.
|
|
9
9
|
*/
|
|
10
10
|
const MIN_BAR_PX = 4;
|
|
11
|
-
// windowSeconds: 지정 시 파일의 처음 N초만 사용(소음을 문장 재생시간만큼만 그릴 때).
|
|
12
|
-
// 컴포넌트 상태를 참조하지 않는 순수 계산이라 모듈 스코프로 분리 — 매 렌더 재생성되지 않는다.
|
|
13
|
-
async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds) {
|
|
14
|
-
try {
|
|
15
|
-
const AudioCtxConstructor = window.AudioContext || window.webkitAudioContext;
|
|
16
|
-
const audioCtx = new AudioCtxConstructor();
|
|
17
|
-
const decoded = await audioCtx.decodeAudioData(arrayBuffer.slice(0));
|
|
18
|
-
const channelData = decoded.numberOfChannels > 0 ? decoded.getChannelData(0) : null;
|
|
19
|
-
if (!channelData)
|
|
20
|
-
return null;
|
|
21
|
-
const total = (windowSeconds && windowSeconds > 0)
|
|
22
|
-
? Math.min(channelData.length, Math.floor(windowSeconds * decoded.sampleRate))
|
|
23
|
-
: channelData.length;
|
|
24
|
-
const usableLen = Math.max(bars, total);
|
|
25
|
-
const blockSize = Math.floor(usableLen / bars) || 1;
|
|
26
|
-
const peaks = new Array(bars).fill(0).map((_, i) => {
|
|
27
|
-
const start = i * blockSize;
|
|
28
|
-
const end = Math.min(start + blockSize, usableLen);
|
|
29
|
-
let max = 0;
|
|
30
|
-
for (let j = start; j < end; j++) {
|
|
31
|
-
const v = Math.abs(channelData[j]);
|
|
32
|
-
if (v > max)
|
|
33
|
-
max = v;
|
|
34
|
-
}
|
|
35
|
-
return max;
|
|
36
|
-
});
|
|
37
|
-
try {
|
|
38
|
-
audioCtx.close && audioCtx.close();
|
|
39
|
-
}
|
|
40
|
-
catch (e) { }
|
|
41
|
-
// 절대 진폭(0~1, full-scale 기준) 반환 — 문장/소음이 같은 기준을 쓰게 해 dBFS 미터와 일치.
|
|
42
|
-
return peaks;
|
|
43
|
-
}
|
|
44
|
-
catch (e) {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
async function computePeaksFromUrl(url, bars, windowSeconds) {
|
|
49
|
-
try {
|
|
50
|
-
const resp = await fetch(url);
|
|
51
|
-
const arrayBuffer = await resp.arrayBuffer();
|
|
52
|
-
return await decodeAndComputePeaks(arrayBuffer, bars, windowSeconds);
|
|
53
|
-
}
|
|
54
|
-
catch (e) {
|
|
55
|
-
try {
|
|
56
|
-
const errMsg = getErrorMessage(e);
|
|
57
|
-
if (import.meta.env?.DEV)
|
|
58
|
-
console.log('WaveForm: computePeaksFromUrl failed', { url, err: errMsg });
|
|
59
|
-
}
|
|
60
|
-
catch (ee) { }
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
11
|
export default function WaveForm(props) {
|
|
65
12
|
const { src, segments, progress = 0, waveProgressRef, noiseSrc, noiseLevel = 0, noiseSpeed = 1, totalDuration = 0, mainVolume = 1, onReadyChange, showSoundWaveForm = true } = props;
|
|
66
13
|
const waveformRef = useRef(null);
|
|
@@ -1,18 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export type CoreNoiseType
|
|
3
|
-
/** @public */
|
|
4
|
-
export type CoreNoiseSettings = {
|
|
5
|
-
enabled: boolean;
|
|
6
|
-
type: CoreNoiseType;
|
|
7
|
-
/** 0–9. 0이면 소음 비활성화. (소음 음원을 +7dB 증폭한 뒤 상한을 4→9로 확장 — 실제 volume = level/9) */
|
|
8
|
-
level: number;
|
|
9
|
-
/** 소음 재생 속도(playbackRate). 0.75–1.25, 기본 1. */
|
|
10
|
-
speed: number;
|
|
11
|
-
/** 문장(메인) 재생 소리(볼륨). 0–1, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
|
|
12
|
-
mainVolume: number;
|
|
13
|
-
/** 문장(메인) 재생 속도(playbackRate). 0.5–1.5, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
|
|
14
|
-
mainSpeed: number;
|
|
15
|
-
};
|
|
1
|
+
import type { CoreNoiseSettings } from '../../types/training';
|
|
2
|
+
export type { CoreNoiseType, CoreNoiseSettings } from '../../types/training';
|
|
16
3
|
/** @public */
|
|
17
4
|
export declare const LEVEL_MAX = 9;
|
|
18
5
|
/** @public */
|
|
@@ -79,6 +79,12 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
|
|
|
79
79
|
.forEach(s => s.remove());
|
|
80
80
|
return `<div class="paper-wrap">${headHtml}${clone.outerHTML}</div>`;
|
|
81
81
|
}).join('\n');
|
|
82
|
+
/* 이 꾸러미의 CSS 는 전부 `.ihabdevteam-core-scope` **아래**에 산다(styles.css 스코핑).
|
|
83
|
+
위에서 `document.styleSheets` 를 통째로 베껴 넣어도, 새 문서의 body 에 이 클래스가
|
|
84
|
+
없으면 2천 개 넘는 규칙이 하나도 안 걸린다 — 글자는 브라우저 기본 크기로, 굵기는
|
|
85
|
+
normal 로 떨어진다. 화면은 앱 셸의 `<body class="ihabdevteam-core-scope">` 가
|
|
86
|
+
받쳐 주지만 이 문서는 손으로 짓는 것이라 그 밖에 있다. `data-theme` 을 html 에
|
|
87
|
+
얹는 것과 같은 결의 처리다. */
|
|
82
88
|
/* eslint-disable no-useless-escape -- 아래 문자열의 `<\/script>`는 실제 <script> 태그로 파싱되어 삽입되므로, 이스케이프가 없으면 바깥 <script> 태그가 조기 종료된다 */
|
|
83
89
|
return `<!DOCTYPE html>
|
|
84
90
|
<html data-theme="${theme}">
|
|
@@ -133,7 +139,7 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
|
|
|
133
139
|
}
|
|
134
140
|
</style>
|
|
135
141
|
</head>
|
|
136
|
-
<body>
|
|
142
|
+
<body class="ihabdevteam-core-scope">
|
|
137
143
|
${wraps}
|
|
138
144
|
<button class="button button48 button48--dot button--variant-default button--align-center new-window-close" onclick="window.close()"><span class="button__icon button__icon--medium"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="button__icon-svg"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></span></button>
|
|
139
145
|
<script>
|
|
@@ -1,28 +1,10 @@
|
|
|
1
|
-
import { type CSSProperties, type RefObject } from 'react';
|
|
2
|
-
export interface UseAnchoredMenuOptions {
|
|
3
|
-
/** 트리거의 getBoundingClientRect()로부터 메뉴에 적용할 style을 계산 */
|
|
4
|
-
computeStyle: (triggerRect: DOMRect) => CSSProperties;
|
|
5
|
-
/** 열려있는 동안 스크롤 발생 시 동작 (메뉴 내부 스크롤은 항상 무시) — 기본 'close' */
|
|
6
|
-
onScroll?: 'close' | 'reposition' | 'ignore';
|
|
7
|
-
/** 열려있는 동안 윈도우 리사이즈 발생 시 동작 — 기본 'close' */
|
|
8
|
-
onResize?: 'close' | 'reposition' | 'ignore';
|
|
9
|
-
/** 바깥클릭 판정에서 제외할 CSS selector (예: 메뉴 안에 중첩 포탈되는 다른 드롭다운) */
|
|
10
|
-
ignoreOutsideClickSelector?: string;
|
|
11
|
-
}
|
|
12
|
-
export interface UseAnchoredMenuResult<Trigger extends HTMLElement, Menu extends HTMLElement> {
|
|
13
|
-
open: boolean;
|
|
14
|
-
setOpen: (open: boolean) => void;
|
|
15
|
-
toggle: () => void;
|
|
16
|
-
close: () => void;
|
|
17
|
-
/** 트리거 기준으로 계산된 메뉴 style — position:'fixed' 등은 computeStyle이 직접 반환 */
|
|
18
|
-
style: CSSProperties;
|
|
19
|
-
triggerRef: RefObject<Trigger | null>;
|
|
20
|
-
menuRef: RefObject<Menu | null>;
|
|
21
|
-
}
|
|
22
1
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
2
|
+
* 옛 자리 — 실제 몸은 `hooks/useAnchoredMenu.ts` 에 있다.
|
|
3
|
+
*
|
|
4
|
+
* 네 컴포넌트(Dropdown · DatePicker · Header · BottomNav)가 함께 쓰는 훅이라
|
|
5
|
+
* 한 컴포넌트 곁이 아니라 `hooks/` 가 제자리다. 다만
|
|
6
|
+
* `@ihabdevteam/core/components/useAnchoredMenu` 로 가져가는 곳이 있을 수 있어
|
|
7
|
+
* 이 자리를 남겨 둔다.
|
|
27
8
|
*/
|
|
28
|
-
export
|
|
9
|
+
export { useAnchoredMenu } from '../hooks/useAnchoredMenu';
|
|
10
|
+
export type { UseAnchoredMenuOptions, UseAnchoredMenuResult } from '../hooks/useAnchoredMenu';
|
|
@@ -1,72 +1,9 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
1
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* 옛 자리 — 실제 몸은 `hooks/useAnchoredMenu.ts` 에 있다.
|
|
3
|
+
*
|
|
4
|
+
* 네 컴포넌트(Dropdown · DatePicker · Header · BottomNav)가 함께 쓰는 훅이라
|
|
5
|
+
* 한 컴포넌트 곁이 아니라 `hooks/` 가 제자리다. 다만
|
|
6
|
+
* `@ihabdevteam/core/components/useAnchoredMenu` 로 가져가는 곳이 있을 수 있어
|
|
7
|
+
* 이 자리를 남겨 둔다.
|
|
8
8
|
*/
|
|
9
|
-
export
|
|
10
|
-
const [open, setOpen] = useState(false);
|
|
11
|
-
const [style, setStyle] = useState({});
|
|
12
|
-
const triggerRef = useRef(null);
|
|
13
|
-
const menuRef = useRef(null);
|
|
14
|
-
const recalc = useCallback(() => {
|
|
15
|
-
if (!triggerRef.current)
|
|
16
|
-
return;
|
|
17
|
-
setStyle(computeStyle(triggerRef.current.getBoundingClientRect()));
|
|
18
|
-
}, [computeStyle]);
|
|
19
|
-
useEffect(() => {
|
|
20
|
-
if (!open)
|
|
21
|
-
return;
|
|
22
|
-
recalc();
|
|
23
|
-
const handleOutsideClick = (e) => {
|
|
24
|
-
const target = e.target;
|
|
25
|
-
if (ignoreOutsideClickSelector && target.closest?.(ignoreOutsideClickSelector))
|
|
26
|
-
return;
|
|
27
|
-
if (triggerRef.current?.contains(target))
|
|
28
|
-
return;
|
|
29
|
-
if (menuRef.current?.contains(target))
|
|
30
|
-
return;
|
|
31
|
-
setOpen(false);
|
|
32
|
-
};
|
|
33
|
-
document.addEventListener('mousedown', handleOutsideClick);
|
|
34
|
-
const handleScroll = (e) => {
|
|
35
|
-
if (menuRef.current?.contains(e.target))
|
|
36
|
-
return;
|
|
37
|
-
if (onScroll === 'reposition')
|
|
38
|
-
recalc();
|
|
39
|
-
else if (onScroll === 'close')
|
|
40
|
-
setOpen(false);
|
|
41
|
-
};
|
|
42
|
-
if (onScroll !== 'ignore')
|
|
43
|
-
window.addEventListener('scroll', handleScroll, true);
|
|
44
|
-
const handleResize = () => {
|
|
45
|
-
if (onResize === 'reposition')
|
|
46
|
-
recalc();
|
|
47
|
-
else if (onResize === 'close')
|
|
48
|
-
setOpen(false);
|
|
49
|
-
};
|
|
50
|
-
if (onResize !== 'ignore')
|
|
51
|
-
window.addEventListener('resize', handleResize);
|
|
52
|
-
// ESC로 닫기 — 이 훅을 쓰는 컴포넌트가 자체 키보드 핸들링(예: Dropdown의 화살표 탐색)을
|
|
53
|
-
// 따로 두더라도 무해하게 중복 호출될 뿐이므로, 여기서 공통으로 처리해 DatePicker/BottomNav
|
|
54
|
-
// 더보기 메뉴처럼 자체 ESC 처리가 없던 소비자도 함께 혜택을 받게 한다.
|
|
55
|
-
const handleKeyDown = (e) => {
|
|
56
|
-
if (e.key === 'Escape')
|
|
57
|
-
setOpen(false);
|
|
58
|
-
};
|
|
59
|
-
document.addEventListener('keydown', handleKeyDown);
|
|
60
|
-
return () => {
|
|
61
|
-
document.removeEventListener('mousedown', handleOutsideClick);
|
|
62
|
-
if (onScroll !== 'ignore')
|
|
63
|
-
window.removeEventListener('scroll', handleScroll, true);
|
|
64
|
-
if (onResize !== 'ignore')
|
|
65
|
-
window.removeEventListener('resize', handleResize);
|
|
66
|
-
document.removeEventListener('keydown', handleKeyDown);
|
|
67
|
-
};
|
|
68
|
-
}, [open, recalc, onScroll, onResize, ignoreOutsideClickSelector]);
|
|
69
|
-
const toggle = useCallback(() => setOpen(o => !o), []);
|
|
70
|
-
const close = useCallback(() => setOpen(false), []);
|
|
71
|
-
return { open, setOpen, toggle, close, style, triggerRef, menuRef };
|
|
72
|
-
}
|
|
9
|
+
export { useAnchoredMenu } from '../hooks/useAnchoredMenu';
|
|
@@ -1,54 +1,11 @@
|
|
|
1
|
-
import type { SpeakerPos } from '../types/audio';
|
|
2
1
|
/**
|
|
3
|
-
*
|
|
4
|
-
* SoundCompass 오케스트레이션 참조 훅.
|
|
2
|
+
* 옛 자리 — 실제 몸은 `hooks/useSoundCompassExam.ts` 에 있다.
|
|
5
3
|
*
|
|
6
|
-
* SoundCompass
|
|
7
|
-
* 이 훅이
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* 다른 동작이 필요하면 이 훅을 복사·수정하거나, SoundCompass의 제어 props를
|
|
12
|
-
* 직접 구동해도 된다.
|
|
4
|
+
* 이것은 **머리 없는 오케스트레이터**다. `SoundCompass` 는 시키는 대로 그리기만 하고
|
|
5
|
+
* 순서·타이밍·점수·진행은 이 훅이 진다(SoundCompass.md 에 그렇게 적혀 있다).
|
|
6
|
+
* 코어의 어느 컴포넌트도 이것을 쓰지 않는다 — 소비자가 쓴다. 그러니 컴포넌트 곁이
|
|
7
|
+
* 아니라 `hooks/` 가 제자리다. 소리방향성이
|
|
8
|
+
* `@ihabdevteam/core/components/useSoundCompassExam` 로 가져가고 있어 이 자리를 남긴다.
|
|
13
9
|
*/
|
|
14
|
-
export
|
|
15
|
-
export
|
|
16
|
-
/** 동작 모드. 기본 'playback' */
|
|
17
|
-
mode?: SoundCompassMode;
|
|
18
|
-
/** 마운트/리셋 시 자동 시작. 기본 false */
|
|
19
|
-
autoStart?: boolean;
|
|
20
|
-
/** 스피커 소리를 재생해야 할 때 호출 (오디오는 호출자 담당) */
|
|
21
|
-
onPlaySpeaker?: (speaker: SpeakerPos) => void;
|
|
22
|
-
/** 모든 문제/재생 완료 시 호출 */
|
|
23
|
-
onComplete?: (result: {
|
|
24
|
-
score: number;
|
|
25
|
-
total: number;
|
|
26
|
-
}) => void;
|
|
27
|
-
/** 문제당 소리 재생 후 선택 대기까지 지연(ms). 기본 1100 */
|
|
28
|
-
playMs?: number;
|
|
29
|
-
/** practice 모드 피드백 표시 시간(ms). 기본 1500 */
|
|
30
|
-
feedbackMs?: number;
|
|
31
|
-
}
|
|
32
|
-
export type SoundCompassPhase = 'idle' | 'playing' | 'waiting' | 'feedback' | 'done';
|
|
33
|
-
export interface SoundCompassExam {
|
|
34
|
-
/** `<SoundCompass {...bind} />` 로 펼쳐 넣는 제어 props */
|
|
35
|
-
bind: {
|
|
36
|
-
activeSpeakerId: string | null;
|
|
37
|
-
playedIds: string[];
|
|
38
|
-
selectedId: string | null;
|
|
39
|
-
answerId: string | null;
|
|
40
|
-
showFeedback: boolean;
|
|
41
|
-
clickable: boolean;
|
|
42
|
-
onSelectSpeaker: (id: string) => void;
|
|
43
|
-
};
|
|
44
|
-
index: number;
|
|
45
|
-
total: number;
|
|
46
|
-
score: number;
|
|
47
|
-
phase: SoundCompassPhase;
|
|
48
|
-
done: boolean;
|
|
49
|
-
/** 시작(또는 재시작) */
|
|
50
|
-
start: () => void;
|
|
51
|
-
/** idle 상태로 리셋 */
|
|
52
|
-
reset: () => void;
|
|
53
|
-
}
|
|
54
|
-
export declare function useSoundCompassExam(speakers: SpeakerPos[], { mode, autoStart, onPlaySpeaker, onComplete, playMs, feedbackMs, }?: UseSoundCompassExamOptions): SoundCompassExam;
|
|
10
|
+
export { useSoundCompassExam } from '../hooks/useSoundCompassExam';
|
|
11
|
+
export type { SoundCompassMode, SoundCompassPhase, SoundCompassExam, UseSoundCompassExamOptions } from '../hooks/useSoundCompassExam';
|