@ihabdevteam/core 0.144.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 +59 -0
- package/dist/components.cjs.js +175 -175
- package/dist/components.esm.css +2 -2
- 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/Dropdown.js +22 -3
- 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/Report/ReportDocument.css +11 -0
- package/dist/packages/core/src/components/Report/ReportDocument.js +11 -5
- 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/Toast.css +5 -0
- 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/components/useToastLifecycle.d.ts +1 -0
- package/dist/packages/core/src/components/useToastLifecycle.js +3 -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
|
}
|
|
@@ -41,7 +41,7 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
|
|
|
41
41
|
useEffect(() => {
|
|
42
42
|
if (open) {
|
|
43
43
|
const idx = options.findIndex(o => o.value === value);
|
|
44
|
-
setActiveIdx(idx >= 0 ? idx : 0);
|
|
44
|
+
setActiveIdx(idx >= 0 ? idx : (options.length ? 0 : -1)); // 빈 목록이면 가리킬 칸이 없다
|
|
45
45
|
requestAnimationFrame(() => {
|
|
46
46
|
const menu = menuRef.current;
|
|
47
47
|
if (!menu)
|
|
@@ -60,6 +60,20 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
|
|
|
60
60
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
61
61
|
}, [open]);
|
|
62
62
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
63
|
+
/* 열린 동안 목록 **길이** 가 바뀌어 가리킴이 목록 밖이면(불러오는 중 [] 로 열었다 목록 도착 · 목록이 줄어듦) 선택값 자리로
|
|
64
|
+
다시 잡는다 — 전에는 목록 밖 칸에서 Enter 가 던지거나(말귀 어드민 상품 고르개), 도착한 목록의 첫 항목을 골랐다.
|
|
65
|
+
목록 안이면 그대로 둔다(인라인 배열로 다시 그려질 때 가리킴이 튀지 않게 한 위 효과의 뜻). */
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!open)
|
|
68
|
+
return;
|
|
69
|
+
setActiveIdx(i => {
|
|
70
|
+
if (i >= 0 && i < options.length)
|
|
71
|
+
return i;
|
|
72
|
+
const idx = options.findIndex(o => o.value === value);
|
|
73
|
+
return idx >= 0 ? idx : (options.length ? 0 : -1);
|
|
74
|
+
});
|
|
75
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
+
}, [open, options.length]);
|
|
63
77
|
const toggle = useCallback(() => { if (!disabled)
|
|
64
78
|
rawToggle(); }, [disabled, rawToggle]);
|
|
65
79
|
const select = useCallback((opt) => {
|
|
@@ -105,9 +119,14 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
|
|
|
105
119
|
return prev >= 0 ? prev : i;
|
|
106
120
|
});
|
|
107
121
|
}
|
|
108
|
-
else if (e.key === 'Enter'
|
|
122
|
+
else if (e.key === 'Enter' || e.key === ' ') {
|
|
123
|
+
/* 열린 목록에서 Space 도 가리킨 항목을 고른다(네이티브 select·APG 와 같게) — 전에는 단추 기본 동작(keyup→click)이 목록을
|
|
124
|
+
닫기만 해 고른 것이 사라졌다(소리방향성 출력 장치). 가리킨 것이 없으면 닫기만 한다. */
|
|
109
125
|
e.preventDefault();
|
|
110
|
-
|
|
126
|
+
if (options[activeIdx])
|
|
127
|
+
select(options[activeIdx]);
|
|
128
|
+
else if (e.key === ' ')
|
|
129
|
+
setOpen(false);
|
|
111
130
|
}
|
|
112
131
|
}, [disabled, open, options, activeIdx, select, setOpen]);
|
|
113
132
|
const menu = open ? (_jsx("div", { ref: menuRef, id: listId, className: "dropdown__menu", "data-tooltip-ignore": true, role: "listbox", style: menuStyle, onMouseLeave: handleMenuLeave,
|
|
@@ -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;
|
|
@@ -131,4 +131,15 @@
|
|
|
131
131
|
.report-doc__page:last-child {
|
|
132
132
|
break-after: auto;
|
|
133
133
|
}
|
|
134
|
+
|
|
135
|
+
/* 숨은 재기 사본(자 + 모든 블록)이 인쇄에서도 흐름 높이를 늘려 끝에 빈 쪽이 붙었다(1장 → 2쪽). 높이를 0 으로 접되, 안의 자는
|
|
136
|
+
줄어들지 않게 한다 — 자가 0 이 되면 ResizeObserver 가 다시 재어 모두 한 장에 몰린다(Chromium 확인). */
|
|
137
|
+
.report-doc__measure {
|
|
138
|
+
height: 0;
|
|
139
|
+
overflow: hidden;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.report-doc__measure > * {
|
|
143
|
+
flex-shrink: 0;
|
|
144
|
+
}
|
|
134
145
|
}
|
|
@@ -96,18 +96,23 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
|
|
|
96
96
|
재지 않았다 — 자의 높이(자크기)를 의존성에 두어 크기가 생기면 다시 잰다. */
|
|
97
97
|
/* 머리·바닥 높이는 **갈래마다** 잰다 — 제목이 있는 갈래와 없는 갈래는 머리 높이가 달라, 첫 갈래 것 하나로 재면 제목 있는
|
|
98
98
|
뒤 갈래의 장이 넘쳤다. */
|
|
99
|
+
/* 높이는 **소수 그대로** 잰다 — offsetHeight 는 정수로 반올림해, 소수 높이 블록이 모이면 반올림 합은 예산과 같은데 실제 장은
|
|
100
|
+
종이보다 3px 넘쳤고(mm 규격의 자 1016.69px 는 1017 로 올림) 인쇄에서는 그 1px 로 쪽이 하나 더 생겼다(Chromium PDF 확인).
|
|
101
|
+
rect 가 0 인 환경(레이아웃 없는 시험)에서는 offsetHeight 로 물러선다. */
|
|
102
|
+
const 높이 = (node) => node.getBoundingClientRect().height || node.offsetHeight;
|
|
99
103
|
const chromeOf = new Map();
|
|
100
104
|
chromeRef.current?.querySelectorAll('[data-chrome-section]').forEach((node) => {
|
|
101
|
-
chromeOf.set(node.dataset.chromeSection ?? '', node
|
|
105
|
+
chromeOf.set(node.dataset.chromeSection ?? '', 높이(node));
|
|
102
106
|
});
|
|
103
|
-
const budgetFor = (section) => ruler
|
|
104
|
-
const heights = Array.from(el.children).map((c) => c
|
|
107
|
+
const budgetFor = (section) => 높이(ruler) - (chromeOf.get(section ?? '') ?? 0);
|
|
108
|
+
const heights = Array.from(el.children).map((c) => 높이(c));
|
|
105
109
|
// 이웃한 같은 group 을 한 덩어리로 묶는다.
|
|
106
110
|
const groups = [];
|
|
107
111
|
blocks.forEach((b, i) => {
|
|
108
112
|
const last = groups[groups.length - 1];
|
|
109
113
|
const prev = last ? blocks[last[0]] : undefined;
|
|
110
|
-
|
|
114
|
+
// 갈래(section)가 다르면 같은 group 값이어도 한 덩어리가 아니다 — 묶이면 "값이 바뀌는 자리에서 반드시 새 장"(JSDoc)이 깨졌다
|
|
115
|
+
const sameGroup = prev && b.group != null && prev.group === b.group && prev.section === b.section;
|
|
111
116
|
if (sameGroup)
|
|
112
117
|
last.push(i);
|
|
113
118
|
else
|
|
@@ -149,7 +154,8 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
|
|
|
149
154
|
setMeasured({ shape, pages: packed });
|
|
150
155
|
// blocks 는 위 shape 로 대신 본다.
|
|
151
156
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
152
|
-
|
|
157
|
+
// 장 규격이 바뀌면 글이 다시 흘러 블록 높이가 달라진다 — 자(세로) 높이는 그대로라 자크기만으로는 다시 재지 않아 장이 넘쳤다
|
|
158
|
+
}, [shape, 자크기, pageWidth, pageHeight, margin]);
|
|
153
159
|
/* 번들러마다 SVG import 결과가 다르다 — Vite 는 문자열, Next 는 `{ src }` 객체다.
|
|
154
160
|
문자열만 보면 Next 앱에서 fallback 경로(`/logo/...`)로 떨어져 404 가 난다
|
|
155
161
|
(kscan 결과지의 바닥 로고가 실제로 깨졌다). 둘 다 받는다. */
|
|
@@ -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
|
}
|
|
@@ -57,6 +57,11 @@
|
|
|
57
57
|
transition: transform 260ms cubic-bezier(.22,.9,.3,1), opacity 180ms ease-out;
|
|
58
58
|
opacity:0;
|
|
59
59
|
}
|
|
60
|
+
/* 보이지 않는 동안(들어오기 전 두 프레임·사라지는 중)은 누름을 통과시킨다 — 투명한 알림이 아래 단추의 클릭을 삼켰다 */
|
|
61
|
+
.toast:not(.is-visible) .toast__inner{
|
|
62
|
+
pointer-events:none;
|
|
63
|
+
}
|
|
64
|
+
|
|
60
65
|
.toast--top.is-visible{
|
|
61
66
|
transform:translateX(-50%) translateY(0);
|
|
62
67
|
opacity:1;
|
|
@@ -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
|
클릭에 두 번씩 나갔다. 풀 때도 알린다(전에는 풀면 부모가 옛 순서를 그대로 들고 있었다). */
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
-
|
|
3
|
+
/* Toast.css 의 퇴장 전환 중 가장 긴 것(transform 260ms)과 같게 — 500 이면 투명해진 알림이 240ms 더 붙어 화면 맨 위 띠의 클릭을
|
|
4
|
+
삼켰다(Chromium 실측: 사라진 뒤 누른 단추가 안 눌림). 시험이 CSS 값과 대조한다. */
|
|
5
|
+
export const EXIT_ANIM_MS = 260;
|
|
4
6
|
/**
|
|
5
7
|
* Toast의 등장/퇴장 슬라이드 애니메이션 + 자동 닫힘 타이머 상태 관리.
|
|
6
8
|
* @public
|
|
@@ -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
|
* 발음이 겹치지 않도록 그리디하게 샘플링한다.
|