@ihabdevteam/core 0.145.0 → 0.146.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 +34 -0
- package/dist/components.cjs.js +175 -175
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +182 -182
- package/dist/ihabdevteam-core.d.ts +3 -1
- package/dist/packages/core/src/components/ActionCard.js +10 -2
- package/dist/packages/core/src/components/LandingHeader.js +12 -5
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +17 -4
- package/dist/packages/core/src/components/Popup/ListPopup.js +1 -0
- package/dist/packages/core/src/components/Popup/Popup.js +4 -0
- package/dist/packages/core/src/components/Section.js +2 -1
- package/dist/packages/core/src/components/Table.js +18 -8
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +2 -0
- package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +11 -1
- package/dist/packages/core/src/hooks/useTablePaging.js +4 -3
- package/dist/packages/core/src/utils/textSort.d.ts +4 -4
- package/dist/packages/core/src/utils/textSort.js +56 -28
- package/dist/packages/core/src/utils/trainingItemResults.js +3 -1
- package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +2 -0
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +5 -2
- package/dist/packages/core/src/utils/wordTrainingPool.d.ts +1 -0
- package/dist/packages/core/src/utils/wordTrainingPool.js +20 -6
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +35 -14
- package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.js +4 -3
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- package/package.json +1 -1
|
@@ -1386,7 +1386,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1386
1386
|
*
|
|
1387
1387
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1388
1388
|
*/
|
|
1389
|
-
export declare const CORE_VERSION = "0.
|
|
1389
|
+
export declare const CORE_VERSION = "0.146.0";
|
|
1390
1390
|
|
|
1391
1391
|
/** @public */
|
|
1392
1392
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -7397,6 +7397,8 @@ export declare type TrainingPlan = {
|
|
|
7397
7397
|
declare interface TrainingProblem {
|
|
7398
7398
|
question: string;
|
|
7399
7399
|
distractors: string[];
|
|
7400
|
+
/** 탈락 문제에서 빠진 조각(합성어 부분 탈락이 채운다 — 앞뒤로 같은 조각이 있으면 remain 만으로는 어느 쪽인지 모른다) */
|
|
7401
|
+
dropped?: string;
|
|
7400
7402
|
}
|
|
7401
7403
|
|
|
7402
7404
|
/** @public */
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId } from 'react';
|
|
2
4
|
import { cx } from '../utils/cx';
|
|
3
5
|
import Icon from './Icon';
|
|
4
6
|
import './ActionCard.css';
|
|
@@ -15,14 +17,20 @@ import './ActionCard.css';
|
|
|
15
17
|
* @public
|
|
16
18
|
*/
|
|
17
19
|
export default function ActionCard({ icon, title, description, cta, tone = 'main', disabled = false, onClick, className = '', }) {
|
|
20
|
+
/* 이름은 제목, 설명·행선지(cta)는 설명으로 잇는다 — aria-label={title} 이 안의 글자를 덮어 "확인 중..." 같은 cta 변화와
|
|
21
|
+
설명이 낭독기에서 사라졌다(kscan 홈) */
|
|
22
|
+
const uid = useId();
|
|
23
|
+
const titleId = `${uid}-title`;
|
|
24
|
+
const descId = `${uid}-desc`;
|
|
25
|
+
const ctaId = `${uid}-cta`;
|
|
18
26
|
const activate = () => {
|
|
19
27
|
if (!disabled)
|
|
20
28
|
onClick?.();
|
|
21
29
|
};
|
|
22
|
-
return (_jsx("section", { role: "button", tabIndex: disabled ? -1 : 0, "aria-disabled": disabled || undefined, "aria-
|
|
30
|
+
return (_jsx("section", { role: "button", tabIndex: disabled ? -1 : 0, "aria-disabled": disabled || undefined, "aria-labelledby": titleId, "aria-describedby": `${descId} ${ctaId}`, onClick: activate, onKeyDown: (e) => {
|
|
23
31
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
24
32
|
e.preventDefault();
|
|
25
33
|
activate();
|
|
26
34
|
}
|
|
27
|
-
}, className: cx('action-card', `action-card--${tone}`, disabled && 'action-card--disabled', className), children: _jsxs("div", { className: "action-card__body", children: [_jsx("span", { "aria-hidden": true, className: "action-card__chip", children: _jsx(Icon, { icon: icon, size: 34 }) }), _jsxs("div", { className: "action-card__text", children: [_jsx("h2", { className: "action-card__title", children: title }), _jsx("p", { className: "action-card__desc", children: description })] }), _jsxs("div", { className: "action-card__cta", children: [_jsx("span", { children: cta }), _jsx("span", { "aria-hidden": true, className: "action-card__arrow", children: _jsx(Icon, { icon: "arrow-right", size: 18 }) })] })] }) }));
|
|
35
|
+
}, className: cx('action-card', `action-card--${tone}`, disabled && 'action-card--disabled', className), children: _jsxs("div", { className: "action-card__body", children: [_jsx("span", { "aria-hidden": true, className: "action-card__chip", children: _jsx(Icon, { icon: icon, size: 34 }) }), _jsxs("div", { className: "action-card__text", children: [_jsx("h2", { id: titleId, className: "action-card__title", children: title }), _jsx("p", { id: descId, className: "action-card__desc", children: description })] }), _jsxs("div", { className: "action-card__cta", children: [_jsx("span", { id: ctaId, children: cta }), _jsx("span", { "aria-hidden": true, className: "action-card__arrow", children: _jsx(Icon, { icon: "arrow-right", size: 18 }) })] })] }) }));
|
|
28
36
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useId, useState } from 'react';
|
|
3
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { pushEsc } from '../hooks/escStack';
|
|
6
6
|
import './LandingHeader.css';
|
|
@@ -27,12 +27,19 @@ export default function LandingHeader({ logo, logoAlt = '', logoHref, links = []
|
|
|
27
27
|
// 서랍 id 를 고정 문자열로 두면 한 화면에 헤더가 둘일 때(미리보기·놀이터 견본) id 가 겹쳐
|
|
28
28
|
// 햄버거의 aria-controls 가 엉뚱한 서랍을 가리킨다 — 헤더마다 따로 짓는다.
|
|
29
29
|
const drawerId = useId();
|
|
30
|
+
const toggleRef = useRef(null);
|
|
30
31
|
/* 서랍이 열려 있으면 Esc 로 닫는다(공유 ESC 스택 — 맨 위 하나만). 넓은 화면(961px~, 서랍이 CSS 로 숨는 폭)으로 가면
|
|
31
32
|
닫는다 — 안 닫으면 open 이 남아 좁은 폭으로 돌아오는 순간 서랍이 다시 펼쳐졌다. */
|
|
32
33
|
useEffect(() => {
|
|
33
34
|
if (!open)
|
|
34
35
|
return;
|
|
35
|
-
|
|
36
|
+
// 서랍 안에 포커스가 있으면 햄버거로 돌려 준다 — 서랍이 hidden 이 되며 포커스가 숨은 링크에 남아 갈 곳을 잃었다
|
|
37
|
+
const 풀기 = pushEsc(() => {
|
|
38
|
+
const 서랍 = document.getElementById(drawerId);
|
|
39
|
+
if (서랍 && 서랍.contains(document.activeElement))
|
|
40
|
+
toggleRef.current?.focus();
|
|
41
|
+
setOpen(false);
|
|
42
|
+
});
|
|
36
43
|
const mq = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(min-width: 961px)') : null;
|
|
37
44
|
const 넓어짐 = (e) => { if (e.matches)
|
|
38
45
|
setOpen(false); };
|
|
@@ -42,7 +49,7 @@ export default function LandingHeader({ logo, logoAlt = '', logoHref, links = []
|
|
|
42
49
|
mq.addEventListener?.('change', 넓어짐);
|
|
43
50
|
}
|
|
44
51
|
return () => { 풀기(); mq?.removeEventListener?.('change', 넓어짐); };
|
|
45
|
-
}, [open]);
|
|
52
|
+
}, [open, drawerId]);
|
|
46
53
|
const innerClass = containerClassName
|
|
47
54
|
? `${containerClassName} landing-header__inner`
|
|
48
55
|
: 'landing-header__inner';
|
|
@@ -58,7 +65,7 @@ export default function LandingHeader({ logo, logoAlt = '', logoHref, links = []
|
|
|
58
65
|
return logoHref
|
|
59
66
|
? _jsx("a", { href: logoHref, className: "landing-header__brand", children: brand })
|
|
60
67
|
: _jsx("div", { className: "landing-header__brand", children: brand });
|
|
61
|
-
})(), links.length > 0 && (_jsx("nav", { className: "landing-header__links", children: links.map(item => (_jsx("span", { children: link(item) }, item.href))) })), _jsxs("div", { className: "landing-header__actions", children: [actions, links.length > 0 && (_jsx("button", { type: "button", onClick: () => setOpen(v => !v), "aria-expanded": open, "aria-controls": drawerId, "aria-label": open ? menuLabel.close : menuLabel.open, className: open
|
|
68
|
+
})(), links.length > 0 && (_jsx("nav", { className: "landing-header__links", children: links.map((item, i) => (_jsx("span", { children: link(item) }, `${i}:${item.href}`))) })), _jsxs("div", { className: "landing-header__actions", children: [actions, links.length > 0 && (_jsx("button", { ref: toggleRef, type: "button", onClick: () => setOpen(v => !v), "aria-expanded": open, "aria-controls": drawerId, "aria-label": open ? menuLabel.close : menuLabel.open, className: open
|
|
62
69
|
? 'landing-header__toggle landing-header__toggle--open'
|
|
63
|
-
: 'landing-header__toggle', children: _jsxs("span", { className: "landing-header__burger", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }) }))] })] }), links.length > 0 && (_jsx("nav", { id: drawerId, hidden: !open, className: "landing-header__drawer", children: _jsx("div", { className: containerClassName ?? undefined, children: links.map(item => (_jsx("span", { className: "landing-header__drawer-item", children: link(item, () => setOpen(false)) }, item.href))) }) }))] }));
|
|
70
|
+
: 'landing-header__toggle', children: _jsxs("span", { className: "landing-header__burger", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }) }))] })] }), links.length > 0 && (_jsx("nav", { id: drawerId, hidden: !open, className: "landing-header__drawer", children: _jsx("div", { className: containerClassName ?? undefined, children: links.map((item, i) => (_jsx("span", { className: "landing-header__drawer-item", children: link(item, () => setOpen(false)) }, `${i}:${item.href}`))) }) }))] }));
|
|
64
71
|
}
|
|
@@ -22,6 +22,12 @@ export default function ConfirmPopup(props) {
|
|
|
22
22
|
동안 한 번 더 누르면 onConfirm 이 또 불렸다(말귀 단어표 삭제·이용권 삭제가 두 번 나가 성공 뒤 실패 토스트).
|
|
23
23
|
닫기로 끝나면 잠금을 풀지 않는다 — 창이 사라지는 중이다. 닫지 않고 끝나면(closeOnConfirm=false·실패) 풀고, 처리 중에
|
|
24
24
|
단추가 꺼지며 body 로 떨어진 포커스를 누른 단추로 되돌린다. */
|
|
25
|
+
/* closeOnConfirm=false 로 두고 콜백이 넘겨받은 close() 를 **스스로** 부르면(문서가 권하는 "성공했을 때만 닫기") 그것도 닫힘으로
|
|
26
|
+
센다 — 전에는 closeOnConfirm(false) 만 돌려 닫히는 260ms 동안 잠금이 풀려, 한 번 더 누르면 확인이 두 번 나갔다. */
|
|
27
|
+
const 닫기를센다 = (closeWithAnimation) => {
|
|
28
|
+
let 닫았음 = false;
|
|
29
|
+
return { close: () => { 닫았음 = true; closeWithAnimation(); }, 닫았나: () => 닫았음 };
|
|
30
|
+
};
|
|
25
31
|
const 한번만 = async (일) => {
|
|
26
32
|
if (busyRef.current)
|
|
27
33
|
return;
|
|
@@ -32,6 +38,11 @@ export default function ConfirmPopup(props) {
|
|
|
32
38
|
try {
|
|
33
39
|
닫는중 = await 일();
|
|
34
40
|
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
/* 거절은 창을 남기고 잠금을 푸는 것으로 끝낸다(계약) — 받아 주지 않으면 처리되지 않은 거절로 새어 Next 개발 오버레이가 떴다 */
|
|
43
|
+
if (typeof console !== 'undefined')
|
|
44
|
+
console.error(e);
|
|
45
|
+
}
|
|
35
46
|
finally {
|
|
36
47
|
if (!닫는중) {
|
|
37
48
|
busyRef.current = false;
|
|
@@ -58,11 +69,12 @@ export default function ConfirmPopup(props) {
|
|
|
58
69
|
// 두 번째는 엉뚱한 실패로 돌아온다.
|
|
59
70
|
onClick: async ({ closeWithAnimation }) => {
|
|
60
71
|
await 한번만(async () => {
|
|
61
|
-
|
|
72
|
+
const 닫기 = 닫기를센다(closeWithAnimation);
|
|
73
|
+
await extraAction.onClick(닫기.close);
|
|
62
74
|
const 닫음 = extraAction.closeOnClick ?? true;
|
|
63
75
|
if (닫음)
|
|
64
76
|
closeWithAnimation();
|
|
65
|
-
return
|
|
77
|
+
return 닫음 || 닫기.닫았나();
|
|
66
78
|
});
|
|
67
79
|
},
|
|
68
80
|
}] : []),
|
|
@@ -78,10 +90,11 @@ export default function ConfirmPopup(props) {
|
|
|
78
90
|
disabled: confirmDisabled || busy,
|
|
79
91
|
onClick: async ({ closeWithAnimation }) => {
|
|
80
92
|
await 한번만(async () => {
|
|
81
|
-
|
|
93
|
+
const 닫기 = 닫기를센다(closeWithAnimation);
|
|
94
|
+
await onConfirm(닫기.close);
|
|
82
95
|
if (closeOnConfirm)
|
|
83
96
|
closeWithAnimation();
|
|
84
|
-
return closeOnConfirm;
|
|
97
|
+
return closeOnConfirm || 닫기.닫았나();
|
|
85
98
|
});
|
|
86
99
|
},
|
|
87
100
|
},
|
|
@@ -66,6 +66,7 @@ export default function ListPopup(props) {
|
|
|
66
66
|
setSortKey(key);
|
|
67
67
|
setSortAsc(true);
|
|
68
68
|
}
|
|
69
|
+
setPage(1); // 차례가 바뀌면 첫 쪽으로 — 3쪽에서 머리를 누르면 3쪽에 남아 새 차례의 앞을 못 봤다
|
|
69
70
|
}, [sortKey]);
|
|
70
71
|
// countLabel("총 {n}명")을 Toolbar count의 prefix/suffix로 분해
|
|
71
72
|
const countParts = useMemo(() => {
|
|
@@ -134,6 +134,10 @@ export default function Popup(props) {
|
|
|
134
134
|
setClosing(false);
|
|
135
135
|
}, []);
|
|
136
136
|
const animateThen = useCallback((cb, 닫기뿐 = false) => {
|
|
137
|
+
/* 이미 떠난 창을 닫으려는 부름(ConfirmPopup 의 onConfirm 안에서 부모가 창을 치운 뒤 closeWithAnimation)은 받지 않는다 —
|
|
138
|
+
정리가 끝난 뒤 걸린 타이머라 걷히지 않고 260ms 뒤 **취소 경로의 onClose** 가 불렸다(kscan 이 closeOnConfirm=false 로 우회). */
|
|
139
|
+
if (닫기뿐 && !살아있음Ref.current)
|
|
140
|
+
return;
|
|
137
141
|
if (closingRef.current)
|
|
138
142
|
return;
|
|
139
143
|
closingRef.current = true;
|
|
@@ -10,7 +10,8 @@ const TITLE_CLASS = {
|
|
|
10
10
|
/** @public */
|
|
11
11
|
export default function Section({ type = 'page', title, headingLevel, icon, action, disc, children, className, style }) {
|
|
12
12
|
const titleClass = TITLE_CLASS[type];
|
|
13
|
-
|
|
13
|
+
// action·icon 만 줘도 머리를 그린다 — 제목·설명이 없으면 추가 단추가 조용히 버려졌다
|
|
14
|
+
const hasHeader = title || disc || action || icon;
|
|
14
15
|
/* 제목이 맨 div 라 낭독기의 제목 목록에 안 잡혔다. 단계는 페이지 구조에 달려 있어
|
|
15
16
|
부르는 쪽이 정한다 — 안 주면 예전 그대로 div. 모양은 클래스(h3/h4/h5)가 정하고
|
|
16
17
|
제목 태그의 기본 여백은 .section__title 의 margin:0 이 지운다. */
|
|
@@ -105,9 +105,12 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
105
105
|
const d = dragRef.current;
|
|
106
106
|
if (!d)
|
|
107
107
|
return;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
/* 저장할 값(colWidthsRef)을 **여기서 곧바로** 맞춘다 — 렌더 뒤 효과에서야 갱신돼, 마지막 이동 직후 손을 떼면 화면은 새 폭인데
|
|
109
|
+
저장소에는 한 박자 전 폭이 남았다(window pointermove 는 React 밖이라 렌더가 밀린다). */
|
|
110
|
+
const prev = colWidthsRef.current;
|
|
111
|
+
if (!prev)
|
|
112
|
+
return;
|
|
113
|
+
{
|
|
111
114
|
const copy = [...prev];
|
|
112
115
|
if (d.next != null && d.startWNext != null) {
|
|
113
116
|
// fit — 이동량을 두 컬럼이 나눠 갖는다. 둘 다 최소폭 밑으로 내려가지 않도록
|
|
@@ -120,8 +123,9 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
120
123
|
else {
|
|
121
124
|
copy[d.index] = Math.max(MIN_COL_W, d.startW + (e.clientX - d.startX));
|
|
122
125
|
}
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
colWidthsRef.current = copy;
|
|
127
|
+
setColWidths(copy);
|
|
128
|
+
}
|
|
125
129
|
}, []);
|
|
126
130
|
const onResizeEnd = useCallback(() => {
|
|
127
131
|
dragRef.current = null;
|
|
@@ -157,6 +161,7 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
157
161
|
dragRef.current = next >= 0
|
|
158
162
|
? { index, startX: e.clientX, startW: measured[index], next, startWNext: measured[next] }
|
|
159
163
|
: { index, startX: e.clientX, startW: measured[index] };
|
|
164
|
+
colWidthsRef.current = measured;
|
|
160
165
|
setColWidths(measured);
|
|
161
166
|
window.addEventListener('pointermove', onResizeMove);
|
|
162
167
|
window.addEventListener('pointerup', onResizeEnd);
|
|
@@ -194,7 +199,10 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
194
199
|
if (c)
|
|
195
200
|
w = Math.max(w, c.getBoundingClientRect().width);
|
|
196
201
|
}
|
|
197
|
-
|
|
202
|
+
/* 고정 px 열은 정해 둔 값 그대로 — 최소 폭(MIN_COL_W)으로 올린 값을 고정 합에 넣으면 격자에는 원래 값(40px)으로 나가
|
|
203
|
+
그만큼(8px) 표 오른쪽이 비었다(말귀 어드민 버그 탭·쿠폰) */
|
|
204
|
+
const 고정값 = isFixedPxRef.current(i) ? parseFloat(defaultColsRef.current[i]) : NaN;
|
|
205
|
+
return Number.isFinite(고정값) ? 고정값 : Math.max(MIN_COL_W, Math.ceil(w));
|
|
198
206
|
});
|
|
199
207
|
/* 격자가 실제로 채우는 폭 — `clientWidth` 는 안쪽 여백을 포함하므로 뺀다
|
|
200
208
|
(되돌리기 단추 자리로 40px 을 비워 두었다). */
|
|
@@ -319,10 +327,12 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
319
327
|
const isDragging = draggingId != null && item.id === draggingId;
|
|
320
328
|
const 눌림 = !!onRowClick && (isRowClickable ? isRowClickable(item) : true);
|
|
321
329
|
const isExpanded = expandedId != null && (expandedId instanceof Set ? expandedId.has(item.id) : item.id === expandedId);
|
|
322
|
-
return (_jsx(TableRow
|
|
330
|
+
return (_jsx(TableRow
|
|
331
|
+
// 타입까지 열쇠에 — id 없는 줄(idx 0)과 id:0, 1 과 '1' 이 같은 열쇠가 되어 목록을 바꾼 뒤 옛 줄이 남았다
|
|
332
|
+
, { className: `${눌림 ? ' table-row--clickable' : ''}${onRowDragStart ? ' table-row--draggable' : ''}${isDragging ? ' table-row--dragging' : ''}`.trim(), draggable: !!onRowDragStart,
|
|
323
333
|
// 옆의 핸들러들처럼 있을 때만 넘긴다. 항상 함수를 만들어 넘기면
|
|
324
334
|
// TableRow 가 "클릭 가능"으로 보고 role="button" · tabIndex=0 을 붙여,
|
|
325
335
|
// 클릭 동작이 없는 표의 모든 행이 버튼으로 읽히고 탭 정지점이 된다.
|
|
326
|
-
onClick: 눌림 ? () => onRowClick(item) : undefined, onMouseEnter: onRowMouseEnter ? (e) => onRowMouseEnter(item, e) : undefined, onMouseLeave: onRowMouseLeave ? (e) => onRowMouseLeave(item, e) : undefined, onDragStart: onRowDragStart ? (e) => onRowDragStart(item, e) : undefined, onDragEnd: onRowDragEnd ? (e) => onRowDragEnd(item, e) : undefined, expandedContent: isExpanded && renderExpandedRow ? renderExpandedRow(item) : undefined, children: renderRow(item).map((cell, i) => (_jsx(TableCell, { className: cell.className, style: cell.style, children: cell.content }, i))) },
|
|
336
|
+
onClick: 눌림 ? () => onRowClick(item) : undefined, onMouseEnter: onRowMouseEnter ? (e) => onRowMouseEnter(item, e) : undefined, onMouseLeave: onRowMouseLeave ? (e) => onRowMouseLeave(item, e) : undefined, onDragStart: onRowDragStart ? (e) => onRowDragStart(item, e) : undefined, onDragEnd: onRowDragEnd ? (e) => onRowDragEnd(item, e) : undefined, expandedContent: isExpanded && renderExpandedRow ? renderExpandedRow(item) : undefined, children: renderRow(item).map((cell, i) => (_jsx(TableCell, { className: cell.className, style: cell.style, children: cell.content }, i))) }, item.id != null ? `${typeof item.id}:${item.id}` : `__idx:${idx}`));
|
|
327
337
|
}) })] }))] }) }));
|
|
328
338
|
}
|
|
@@ -850,6 +850,8 @@ function TrainingPagePopupInner(props) {
|
|
|
850
850
|
results: (resultOverlay.results || resultOverlay.answers || []),
|
|
851
851
|
contextsLength: resultOverlay.results ? resultOverlay.results.length : (resultOverlay.answers ? resultOverlay.answers.length : 0),
|
|
852
852
|
onReplay: () => {
|
|
853
|
+
// 다시 푸는 판의 소요 시간은 지금부터 — 마운트 때 한 번만 찍어 앞 판 시간·결과 창을 보던 시간까지 섞였다(60초가 660초)
|
|
854
|
+
completionStartRef.current = Date.now();
|
|
853
855
|
결과창닫기();
|
|
854
856
|
setReplayCount((n) => n + 1);
|
|
855
857
|
if (hasScenario) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback, useMemo, useState, useEffect } from 'react';
|
|
3
|
+
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import TrainingStep from './TrainingStep';
|
|
6
6
|
import TrainingWordCard from './TrainingWordCard';
|
|
@@ -29,6 +29,16 @@ export default function TrainingStepCardOrder({ index, stepIndex, isLast = false
|
|
|
29
29
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
30
30
|
}, [choicesKey]);
|
|
31
31
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
32
|
+
/* 쪽 전환: stepIndex 가 이 단계 이상이었다가 미만으로 돌아오면(다음 문제가 시작됨) 고른 순서를 비운다(Confirm 과 같게) —
|
|
33
|
+
보기가 같은 다음 문항(같은 세 낱말·섞은 배치가 같음)에서 앞 문항의 순서가 남아 고른 적 없는 카드까지 채점됐다. */
|
|
34
|
+
const prevStepIndexRef = useRef(undefined);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const prev = prevStepIndexRef.current;
|
|
37
|
+
if (typeof prev === 'number' && typeof stepIndex === 'number' && typeof index === 'number' && prev >= index && stepIndex < index) {
|
|
38
|
+
setSelectedOrder(Array.from({ length: choiceCount }, () => null));
|
|
39
|
+
}
|
|
40
|
+
prevStepIndexRef.current = stepIndex;
|
|
41
|
+
}, [stepIndex, index, choiceCount]);
|
|
32
42
|
const nextRank = useMemo(() => selectedOrder.filter(v => v !== null).length + 1, [selectedOrder]);
|
|
33
43
|
/* 다음 배열은 updater **밖에서** 셈하고 알린다 — updater 안에서 onChange·onDone 을 부르면 StrictMode 에서 한 번
|
|
34
44
|
클릭에 두 번씩 나갔다. 풀 때도 알린다(전에는 풀면 부모가 옛 순서를 그대로 들고 있었다). */
|
|
@@ -46,9 +46,10 @@ export function useTablePaging(rows, resetOn, opts) {
|
|
|
46
46
|
const total = Math.max(1, Math.ceil(rows.length / Number(pageSize)));
|
|
47
47
|
/* 행이 줄어 범위를 넘으면 **상태도** 끝 쪽으로 맞춘다 — 보이는 쪽만 조이고 상태를 두면, 필터로 줄었다가 다시 늘 때 예전
|
|
48
48
|
5쪽으로 튀었다. 렌더 중에 맞춘다(위 resetOn 과 같은 꼴). */
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
// 1 보다 작은 쪽(setPage(0)·-1)도 막는다 — 0 은 빈 쪽, -1 은 음수 slice 로 엉뚱한 줄이 나왔다
|
|
50
|
+
if (page > total || page < 1 || !Number.isFinite(page))
|
|
51
|
+
setPage(Math.min(Math.max(1, Number.isFinite(page) ? page : 1), total));
|
|
52
|
+
const 이쪽 = Math.min(Math.max(1, Number.isFinite(page) ? page : 1), total);
|
|
52
53
|
const pageRows = useMemo(() => rows.slice((이쪽 - 1) * Number(pageSize), 이쪽 * Number(pageSize)), [rows, 이쪽, pageSize]);
|
|
53
54
|
return {
|
|
54
55
|
pageRows,
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/** 글자 둘을 한국어 차례로 견준다 */
|
|
2
2
|
export declare function 한글순(a: string, b: string): number;
|
|
3
3
|
/**
|
|
4
|
-
* 표의 한 칸끼리 견준다 — **숫자는 숫자로,
|
|
4
|
+
* 표의 한 칸끼리 견준다 — **숫자는 숫자로, 글자 속 숫자도 수로.**
|
|
5
5
|
*
|
|
6
6
|
* 표의 칸은 무엇이든 올 수 있어서 늘 글자로 견주면 **숫자 칸이 망가진다**
|
|
7
|
-
* (`10` 이 `9` 앞에 선다).
|
|
8
|
-
*
|
|
7
|
+
* (`10` 이 `9` 앞에 선다). 값을 숫자 조각과 글자 조각으로 나눠 앞에서부터 견준다 —
|
|
8
|
+
* `회차 9` 가 `회차 10` 앞, `12.25%` 가 `12.5%` 앞, `-10 dB` 가 `-5 dB` 앞. 숫자 조각은 글자 조각 앞에 선다.
|
|
9
9
|
*
|
|
10
|
-
* 비어 있는 값(`null`·`undefined
|
|
10
|
+
* 비어 있는 값(`null`·`undefined`·NaN)은 빈 글자로 본다 — 부르는 쪽이 이미
|
|
11
11
|
* `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
|
|
12
12
|
*/
|
|
13
13
|
export declare function 칸순(a: unknown, b: unknown): number;
|
|
@@ -22,39 +22,67 @@ const 콜레이터 = (() => {
|
|
|
22
22
|
export function 한글순(a, b) {
|
|
23
23
|
return 콜레이터().compare(a, b);
|
|
24
24
|
}
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
25
|
+
const 조각열쇠 = new Map();
|
|
26
|
+
function 조각내기(s) {
|
|
27
|
+
const 있음 = 조각열쇠.get(s);
|
|
28
|
+
if (있음)
|
|
29
|
+
return 있음;
|
|
30
|
+
const out = [];
|
|
31
|
+
// 뒤돌아보기(?<=) 는 iOS 15 Safari 가 못 읽어 모듈째 깨지므로 쓰지 않는다 — 빼기표 앞 글자를 손으로 본다
|
|
32
|
+
const re = /-?\d+(?:\.\d+)?/g;
|
|
33
|
+
let 끝 = 0;
|
|
34
|
+
for (let m = re.exec(s); m; m = re.exec(s)) {
|
|
35
|
+
let 시작 = m.index;
|
|
36
|
+
let 수 = m[0];
|
|
37
|
+
if (수[0] === '-' && 시작 > 0 && !/\s/.test(s[시작 - 1])) {
|
|
38
|
+
시작 += 1;
|
|
39
|
+
수 = 수.slice(1);
|
|
40
|
+
}
|
|
41
|
+
if (시작 > 끝)
|
|
42
|
+
out.push(s.slice(끝, 시작));
|
|
43
|
+
out.push(Number(수));
|
|
44
|
+
끝 = m.index + m[0].length;
|
|
45
|
+
}
|
|
46
|
+
if (끝 < s.length)
|
|
47
|
+
out.push(s.slice(끝));
|
|
48
|
+
if (조각열쇠.size > 5000)
|
|
49
|
+
조각열쇠.clear();
|
|
50
|
+
조각열쇠.set(s, out);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
37
53
|
/**
|
|
38
|
-
* 표의 한 칸끼리 견준다 — **숫자는 숫자로,
|
|
54
|
+
* 표의 한 칸끼리 견준다 — **숫자는 숫자로, 글자 속 숫자도 수로.**
|
|
39
55
|
*
|
|
40
56
|
* 표의 칸은 무엇이든 올 수 있어서 늘 글자로 견주면 **숫자 칸이 망가진다**
|
|
41
|
-
* (`10` 이 `9` 앞에 선다).
|
|
42
|
-
*
|
|
57
|
+
* (`10` 이 `9` 앞에 선다). 값을 숫자 조각과 글자 조각으로 나눠 앞에서부터 견준다 —
|
|
58
|
+
* `회차 9` 가 `회차 10` 앞, `12.25%` 가 `12.5%` 앞, `-10 dB` 가 `-5 dB` 앞. 숫자 조각은 글자 조각 앞에 선다.
|
|
43
59
|
*
|
|
44
|
-
* 비어 있는 값(`null`·`undefined
|
|
60
|
+
* 비어 있는 값(`null`·`undefined`·NaN)은 빈 글자로 본다 — 부르는 쪽이 이미
|
|
45
61
|
* `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
|
|
46
62
|
*/
|
|
47
63
|
export function 칸순(a, b) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
const 글 = (v) => (v == null || (typeof v === 'number' && Number.isNaN(v)) ? '' : String(v).trim());
|
|
65
|
+
const sa = 글(a), sb = 글(b);
|
|
66
|
+
if (sa === sb)
|
|
67
|
+
return 0;
|
|
68
|
+
const ka = 조각내기(sa), kb = 조각내기(sb);
|
|
69
|
+
const n = Math.min(ka.length, kb.length);
|
|
70
|
+
for (let i = 0; i < n; i++) {
|
|
71
|
+
const x = ka[i], y = kb[i];
|
|
72
|
+
if (typeof x === 'number' && typeof y === 'number') {
|
|
73
|
+
if (x !== y)
|
|
74
|
+
return x - y;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (typeof x === 'number')
|
|
78
|
+
return -1;
|
|
79
|
+
if (typeof y === 'number')
|
|
80
|
+
return 1;
|
|
81
|
+
const c = 한글순(x, y);
|
|
82
|
+
if (c !== 0)
|
|
83
|
+
return c;
|
|
84
|
+
}
|
|
85
|
+
if (ka.length !== kb.length)
|
|
86
|
+
return ka.length - kb.length;
|
|
87
|
+
return 한글순(sa, sb);
|
|
60
88
|
}
|
|
@@ -55,7 +55,9 @@ export function toItemResults(raw) {
|
|
|
55
55
|
/* 물음이 담긴 칸 이름이 유형마다 다르다 — 문장 훈련 넷(소음 하 듣기·문장 순서화·
|
|
56
56
|
짧은/긴 이야기)은 `sentence` 에, 낱말 훈련은 `word` 에 담는다. `sentence` 를
|
|
57
57
|
빠뜨렸더니 대면 세션 기록의 item_id 가 전부 null 이었다(2026-08-27 실측). */
|
|
58
|
-
|
|
58
|
+
/* 단어 조작(합성·대치·탈락)·확인형은 물음을 `text` 에 담는다 — 빠져 기록의 question 이 null 이라 오답 패널에서 통째로
|
|
59
|
+
빠졌는데, 결과 창(resolveProblem)은 text 를 읽어 보여 두 화면이 어긋났다. 결과 창과 같은 칸들을 본다. */
|
|
60
|
+
question: text(r.question) ?? text(r.q) ?? text(r.sentence) ?? text(r.text) ?? text(r.prompt) ?? text(r.word) ?? text(r.targetSyllable),
|
|
59
61
|
answer: text(r.answer),
|
|
60
62
|
selected: text(r.selectedText) ?? text(r.selected),
|
|
61
63
|
correct: itemCorrectness(r),
|
|
@@ -75,6 +75,8 @@ export declare function syllableContrasts(cs: Syllable, part: 'cho' | 'jung' | '
|
|
|
75
75
|
export interface TrainingProblem {
|
|
76
76
|
question: string;
|
|
77
77
|
distractors: string[];
|
|
78
|
+
/** 탈락 문제에서 빠진 조각(합성어 부분 탈락이 채운다 — 앞뒤로 같은 조각이 있으면 remain 만으로는 어느 쪽인지 모른다) */
|
|
79
|
+
dropped?: string;
|
|
78
80
|
}
|
|
79
81
|
/**
|
|
80
82
|
* @public 2음절 단어 → [문제단어, 남는 1음절(정답), 대립쌍 오답 2]. 정답·오답 모두 '실단어'(wordSet)만 인정.
|
|
@@ -210,7 +210,10 @@ export function primarySelPart(comp) {
|
|
|
210
210
|
/** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). */
|
|
211
211
|
export function syllMatchesComp(s, comp) {
|
|
212
212
|
const ok = (arr, val) => !hasSel(arr) || asArr(arr).includes(val);
|
|
213
|
-
|
|
213
|
+
/* '받침 없음'(jongNone)도 본다 — 풀 필터(wordTrainingPool)는 jongNone 이면 받침 없는 음절만 맞다고 보는데 여기만 무시해, 초성 ㅎ +
|
|
214
|
+
받침 없음으로 골라도 음절 바꾸기가 '해협' 의 '협' 을 바꿀 음절로 골랐다. jongNone 이면 jong 선택은 보지 않는다(풀과 같게). */
|
|
215
|
+
const jongOk = comp.jongNone ? s.jong === 0 : ok(comp.jong, JONGSEONG[s.jong]);
|
|
216
|
+
return ok(comp.cho, CHOSEONG[s.cho]) && ok(comp.jung, JUNGSEONG[s.jung]) && jongOk;
|
|
214
217
|
}
|
|
215
218
|
/**
|
|
216
219
|
* @public 발음 모드에서 comp 매칭에 쓸 음절 배열. 표준발음(pronMap)의 음절수가 철자와 같으면 발음 음절로,
|
|
@@ -407,7 +410,7 @@ export function buildDropCompoundProblem(word, comp, pronMap, phonemeView, wordS
|
|
|
407
410
|
break;
|
|
408
411
|
}
|
|
409
412
|
if (wrongs.length >= 2)
|
|
410
|
-
return { question: word, distractors: [remain, ...wrongs] };
|
|
413
|
+
return { question: word, distractors: [remain, ...wrongs], dropped };
|
|
411
414
|
}
|
|
412
415
|
}
|
|
413
416
|
return null;
|
|
@@ -25,6 +25,7 @@ export declare function pickOne<T>(arr: T[]): T | undefined;
|
|
|
25
25
|
/** 부분 Fisher-Yates: n개만 선택 — arr.length 전체를 교환하지 않고 n번만 교환(대용량 풀 성능용, 공용 sample과 별개). */
|
|
26
26
|
export declare function sample<T>(arr: T[], n: number): T[];
|
|
27
27
|
export declare function getPronMap(): Map<string, string>;
|
|
28
|
+
export declare function pronOfWord(word: string): string;
|
|
28
29
|
/**
|
|
29
30
|
* 후보군에서 n개를 뽑되, 이미 선택된 것들(또는 usedPron으로 미리 배제한 것들)과
|
|
30
31
|
* 발음이 겹치지 않도록 그리디하게 샘플링한다.
|
|
@@ -103,7 +103,7 @@ export function getPronMap() {
|
|
|
103
103
|
_cachedPronMapPool = pool;
|
|
104
104
|
return m;
|
|
105
105
|
}
|
|
106
|
-
function pronOfWord(word) {
|
|
106
|
+
export function pronOfWord(word) {
|
|
107
107
|
return getPronMap().get(word) || word;
|
|
108
108
|
}
|
|
109
109
|
/**
|
|
@@ -433,7 +433,8 @@ async function _buildUsablePool(opts) {
|
|
|
433
433
|
usable = bucket.filter(other => {
|
|
434
434
|
if (other === w.w || pronOfWord(other) === wPron)
|
|
435
435
|
return false;
|
|
436
|
-
|
|
436
|
+
// 대립 비교는 두 모드 모두 **발음** — 철자로 세면 '팥빵[팓빵]/팥밥[팓빱]' 이 2개, '틀다/끊다[끈타]' 가 2개로 셌다
|
|
437
|
+
const d = countPhonemeDiff(wPron, pronOfWord(other));
|
|
437
438
|
return contrastCount >= 5 ? d >= 5 : d === contrastCount;
|
|
438
439
|
});
|
|
439
440
|
}
|
|
@@ -442,11 +443,24 @@ async function _buildUsablePool(opts) {
|
|
|
442
443
|
// 'random' 또는 null(difficulty:false 타입): 음운론적 제약은 없지만,
|
|
443
444
|
// 정답과 오답의 음절수는 같아야 한다(단어 길이만으로 정답을 짐작하지 못하도록) — 본 단어와 다른 단어 + 음절수(글자수) 동일
|
|
444
445
|
const 쓸수있음 = (other) => other !== w.w && other.length === w.w.length && pronOfWord(other) !== wPron;
|
|
445
|
-
/* 셈(fullCount — 가능 단어 수 안내)은
|
|
446
|
-
|
|
446
|
+
/* 셈(fullCount — 가능 단어 수 안내)은 **서로 소리가 다른 오답이 보기 수 − 1 개** 있는지까지만 본다(countUsableWords 가 그
|
|
447
|
+
기준으로 센다). 2.2만 후보마다 600개를 끝까지 걸러 칩 한 번에 300ms 넘게 멈췄다. */
|
|
447
448
|
if (opts.fullCount) {
|
|
448
|
-
const
|
|
449
|
-
|
|
449
|
+
const need = Math.max(1, (opts.choicesPerItem ?? 3) - 1);
|
|
450
|
+
const 고름 = [];
|
|
451
|
+
const 소리 = new Set();
|
|
452
|
+
for (const other of distractorPool) {
|
|
453
|
+
if (!쓸수있음(other))
|
|
454
|
+
continue;
|
|
455
|
+
const p = pronOfWord(other);
|
|
456
|
+
if (소리.has(p))
|
|
457
|
+
continue;
|
|
458
|
+
소리.add(p);
|
|
459
|
+
고름.push(other);
|
|
460
|
+
if (고름.length >= need)
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
usable = 고름;
|
|
450
464
|
}
|
|
451
465
|
else
|
|
452
466
|
usable = distractorPool.filter(쓸수있음);
|