@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
|
@@ -1,128 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const [score, setScore] = useState(0);
|
|
12
|
-
// updater 밖에서 최신 score를 읽기 위한 미러 — setState updater 안에서 onComplete를
|
|
13
|
-
// 호출하면(부수효과) StrictMode에서 updater가 2회 실행돼 완료 콜백(점수 기록)이 중복된다.
|
|
14
|
-
const scoreRef = useRef(0);
|
|
15
|
-
const setScoreTracked = useCallback((next) => {
|
|
16
|
-
scoreRef.current = typeof next === 'function' ? next(scoreRef.current) : next;
|
|
17
|
-
setScore(scoreRef.current);
|
|
18
|
-
}, []);
|
|
19
|
-
// 최신 콜백/타이머 참조 (effect 재실행 없이 사용)
|
|
20
|
-
const onPlayRef = useRef(onPlaySpeaker);
|
|
21
|
-
const onDoneRef = useRef(onComplete);
|
|
22
|
-
useEffect(() => { onPlayRef.current = onPlaySpeaker; onDoneRef.current = onComplete; }, [onPlaySpeaker, onComplete]);
|
|
23
|
-
const timers = useRef([]);
|
|
24
|
-
const clearTimers = () => { timers.current.forEach(clearTimeout); timers.current = []; };
|
|
25
|
-
const after = (ms, fn) => { timers.current.push(setTimeout(fn, ms)); };
|
|
26
|
-
useEffect(() => () => clearTimers(), []);
|
|
27
|
-
const answerId = queue[index]?.id ?? null;
|
|
28
|
-
// ── playback: 모든 스피커를 순서대로 ──────────────────────────────────
|
|
29
|
-
const runPlayback = useCallback(() => {
|
|
30
|
-
clearTimers();
|
|
31
|
-
setPhase('playing');
|
|
32
|
-
setPlayedIds([]);
|
|
33
|
-
setSelectedId(null);
|
|
34
|
-
setScoreTracked(0);
|
|
35
|
-
let acc = 0;
|
|
36
|
-
speakers.forEach((sp, i) => {
|
|
37
|
-
after(acc, () => { setIndex(i); setActiveId(sp.id); onPlayRef.current?.(sp); });
|
|
38
|
-
after(acc + 1000, () => { setActiveId(null); setPlayedIds(prev => [...prev, sp.id]); });
|
|
39
|
-
acc += 1300;
|
|
40
|
-
});
|
|
41
|
-
after(acc, () => { setPhase('done'); onDoneRef.current?.({ score: 0, total: speakers.length }); });
|
|
42
|
-
}, [speakers, setScoreTracked]);
|
|
43
|
-
// ── practice / exam: 문제 재생 → 선택 대기 ────────────────────────────
|
|
44
|
-
const playQuestion = useCallback((q, i) => {
|
|
45
|
-
if (i >= q.length) {
|
|
46
|
-
setPhase('done');
|
|
47
|
-
onDoneRef.current?.({ score: scoreRef.current, total: q.length });
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
setIndex(i);
|
|
51
|
-
setSelectedId(null);
|
|
52
|
-
setPhase('playing');
|
|
53
|
-
setActiveId(q[i].id);
|
|
54
|
-
onPlayRef.current?.(q[i]);
|
|
55
|
-
after(playMs, () => { setActiveId(null); setPhase('waiting'); });
|
|
56
|
-
}, [playMs]);
|
|
57
|
-
const runExam = useCallback(() => {
|
|
58
|
-
clearTimers();
|
|
59
|
-
const q = shuffle(speakers);
|
|
60
|
-
setQueue(q);
|
|
61
|
-
setScoreTracked(0);
|
|
62
|
-
setPlayedIds([]);
|
|
63
|
-
playQuestion(q, 0);
|
|
64
|
-
}, [speakers, playQuestion, setScoreTracked]);
|
|
65
|
-
const start = useCallback(() => {
|
|
66
|
-
if (mode === 'playback') {
|
|
67
|
-
setQueue(speakers);
|
|
68
|
-
runPlayback();
|
|
69
|
-
}
|
|
70
|
-
else
|
|
71
|
-
runExam();
|
|
72
|
-
}, [mode, speakers, runPlayback, runExam]);
|
|
73
|
-
const reset = useCallback(() => {
|
|
74
|
-
clearTimers();
|
|
75
|
-
setPhase('idle');
|
|
76
|
-
setActiveId(null);
|
|
77
|
-
setPlayedIds([]);
|
|
78
|
-
setSelectedId(null);
|
|
79
|
-
setIndex(0);
|
|
80
|
-
setScoreTracked(0);
|
|
81
|
-
setQueue([]);
|
|
82
|
-
}, [setScoreTracked]);
|
|
83
|
-
// autoStart / 입력 변경 시 재시작 — start/reset이 여러 상태를 동기 초기화하지만 의도된 동작이다
|
|
84
|
-
/* eslint-disable react-hooks/set-state-in-effect */
|
|
85
|
-
useEffect(() => {
|
|
86
|
-
if (autoStart)
|
|
87
|
-
start();
|
|
88
|
-
else
|
|
89
|
-
reset();
|
|
90
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
91
|
-
}, [mode, speakers.length, autoStart]);
|
|
92
|
-
/* eslint-enable react-hooks/set-state-in-effect */
|
|
93
|
-
const onSelectSpeaker = useCallback((id) => {
|
|
94
|
-
if (phase !== 'waiting' || mode === 'playback')
|
|
95
|
-
return;
|
|
96
|
-
setSelectedId(id);
|
|
97
|
-
const correct = id === queue[index]?.id;
|
|
98
|
-
if (correct)
|
|
99
|
-
setScoreTracked(s => s + 1);
|
|
100
|
-
if (mode === 'exam') {
|
|
101
|
-
setPhase('playing');
|
|
102
|
-
after(400, () => playQuestion(queue, index + 1));
|
|
103
|
-
}
|
|
104
|
-
else {
|
|
105
|
-
setPhase('feedback');
|
|
106
|
-
after(feedbackMs, () => playQuestion(queue, index + 1));
|
|
107
|
-
}
|
|
108
|
-
}, [phase, mode, queue, index, playQuestion, feedbackMs, setScoreTracked]);
|
|
109
|
-
const total = mode === 'playback' ? speakers.length : queue.length;
|
|
110
|
-
return {
|
|
111
|
-
bind: {
|
|
112
|
-
activeSpeakerId: activeId,
|
|
113
|
-
playedIds,
|
|
114
|
-
selectedId,
|
|
115
|
-
answerId,
|
|
116
|
-
showFeedback: phase === 'feedback',
|
|
117
|
-
clickable: phase === 'waiting',
|
|
118
|
-
onSelectSpeaker,
|
|
119
|
-
},
|
|
120
|
-
index,
|
|
121
|
-
total,
|
|
122
|
-
score,
|
|
123
|
-
phase,
|
|
124
|
-
done: phase === 'done',
|
|
125
|
-
start,
|
|
126
|
-
reset,
|
|
127
|
-
};
|
|
128
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 옛 자리 — 실제 몸은 `hooks/useSoundCompassExam.ts` 에 있다.
|
|
3
|
+
*
|
|
4
|
+
* 이것은 **머리 없는 오케스트레이터**다. `SoundCompass` 는 시키는 대로 그리기만 하고
|
|
5
|
+
* 순서·타이밍·점수·진행은 이 훅이 진다(SoundCompass.md 에 그렇게 적혀 있다).
|
|
6
|
+
* 코어의 어느 컴포넌트도 이것을 쓰지 않는다 — 소비자가 쓴다. 그러니 컴포넌트 곁이
|
|
7
|
+
* 아니라 `hooks/` 가 제자리다. 소리방향성이
|
|
8
|
+
* `@ihabdevteam/core/components/useSoundCompassExam` 로 가져가고 있어 이 자리를 남긴다.
|
|
9
|
+
*/
|
|
10
|
+
export { useSoundCompassExam } from '../hooks/useSoundCompassExam';
|
|
@@ -63,8 +63,8 @@ export { default as PassCardBgLeftColorGrid, passCardBgLeftColorGridTheme } from
|
|
|
63
63
|
export type { PassCardBgLeftColorGridProps } from './components/PassCard/PassCardBgLeftColorGrid';
|
|
64
64
|
export { default as SoundCompass } from './components/SoundCompass';
|
|
65
65
|
export type { SoundCompassProps, SpeakerState } from './components/SoundCompass';
|
|
66
|
-
export { useSoundCompassExam } from './
|
|
67
|
-
export type { SoundCompassMode, SoundCompassPhase, SoundCompassExam, UseSoundCompassExamOptions } from './
|
|
66
|
+
export { useSoundCompassExam } from './hooks/useSoundCompassExam';
|
|
67
|
+
export type { SoundCompassMode, SoundCompassPhase, SoundCompassExam, UseSoundCompassExamOptions } from './hooks/useSoundCompassExam';
|
|
68
68
|
export type { SpeakerPos } from './types/audio';
|
|
69
69
|
export { default as SideNav } from './components/SideNav';
|
|
70
70
|
export { default as Button } from './components/Button';
|
|
@@ -126,8 +126,8 @@ export type { TextFieldProps } from './components/TextField';
|
|
|
126
126
|
export { useComboboxSuggestions } from './components/useComboboxSuggestions';
|
|
127
127
|
export { default as Dropdown } from './components/Dropdown';
|
|
128
128
|
export type { DropdownProps, DropdownOption } from './components/Dropdown';
|
|
129
|
-
export { useAnchoredMenu } from './
|
|
130
|
-
export type { UseAnchoredMenuOptions, UseAnchoredMenuResult } from './
|
|
129
|
+
export { useAnchoredMenu } from './hooks/useAnchoredMenu';
|
|
130
|
+
export type { UseAnchoredMenuOptions, UseAnchoredMenuResult } from './hooks/useAnchoredMenu';
|
|
131
131
|
export { default as Toggle } from './components/Toggle';
|
|
132
132
|
export type { ToggleProps } from './components/Toggle';
|
|
133
133
|
export { default as Checkbox } from './components/Checkbox';
|
|
@@ -49,7 +49,7 @@ export { default as PassCardBgTwoCircle, passCardBgTwoCircleTheme } from './comp
|
|
|
49
49
|
export { default as PassCardBgIHAB, passCardBgIhabTheme } from './components/PassCard/PassCardBgIHAB';
|
|
50
50
|
export { default as PassCardBgLeftColorGrid, passCardBgLeftColorGridTheme } from './components/PassCard/PassCardBgLeftColorGrid';
|
|
51
51
|
export { default as SoundCompass } from './components/SoundCompass';
|
|
52
|
-
export { useSoundCompassExam } from './
|
|
52
|
+
export { useSoundCompassExam } from './hooks/useSoundCompassExam';
|
|
53
53
|
export { default as SideNav } from './components/SideNav';
|
|
54
54
|
export { default as Button } from './components/Button';
|
|
55
55
|
export { default as Badge } from './components/Badge';
|
|
@@ -83,7 +83,7 @@ export { default as SegmentedControl } from './components/SegmentedControl';
|
|
|
83
83
|
export { default as TextField } from './components/TextField';
|
|
84
84
|
export { useComboboxSuggestions } from './components/useComboboxSuggestions';
|
|
85
85
|
export { default as Dropdown } from './components/Dropdown';
|
|
86
|
-
export { useAnchoredMenu } from './
|
|
86
|
+
export { useAnchoredMenu } from './hooks/useAnchoredMenu';
|
|
87
87
|
export { default as Toggle } from './components/Toggle';
|
|
88
88
|
export { default as Checkbox } from './components/Checkbox';
|
|
89
89
|
export { default as BulkBar } from './components/BulkBar';
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
/**
|
|
23
|
+
* 트리거 요소에 fixed 포지션으로 앵커링되는 메뉴/드롭다운/팝오버의 공용 로직.
|
|
24
|
+
* 열림상태 + 위치 계산 + 바깥클릭/스크롤/리사이즈 처리를 담당하고, 렌더링(포탈 등)은
|
|
25
|
+
* 사용하는 쪽에서 담당한다. Dropdown, DatePicker, BottomNav의 "더보기" 메뉴가 이 훅을 쓴다.
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
export declare function useAnchoredMenu<Trigger extends HTMLElement = HTMLElement, Menu extends HTMLElement = HTMLElement>({ computeStyle, onScroll, onResize, ignoreOutsideClickSelector, }: UseAnchoredMenuOptions): UseAnchoredMenuResult<Trigger, Menu>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* 트리거 요소에 fixed 포지션으로 앵커링되는 메뉴/드롭다운/팝오버의 공용 로직.
|
|
5
|
+
* 열림상태 + 위치 계산 + 바깥클릭/스크롤/리사이즈 처리를 담당하고, 렌더링(포탈 등)은
|
|
6
|
+
* 사용하는 쪽에서 담당한다. Dropdown, DatePicker, BottomNav의 "더보기" 메뉴가 이 훅을 쓴다.
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
export function useAnchoredMenu({ computeStyle, onScroll = 'close', onResize = 'close', ignoreOutsideClickSelector, }) {
|
|
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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { SpeakerPos } from '../types/audio';
|
|
2
|
+
/**
|
|
3
|
+
* @public
|
|
4
|
+
* SoundCompass 오케스트레이션 참조 훅.
|
|
5
|
+
*
|
|
6
|
+
* SoundCompass 자체는 제어형(표현) 컴포넌트라 순서·타이밍·점수·진행을 갖지 않는다.
|
|
7
|
+
* 이 훅이 그 "부모(검사 컨트롤러)" 역할의 기본 구현을 제공한다 —
|
|
8
|
+
* 큐 셔플, 문제 재생 타이밍, 선택 수집, 피드백, 진행/점수, 완료 콜백.
|
|
9
|
+
*
|
|
10
|
+
* 반환한 `bind`를 `<SoundCompass {...exam.bind} />`처럼 펼쳐 넣으면 된다.
|
|
11
|
+
* 다른 동작이 필요하면 이 훅을 복사·수정하거나, SoundCompass의 제어 props를
|
|
12
|
+
* 직접 구동해도 된다.
|
|
13
|
+
*/
|
|
14
|
+
export type SoundCompassMode = 'playback' | 'practice' | 'exam';
|
|
15
|
+
export interface UseSoundCompassExamOptions {
|
|
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;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { shuffle } from '../utils/array';
|
|
4
|
+
export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = false, onPlaySpeaker, onComplete, playMs = 1100, feedbackMs = 1500, } = {}) {
|
|
5
|
+
const [queue, setQueue] = useState([]);
|
|
6
|
+
const [index, setIndex] = useState(0);
|
|
7
|
+
const [phase, setPhase] = useState('idle');
|
|
8
|
+
const [activeId, setActiveId] = useState(null);
|
|
9
|
+
const [playedIds, setPlayedIds] = useState([]);
|
|
10
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
11
|
+
const [score, setScore] = useState(0);
|
|
12
|
+
// updater 밖에서 최신 score를 읽기 위한 미러 — setState updater 안에서 onComplete를
|
|
13
|
+
// 호출하면(부수효과) StrictMode에서 updater가 2회 실행돼 완료 콜백(점수 기록)이 중복된다.
|
|
14
|
+
const scoreRef = useRef(0);
|
|
15
|
+
const setScoreTracked = useCallback((next) => {
|
|
16
|
+
scoreRef.current = typeof next === 'function' ? next(scoreRef.current) : next;
|
|
17
|
+
setScore(scoreRef.current);
|
|
18
|
+
}, []);
|
|
19
|
+
// 최신 콜백/타이머 참조 (effect 재실행 없이 사용)
|
|
20
|
+
const onPlayRef = useRef(onPlaySpeaker);
|
|
21
|
+
const onDoneRef = useRef(onComplete);
|
|
22
|
+
useEffect(() => { onPlayRef.current = onPlaySpeaker; onDoneRef.current = onComplete; }, [onPlaySpeaker, onComplete]);
|
|
23
|
+
const timers = useRef([]);
|
|
24
|
+
const clearTimers = () => { timers.current.forEach(clearTimeout); timers.current = []; };
|
|
25
|
+
const after = (ms, fn) => { timers.current.push(setTimeout(fn, ms)); };
|
|
26
|
+
useEffect(() => () => clearTimers(), []);
|
|
27
|
+
const answerId = queue[index]?.id ?? null;
|
|
28
|
+
// ── playback: 모든 스피커를 순서대로 ──────────────────────────────────
|
|
29
|
+
const runPlayback = useCallback(() => {
|
|
30
|
+
clearTimers();
|
|
31
|
+
setPhase('playing');
|
|
32
|
+
setPlayedIds([]);
|
|
33
|
+
setSelectedId(null);
|
|
34
|
+
setScoreTracked(0);
|
|
35
|
+
let acc = 0;
|
|
36
|
+
speakers.forEach((sp, i) => {
|
|
37
|
+
after(acc, () => { setIndex(i); setActiveId(sp.id); onPlayRef.current?.(sp); });
|
|
38
|
+
after(acc + 1000, () => { setActiveId(null); setPlayedIds(prev => [...prev, sp.id]); });
|
|
39
|
+
acc += 1300;
|
|
40
|
+
});
|
|
41
|
+
after(acc, () => { setPhase('done'); onDoneRef.current?.({ score: 0, total: speakers.length }); });
|
|
42
|
+
}, [speakers, setScoreTracked]);
|
|
43
|
+
// ── practice / exam: 문제 재생 → 선택 대기 ────────────────────────────
|
|
44
|
+
const playQuestion = useCallback((q, i) => {
|
|
45
|
+
if (i >= q.length) {
|
|
46
|
+
setPhase('done');
|
|
47
|
+
onDoneRef.current?.({ score: scoreRef.current, total: q.length });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
setIndex(i);
|
|
51
|
+
setSelectedId(null);
|
|
52
|
+
setPhase('playing');
|
|
53
|
+
setActiveId(q[i].id);
|
|
54
|
+
onPlayRef.current?.(q[i]);
|
|
55
|
+
after(playMs, () => { setActiveId(null); setPhase('waiting'); });
|
|
56
|
+
}, [playMs]);
|
|
57
|
+
const runExam = useCallback(() => {
|
|
58
|
+
clearTimers();
|
|
59
|
+
const q = shuffle(speakers);
|
|
60
|
+
setQueue(q);
|
|
61
|
+
setScoreTracked(0);
|
|
62
|
+
setPlayedIds([]);
|
|
63
|
+
playQuestion(q, 0);
|
|
64
|
+
}, [speakers, playQuestion, setScoreTracked]);
|
|
65
|
+
const start = useCallback(() => {
|
|
66
|
+
if (mode === 'playback') {
|
|
67
|
+
setQueue(speakers);
|
|
68
|
+
runPlayback();
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
runExam();
|
|
72
|
+
}, [mode, speakers, runPlayback, runExam]);
|
|
73
|
+
const reset = useCallback(() => {
|
|
74
|
+
clearTimers();
|
|
75
|
+
setPhase('idle');
|
|
76
|
+
setActiveId(null);
|
|
77
|
+
setPlayedIds([]);
|
|
78
|
+
setSelectedId(null);
|
|
79
|
+
setIndex(0);
|
|
80
|
+
setScoreTracked(0);
|
|
81
|
+
setQueue([]);
|
|
82
|
+
}, [setScoreTracked]);
|
|
83
|
+
// autoStart / 입력 변경 시 재시작 — start/reset이 여러 상태를 동기 초기화하지만 의도된 동작이다
|
|
84
|
+
/* eslint-disable react-hooks/set-state-in-effect */
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (autoStart)
|
|
87
|
+
start();
|
|
88
|
+
else
|
|
89
|
+
reset();
|
|
90
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
91
|
+
}, [mode, speakers.length, autoStart]);
|
|
92
|
+
/* eslint-enable react-hooks/set-state-in-effect */
|
|
93
|
+
const onSelectSpeaker = useCallback((id) => {
|
|
94
|
+
if (phase !== 'waiting' || mode === 'playback')
|
|
95
|
+
return;
|
|
96
|
+
setSelectedId(id);
|
|
97
|
+
const correct = id === queue[index]?.id;
|
|
98
|
+
if (correct)
|
|
99
|
+
setScoreTracked(s => s + 1);
|
|
100
|
+
if (mode === 'exam') {
|
|
101
|
+
setPhase('playing');
|
|
102
|
+
after(400, () => playQuestion(queue, index + 1));
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
setPhase('feedback');
|
|
106
|
+
after(feedbackMs, () => playQuestion(queue, index + 1));
|
|
107
|
+
}
|
|
108
|
+
}, [phase, mode, queue, index, playQuestion, feedbackMs, setScoreTracked]);
|
|
109
|
+
const total = mode === 'playback' ? speakers.length : queue.length;
|
|
110
|
+
return {
|
|
111
|
+
bind: {
|
|
112
|
+
activeSpeakerId: activeId,
|
|
113
|
+
playedIds,
|
|
114
|
+
selectedId,
|
|
115
|
+
answerId,
|
|
116
|
+
showFeedback: phase === 'feedback',
|
|
117
|
+
clickable: phase === 'waiting',
|
|
118
|
+
onSelectSpeaker,
|
|
119
|
+
},
|
|
120
|
+
index,
|
|
121
|
+
total,
|
|
122
|
+
score,
|
|
123
|
+
phase,
|
|
124
|
+
done: phase === 'done',
|
|
125
|
+
start,
|
|
126
|
+
reset,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -26,6 +26,7 @@ export declare function useWordAttributeFilterKeys(): WordAttributeFilterKeys;
|
|
|
26
26
|
export declare function buildGradeSection(t: TFunction, keys: WordAttributeFilterKeys, opts?: {
|
|
27
27
|
allLabel?: string;
|
|
28
28
|
labelAction?: ReactNode;
|
|
29
|
+
labelInfo?: FilterPanelSection['labelInfo'];
|
|
29
30
|
}): FilterPanelSection;
|
|
30
31
|
/** @public 품사 FilterPanelSection — 세 화면 공용. */
|
|
31
32
|
export declare function buildPartOfSpeechSection(keys: WordAttributeFilterKeys): FilterPanelSection;
|
|
@@ -42,6 +43,7 @@ export declare function buildSyllableSection(t: TFunction, keys: WordAttributeFi
|
|
|
42
43
|
export declare function buildSelectSection(t: TFunction, opts?: {
|
|
43
44
|
includeStructure?: boolean;
|
|
44
45
|
labelAction?: ReactNode;
|
|
46
|
+
labelInfo?: FilterPanelSection['labelInfo'];
|
|
45
47
|
}): FilterPanelSection;
|
|
46
48
|
/**
|
|
47
49
|
* @public 위치(어두/어중/어말) FilterPanelSection 빌더 — WordSearchPage·단어훈련 만들기(메인 앱)가 공용.
|
|
@@ -37,6 +37,7 @@ export function buildGradeSection(t, keys, opts) {
|
|
|
37
37
|
allOption: true,
|
|
38
38
|
allLabel: opts?.allLabel,
|
|
39
39
|
labelAction: opts?.labelAction,
|
|
40
|
+
labelInfo: opts?.labelInfo,
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
/** @public 품사 FilterPanelSection — 세 화면 공용. */
|
|
@@ -72,7 +73,7 @@ export function buildSelectSection(t, opts) {
|
|
|
72
73
|
const items = [t('wordPairs.filter.onlyHasImage', '이미지 있는 단어만')];
|
|
73
74
|
if (opts?.includeStructure)
|
|
74
75
|
items.push(t('wordPairs.filter.onlySameStructure', '음소 구조 같은 단어만'));
|
|
75
|
-
return { label: t('wordPairs.filter.selectLabel', '선택'), items, multiple: true, labelAction: opts?.labelAction };
|
|
76
|
+
return { label: t('wordPairs.filter.selectLabel', '선택'), items, multiple: true, labelAction: opts?.labelAction, labelInfo: opts?.labelInfo };
|
|
76
77
|
}
|
|
77
78
|
/**
|
|
78
79
|
* @public 위치(어두/어중/어말) FilterPanelSection 빌더 — WordSearchPage·단어훈련 만들기(메인 앱)가 공용.
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { type WordTrainingTypeKey } from '../utils/wordTrainingRuntime';
|
|
2
2
|
import type { DifficultyLevel } from '../utils/koreanSyllable';
|
|
3
3
|
import { type WordComposition } from './useWordTrainingFilter';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
label: string;
|
|
7
|
-
icon: string;
|
|
8
|
-
color: string;
|
|
9
|
-
subTitle: string;
|
|
10
|
-
}
|
|
4
|
+
import type { WordTrainingType } from '../utils/wordTrainingRuntimeTypes';
|
|
5
|
+
export type { WordTrainingType } from '../utils/wordTrainingRuntimeTypes';
|
|
11
6
|
interface WordTrainingDataEntry {
|
|
12
7
|
id: string;
|
|
13
8
|
name: string;
|
|
@@ -31,4 +26,3 @@ export declare function useWordTrainingDataGeneration(types: WordTrainingType[],
|
|
|
31
26
|
perTypeData: Record<string, WordTrainingDataEntry | null>;
|
|
32
27
|
generating: boolean;
|
|
33
28
|
};
|
|
34
|
-
export {};
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { useCallback, useEffect, useMemo, useRef, useState
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { getViableComposition, countUsableWords } from '../utils/wordTrainingRuntime';
|
|
5
5
|
import { FILTER_ALL_VALUE } from '../types/filterPanel';
|
|
6
6
|
import { useWindowEvent } from './useWindowEvent';
|
|
7
|
-
import FilterSectionInfo from '../components/FilterSectionInfo';
|
|
8
7
|
import { useWordAttributeFilterKeys, buildGradeSection, buildPartOfSpeechSection, buildWordTypeSection } from './useWordAttributeFilter';
|
|
9
8
|
import { matchesFilters } from '../utils/phonemePairSearch';
|
|
10
9
|
import { hasSel } from '../utils/wordContrast';
|
|
@@ -327,7 +326,9 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
327
326
|
const partByLabel = useMemo(() => ({
|
|
328
327
|
[LABELS.trainOnset]: 'cho', [LABELS.trainVowel]: 'jung', [LABELS.trainCoda]: 'jong',
|
|
329
328
|
}), [LABELS]);
|
|
330
|
-
|
|
329
|
+
/* 요소가 아니라 **서술**을 넘긴다 — 그리는 일은 FilterPanel 이 한다.
|
|
330
|
+
예전에는 여기서 createElement 로 만들었고, 그 탓에 훅이 컴포넌트를 올려다봤다. */
|
|
331
|
+
const infoAction = useCallback((items, title) => ({ title, items }), []);
|
|
331
332
|
const selectInfoItems = useMemo(() => [
|
|
332
333
|
t('training.filter.excludeImageInfo', { defaultValue: '이미지 있는 단어만: 단어 중에 이미지가 있는 단어만 결과에 보여줍니다.' }),
|
|
333
334
|
t('training.filter.sameStructureInfo', { defaultValue: '음소 구조 같은 단어만: 정답과 오답의 음절 구조(글자 수·받침 유무 등)가 같은 결과만 남겨요.' }),
|
|
@@ -383,7 +384,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
383
384
|
// label만 오버라이드하고 나머지(items/allOption 등)는 공용 빌더 그대로 사용.
|
|
384
385
|
sections.push({
|
|
385
386
|
...buildGradeSection(t, attrKeys, {
|
|
386
|
-
|
|
387
|
+
labelInfo: infoAction([t('wordPairs.filter.difficultyInfo', { defaultValue: '국립국어원의 기초 어휘 등급이 결과에 반영됩니다.' })]),
|
|
387
388
|
}),
|
|
388
389
|
label: LABELS.gradeLabel,
|
|
389
390
|
});
|
|
@@ -423,10 +424,10 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
423
424
|
// 선택 — choseong 타입은 긍정형 다중 토글(이미지 있는 단어만 / 음소 구조 같은 단어만),
|
|
424
425
|
// 아니면 이미지 제외만 마지막에 노출. (받침 제외는 종성 섹션의 '제외' 버튼)
|
|
425
426
|
if (caps.choseong) {
|
|
426
|
-
sections.push({ label: LABELS.exclude, displayLabel: LABELS.selectLabel, multiple: true, items: [LABELS.excludeImage, LABELS.sameStructure],
|
|
427
|
+
sections.push({ label: LABELS.exclude, displayLabel: LABELS.selectLabel, multiple: true, items: [LABELS.excludeImage, LABELS.sameStructure], labelInfo: infoAction(selectInfoItems) });
|
|
427
428
|
}
|
|
428
429
|
else {
|
|
429
|
-
sections.push({ label: LABELS.exclude, displayLabel: LABELS.selectLabel, multiple: true, items: [LABELS.excludeImage],
|
|
430
|
+
sections.push({ label: LABELS.exclude, displayLabel: LABELS.selectLabel, multiple: true, items: [LABELS.excludeImage], labelInfo: infoAction([selectInfoItems[0]]) });
|
|
430
431
|
}
|
|
431
432
|
return sections;
|
|
432
433
|
}, [caps.choseong, caps.partOfSpeech, caps.wordType, caps.requireSyllable, caps.fixedSyllable, caps.fixedNoCoda, caps.requireCoda, minSyllables, partViable, viable, phonemeView, syllableViable, LABELS, t, infoAction, selectInfoItems, attrKeys]);
|
|
@@ -26,3 +26,7 @@ export * from './hooks/usePagedList';
|
|
|
26
26
|
export * from './hooks/useToastState';
|
|
27
27
|
export { useTrainingTypeLabels } from './hooks/useTrainingTypeLabels';
|
|
28
28
|
export { CORE_VERSION } from './version';
|
|
29
|
+
export { useAnchoredMenu } from './hooks/useAnchoredMenu';
|
|
30
|
+
export type { UseAnchoredMenuOptions, UseAnchoredMenuResult } from './hooks/useAnchoredMenu';
|
|
31
|
+
export { useSoundCompassExam } from './hooks/useSoundCompassExam';
|
|
32
|
+
export type { SoundCompassMode, SoundCompassPhase, SoundCompassExam, UseSoundCompassExamOptions } from './hooks/useSoundCompassExam';
|
|
@@ -27,3 +27,9 @@ export * from './hooks/usePagedList';
|
|
|
27
27
|
export * from './hooks/useToastState';
|
|
28
28
|
export { useTrainingTypeLabels } from './hooks/useTrainingTypeLabels';
|
|
29
29
|
export { CORE_VERSION } from './version';
|
|
30
|
+
/* 컴포넌트 곁에 있다가 이리로 온 둘 — 옛 경로(`components/…`)도 껍데기로 살아 있다.
|
|
31
|
+
기준은 "몇 곳이 쓰는가" 다: useAnchoredMenu 는 네 컴포넌트가 함께 쓰고,
|
|
32
|
+
useSoundCompassExam 은 어느 컴포넌트도 안 쓴다(소비자가 쓰는 머리 없는 훅이다).
|
|
33
|
+
한 컴포넌트만 쓰는 훅 다섯은 그 곁에 그대로 두었다 — 옮기면 응집만 떨어진다. */
|
|
34
|
+
export { useAnchoredMenu } from './hooks/useAnchoredMenu';
|
|
35
|
+
export { useSoundCompassExam } from './hooks/useSoundCompassExam';
|
|
@@ -2571,6 +2571,14 @@
|
|
|
2571
2571
|
"orDivider": "or",
|
|
2572
2572
|
"kakaoStart": "Continue with Kakao"
|
|
2573
2573
|
},
|
|
2574
|
+
"kakaoLink": {
|
|
2575
|
+
"title": "Kakao login linked",
|
|
2576
|
+
"messageWithEmail": "Kakao login was added to {{email}}. You can now sign in either way.",
|
|
2577
|
+
"message": "Kakao login was added to your existing account with the same email. You can now sign in either way.",
|
|
2578
|
+
"keep": "OK",
|
|
2579
|
+
"unlink": "Unlink",
|
|
2580
|
+
"unlinkFailed": "Could not unlink."
|
|
2581
|
+
},
|
|
2574
2582
|
"agreementGate": {
|
|
2575
2583
|
"title": "Agreement required",
|
|
2576
2584
|
"subtitle": "Please agree to the Terms of Use and Privacy Policy to continue using the service.",
|
|
@@ -2569,6 +2569,14 @@
|
|
|
2569
2569
|
"orDivider": "또는",
|
|
2570
2570
|
"kakaoStart": "카카오로 시작하기"
|
|
2571
2571
|
},
|
|
2572
|
+
"kakaoLink": {
|
|
2573
|
+
"title": "카카오 로그인이 연결되었습니다",
|
|
2574
|
+
"messageWithEmail": "{{email}} 계정에 카카오 로그인이 추가되었습니다. 이제 두 가지 방법으로 모두 로그인할 수 있습니다.",
|
|
2575
|
+
"message": "같은 이메일로 가입된 기존 계정에 카카오 로그인이 추가되었습니다. 이제 두 가지 방법으로 모두 로그인할 수 있습니다.",
|
|
2576
|
+
"keep": "확인",
|
|
2577
|
+
"unlink": "연결 해제",
|
|
2578
|
+
"unlinkFailed": "연결을 해제하지 못했습니다."
|
|
2579
|
+
},
|
|
2572
2580
|
"agreementGate": {
|
|
2573
2581
|
"title": "약관 동의가 필요합니다",
|
|
2574
2582
|
"subtitle": "서비스를 계속 이용하시려면 이용약관과 개인정보처리방침에 동의해 주세요.",
|
|
@@ -45,6 +45,21 @@ export interface FilterPanelSection {
|
|
|
45
45
|
/** 가로 배치에서 이 섹션을 한 줄 전체(자체 줄)로 배치 — 인라인 흐름에서 분리 */
|
|
46
46
|
block?: boolean;
|
|
47
47
|
labelAction?: ReactNode;
|
|
48
|
+
/**
|
|
49
|
+
* 라벨 옆 "?" 설명 버튼을 **서술로** 지정한다 — `labelAction` 에 요소를 직접 넣는 대신
|
|
50
|
+
* 내용만 준다. FilterPanel 이 `FilterSectionInfo` 로 그린다.
|
|
51
|
+
*
|
|
52
|
+
* 왜 두 갈래인가: 훅은 화면을 그리면 안 된다. 섹션을 만들어 주는 훅
|
|
53
|
+
* (`useWordTrainingFilter`)이 예전에는 `createElement(FilterSectionInfo, …)` 로
|
|
54
|
+
* 요소를 만들었는데, 그러려면 **훅이 컴포넌트를 올려다봐야** 했다. 이 슬롯을 쓰면
|
|
55
|
+
* 훅은 내용만 넘기고 그리는 일은 컴포넌트가 한다.
|
|
56
|
+
* `labelAction` 은 그대로 둔다 — 호출부가 제 요소를 넣고 싶을 때가 있다.
|
|
57
|
+
* 둘 다 있으면 `labelAction` 이 이긴다.
|
|
58
|
+
*/
|
|
59
|
+
labelInfo?: {
|
|
60
|
+
title?: string;
|
|
61
|
+
items: string[];
|
|
62
|
+
};
|
|
48
63
|
items?: (FilterPanelItem | string)[];
|
|
49
64
|
groups?: (FilterPanelItem | string)[][];
|
|
50
65
|
/** 'note': 선택 불가능한 안내 텍스트 섹션 — content를 그대로 렌더링한다(예: 입력 구문 사용법). */
|