@ihabdevteam/core 0.133.0 → 0.135.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 (69) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/dist/components.cjs.js +176 -176
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +182 -182
  7. package/dist/ihabdevteam-core.d.ts +8 -2
  8. package/dist/packages/core/src/components/BarChart.js +3 -1
  9. package/dist/packages/core/src/components/Breadcrumb.js +5 -1
  10. package/dist/packages/core/src/components/Dialog.js +32 -9
  11. package/dist/packages/core/src/components/Header.css +2 -0
  12. package/dist/packages/core/src/components/Header.js +4 -1
  13. package/dist/packages/core/src/components/LandingHeader.js +18 -1
  14. package/dist/packages/core/src/components/Pagination.js +5 -2
  15. package/dist/packages/core/src/components/Panel.d.ts +1 -1
  16. package/dist/packages/core/src/components/Panel.js +4 -1
  17. package/dist/packages/core/src/components/PassCard/passCardProduct.js +6 -0
  18. package/dist/packages/core/src/components/PaymentPlan.d.ts +6 -0
  19. package/dist/packages/core/src/components/PaymentPlan.js +5 -3
  20. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +34 -21
  21. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +4 -1
  22. package/dist/packages/core/src/components/Popup/ListPopup.js +7 -2
  23. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +13 -3
  24. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +5 -1
  25. package/dist/packages/core/src/components/PromoBanner.js +1 -1
  26. package/dist/packages/core/src/components/Report/ReportDocument.js +35 -10
  27. package/dist/packages/core/src/components/RingChart.js +14 -3
  28. package/dist/packages/core/src/components/SideNav.js +1 -1
  29. package/dist/packages/core/src/components/SoundCompass.js +10 -1
  30. package/dist/packages/core/src/components/Table.js +15 -5
  31. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +7 -2
  32. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
  33. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +28 -2
  34. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +11 -7
  35. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
  36. package/dist/packages/core/src/components/Training/TrainingWordCard.js +4 -0
  37. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -1
  38. package/dist/packages/core/src/components/Training/useTTSPlayback.js +9 -0
  39. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -2
  40. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -1
  41. package/dist/packages/core/src/components/barChartData.d.ts +8 -0
  42. package/dist/packages/core/src/components/barChartData.js +21 -0
  43. package/dist/packages/core/src/components/useBarChartControl.js +5 -3
  44. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +17 -1
  45. package/dist/packages/core/src/hooks/useEditableList.d.ts +6 -0
  46. package/dist/packages/core/src/hooks/useEditableList.js +20 -1
  47. package/dist/packages/core/src/hooks/useTablePaging.js +4 -0
  48. package/dist/packages/core/src/hooks/useVisitHistory.js +2 -2
  49. package/dist/packages/core/src/hooks/useVisitSchedule.js +5 -4
  50. package/dist/packages/core/src/hooks/useWordMeanings.js +13 -4
  51. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +9 -4
  52. package/dist/packages/core/src/hooks/wordTrainingFilterModel.d.ts +1 -1
  53. package/dist/packages/core/src/hooks/wordTrainingFilterModel.js +7 -2
  54. package/dist/packages/core/src/i18n/locales/en.json +13 -1
  55. package/dist/packages/core/src/i18n/locales/ko.json +13 -1
  56. package/dist/packages/core/src/utils/format.js +17 -1
  57. package/dist/packages/core/src/utils/getTrainingData.js +11 -4
  58. package/dist/packages/core/src/utils/imageConfig.js +6 -3
  59. package/dist/packages/core/src/utils/koreanPronunciation.js +7 -0
  60. package/dist/packages/core/src/utils/pressable.js +4 -0
  61. package/dist/packages/core/src/utils/savedWordTables.js +2 -1
  62. package/dist/packages/core/src/utils/wordTrainingPool.d.ts +7 -0
  63. package/dist/packages/core/src/utils/wordTrainingPool.js +30 -0
  64. package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +1 -1
  65. package/dist/packages/core/src/utils/wordTrainingRuntime.js +38 -17
  66. package/dist/packages/core/src/version.d.ts +1 -1
  67. package/dist/packages/core/src/version.js +1 -1
  68. package/dist/utils.cjs.js +2 -2
  69. package/package.json +1 -1
@@ -1308,7 +1308,7 @@ export declare interface ContrastFilterPanelProps {
1308
1308
  }
1309
1309
 
1310
1310
  /** 모드 전환 시 composition의 초성/모음/종성을 대상 모드 값으로 변환. 종성 '없음'(NO_CODA)은 유지. */
1311
- export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null): WordComposition;
1311
+ export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null, preferredCharJong?: string | null): WordComposition;
1312
1312
 
1313
1313
  /**
1314
1314
  * 텍스트를 클립보드에 복사. 성공 여부를 boolean으로 반환.
@@ -1369,7 +1369,7 @@ export declare function copyText(text: string): Promise<boolean>;
1369
1369
  *
1370
1370
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1371
1371
  */
1372
- export declare const CORE_VERSION = "0.133.0";
1372
+ export declare const CORE_VERSION = "0.135.0";
1373
1373
 
1374
1374
  /** @public */
1375
1375
  declare const coreDefault: Record<string, unknown>;
@@ -3794,6 +3794,12 @@ export declare interface PaymentPlanTier {
3794
3794
  * 구매 횟수가 제한되지 않는다(같은 프로모션이되 1회 제한 대상은 아닌 tier용).
3795
3795
  */
3796
3796
  openPromo?: boolean;
3797
+ /**
3798
+ * **계정당 1회만 살 수 있는 tier**(오픈 특가 30일 등). 이미 산 것(`ownedCodes`)이면 숨기고 기본 tier 를 카드로 올린다.
3799
+ * 안 주면 예전처럼 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
3800
+ * 이미 산 특가 카드가 남고 정가 30일권을 살 길이 없어진다. 번역되는 라벨 대신 이 값을 넘긴다.
3801
+ */
3802
+ onceOnly?: boolean;
3797
3803
  [k: string]: unknown;
3798
3804
  }
3799
3805
 
@@ -40,7 +40,9 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, h
40
40
  }, [padded]);
41
41
  const [tooltip, setTooltip] = useState(null);
42
42
  const handleBarLeave = useCallback(() => setTooltip(null), []);
43
- return (_jsxs("div", { className: "manager-bar-chart-wrap", children: [_jsx("div", { className: "manager-bar-chart-inner", children: _jsx("div", { className: "manager-bar-chart-scroll", children: _jsxs("div", { className: "manager-bar-chart-area", children: [_jsxs("div", { className: "manager-bar-chart-plot", style: { height: h }, children: [ticks.slice(0, -1).map((t, i) => (_jsx("div", { className: "manager-bar-chart-gridline", style: { top: i * (h / (ticks.length - 1)) }, children: _jsx("span", { className: "manager-bar-chart-gridline__label caption-1", children: t }) }, i))), data.map((d, i) => {
43
+ return (_jsxs("div", { className: "manager-bar-chart-wrap", children: [_jsx("div", { className: "manager-bar-chart-inner", children: _jsx("div", { className: "manager-bar-chart-scroll", children: _jsxs("div", { className: "manager-bar-chart-area", children: [_jsxs("div", { className: "manager-bar-chart-plot", style: { height: h }, children: [ticks.slice(0, -1).map((t, i) => (
44
+ /* 눈금선은 **적힌 값의 높이**에 둔다 — 가운데 값을 반올림(최대 3 → '2')하면서 자리는 절반(1.5)에 두어 선과 숫자가 어긋났다 */
45
+ _jsx("div", { className: "manager-bar-chart-gridline", style: { top: (1 - t / padded) * h }, children: _jsx("span", { className: "manager-bar-chart-gridline__label caption-1", children: t }) }, i))), data.map((d, i) => {
44
46
  const isFuture = d.a === -1;
45
47
  /* 판 높이를 넘지 않는다 — maxOverride(진행률 100)보다 큰 값이 와도 막대가 눈금 위로 삐져나가지 않게 */
46
48
  const 높이 = (v) => Math.min(h, Math.max(0, (수(v) / padded) * h));
@@ -1,9 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useTranslation } from 'react-i18next';
2
3
  import './Breadcrumb.css';
3
4
  import Icon from './Icon';
4
5
  /** @public */
5
6
  export default function Breadcrumb({ items, className = '' }) {
6
- return (_jsx("nav", { className: `breadcrumb ${className}`.trim(), "aria-label": "breadcrumb", children: _jsx("ol", { className: "breadcrumb__list", children: items.map((item, index) => {
7
+ const { t } = useTranslation();
8
+ return (
9
+ /* 영어로 박혀 있어 한국어 화면에서도 낭독기가 "breadcrumb" 이라고 읽었다 — 사전을 탄다 */
10
+ _jsx("nav", { className: `breadcrumb ${className}`.trim(), "aria-label": t('breadcrumb.ariaLabel', '이동 경로'), children: _jsx("ol", { className: "breadcrumb__list", children: items.map((item, index) => {
7
11
  const isLast = index === items.length - 1;
8
12
  return (_jsxs("li", { className: "breadcrumb__item", children: [isLast ? (_jsx("span", { className: "breadcrumb__current body-3", "aria-current": "page", children: item.label })) : (_jsx("button", { type: "button", className: "breadcrumb__link body-3", onClick: item.onClick, children: item.label })), !isLast && (_jsx(Icon, { icon: "chevron-right", size: 14, className: "breadcrumb__separator" }))] }, index));
9
13
  }) }) }));
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useId, useRef } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { createPortal } from 'react-dom';
6
6
  import { cx } from '../utils/cx';
7
+ import { lockBodyScroll } from '../hooks/bodyScrollLock';
7
8
  import { pushEsc } from '../hooks/escStack';
8
9
  import { useFocusTrap } from '../hooks/useFocusTrap';
9
10
  import './Dialog.css';
@@ -39,12 +40,18 @@ export default function Dialog({ open, onClose, ariaLabel, title, children, size
39
40
  if (!open)
40
41
  return;
41
42
  const 돌아갈곳 = document.activeElement;
42
- const { overflow } = document.body.style;
43
- document.body.style.overflow = 'hidden';
44
- surfaceRef.current?.focus();
43
+ /* 스크롤 잠금은 **공유 카운터**(Popup·TrainingPagePopup 과 같은 것)로 — 전에는 body.style.overflow 를 제 손으로
44
+ 기억했다 되돌려, Dialog 위에 Popup 을 열고 Dialog 를 먼저 닫으면 Popup 이 떠 있는데 스크롤이 풀리고, 이어 Popup 을
45
+ 닫으면 overflow:hidden 으로 영영 굳었다. */
46
+ const 풀기 = lockBodyScroll();
47
+ const 판 = surfaceRef.current;
48
+ 판?.focus();
45
49
  return () => {
46
- document.body.style.overflow = overflow;
47
- 돌아갈곳?.focus?.();
50
+ 풀기();
51
+ // 포커스가 이 창 안이거나 갈 곳을 잃었을 때만 되돌린다 — 이미 다른 창으로 옮겨 갔으면 빼앗지 않는다(Popup 과 같다)
52
+ const 지금 = document.activeElement;
53
+ if (!지금 || 지금 === document.body || (판 && 판.contains(지금)))
54
+ 돌아갈곳?.focus?.();
48
55
  };
49
56
  }, [open]);
50
57
  /* ESC 는 **공유 스택**에 맡긴다 — 예전에는 window 에서 직접 들었다.
@@ -53,11 +60,21 @@ export default function Dialog({ open, onClose, ariaLabel, title, children, size
53
60
  **아래 팝업이 닫히고 이 확인창만 남는** 꼴이다 — 닫으려던 것은 그대로 있고
54
61
  뒤에 있던 화면이 사라진다.
55
62
  스택에 올리면 맨 위 하나만 닫힌다(그것이 이 창이다). */
63
+ /* 스택에는 **열릴 때 한 번** 올린다 — handleClose 가 바뀔 때마다(인라인 onClose) 다시 올리면 맨 위로 끼어들어, 위에
64
+ 팝업이 떠 있어도 Esc 가 이 창을 닫았다. closeOnEscape={false} 라도 올려서 Esc 를 **삼킨다** — 안 올리면 이 창
65
+ 아래에 가려진 팝업이 닫혔다(모달은 제 아래를 지킨다). */
66
+ const closeRef = useRef(handleClose);
67
+ const escRef = useRef(closeOnEscape);
68
+ useEffect(() => { closeRef.current = handleClose; escRef.current = closeOnEscape; });
56
69
  useEffect(() => {
57
- if (!open || !closeOnEscape)
70
+ if (!open)
58
71
  return;
59
- return pushEsc(handleClose);
60
- }, [open, closeOnEscape, handleClose]);
72
+ return pushEsc(() => { if (escRef.current)
73
+ closeRef.current(); });
74
+ }, [open]);
75
+ /* 바탕 누르기 — 누른 곳과 뗀 곳이 **둘 다 바탕일 때만** 닫는다(Popup 0.128.0 과 같다). 판 안에서 글자를 긁어
76
+ 고르다 바깥에서 놓으면 click 이 공통 조상(바탕)에 떨어져 창이 닫혔다. */
77
+ const 바탕에서누름 = useRef(false);
61
78
  /* Tab 을 이 판 안에 가둔다 — `aria-modal="true"` 는 **낭독기에게만** 통한다.
62
79
  눈으로 보며 Tab 을 누르는 사람은 그 표시를 못 보므로, 가둠이 없으면 마지막
63
80
  단추 다음에서 **뒤 화면**으로 빠져나간다. 실제로 그랬다: 놀이터
@@ -67,5 +84,11 @@ export default function Dialog({ open, onClose, ariaLabel, title, children, size
67
84
  useFocusTrap(surfaceRef, open);
68
85
  if (!open || typeof document === 'undefined')
69
86
  return null;
70
- return createPortal(_jsx("div", { className: cx('dialog', className), onClick: closeOnBackdrop ? handleClose : undefined, children: _jsxs("div", { ref: surfaceRef, role: "dialog", "aria-modal": "true", "aria-label": title ? undefined : ariaLabel, "aria-labelledby": title ? titleId : undefined, tabIndex: -1, className: cx('dialog__surface', `dialog__surface--${size}`, contentClassName), onClick: (e) => e.stopPropagation(), children: [title ? (_jsx("div", { className: "dialog__header", children: _jsx("h2", { id: titleId, className: "dialog__title h4b", children: title }) })) : null, showClose ? (_jsx("button", { type: "button", className: "dialog__close", "aria-label": t('common.close', '닫기'), onClick: handleClose, children: _jsx(CloseIcon, {}) })) : null, _jsx("div", { className: "dialog__body", children: children })] }) }), document.body);
87
+ return createPortal(_jsx("div", { className: cx('dialog', className), onPointerDown: closeOnBackdrop ? (e) => { 바탕에서누름.current = e.target === e.currentTarget; } : undefined, onMouseDown: (e) => { if (e.target === e.currentTarget)
88
+ e.preventDefault(); }, onClick: closeOnBackdrop ? (e) => {
89
+ const 눌렀나 = 바탕에서누름.current;
90
+ 바탕에서누름.current = false;
91
+ if (눌렀나 && e.target === e.currentTarget)
92
+ handleClose();
93
+ } : undefined, children: _jsxs("div", { ref: surfaceRef, role: "dialog", "aria-modal": "true", "aria-label": title ? undefined : ariaLabel, "aria-labelledby": title ? titleId : undefined, tabIndex: -1, className: cx('dialog__surface', `dialog__surface--${size}`, contentClassName), onClick: (e) => e.stopPropagation(), children: [title ? (_jsx("div", { className: "dialog__header", children: _jsx("h2", { id: titleId, className: "dialog__title h4b", children: title }) })) : null, showClose ? (_jsx("button", { type: "button", className: "dialog__close", "aria-label": t('common.close', '닫기'), onClick: handleClose, children: _jsx(CloseIcon, {}) })) : null, _jsx("div", { className: "dialog__body", children: children })] }) }), document.body);
71
94
  }
@@ -21,6 +21,8 @@
21
21
  display: none;
22
22
  background: transparent;
23
23
  border: none;
24
+ /* 읽지 않은 알림 배지(top-right)의 기준 — SideNav 알림 단추(.side-nav__notification-wrap)와 같다 */
25
+ position: relative;
24
26
  }
25
27
 
26
28
  .header__bottom {
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
5
5
  import { useMediaQuery } from '../hooks';
6
6
  import BottomNav from './BottomNav';
7
7
  import Button from './Button';
8
+ import Badge from './Badge';
8
9
  import MenuTooltip from './Tooltip/MenuTooltip';
9
10
  import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
10
11
  import { createPortal } from 'react-dom';
@@ -112,7 +113,9 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
112
113
  onClick: () => { it.onClick?.(); closeUtilMenu(); },
113
114
  })),
114
115
  }], [bottomMoreItems, activeKey, closeUtilMenu]);
115
- return (_jsxs("aside", { className: `header${appMode === 'manager' ? ' header--manager' : ''}`, children: [_jsxs("div", { className: "header__bi", children: [_jsx(BrandIdentity, { appMode: appMode }), _jsxs("div", { className: "header__menu-button", ref: utilTriggerRef, children: [_jsx(Button, { htmlType: "button", size: 48, type: "dot", shape: "pill", variant: "default", icon: 'menu', ariaLabel: t('header.menuOpen', '메뉴 열기'), onClick: toggleUtilMenu, "aria-expanded": utilOpen, "aria-controls": utilOpen ? utilMenuId : undefined }), utilOpen && createPortal(_jsx("div", { id: utilMenuId, role: "group", "aria-label": t('header.utilMenu', '유틸리티 메뉴'), className: "tooltip-content", style: utilMenuStyle, ref: utilMenuRef, children: _jsx(MenuTooltip, { columns: utilColumns, onClose: closeUtilMenu }) }), document.body)] })] }), _jsx(SideNav, { items: items, activeKey: activeKey ?? undefined, onChange: onChange, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp, appMode: appMode, onAppModeChange: onAppModeChange }), isMobile && _jsx(BottomNav, { items: bottomItems, activeKey: activeKey ?? undefined, onChange: handleBottomChange, appMode: appMode, onAppModeChange: onAppModeChange })] }));
116
+ return (_jsxs("aside", { className: `header${appMode === 'manager' ? ' header--manager' : ''}`, children: [_jsxs("div", { className: "header__bi", children: [_jsx(BrandIdentity, { appMode: appMode }), _jsxs("div", { className: "header__menu-button", ref: utilTriggerRef, children: [_jsx(Button, { htmlType: "button", size: 48, type: "dot", shape: "pill", variant: "default", icon: 'menu', ariaLabel: onNotification && unreadCount && unreadCount > 0
117
+ ? t('nav.badgeLabel', { label: t('header.menuOpen', '메뉴 열기'), count: unreadCount, defaultValue: '{{label}}, {{count}}개' })
118
+ : t('header.menuOpen', '메뉴 열기'), onClick: toggleUtilMenu, "aria-expanded": utilOpen, "aria-controls": utilOpen ? utilMenuId : undefined }), !!onNotification && !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true })), utilOpen && createPortal(_jsx("div", { id: utilMenuId, role: "group", "aria-label": t('header.utilMenu', '유틸리티 메뉴'), className: "tooltip-content", style: utilMenuStyle, ref: utilMenuRef, children: _jsx(MenuTooltip, { columns: utilColumns, onClose: closeUtilMenu }) }), document.body)] })] }), _jsx(SideNav, { items: items, activeKey: activeKey ?? undefined, onChange: onChange, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp, appMode: appMode, onAppModeChange: onAppModeChange }), isMobile && _jsx(BottomNav, { items: bottomItems, activeKey: activeKey ?? undefined, onChange: handleBottomChange, appMode: appMode, onAppModeChange: onAppModeChange })] }));
116
119
  }
117
120
  /**
118
121
  * `Header`의 alias입니다. 프로젝트 내 `Header`라는 이름이 이미 사용 중일 때 충돌 없이 임포트할 수 있습니다.
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useId, useState } from 'react';
3
+ import { useEffect, useId, useState } from 'react';
4
+ import { pushEsc } from '../hooks/escStack';
4
5
  import './LandingHeader.css';
5
6
  /**
6
7
  * 랜딩/마케팅 페이지용 상단 고정 헤더.
@@ -22,6 +23,22 @@ export default function LandingHeader({ logo, logoAlt = '', logoHref, links = []
22
23
  // 서랍 id 를 고정 문자열로 두면 한 화면에 헤더가 둘일 때(미리보기·놀이터 견본) id 가 겹쳐
23
24
  // 햄버거의 aria-controls 가 엉뚱한 서랍을 가리킨다 — 헤더마다 따로 짓는다.
24
25
  const drawerId = useId();
26
+ /* 서랍이 열려 있으면 Esc 로 닫는다(공유 ESC 스택 — 맨 위 하나만). 넓은 화면(961px~, 서랍이 CSS 로 숨는 폭)으로 가면
27
+ 닫는다 — 안 닫으면 open 이 남아 좁은 폭으로 돌아오는 순간 서랍이 다시 펼쳐졌다. */
28
+ useEffect(() => {
29
+ if (!open)
30
+ return;
31
+ const 풀기 = pushEsc(() => setOpen(false));
32
+ const mq = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(min-width: 961px)') : null;
33
+ const 넓어짐 = (e) => { if (e.matches)
34
+ setOpen(false); };
35
+ if (mq) {
36
+ if (mq.matches)
37
+ setOpen(false);
38
+ mq.addEventListener?.('change', 넓어짐);
39
+ }
40
+ return () => { 풀기(); mq?.removeEventListener?.('change', 넓어짐); };
41
+ }, [open]);
25
42
  const innerClass = containerClassName
26
43
  ? `${containerClassName} landing-header__inner`
27
44
  : 'landing-header__inner';
@@ -14,7 +14,10 @@ export default function Pagination({ page, totalPages, onChange, prevLabel, next
14
14
  const { t } = useTranslation();
15
15
  const resolvedPrev = prevLabel ?? t('common.prevPage', '이전 페이지');
16
16
  const resolvedNext = nextLabel ?? t('common.nextPage', '다음 페이지');
17
- const handlePrev = () => onChange(Math.max(1, page - 1));
18
- const handleNext = () => onChange(Math.min(totalPages, page + 1));
17
+ /* 범위 밖 쪽(page > totalPages — 결과가 줄어든 직후)에서도 '이전' 이 범위 안으로 돌아오게 먼저 조인다.
18
+ 전에는 page-1 이 여전히 totalPages 보다 커 눌러도 범위 밖에 머물렀다. */
19
+ const 마지막 = Math.max(1, totalPages);
20
+ const handlePrev = () => onChange(Math.max(1, Math.min(마지막, page - 1)));
21
+ const handleNext = () => onChange(Math.min(마지막, page + 1));
19
22
  return (_jsxs("div", { className: cx('pagination', className), children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-left", ariaLabel: resolvedPrev, disabled: page <= 1, onClick: handlePrev }), _jsxs("span", { className: "pagination__info body-3", children: [page, " / ", totalPages] }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-right", ariaLabel: resolvedNext, disabled: page >= totalPages, onClick: handleNext })] }));
20
23
  }
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react';
1
+ import { type ReactNode } from 'react';
2
2
  import './Panel.css';
3
3
  type PanelSize = 'small' | 'medium' | 'large';
4
4
  interface PanelProps {
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cx } from '../utils/cx';
4
4
  import { PanelRow } from './PanelRow';
5
+ import { Children } from 'react';
5
6
  import Icon from './Icon';
6
7
  import './Panel.css';
7
8
  export default function Panel({ title, icon, disc, actions, size = 'medium', minHeight, maxWidth, className, scrollable, useBorder = true, useBg = true, useRowBorder = true, labelWidth, form = false, loading, empty, children }) {
@@ -15,7 +16,9 @@ export default function Panel({ title, icon, disc, actions, size = 'medium', min
15
16
  style.maxWidth = typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth;
16
17
  if (labelWidth !== undefined)
17
18
  style['--panel-label-width'] = typeof labelWidth === 'number' ? `${labelWidth}px` : labelWidth;
18
- return (_jsxs("div", { className: cls, style: Object.keys(style).length ? style : undefined, children: [(title != null || disc != null) && (_jsxs("div", { className: cx('panel__header', disc != null ? 'panel__header--with-disc' : ''), children: [icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "panel__icon" })), _jsxs("div", { className: "panel__title-group", children: [title != null && (_jsx("h2", { className: `panel__title ${disc != null ? 'body-3b' : 'h5b'}`, children: title })), disc != null && _jsx("p", { className: "panel__disc caption-1", children: disc })] }), actions && _jsx("div", { className: "panel__actions", children: actions })] })), loading ? (_jsx(PanelRow, { variant: "loading" })) : empty != null && (children == null || (Array.isArray(children) && children.length === 0)) ? (_jsx(PanelRow, { variant: "empty", children: empty })) : scrollable ? (_jsx("div", { className: "panel__body--scroll", children: children })) : children] }));
19
+ return (_jsxs("div", { className: cls, style: Object.keys(style).length ? style : undefined, children: [(title != null || disc != null) && (_jsxs("div", { className: cx('panel__header', disc != null ? 'panel__header--with-disc' : ''), children: [icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "panel__icon" })), _jsxs("div", { className: "panel__title-group", children: [title != null && (_jsx("h2", { className: `panel__title ${disc != null ? 'body-3b' : 'h5b'}`, children: title })), disc != null && _jsx("p", { className: "panel__disc caption-1", children: disc })] }), actions && _jsx("div", { className: "panel__actions", children: actions })] })), loading ? (_jsx(PanelRow, { variant: "loading" })
20
+ /* 비었는지는 React 가 그리는 것으로 센다 — `{조건 && 행들}` 이 false 면 children 이 false 라 빈 상태 문구가 안 떴다 */
21
+ ) : empty != null && Children.toArray(children).length === 0 ? (_jsx(PanelRow, { variant: "empty", children: empty })) : scrollable ? (_jsx("div", { className: "panel__body--scroll", children: children })) : children] }));
19
22
  }
20
23
  // ── PanelGrid ──────────────────────────────────────────────────────────────
21
24
  /** @public */
@@ -24,6 +24,12 @@ function derivePlan(p) {
24
24
  const nm = name.match(/(\d+\s*(?:년|개월|주|일))\s*(?:이용권)?\s*$/);
25
25
  if (nm)
26
26
  return nm[1].replace(/\s+/g, '');
27
+ /* 영어 이름('Open Special 30-Day Plan'·'1-Year Plan')에서도 일·년 기간을 뽑는다 — 한국어 꼴만 봐서 영어 화면(말귀 랜딩
28
+ 요금 카드는 code 를 안 넘긴다)에서는 이름 전체가 큰 글씨로 찍히고 뒤에 등급 이름이 또 붙었다. 돌려주는 토큰은 위와 같은
29
+ '30일'·'1년' 꼴이다 — PassCard 가 숫자와 단위로 쪼개 단위를 passCard.unitDay·unitYear 로 번역한다. */
30
+ const en = name.match(/(\d+)\s*-?\s*(day|year)s?\b/i);
31
+ if (en)
32
+ return en[2].toLowerCase() === 'year' ? `${en[1]}년` : `${en[1]}일`;
27
33
  return name || '이용권';
28
34
  }
29
35
  /**
@@ -20,6 +20,12 @@ export interface PaymentPlanTier {
20
20
  * 구매 횟수가 제한되지 않는다(같은 프로모션이되 1회 제한 대상은 아닌 tier용).
21
21
  */
22
22
  openPromo?: boolean;
23
+ /**
24
+ * **계정당 1회만 살 수 있는 tier**(오픈 특가 30일 등). 이미 산 것(`ownedCodes`)이면 숨기고 기본 tier 를 카드로 올린다.
25
+ * 안 주면 예전처럼 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
26
+ * 이미 산 특가 카드가 남고 정가 30일권을 살 길이 없어진다. 번역되는 라벨 대신 이 값을 넘긴다.
27
+ */
28
+ onceOnly?: boolean;
23
29
  [k: string]: unknown;
24
30
  }
25
31
  /** @public */
@@ -43,7 +43,8 @@ export default function PaymentPlan(props) {
43
43
  const { plan, onSelect, period = "monthly", ownedCodes } = props || {};
44
44
  const owned = (code) => !!code && !!ownedCodes?.includes(code);
45
45
  // 이 플랜의 오픈특가 tier를 이미 보유(구매)했는지 — 그러면 기본 tier를 카드로 승격
46
- const openSoldOut = (plan.tiers || []).some(tr => /오픈\s*특가/.test(tr.label ?? '') && owned(tr.code));
46
+ const isOnceOnlyTier = (tr) => tr.onceOnly ?? /오픈\s*특가/.test(tr.label ?? '');
47
+ const openSoldOut = (plan.tiers || []).some(tr => isOnceOnlyTier(tr) && owned(tr.code));
47
48
  // 할인 tier 가 정가를 취소선으로 이미 보여 주면, 같은 값을 가진 정가 행은 같은 상품을 두 번
48
49
  // 늘어놓는 셈이 된다(30일 이용권 ₩10,000 행 + 30일 ₩10,000→₩5,000 카드). 그 행은 숨긴다.
49
50
  const coveredByDiscount = (tier) => {
@@ -61,7 +62,8 @@ export default function PaymentPlan(props) {
61
62
  const tierPriceOf = (tier) => (tier.prices ? tier.prices[period] : tier.price);
62
63
  const isFree = (plan.tiers || []).length > 0 && (plan.tiers || []).every(tier => {
63
64
  const price = tierPriceOf(tier);
64
- return price === "무료" || price === freePrice || price === "0원";
65
+ // 숫자가 모두 0 인 가격도 무료다 — '₩0'·'$0.00' 처럼 로케일마다 표기가 달라 '무료'·'0원' 글자만 보면 영어 화면에서 놓쳤다
66
+ return price === "무료" || price === freePrice || price === "0원" || (typeof price === 'string' && /\d/.test(price) && !/[1-9]/.test(price));
65
67
  });
66
68
  // 맞춤 견적(문의) 플랜 — 가격이 '문의'(ko) 또는 로케일 견적 문구
67
69
  const isInquiry = (plan.tiers || []).length > 0 && (plan.tiers || []).every(tier => {
@@ -85,7 +87,7 @@ export default function PaymentPlan(props) {
85
87
  읽는** 것이라 한국어 그대로여야 한다. */
86
88
  const rawLabel = tier.label || 기본이용권;
87
89
  // 라벨의 '오픈 특가'는 계정당 1회 제한의 표시다 — 숨김·소진 판정에만 쓴다.
88
- const isOnceOnly = /오픈\s*특가/.test(rawLabel);
90
+ const isOnceOnly = isOnceOnlyTier(tier);
89
91
  // 배지 문구는 별도 — 같은 프로모션이되 1회 제한은 없는 tier(1년권)도 함께 얹는다.
90
92
  const showOpenPromo = isOnceOnly || tier.openPromo === true;
91
93
  const planText = rawLabel.replace(/^오픈\s*특가\s*/, '') || 기본이용권;
@@ -18,6 +18,32 @@ export default function ConfirmPopup(props) {
18
18
  // 흐름(결제/환불)에서 더블클릭 시 RPC가 2회 발화될 수 있어, 여기서 in-flight를 직접 막는다.
19
19
  const busyRef = useRef(false);
20
20
  const [busy, setBusy] = useState(false);
21
+ /* 확인·추가 행동을 **한 번만** 돌린다. 전에는 잠금을 onConfirm 이 끝나자마자 finally 에서 풀어, 닫힘 애니메이션(260ms)
22
+ 동안 한 번 더 누르면 onConfirm 이 또 불렸다(말귀 단어표 삭제·이용권 삭제가 두 번 나가 성공 뒤 실패 토스트).
23
+ 닫기로 끝나면 잠금을 풀지 않는다 — 창이 사라지는 중이다. 닫지 않고 끝나면(closeOnConfirm=false·실패) 풀고, 처리 중에
24
+ 단추가 꺼지며 body 로 떨어진 포커스를 누른 단추로 되돌린다. */
25
+ const 한번만 = async (일) => {
26
+ if (busyRef.current)
27
+ return;
28
+ busyRef.current = true;
29
+ const 누른곳 = typeof document !== 'undefined' ? document.activeElement : null;
30
+ setBusy(true);
31
+ let 닫는중 = false;
32
+ try {
33
+ 닫는중 = await 일();
34
+ }
35
+ finally {
36
+ if (!닫는중) {
37
+ busyRef.current = false;
38
+ setBusy(false);
39
+ requestAnimationFrame(() => {
40
+ const 지금 = document.activeElement;
41
+ if ((!지금 || 지금 === document.body) && 누른곳?.isConnected)
42
+ 누른곳.focus();
43
+ });
44
+ }
45
+ }
46
+ };
21
47
  return (_jsxs(Popup, { title: title, onClose: onClose,
22
48
  // onConfirm(서버 요청 등)이 진행 중일 때 X/ESC로 닫으면, 팝업은 즉시 사라지는데
23
49
  // 이미 시작된 요청은 계속 진행돼 완료 후 부모 state/토스트가 뒤늦게 갱신된다 —
@@ -31,19 +57,13 @@ export default function ConfirmPopup(props) {
31
57
  // 없는 일(삭제·연결 해제)이라, 두 번 눌려 두 번 나가면 첫 번째만 뜻이 있고
32
58
  // 두 번째는 엉뚱한 실패로 돌아온다.
33
59
  onClick: async ({ closeWithAnimation }) => {
34
- if (busyRef.current)
35
- return;
36
- busyRef.current = true;
37
- setBusy(true);
38
- try {
60
+ await 한번만(async () => {
39
61
  await extraAction.onClick(closeWithAnimation);
40
- if (extraAction.closeOnClick ?? true)
62
+ const 닫음 = extraAction.closeOnClick ?? true;
63
+ if (닫음)
41
64
  closeWithAnimation();
42
- }
43
- finally {
44
- busyRef.current = false;
45
- setBusy(false);
46
- }
65
+ return 닫음;
66
+ });
47
67
  },
48
68
  }] : []),
49
69
  ...(hideCancel ? [] : [{
@@ -57,19 +77,12 @@ export default function ConfirmPopup(props) {
57
77
  variant: confirmVariant,
58
78
  disabled: confirmDisabled || busy,
59
79
  onClick: async ({ closeWithAnimation }) => {
60
- if (busyRef.current)
61
- return;
62
- busyRef.current = true;
63
- setBusy(true);
64
- try {
80
+ await 한번만(async () => {
65
81
  await onConfirm(closeWithAnimation);
66
82
  if (closeOnConfirm)
67
83
  closeWithAnimation();
68
- }
69
- finally {
70
- busyRef.current = false;
71
- setBusy(false);
72
- }
84
+ return closeOnConfirm;
85
+ });
73
86
  },
74
87
  },
75
88
  ], children: [topContent != null && _jsx("div", { className: "confirm-popup__top", children: topContent }), details && details.length > 0 && (_jsx(Panel, { children: details.map((d, i) => (_jsx(PanelRow, { label: d.label, children: _jsx("span", { className: "panel-row__text body-2", children: d.value }) }, i))) })), _jsx("p", { className: "body-2 confirm-popup__message", children: message }), children] }));
@@ -28,10 +28,13 @@ export default function CouponInputPopup({ onApply, onCancel }) {
28
28
  catch (e) {
29
29
  setError(getErrorMessage(e));
30
30
  setLoading(false);
31
+ /* 처리 중 입력칸이 꺼지며 포커스가 body 로 떨어졌다 — 오류를 본 사람이 곧바로 고쳐 칠 수 있게 칸으로 되돌린다 */
32
+ requestAnimationFrame(() => inputRef.current?.focus());
31
33
  }
32
34
  }, [code, onApply]);
33
35
  const handleKey = useCallback((e, closeWithAnimation) => {
34
- if (e.key === 'Enter')
36
+ // 한글 조합을 끝내는 Enter 는 적용이 아니다(TextField 와 같다)
37
+ if (e.key === 'Enter' && !e.nativeEvent.isComposing)
35
38
  void handleApply(closeWithAnimation);
36
39
  }, [handleApply]);
37
40
  const handleChange = (e) => {
@@ -52,7 +52,12 @@ export default function ListPopup(props) {
52
52
  });
53
53
  }, [items, search, sortKey, sortAsc, searchEnabled, effectiveSearchFn]);
54
54
  const totalPages = Math.max(1, Math.ceil(filtered.length / Number(pageSize)));
55
- const paged = filtered.slice((page - 1) * Number(pageSize), page * Number(pageSize));
55
+ /* 목록이 줄어 보던 쪽이 범위를 넘으면 끝 쪽으로 — 전에는 3쪽을 보다 항목이 5개로 줄면 '항목이 없습니다' 와 '총 5개' 가 함께
56
+ 떴다. 렌더 중에 상태도 맞춘다. */
57
+ if (page > totalPages)
58
+ setPage(totalPages);
59
+ const 이쪽 = Math.min(page, totalPages);
60
+ const paged = filtered.slice((이쪽 - 1) * Number(pageSize), 이쪽 * Number(pageSize));
56
61
  const handleSort = useCallback((key) => {
57
62
  if (sortKey === key)
58
63
  setSortAsc(a => !a);
@@ -91,7 +96,7 @@ export default function ListPopup(props) {
91
96
  sortDir: col.sortable !== false && sortKey === col.key ? (sortAsc ? 'asc' : 'desc') : null,
92
97
  })), [columns, sortKey, sortAsc, handleSort]);
93
98
  const pageSizeOpts = useMemo(() => pageSizeOptions.map(n => ({ value: String(n), label: t('common.pageSizeLabel', { n }) })), [pageSizeOptions, t]);
94
- return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, 'label' in countParts && _jsx("p", { className: "toolbar__count body-1", children: countParts.label }), _jsx(Toolbar, { count: 'label' in countParts ? undefined : { value: filtered.length, prefix: countParts.prefix, suffix: countParts.suffix }, search: searchEnabled ? { value: search, onChange: setSearch, placeholder: resolvedSearchPlaceholder } : undefined, pageSize: { options: pageSizeOpts, value: pageSize, onChange: setPageSize }, pagination: { page, total: totalPages, onChange: setPage } }), _jsx(Table, { size: "medium", gridTemplateColumns: gridTemplateColumns, resizable: true, fields: tableFields, items: paged, onRowClick: onSelect, renderRow: (it) => {
99
+ return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, 'label' in countParts && _jsx("p", { className: "toolbar__count body-1", children: countParts.label }), _jsx(Toolbar, { count: 'label' in countParts ? undefined : { value: filtered.length, prefix: countParts.prefix, suffix: countParts.suffix }, search: searchEnabled ? { value: search, onChange: setSearch, placeholder: resolvedSearchPlaceholder } : undefined, pageSize: { options: pageSizeOpts, value: pageSize, onChange: setPageSize }, pagination: { page: 이쪽, total: totalPages, onChange: setPage } }), _jsx(Table, { size: "medium", gridTemplateColumns: gridTemplateColumns, resizable: true, fields: tableFields, items: paged, onRowClick: onSelect, renderRow: (it) => {
95
100
  const isSelected = selectedIds != null
96
101
  ? (it.id != null && selectedIds.has(it.id))
97
102
  : (selectedId != null && it.id === selectedId);
@@ -41,10 +41,20 @@ export default function SaveTablePopup({ title, saveLabel, defaultName = '', def
41
41
  }, [previewW, previewH, preview]);
42
42
  const isDuplicateName = !!existingNames &&
43
43
  existingNames.some(n => n.trim().toLowerCase() === name.trim().toLowerCase());
44
- const handleSave = useCallback((animateThen) => animateThen(() => onSave(name.trim(), description.trim() || undefined)), [name, description, onSave]);
44
+ /* 저장은 **한 번만** — 저장 단추의 닫힘 애니메이션(260ms) 동안 이름칸에서 Enter 를 치면 onSave 가 또 불렸다(Safari 는 단추를
45
+ 눌러도 포커스가 칸에 남아 쉽게 난다). */
46
+ const 저장함 = useRef(false);
47
+ const handleSave = useCallback((animateThen) => {
48
+ if (저장함.current)
49
+ return;
50
+ 저장함.current = true;
51
+ animateThen(() => onSave(name.trim(), description.trim() || undefined));
52
+ }, [name, description, onSave]);
45
53
  const handleEnter = useCallback(() => {
46
- if (name.trim() && !isDuplicateName)
47
- onSave(name.trim(), description.trim() || undefined);
54
+ if (저장함.current || !name.trim() || isDuplicateName)
55
+ return;
56
+ 저장함.current = true;
57
+ onSave(name.trim(), description.trim() || undefined);
48
58
  }, [name, description, isDuplicateName, onSave]);
49
59
  return (_jsx(Popup, { className: "popup--save-table", title: title ?? t('wordTable.save.title', '단어표 저장하기'), onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: saveLabel ?? t('common.save', '저장'), disabled: !name.trim() || isDuplicateName, onClick: () => handleSave(animateThen) })] })), children: _jsxs("div", { className: "save-table-popup__body", children: [preview && (_jsx("div", { ref: containerRef, className: "save-table-popup__preview-wrap", style: { height: previewState.fixedH }, children: _jsx("div", { className: "save-table-popup__preview", style: {
50
60
  width: previewW, height: previewH,
@@ -95,7 +95,11 @@ export default function TrainingResultPopup(props) {
95
95
  // 래퍼(.trp-result__scroll)를 하나 더 두어 이중 스크롤이 됐고, 안쪽이 overscroll:contain으로
96
96
  // 휠을 삼키면서 "커서가 바깥 스크롤바 위에 있을 때만 스크롤되는" 문제가 있었다.
97
97
  // 하단 버튼은 Popup의 footer 슬롯으로 넘겨 스크롤과 무관하게 고정된다.
98
- return (_jsx(Popup, { onClose: onClose, inline: inline,
98
+ return (_jsx(Popup, { onClose: onClose,
99
+ /* 바탕 누르기로는 닫지 않는다 — 이 창의 닫기(onClose)는 '완료'(기록 확정 + 훈련 닫기)라, 결과를 보다 어두운 바탕을
100
+ 잘못 누르면 기록이 확정되고 '다시하기' 를 쓸 수 없었다(0.128.0 에서 Popup 바탕 닫기가 기본이 되며 생김).
101
+ 완료·다시하기 단추(와 Esc)로만 닫는다. */
102
+ closeOnBackdrop: false, inline: inline,
99
103
  /* 이 팝업은 Popup 의 title 슬롯을 안 쓰고 본문 안에 제 제목(h1)을 그린다 —
100
104
  그래서 다이얼로그 자체에 접근명이 없었다(운영 경고 9건, 08-17 ~ 08-30,
101
105
  /today · /training · /my-word-training · /my-homework). 화면에 보이는 그
@@ -65,7 +65,7 @@ export default function PromoBanner({ title, description, actionLabel, onAction,
65
65
  */
66
66
  const cls = cx('promo-banner', `promo-banner--${size}`, className);
67
67
  const Bg = PROMO_BANNER_BG_COMPONENTS[resolvedBg];
68
- return (_jsxs("div", { ref: rootRef, className: cls, role: "note", children: [_jsx(Bg, {}), _jsxs("div", { className: "promo-banner__text", children: [_jsx("span", { className: `promo-banner__title ${size === 'small' ? 'body-2' : 'h2'}`, children: title }), description && _jsx("span", { className: "promo-banner__description body-3", children: description })] }), actionLabel && onAction && (_jsx(Button, { size: 40, variant: actionVariant ?? resolvedActionVariant, shape: "pill", useBorder: true, onClick: onAction, ariaLabel: actionLabel, ...(isMobile
68
+ return (_jsxs("div", { ref: rootRef, className: cls, role: "note", children: [_jsx(Bg, {}), _jsxs("div", { className: "promo-banner__text", children: [_jsx("span", { className: `promo-banner__title ${size === 'small' ? 'body-2' : 'h2'}`, children: title }), description && _jsx("span", { className: "promo-banner__description body-3", children: description })] }), actionLabel && onAction && (_jsx(Button, { htmlType: "button", size: 40, variant: actionVariant ?? resolvedActionVariant, shape: "pill", useBorder: true, onClick: onAction, ariaLabel: actionLabel, ...(isMobile
69
69
  ? { type: 'dot', icon: 'arrow-right' }
70
70
  : { rightIcon: 'arrow-right', label: actionLabel, minWidth: 140 }) }))] }));
71
71
  }
@@ -44,7 +44,10 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
44
44
  const measureRef = useRef(null);
45
45
  const chromeRef = useRef(null);
46
46
  const rulerRef = useRef(null);
47
- const [pages, setPages] = useState(null);
47
+ /* 쪽 나눔은 **어느 블록 구성(shape)으로 잰 것인지**와 함께 둔다. 블록 수가 줄면 다시 재는 layout effect 가 렌더 뒤에야 돌아,
48
+ 그 사이 렌더가 옛 쪽의 번호로 새 blocks 를 읽다 `blocks[i]` 가 없어 **화면 전체가 깨졌다**(선택 섹션을 끄거나 자료를 다시
49
+ 받아 행이 줄 때). 지금 구성의 것이 아니면 없는 것으로 본다. */
50
+ const [measured, setMeasured] = useState(null);
48
51
  /* **넘칠 때만 탭 자리를 만든다.** A4 한 장은 794px 이라 좁은 자리에서는 가로로
49
52
  넘치는데, 이 상자 안에는 포커스 갈 곳이 하나도 없다 — 그러면 키보드만 쓰는
50
53
  사람은 오른쪽 절반을 **영영 못 본다**(WCAG 2.1.1). 실측: 겉 560px 에 안 794px.
@@ -64,7 +67,7 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
64
67
  const ro = new ResizeObserver(재자);
65
68
  ro.observe(el);
66
69
  return () => ro.disconnect();
67
- }, [pages]);
70
+ }, [measured]);
68
71
  // 블록 구성이 바뀌었는지를 값 하나로 본다. blocks 배열 자체는 렌더마다 새로
69
72
  // 만들어지는 일이 흔해 의존성에 그대로 넣으면 측정 → setPages → 렌더 → 측정
70
73
  // 이 끝나지 않는다.
@@ -73,13 +76,31 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
73
76
  통째로 건너뛴다 — 고친 사람의 변경을 볼 수도 찾을 수도 없다.
74
77
  돌리는 값은 예전과 한 바이트도 다르지 않다. */
75
78
  const shape = blocks.map((b) => `${b.id}\u0000${b.section ?? ''}\u0000${b.group ?? ''}`).join('\u0001');
79
+ const pages = measured && measured.shape === shape ? measured.pages : null;
80
+ /* 자(ruler)의 높이 — 0 에서 크기가 생기면(숨겨져 있다 보이게 됨) 다시 잰다 */
81
+ const [자크기, set자크기] = useState(0);
82
+ useEffect(() => {
83
+ const ruler = rulerRef.current;
84
+ if (!ruler || typeof ResizeObserver === 'undefined')
85
+ return;
86
+ const ro = new ResizeObserver(() => set자크기(ruler.offsetHeight));
87
+ ro.observe(ruler);
88
+ return () => ro.disconnect();
89
+ }, []);
76
90
  useLayoutEffect(() => {
77
91
  const el = measureRef.current;
78
92
  const ruler = rulerRef.current;
79
93
  if (!el || !ruler)
80
94
  return;
81
- const chromeH = chromeRef.current?.offsetHeight ?? 0;
82
- const budget = ruler.offsetHeight - chromeH;
95
+ /* display:none 안에서 처음 그리면 자·블록이 모두 0 이라 한 갈래가 한 장에 몰리는데, 보이게 된 뒤에도 구성이 같아 다시
96
+ 재지 않았다 — 자의 높이(자크기)를 의존성에 두어 크기가 생기면 다시 잰다. */
97
+ /* 머리·바닥 높이는 **갈래마다** 잰다 — 제목이 있는 갈래와 없는 갈래는 머리 높이가 달라, 첫 갈래 것 하나로 재면 제목 있는
98
+ 뒤 갈래의 장이 넘쳤다. */
99
+ const chromeOf = new Map();
100
+ chromeRef.current?.querySelectorAll('[data-chrome-section]').forEach((node) => {
101
+ chromeOf.set(node.dataset.chromeSection ?? '', node.offsetHeight);
102
+ });
103
+ const budgetFor = (section) => ruler.offsetHeight - (chromeOf.get(section ?? '') ?? 0);
83
104
  const heights = Array.from(el.children).map((c) => c.offsetHeight);
84
105
  // 이웃한 같은 group 을 한 덩어리로 묶는다.
85
106
  const groups = [];
@@ -103,6 +124,7 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
103
124
  };
104
125
  groups.forEach((grp) => {
105
126
  const groupH = grp.reduce((sum, i) => sum + heights[i], 0);
127
+ const budget = budgetFor(blocks[grp[0]].section);
106
128
  // 갈래가 바뀌면 새 장에서 시작한다.
107
129
  if (cur.length && blocks[cur[0]].section !== blocks[grp[0]].section)
108
130
  flush();
@@ -124,10 +146,10 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
124
146
  }
125
147
  });
126
148
  flush();
127
- setPages(packed);
149
+ setMeasured({ shape, pages: packed });
128
150
  // blocks 는 위 shape 로 대신 본다.
129
151
  // eslint-disable-next-line react-hooks/exhaustive-deps
130
- }, [shape]);
152
+ }, [shape, 자크기]);
131
153
  /* 번들러마다 SVG import 결과가 다르다 — Vite 는 문자열, Next 는 `{ src }` 객체다.
132
154
  문자열만 보면 Next 앱에서 fallback 경로(`/logo/...`)로 떨어져 404 가 난다
133
155
  (kscan 결과지의 바닥 로고가 실제로 깨졌다). 둘 다 받는다. */
@@ -140,9 +162,9 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
140
162
  header: (_jsxs("div", { className: "report-doc__header", children: [_jsx("h2", { className: "report-doc__title h3b", children: (section != null && sectionTitles?.[section]) || null }), logo] })),
141
163
  footer: (_jsxs("div", { className: "report-doc__footer caption-1", children: [_jsx("span", { className: "report-doc__footer-left", children: footerLeft }), _jsx("span", { className: "report-doc__page-no", children: pageLabel(page, total) })] })),
142
164
  });
143
- // 머리·바닥이 쓰는 높이를 재기 위한 견본. 쪽 번호 자릿수만큼만 다르므로
144
- // 첫 장의 것으로 대신한다.
145
- const sampleChrome = chrome(1, 1, blocks[0]?.section);
165
+ // 머리·바닥이 쓰는 높이를 재기 위한 견본 — 갈래마다 하나(제목 유무로 머리 높이가 다르다). 쪽 번호 자릿수만큼만
166
+ // 다르므로 첫 장 번호로 잰다.
167
+ const chromeSections = Array.from(new Set(blocks.map((b) => b.section ?? '')));
146
168
  const vars = {
147
169
  ...(pageWidth ? { '--report-page-w': pageWidth } : null),
148
170
  ...(pageHeight ? { '--report-page-h': pageHeight } : null),
@@ -152,7 +174,10 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
152
174
  return (_jsxs("div", { ref: rootRef, className: cx('report-doc', className), style: vars,
153
175
  /* 굴릴 것이 있을 때만 포커스를 받는다. 이름 없이 포커스만 주면 낭독기가
154
176
  문서 전체를 읽어 버리므로 역할과 이름을 함께 준다. */
155
- tabIndex: 넘침 ? 0 : undefined, role: 넘침 ? 'group' : undefined, "aria-label": 넘침 ? t('report.previewRegion', '보고서 미리보기') : undefined, children: [_jsxs("div", { className: "report-doc__measure", "aria-hidden": true, children: [_jsx("div", { ref: rulerRef, className: "report-doc__ruler" }), _jsxs("div", { ref: chromeRef, children: [sampleChrome.header, sampleChrome.footer] }), _jsx("div", { ref: measureRef, className: "report-doc__measure-blocks", children: blocks.map((b) => (_jsx("div", { children: b.content }, b.id))) })] }), pages && (_jsx("div", { className: "report-doc__pages", children: pages.map((idxs, p) => {
177
+ tabIndex: 넘침 ? 0 : undefined, role: 넘침 ? 'group' : undefined, "aria-label": 넘침 ? t('report.previewRegion', '보고서 미리보기') : undefined, children: [_jsxs("div", { className: "report-doc__measure", "aria-hidden": true, children: [_jsx("div", { ref: rulerRef, className: "report-doc__ruler" }), _jsx("div", { ref: chromeRef, children: chromeSections.map((sec) => {
178
+ const c = chrome(1, 1, sec || undefined);
179
+ return _jsxs("div", { "data-chrome-section": sec, children: [c.header, c.footer] }, sec);
180
+ }) }), _jsx("div", { ref: measureRef, className: "report-doc__measure-blocks", children: blocks.map((b) => (_jsx("div", { children: b.content }, b.id))) })] }), pages && (_jsx("div", { className: "report-doc__pages", children: pages.map((idxs, p) => {
156
181
  const { header, footer: pageFooter } = chrome(p + 1, pages.length, blocks[idxs[0]].section);
157
182
  return (_jsxs("section", { className: "report-doc__page", children: [header, idxs.map((i) => (_jsx("div", { children: blocks[i].content }, blocks[i].id))), pageFooter] }, p));
158
183
  }) }))] }));