@ihabdevteam/core 0.146.0 → 0.148.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 +57 -0
- package/dist/components.cjs.js +176 -176
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +181 -181
- package/dist/ihabdevteam-core.d.ts +22 -14
- package/dist/packages/core/src/components/BottomNav.js +15 -6
- package/dist/packages/core/src/components/Dropdown.js +2 -2
- package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +3 -1
- package/dist/packages/core/src/components/Popup/Popup.d.ts +5 -4
- package/dist/packages/core/src/components/Popup/Popup.js +1 -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/SegmentedControl.js +3 -1
- package/dist/packages/core/src/components/SideNav.js +1 -1
- package/dist/packages/core/src/components/TopBanner.js +7 -2
- 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/WordTableBlock.d.ts +0 -1
- package/dist/packages/core/src/components/Word/WordTableBlock.js +47 -22
- 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/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/phonemePairSearch.js +10 -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.js +4 -2
- package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +2 -2
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +2 -2
- package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +2 -1
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +5 -2
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +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.148.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 */
|
|
@@ -8426,7 +8434,8 @@ export declare interface UseWordTablePrintOptions {
|
|
|
8426
8434
|
|
|
8427
8435
|
/** @public */
|
|
8428
8436
|
export declare interface UseWordTablePrintResult {
|
|
8429
|
-
/** 화면 밖 iframe에서 격리 인쇄(전역 print CSS·팝업 차단 무관).
|
|
8437
|
+
/** 화면 밖 iframe에서 격리 인쇄(전역 print CSS·팝업 차단 무관). 인쇄가 진행 중이면(10초 한도) 다시 불러도 받지 않는다.
|
|
8438
|
+
* 세 동작 모두 문서는 늘 light 테마(종이가 흰색). */
|
|
8430
8439
|
print: () => void;
|
|
8431
8440
|
/** 새 탭에 단어표를 열어 사용자가 직접 인쇄/저장하게 한다. */
|
|
8432
8441
|
openNewWindow: () => void;
|
|
@@ -8934,7 +8943,6 @@ export declare interface WordSearchResultWord {
|
|
|
8934
8943
|
*/
|
|
8935
8944
|
export declare function WordTable({ title, titleIcon, disc, className, orientation, contentTypes, cutLine, showOutline, showDivLine, items, editCells, count, activeCell, onActivateCell, onRemoveCell, onRemoveItem, onReorder, showFillerAdd, contentRef, buttonSize, pageNumber, pageNumberPosition, fitHeight, scoring, marks, onMarkClick, scoreIndexOffset, }: WordTableProps): JSX.Element;
|
|
8936
8945
|
|
|
8937
|
-
/** @public */
|
|
8938
8946
|
export declare function WordTableBlock({ title, titleIcon, disc, items, onSaveComplete, onLoad, onRemoveItem, onReset, resetLabel, saveButtonLabel, secondarySaveButtonLabel, onReorder, showCellCount, onAddItem, className, renderSavePopup, renderWordSearch, hideControls, saveOnly, tableName, nameValue, onNameChange, descriptionValue, onDescriptionChange, namePlaceholder, descriptionPlaceholder, existingNames, editingName, onCancel }: WordTableBlockProps): JSX.Element;
|
|
8939
8947
|
|
|
8940
8948
|
/** @public */
|
|
@@ -40,16 +40,25 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
40
40
|
ro.observe(el);
|
|
41
41
|
return () => ro.disconnect();
|
|
42
42
|
}, []);
|
|
43
|
+
/* 잰 폭은 "…"(구분선 1px + 52px 칸)가 서 있으면 그만큼 줄어든 값이다. 그 폭으로만 판정하면 같은 폭·같은 항목인데 지나온 길에 따라
|
|
44
|
+
넘침이 달랐다(13개 매니저 메뉴 → 8개 말귀 메뉴로 바꾸면 들어갈 8개 중 하나가 "…" 에 갇힘). 유틸이 없을 때 지난 렌더에 "…" 가
|
|
45
|
+
서 있었다면(넘침 때문이든 그때의 유틸 때문이든) 그 폭을 되돌려 **"…" 없는 폭** 으로 판정하고, 넘치면 "…" 자리를 뺀 폭으로 센다. */
|
|
46
|
+
const MORE_W = BTN_SIZE + 1;
|
|
47
|
+
const 더보기섰음Ref = useRef(false);
|
|
48
|
+
const 온폭 = containerWidth > 0 && moreItems.length === 0 && 더보기섰음Ref.current ? containerWidth + MORE_W : containerWidth;
|
|
49
|
+
const 다들어감 = itemsLen * (BTN_SIZE + BTN_GAP) - BTN_GAP <= 온폭;
|
|
43
50
|
// n개 버튼: n*52 + (n-1)*10 <= W → n <= (W + 10) / 62
|
|
44
|
-
const n = containerWidth
|
|
45
|
-
? Math.floor((containerWidth + BTN_GAP) / (BTN_SIZE + BTN_GAP))
|
|
46
|
-
|
|
51
|
+
const n = containerWidth <= 0 ? itemsLen
|
|
52
|
+
: moreItems.length > 0 ? Math.floor((containerWidth + BTN_GAP) / (BTN_SIZE + BTN_GAP))
|
|
53
|
+
: 다들어감 ? itemsLen
|
|
54
|
+
: Math.floor((온폭 - MORE_W + BTN_GAP) / (BTN_SIZE + BTN_GAP));
|
|
47
55
|
const overflow = containerWidth > 0 && n < itemsLen;
|
|
48
56
|
const maxVisible = overflow ? n : itemsLen;
|
|
49
57
|
const visibleItems = items.slice(0, maxVisible);
|
|
50
58
|
const hiddenItems = items.slice(maxVisible);
|
|
51
59
|
// 넘친 nav 항목이 있거나 유틸(moreItems)이 있으면 더보기(…) 버튼을 노출
|
|
52
60
|
const showMore = overflow || moreItems.length > 0;
|
|
61
|
+
useEffect(() => { 더보기섰음Ref.current = showMore; });
|
|
53
62
|
const computeMenuStyle = useCallback((rect) => ({
|
|
54
63
|
position: 'fixed',
|
|
55
64
|
bottom: window.innerHeight - rect.top + 8,
|
|
@@ -66,7 +75,7 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
66
75
|
const nameWithCount = useCallback((label, count) => count > 0 ? t('nav.badgeLabel', { label, count, defaultValue: '{{label}}, {{count}}개' }) : label, [t]);
|
|
67
76
|
const handleModeChange = useCallback(() => onAppModeChange?.(nextMode), [onAppModeChange, nextMode]);
|
|
68
77
|
// 넘친 nav 항목 또는 유틸 항목이 현재 활성 상태면 … 버튼에 그 아이콘/선택표시를 반영
|
|
69
|
-
const activeMore = useMemo(() => hiddenItems.find(it => it.key === activeKey) ?? moreItems.find(it => it.selected || (it.key && it.key === activeKey)), [hiddenItems, activeKey, moreItems]);
|
|
78
|
+
const activeMore = useMemo(() => hiddenItems.find(it => !!it.key && it.key === activeKey) ?? moreItems.find(it => it.selected || (it.key && it.key === activeKey)), [hiddenItems, activeKey, moreItems]);
|
|
70
79
|
// 넘친 nav 항목 + 유틸 항목을 한 컬럼으로 합쳐 더보기 메뉴에 노출(각자 아이콘으로 구분)
|
|
71
80
|
const moreColumns = useMemo(() => [{
|
|
72
81
|
items: [
|
|
@@ -74,7 +83,7 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
74
83
|
label: it.label,
|
|
75
84
|
icon: it.icon,
|
|
76
85
|
badgeCount: it.badgeCount,
|
|
77
|
-
selected: it.key === activeKey,
|
|
86
|
+
selected: !!it.key && it.key === activeKey,
|
|
78
87
|
closeOnSelect: true,
|
|
79
88
|
// "…" 로 넘어간 꺼진 항목도 꺼진 채로 — 줄에서는 막히는데 메뉴에서는 골라졌다
|
|
80
89
|
disabled: it.state === 'disabled',
|
|
@@ -105,6 +114,6 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
105
114
|
const count = badgeOf(it.badgeCount);
|
|
106
115
|
return (
|
|
107
116
|
// key 는 선택 prop 이다 — 없으면 자리 번호로(형제 key 가 모두 undefined 가 되던 것)
|
|
108
|
-
_jsxs("span", { className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: it.icon, ariaLabel: nameWithCount(it.label, count), selected: it.key === activeKey, disabled: it.state === 'disabled', className: "bottom-nav-item", onClick: () => it.key && onChange?.(it.key) }), count > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(count, 99), size: 24 }) }))] }, it.key ?? `__slot-${i}`));
|
|
117
|
+
_jsxs("span", { className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: it.icon, ariaLabel: nameWithCount(it.label, count), selected: !!it.key && it.key === activeKey, disabled: it.state === 'disabled', className: "bottom-nav-item", onClick: () => it.key && onChange?.(it.key) }), count > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(count, 99), size: 24 }) }))] }, it.key ?? `__slot-${i}`));
|
|
109
118
|
}) }), showMore && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" }), _jsx("div", { className: "bottom-nav__right", children: _jsxs("div", { ref: moreRef, className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: activeMore?.icon ?? 'ellipsis', ariaLabel: nameWithCount(activeMore?.label ?? t('nav.more', '더보기'), moreBadge), selected: !!activeMore, className: "bottom-nav-item", onClick: handleToggleMore, "aria-expanded": moreOpen, "aria-controls": moreOpen ? moreMenuId : undefined }), moreBadge > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(moreBadge, 99), size: 24 }) })), moreOpen && createPortal(_jsx("div", { id: moreMenuId, role: "group", "aria-label": t('nav.moreMenu', '더보기 메뉴'), className: "tooltip-content", style: menuStyle, ref: menuRef, children: _jsx(MenuTooltip, { columns: moreColumns, onClose: handleCloseMore }) }), document.body)] }) })] }))] }));
|
|
110
119
|
}
|
|
@@ -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,
|
|
@@ -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 */
|
|
@@ -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);
|
|
@@ -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];
|
|
@@ -8,7 +8,9 @@ import Button from './Button';
|
|
|
8
8
|
/** @public */
|
|
9
9
|
export default function SegmentedControl(props) {
|
|
10
10
|
const { t } = useTranslation();
|
|
11
|
-
const { options = [],
|
|
11
|
+
const { options = [],
|
|
12
|
+
// 안 주면 **켜진** 첫 칸 — 꺼진 첫 칸이 골라져 있었다(Tabs 와 같게)
|
|
13
|
+
value = options.find((o) => !o.disabled)?.key ?? options[0]?.key, onChange = (_v) => { }, className = '', size = 48, shape = 'pill', useBG = true, fullWidth = false, ariaLabel, } = props || {};
|
|
12
14
|
/* **라디오 무리는 Tab 자리 하나 + 화살표**가 ARIA 규칙이다.
|
|
13
15
|
`role="radiogroup"`·`role="radio"` 만 붙여 놓고 키를 안 만들면 **안 붙인 것보다
|
|
14
16
|
나쁘다** — 낭독기가 "라디오 그룹, 3 중 2" 라고 읽어 주니 사람은 화살표를 누르는데
|
|
@@ -63,7 +63,7 @@ export default function SideNav(props) {
|
|
|
63
63
|
const handleModeSwitch = useCallback(() => onAppModeChange?.(isManager ? 'malgwi' : 'manager'), [onAppModeChange, isManager]);
|
|
64
64
|
return (_jsxs("nav", { "aria-label": t('nav.mainMenu', '메인 메뉴'), className: "side-nav__menu", children: [_jsxs("div", { className: "side-nav__menu-list", children: [(items || [])
|
|
65
65
|
.filter((it) => !it.section && (it.key || it.label))
|
|
66
|
-
.map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl, fromPointer) => {
|
|
66
|
+
.map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: !!it.key && it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl, fromPointer) => {
|
|
67
67
|
if (it.disabled)
|
|
68
68
|
return;
|
|
69
69
|
if (it.href) {
|
|
@@ -19,7 +19,11 @@ export default function TopBanner({ children, interval = 5000, dotTone, classNam
|
|
|
19
19
|
const slides = Children.toArray(children);
|
|
20
20
|
const count = slides.length;
|
|
21
21
|
const [index, setIndex] = useState(0);
|
|
22
|
-
|
|
22
|
+
/* 마우스 머무름과 포커스를 따로 든다 — 한 값으로 들면 포커스가 든 채 마우스가 스치고 나가면 멈춤이 풀려 포커스가 든 장이 넘어가
|
|
23
|
+
inert 가 되고 포커스가 body 로 떨어졌다(반대로 마우스 아래에서 포커스만 나가도 넘어갔다) */
|
|
24
|
+
const [hovered, setHovered] = useState(false);
|
|
25
|
+
const [focused, setFocused] = useState(false);
|
|
26
|
+
const paused = hovered || focused;
|
|
23
27
|
// 슬라이드 수가 줄어도 범위를 벗어나지 않도록
|
|
24
28
|
const current = count > 0 ? index % count : 0;
|
|
25
29
|
useEffect(() => {
|
|
@@ -30,7 +34,8 @@ export default function TopBanner({ children, interval = 5000, dotTone, classNam
|
|
|
30
34
|
}, [count, interval, paused]);
|
|
31
35
|
if (count === 0)
|
|
32
36
|
return null;
|
|
33
|
-
return (_jsxs("section", { className: cx('top-banner', className), "aria-label": ariaLabel, "aria-roledescription": "carousel", onMouseEnter: () =>
|
|
37
|
+
return (_jsxs("section", { className: cx('top-banner', className), "aria-label": ariaLabel, "aria-roledescription": "carousel", onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), onFocus: () => setFocused(true), onBlur: (e) => { if (!e.currentTarget.contains(e.relatedTarget))
|
|
38
|
+
setFocused(false); }, children: [_jsx("div", { className: "top-banner__viewport", children: _jsx("div", { className: "top-banner__track", style: { transform: `translateX(-${current * 100}%)` }, children: slides.map((slide, i) => (_jsx("div", { className: "top-banner__slide", "aria-hidden": i !== current, ...(i !== current ? { inert: true } : {}), children: slide }, i))) }) }), count > 1 && (_jsx("div", { className: `top-banner__dots${(dotTone?.[current] ?? 'light') === 'dark' ? ' top-banner__dots--dark' : ''}`, children: slides.map((_, i) => (_jsx("button", { type: "button", className: `top-banner__dot${i === current ? ' top-banner__dot--active' : ''}`, "aria-label": t('topBanner.dotAriaLabel', {
|
|
34
39
|
count, index: i + 1, defaultValue: `${count}개 중 ${i + 1}번째 배너`,
|
|
35
40
|
}), "aria-current": i === current, onClick: () => setIndex(i) }, i))) }))] }));
|
|
36
41
|
}
|
|
@@ -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;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import './WordTableBlock.css';
|
|
2
2
|
import type { WordTableBlockProps } from './wordTableBlockTypes';
|
|
3
3
|
export type { WordEntry, WordTableBlockProps } from './wordTableBlockTypes';
|
|
4
|
-
/** @public */
|
|
5
4
|
export default function WordTableBlock({ title, titleIcon, disc, items, onSaveComplete, onLoad, onRemoveItem, onReset, resetLabel, saveButtonLabel, secondarySaveButtonLabel, onReorder, showCellCount, onAddItem, className, renderSavePopup, renderWordSearch, hideControls, saveOnly, tableName, nameValue, onNameChange, descriptionValue, onDescriptionChange, namePlaceholder, descriptionPlaceholder, existingNames, editingName, onCancel }: WordTableBlockProps): import("react").JSX.Element;
|
|
@@ -23,6 +23,28 @@ import { useWordTablePrint } from './useWordTablePrint';
|
|
|
23
23
|
import { CELL_COUNTS, ITEMS_PER_PAGE_OPTIONS } from './wordTableBlockTypes';
|
|
24
24
|
// ── 컴포넌트 ───────────────────────────────────────────────
|
|
25
25
|
/** @public */
|
|
26
|
+
/** 편집 칸의 낱말 → 저장 항목의 낱말. 편집 칸에서 보이던 구어체 발음·등급·품사·유형을 그대로 옮긴다. */
|
|
27
|
+
function 항목낱말(e) {
|
|
28
|
+
return {
|
|
29
|
+
label: e.word, subLabel: e.pronunciation, colloquialLabel: e.colloquialPronunciation, homonum: e.homonum,
|
|
30
|
+
grade: e.grade, syllables: [...e.word].length, pos: e.pos, wordType: e.wordType,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** 동음이의어 번호까지 넣은 낱말들로 만든 id — 첫 낱말만 쓰면 '배'(1)·'배'(2), '가다|나다'·'가다|다다' 가 같은 id 였다 */
|
|
34
|
+
function 항목id(entries) {
|
|
35
|
+
return entries.map(e => (e.homonum != null ? `${e.word}#${e.homonum}` : e.word)).join('|');
|
|
36
|
+
}
|
|
37
|
+
/** 표 안에서 같은 낱말을 두 칸에 넣었을 때도 id 가 겹치지 않게 뒤에 ~2, ~3 을 붙인다 */
|
|
38
|
+
function 겹치지않는id(list) {
|
|
39
|
+
const 쓴 = new Set();
|
|
40
|
+
return list.map(it => {
|
|
41
|
+
let id = it.id;
|
|
42
|
+
for (let n = 2; 쓴.has(id); n++)
|
|
43
|
+
id = `${it.id}~${n}`;
|
|
44
|
+
쓴.add(id);
|
|
45
|
+
return id === it.id ? it : { ...it, id };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
26
48
|
export default function WordTableBlock({ title, titleIcon, disc, items, onSaveComplete, onLoad, onRemoveItem, onReset, resetLabel, saveButtonLabel, secondarySaveButtonLabel, onReorder, showCellCount, onAddItem, className, renderSavePopup, renderWordSearch, hideControls, saveOnly, tableName, nameValue, onNameChange, descriptionValue, onDescriptionChange, namePlaceholder, descriptionPlaceholder, existingNames, editingName, onCancel }) {
|
|
27
49
|
const { t } = useTranslation();
|
|
28
50
|
const resolvedResetLabel = resetLabel ?? t('wordPrint.clearAllSelection', '모두 선택 해제');
|
|
@@ -53,6 +75,7 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
53
75
|
const [cells, setCells] = useState(Array(4).fill(null));
|
|
54
76
|
const [cellCount, setCellCount] = useState(4);
|
|
55
77
|
const [activeCell, setActiveCell] = useState(null);
|
|
78
|
+
const visibleCells = cells.slice(0, cellCount);
|
|
56
79
|
// 공통 단어 표 설정 상태
|
|
57
80
|
const [orientation, setOrientation] = useState('landscape');
|
|
58
81
|
const [contentTypes, setContentTypes] = useState(['이미지', '단어', '표준 발음']);
|
|
@@ -104,7 +127,7 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
104
127
|
const pages = pagesEnabled ? chunkItems(items ?? [], itemsPerPage) : null;
|
|
105
128
|
const isPaginated = !!pages && pages.length > 1;
|
|
106
129
|
const hasPairs = isEditable
|
|
107
|
-
?
|
|
130
|
+
? visibleCells.some(c => c !== null && c.length > 1)
|
|
108
131
|
: (items ?? []).some(item => item.words.length > 1);
|
|
109
132
|
// PDF로 저장할 때 OS가 제안하는 기본 파일명. 저장된 표 이름이 있으면 그대로, 없으면 "오늘 날짜 + 단어표/대립쌍표"
|
|
110
133
|
const printFileName = tableName?.trim()
|
|
@@ -118,13 +141,11 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
118
141
|
onPopupBlocked: () => { showWarning(t('wordTable.popupBlocked', '팝업이 차단되었습니다. 브라우저 설정에서 팝업을 허용해 주세요.')); },
|
|
119
142
|
});
|
|
120
143
|
// 편집 모드 핸들러
|
|
144
|
+
/* 칸 수를 줄여도 뒤 칸의 낱말은 버리지 않고 들고 있는다 — 4칸→2칸→4칸에 셋째·넷째 낱말이 조용히 사라졌다.
|
|
145
|
+
보이는(저장되는) 것은 앞의 cellCount 칸뿐이다. */
|
|
121
146
|
const handleCellCountChange = (next) => {
|
|
122
147
|
setCellCount(next);
|
|
123
|
-
setCells(prev =>
|
|
124
|
-
const arr = Array(next).fill(null);
|
|
125
|
-
prev.slice(0, next).forEach((v, i) => { arr[i] = v; });
|
|
126
|
-
return arr;
|
|
127
|
-
});
|
|
148
|
+
setCells(prev => prev.length >= next ? prev : [...prev, ...Array(next - prev.length).fill(null)]);
|
|
128
149
|
setActiveCell(null);
|
|
129
150
|
};
|
|
130
151
|
const handleRemove = (idx) => {
|
|
@@ -136,12 +157,13 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
136
157
|
if (activeCell === null)
|
|
137
158
|
return;
|
|
138
159
|
const idx = activeCell;
|
|
139
|
-
|
|
140
|
-
const
|
|
160
|
+
// 빈 배열로 확인하면 비운 칸으로 둔다 — [] 를 담으면 저장 항목을 만들 때 c[0].word 에서 던졌다
|
|
161
|
+
setCells(prev => { const next = [...prev]; next[idx] = entries.length > 0 ? entries : null; return next; });
|
|
162
|
+
const nextEmpty = visibleCells.findIndex((c, i) => i > idx && c === null);
|
|
141
163
|
setActiveCell(nextEmpty !== -1 ? nextEmpty : null);
|
|
142
164
|
};
|
|
143
165
|
const hasColloquial = isEditable
|
|
144
|
-
?
|
|
166
|
+
? visibleCells.some(c => c?.some(e => !!e.colloquialPronunciation))
|
|
145
167
|
: (items ?? []).some(item => item.words.some(w => !!w.colloquialLabel));
|
|
146
168
|
// 표에 실제로 이미지가 있는지 — resolver(word)는 존재 여부와 무관하게 항상 URL을 만들어 반환하므로
|
|
147
169
|
// 그 truthiness만으로는 판정할 수 없다(항상 true가 되어 이미지 없는 표에서도 '이미지' 토글이
|
|
@@ -153,9 +175,9 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
153
175
|
if (!getImageResolver())
|
|
154
176
|
return [];
|
|
155
177
|
return isEditable
|
|
156
|
-
? cells.flatMap(c => (c ?? []).map(e => [e.word, e.homonum]))
|
|
178
|
+
? cells.slice(0, cellCount).flatMap(c => (c ?? []).map(e => [e.word, e.homonum]))
|
|
157
179
|
: (items ?? []).flatMap(item => item.words.map(w => [w.label, w.homonum]));
|
|
158
|
-
}, [isEditable, cells, items]);
|
|
180
|
+
}, [isEditable, cells, cellCount, items]);
|
|
159
181
|
/* 실제로 파일이 있는지는 물어봐야 안다(HEAD 요청 + 캐시). 그 답만 상태로 든다.
|
|
160
182
|
`imagePairs` 가 비면 물어볼 것도 없으므로 아래 유도에서 곧바로 false 가 된다. */
|
|
161
183
|
const [imageFound, setImageFound] = useState(false);
|
|
@@ -179,7 +201,7 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
179
201
|
...(isPaginated ? [buildPageNumberSection(t)] : []),
|
|
180
202
|
], [t, isEditable, showCellCount, pagesEnabled, items?.length, hasPairs, itemCount, isPaginated]);
|
|
181
203
|
const middleSelected = {
|
|
182
|
-
...((isEditable || showCellCount) ? { [t('wordTable.filter.columns', '칸 수')]: `${
|
|
204
|
+
...((isEditable || showCellCount) ? { [t('wordTable.filter.columns', '칸 수')]: `${count}칸` } : {}),
|
|
183
205
|
...(pagesEnabled && (items?.length ?? 0) > 0 ? { [t(hasPairs ? 'wordTable.filter.itemsPerPagePair' : 'wordTable.filter.itemsPerPageWord')]: `${effectiveItemsPerPage}개` } : {}),
|
|
184
206
|
...(isPaginated ? { [t('wordTable.filter.pageNumber', '페이지 번호')]: PAGE_NUM_POSITION_VALUE[pageNumberPosition] } : {}),
|
|
185
207
|
};
|
|
@@ -187,8 +209,9 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
187
209
|
if (sectionLabel === t('wordTable.filter.columns', '칸 수')) {
|
|
188
210
|
if (isEditable)
|
|
189
211
|
handleCellCountChange(parseInt(value));
|
|
212
|
+
// 항목 수보다 적게는 못 줄인다(그리드는 항목 수만큼 그린다) — 고른 값을 그대로 담으면 켜진 칸 수와 보이는 표가 달랐다
|
|
190
213
|
else
|
|
191
|
-
setDisplayCellCount(parseInt(value));
|
|
214
|
+
setDisplayCellCount(Math.max(parseInt(value), items?.length ?? 1));
|
|
192
215
|
}
|
|
193
216
|
else if (sectionLabel === t(hasPairs ? 'wordTable.filter.itemsPerPagePair' : 'wordTable.filter.itemsPerPageWord')) {
|
|
194
217
|
/* 목록에 없는 개수(= 그때의 '전체' 칸)를 고르면 **전체** 로 기억한다 — 숫자로 담으면 항목이 늘었을 때 고른 값('10개')이
|
|
@@ -202,10 +225,10 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
202
225
|
};
|
|
203
226
|
// 저장 팝업용 현재 items / preview
|
|
204
227
|
const saveItems = isEditable
|
|
205
|
-
?
|
|
206
|
-
id: c
|
|
207
|
-
words: c.map(
|
|
208
|
-
}))
|
|
228
|
+
? 겹치지않는id(visibleCells.filter((c) => c !== null && c.length > 0).map(c => ({
|
|
229
|
+
id: 항목id(c),
|
|
230
|
+
words: c.map(항목낱말),
|
|
231
|
+
})))
|
|
209
232
|
: items ?? [];
|
|
210
233
|
const handleSave = (asNew = false) => {
|
|
211
234
|
setSaveAsNew(asNew);
|
|
@@ -279,10 +302,12 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
279
302
|
if (onAddItem) {
|
|
280
303
|
const entry = entries[0];
|
|
281
304
|
if (entry) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
305
|
+
// 이미 있는 항목과 id 가 겹치면(같은 낱말의 다른 동음이의어·같은 낱말) onRemoveItem(id) 가 둘을 함께 지운다
|
|
306
|
+
const 쓴id = new Set((items ?? []).map(it => it.id));
|
|
307
|
+
let id = 항목id([entry]);
|
|
308
|
+
for (let n = 2; 쓴id.has(id); n++)
|
|
309
|
+
id = `${항목id([entry])}~${n}`;
|
|
310
|
+
onAddItem({ id, words: [항목낱말(entry)] });
|
|
286
311
|
}
|
|
287
312
|
}
|
|
288
313
|
setActiveCell(null);
|
|
@@ -291,7 +316,7 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
291
316
|
}), !hideControls && (_jsx(TableFilterPanel, { hasImage: hasImage, hasColloquial: hasColloquial, contentTypes: contentTypes, orientation: orientation, showOutline: showOutline, cutLine: cutLine, showDivLine: showDivLine, setters: { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine }, showCutLine: (pagesEnabled ? itemsPerPage : count) > 1, showDivLine2: hasPairs, middleSections: middleSections, extraSelected: middleSelected, onExtraSelect: handleMiddleSelect })), isPaginated && pages ? (_jsx("div", { className: "word-table-block__pages", children: pages.map((page, i) => (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: itemsPerPage, items: page, onRemoveItem: onRemoveItem,
|
|
292
317
|
/* 쪽이 나뉘면 끌어 옮기기가 통째로 꺼졌다(말귀 단어 찾기: 9개부터). 쪽 안의 자리를 전체 자리로 옮겨 넘긴다 —
|
|
293
318
|
쪽을 넘는 이동은 하지 않는다. */
|
|
294
|
-
onReorder: onReorder && ((from, to) => onReorder(from + i * itemsPerPage, to + i * itemsPerPage)), contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ?
|
|
319
|
+
onReorder: onReorder && ((from, to) => onReorder(from + i * itemsPerPage, to + i * itemsPerPage)), contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? visibleCells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined, showFillerAdd: !!onAddItem, contentRef: contentRef })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
|
|
295
320
|
/* PanelRow의 라벨은 시각적으로만 붙어 있고 입력과 연결돼 있지 않아, 낭독기에는
|
|
296
321
|
이름 없는 입력으로 읽혔다. placeholder는 자동완성 안내문("Tab 또는 space…")이
|
|
297
322
|
섞여 있어 이름으로 부적절하므로 같은 라벨 문구를 넘긴다. */
|
|
@@ -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;
|