@ihabdevteam/core 0.128.0 → 0.131.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 (120) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core-tokens.css +1 -1
  7. package/dist/ihabdevteam-core.cjs.js +182 -182
  8. package/dist/ihabdevteam-core.css +1 -1
  9. package/dist/ihabdevteam-core.d.ts +282 -80
  10. package/dist/packages/core/src/components/AudioMeter.js +29 -15
  11. package/dist/packages/core/src/components/Avatar.js +6 -2
  12. package/dist/packages/core/src/components/Badge.d.ts +5 -1
  13. package/dist/packages/core/src/components/Badge.js +19 -5
  14. package/dist/packages/core/src/components/BottomNav.css +15 -0
  15. package/dist/packages/core/src/components/BottomNav.d.ts +6 -0
  16. package/dist/packages/core/src/components/BottomNav.js +12 -1
  17. package/dist/packages/core/src/components/BulkBar.d.ts +2 -1
  18. package/dist/packages/core/src/components/BulkBar.js +9 -2
  19. package/dist/packages/core/src/components/Calendar.js +1 -1
  20. package/dist/packages/core/src/components/ColorPicker.d.ts +3 -1
  21. package/dist/packages/core/src/components/ColorPicker.js +4 -2
  22. package/dist/packages/core/src/components/DatePicker.d.ts +2 -1
  23. package/dist/packages/core/src/components/DatePicker.js +9 -4
  24. package/dist/packages/core/src/components/Effect/EffectPartyPopper.d.ts +9 -0
  25. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +30 -25
  26. package/dist/packages/core/src/components/EmptyState.js +3 -1
  27. package/dist/packages/core/src/components/ErrorState.js +3 -1
  28. package/dist/packages/core/src/components/FilterPanel.js +7 -0
  29. package/dist/packages/core/src/components/Header.d.ts +6 -0
  30. package/dist/packages/core/src/components/Header.js +27 -1
  31. package/dist/packages/core/src/components/Icon.css +22 -0
  32. package/dist/packages/core/src/components/Icon.d.ts +7 -3
  33. package/dist/packages/core/src/components/Icon.js +7 -1
  34. package/dist/packages/core/src/components/IconGridMenu.css +4 -2
  35. package/dist/packages/core/src/components/IconGridMenu.d.ts +1 -1
  36. package/dist/packages/core/src/components/IconGridMenu.js +6 -3
  37. package/dist/packages/core/src/components/LandingHeader.js +6 -3
  38. package/dist/packages/core/src/components/Loader.d.ts +1 -1
  39. package/dist/packages/core/src/components/Loader.js +7 -2
  40. package/dist/packages/core/src/components/NoticeBox.d.ts +5 -0
  41. package/dist/packages/core/src/components/NoticeBox.js +3 -1
  42. package/dist/packages/core/src/components/Pagination.d.ts +2 -2
  43. package/dist/packages/core/src/components/Pagination.js +2 -2
  44. package/dist/packages/core/src/components/PanelRow.js +5 -1
  45. package/dist/packages/core/src/components/PassCard/PassCard.d.ts +10 -1
  46. package/dist/packages/core/src/components/PassCard/PassCardBg.css +33 -0
  47. package/dist/packages/core/src/components/PaymentPlan.d.ts +8 -1
  48. package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +1 -1
  49. package/dist/packages/core/src/components/Popup/ListPopup.d.ts +8 -1
  50. package/dist/packages/core/src/components/Popup/ListPopup.js +17 -2
  51. package/dist/packages/core/src/components/Popup/Popup.css +1 -1
  52. package/dist/packages/core/src/components/Popup/Popup.d.ts +1 -1
  53. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +3 -2
  54. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.d.ts +4 -1
  55. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +8 -3
  56. package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +7 -2
  57. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +20 -3
  58. package/dist/packages/core/src/components/ProgressIndicator.d.ts +2 -5
  59. package/dist/packages/core/src/components/PromoBanner.js +23 -6
  60. package/dist/packages/core/src/components/RadioGroup.d.ts +3 -1
  61. package/dist/packages/core/src/components/RadioGroup.js +2 -2
  62. package/dist/packages/core/src/components/Report/ReportDocument.d.ts +4 -1
  63. package/dist/packages/core/src/components/Report/ReportDocument.js +4 -1
  64. package/dist/packages/core/src/components/Section.d.ts +7 -1
  65. package/dist/packages/core/src/components/Section.js +6 -2
  66. package/dist/packages/core/src/components/Table.d.ts +1 -1
  67. package/dist/packages/core/src/components/TextField.css +2 -1
  68. package/dist/packages/core/src/components/TextField.js +10 -2
  69. package/dist/packages/core/src/components/Toggle.js +4 -1
  70. package/dist/packages/core/src/components/Tooltip/DebugTooltip.d.ts +1 -6
  71. package/dist/packages/core/src/components/Tooltip/DebugTooltip.js +4 -7
  72. package/dist/packages/core/src/components/Tooltip/Tooltip.js +11 -1
  73. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +3 -3
  74. package/dist/packages/core/src/components/Training/TrainingBlock.d.ts +8 -3
  75. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +10 -11
  76. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +4 -4
  77. package/dist/packages/core/src/components/Training/trainingScenario.d.ts +6 -2
  78. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -1
  79. package/dist/packages/core/src/components/Word/WordTableBlock.js +6 -0
  80. package/dist/packages/core/src/components/Word/WordTableButton.d.ts +2 -2
  81. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  82. package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +1 -1
  83. package/dist/packages/core/src/components/Word/wordTableScoring.d.ts +1 -4
  84. package/dist/packages/core/src/components/Word/wordTableScoring.js +1 -4
  85. package/dist/packages/core/src/components/WordTable.d.ts +1 -1
  86. package/dist/packages/core/src/components/WordTable.js +3 -1
  87. package/dist/packages/core/src/components.d.ts +3 -0
  88. package/dist/packages/core/src/components.js +5 -0
  89. package/dist/packages/core/src/hooks/useDeviceManagement.js +11 -2
  90. package/dist/packages/core/src/hooks/useFontScale.js +3 -1
  91. package/dist/packages/core/src/hooks/useLucideIcon.d.ts +3 -4
  92. package/dist/packages/core/src/hooks/useLucideIcon.js +3 -4
  93. package/dist/packages/core/src/hooks/usePasswordChange.js +20 -7
  94. package/dist/packages/core/src/hooks/useVisitAllPanel.d.ts +7 -0
  95. package/dist/packages/core/src/hooks/useVisitAllPanel.js +8 -0
  96. package/dist/packages/core/src/hooks/useWordMeanings.js +3 -0
  97. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +5 -0
  98. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +23 -33
  99. package/dist/packages/core/src/i18n/locales/en.json +34 -6
  100. package/dist/packages/core/src/i18n/locales/ko.json +34 -6
  101. package/dist/packages/core/src/tokens.css +2 -1
  102. package/dist/packages/core/src/utils/CategoryData.d.ts +12 -40
  103. package/dist/packages/core/src/utils/CategoryData.js +15 -10
  104. package/dist/packages/core/src/utils/assets.js +7 -3
  105. package/dist/packages/core/src/utils/clipboard.js +16 -2
  106. package/dist/packages/core/src/utils/errorMessage.d.ts +2 -1
  107. package/dist/packages/core/src/utils/errorMessage.js +19 -2
  108. package/dist/packages/core/src/utils/getTrainingData.d.ts +8 -1
  109. package/dist/packages/core/src/utils/getTrainingData.js +8 -1
  110. package/dist/packages/core/src/utils/koreanSyllable.d.ts +15 -12
  111. package/dist/packages/core/src/utils/koreanSyllable.js +16 -13
  112. package/dist/packages/core/src/utils/lucideIconSet.d.ts +4 -3
  113. package/dist/packages/core/src/utils/lucideIconSet.js +4 -3
  114. package/dist/packages/core/src/utils/ttsResolver.d.ts +5 -0
  115. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +3 -3
  116. package/dist/packages/core/src/utils/wordTrainingGenerators.js +28 -7
  117. package/dist/packages/core/src/version.d.ts +1 -1
  118. package/dist/packages/core/src/version.js +1 -1
  119. package/dist/utils.cjs.js +2 -2
  120. package/package.json +4 -2
@@ -32,11 +32,8 @@ export interface ProgressIndicatorProps {
32
32
  */
33
33
  announce?: boolean;
34
34
  /**
35
- * 이름표를 직접 준다. 안 주면 i18n 의 `progress.steps` 로 짓는다.
36
- *
37
- * i18n 을 쓰지 않는 앱에서는 그 값이 한글 기본값으로 떨어진다 —
38
- * 화면은 영문인데 이름표만 한글이 되는 자리가 실제로 있었다
39
- * (sound-localization-system 의 병원용 빌드).
35
+ * 이름표를 직접 준다. 안 주면 i18n 의 `progress.steps`('진행 단계 N / M')로 짓는다 —
36
+ * i18n 사전을 싣지 않는 앱은 이름표가 한글 기본값으로 떨어지므로 번역한 글을 여기 넘긴다.
40
37
  */
41
38
  ariaLabel?: string;
42
39
  }
@@ -17,17 +17,34 @@ export default function PromoBanner({ title, description, actionLabel, onAction,
17
17
  const isMobile = useMediaQuery('(max-width: 767px)');
18
18
  // PromoBannerBg*가 useLayoutEffect(child)에서 --promo-banner-bg-title/description을
19
19
  // 주입한 뒤, 이 useLayoutEffect(parent)에서 override → 항상 마지막에 적용됨.
20
- // ⚠️ prop이 undefined일 때는 절대 removeProperty 하지 않는다 — 배경이 주입한 값을
20
+ // ⚠️ prop이 undefined일 때는 removeProperty 하지 않는다 — 배경이 주입한 값을
21
21
  // 지워버리기 때문. undefined = "override 안 함"(PassCard의 textColor와 동일 패턴).
22
+ // 다만 **줬던 값을 거둘 때는** 배경이 정한 값(레지스트리의 theme)으로 되돌린다 — 예전에는
23
+ // undefined 로 바꿔도 마지막으로 준 색이 CSS 변수에 그대로 남았다.
24
+ // 배경을 갈면 새 배경이 제 값을 다시 주입하므로 resolvedBg 가 바뀔 때도 다시 덮는다.
25
+ const overriddenRef = useRef({ title: false, description: false });
22
26
  useLayoutEffect(() => {
23
27
  const el = rootRef.current;
24
28
  if (!el)
25
29
  return;
26
- if (titleColor !== undefined)
27
- el.style.setProperty('--promo-banner-bg-title', titleColor);
28
- if (descriptionColor !== undefined)
29
- el.style.setProperty('--promo-banner-bg-description', descriptionColor);
30
- }, [titleColor, descriptionColor]);
30
+ const theme = getPromoBannerBgTheme(el);
31
+ const apply = (key, name, value) => {
32
+ if (value !== undefined) {
33
+ el.style.setProperty(name, value);
34
+ overriddenRef.current[key] = true;
35
+ }
36
+ else if (overriddenRef.current[key]) {
37
+ const fallback = theme?.[key];
38
+ if (fallback)
39
+ el.style.setProperty(name, fallback);
40
+ else
41
+ el.style.removeProperty(name);
42
+ overriddenRef.current[key] = false;
43
+ }
44
+ };
45
+ apply('title', '--promo-banner-bg-title', titleColor);
46
+ apply('description', '--promo-banner-bg-description', descriptionColor);
47
+ }, [titleColor, descriptionColor, resolvedBg]);
31
48
  // 배경(Bg)이 자신의 useLayoutEffect에서 레지스트리에 theme을 주입한 뒤 읽는다(PassCard의
32
49
  // logoColor와 동일 패턴) — 배경마다 다른 기본 버튼 variant를 PromoBannerBg* 파일이 직접 갖는다.
33
50
  useLayoutEffect(() => {
@@ -16,6 +16,8 @@ export interface RadioGroupProps {
16
16
  disabled?: boolean;
17
17
  direction?: RadioGroupDirection;
18
18
  className?: string;
19
+ /** 묶음의 이름 — 낭독기가 "무엇을 고르는 라디오 묶음" 인지 읽는다. 보이는 제목이 따로 있어도 이어지지 않으면 준다. */
20
+ ariaLabel?: string;
19
21
  }
20
22
  /** @public */
21
- export default function RadioGroup({ options, value, onChange, name, disabled, direction, className, }: RadioGroupProps): import("react").JSX.Element;
23
+ export default function RadioGroup({ options, value, onChange, name, disabled, direction, className, ariaLabel, }: RadioGroupProps): import("react").JSX.Element;
@@ -4,10 +4,10 @@ import { useId } from 'react';
4
4
  import Radio from './Radio';
5
5
  import './RadioGroup.css';
6
6
  /** @public */
7
- export default function RadioGroup({ options, value, onChange, name, disabled = false, direction = 'vertical', className = '', }) {
7
+ export default function RadioGroup({ options, value, onChange, name, disabled = false, direction = 'vertical', className = '', ariaLabel, }) {
8
8
  const autoId = useId();
9
9
  const groupName = name ?? `radio-group-${autoId}`;
10
- return (_jsx("div", { className: `radio-group radio-group--${direction} ${className}`.trim(), role: "radiogroup", children: options.map(option => (_jsx(Radio, { name: groupName, value: option.value, checked: value === option.value, disabled: disabled || option.disabled, label: option.label, onChange: checked => {
10
+ return (_jsx("div", { className: `radio-group radio-group--${direction} ${className}`.trim(), role: "radiogroup", "aria-label": ariaLabel, children: options.map(option => (_jsx(Radio, { name: groupName, value: option.value, checked: value === option.value, disabled: disabled || option.disabled, label: option.label, onChange: checked => {
11
11
  if (checked)
12
12
  onChange?.(option.value);
13
13
  } }, option.value))) }));
@@ -54,7 +54,10 @@ export interface ReportDocumentProps {
54
54
  * 바닥(로고·저작권 + 쪽 번호)이 반복된다.
55
55
  *
56
56
  * 재는 방식이라 글꼴이 늦게 오거나 이미지가 나중에 뜨면 쪽이 밀릴 수 있다.
57
- * 그 경우 `blocks` 를 그대로 두고 다시 그리면 다시 잰다.
57
+ * 다시 재는 때는 블록들의 `id`·`section`·`group` 구성이 바뀔 때와 처음 그릴 때뿐이다 —
58
+ * 같은 구성으로 다시 그리거나 `content` 만 바뀌어서는 다시 재지 않는다(렌더마다 재면
59
+ * 재기 → 쪽 나눔 → 렌더가 끝없이 돈다). 글꼴·이미지가 다 온 뒤 다시 재야 하면
60
+ * `key` 를 바꿔 새로 붙인다.
58
61
  *
59
62
  * 화면에 그린 쪽 수는 다른 도구로 만든 PDF 와 다를 수 있다. 같은 내용을 그리는
60
63
  * 높이가 서로 달라서인데, 쪽 수까지 맞춰야 하면 양쪽 본문 높이를 견줘야 한다.
@@ -15,7 +15,10 @@ const DEFAULT_PAGE_LABEL = (page, total) => `${page} / ${total}`;
15
15
  * 바닥(로고·저작권 + 쪽 번호)이 반복된다.
16
16
  *
17
17
  * 재는 방식이라 글꼴이 늦게 오거나 이미지가 나중에 뜨면 쪽이 밀릴 수 있다.
18
- * 그 경우 `blocks` 를 그대로 두고 다시 그리면 다시 잰다.
18
+ * 다시 재는 때는 블록들의 `id`·`section`·`group` 구성이 바뀔 때와 처음 그릴 때뿐이다 —
19
+ * 같은 구성으로 다시 그리거나 `content` 만 바뀌어서는 다시 재지 않는다(렌더마다 재면
20
+ * 재기 → 쪽 나눔 → 렌더가 끝없이 돈다). 글꼴·이미지가 다 온 뒤 다시 재야 하면
21
+ * `key` 를 바꿔 새로 붙인다.
19
22
  *
20
23
  * 화면에 그린 쪽 수는 다른 도구로 만든 PDF 와 다를 수 있다. 같은 내용을 그리는
21
24
  * 높이가 서로 달라서인데, 쪽 수까지 맞춰야 하면 양쪽 본문 높이를 견줘야 한다.
@@ -5,6 +5,12 @@ export type SectionType = 'page' | 'pagePopup' | 'popup';
5
5
  export interface SectionProps {
6
6
  type?: SectionType;
7
7
  title?: ReactNode;
8
+ /**
9
+ * 제목을 이 단계의 제목 태그(`h1`~`h6`)로 그린다 — **주는 것을 권한다.** 페이지 제목 아래 구획이면 2, 그 아래면 3 처럼 문서의 제목 순서에 맞춰 주면 낭독기의 제목 목록으로 이 구획에 바로 갈 수 있다.
10
+ *
11
+ * 주지 않으면 `div` 로 그려 제목 목록에 안 나온다. 글자 모양은 어느 쪽이든 `type` 이 정한다.
12
+ */
13
+ headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
8
14
  icon?: string;
9
15
  action?: ReactNode;
10
16
  disc?: ReactNode;
@@ -13,4 +19,4 @@ export interface SectionProps {
13
19
  style?: CSSProperties;
14
20
  }
15
21
  /** @public */
16
- export default function Section({ type, title, icon, action, disc, children, className, style }: SectionProps): import("react").JSX.Element;
22
+ export default function Section({ type, title, headingLevel, icon, action, disc, children, className, style }: SectionProps): import("react").JSX.Element;
@@ -8,8 +8,12 @@ const TITLE_CLASS = {
8
8
  popup: 'h5',
9
9
  };
10
10
  /** @public */
11
- export default function Section({ type = 'page', title, icon, action, disc, children, className, style }) {
11
+ export default function Section({ type = 'page', title, headingLevel, icon, action, disc, children, className, style }) {
12
12
  const titleClass = TITLE_CLASS[type];
13
13
  const hasHeader = title || disc;
14
- return (_jsxs("div", { className: cx('section', `section--${type}`, className), style: style, children: [hasHeader && (_jsxs("div", { className: "section__header", children: [_jsxs("div", { className: "section__title-row", children: [icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "section__icon" })), _jsxs("div", { className: "section__title-group", children: [title && _jsx("div", { className: `section__title ${titleClass}`, children: title }), disc && _jsx("div", { className: "section__disc body-3", children: disc })] })] }), action && _jsx("div", { className: "section__action", children: action })] })), children] }));
14
+ /* 제목이 맨 div 라 낭독기의 제목 목록에 안 잡혔다. 단계는 페이지 구조에 달려 있어
15
+ 부르는 쪽이 정한다 — 안 주면 예전 그대로 div. 모양은 클래스(h3/h4/h5)가 정하고
16
+ 제목 태그의 기본 여백은 .section__title 의 margin:0 이 지운다. */
17
+ const TitleTag = headingLevel ? `h${headingLevel}` : 'div';
18
+ return (_jsxs("div", { className: cx('section', `section--${type}`, className), style: style, children: [hasHeader && (_jsxs("div", { className: "section__header", children: [_jsxs("div", { className: "section__title-row", children: [icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "section__icon" })), _jsxs("div", { className: "section__title-group", children: [title && _jsx(TitleTag, { className: `section__title ${titleClass}`, children: title }), disc && _jsx("div", { className: "section__disc body-3", children: disc })] })] }), action && _jsx("div", { className: "section__action", children: action })] })), children] }));
15
19
  }
@@ -83,7 +83,7 @@ export type TableProps<T extends {
83
83
  headerActions?: ReactNode;
84
84
  /** 행이 없을 때 body 영역에 표시할 콘텐츠 */
85
85
  empty?: ReactNode;
86
- /** true면 rows/empty 대신 스피너 표시 (fields 헤더도 숨김) */
86
+ /** true면 rows/empty 대신 스피너 표시. 제목 머리와 fields 머리는 그대로 둔다. */
87
87
  loading?: boolean;
88
88
  /** 드래그 오버 강조 */
89
89
  dragOver?: boolean;
@@ -55,7 +55,8 @@
55
55
  opacity: 0.5;
56
56
  cursor: not-allowed;
57
57
  }
58
- /* 읽기 전용은 클릭·포커스·텍스트 선택 불가, 보더 제거 */
58
+ /* 읽기 전용은 마우스 클릭·텍스트 선택 불가, 보더 제거.
59
+ 키보드 포커스(Tab)는 닿는다 — 값을 낭독기로 들을 수 있어야 한다(TextField.tsx 참고). */
59
60
  .text-field--readonly .text-field__input {
60
61
  pointer-events: none;
61
62
  user-select: none;
@@ -95,7 +95,9 @@ export default function TextField(props) {
95
95
  disabled,
96
96
  readOnly,
97
97
  autoFocus,
98
- tabIndex: readOnly ? -1 : undefined,
98
+ /* readOnly 칸도 Tab 으로 닿는다 — 예전에는 tabIndex -1 이라 키보드·낭독기 사용자가
99
+ 폼을 Tab 으로 지나며 값(이메일·업체아이디 같은 고칠 수 없는 값)을 들을 길이 없었다.
100
+ 고칠 수 없는 것은 readOnly 가 이미 막는다. 마우스 반응 없음은 CSS 가 그대로 맡는다. */
99
101
  maxLength,
100
102
  list,
101
103
  inputMode,
@@ -129,7 +131,13 @@ export default function TextField(props) {
129
131
  combobox.handleBlur();
130
132
  }),
131
133
  onKeyDown: ((e) => {
132
- if (onEnter && e.key === 'Enter' && !e.nativeEvent.isComposing) {
134
+ /* 후보를 가리킨 채 누른 Enter 는 **후보를 고르는** 키다(아래 combobox.handleKeyDown
135
+ 과 같은 조건). 예전에는 그 Enter 가 onEnter 까지 불러, 후보를 고르는 순간 검색·제출이
136
+ 한 번 더 나갔다. 고르는 Enter 는 폼 제출도 막는다. */
137
+ const 후보고르는엔터 = !!suggestions && e.key === 'Enter' && !e.nativeEvent.isComposing && combobox.activeIdx >= 0;
138
+ if (후보고르는엔터)
139
+ e.preventDefault();
140
+ else if (onEnter && e.key === 'Enter' && !e.nativeEvent.isComposing) {
133
141
  e.preventDefault();
134
142
  onEnter();
135
143
  }
@@ -3,5 +3,8 @@ import './Toggle.css';
3
3
  /** @public */
4
4
  export default function Toggle({ checked, onChange, disabled = false, label, ariaLabel, labelPosition = 'right', className = '', id }) {
5
5
  const labelNode = label && _jsx("span", { className: "toggle__label body-3", children: label });
6
- return (_jsxs("label", { className: `toggle${disabled ? ' toggle--disabled' : ''} ${className}`.trim(), children: [labelPosition === 'left' && labelNode, _jsx("input", { id: id, type: "checkbox", checked: checked, disabled: disabled, onChange: e => onChange?.(e.target.checked), "aria-label": !label && ariaLabel ? ariaLabel : undefined }), _jsx("span", { className: "toggle__slider" }), labelPosition === 'right' && labelNode] }));
6
+ return (_jsxs("label", { className: `toggle${disabled ? ' toggle--disabled' : ''} ${className}`.trim(), children: [labelPosition === 'left' && labelNode, _jsx("input", { id: id, type: "checkbox",
7
+ /* 켜고 끄는 스위치다 — 체크박스로 읽히면 "선택됨/선택 안 됨" 이라 무엇이 켜졌는지가 흐려진다.
8
+ role="switch" 면 낭독기가 "켬/끔" 으로 읽는다(input 은 그대로라 폼·label 연결은 같다). */
9
+ role: "switch", checked: checked, disabled: disabled, onChange: e => onChange?.(e.target.checked), "aria-label": !label && ariaLabel ? ariaLabel : undefined }), _jsx("span", { className: "toggle__slider" }), labelPosition === 'right' && labelNode] }));
7
10
  }
@@ -3,12 +3,7 @@ import './DebugTooltip.css';
3
3
  /**
4
4
  * 결과 팝업을 실제 훈련 없이 열어보는 개발용 단추 넷.
5
5
  *
6
- * 예전에는 파일 안의 `const DEBUG = false` 로만 켤 수 있었다. 모노레포 시절에는
7
- * packages/core 를 그 자리에서 고칠 수 있었으니 말이 됐는데, **패키지로 분리되면서
8
- * 켤 방법이 사라졌다** — 소비자가 node_modules 를 고치지 않는 한 언제나 null 이다.
9
- * 실제로 speechear-v2 가 이것을 렌더하고 있고, 아무것도 그려지지 않는다.
10
- *
11
- * 그래서 스위치를 밖으로 낸다. 기본값은 그대로 꺼짐이라 지금 쓰는 곳은 안 바뀐다.
6
+ * `enabled` 를 주어야 단추가 선다. 기본은 꺼짐이다.
12
7
  *
13
8
  * @example
14
9
  * <DebugResultLauncher enabled={import.meta.env.DEV} renderResultPopup={…} />
@@ -1,16 +1,12 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState, useCallback } from 'react';
4
+ import { useTranslation } from 'react-i18next';
4
5
  import './DebugTooltip.css';
5
6
  /**
6
7
  * 결과 팝업을 실제 훈련 없이 열어보는 개발용 단추 넷.
7
8
  *
8
- * 예전에는 파일 안의 `const DEBUG = false` 로만 켤 수 있었다. 모노레포 시절에는
9
- * packages/core 를 그 자리에서 고칠 수 있었으니 말이 됐는데, **패키지로 분리되면서
10
- * 켤 방법이 사라졌다** — 소비자가 node_modules 를 고치지 않는 한 언제나 null 이다.
11
- * 실제로 speechear-v2 가 이것을 렌더하고 있고, 아무것도 그려지지 않는다.
12
- *
13
- * 그래서 스위치를 밖으로 낸다. 기본값은 그대로 꺼짐이라 지금 쓰는 곳은 안 바뀐다.
9
+ * `enabled` 를 주어야 단추가 선다. 기본은 꺼짐이다.
14
10
  *
15
11
  * @example
16
12
  * <DebugResultLauncher enabled={import.meta.env.DEV} renderResultPopup={…} />
@@ -18,6 +14,7 @@ import './DebugTooltip.css';
18
14
  * @public
19
15
  */
20
16
  export default function DebugResultLauncher({ enabled = false, renderResultPopup } = {}) {
17
+ const { t } = useTranslation();
21
18
  const [openType, setOpenType] = useState(null);
22
19
  const [payload, setPayload] = useState(null);
23
20
  const openPopup = (type) => {
@@ -82,7 +79,7 @@ export default function DebugResultLauncher({ enabled = false, renderResultPopup
82
79
  // 훅 규칙 준수를 위해 모든 훅 호출 뒤에서 분기한다 — 위 훅들은 언제나 같은 수만큼 돈다.
83
80
  if (!enabled)
84
81
  return null;
85
- return (_jsxs("div", { className: "debug-result-launcher", children: [_jsxs("div", { className: "debug-buttons", children: [_jsx("button", { className: "debug-button", onClick: handleNoisePopup, children: "Noise \uACB0\uACFC\uBCF4\uAE30" }), _jsx("button", { className: "debug-button", onClick: handleShortPopup, children: "ShortStory \uACB0\uACFC\uBCF4\uAE30" }), _jsx("button", { className: "debug-button", onClick: handleLongPopup, children: "LongStory \uACB0\uACFC\uBCF4\uAE30" }), _jsx("button", { className: "debug-button", onClick: handleOrderPopup, children: "Order \uACB0\uACFC\uBCF4\uAE30" })] }), openType && payload ? renderResultPopup?.({
82
+ return (_jsxs("div", { className: "debug-result-launcher", children: [_jsxs("div", { className: "debug-buttons", children: [_jsx("button", { type: "button", className: "debug-button", onClick: handleNoisePopup, children: t('debug.showResult', { type: 'Noise', defaultValue: '{{type}} 결과보기' }) }), _jsx("button", { type: "button", className: "debug-button", onClick: handleShortPopup, children: t('debug.showResult', { type: 'ShortStory', defaultValue: '{{type}} 결과보기' }) }), _jsx("button", { type: "button", className: "debug-button", onClick: handleLongPopup, children: t('debug.showResult', { type: 'LongStory', defaultValue: '{{type}} 결과보기' }) }), _jsx("button", { type: "button", className: "debug-button", onClick: handleOrderPopup, children: t('debug.showResult', { type: 'Order', defaultValue: '{{type}} 결과보기' }) })] }), openType && payload ? renderResultPopup?.({
86
83
  score: payload.score,
87
84
  contextsLength: payload.contextsLength,
88
85
  results: payload.results,
@@ -181,6 +181,11 @@ export default function Tooltip(props) {
181
181
  // 불렸다. ref 로 "이미 닫는 중"을 즉시 표시해 한 번만 지나가게 한다(Popup 과 같은 방식).
182
182
  const closingRef = useRef(false);
183
183
  const startClose = useCallback((immediate = false) => {
184
+ /* 닫기를 넘길 곳(onClose)이 없으면 아무것도 하지 않는다. 예전에는 그래도 is-closing 을
185
+ 붙여, 부모가 치우지 않는 툴팁이 **흐려진 채 사라진 것처럼** 그 자리에 남았다
186
+ (Esc 한 번에 추천 툴팁·상태 툴팁이 반투명으로 굳었다). */
187
+ if (!onClose)
188
+ return;
184
189
  if (closing || closingRef.current)
185
190
  return;
186
191
  closingRef.current = true;
@@ -201,6 +206,11 @@ export default function Tooltip(props) {
201
206
  }, 200);
202
207
  }, [closing, onClose, setClosing]);
203
208
  useEffect(() => {
209
+ /* 제자리(글 흐름 안)에 그린 툴팁은 떠 있는 층이 아니라 본문의 한 조각이다 — document 의
210
+ 바깥 누름·Esc·Tab 을 듣지 않는다. 들으면 화면 아무 데나 누르거나 Esc 를 칠 때마다
211
+ 늘어놓은 툴팁이 한꺼번에 닫히고, 마지막 칸에서 Tab 이 본문으로 못 나가고 되감긴다. */
212
+ if (isInline)
213
+ return;
204
214
  const handlePointerDown = (e) => {
205
215
  const el = ref.current;
206
216
  if (!el)
@@ -274,7 +284,7 @@ export default function Tooltip(props) {
274
284
  document.removeEventListener('click', handleClickCapture, true);
275
285
  document.removeEventListener('keydown', handleKeyDown);
276
286
  };
277
- }, [onClose, closeOnOutsideClick, startClose]);
287
+ }, [onClose, closeOnOutsideClick, startClose, isInline]);
278
288
  // Allow external callers to force-close all tooltips by dispatching
279
289
  // a `speechear-close-tooltips` CustomEvent on `document`.
280
290
  useEffect(() => {
@@ -120,9 +120,9 @@ export default function WaveForm(props) {
120
120
  return () => { cancelled = true; };
121
121
  }, [noiseSrc, barsTotal, totalDuration, noiseSpeed]);
122
122
  /* eslint-enable react-hooks/set-state-in-effect */
123
- // **그림의 눈금은 "문장 = 기준"** — 소리 셈(문제 소리 = M×min(1,1/R))과 달리 문장을 줄이지 않고
124
- // 소음을 R 배로 키워 비율을 보인다. 막대 크기 a: 문장 = peak/문장최고치 × 전체 소리(M),
125
- // 소음 = 같은 분모 × M × 소음 비율(R). a → 픽셀은 구간별 눈금(waveScale — 1 까지 45px, 2 에 74px).
123
+ // **그림의 눈금은 소리 셈과 같다**(NoisePlayerContext) — 문제 소리 = M, 소음 = M × R. 문장은 비율과 상관없이
124
+ // 줄이지 않는다. 막대 크기 a: 문장 = peak/문장최고치 × 전체 소리(M), 소음 = 같은 분모 × M × 소음 비율(R) ÷ gain
125
+ // (봉우리를 gain 배 키운 파일에서 뽑으므로 되돌린다 — 아래 noiseAmp). a → 픽셀은 구간별 눈금(waveScale — 1 까지 45px, 2 에 74px).
126
126
  const mainAmp = Math.max(0, Math.min(1, mainVolume));
127
127
  const sentenceMaxPeak = useMemo(() => (wavePeaks && wavePeaks.length) ? (Math.max(...wavePeaks) || 1) : 1, [wavePeaks]);
128
128
  const barsCountForMix = barsTotal || 40;
@@ -14,19 +14,24 @@ export interface TrainingBlockProps {
14
14
  type?: TrainingTypeDescriptor;
15
15
  trainingDatas?: TrainingItem[] | unknown[];
16
16
  depth?: string | string[];
17
+ /**
18
+ * @deprecated 쓰이지 않는다 — 한 번에 보이는 카드 수는 20개로 고정이고 나머지는 '더보기'로 20개씩 늘린다.
19
+ * 소비처가 항목 수(`trainingDatas.length`)를 넘기고 있어, 이 값을 페이지 크기로 살리면 20개 끊기가 사라진다.
20
+ */
17
21
  count?: number;
18
22
  /** 버튼 클릭 시 열릴 팝업 렌더링 함수. TrainingBlockButton의 renderPopup과 동일. */
19
23
  renderPopup?: (props: TrainingBlockButtonPopupProps) => ReactNode;
20
24
  /** 오늘 완료된 훈련 항목 ID Set. completedIds에 포함된 항목은 완료 상태로 표시됩니다. */
21
25
  completedIds?: Set<string>;
22
- /** 선택 상태 ID Set. 완료 뱃지 없이 테두리 강조만 표시. */
26
+ /** 선택 상태 ID Set. 포함된 항목(완료가 아닌 것)에 체크 배지를 표시 — 완료와 같은 배지다. */
23
27
  selectedIds?: Set<string>;
24
28
  /** 훈련 항목 완료 시 호출되는 콜백. itemId는 완료된 TrainingItem의 id입니다. */
25
29
  onItemComplete?: (itemId: string) => void;
26
30
  /**
27
31
  * 버튼 레이아웃 크기.
28
- * - `square` (기본값): 200×200px 정사각형 카드
29
- * - `list`: 전체 너비 × 80px 가로형 리스트 항목
32
+ * - `square` (기본값): 200×200px 정사각형 카드(좁은 화면에선 190px·반 폭으로 줄어든다)
33
+ * - `small`: 120×120px 축소 카드
34
+ * - `list`: 너비 200px · 최소 높이 68px 가로형 항목
30
35
  */
31
36
  size?: 'square' | 'list' | 'small';
32
37
  /** true 이면 버튼 호버 시 DescriptionTooltip 으로 문제 텍스트를 표시. 기본값 false. */
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useCallback, useEffect, useState } from "react";
3
+ import { useCallback, useState } from "react";
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import Button from '../Button';
6
6
  import "./TrainingStep.css";
@@ -15,16 +15,15 @@ export default function TrainingStepConfirmWithText({ index, stepIndex, isLast =
15
15
  const sentence = context && typeof context === 'object' ? (context.sentence || '') : (typeof context === 'string' ? context : '');
16
16
  const choices = context && typeof context === 'object' ? context.choices : undefined;
17
17
  const [selectedAnswer, setSelectedAnswer] = useState(demoSelected ?? null);
18
- useEffect(() => {
19
- // 데모 강제 선택 시에는 sentence 변경 리셋을 건너뜀
20
- if (demoSelected != null)
21
- return undefined;
22
- if (selectedAnswer !== null) {
23
- const id = setTimeout(() => setSelectedAnswer(null), 0);
24
- return () => clearTimeout(id);
25
- }
26
- return undefined;
27
- }, [sentence, demoSelected, selectedAnswer]);
18
+ // 문장이 **바뀔 때만** 선택을 푼다(렌더 중 상태 조정). 예전 effect 는 selectedAnswer 를 의존성에 두고
19
+ // "null 이 아니면 null 로" 를 해서, 답을 누르면 한 박자 뒤 강조가 곧바로 사라졌다.
20
+ // 데모 강제 선택 시에는 문장 변경 리셋을 건너뛴다.
21
+ const [prevSentence, setPrevSentence] = useState(sentence);
22
+ if (prevSentence !== sentence) {
23
+ setPrevSentence(sentence);
24
+ if (demoSelected == null)
25
+ setSelectedAnswer(null);
26
+ }
28
27
  const handleEndedLocal = useCallback((payload) => {
29
28
  const p = (typeof payload === 'object' && payload !== null)
30
29
  ? Object.assign({}, payload, { __ts: Date.now() })
@@ -37,15 +37,15 @@ export interface TrainingBlockButtonProps {
37
37
  completed?: boolean;
38
38
  /**
39
39
  * 버튼 레이아웃 크기.
40
- * - `square` (기본값): 200×200px 정사각형 카드
40
+ * - `square` (기본값): 200×200px 정사각형 카드(좁은 화면에선 190px·반 폭으로 줄어든다)
41
41
  * - `small`: 120×120px 축소 카드 (행 펼치기 영역 등 좁은 공간용)
42
- * - `list`: 전체 너비 × 60px 가로형 리스트 항목
43
- * - `compact`: 툴팁 등 좁은 공간용 소형 리스트 항목 (44px, 아이콘 24px)
42
+ * - `list`: 너비 200px · 최소 높이 68px 가로형 항목
43
+ * - `compact`: 툴팁 등 좁은 공간용 소형 리스트 항목 (전체 너비 × 높이 40px, 작은 아이콘)
44
44
  */
45
45
  size?: 'square' | 'small' | 'list' | 'compact';
46
46
  renderPopup?: (props: TrainingBlockButtonPopupProps) => ReactNode;
47
47
  completedIds?: Set<string>;
48
- /** 선택 상태 ID Set. 완료 뱃지 없이 테두리 강조만 표시. */
48
+ /** 선택 상태 ID Set. 포함된 항목(완료가 아닌 것)에 체크 배지를 표시 — 완료와 같은 배지이고, `hideCheckBadge` 면 숨는다. */
49
49
  selectedIds?: Set<string>;
50
50
  /** 호버 시 표시할 문제/문장 텍스트. 제공 시 DescriptionTooltip 으로 렌더. */
51
51
  tooltipText?: string | string[];
@@ -10,10 +10,14 @@ import type { ReactNode } from 'react';
10
10
  * 리액트가 다른 것을 같은 것으로 보고 상태를 잘못 물려준다. 그래서 **주어진 이름이
11
11
  * 있으면 반드시 그것을 쓴다**는 게 이 함수들의 약속이다.
12
12
  */
13
- /** @public */
13
+ /**
14
+ * @public 시나리오의 한 쪽. `steps` 는 시나리오(`scenario.pages`)에서는 **ScenarioStep 객체**다 — 팝업이
15
+ * 그것을 `ScenarioStep` 으로 읽는다(key·type·props). 자식 요소(`<Page>`)로 쪽을 짠 경우에만 팝업이 안에서
16
+ * 단계 요소(ReactNode)를 담는다.
17
+ */
14
18
  export type ScenarioPage = {
15
19
  id?: string;
16
- steps?: ReactNode[];
20
+ steps?: ScenarioStep[] | ReactNode[];
17
21
  pageTop?: ReactNode | null;
18
22
  pageBottom?: ReactNode | null;
19
23
  } & Record<string, unknown>;
@@ -36,7 +36,9 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
36
36
  const rawQuery = (searchQuery ?? '').trim();
37
37
  const q = /^[^,[\]\s]+$/.test(rawQuery) ? rawQuery : '';
38
38
  const hasQuery = q.length > 0;
39
- const [sortKey, setSortKey] = useState('ganada');
39
+ // 첫 렌더에 이미 검색어가 있어도 '시작 우선'으로 시작한다 — 아래 조정은 검색어 유무가 **바뀔 때**만 돌아서,
40
+ // 초깃값을 'ganada' 로 고정하면 검색어를 들고 마운트한 화면은 가나다순에 머물렀다.
41
+ const [sortKey, setSortKey] = useState(() => (hasQuery ? 'match' : 'ganada'));
40
42
  const [pageSizeStr, setPageSizeStr] = useState('50');
41
43
  const [page, setPage] = useState(1);
42
44
  // 검색어 유무가 바뀌면 그에 맞는 기본 정렬로 되돌린다(렌더 중 상태 조정).
@@ -18,6 +18,7 @@ import { useToastState } from '../../hooks/useToastState';
18
18
  import { getImageResolver, checkImageExists } from '../../utils/imageConfig';
19
19
  import { formatYmd, getTodayKst } from '../../utils/date';
20
20
  import { getSavedTables } from '../../utils/savedWordTables';
21
+ import { devWarnOnce } from '../../utils/devWarn';
21
22
  import { useWordTablePrint } from './useWordTablePrint';
22
23
  import { CELL_COUNTS } from './wordTableBlockTypes';
23
24
  // ── 컴포넌트 ───────────────────────────────────────────────
@@ -73,6 +74,11 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
73
74
  // 토스트
74
75
  const { toastMessage, toastType, showWarning, clearToast } = useToastState('warning');
75
76
  const handleCloseWordSearch = useCallback(() => setActiveCell(null), [setActiveCell]);
77
+ /* onAddItem 의 "추가하기" 는 빈 칸에 선다 — 표시 모드에서 빈 칸은 showCellCount 로 칸 수를 늘릴 때만 생긴다.
78
+ 둘을 함께 주지 않으면 아무것도 안 떠서 조용히 헛돈다. */
79
+ if (onAddItem && !isEditable && !showCellCount) {
80
+ devWarnOnce('word-table-block-add-without-cell-count', '[WordTableBlock] 표시 모드에서 onAddItem 은 showCellCount 와 함께 줘야 "추가하기" 칸이 생긴다.');
81
+ }
76
82
  // display 모드에서 칸 수 조절용 상태 (showCellCount일 때만 사용)
77
83
  const [displayCellCount, setDisplayCellCount] = useState(() => Math.max(items?.length ?? 1, 1));
78
84
  /* eslint-disable react-hooks/set-state-in-effect */
@@ -13,11 +13,11 @@ export interface WordTableButtonProps {
13
13
  createdAt?: string;
14
14
  /** 스토어 발행 배지 표시 */
15
15
  published?: boolean;
16
- /** 발행 배지 라벨(i18n 주입) */
16
+ /** 발행 배지 라벨. 없으면 사전 `myWordTable.publishedBadge`(기본 '발행됨'). */
17
17
  publishedLabel?: string;
18
18
  /** 활성 공유 링크 존재 배지 표시(썸네일 우상단) */
19
19
  shared?: boolean;
20
- /** 공유 배지 라벨(i18n 주입) */
20
+ /** 공유 배지 라벨. 없으면 사전 `myWordTable.sharedBadge`(기본 '공유 중'). */
21
21
  sharedLabel?: string;
22
22
  /** 최근 생성·미열람 표 표시 — 우상단 원형 배지(circle badge) */
23
23
  isNew?: boolean;
@@ -24,5 +24,5 @@ export default function WordTableButton({ items, name, description, createdAt, p
24
24
  return (_jsxs("div", { className: cx('word-table-button', selected ? 'word-table-button--selected' : '', !hasStoreFooter ? 'word-table-button--no-footer' : '', className), role: "button", tabIndex: 0, "aria-label": name, onClick: renaming ? undefined : onClick, onKeyDown: handleKey, children: [isNew && _jsx("span", { className: "word-table-button__new-dot", "aria-label": t('myWordTable.newBadge', '새로 만든 표') }), _jsx(WordTableThumbnail, { items: items, className: "word-table-button__thumb" }), _jsxs("div", { className: "word-table-button__info", children: [_jsxs("div", { className: "word-table-button__info-text", children: [titleNode ?? _jsx("div", { className: "word-table-button__title h4", children: name }), description && _jsx("div", { className: "word-table-button__desc body-3", children: description })] }), onMore && !renaming && (_jsx(Button, { className: "word-table-button__more", size: 32, type: "dot", variant: "transparent-gray", useBorder: false, icon: "ellipsis",
25
25
  /* 하드코딩 '더보기'였다(영어 화면에서도 한국어로 남았다). 목록에 표가
26
26
  여러 개면 이름이 같은 버튼이 여러 개이기도 해서 이름을 함께 넣는다. */
27
- ariaLabel: t('common.moreFor', { name }), onClick: (e) => { e.stopPropagation(); onMore(e); } }))] }), (createdAt || published || shared) && (_jsxs("div", { className: "word-table-button__meta-row", children: [_jsx("span", { className: "word-table-button__meta caption-1", children: createdAt ? formatRelativeTime(createdAt, t) : '' }), _jsxs("span", { className: "word-table-button__badges", children: [published && (_jsx(Badge, { className: "word-table-button__badge", size: "small", variant: "main", fill: "soft", iconLeft: "book-marked", label: publishedLabel ?? '발행됨' })), shared && (_jsx(Badge, { className: "word-table-button__badge", size: "small", fill: "soft", iconLeft: "link", label: sharedLabel ?? '공유 중' }))] })] })), hasStoreFooter && (_jsxs("div", { className: "word-table-button__footer", children: [_jsxs("span", { className: "word-table-button__store-by", children: [publisherName && (_jsx("button", { className: "training-card__publisher body-4", type: "button", onClick: (e) => { e.stopPropagation(); onPublisherClick?.(); }, children: publisherName })), publishedAt && _jsx("span", { className: "training-card__date body-4", children: formatRelativeTime(publishedAt, t) })] }), _jsxs("span", { className: "word-table-button__store-stats", children: [likeCount != null && _jsx(Badge, { size: "small", fill: "soft", variant: "like", iconLeft: "heart", label: String(likeCount) }), downloadCount != null && _jsx(Badge, { size: "small", fill: "soft", variant: "main", iconLeft: "download", label: String(downloadCount) })] })] }))] }));
27
+ ariaLabel: t('common.moreFor', { name }), onClick: (e) => { e.stopPropagation(); onMore(e); } }))] }), (createdAt || published || shared) && (_jsxs("div", { className: "word-table-button__meta-row", children: [_jsx("span", { className: "word-table-button__meta caption-1", children: createdAt ? formatRelativeTime(createdAt, t) : '' }), _jsxs("span", { className: "word-table-button__badges", children: [published && (_jsx(Badge, { className: "word-table-button__badge", size: "small", variant: "main", fill: "soft", iconLeft: "book-marked", label: publishedLabel ?? t('myWordTable.publishedBadge', '발행됨') })), shared && (_jsx(Badge, { className: "word-table-button__badge", size: "small", fill: "soft", iconLeft: "link", label: sharedLabel ?? t('myWordTable.sharedBadge', '공유 중') }))] })] })), hasStoreFooter && (_jsxs("div", { className: "word-table-button__footer", children: [_jsxs("span", { className: "word-table-button__store-by", children: [publisherName && (_jsx("button", { className: "training-card__publisher body-4", type: "button", onClick: (e) => { e.stopPropagation(); onPublisherClick?.(); }, children: publisherName })), publishedAt && _jsx("span", { className: "training-card__date body-4", children: formatRelativeTime(publishedAt, t) })] }), _jsxs("span", { className: "word-table-button__store-stats", children: [likeCount != null && _jsx(Badge, { size: "small", fill: "soft", variant: "like", iconLeft: "heart", label: String(likeCount) }), downloadCount != null && _jsx(Badge, { size: "small", fill: "soft", variant: "main", iconLeft: "download", label: String(downloadCount) })] })] }))] }));
28
28
  }
@@ -53,7 +53,7 @@ export interface WordTableBlockProps {
53
53
  onReorder?: (fromIndex: number, toIndex: number) => void;
54
54
  /** display 모드에서도 칸 수 조절 허용 */
55
55
  showCellCount?: boolean;
56
- /** display 모드에서 빈 슬롯에 추가하기 버튼 표시 */
56
+ /** display 모드에서 칸 수 안의 빈 칸에 추가하기 버튼 표시 → `renderWordSearch` 로 고른 첫 낱말을 새 항목으로 넘긴다. 칸 수는 `showCellCount` 로만 늘릴 수 있어 **함께 줘야** 빈 칸이 생긴다(없으면 칸 수 = 항목 수라 단추가 뜰 자리가 없다). */
57
57
  onAddItem?: (item: WordSearchResultItem) => void;
58
58
  className?: string;
59
59
  /** 저장 팝업 렌더러 */
@@ -38,13 +38,10 @@ export declare function markAfterClick(current: ScoreMark | null, clicked: Score
38
38
  /** 표시마다 쓰는 아이콘 이름(lucide). 저장소가 쓰는 아이콘 체계를 그대로 따른다. */
39
39
  export declare const MARK_ICON: Record<ScoreMark, string>;
40
40
  /**
41
- * 표시마다 쓰는 단추 변형.
41
+ * 고른 표시의 단추 변형(○ main · △ warn · ✕ danger).
42
42
  *
43
43
  * **모양(아이콘)이 이미 셋을 가르고 색은 거들 뿐이다** — 색만으로 구분하게 두면
44
44
  * 색을 잘 못 가리는 사람에게는 세 표시가 같아진다.
45
- */
46
- /**
47
- * 고른 표시의 단추 변형.
48
45
  *
49
46
  * △ 는 `sub`(청록)였는데 **파랑 옆에서 '좋음' 계열로 읽혔다** — ○ 와 △ 가 나란히
50
47
  * 있으면 둘 다 통과한 것처럼 보인다. 애매함은 주의색이 맞다.
@@ -44,13 +44,10 @@ export const MARK_ICON = {
44
44
  x: 'x',
45
45
  };
46
46
  /**
47
- * 표시마다 쓰는 단추 변형.
47
+ * 고른 표시의 단추 변형(○ main · △ warn · ✕ danger).
48
48
  *
49
49
  * **모양(아이콘)이 이미 셋을 가르고 색은 거들 뿐이다** — 색만으로 구분하게 두면
50
50
  * 색을 잘 못 가리는 사람에게는 세 표시가 같아진다.
51
- */
52
- /**
53
- * 고른 표시의 단추 변형.
54
51
  *
55
52
  * △ 는 `sub`(청록)였는데 **파랑 옆에서 '좋음' 계열로 읽혔다** — ○ 와 △ 가 나란히
56
53
  * 있으면 둘 다 통과한 것처럼 보인다. 애매함은 주의색이 맞다.
@@ -30,7 +30,7 @@ export interface WordTableProps {
30
30
  onRemoveItem?: (id: string) => void;
31
31
  /** 표시 항목 드래그 재정렬 */
32
32
  onReorder?: (fromIndex: number, toIndex: number) => void;
33
- /** 표시 모드 빈칸에 '추가' 버튼 노출 */
33
+ /** 표시 모드에서 `count` 안의 빈칸(항목 수 뒤 ~ count)에 '추가' 버튼 노출. count 가 항목 수와 같으면 그런 칸이 없다. `onActivateCell` 필요. */
34
34
  showFillerAdd?: boolean;
35
35
  /** 그리드 콘텐츠 DOM ref — 인쇄/캡처용 */
36
36
  contentRef?: Ref<HTMLDivElement>;
@@ -171,7 +171,9 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
171
171
  const contentClass = cx('word-table-block__content', CUT_CLASS[cutLine], showOutline && 'word-table-block__content--outline', showDivLine && hasPairs && 'word-table-block__content--div-line');
172
172
  // 빈칸(추가버튼)은 스케일 역보정으로 실제 크기 유지
173
173
  const counterScale = { transform: `scale(${1 / scale})`, transformOrigin: 'center center' };
174
- const grid = (_jsx("div", { ref: scalerRef, className: "word-table__scaler", style: { height: height * scale }, children: _jsx("div", { className: cx('word-table', className), style: { width, height, transform: `scale(${scale})`, transformOrigin: 'top left', marginLeft: offset }, children: _jsxs("div", { ref: contentRef, className: contentClass, style: { gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }, children: [isEdit ? (_jsx(_Fragment, { children: (editCells ?? []).map((cell, i) => (_jsx("div", { className: cx('word-table-block__edit-cell', cell ? 'word-table-block__edit-cell--filled' : 'word-table-block__edit-cell--empty', activeCell === i ? 'word-table-block__edit-cell--active' : ''), style: dotDashStyle(i, cols, rows, cutLine), children: cell ? (_jsxs(_Fragment, { children: [cell.map((e, wi) => (_jsxs(Fragment, { children: [wi > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: e.word, subLabel: e.pronunciation, homonum: e.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize })] }, wi))), scoring && renderScore(i, cell.map(e => e.word).join(' / ')), onRemoveCell && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveCell(i) }) }))] })) : (onActivateCell && (_jsx("div", { style: counterScale, children: _jsx(Button, { size: "48", type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell(i) }) }))) }, i))) })) : (_jsxs(_Fragment, { children: [(items ?? []).map((item, index) => (_jsxs("div", { className: `word-table-block__card${onReorder ? ' word-table-block__card--draggable' : ''}${dragOver === index ? ' word-table-block__card--drag-over' : ''}`, style: dotDashStyle(index, cols, rows, cutLine), draggable: !!onReorder, onDragStart: onReorder ? () => { dragIndex.current = index; } : undefined, onDragOver: onReorder ? (e) => { e.preventDefault(); setDragOver(index); } : undefined, onDragLeave: onReorder ? () => setDragOver(null) : undefined, onDrop: onReorder ? (e) => { e.preventDefault(); if (dragIndex.current !== null && dragIndex.current !== index) {
174
+ const grid = (_jsx("div", { ref: scalerRef, className: "word-table__scaler", style: { height: height * scale }, children: _jsx("div", { className: cx('word-table', className), style: { width, height, transform: `scale(${scale})`, transformOrigin: 'top left', marginLeft: offset }, children: _jsxs("div", { ref: contentRef, className: contentClass, style: { gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }, children: [isEdit ? (_jsx(_Fragment, { children: (editCells ?? []).map((cell, i) => (_jsx("div", { className: cx('word-table-block__edit-cell', cell ? 'word-table-block__edit-cell--filled' : 'word-table-block__edit-cell--empty', activeCell === i ? 'word-table-block__edit-cell--active' : ''), style: dotDashStyle(i, cols, rows, cutLine), children: cell ? (_jsxs(_Fragment, { children: [cell.map((e, wi) => (_jsxs(Fragment, { children: [wi > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: e.word, subLabel: e.pronunciation,
175
+ // 편집 칸도 구어체 발음을 넘긴다 — 빠져 있어 '구어체 발음'을 켜도 편집 모드에서만 비었다.
176
+ colloquialLabel: e.colloquialPronunciation, homonum: e.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize })] }, wi))), scoring && renderScore(i, cell.map(e => e.word).join(' / ')), onRemoveCell && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveCell(i) }) }))] })) : (onActivateCell && (_jsx("div", { style: counterScale, children: _jsx(Button, { size: "48", type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell(i) }) }))) }, i))) })) : (_jsxs(_Fragment, { children: [(items ?? []).map((item, index) => (_jsxs("div", { className: `word-table-block__card${onReorder ? ' word-table-block__card--draggable' : ''}${dragOver === index ? ' word-table-block__card--drag-over' : ''}`, style: dotDashStyle(index, cols, rows, cutLine), draggable: !!onReorder, onDragStart: onReorder ? () => { dragIndex.current = index; } : undefined, onDragOver: onReorder ? (e) => { e.preventDefault(); setDragOver(index); } : undefined, onDragLeave: onReorder ? () => setDragOver(null) : undefined, onDrop: onReorder ? (e) => { e.preventDefault(); if (dragIndex.current !== null && dragIndex.current !== index) {
175
177
  onReorder(dragIndex.current, index);
176
178
  } ; dragIndex.current = null; setDragOver(null); } : undefined, onDragEnd: onReorder ? () => { dragIndex.current = null; setDragOver(null); } : undefined, children: [item.words.map((w, i) => (_jsxs(Fragment, { children: [i > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: w.label, subLabel: w.subLabel, colloquialLabel: w.colloquialLabel, homonum: w.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize, wordInfo: (w.grade || w.syllables != null || w.pos || w.wordType) ? { grade: w.grade, syllables: w.syllables, pos: w.pos, wordType: w.wordType } : undefined })] }, i))), scoring && renderScore(index, item.words.map(w => w.label).join(' / ')), onRemoveItem && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: item.words.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: item.words.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveItem(item.id) }) }))] }, item.id))), Array.from({ length: Math.max(0, cols * rows - (items?.length ?? 0)) }).map((_, i) => {
177
179
  const isWithinCount = (items?.length ?? 0) + i < cellCount;
@@ -202,6 +202,9 @@ export { default as TrainingPagePopup } from './components/Training/TrainingPage
202
202
  export { default as TrainingPagePopupBottom } from './components/Training/TrainingPagePopupBottom';
203
203
  export { default as NoiseSettingsTooltip } from './components/Training/NoiseSettingsTooltip';
204
204
  export type { NoiseSettingsTooltipProps } from './components/Training/NoiseSettingsTooltip';
205
+ export { NoisePlayerContext, useNoisePlayer, DEFAULT_NOISE_SETTINGS, noiseRatioOf, effectiveSpeechVolume, effectiveNoiseVolume, noiseElementVolume, mergeNoiseSettings, NOISE_RATIO_MAX, NOISE_RATIO_STEP, LEVEL_MAX, levelToPct, pctToLevel, } from './components/Training/NoisePlayerContext';
206
+ export type { NoisePlayerContextValue } from './components/Training/NoisePlayerContext';
207
+ export { waveBarPx, waveBarVal, WAVE_H1_PX, WAVE_HMAX_PX, WAVE_A_MAX } from './components/Training/waveScale';
205
208
  export { default as TrainingPagePopupTitle } from './components/Training/TrainingPagePopupTitle';
206
209
  export { default as FilterPanel, FILTER_ALL_VALUE } from './components/FilterPanel';
207
210
  export type { FilterPanelProps, FilterPanelSection, FilterPanelItem } from './components/FilterPanel';
@@ -147,6 +147,11 @@ export { default as TrainingStepAudioPlayer } from './components/Training/Traini
147
147
  export { default as TrainingPagePopup } from './components/Training/TrainingPagePopup';
148
148
  export { default as TrainingPagePopupBottom } from './components/Training/TrainingPagePopupBottom';
149
149
  export { default as NoiseSettingsTooltip } from './components/Training/NoiseSettingsTooltip';
150
+ /* 훈련 소리 모델 — 앱이 결과 기록·설정 페이지에서 코어와 **같은 셈**을 써야 해서 딥 경로
151
+ (`components/Training/NoisePlayerContext`)로 가져가던 것들이다. 배럴에도 연다(딥 경로도 그대로 된다). */
152
+ export { NoisePlayerContext, useNoisePlayer, DEFAULT_NOISE_SETTINGS, noiseRatioOf, effectiveSpeechVolume, effectiveNoiseVolume, noiseElementVolume, mergeNoiseSettings, NOISE_RATIO_MAX, NOISE_RATIO_STEP, LEVEL_MAX, levelToPct, pctToLevel, } from './components/Training/NoisePlayerContext';
153
+ /* 파형 세로 눈금(구간별 — 1배 45px, 2배 74px). 앱이 같은 눈금으로 그림을 그릴 때 쓴다. */
154
+ export { waveBarPx, waveBarVal, WAVE_H1_PX, WAVE_HMAX_PX, WAVE_A_MAX } from './components/Training/waveScale';
150
155
  export { default as TrainingPagePopupTitle } from './components/Training/TrainingPagePopupTitle';
151
156
  export { default as FilterPanel, FILTER_ALL_VALUE } from './components/FilterPanel';
152
157
  export { default as WordTable, WORD_TABLE_PAPER } from './components/WordTable';