@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.
Files changed (29) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/components.cjs.js +175 -175
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +182 -182
  6. package/dist/ihabdevteam-core.d.ts +3 -1
  7. package/dist/packages/core/src/components/ActionCard.js +10 -2
  8. package/dist/packages/core/src/components/LandingHeader.js +12 -5
  9. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +17 -4
  10. package/dist/packages/core/src/components/Popup/ListPopup.js +1 -0
  11. package/dist/packages/core/src/components/Popup/Popup.js +4 -0
  12. package/dist/packages/core/src/components/Section.js +2 -1
  13. package/dist/packages/core/src/components/Table.js +18 -8
  14. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +2 -0
  15. package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +11 -1
  16. package/dist/packages/core/src/hooks/useTablePaging.js +4 -3
  17. package/dist/packages/core/src/utils/textSort.d.ts +4 -4
  18. package/dist/packages/core/src/utils/textSort.js +56 -28
  19. package/dist/packages/core/src/utils/trainingItemResults.js +3 -1
  20. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +2 -0
  21. package/dist/packages/core/src/utils/wordTrainingGenerators.js +5 -2
  22. package/dist/packages/core/src/utils/wordTrainingPool.d.ts +1 -0
  23. package/dist/packages/core/src/utils/wordTrainingPool.js +20 -6
  24. package/dist/packages/core/src/utils/wordTrainingRuntime.js +35 -14
  25. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.js +4 -3
  26. package/dist/packages/core/src/version.d.ts +1 -1
  27. package/dist/packages/core/src/version.js +1 -1
  28. package/dist/utils.cjs.js +2 -2
  29. 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.145.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-label": title, onClick: activate, onKeyDown: (e) => {
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
- const 풀기 = pushEsc(() => setOpen(false));
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
- await extraAction.onClick(closeWithAnimation);
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
- await onConfirm(closeWithAnimation);
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
- const hasHeader = title || disc;
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
- setColWidths(prev => {
109
- if (!prev)
110
- return prev;
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
- return copy;
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
- return Math.max(MIN_COL_W, Math.ceil(w));
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, { className: `${눌림 ? ' table-row--clickable' : ''}${onRowDragStart ? ' table-row--draggable' : ''}${isDragging ? ' table-row--dragging' : ''}`.trim(), draggable: !!onRowDragStart,
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))) }, String(item.id ?? idx)));
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
- if (page > total)
50
- setPage(total);
51
- const 이쪽 = Math.min(page, total);
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
- * 그 밖에는 한국어 차례로 견주되 글자 안의 숫자는 수로 읽는다(`회차 9` 가 `회차 10` 앞).
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 숫자글자 = /^-?\d+(\.\d+)?$/;
26
- /* 칸의 글자 비교는 **안의 숫자도 수로** 본다 — 숫자 칸끼리는 수로, 섞인 칸은 글자로 견주면 '9'<'10'(수)·'10'<'10분'(글자)·
27
- '10분'<'9'(글자) 로 돌고 돌아, 같은 행을 들어온 차례만 바꿔도 정렬 결과가 달랐다. 수를 수로 읽는 콜레이터는
28
- 숫자끼리의 차례와 같은 쪽을 낸다. */
29
- const 칸콜레이터 = (() => {
30
- let 것 = null;
31
- return () => {
32
- if (!것)
33
- 것 = new Intl.Collator('ko', { numeric: true });
34
- return 것;
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
- * 그 밖에는 한국어 차례로 견주되 글자 안의 숫자는 수로 읽는다(`회차 9` 가 `회차 10` 앞).
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
- /* NaN 은 숫자가 아닌 것으로 — a - b 가 NaN 이면 정렬 비교가 어긋나 줄이 뒤섞였다. 글자로 온 숫자('10'·'9')도
49
- 둘 다 온전한 숫자면 숫자로 견준다(전에는 '10' 이 '9' 앞에 섰다). */
50
- const 수 = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : typeof v === 'string' && 숫자글자.test(v.trim()) ? Number(v) : null);
51
- const na = 수(a), nb = 수(b);
52
- if (na !== null && nb !== null)
53
- return na - nb;
54
- const sa = a == null ? '' : String(a), sb = b == null ? '' : String(b);
55
- /* 소수점·음수가 든 글자('12.5%'·'-10 dB')는 수로 읽는 콜레이터가 틀린다 — 점 뒤를 따로 떼어 '12.5' 가 '12.25' 뒤에,
56
- 빼기표를 버려 '-5' 가 '-10' 뒤에 섰다. 그런 칸은 한국어 글자 차례로 둔다. 선행 0 도 수로 읽으면 'A007'='A7' 이 되어 글자로. */
57
- if (/\d\.\d|-\d|(^|\D)0\d/.test(sa) || /\d\.\d|-\d|(^|\D)0\d/.test(sb))
58
- return 한글순(sa, sb);
59
- return 칸콜레이터().compare(sa, sb);
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
- question: text(r.question) ?? text(r.sentence) ?? text(r.word) ?? text(r.targetSyllable),
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
- return ok(comp.cho, CHOSEONG[s.cho]) && ok(comp.jung, JUNGSEONG[s.jung]) && ok(comp.jong, JONGSEONG[s.jong]);
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
- const d = countPhonemeDiff(w.w, other);
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 — 가능 단어 수 안내)은 '오답이 하나라도 있나' 만 본다. 2.2만 후보마다 600개를 끝까지 걸러 칩 한 번에
446
- 300ms 넘게 멈췄다. 셈은 결과 개수만 쓰고(countUsableWords → pool.length) 오답 목록은 쓰지 않는다. */
446
+ /* 셈(fullCount — 가능 단어 수 안내)은 **서로 소리가 다른 오답이 보기 수 − 1 개** 있는지까지만 본다(countUsableWords 가 그
447
+ 기준으로 센다). 2.2만 후보마다 600개를 끝까지 걸러 칩 한 번에 300ms 넘게 멈췄다. */
447
448
  if (opts.fullCount) {
448
- const one = distractorPool.find(쓸수있음);
449
- usable = one === undefined ? [] : [one];
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(쓸수있음);