@ihabdevteam/core 0.145.0 → 0.147.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 +67 -0
- package/dist/components.cjs.js +174 -174
- 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 +24 -13
- package/dist/packages/core/src/components/ActionCard.js +10 -2
- package/dist/packages/core/src/components/Dropdown.js +2 -2
- package/dist/packages/core/src/components/LandingHeader.js +12 -5
- package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +3 -1
- 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.d.ts +5 -4
- package/dist/packages/core/src/components/Popup/Popup.js +5 -1
- package/dist/packages/core/src/components/Report/ReportDocument.d.ts +3 -2
- package/dist/packages/core/src/components/Report/ReportDocument.js +5 -3
- 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/components/Training/WordTablePopup.js +5 -5
- package/dist/packages/core/src/components/Training/useNoiseFade.js +25 -7
- package/dist/packages/core/src/components/Word/useWordTablePrint.d.ts +2 -1
- package/dist/packages/core/src/components/Word/wordTablePrint.js +8 -1
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +35 -7
- package/dist/packages/core/src/hooks/useTablePaging.js +4 -3
- package/dist/packages/core/src/hooks/useTrainingSessionLog.d.ts +5 -2
- package/dist/packages/core/src/hooks/useTrainingSessionLog.js +45 -8
- package/dist/packages/core/src/hooks/useWordMeanings.js +13 -2
- package/dist/packages/core/src/utils/getTrainingData.d.ts +5 -1
- package/dist/packages/core/src/utils/getTrainingData.js +20 -4
- package/dist/packages/core/src/utils/planFeatures.d.ts +1 -1
- package/dist/packages/core/src/utils/planFeatures.js +7 -1
- package/dist/packages/core/src/utils/textSort.d.ts +4 -4
- package/dist/packages/core/src/utils/textSort.js +58 -28
- package/dist/packages/core/src/utils/trainingItemResults.js +3 -1
- package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +4 -2
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +7 -4
- 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.d.ts +2 -1
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +39 -15
- 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
|
@@ -526,7 +526,7 @@ export declare function buildChangeWholeSyllableProblem(word: string, compFrom:
|
|
|
526
526
|
* @public 합성어 부분 탈락 → [문제단어(합성어), 남는 실단어(정답), 그 단어의 대립쌍 실단어 오답 2].
|
|
527
527
|
* 합성어 근사: 앞/뒤로 나눈 두 조각이 '둘 다 실단어'(wordSet)인 첫 조합을 채택(예: 손수건=손+수건).
|
|
528
528
|
* 정답·오답 전부 실단어라 발음이 그대로. comp(훈련음소) 지정 시 남는 단어의 그 자리를 대립, 없으면 전 음절.
|
|
529
|
-
* dropPos: 0=어두 어근(앞) 탈락, 1=어말 어근(뒤) 탈락. 편집기·런타임 공용.
|
|
529
|
+
* dropPos: 0=어두 어근(앞) 탈락, 1=어말 어근(뒤) 탈락. 결과의 `dropped` 에 뺀 조각을 담는다. 편집기·런타임 공용.
|
|
530
530
|
*/
|
|
531
531
|
export declare function buildDropCompoundProblem(word: string, comp: CompositionFilter | null, pronMap: Map<string, string>, phonemeView: 'char' | 'pron', wordSet: Set<string>, dropPos?: number | null): TrainingProblem | null;
|
|
532
532
|
|
|
@@ -1254,7 +1254,9 @@ export declare interface ConfirmPopupProps {
|
|
|
1254
1254
|
};
|
|
1255
1255
|
/**
|
|
1256
1256
|
* 확인 클릭 시 실행. `close`로 직접 닫을 수 있음(async 지원).
|
|
1257
|
-
* 기본은 onConfirm 완료 후 자동으로 닫힘(closeOnConfirm).
|
|
1257
|
+
* 기본은 onConfirm 완료 후 자동으로 닫힘(closeOnConfirm). 받은 `close()` 를 부르면 그것도 닫힘으로 센다(닫히는 동안 잠금 유지).
|
|
1258
|
+
* 던지거나 거절되면 창이 남고 잠금이 풀린다 — 거절은 console.error 로 받는다.
|
|
1259
|
+
* 안에서 부모가 창을 치우면 그 뒤 onClose 는 불리지 않는다.
|
|
1258
1260
|
*/
|
|
1259
1261
|
onConfirm: (close: () => void) => void | Promise<void>;
|
|
1260
1262
|
/** false면 자동으로 닫지 않음 — onConfirm이 전달받은 close로 닫힘 시점을 제어 (성공 시에만 닫기 등). 기본 true */
|
|
@@ -1386,7 +1388,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1386
1388
|
*
|
|
1387
1389
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1388
1390
|
*/
|
|
1389
|
-
export declare const CORE_VERSION = "0.
|
|
1391
|
+
export declare const CORE_VERSION = "0.147.0";
|
|
1390
1392
|
|
|
1391
1393
|
/** @public */
|
|
1392
1394
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -1929,7 +1931,11 @@ export declare interface EmptyStateProps {
|
|
|
1929
1931
|
className?: string;
|
|
1930
1932
|
}
|
|
1931
1933
|
|
|
1932
|
-
|
|
1934
|
+
/**
|
|
1935
|
+
* 자료를 받게 하고 **받았는지** 를 돌려준다(시간 초과·실패는 false). 전에는 성공·3초 초과·실패가 모두 undefined 로 끝나
|
|
1936
|
+
* 부르는 쪽이 빈 풀로 계획을 만들고도 알 길이 없었다. 늦게 도착하면 'speechear.data.updated' 가 온다.
|
|
1937
|
+
*/
|
|
1938
|
+
export declare function ensureTrainingDataLoaded(timeout?: number): Promise<boolean>;
|
|
1933
1939
|
|
|
1934
1940
|
export declare function ErrorState({ message, hint, icon, onRetry, retryLabel, className }: ErrorStateProps): JSX.Element;
|
|
1935
1941
|
|
|
@@ -2364,7 +2370,7 @@ export declare function getPairIndex(): PairIndex | null;
|
|
|
2364
2370
|
export declare function getPayloadTimestamp(payload: unknown): number;
|
|
2365
2371
|
|
|
2366
2372
|
/** product code → 요금제 티어 */
|
|
2367
|
-
export declare function getPlanTier(productCode: string): PlanTier;
|
|
2373
|
+
export declare function getPlanTier(productCode: string | null | undefined): PlanTier;
|
|
2368
2374
|
|
|
2369
2375
|
/** 레지스트리에서 theme 조회 */
|
|
2370
2376
|
export declare function getPromoBannerBgTheme(el: Element | null): PromoBannerBgTheme | undefined;
|
|
@@ -4179,8 +4185,8 @@ export declare interface PopupProps {
|
|
|
4179
4185
|
*
|
|
4180
4186
|
* "입력 중인데 정말 닫을까요?" 같은 확인 절차용. onClose에서 막지 않는 이유 —
|
|
4181
4187
|
* onClose는 이미 닫힘 애니메이션(260ms)이 끝난 뒤(animateThen의 setTimeout 안)에 불리므로,
|
|
4182
|
-
* 거기서 닫기를 취소하면 창이
|
|
4183
|
-
*
|
|
4188
|
+
* 거기서 닫기를 취소하면(열림 상태를 끄지 않으면) 창이 닫힌 모양으로 남는다(animateThen 은 약속을 돌려준
|
|
4189
|
+
* 콜백만 되살린다 — onClose 같은 동기 콜백 뒤에는 되살리지 않는다). 사라지지 않고 그대로 머물게 하려면 이 시점에 판단한다.
|
|
4184
4190
|
*
|
|
4185
4191
|
* 미지정이면 묻지 않고 닫는다.
|
|
4186
4192
|
*/
|
|
@@ -4195,8 +4201,9 @@ export declare interface PopupProps {
|
|
|
4195
4201
|
closeOnBackdrop?: boolean;
|
|
4196
4202
|
className?: string;
|
|
4197
4203
|
/**
|
|
4198
|
-
* 다이얼로그 접근명 — `title
|
|
4199
|
-
* title이 있으면 그쪽이 `aria-labelledby`로 연결되므로 이 값은 무시된다.
|
|
4204
|
+
* 다이얼로그 접근명 — 제목을 그리지 않는 팝업(`title` 이 없거나 useTitle=false)에서 쓴다.
|
|
4205
|
+
* 제목을 그리면(useTitle 이고 title 이 있으면) 그쪽이 `aria-labelledby`로 연결되므로 이 값은 무시된다.
|
|
4206
|
+
* useTitle=false 인데 이 값이 없으면 글자 `title` 을 이름으로 쓴다.
|
|
4200
4207
|
* (없으면 스크린리더가 "대화상자"라고만 읽어 무슨 창인지 알 수 없다.)
|
|
4201
4208
|
*/
|
|
4202
4209
|
ariaLabel?: string;
|
|
@@ -4790,7 +4797,7 @@ export declare interface ReportBlock {
|
|
|
4790
4797
|
/**
|
|
4791
4798
|
* 쪽을 나눌 때 한 덩어리로 다룰 묶음. 표 머리와 그 행들처럼 갈라지면 읽을 수
|
|
4792
4799
|
* 없는 것들에 같은 값을 준다 — 남은 자리가 모자라면 통째로 다음 장으로 간다.
|
|
4793
|
-
* 묶음 하나가 한 장보다 크면 어쩔 수 없이 블록 단위로 쪼갠다.
|
|
4800
|
+
* 묶음 하나가 한 장보다 크면 어쩔 수 없이 블록 단위로 쪼갠다. `section` 이 다르면 같은 값이어도 한 묶음이 아니다.
|
|
4794
4801
|
*/
|
|
4795
4802
|
group?: string;
|
|
4796
4803
|
}
|
|
@@ -4803,7 +4810,8 @@ export declare interface ReportBlock {
|
|
|
4803
4810
|
* 바닥(로고·저작권 + 쪽 번호)이 반복된다.
|
|
4804
4811
|
*
|
|
4805
4812
|
* 재는 방식이라 글꼴이 늦게 오거나 이미지가 나중에 뜨면 쪽이 밀릴 수 있다.
|
|
4806
|
-
* 다시 재는 때는 블록들의 `id`·`section`·`group`
|
|
4813
|
+
* 다시 재는 때는 블록들의 `id`·`section`·`group` 구성이나 장 규격(`pageWidth`·`pageHeight`·`margin`)이 바뀔 때와
|
|
4814
|
+
* 처음 그릴 때(숨겨져 있다 보이게 될 때 포함)뿐이다 —
|
|
4807
4815
|
* 같은 구성으로 다시 그리거나 `content` 만 바뀌어서는 다시 재지 않는다(렌더마다 재면
|
|
4808
4816
|
* 재기 → 쪽 나눔 → 렌더가 끝없이 돈다). 글꼴·이미지가 다 온 뒤 다시 재야 하면
|
|
4809
4817
|
* `key` 를 바꿔 새로 붙인다.
|
|
@@ -6113,7 +6121,7 @@ export declare function syllablePron(ch: string): string;
|
|
|
6113
6121
|
/** composition.syllable 선택값을 실제 매칭용 음절 수 배열로 변환 (SYLLABLE_MIN 이상은 범위로 확장) */
|
|
6114
6122
|
export declare function syllableToFilter(syllable: number): number[];
|
|
6115
6123
|
|
|
6116
|
-
/** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). */
|
|
6124
|
+
/** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). `jongNone` 이면 받침 없는 음절만 맞고 `jong` 은 보지 않는다(풀 필터와 같게). */
|
|
6117
6125
|
export declare function syllMatchesComp(s: Syllable, comp: CompositionFilter): boolean;
|
|
6118
6126
|
|
|
6119
6127
|
/** @public */
|
|
@@ -7397,6 +7405,8 @@ export declare type TrainingPlan = {
|
|
|
7397
7405
|
declare interface TrainingProblem {
|
|
7398
7406
|
question: string;
|
|
7399
7407
|
distractors: string[];
|
|
7408
|
+
/** 탈락 문제에서 빠진 조각(합성어 부분 탈락이 채운다 — 앞뒤로 같은 조각이 있으면 remain 만으로는 어느 쪽인지 모른다) */
|
|
7409
|
+
dropped?: string;
|
|
7400
7410
|
}
|
|
7401
7411
|
|
|
7402
7412
|
/** @public */
|
|
@@ -8424,7 +8434,8 @@ export declare interface UseWordTablePrintOptions {
|
|
|
8424
8434
|
|
|
8425
8435
|
/** @public */
|
|
8426
8436
|
export declare interface UseWordTablePrintResult {
|
|
8427
|
-
/** 화면 밖 iframe에서 격리 인쇄(전역 print CSS·팝업 차단 무관).
|
|
8437
|
+
/** 화면 밖 iframe에서 격리 인쇄(전역 print CSS·팝업 차단 무관). 인쇄가 진행 중이면(10초 한도) 다시 불러도 받지 않는다.
|
|
8438
|
+
* 세 동작 모두 문서는 늘 light 테마(종이가 흰색). */
|
|
8428
8439
|
print: () => void;
|
|
8429
8440
|
/** 새 탭에 단어표를 열어 사용자가 직접 인쇄/저장하게 한다. */
|
|
8430
8441
|
openNewWindow: () => void;
|
|
@@ -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
|
}
|
|
@@ -125,8 +125,8 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
|
|
|
125
125
|
e.preventDefault();
|
|
126
126
|
if (options[activeIdx])
|
|
127
127
|
select(options[activeIdx]);
|
|
128
|
-
else
|
|
129
|
-
setOpen(false);
|
|
128
|
+
else
|
|
129
|
+
setOpen(false); // Enter 도 — 가리킨 것 없이 기본 동작만 막으면 목록이 열린 채 아무 일도 없었다
|
|
130
130
|
}
|
|
131
131
|
}, [disabled, open, options, activeIdx, select, setOpen]);
|
|
132
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
|
}
|
|
@@ -50,7 +50,9 @@ export interface ConfirmPopupProps {
|
|
|
50
50
|
};
|
|
51
51
|
/**
|
|
52
52
|
* 확인 클릭 시 실행. `close`로 직접 닫을 수 있음(async 지원).
|
|
53
|
-
* 기본은 onConfirm 완료 후 자동으로 닫힘(closeOnConfirm).
|
|
53
|
+
* 기본은 onConfirm 완료 후 자동으로 닫힘(closeOnConfirm). 받은 `close()` 를 부르면 그것도 닫힘으로 센다(닫히는 동안 잠금 유지).
|
|
54
|
+
* 던지거나 거절되면 창이 남고 잠금이 풀린다 — 거절은 console.error 로 받는다.
|
|
55
|
+
* 안에서 부모가 창을 치우면 그 뒤 onClose 는 불리지 않는다.
|
|
54
56
|
*/
|
|
55
57
|
onConfirm: (close: () => void) => void | Promise<void>;
|
|
56
58
|
/** false면 자동으로 닫지 않음 — onConfirm이 전달받은 close로 닫힘 시점을 제어 (성공 시에만 닫기 등). 기본 true */
|
|
@@ -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(() => {
|
|
@@ -40,8 +40,8 @@ export interface PopupProps {
|
|
|
40
40
|
*
|
|
41
41
|
* "입력 중인데 정말 닫을까요?" 같은 확인 절차용. onClose에서 막지 않는 이유 —
|
|
42
42
|
* onClose는 이미 닫힘 애니메이션(260ms)이 끝난 뒤(animateThen의 setTimeout 안)에 불리므로,
|
|
43
|
-
* 거기서 닫기를 취소하면 창이
|
|
44
|
-
*
|
|
43
|
+
* 거기서 닫기를 취소하면(열림 상태를 끄지 않으면) 창이 닫힌 모양으로 남는다(animateThen 은 약속을 돌려준
|
|
44
|
+
* 콜백만 되살린다 — onClose 같은 동기 콜백 뒤에는 되살리지 않는다). 사라지지 않고 그대로 머물게 하려면 이 시점에 판단한다.
|
|
45
45
|
*
|
|
46
46
|
* 미지정이면 묻지 않고 닫는다.
|
|
47
47
|
*/
|
|
@@ -56,8 +56,9 @@ export interface PopupProps {
|
|
|
56
56
|
closeOnBackdrop?: boolean;
|
|
57
57
|
className?: string;
|
|
58
58
|
/**
|
|
59
|
-
* 다이얼로그 접근명 — `title
|
|
60
|
-
* title이 있으면 그쪽이 `aria-labelledby`로 연결되므로 이 값은 무시된다.
|
|
59
|
+
* 다이얼로그 접근명 — 제목을 그리지 않는 팝업(`title` 이 없거나 useTitle=false)에서 쓴다.
|
|
60
|
+
* 제목을 그리면(useTitle 이고 title 이 있으면) 그쪽이 `aria-labelledby`로 연결되므로 이 값은 무시된다.
|
|
61
|
+
* useTitle=false 인데 이 값이 없으면 글자 `title` 을 이름으로 쓴다.
|
|
61
62
|
* (없으면 스크린리더가 "대화상자"라고만 읽어 무슨 창인지 알 수 없다.)
|
|
62
63
|
*/
|
|
63
64
|
ariaLabel?: string;
|
|
@@ -109,7 +109,7 @@ export default function Popup(props) {
|
|
|
109
109
|
/* Tab 순환을 패널 안으로 가둔다 — 네 곳에 있던 같은 열두 줄을 훅 한 벌로 모았다.
|
|
110
110
|
inline 으로 쓸 때는 다이얼로그가 아니라 걸지 않는다(예전과 같다). */
|
|
111
111
|
useFocusTrap(panelRef, !inline);
|
|
112
|
-
/* 닫힘 애니메이션 뒤 콜백을 부른다. **콜백이 던지거나 거절되면, 또는
|
|
112
|
+
/* 닫힘 애니메이션 뒤 콜백을 부른다. **콜백이 던지거나 거절되면, 또는 약속을 돌려준 콜백이 끝난 뒤에도 이 창이 그대로 있으면 되살린다** —
|
|
113
113
|
전에는 닫는 중 표시만 남아 투명한 막이 화면을 덮고(클릭을 가로챔) ESC·X·바탕·다시 저장이 모두 무시돼 페이지를 쓸 수
|
|
114
114
|
없었다(말귀 업체 정보·훈련 묶음 수정 저장이 실패해 부모가 창을 열어 둘 때). 성공하면 부모가 곧 치우므로 되살릴 일이 없다. */
|
|
115
115
|
const 살아있음Ref = useRef(true);
|
|
@@ -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;
|
|
@@ -15,7 +15,7 @@ export interface ReportBlock {
|
|
|
15
15
|
/**
|
|
16
16
|
* 쪽을 나눌 때 한 덩어리로 다룰 묶음. 표 머리와 그 행들처럼 갈라지면 읽을 수
|
|
17
17
|
* 없는 것들에 같은 값을 준다 — 남은 자리가 모자라면 통째로 다음 장으로 간다.
|
|
18
|
-
* 묶음 하나가 한 장보다 크면 어쩔 수 없이 블록 단위로 쪼갠다.
|
|
18
|
+
* 묶음 하나가 한 장보다 크면 어쩔 수 없이 블록 단위로 쪼갠다. `section` 이 다르면 같은 값이어도 한 묶음이 아니다.
|
|
19
19
|
*/
|
|
20
20
|
group?: string;
|
|
21
21
|
}
|
|
@@ -54,7 +54,8 @@ export interface ReportDocumentProps {
|
|
|
54
54
|
* 바닥(로고·저작권 + 쪽 번호)이 반복된다.
|
|
55
55
|
*
|
|
56
56
|
* 재는 방식이라 글꼴이 늦게 오거나 이미지가 나중에 뜨면 쪽이 밀릴 수 있다.
|
|
57
|
-
* 다시 재는 때는 블록들의 `id`·`section`·`group`
|
|
57
|
+
* 다시 재는 때는 블록들의 `id`·`section`·`group` 구성이나 장 규격(`pageWidth`·`pageHeight`·`margin`)이 바뀔 때와
|
|
58
|
+
* 처음 그릴 때(숨겨져 있다 보이게 될 때 포함)뿐이다 —
|
|
58
59
|
* 같은 구성으로 다시 그리거나 `content` 만 바뀌어서는 다시 재지 않는다(렌더마다 재면
|
|
59
60
|
* 재기 → 쪽 나눔 → 렌더가 끝없이 돈다). 글꼴·이미지가 다 온 뒤 다시 재야 하면
|
|
60
61
|
* `key` 를 바꿔 새로 붙인다.
|
|
@@ -15,7 +15,8 @@ const DEFAULT_PAGE_LABEL = (page, total) => `${page} / ${total}`;
|
|
|
15
15
|
* 바닥(로고·저작권 + 쪽 번호)이 반복된다.
|
|
16
16
|
*
|
|
17
17
|
* 재는 방식이라 글꼴이 늦게 오거나 이미지가 나중에 뜨면 쪽이 밀릴 수 있다.
|
|
18
|
-
* 다시 재는 때는 블록들의 `id`·`section`·`group`
|
|
18
|
+
* 다시 재는 때는 블록들의 `id`·`section`·`group` 구성이나 장 규격(`pageWidth`·`pageHeight`·`margin`)이 바뀔 때와
|
|
19
|
+
* 처음 그릴 때(숨겨져 있다 보이게 될 때 포함)뿐이다 —
|
|
19
20
|
* 같은 구성으로 다시 그리거나 `content` 만 바뀌어서는 다시 재지 않는다(렌더마다 재면
|
|
20
21
|
* 재기 → 쪽 나눔 → 렌더가 끝없이 돈다). 글꼴·이미지가 다 온 뒤 다시 재야 하면
|
|
21
22
|
* `key` 를 바꿔 새로 붙인다.
|
|
@@ -134,12 +135,13 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
|
|
|
134
135
|
if (cur.length && blocks[cur[0]].section !== blocks[grp[0]].section)
|
|
135
136
|
flush();
|
|
136
137
|
// 덩어리 전체가 남은 자리에 안 들어가면 통째로 다음 장으로.
|
|
137
|
-
|
|
138
|
+
// 소수 높이는 배율(transform: scale) 안에서 요소마다 1/64px 안팎 오차가 붙는다 — 딱 찬 결과지가 두 장이 되지 않게 조금 봐준다
|
|
139
|
+
if (cur.length && used + groupH > budget + 0.01)
|
|
138
140
|
flush();
|
|
139
141
|
if (groupH > budget) {
|
|
140
142
|
// 덩어리 하나가 한 장보다 크면 쪼갤 수밖에 없다.
|
|
141
143
|
grp.forEach((i) => {
|
|
142
|
-
if (cur.length && used + heights[i] > budget)
|
|
144
|
+
if (cur.length && used + heights[i] > budget + 0.01)
|
|
143
145
|
flush();
|
|
144
146
|
cur.push(i);
|
|
145
147
|
used += heights[i];
|
|
@@ -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
|
클릭에 두 번씩 나갔다. 풀 때도 알린다(전에는 풀면 부모가 옛 순서를 그대로 들고 있었다). */
|
|
@@ -51,13 +51,13 @@ export default function WordTablePopup({ items, words, title, subtitle, emptyTex
|
|
|
51
51
|
새 함수 → 알림 이 끝없이 돈다. 부모가 어떻게 짜든 안 돌게 한다. */
|
|
52
52
|
const notifyRef = useRef(onScoreChange);
|
|
53
53
|
useEffect(() => { notifyRef.current = onScoreChange; });
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
/* 매긴 것이 **바뀌었을 때만** 알린다 — 처음 열 때 onScoreChange({}) 가 나갔다. '첫 실행 건너뛰기' 는 StrictMode 의 두 번째 실행에서
|
|
55
|
+
새어 이전 값과 견준다. */
|
|
56
|
+
const 알린marksRef = useRef(marks);
|
|
56
57
|
useEffect(() => {
|
|
57
|
-
if (
|
|
58
|
-
첫알림Ref.current = false;
|
|
58
|
+
if (알린marksRef.current === marks)
|
|
59
59
|
return;
|
|
60
|
-
|
|
60
|
+
알린marksRef.current = marks;
|
|
61
61
|
notifyRef.current?.(marksForSave(marks));
|
|
62
62
|
}, [marks]);
|
|
63
63
|
/* 표에는 지운 칸(null)이 빠진 것만 넘긴다 — 표는 "매겨진 것"만 알면 된다. */
|
|
@@ -240,6 +240,9 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
240
240
|
? { enabled: noisePlayer.settings.enabled, volume: noiseVolume, type: noisePlayer.settings.type }
|
|
241
241
|
: null;
|
|
242
242
|
}, [noisePlayer, noisePlayer?.settings.enabled, noiseVolume, noisePlayer?.settings.type]);
|
|
243
|
+
/* 새 소음 파일의 canplay 를 기다리는 핸들러 — 다음 종류 변경·떠날 때 뗀다(옛 파일 기다림이 새 설정 위에서 울리지 않게) */
|
|
244
|
+
const canplay떼기Ref = useRef(null);
|
|
245
|
+
useEffect(() => () => { canplay떼기Ref.current?.(); canplay떼기Ref.current = null; }, []);
|
|
243
246
|
// 소음 설정 변경 → 오디오 반영
|
|
244
247
|
useEffect(() => {
|
|
245
248
|
const ns = noisePlayer?.settings;
|
|
@@ -267,14 +270,23 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
267
270
|
noiseEl.load();
|
|
268
271
|
const mainAudio = audioRef.current;
|
|
269
272
|
if (mainAudio && !mainAudio.paused) {
|
|
270
|
-
|
|
273
|
+
canplay떼기Ref.current?.();
|
|
274
|
+
const h = () => {
|
|
271
275
|
noiseEl.removeEventListener('canplay', h);
|
|
276
|
+
if (canplay떼기Ref.current === 떼기)
|
|
277
|
+
canplay떼기Ref.current = null;
|
|
272
278
|
// 파일 로드가 끝난 "이 시점"에 메인이 여전히 재생 중인지 재확인 —
|
|
273
279
|
// 로드가 느리면 예약 시점과 실행 시점 사이에 메인이 끝났을 수 있다.
|
|
280
|
+
/* 볼륨·켜짐은 **지금** 설정으로 — 효과 때 붙잡은 값으로 켜면, 불러오는 동안 치료사가 비율을 0.3 으로 내렸거나 소음을
|
|
281
|
+
껐어도 canplay 에서 1 로 다시 켜져 끝까지 났다(난이도가 뜻보다 어려워지고 기록과 들린 소리가 어긋남). */
|
|
274
282
|
const cur = audioRef.current;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
283
|
+
const 지금 = noiseSettingsRef.current;
|
|
284
|
+
if (cur && !cur.paused && !cur.ended && 지금?.enabled && 지금.volume > 0)
|
|
285
|
+
fadeInNoise(지금.volume);
|
|
286
|
+
};
|
|
287
|
+
const 떼기 = () => noiseEl.removeEventListener('canplay', h);
|
|
288
|
+
canplay떼기Ref.current = 떼기;
|
|
289
|
+
noiseEl.addEventListener('canplay', h);
|
|
278
290
|
}
|
|
279
291
|
});
|
|
280
292
|
}
|
|
@@ -286,8 +298,14 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
286
298
|
// — 그때 noiseEl이 아직(레이스 등으로) paused가 아니면 볼륨만 올려버려 소음이 계속
|
|
287
299
|
// 나오는 버그가 있었다. 메인이 재생 중이 아니면 무조건 정지시킨다.
|
|
288
300
|
const mainAudio = audioRef.current;
|
|
289
|
-
|
|
290
|
-
|
|
301
|
+
// speechSynthesis TTS 경로는 메인 <audio> 없이 재생된다 — 감시 타이머처럼 playbackState 도 본다(안 보면 비율만 바꿔도 소음이 멈췄다)
|
|
302
|
+
const mainPlaying = (!!mainAudio && !mainAudio.paused && !mainAudio.ended) || playbackStateRef.current === 'playing';
|
|
303
|
+
if (mainPlaying && pendingNoiseUrlRef.current) {
|
|
304
|
+
/* 종류를 바꾼 뒤 150ms 페이드아웃 안에 다른 설정을 바꾸면, 아래 볼륨만 옮기는 갈래가 파일을 바꿀 페이드를 지워 화면의 종류와
|
|
305
|
+
들리는 종류가 문장 끝까지 달랐다 — 기다리는 파일이 있으면 fadeInNoise 가 그것을 끼운다. */
|
|
306
|
+
fadeInNoise(volume);
|
|
307
|
+
}
|
|
308
|
+
else if (mainPlaying && noiseEl.paused && noiseEl.src) {
|
|
291
309
|
fadeInNoise(volume);
|
|
292
310
|
}
|
|
293
311
|
else if (!noiseEl.paused) {
|
|
@@ -315,7 +333,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
315
333
|
}
|
|
316
334
|
}
|
|
317
335
|
}
|
|
318
|
-
}, [noisePlayer?.settings, noisePlayer?.settings.enabled, noiseVolume, noisePlayer?.settings.type, cancelNoiseFade, fadeInNoise, fadeOutNoise, audioRef]);
|
|
336
|
+
}, [noisePlayer?.settings, noisePlayer?.settings.enabled, noiseVolume, noisePlayer?.settings.type, cancelNoiseFade, fadeInNoise, fadeOutNoise, audioRef, playbackStateRef, noiseSettingsRef]);
|
|
319
337
|
// 소음 재생 속도(playbackRate) 반영 — 0.75~1.25. 엘리먼트 속성이라 src 변경에도 유지됨
|
|
320
338
|
useEffect(() => {
|
|
321
339
|
const el = noiseAudioRef.current;
|
|
@@ -15,7 +15,8 @@ export interface UseWordTablePrintOptions {
|
|
|
15
15
|
}
|
|
16
16
|
/** @public */
|
|
17
17
|
export interface UseWordTablePrintResult {
|
|
18
|
-
/** 화면 밖 iframe에서 격리 인쇄(전역 print CSS·팝업 차단 무관).
|
|
18
|
+
/** 화면 밖 iframe에서 격리 인쇄(전역 print CSS·팝업 차단 무관). 인쇄가 진행 중이면(10초 한도) 다시 불러도 받지 않는다.
|
|
19
|
+
* 세 동작 모두 문서는 늘 light 테마(종이가 흰색). */
|
|
19
20
|
print: () => void;
|
|
20
21
|
/** 새 탭에 단어표를 열어 사용자가 직접 인쇄/저장하게 한다. */
|
|
21
22
|
openNewWindow: () => void;
|
|
@@ -105,7 +105,14 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
|
|
|
105
105
|
아무도 opacity 를 되돌리지 않아 **빈칸으로 인쇄**되고, 새 창·내려받은 HTML 에서는 스피너가 영영 돈다(말귀 내 단어표
|
|
106
106
|
'인쇄' 는 열자마자 100ms 뒤 인쇄해 처음 보는 표는 거의 늘 이랬다). */
|
|
107
107
|
clone.querySelectorAll('.word-table-cell__spinner').forEach(s => s.remove());
|
|
108
|
-
|
|
108
|
+
/* 불러오는 중이던 그림은 opacity 를 **그대로 두고** 받아지면 보이고 실패하면 숨게 한다 — 지우기만 하면 없는 그림(404, 화면에서는
|
|
109
|
+
onError 로 칸이 사라짐)이 인쇄 문서에서 깨진 그림 아이콘과 alt 글자로 찍혔다. autoPrint 는 그림 로드를 기다린 뒤 인쇄한다. */
|
|
110
|
+
clone.querySelectorAll('img').forEach(i => {
|
|
111
|
+
if (i.style.opacity !== '0')
|
|
112
|
+
return;
|
|
113
|
+
i.setAttribute('onload', "this.style.opacity=''");
|
|
114
|
+
i.setAttribute('onerror', "this.style.visibility='hidden'");
|
|
115
|
+
});
|
|
109
116
|
return `<div class="paper-wrap">${headHtml}${clone.outerHTML}</div>`;
|
|
110
117
|
}).join('\n');
|
|
111
118
|
/* 이 꾸러미의 CSS 는 전부 `.ihabdevteam-core-scope` **아래**에 산다(styles.css 스코핑).
|