@ihabdevteam/core 0.318.0 → 0.322.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +336 -0
- package/dist/components.cjs.js +22 -22
- package/dist/components.esm.css +2 -2
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +188 -188
- package/dist/ihabdevteam-core.d.ts +144 -63
- package/dist/packages/core/src/components/HearingDeviceInfo.d.ts +5 -5
- package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
- package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
- package/dist/packages/core/src/components/PanelRow.js +2 -2
- package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
- package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -3
- package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -3
- package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.css +19 -0
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.d.ts +16 -1
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +8 -6
- package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingCardGroup.d.ts +20 -1
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +15 -0
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +16 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +14 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirm.d.ts +3 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +42 -30
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +2 -2
- package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
- package/dist/packages/core/src/components/Training/oneShotSound.js +3 -3
- package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +60 -10
- package/dist/packages/core/src/components/Training/trainingMissStats.js +2 -2
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +9 -9
- package/dist/packages/core/src/components/Word/WordTableBlock.css +25 -0
- package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
- package/dist/packages/core/src/components/Word/wordScoreStats.js +2 -2
- package/dist/packages/core/src/components/Word/wordTableRecommend.js +2 -2
- package/dist/packages/core/src/components/wordTableLayout.d.ts +5 -3
- package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
- package/dist/packages/core/src/hooks/inputModality.js +1 -1
- package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
- package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
- package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
- package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
- package/dist/packages/core/src/hooks/useTablePaging.d.ts +16 -5
- package/dist/packages/core/src/hooks/useTablePaging.js +20 -9
- package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +10 -1
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +11 -2
- package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +8 -4
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +44 -7
- package/dist/packages/core/src/i18n/locales/en.json +8 -5
- package/dist/packages/core/src/i18n/locales/ko.json +8 -5
- package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
- package/dist/packages/core/src/utils/devWarn.js +5 -5
- package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
- package/dist/packages/core/src/utils/filterPanelSummary.js +4 -4
- package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
- package/dist/packages/core/src/utils/navScroll.js +4 -4
- package/dist/packages/core/src/utils/phonemePairSearch.js +10 -3
- package/dist/packages/core/src/utils/pressable.d.ts +1 -1
- package/dist/packages/core/src/utils/pressable.js +3 -3
- package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
- package/dist/packages/core/src/utils/sessionTab.js +9 -9
- package/dist/packages/core/src/utils/textSort.d.ts +15 -5
- package/dist/packages/core/src/utils/textSort.js +19 -9
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +37 -0
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +60 -3
- package/dist/packages/core/src/utils/wordTrainingPool.js +3 -2
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +7 -3
- package/package.json +2 -1
|
@@ -888,6 +888,7 @@
|
|
|
888
888
|
"positionTail": "어말",
|
|
889
889
|
"difficulty": "등급",
|
|
890
890
|
"grade": "{{n}}등급",
|
|
891
|
+
"gradeNone": "없음",
|
|
891
892
|
"syllables": "음절 수",
|
|
892
893
|
"syllable": "{{n}}음절",
|
|
893
894
|
"syllable5plus": "5음절 이상",
|
|
@@ -1134,11 +1135,13 @@
|
|
|
1134
1135
|
],
|
|
1135
1136
|
"orderAny": "무관",
|
|
1136
1137
|
"orderMatch": "일치",
|
|
1137
|
-
"
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1138
|
+
"difficultyInfo": [
|
|
1139
|
+
"1등급 : 학령전기에 습득하여 평생에 걸쳐 일상 언어생활을 하는 데 사용되는 어휘",
|
|
1140
|
+
"2등급 : 초등학교 1-2학년 난이도 어휘",
|
|
1141
|
+
"3등급 : 초등학교 3-4학년 난이도 어휘",
|
|
1142
|
+
"4등급 : 초등학교 5-6학년 난이도 어휘",
|
|
1143
|
+
"등급없음 : 국립국어원 표준 어휘 외 고빈도 생활 어휘, 신조어 및 외래어"
|
|
1144
|
+
],
|
|
1142
1145
|
"selectLabel": "선택",
|
|
1143
1146
|
"onlyHasImage": "이미지 있는 단어만",
|
|
1144
1147
|
"onlySameStructure": "음소 구조 같은 단어만"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare const isDev: boolean;
|
|
2
|
-
/**
|
|
3
|
-
export declare function devWarnOnce(
|
|
2
|
+
/** `key` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
|
|
3
|
+
export declare function devWarnOnce(key: string, ...args: unknown[]): void;
|
|
4
4
|
/** 시험용 — 기록을 비운다. 운영 코드에서 부를 일은 없다. */
|
|
5
5
|
export declare function __resetDevWarnOnce(): void;
|
|
@@ -27,12 +27,12 @@ export const isDev = (() => {
|
|
|
27
27
|
return false;
|
|
28
28
|
}
|
|
29
29
|
})();
|
|
30
|
-
/**
|
|
31
|
-
export function devWarnOnce(
|
|
32
|
-
if (!isDev || 찍은것.has(
|
|
30
|
+
/** `key` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
|
|
31
|
+
export function devWarnOnce(key, ...args) {
|
|
32
|
+
if (!isDev || 찍은것.has(key))
|
|
33
33
|
return;
|
|
34
|
-
찍은것.add(
|
|
35
|
-
console.warn(
|
|
34
|
+
찍은것.add(key);
|
|
35
|
+
console.warn(...args);
|
|
36
36
|
}
|
|
37
37
|
/** 시험용 — 기록을 비운다. 운영 코드에서 부를 일은 없다. */
|
|
38
38
|
export function __resetDevWarnOnce() {
|
|
@@ -11,4 +11,4 @@ import type { FilterPanelSection } from '../types/filterPanel';
|
|
|
11
11
|
* 안 담는 절: `note`(고르는 절이 아니다) · `input`(빈 칸을 '전체'라 말하면 거짓이다) ·
|
|
12
12
|
* 고른 것이 없는데 '전체' 보기도 없는 절(말할 것이 없다).
|
|
13
13
|
*/
|
|
14
|
-
export declare function buildFilterSummary(sections: readonly FilterPanelSection[], selected: Record<string, string | string[]> | undefined,
|
|
14
|
+
export declare function buildFilterSummary(sections: readonly FilterPanelSection[], selected: Record<string, string | string[]> | undefined, allLabel: string): string;
|
|
@@ -11,13 +11,13 @@ import { FILTER_ALL_VALUE } from '../types/filterPanel';
|
|
|
11
11
|
* 안 담는 절: `note`(고르는 절이 아니다) · `input`(빈 칸을 '전체'라 말하면 거짓이다) ·
|
|
12
12
|
* 고른 것이 없는데 '전체' 보기도 없는 절(말할 것이 없다).
|
|
13
13
|
*/
|
|
14
|
-
export function buildFilterSummary(sections, selected,
|
|
14
|
+
export function buildFilterSummary(sections, selected, allLabel) {
|
|
15
15
|
const 조각들 = [];
|
|
16
16
|
for (const section of sections ?? []) {
|
|
17
17
|
if (section.type === 'note' || section.type === 'input')
|
|
18
18
|
continue;
|
|
19
19
|
const 이름 = section.displayLabel ?? section.label;
|
|
20
|
-
const 값 = 값말(section, selected?.[section.label],
|
|
20
|
+
const 값 = 값말(section, selected?.[section.label], allLabel);
|
|
21
21
|
if (값 === null)
|
|
22
22
|
continue;
|
|
23
23
|
조각들.push(`${이름} ${값}`);
|
|
@@ -25,12 +25,12 @@ export function buildFilterSummary(sections, selected, 전체말) {
|
|
|
25
25
|
return 조각들.join(' · ');
|
|
26
26
|
}
|
|
27
27
|
/** 한 절이 무엇으로 정해져 있는지 — 말할 것이 없으면 `null` */
|
|
28
|
-
function 값말(section, sel,
|
|
28
|
+
function 값말(section, sel, allLabel) {
|
|
29
29
|
const 고른것 = Array.isArray(sel) ? sel : sel === undefined ? [] : [sel];
|
|
30
30
|
/* '전체' 는 두 꼴로 온다 — 아무것도 안 고른 것과 '전체' 보기를 고른 것. 같은 말로 읽는다. */
|
|
31
31
|
const 실제 = 고른것.filter(v => v !== FILTER_ALL_VALUE);
|
|
32
32
|
if (실제.length === 0) {
|
|
33
|
-
return section.allOption ? (section.allLabel ??
|
|
33
|
+
return section.allOption ? (section.allLabel ?? allLabel) : null;
|
|
34
34
|
}
|
|
35
35
|
return 실제.map(v => 보기말(section, v)).join(', ');
|
|
36
36
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { RefObject } from 'react';
|
|
2
2
|
/** 소비자가 통을 안 알려 줄 때 찾아 보는 이름 — 옛 동작 유지(말귀 셸의 본문). @internal */
|
|
3
|
-
export declare const
|
|
3
|
+
export declare const DEFAULT_SCROLL_SELECTOR = ".content-area";
|
|
4
4
|
/**
|
|
5
5
|
* 갈래를 고른 뒤 본문을 맨 위로 — 내비(SideNav·BottomNav)가 함께 쓴다. @internal
|
|
6
6
|
*
|
|
@@ -8,4 +8,4 @@ export declare const 기본통고르개 = ".content-area";
|
|
|
8
8
|
* 그것이 **아무 일도 하지 않는다** — 그때는 조용히 죽는 대신 개발 모드에 한 번 알린다.
|
|
9
9
|
* 코어가 `.content-area`(소비자 클래스 이름)를 붙잡고 있던 자리라, 부르는 쪽이 통을 알려 주는 것이 정본이다.
|
|
10
10
|
*/
|
|
11
|
-
export declare function scrollNavTargetToTop(scrollContainer?: string | RefObject<HTMLElement | null>,
|
|
11
|
+
export declare function scrollNavTargetToTop(scrollContainer?: string | RefObject<HTMLElement | null>, from?: string): void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { devWarnOnce } from './devWarn';
|
|
2
2
|
/** 소비자가 통을 안 알려 줄 때 찾아 보는 이름 — 옛 동작 유지(말귀 셸의 본문). @internal */
|
|
3
|
-
export const
|
|
3
|
+
export const DEFAULT_SCROLL_SELECTOR = '.content-area';
|
|
4
4
|
/**
|
|
5
5
|
* 갈래를 고른 뒤 본문을 맨 위로 — 내비(SideNav·BottomNav)가 함께 쓴다. @internal
|
|
6
6
|
*
|
|
@@ -8,18 +8,18 @@ export const 기본통고르개 = '.content-area';
|
|
|
8
8
|
* 그것이 **아무 일도 하지 않는다** — 그때는 조용히 죽는 대신 개발 모드에 한 번 알린다.
|
|
9
9
|
* 코어가 `.content-area`(소비자 클래스 이름)를 붙잡고 있던 자리라, 부르는 쪽이 통을 알려 주는 것이 정본이다.
|
|
10
10
|
*/
|
|
11
|
-
export function scrollNavTargetToTop(scrollContainer,
|
|
11
|
+
export function scrollNavTargetToTop(scrollContainer, from = 'Nav') {
|
|
12
12
|
try {
|
|
13
13
|
const el = typeof scrollContainer === 'string'
|
|
14
14
|
? document.querySelector(scrollContainer)
|
|
15
|
-
: scrollContainer?.current ?? document.querySelector(
|
|
15
|
+
: scrollContainer?.current ?? document.querySelector(DEFAULT_SCROLL_SELECTOR);
|
|
16
16
|
if (el) {
|
|
17
17
|
el.scrollTop = 0;
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
20
|
const 문서 = document.scrollingElement ?? document.documentElement;
|
|
21
21
|
if (문서 && 문서.scrollHeight <= 문서.clientHeight) {
|
|
22
|
-
devWarnOnce('nav-scroll-container', `[${
|
|
22
|
+
devWarnOnce('nav-scroll-container', `[${from}] 본문을 맨 위로 되돌릴 통을 못 찾았다(기본 ${DEFAULT_SCROLL_SELECTOR}) — 창도 구를 수 없어 아무 일도 하지 않는다. scrollContainer 로 통을 알려 준다.`);
|
|
23
23
|
}
|
|
24
24
|
window.scrollTo({ top: 0 });
|
|
25
25
|
}
|
|
@@ -115,7 +115,7 @@ const POS_GROUP_MAP_KO = {
|
|
|
115
115
|
'용언(동사,형용사)': ['동', '형'],
|
|
116
116
|
'수식언(부사,관형사)': ['부', '관'],
|
|
117
117
|
};
|
|
118
|
-
import { POS_CODE_TARGETS, WORD_TYPE_CODE_VALUES, wordAttributeSectionOf, wordAttributeValueCodeOf } from './wordAttributeFilterCodes';
|
|
118
|
+
import { POS_CODE_TARGETS, WORD_TYPE_CODE_VALUES, wordAttributeSectionOf, wordAttributeValueCodeOf, matchesGradeCodes } from './wordAttributeFilterCodes';
|
|
119
119
|
const DEFAULT_PAIR_FILTER_KEYS = {
|
|
120
120
|
difficulty: '난이도 등급',
|
|
121
121
|
syllables: '음절 수',
|
|
@@ -155,8 +155,15 @@ function 모으기(filterSelected, keys) {
|
|
|
155
155
|
*/
|
|
156
156
|
export function matchesFilters(word, filterSelected, keys = DEFAULT_PAIR_FILTER_KEYS) {
|
|
157
157
|
const { grades, syls, poses, types } = 모으기(filterSelected, keys);
|
|
158
|
-
if (grades.length
|
|
159
|
-
|
|
158
|
+
if (grades.length) {
|
|
159
|
+
/* **`없음` 은 값이 아니라 빔이다**((205), 2026-10-12) — 거르는 결정은 `matchesGradeCodes` **하나**다
|
|
160
|
+
(단어훈련 풀도 같은 것을 부른다). 여기서는 라벨을 코드로만 바꾼다.
|
|
161
|
+
되거름(숫자만 남기기)이 `''` 를 내면 **버린다** — 빈 되거름이 그대로 가면 빈 등급 단어가 **우연히**
|
|
162
|
+
걸려, 사전에서 라벨이 빠진 날 `없음` 이 겉보기엔 되는 꼴이 된다. 그 흠은 제일 늦게 들킨다. */
|
|
163
|
+
const codes = grades.map(g => wordAttributeValueCodeOf('grade', g) ?? g.replace(/\D/g, '')).filter(c => c !== '');
|
|
164
|
+
if (codes.length === 0 || !matchesGradeCodes(codes, word.grade))
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
160
167
|
if (syls.length) {
|
|
161
168
|
const len = word.syllables ?? 0;
|
|
162
169
|
const match = syls.some(syl => {
|
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
* 진짜 `<button>` 을 쓸 수 있으면 그쪽이 낫다. 이건 이미 `<div>`·`<li>` 로 짜인
|
|
15
15
|
* 자리를 **모양을 안 바꾸고** 눌리게 만드는 길이다.
|
|
16
16
|
*/
|
|
17
|
-
export function pressable(onPress,
|
|
17
|
+
export function pressable(onPress, options) {
|
|
18
18
|
if (!onPress)
|
|
19
19
|
return {};
|
|
20
20
|
return {
|
|
21
|
-
role:
|
|
21
|
+
role: options?.role ?? 'button',
|
|
22
22
|
tabIndex: 0,
|
|
23
|
-
...(
|
|
23
|
+
...(options?.selected === undefined ? {} : { 'aria-selected': options.selected }),
|
|
24
24
|
onKeyDown: (e) => {
|
|
25
25
|
/* **이 자리 자신에서 난 키만** 받는다. 안에 든 진짜 단추(랜딩 띠의 action 등)에서 누른 Enter 가 올라오면
|
|
26
26
|
전에는 여기서 preventDefault 해 그 단추의 click 을 막고 이 자리의 onPress 를 대신 불렀다. */
|
|
@@ -20,17 +20,17 @@ export interface TabSessionLike {
|
|
|
20
20
|
* 여기서 막으면 이 탭이 로그인된 채로 남는다 — 막는 것보다 나쁜 쪽이다.
|
|
21
21
|
*
|
|
22
22
|
* ```ts
|
|
23
|
-
* onAuthStateChange((event,
|
|
23
|
+
* onAuthStateChange((event, incoming) => {
|
|
24
24
|
* getSession().then(({ data }) => {
|
|
25
|
-
* const
|
|
26
|
-
* if (!isSessionForThisTab(
|
|
27
|
-
*
|
|
25
|
+
* const thisTab = data.session
|
|
26
|
+
* if (!isSessionForThisTab(incoming, thisTab)) return // 다른 탭에서 온 것 — 무시
|
|
27
|
+
* …thisTab 으로 상태를 맞춘다
|
|
28
28
|
* })
|
|
29
29
|
* })
|
|
30
30
|
* ```
|
|
31
31
|
*
|
|
32
|
-
* @param
|
|
33
|
-
* @param
|
|
32
|
+
* @param incoming 이벤트가 실어 온 세션(없을 수 있다)
|
|
33
|
+
* @param thisTab `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
|
|
34
34
|
* @public
|
|
35
35
|
*/
|
|
36
|
-
export declare function isSessionForThisTab(
|
|
36
|
+
export declare function isSessionForThisTab(incoming: TabSessionLike | null | undefined, thisTab: TabSessionLike | null | undefined): boolean;
|
|
@@ -16,21 +16,21 @@
|
|
|
16
16
|
* 여기서 막으면 이 탭이 로그인된 채로 남는다 — 막는 것보다 나쁜 쪽이다.
|
|
17
17
|
*
|
|
18
18
|
* ```ts
|
|
19
|
-
* onAuthStateChange((event,
|
|
19
|
+
* onAuthStateChange((event, incoming) => {
|
|
20
20
|
* getSession().then(({ data }) => {
|
|
21
|
-
* const
|
|
22
|
-
* if (!isSessionForThisTab(
|
|
23
|
-
*
|
|
21
|
+
* const thisTab = data.session
|
|
22
|
+
* if (!isSessionForThisTab(incoming, thisTab)) return // 다른 탭에서 온 것 — 무시
|
|
23
|
+
* …thisTab 으로 상태를 맞춘다
|
|
24
24
|
* })
|
|
25
25
|
* })
|
|
26
26
|
* ```
|
|
27
27
|
*
|
|
28
|
-
* @param
|
|
29
|
-
* @param
|
|
28
|
+
* @param incoming 이벤트가 실어 온 세션(없을 수 있다)
|
|
29
|
+
* @param thisTab `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
|
|
30
30
|
* @public
|
|
31
31
|
*/
|
|
32
|
-
export function isSessionForThisTab(
|
|
33
|
-
if (
|
|
32
|
+
export function isSessionForThisTab(incoming, thisTab) {
|
|
33
|
+
if (!incoming)
|
|
34
34
|
return true;
|
|
35
|
-
return
|
|
35
|
+
return !!thisTab && thisTab.access_token === incoming.access_token;
|
|
36
36
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** 글자 둘을 한국어 차례로 견준다 */
|
|
2
|
-
export declare function
|
|
2
|
+
export declare function compareKorean(a: string, b: string): number;
|
|
3
3
|
/**
|
|
4
4
|
* 표의 한 칸끼리 견준다 — **숫자는 숫자로, 글자 속 숫자도 수로.**
|
|
5
5
|
*
|
|
@@ -13,10 +13,20 @@ export declare function 한글순(a: string, b: string): number;
|
|
|
13
13
|
* 비어 있는 값(`null`·`undefined`·NaN)은 빈 글자로 본다 — 부르는 쪽이 이미
|
|
14
14
|
* `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
|
|
15
15
|
*/
|
|
16
|
-
export declare function
|
|
16
|
+
export declare function compareCell(a: unknown, b: unknown): number;
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* @deprecated **`compareCell` 을 쓴다 — 같은 함수다.**
|
|
19
|
+
*
|
|
20
|
+
* 2026-10-13 공개 이름을 영어로 옮기며(사장님 지시) 옛 이름을 남겼다. 말귀가
|
|
21
|
+
* `import { 칸순 } from '@ihabdevteam/core/utils/textSort'` 를 **다섯 줄**에서
|
|
22
|
+
* 쓰고 있어 한 번에 걷으면 그쪽이 통째로 빨개진다. 옮긴 뒤 알려 주면 걷는다.
|
|
23
|
+
*
|
|
24
|
+
* 같은 꼴로 남겨 둔 것이 또 있다 — `useTableSort()` 의 `정렬`·`칸`.
|
|
25
|
+
*/
|
|
26
|
+
export declare const 칸순: typeof compareCell;
|
|
27
|
+
/**
|
|
28
|
+
* 표 정렬 한 번의 비교 — compareCell에 방향을 입히되 **빈 값은 오름·내림 모두 맨 뒤.**
|
|
29
|
+
* 방향을 `-compareCell` 으로 뒤집기만 하면 빈 값이 오름차순 맨 앞·내림차순 맨 뒤로 오가, 표마다 빈 줄이 다른 끝에 섰다.
|
|
20
30
|
* ListPopup 과 useTableSort().sortRows 가 함께 쓴다.
|
|
21
31
|
*/
|
|
22
|
-
export declare function
|
|
32
|
+
export declare function compareCellBlankLast(a: unknown, b: unknown, asc: boolean): number;
|
|
@@ -19,7 +19,7 @@ const 콜레이터 = (() => {
|
|
|
19
19
|
};
|
|
20
20
|
})();
|
|
21
21
|
/** 글자 둘을 한국어 차례로 견준다 */
|
|
22
|
-
export function
|
|
22
|
+
export function compareKorean(a, b) {
|
|
23
23
|
return 콜레이터().compare(a, b);
|
|
24
24
|
}
|
|
25
25
|
const 조각열쇠 = new Map();
|
|
@@ -77,7 +77,7 @@ function 전화열쇠(s) {
|
|
|
77
77
|
* 비어 있는 값(`null`·`undefined`·NaN)은 빈 글자로 본다 — 부르는 쪽이 이미
|
|
78
78
|
* `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
|
|
79
79
|
*/
|
|
80
|
-
export function
|
|
80
|
+
export function compareCell(a, b) {
|
|
81
81
|
const 글 = (v) => (v == null || (typeof v === 'number' && Number.isNaN(v)) ? '' : String(v).trim());
|
|
82
82
|
const sa = 글(a), sb = 글(b);
|
|
83
83
|
if (sa === sb)
|
|
@@ -102,25 +102,35 @@ export function 칸순(a, b) {
|
|
|
102
102
|
return -1;
|
|
103
103
|
if (typeof y === 'number')
|
|
104
104
|
return 1;
|
|
105
|
-
const c =
|
|
105
|
+
const c = compareKorean(x, y);
|
|
106
106
|
if (c !== 0)
|
|
107
107
|
return c;
|
|
108
108
|
}
|
|
109
109
|
if (ka.length !== kb.length)
|
|
110
110
|
return ka.length - kb.length;
|
|
111
|
-
return
|
|
111
|
+
return compareKorean(sa, sb);
|
|
112
112
|
}
|
|
113
|
-
|
|
113
|
+
/**
|
|
114
|
+
* @deprecated **`compareCell` 을 쓴다 — 같은 함수다.**
|
|
115
|
+
*
|
|
116
|
+
* 2026-10-13 공개 이름을 영어로 옮기며(사장님 지시) 옛 이름을 남겼다. 말귀가
|
|
117
|
+
* `import { 칸순 } from '@ihabdevteam/core/utils/textSort'` 를 **다섯 줄**에서
|
|
118
|
+
* 쓰고 있어 한 번에 걷으면 그쪽이 통째로 빨개진다. 옮긴 뒤 알려 주면 걷는다.
|
|
119
|
+
*
|
|
120
|
+
* 같은 꼴로 남겨 둔 것이 또 있다 — `useTableSort()` 의 `정렬`·`칸`.
|
|
121
|
+
*/
|
|
122
|
+
export const 칸순 = compareCell;
|
|
123
|
+
/* 빈 값 — null·undefined·NaN·빈 글자(빈칸만 있는 글자 포함). compareCell이 '' 로 보는 것과 같은 범위. */
|
|
114
124
|
const 빈칸값 = (v) => v == null || (typeof v === 'number' && Number.isNaN(v)) || (typeof v === 'string' && v.trim() === '');
|
|
115
125
|
/**
|
|
116
|
-
* 표 정렬 한 번의 비교 —
|
|
117
|
-
* 방향을
|
|
126
|
+
* 표 정렬 한 번의 비교 — compareCell에 방향을 입히되 **빈 값은 오름·내림 모두 맨 뒤.**
|
|
127
|
+
* 방향을 `-compareCell` 으로 뒤집기만 하면 빈 값이 오름차순 맨 앞·내림차순 맨 뒤로 오가, 표마다 빈 줄이 다른 끝에 섰다.
|
|
118
128
|
* ListPopup 과 useTableSort().sortRows 가 함께 쓴다.
|
|
119
129
|
*/
|
|
120
|
-
export function
|
|
130
|
+
export function compareCellBlankLast(a, b, asc) {
|
|
121
131
|
const ea = 빈칸값(a), eb = 빈칸값(b);
|
|
122
132
|
if (ea || eb)
|
|
123
133
|
return ea === eb ? 0 : ea ? 1 : -1;
|
|
124
|
-
const c =
|
|
134
|
+
const c = compareCell(a, b);
|
|
125
135
|
return asc ? c : -c;
|
|
126
136
|
}
|
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
/** @public 공용 속성 필터 섹션 코드 */
|
|
2
2
|
export type WordAttributeSectionCode = 'grade' | 'syllables' | 'pos' | 'wordType';
|
|
3
|
+
/**
|
|
4
|
+
* **등급이 빈 단어를 가리키는 코드** — `'none'`((205), 사장님 지시 2026-10-12).
|
|
5
|
+
*
|
|
6
|
+
* ## 이것은 **값이 아니라 빔**이다
|
|
7
|
+
*
|
|
8
|
+
* `'1'`~`'4'` 는 단어의 `grade` 에 **그 글자가 들어 있다.** `'none'` 은 다르다 — 어느 단어의
|
|
9
|
+
* `grade` 도 `'none'` 이 아니고, 가리키는 것은 **`grade` 가 비어 있다**는 사실이다
|
|
10
|
+
* (`undefined` · `null` · `''`, 그리고 공백만 든 것도).
|
|
11
|
+
*
|
|
12
|
+
* 그래서 **맞추개가 이 코드만은 다른 길로 간다** — `word.grade === code` 가 아니라
|
|
13
|
+
* `isBlankGrade(word.grade)` 로 본다(`phonemePairSearch.matchesFilters`). 같은 꼴로 재면
|
|
14
|
+
* 한 단어도 안 걸리고, 그런데 **필터는 멀쩡히 켜져 보인다.**
|
|
15
|
+
*
|
|
16
|
+
* **숫자 길에도 안 걸린다** — 아래 `wordAttributeValueCodeOf` 가 등급을 숫자로도 읽는데
|
|
17
|
+
* (`'3등급'` → `'3'`), `'없음'` 은 숫자가 없어 `NaN` 이 되어 그 길에서 `null` 로 떨어진다.
|
|
18
|
+
* 그러니 **사전에 라벨이 있어야** 읽힌다. 사전에서 빠지면 조용히 안 읽힌다 —
|
|
19
|
+
* `test/gradeNoneOption.test.ts` 가 그 자리를 센다.
|
|
20
|
+
*/
|
|
21
|
+
export declare const GRADE_NONE = "none";
|
|
22
|
+
/** 등급이 비었나 — `'none'` 이 가리키는 그 **빔**을 한 자리에서 판정한다. 공백만 든 것도 빔이다. */
|
|
23
|
+
export declare function isBlankGrade(grade: string | null | undefined): boolean;
|
|
24
|
+
/** 등급 코드인가 — `'1'`~`'4'`·`'none'`. 저장된 값을 되읽을 때 **아닌 것은 버린다**(한때 `''` 가 저장됐다). */
|
|
25
|
+
export declare function isGradeCode(v: unknown): v is string;
|
|
26
|
+
/**
|
|
27
|
+
* **등급 코드 집합으로 단어 하나를 거른다 — 이 결정은 여기 하나다.**
|
|
28
|
+
*
|
|
29
|
+
* (205) 를 지을 때 맞추개를 `phonemePairSearch.matchesFilters` 하나로 알고 넷을 고쳤는데,
|
|
30
|
+
* **단어훈련 풀**(`wordTrainingPool`)이 같은 결정을 **두 줄로 따로** 갖고 있었다
|
|
31
|
+
* (`!w.grade || !gradeSet.has(w.grade)`). 그 둘은 빈 등급을 **늘 떨어뜨리므로** `없음` 을 골라도
|
|
32
|
+
* 0개였다(말귀_1 실측 2026-10-12: 매니저 단어표는 맞고 단어훈련 페이지는 틀렸다).
|
|
33
|
+
*
|
|
34
|
+
* **같은 결정이 세 자리에 적혀 있으면 하나는 안 따라온다.** 그래서 세 자리가 이 하나를 부른다.
|
|
35
|
+
* `codes` 가 비면 거르지 않는다(전체).
|
|
36
|
+
*/
|
|
37
|
+
export declare function matchesGradeCodes(codes: ReadonlySet<string> | readonly string[] | null | undefined, grade: string | null | undefined): boolean;
|
|
3
38
|
/**
|
|
4
39
|
* @public 공용 속성 필터를 **언어와 무관한 코드**로 담은 모양 — 저장(DB config·로컬 저장소)용.
|
|
5
40
|
* 예: `{ grade: ['1', '2'], syllables: ['5+'], pos: ['noun'], wordType: ['sino'] }`
|
|
@@ -12,6 +47,8 @@ export declare const WORD_TYPE_CODE_VALUES: Record<string, string>;
|
|
|
12
47
|
export declare function wordAttributeSectionOf(label: string): WordAttributeSectionCode | null;
|
|
13
48
|
/** 한 섹션의 값(코드·어느 언어 라벨·옛 라벨) → 값 코드. 못 읽으면 null. */
|
|
14
49
|
export declare function wordAttributeValueCodeOf(section: WordAttributeSectionCode, value: string): string | null;
|
|
50
|
+
/** 코드 → 지금 언어의 라벨. `'none'` 도 사전 꼴로 나온다(숫자 틀 `{{n}}등급` 에 넣으면 `NaN등급` 이 된다). 모르는 코드는 그대로. */
|
|
51
|
+
export declare function wordAttributeValueLabelOf(section: WordAttributeSectionCode, code: string, lng?: string): string;
|
|
15
52
|
/**
|
|
16
53
|
* @public filterSelected(라벨 열쇠·라벨 값, 어느 언어든 섞여 있어도) → **코드 모양**. 저장할 때 쓴다.
|
|
17
54
|
* 속성 섹션(등급·음절 수·품사·어종)만 담고, 그 밖의 섹션(초성·선택·단어 입력 등)은 뺀다. 못 읽는 값은 버린다.
|
|
@@ -6,13 +6,62 @@
|
|
|
6
6
|
* 영어 열쇠('Part of Speech')로는 한국어 값이 안 읽혀 **필터가 풀리거나**, 품사 값이 posMap 에 없어 **모든 단어가 떨어졌다**.
|
|
7
7
|
* 여기서 섹션·값을 언어와 무관한 코드로 읽는다 — 코드, 지금 언어·ko·en 라벨, 옛 라벨('난이도 등급'·'체언(명사,수사)') 모두.
|
|
8
8
|
*
|
|
9
|
-
* 코드: 섹션 `grade`·`syllables`·`pos`·`wordType`, 값 등급 `'1'`~`'4'` · 음절 `'1'`~`'4'`·`'5+'` ·
|
|
9
|
+
* 코드: 섹션 `grade`·`syllables`·`pos`·`wordType`, 값 등급 `'1'`~`'4'`·`'none'` · 음절 `'1'`~`'4'`·`'5+'` ·
|
|
10
10
|
* 품사 `noun`·`verb`·`modifier` · 어종 `native`·`sino`·`foreign`·`mixed`.
|
|
11
11
|
* (useWordTrainingFilter 의 POS_CODES·toAttrCodes 와 같은 코드다.)
|
|
12
12
|
*/
|
|
13
13
|
import i18n from '../i18n';
|
|
14
|
+
/**
|
|
15
|
+
* **등급이 빈 단어를 가리키는 코드** — `'none'`((205), 사장님 지시 2026-10-12).
|
|
16
|
+
*
|
|
17
|
+
* ## 이것은 **값이 아니라 빔**이다
|
|
18
|
+
*
|
|
19
|
+
* `'1'`~`'4'` 는 단어의 `grade` 에 **그 글자가 들어 있다.** `'none'` 은 다르다 — 어느 단어의
|
|
20
|
+
* `grade` 도 `'none'` 이 아니고, 가리키는 것은 **`grade` 가 비어 있다**는 사실이다
|
|
21
|
+
* (`undefined` · `null` · `''`, 그리고 공백만 든 것도).
|
|
22
|
+
*
|
|
23
|
+
* 그래서 **맞추개가 이 코드만은 다른 길로 간다** — `word.grade === code` 가 아니라
|
|
24
|
+
* `isBlankGrade(word.grade)` 로 본다(`phonemePairSearch.matchesFilters`). 같은 꼴로 재면
|
|
25
|
+
* 한 단어도 안 걸리고, 그런데 **필터는 멀쩡히 켜져 보인다.**
|
|
26
|
+
*
|
|
27
|
+
* **숫자 길에도 안 걸린다** — 아래 `wordAttributeValueCodeOf` 가 등급을 숫자로도 읽는데
|
|
28
|
+
* (`'3등급'` → `'3'`), `'없음'` 은 숫자가 없어 `NaN` 이 되어 그 길에서 `null` 로 떨어진다.
|
|
29
|
+
* 그러니 **사전에 라벨이 있어야** 읽힌다. 사전에서 빠지면 조용히 안 읽힌다 —
|
|
30
|
+
* `test/gradeNoneOption.test.ts` 가 그 자리를 센다.
|
|
31
|
+
*/
|
|
32
|
+
export const GRADE_NONE = 'none';
|
|
33
|
+
/** 등급이 비었나 — `'none'` 이 가리키는 그 **빔**을 한 자리에서 판정한다. 공백만 든 것도 빔이다. */
|
|
34
|
+
export function isBlankGrade(grade) {
|
|
35
|
+
return grade == null || String(grade).trim() === '';
|
|
36
|
+
}
|
|
37
|
+
/** 등급 코드인가 — `'1'`~`'4'`·`'none'`. 저장된 값을 되읽을 때 **아닌 것은 버린다**(한때 `''` 가 저장됐다). */
|
|
38
|
+
export function isGradeCode(v) {
|
|
39
|
+
return typeof v === 'string' && 등급값.includes(v);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* **등급 코드 집합으로 단어 하나를 거른다 — 이 결정은 여기 하나다.**
|
|
43
|
+
*
|
|
44
|
+
* (205) 를 지을 때 맞추개를 `phonemePairSearch.matchesFilters` 하나로 알고 넷을 고쳤는데,
|
|
45
|
+
* **단어훈련 풀**(`wordTrainingPool`)이 같은 결정을 **두 줄로 따로** 갖고 있었다
|
|
46
|
+
* (`!w.grade || !gradeSet.has(w.grade)`). 그 둘은 빈 등급을 **늘 떨어뜨리므로** `없음` 을 골라도
|
|
47
|
+
* 0개였다(말귀_1 실측 2026-10-12: 매니저 단어표는 맞고 단어훈련 페이지는 틀렸다).
|
|
48
|
+
*
|
|
49
|
+
* **같은 결정이 세 자리에 적혀 있으면 하나는 안 따라온다.** 그래서 세 자리가 이 하나를 부른다.
|
|
50
|
+
* `codes` 가 비면 거르지 않는다(전체).
|
|
51
|
+
*/
|
|
52
|
+
export function matchesGradeCodes(codes, grade) {
|
|
53
|
+
if (!codes)
|
|
54
|
+
return true;
|
|
55
|
+
const set = codes instanceof Set ? codes : new Set(codes);
|
|
56
|
+
if (set.size === 0)
|
|
57
|
+
return true;
|
|
58
|
+
if (isBlankGrade(grade))
|
|
59
|
+
return set.has(GRADE_NONE);
|
|
60
|
+
return set.has(String(grade));
|
|
61
|
+
}
|
|
14
62
|
const 섹션들 = ['grade', 'syllables', 'pos', 'wordType'];
|
|
15
|
-
|
|
63
|
+
/** 등급 코드 — `'none'` 은 **등급이 빈 단어**다(값이 아니라 **빔**이다. GRADE_NONE 주석을 볼 것) */
|
|
64
|
+
const 등급값 = ['1', '2', '3', '4', 'none'];
|
|
16
65
|
const 음절값 = ['1', '2', '3', '4', '5+'];
|
|
17
66
|
const 품사값 = ['noun', 'verb', 'modifier'];
|
|
18
67
|
const 어종값 = ['native', 'sino', 'foreign', 'mixed'];
|
|
@@ -44,7 +93,11 @@ function 사전(lng) {
|
|
|
44
93
|
wordType: t('wordSearch.filter.wordType', '유형'),
|
|
45
94
|
},
|
|
46
95
|
값: {
|
|
47
|
-
grade:
|
|
96
|
+
grade: {
|
|
97
|
+
...Object.fromEntries(등급값.filter(n => n !== GRADE_NONE).map((n) => [n, t('wordSearch.filter.grade', { n: Number(n), defaultValue: `${n}등급` })])),
|
|
98
|
+
/* **`없음` 은 숫자 틀을 안 쓴다** — `t('…grade', { n: Number('none') })` 은 `NaN등급` 이 된다 */
|
|
99
|
+
[GRADE_NONE]: t('wordSearch.filter.gradeNone', '없음'),
|
|
100
|
+
},
|
|
48
101
|
syllables: {
|
|
49
102
|
...Object.fromEntries(['1', '2', '3', '4'].map((n) => [n, t('wordSearch.filter.syllable', { n: Number(n), defaultValue: `${n}음절` })])),
|
|
50
103
|
'5+': t('wordSearch.filter.syllable5plus', '5음절 이상'),
|
|
@@ -105,6 +158,10 @@ export function wordAttributeValueCodeOf(section, value) {
|
|
|
105
158
|
}
|
|
106
159
|
return null;
|
|
107
160
|
}
|
|
161
|
+
/** 코드 → 지금 언어의 라벨. `'none'` 도 사전 꼴로 나온다(숫자 틀 `{{n}}등급` 에 넣으면 `NaN등급` 이 된다). 모르는 코드는 그대로. */
|
|
162
|
+
export function wordAttributeValueLabelOf(section, code, lng = i18n.language) {
|
|
163
|
+
return 사전(lng).값[section][code] ?? code;
|
|
164
|
+
}
|
|
108
165
|
const 배열로 = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : typeof v === 'string' && v ? [v] : []);
|
|
109
166
|
/**
|
|
110
167
|
* @public filterSelected(라벨 열쇠·라벨 값, 어느 언어든 섞여 있어도) → **코드 모양**. 저장할 때 쓴다.
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { getImageExistenceStats, getImageOkWordSet } from './imageConfig';
|
|
13
13
|
import { getWordPool } from './wordPool';
|
|
14
|
+
import { matchesGradeCodes } from './wordAttributeFilterCodes';
|
|
14
15
|
import { shuffle } from './array';
|
|
15
16
|
import { getPairIndex } from './wordPairIndex';
|
|
16
17
|
import { classifyPairDifficulty, wordContainsPhoneme, wordHasNoCoda, wordHasSyllableMatching, syllableLength, findTypedContrastWords, countPhonemeDiff, createDecompCache } from './koreanSyllable';
|
|
@@ -259,7 +260,7 @@ export function answerWordFilter(opts) {
|
|
|
259
260
|
return null;
|
|
260
261
|
const pron = opts.phonemeView === 'pron';
|
|
261
262
|
return (w) => {
|
|
262
|
-
if (gradeSet
|
|
263
|
+
if (!matchesGradeCodes(gradeSet, w.grade))
|
|
263
264
|
return false;
|
|
264
265
|
if (preds.length === 0)
|
|
265
266
|
return true;
|
|
@@ -308,7 +309,7 @@ async function _buildUsablePool(opts) {
|
|
|
308
309
|
let candidates = MVP.filter(w => {
|
|
309
310
|
if (!w.w)
|
|
310
311
|
return false;
|
|
311
|
-
if (gradeSet
|
|
312
|
+
if (!matchesGradeCodes(gradeSet, w.grade))
|
|
312
313
|
return false;
|
|
313
314
|
if (useAllCandidates)
|
|
314
315
|
return true;
|