@ihabdevteam/core 0.141.0 → 0.143.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 (76) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/components.cjs.js +176 -176
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +182 -182
  6. package/dist/ihabdevteam-core.d.ts +81 -29
  7. package/dist/packages/core/src/components/BarChart.d.ts +1 -1
  8. package/dist/packages/core/src/components/BarChart.js +4 -3
  9. package/dist/packages/core/src/components/BottomNav.js +4 -0
  10. package/dist/packages/core/src/components/BulkBar.js +15 -4
  11. package/dist/packages/core/src/components/Button.js +10 -4
  12. package/dist/packages/core/src/components/DatePicker.js +5 -1
  13. package/dist/packages/core/src/components/Dialog.js +3 -1
  14. package/dist/packages/core/src/components/Dropdown.js +6 -2
  15. package/dist/packages/core/src/components/FileUpload.js +3 -1
  16. package/dist/packages/core/src/components/FilterPanel.js +3 -2
  17. package/dist/packages/core/src/components/PanelRow.js +5 -0
  18. package/dist/packages/core/src/components/PaymentPlan.d.ts +2 -2
  19. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -1
  20. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +5 -2
  21. package/dist/packages/core/src/components/Popup/Popup.d.ts +6 -6
  22. package/dist/packages/core/src/components/Popup/Popup.js +11 -9
  23. package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +1 -1
  24. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +1 -1
  25. package/dist/packages/core/src/components/TextField.js +3 -1
  26. package/dist/packages/core/src/components/Tooltip/Tooltip.js +7 -1
  27. package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -1
  28. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +1 -1
  29. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +7 -1
  30. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +52 -2
  31. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +3 -1
  32. package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -1
  33. package/dist/packages/core/src/components/Training/useAudioPreview.js +28 -3
  34. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +19 -5
  35. package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
  36. package/dist/packages/core/src/components/Word/WordTableButton.d.ts +3 -3
  37. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  38. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +1 -1
  39. package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +2 -2
  40. package/dist/packages/core/src/components/barChartData.d.ts +3 -5
  41. package/dist/packages/core/src/components/barChartData.js +7 -3
  42. package/dist/packages/core/src/components/barChartTypes.d.ts +16 -3
  43. package/dist/packages/core/src/components/barChartTypes.js +27 -13
  44. package/dist/packages/core/src/components.d.ts +1 -0
  45. package/dist/packages/core/src/hooks/focusReturn.d.ts +4 -0
  46. package/dist/packages/core/src/hooks/focusReturn.js +27 -0
  47. package/dist/packages/core/src/hooks/inputModality.d.ts +2 -0
  48. package/dist/packages/core/src/hooks/inputModality.js +31 -0
  49. package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +12 -0
  50. package/dist/packages/core/src/hooks/useDocumentTitle.js +7 -6
  51. package/dist/packages/core/src/hooks/useEditableList.js +3 -3
  52. package/dist/packages/core/src/hooks/useWordMeanings.js +6 -3
  53. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +2 -2
  54. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +54 -17
  55. package/dist/packages/core/src/utils/date.d.ts +18 -0
  56. package/dist/packages/core/src/utils/date.js +23 -0
  57. package/dist/packages/core/src/utils/format.js +2 -1
  58. package/dist/packages/core/src/utils/imageConfig.d.ts +2 -0
  59. package/dist/packages/core/src/utils/imageConfig.js +34 -5
  60. package/dist/packages/core/src/utils/josa.js +2 -0
  61. package/dist/packages/core/src/utils/koreanSyllable.d.ts +5 -0
  62. package/dist/packages/core/src/utils/koreanSyllable.js +69 -43
  63. package/dist/packages/core/src/utils/phonemePairSearch.js +17 -0
  64. package/dist/packages/core/src/utils/textSort.d.ts +10 -0
  65. package/dist/packages/core/src/utils/textSort.js +16 -11
  66. package/dist/packages/core/src/utils/timeoutSignal.d.ts +5 -0
  67. package/dist/packages/core/src/utils/timeoutSignal.js +12 -0
  68. package/dist/packages/core/src/utils/ttsConfig.js +2 -1
  69. package/dist/packages/core/src/utils/wordContrast.js +6 -4
  70. package/dist/packages/core/src/utils/wordTrainingPool.js +14 -5
  71. package/dist/packages/core/src/utils.d.ts +1 -1
  72. package/dist/packages/core/src/utils.js +1 -1
  73. package/dist/packages/core/src/version.d.ts +1 -1
  74. package/dist/packages/core/src/version.js +1 -1
  75. package/dist/utils.cjs.js +2 -2
  76. package/package.json +1 -1
@@ -8,6 +8,7 @@ import { pushEsc } from "../../hooks/escStack";
8
8
  import { lockBodyScroll } from "../../hooks/bodyScrollLock";
9
9
  import "./Popup.css";
10
10
  import { useFocusTrap, FOCUSABLE_SELECTOR } from '../../hooks/useFocusTrap';
11
+ import { rememberFocusReturn, resolveFocusReturn } from '../../hooks/focusReturn';
11
12
  const ANIM_MS = 260;
12
13
  // 패널 안에서 Tab 순환 대상으로 삼을 요소들 — disabled/tabindex="-1"은 제외.
13
14
  /** @public */
@@ -81,7 +82,8 @@ export default function Popup(props) {
81
82
  // 포커스 관리 — 이전까진 팝업이 열려도 포커스가 배경(주로 body)에 남아있어서,
82
83
  // 키보드 사용자가 Tab을 눌러도 팝업 밖 배경 요소로 이동해버리고(포커스 트랩 없음),
83
84
  // 스크린리더는 열린 다이얼로그를 아예 인지하지 못했다. 열릴 때 패널(또는 첫 포커스
84
- // 가능 요소)로 포커스를 옮기고, 닫히면 원래 포커스였던 요소로 되돌린다.
85
+ // 가능 요소)로 포커스를 옮기고, 닫히면 — 포커스가 아직 이 창 안이거나 body 일 때만 —
86
+ // 원래 포커스였던 요소로 되돌린다(다른 창으로 옮겨 갔으면 그대로 둔다).
85
87
  // 단, 자식이 자체 autoFocus를 쓰는 경우(SaveTablePopup 등)는 이미 그쪽으로 포커스가
86
88
  // 가 있을 것이므로(브라우저가 커밋 시점에 처리) 뺏지 않는다.
87
89
  useEffect(() => {
@@ -89,6 +91,7 @@ export default function Popup(props) {
89
91
  return;
90
92
  const previouslyFocused = document.activeElement;
91
93
  const panel = panelRef.current;
94
+ rememberFocusReturn(panel, previouslyFocused);
92
95
  if (panel && !panel.contains(document.activeElement)) {
93
96
  const first = panel.querySelector(FOCUSABLE_SELECTOR);
94
97
  (first ?? panel).focus();
@@ -98,8 +101,9 @@ export default function Popup(props) {
98
101
  (미리보기 → 삭제 확인)에서는 닫힘 애니메이션(260ms) 뒤에 여기가 돌 때 포커스가 이미 새 창에 있는데,
99
102
  전에는 그것을 뒤 화면의 여는 단추로 빼앗아 Tab 가둠이 풀리고 Enter 가 뒤 화면을 눌렀다. */
100
103
  const active = document.activeElement;
104
+ // 기억한 곳이 앞 창과 함께 사라졌으면(미리보기 → 삭제 확인) 그 창이 돌려줄 곳으로
101
105
  if (!active || active === document.body || (panel && panel.contains(active)))
102
- previouslyFocused?.focus?.();
106
+ resolveFocusReturn(previouslyFocused)?.focus?.();
103
107
  };
104
108
  }, [inline]);
105
109
  /* Tab 순환을 패널 안으로 가둔다 — 네 곳에 있던 같은 열두 줄을 훅 한 벌로 모았다.
@@ -130,13 +134,11 @@ export default function Popup(props) {
130
134
  되살리기();
131
135
  return;
132
136
  }
133
- // 부모가 치울 틈(렌더·커밋)을 준 뒤에도 남아 있으면 되살린다
134
- const 남았으면 = () => { setTimeout(되살리기, 120); };
137
+ /* 약속을 돌려준 콜백만 **끝난 뒤에도 남아 있으면** 되살린다. 동기 콜백(undefined)은 되살리지 않는다 — 부모가 저장을
138
+ 따로 기다렸다 닫는 흔한 꼴(onSave 를 부르고 약속은 안 돌려줌)에서 기다리는 사이 창이 다시 보이고 저장 단추가 다시
139
+ 눌려 두 번 저장됐다. 저장 실패에 창을 되살리고 싶으면 콜백이 그 약속을 돌려주면 된다. */
135
140
  if (결과 && typeof 결과.then === 'function') {
136
- 결과.then(남았으면, 되살리기);
137
- }
138
- else {
139
- 남았으면();
141
+ 결과.then(() => { setTimeout(되살리기, 120); }, 되살리기);
140
142
  }
141
143
  }, ANIM_MS);
142
144
  }, [되살리기]);
@@ -185,7 +187,7 @@ export default function Popup(props) {
185
187
  e.preventDefault(); }, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}${height != null ? ' popup-panel--has-height' : ''}${fillBody ? ' popup-panel--fill-body' : ''}`,
186
188
  /* 높이는 **CSS 변수로** 넘긴다 — 인라인 height 로 박으면 앱의 미디어 규칙(좁은 화면만 다른 높이)이 못 이겼다.
187
189
  max-height(CSS)가 height 보다 이기므로 화면보다 크게 줘도 화면에 맞춰진다 */
188
- style: height != null ? { '--popup-height': typeof height === 'number' ? `${height}px` : height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && title ? titleId : undefined, "aria-label": !inline && !title && ariaLabel ? ariaLabel : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { htmlType: "button", size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
190
+ style: height != null ? { '--popup-height': typeof height === 'number' ? `${height}px` : height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && useTitle && title ? titleId : undefined, "aria-label": !inline && !(useTitle && title) ? (ariaLabel ?? (typeof title === 'string' ? title : undefined)) : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { htmlType: "button", size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
189
191
  if (inline || typeof document === 'undefined')
190
192
  return el;
191
193
  return createPortal(el, document.body);
@@ -27,7 +27,7 @@ export declare function visitItemKey(v: {
27
27
  minute: string;
28
28
  }): string;
29
29
  /**
30
- * 초 → "M분 S초"(또는 "M분"·"S초"). 없거나 0 이하면 '-'.
30
+ * 초 → "M분 S초"(또는 "M분"·"S초"). 초 단위로 반올림한 뒤 나눈다 — 없거나(숫자가 아님) 반올림해 0 이하(0.5초 미만 포함)면 '-'.
31
31
  *
32
32
  * 문구는 지금 언어로(`customers.duration.*`, 사전에 없으면 한국어). `durationStr` 를 주면
33
33
  * 그 함수가 만든 글을 그대로 쓴다 — `calcAge` 의 `ageStr` 와 같은 결.
@@ -71,7 +71,7 @@ export function visitItemKey(v) {
71
71
  return `${v.year}-${v.month}-${v.day}T${v.hour}:${v.minute}`;
72
72
  }
73
73
  /**
74
- * 초 → "M분 S초"(또는 "M분"·"S초"). 없거나 0 이하면 '-'.
74
+ * 초 → "M분 S초"(또는 "M분"·"S초"). 초 단위로 반올림한 뒤 나눈다 — 없거나(숫자가 아님) 반올림해 0 이하(0.5초 미만 포함)면 '-'.
75
75
  *
76
76
  * 문구는 지금 언어로(`customers.duration.*`, 사전에 없으면 한국어). `durationStr` 를 주면
77
77
  * 그 함수가 만든 글을 그대로 쓴다 — `calcAge` 의 `ageStr` 와 같은 결.
@@ -12,7 +12,9 @@ import './TextField.css';
12
12
  const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
13
13
  /** @public */
14
14
  export default function TextField(props) {
15
- const { value, defaultValue, name, inputRef, onChange, label, ariaLabel, required, placeholder, type = 'text', disabled = false, readOnly = false, error, helperText, maxLength, size = 48, variant = 'input', fullWidth = false, newline = false, multiline = false, rows = 3, leftIcon, rightIcon, clearable = false, list, showCount = true, className = '', onFocus, onBlur, onKeyDown, onEnter, inputMode, autoComplete, autoFocus, suggestions, openOnFocus = false, } = props;
15
+ const { value, defaultValue, name, inputRef, onChange, label, ariaLabel, required, placeholder, type = 'text', disabled = false, readOnly = false, error, helperText, maxLength, size = 48, variant = 'input', fullWidth = false, newline = false, multiline = false, rows = 3, leftIcon, rightIcon, clearable = false, list, showCount = true, className = '', onFocus, onBlur, onKeyDown, onEnter, inputMode, autoComplete, autoFocus, suggestions: suggestionsProp, openOnFocus = false, } = props;
16
+ // 읽기 전용·꺼진 칸은 후보로 값을 바꾸지 못한다 — 전에는 ↓·Enter·후보 누름으로 onChange 가 나갔다
17
+ const suggestions = readOnly || disabled ? undefined : suggestionsProp;
16
18
  const { t } = useTranslation();
17
19
  const uid = useId();
18
20
  /* **이름 없는 입력칸은 화면을 못 보는 사람에게 빈칸이다.** placeholder 는 이름이
@@ -82,7 +82,7 @@ export default function Tooltip(props) {
82
82
  top = rect.top + rect.height / 2 - h / 2;
83
83
  }
84
84
  }
85
- const m = Number(edgeMargin) || 8;
85
+ const m = Number.isFinite(Number(edgeMargin)) ? Number(edgeMargin) : 8; // 0 도 값이다(`|| 8` 이 0 을 8 로 바꿨다)
86
86
  left = Math.min(Math.max(left, m), vw - w - m);
87
87
  top = Math.min(Math.max(top, m), vh - h - m);
88
88
  const leftRounded = Math.round(left);
@@ -146,6 +146,12 @@ export default function Tooltip(props) {
146
146
  const t = e.target;
147
147
  if (t && ref.current && ref.current.contains(t))
148
148
  return;
149
+ // 툴팁에서 body 로 빠진 메뉴·달력(data-tooltip-ignore)을 굴리는 것도 툴팁 안의 일이다 — 열자마자 선택값으로 굴리면 닫혔다
150
+ try {
151
+ if (t && t.closest && t.closest('[data-tooltip-ignore]'))
152
+ return;
153
+ }
154
+ catch (err) { }
149
155
  startCloseRef.current(true);
150
156
  return;
151
157
  }
@@ -7,6 +7,7 @@ import Section from '../Section';
7
7
  import './TrainingBlock.css';
8
8
  import TrainingBlockButton from './TrainingBlockButton';
9
9
  import { useWindowEvent } from '../../hooks/useWindowEvent';
10
+ import { 키보드로눌렀나 } from '../../hooks/inputModality';
10
11
  const BTN_COUNT_MAX = 20;
11
12
  /** @public */
12
13
  export default function TrainingBlock(props) {
@@ -34,7 +35,7 @@ export default function TrainingBlock(props) {
34
35
  const 더보기초점Ref = useRef(null);
35
36
  function loadMore() {
36
37
  const 단추 = document.activeElement;
37
- 더보기초점Ref.current = 단추 && 단추.closest?.('.training-block__more') ? visible : null;
38
+ 더보기초점Ref.current = 키보드로눌렀나() && 단추 && 단추.closest?.('.training-block__more') ? visible : null;
38
39
  setVisible((v) => Math.min(total, v + BTN_COUNT_MAX));
39
40
  }
40
41
  useEffect(() => {
@@ -78,7 +78,7 @@ export type TrainingPagePopupProps = {
78
78
  * 판에 실제로 걸린 값은 `NoiseSettingsStore.applied` 로 받는다.
79
79
  */
80
80
  noiseOverride?: Partial<CoreNoiseSettings> | ((saved: CoreNoiseSettings) => Partial<CoreNoiseSettings>);
81
- /** 훈련 결과 팝업 렌더러 — 없으면 결과 팝업을 표시하지 않음 */
81
+ /** 훈련 결과 팝업 렌더러 — 없으면 기본 결과 팝업(TrainingResultPopup)을 띄운다 */
82
82
  renderResultPopup?: (props: {
83
83
  score?: number | null;
84
84
  results?: unknown[];
@@ -5,6 +5,7 @@ import { PAGE_POPUP_CLOSE_MS } from './pagePopupTiming';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { createPortal } from "react-dom";
7
7
  import { useFocusTrap, FOCUSABLE_SELECTOR } from '../../hooks/useFocusTrap';
8
+ import { rememberFocusReturn, resolveFocusReturn } from '../../hooks/focusReturn';
8
9
  import { pushEsc } from '../../hooks/escStack';
9
10
  import Toast from '../Toast';
10
11
  import Button from '../Button';
@@ -25,6 +26,7 @@ import useTrainingSessionLog from '../../hooks/useTrainingSessionLog';
25
26
  import { logTrainingCompletion } from '../../utils/trainingLogger';
26
27
  import { toItemResults } from '../../utils/trainingItemResults';
27
28
  import { loadNoiseSettings, saveNoiseSettings, reportAppliedNoiseSettings } from '../../utils/noiseSettingsStore';
29
+ import { 키보드로눌렀나 } from '../../hooks/inputModality';
28
30
  // 문장 4종은 각 PagePopup 래퍼에서 useTrainingSessionLog로 이미 기록 → 베이스에서 중복 방지
29
31
  const SENTENCE_TIME_LOGGED_TYPES = new Set(['LISTEN_IN_NOISE', 'SHORT_STORY', 'LONG_STORY', 'ORDERING_SENTENCE']);
30
32
  // renderResultPopup 미지정 시 기본 결과 팝업 — 모든 훈련 타입 공통 폴백
@@ -239,6 +241,7 @@ function TrainingPagePopupInner(props) {
239
241
  useEffect(() => {
240
242
  const panel = panelRef.current;
241
243
  const previouslyFocused = document.activeElement;
244
+ rememberFocusReturn(panel, previouslyFocused);
242
245
  if (panel) {
243
246
  const firstEl = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => !el.closest('[aria-hidden="true"]'))[0];
244
247
  if (firstEl) {
@@ -253,7 +256,7 @@ function TrainingPagePopupInner(props) {
253
256
  const active = document.activeElement;
254
257
  if (!active || active === document.body || (panel && panel.contains(active))) {
255
258
  try {
256
- previouslyFocused?.focus?.();
259
+ resolveFocusReturn(previouslyFocused)?.focus?.();
257
260
  }
258
261
  catch (e) { }
259
262
  }
@@ -763,6 +766,9 @@ function TrainingPagePopupInner(props) {
763
766
  앞쪽Ref.current = effectivePageIndex;
764
767
  if (앞 === undefined || 앞 === effectivePageIndex)
765
768
  return;
769
+ // 키보드로 넘겼을 때만 — 마우스로 누른 사람에게 매 문제 '듣기' 초점 안내가 뜨지 않게
770
+ if (!키보드로눌렀나())
771
+ return;
766
772
  const panel = panelRef.current;
767
773
  if (!panel)
768
774
  return;
@@ -10,6 +10,7 @@ import { resumeAudioMeter, routeElementAudio, setElementVolume, getElementVolume
10
10
  import { useNoisePlayer, effectiveNoiseVolume, effectiveSpeechVolume, noiseRatioOf } from './NoisePlayerContext';
11
11
  import { useNoiseFade } from './useNoiseFade';
12
12
  import { useTTSPlayback } from './useTTSPlayback';
13
+ import { claimSound, stopSoundIfOwner } from './currentSound';
13
14
  import './TrainingStepAudioPlayer.css';
14
15
  import WaveForm from './AudioPlayerWaveForm';
15
16
  import Toast from '../Toast';
@@ -17,6 +18,7 @@ import Button from '../Button';
17
18
  import Icon from '../Icon';
18
19
  import InfoTooltip from '../Tooltip/InfoTooltip';
19
20
  import { STATUS_MESSAGES } from './trainingStepAudioPlayerTypes';
21
+ import { 키보드로눌렀나 } from '../../hooks/inputModality';
20
22
  import { measureAudioDuration, waitForCanPlay } from './audioWaits';
21
23
  import { useWaveReady } from './useWaveReady';
22
24
  import { useObjectUrls } from './useObjectUrls';
@@ -156,7 +158,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
156
158
  audioRef,
157
159
  playbackStateRef,
158
160
  });
159
- const { startTTS, stopTTS } = useTTSPlayback({
161
+ const { startTTS: 합성시작, stopTTS } = useTTSPlayback({
160
162
  ttsText,
161
163
  playbackRate,
162
164
  // TTS 음성 = 문제 소리 = 전체 소리
@@ -172,6 +174,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
172
174
  onDoneRef,
173
175
  handleEndRef,
174
176
  });
177
+ useEffect(() => { stopTTSRef.current = stopTTS; });
178
+ const startTTS = (text) => { 소리잡기('tts'); 합성시작(text); };
175
179
  const requestedSources = Array.isArray(soundSrc)
176
180
  ? soundSrc.filter(Boolean)
177
181
  : (soundSrc ? [soundSrc] : []);
@@ -191,12 +195,50 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
191
195
  오류" 로만 보고 다시 부르면 **정지를 눌렀는데 소리가 다시 났다**. 시작할 때의 의도가 그대로일 때만 다시 시도한다. */
192
196
  const 재생의도Ref = useRef(0);
193
197
  const 멈춤의도 = () => { 재생의도Ref.current += 1; jumpTokenRef.current += 1; };
198
+ /* 스피커는 하나(currentSound.ts) — 한 화면의 다른 재생기(문장 듣기·문제 듣기)·낱말 카드·미리 듣기가 울리면 이 재생기는
199
+ 멈춘다. 전에는 낱말 카드만 자물쇠를 잡아, 듣기 단계가 둘인 화면·듣기+카드 단계에서 두 소리가 함께 울렸다.
200
+ 멈춤 단추와 같게 멈춘다(이어 듣기 가능). 함수는 한 벌이어야 자물쇠가 '내 것' 을 알아본다. */
201
+ const stopTTSRef = useRef(null);
202
+ const 소리종류Ref = useRef(null);
203
+ const 소리놓기Ref = useRef(null);
204
+ const 소리멈춤Ref = useRef(null);
205
+ if (!소리멈춤Ref.current) {
206
+ 소리멈춤Ref.current = () => {
207
+ 소리놓기Ref.current = null;
208
+ const a = audioRef.current;
209
+ if (소리종류Ref.current === 'audio' && a && !a.paused) {
210
+ 재생의도Ref.current += 1;
211
+ jumpTokenRef.current += 1;
212
+ try {
213
+ a.pause();
214
+ }
215
+ catch (e) { }
216
+ if (mountedRef.current)
217
+ setPlaybackState('paused');
218
+ }
219
+ else if (소리종류Ref.current === 'tts') {
220
+ try {
221
+ stopTTSRef.current?.();
222
+ }
223
+ catch (e) { }
224
+ }
225
+ };
226
+ }
227
+ const 소리잡기 = (종류) => {
228
+ 소리종류Ref.current = 종류;
229
+ 소리놓기Ref.current = claimSound(소리멈춤Ref.current);
230
+ };
231
+ // 멈춤·정지·완료 뒤에는 자리를 비운다 — 남이 울릴 때 끝난 재생기의 상태를 건드리지 않게
232
+ const 소리놓기 = () => { 소리놓기Ref.current?.(); 소리놓기Ref.current = null; };
233
+ useEffect(() => () => { if (소리멈춤Ref.current)
234
+ stopSoundIfOwner(소리멈춤Ref.current); }, []);
194
235
  const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
195
236
  const 의도 = 재생의도Ref.current;
196
237
  for (let i = 0; i < attempts; i++) {
197
238
  if (!mountedRef.current || 의도 !== 재생의도Ref.current)
198
239
  return false;
199
240
  try {
241
+ 소리잡기('audio');
200
242
  await a.play();
201
243
  return true;
202
244
  }
@@ -730,6 +772,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
730
772
  try {
731
773
  if (synth && synth.paused) {
732
774
  try {
775
+ 소리잡기('tts');
733
776
  synth.resume();
734
777
  setPlaybackState('playing');
735
778
  }
@@ -846,6 +889,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
846
889
  const handleStop = () => {
847
890
  // 기다리던 트랙 이동·재시도가 정지 뒤에 소리를 내지 않게
848
891
  멈춤의도();
892
+ 소리놓기();
849
893
  if (ttsMode) {
850
894
  stopTTS();
851
895
  return;
@@ -948,6 +992,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
948
992
  const outDelay = 340;
949
993
  markCompletedByEnd(true);
950
994
  멈춤의도();
995
+ 소리놓기();
951
996
  try {
952
997
  setProgress(1);
953
998
  }
@@ -1092,7 +1137,12 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1092
1137
  const fillPausedFlag = playbackState === "paused";
1093
1138
  const fillLeftPercent = Math.max(0, Math.min(100, (progress || 0) * 100));
1094
1139
  const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
1095
- return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (_jsxs("div", { className: "audio-player__loading", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: t(`training.audioStatus.${fetchDebugStatus}`, { defaultValue: STATUS_MESSAGES[fetchDebugStatus] }) })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button", onClick: (e) => { 듣기초점옮기기Ref.current = document.activeElement === e.currentTarget; hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1140
+ return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (_jsxs("div", { className: "audio-player__loading", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: t(`training.audioStatus.${fetchDebugStatus}`, { defaultValue: STATUS_MESSAGES[fetchDebugStatus] }) })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button",
1141
+ /* 막힌 단계(아직 차례가 아닌 듣기·합성 주소를 받는 중)는 CSS pointer-events 로만 막아, Tab 으로 와서 Enter 를
1142
+ 누르면 소리를 받으러 가거나 "가져오지 못했습니다" 를 띄웠다 — 마우스와 같게 키보드도 막는다 */
1143
+ disabled: isDisabled,
1144
+ // 키보드로 눌렀을 때만 초점을 옮긴다 — 마우스로 누른 사람에게 '재생 / 멈춤' 초점 툴팁이 뜨지 않게
1145
+ onClick: () => { 듣기초점옮기기Ref.current = 키보드로눌렀나(); hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1096
1146
  // 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
1097
1147
  onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
1098
1148
  handleJumpTo(idx);
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { useTranslation } from 'react-i18next';
5
+ import { 키보드로눌렀나 } from '../../hooks/inputModality';
5
6
  import Button from '../Button';
6
7
  import Toast from '../Toast';
7
8
  import { useFontScale } from '../../hooks';
@@ -34,7 +35,8 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
34
35
  const 상자Ref = useRef(null);
35
36
  const 펼친뒤초점Ref = useRef(false);
36
37
  const handleActivate = useCallback(() => {
37
- 펼친뒤초점Ref.current = !!상자Ref.current && 상자Ref.current.contains(document.activeElement);
38
+ // 키보드로 눌렀을 때만 — 마우스로 눌렀는데 옮기면 '글자 크기 감소' 초점 툴팁이 떴다
39
+ 펼친뒤초점Ref.current = 키보드로눌렀나() && !!상자Ref.current && 상자Ref.current.contains(document.activeElement);
38
40
  setLocalStatus("active");
39
41
  }, []);
40
42
  const handleNoticeClose = useCallback(() => setNoticeMessage(''), []);
@@ -51,7 +51,15 @@ export default function WordTablePopup({ items, words, title, subtitle, emptyTex
51
51
  새 함수 → 알림 이 끝없이 돈다. 부모가 어떻게 짜든 안 돌게 한다. */
52
52
  const notifyRef = useRef(onScoreChange);
53
53
  useEffect(() => { notifyRef.current = onScoreChange; });
54
- useEffect(() => { notifyRef.current?.(marksForSave(marks)); }, [marks]);
54
+ // 처음 열 때는 알리지 않는다 — 아무것도 안 매겼는데 onScoreChange({}) 가 나갔다
55
+ const 첫알림Ref = useRef(true);
56
+ useEffect(() => {
57
+ if (첫알림Ref.current) {
58
+ 첫알림Ref.current = false;
59
+ return;
60
+ }
61
+ notifyRef.current?.(marksForSave(marks));
62
+ }, [marks]);
55
63
  /* 표에는 지운 칸(null)이 빠진 것만 넘긴다 — 표는 "매겨진 것"만 알면 된다. */
56
64
  const 표에줄채점 = useMemo(() => marksForSave(marks), [marks]);
57
65
  // 인쇄 — WordTable 내부 콘텐츠(.word-table 아래 그리드)를 격리 iframe으로 출력한다.
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { resolveSoundPath, getSignedOrPublicUrl, downloadSound } from '../../utils/storage';
4
+ import { claimSound, stopSoundIfOwner } from './currentSound';
4
5
  function derivePreviewMeta(trainingData, typeKey) {
5
6
  if (!trainingData)
6
7
  return { previewCodes: [], previewBucket: null };
@@ -51,6 +52,9 @@ export function useAudioPreview(trainingData, typeKey) {
51
52
  const [resolved, setResolved] = useState({ key: '', urls: [] });
52
53
  const previewAudioRef = useRef(null);
53
54
  const playingRef = useRef(false);
55
+ /* 지금 미리 듣기를 멈추는 함수 — 스피커 자물쇠(currentSound.ts)에 건다. 다른 블록 미리 듣기·훈련 소리가 울리면 이것이 불린다.
56
+ 전에는 자물쇠에 참여하지 않아 바구니의 블록 둘을 잇달아 누르면 두 문장이 함께 울렸다. */
57
+ const 멈춤Ref = useRef(null);
54
58
  const previewBtnRef = useRef(null);
55
59
  const [previewHover, setPreviewHover] = useState(false);
56
60
  // downloadSound()로 만든 blob URL만 추적 — getSignedOrPublicUrl()이 반환하는 http(s) URL은
@@ -129,6 +133,8 @@ export function useAudioPreview(trainingData, typeKey) {
129
133
  e.stopPropagation();
130
134
  e.preventDefault();
131
135
  if (playingRef.current) {
136
+ if (멈춤Ref.current)
137
+ stopSoundIfOwner(멈춤Ref.current);
132
138
  playingRef.current = false;
133
139
  try {
134
140
  previewAudioRef.current?.pause();
@@ -141,6 +147,18 @@ export function useAudioPreview(trainingData, typeKey) {
141
147
  const urls = previewUrls.filter(Boolean);
142
148
  if (!urls.length)
143
149
  return;
150
+ // pause() 만으로는 ended 가 오지 않아 기다리던 약속이 풀리지 않는다 — 멈출 때 풀어 반복을 끝낸다
151
+ let 기다림풀기 = null;
152
+ const 멈춤 = () => {
153
+ playingRef.current = false;
154
+ try {
155
+ previewAudioRef.current?.pause();
156
+ }
157
+ catch { }
158
+ 기다림풀기?.();
159
+ };
160
+ 멈춤Ref.current = 멈춤;
161
+ const release = claimSound(멈춤);
144
162
  playingRef.current = true;
145
163
  setPlaying(true);
146
164
  for (let i = 0; i < previewUrls.length; i++) {
@@ -152,6 +170,7 @@ export function useAudioPreview(trainingData, typeKey) {
152
170
  setPlayingIndex(i);
153
171
  try {
154
172
  await new Promise((resolve, reject) => {
173
+ 기다림풀기 = resolve;
155
174
  const audio = new Audio(url);
156
175
  previewAudioRef.current = audio;
157
176
  audio.onended = () => resolve();
@@ -163,13 +182,19 @@ export function useAudioPreview(trainingData, typeKey) {
163
182
  break;
164
183
  }
165
184
  }
166
- playingRef.current = false;
167
- setPlaying(false);
168
- setPlayingIndex(-1);
185
+ release();
186
+ if (멈춤Ref.current === 멈춤) {
187
+ 멈춤Ref.current = null;
188
+ playingRef.current = false;
189
+ setPlaying(false);
190
+ setPlayingIndex(-1);
191
+ }
169
192
  }, [previewUrls]);
170
193
  // Pause and release audio on unmount
171
194
  useEffect(() => {
172
195
  return () => {
196
+ if (멈춤Ref.current)
197
+ stopSoundIfOwner(멈춤Ref.current);
173
198
  playingRef.current = false;
174
199
  try {
175
200
  previewAudioRef.current?.pause();
@@ -11,7 +11,8 @@ import Dropdown from '../Dropdown';
11
11
  import Pagination from '../Pagination';
12
12
  import WordImageTooltip from '../Tooltip/WordImageTooltip';
13
13
  import { useHoverAnchor } from '../../hooks/useHoverAnchor';
14
- import { matchesWordQuery } from '../../utils/koreanSyllable';
14
+ import { makeWordQueryMatcher } from '../../utils/koreanSyllable';
15
+ const 가나다 = new Intl.Collator('ko');
15
16
  import { withIga, withEulReul } from '../../utils/josa';
16
17
  /** @public */
17
18
  export default function WordSearchResultBlock({ titleIcon, resultCount, resultUnit, searchQuery, items, groups, selected = [], onSelect, maxSelect, onClearSelection, onPrint, onPrintEmpty, className, emptyHint, sectionType = 'page', disc, notice, }) {
@@ -61,6 +62,21 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
61
62
  value: v, label: t('wordSearch.pageSize', { count: Number(v), defaultValue: `${v}개씩 보기` }),
62
63
  })), [t]);
63
64
  const firstWord = (it) => it.words[0];
65
+ /* '시작 우선' 순위는 검색어가 바뀔 때 판별기를 한 번 만들고 항목마다 한 번만 센다 — 비교할 때마다 검색어를 다시 펼치고
66
+ localeCompare 를 불러 결과가 바뀔 때 23k 개에 38ms 걸렸다. */
67
+ const 시작순위 = useMemo(() => {
68
+ const qLen = [...q].length;
69
+ const m = makeWordQueryMatcher(q);
70
+ const memo = new Map();
71
+ return (it) => {
72
+ let r = memo.get(it);
73
+ if (r === undefined) {
74
+ r = m((it.words[0]?.label ?? '').slice(0, qLen)) ? 0 : 1;
75
+ memo.set(it, r);
76
+ }
77
+ return r;
78
+ };
79
+ }, [q]);
64
80
  // 정렬 비교자 — flat/그룹 뷰가 공유한다.
65
81
  const sortCompare = useCallback((a, b) => {
66
82
  const label = (it) => firstWord(it)?.label ?? '';
@@ -70,9 +86,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
70
86
  // 시작 우선 — 검색어로 시작하는 단어를 위로. 앞 q 글자만 잘라 매칭하면 "첫 음절부터 일치"만
71
87
  // 남는다(초성 검색도 동일: 'ㄴ' → '나무'가 위). 동순위는 가나다.
72
88
  case 'match': {
73
- const qLen = [...q].length;
74
- const rank = (it) => matchesWordQuery(label(it).slice(0, qLen), q) ? 0 : 1;
75
- return rank(a) - rank(b) || label(a).localeCompare(label(b), 'ko');
89
+ return 시작순위(a) - 시작순위(b) || 가나다.compare(label(a), label(b));
76
90
  }
77
91
  case 'ganada': return label(a).localeCompare(label(b), 'ko');
78
92
  case 'ganada_desc': return label(b).localeCompare(label(a), 'ko');
@@ -82,7 +96,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
82
96
  case 'grade_desc': return grade(b) - grade(a) || label(a).localeCompare(label(b), 'ko');
83
97
  default: return 0;
84
98
  }
85
- }, [effectiveSortKey, q]);
99
+ }, [effectiveSortKey, 시작순위]);
86
100
  const sortedItems = useMemo(() => items.slice().sort(sortCompare), [items, sortCompare]);
87
101
  const pageSize = Number(pageSizeStr) || 10;
88
102
  // 그룹 뷰(대립쌍표 음소대립)도 flat과 동일하게 정렬·페이지네이션한다 — 그룹 순서를 유지하고 그룹 내부를
@@ -166,7 +166,7 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
166
166
  Promise.all(imagePairs.map(([w, h]) => checkImageExists(w, h))).then(results => {
167
167
  if (!cancelled)
168
168
  setImageFound(results.some(Boolean));
169
- });
169
+ }, () => { }); // 확인이 거절돼도(리졸버 예외 등) 처리되지 않은 거절로 새지 않게 — 이미지 없음으로 둔다
170
170
  return () => { cancelled = true; };
171
171
  }, [imagePairs]);
172
172
  const hasImage = imagePairs.length > 0 && imageFound;
@@ -288,7 +288,10 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
288
288
  setActiveCell(null);
289
289
  },
290
290
  onClose: handleCloseWordSearch,
291
- }), !hideControls && (_jsx(TableFilterPanel, { hasImage: hasImage, hasColloquial: hasColloquial, contentTypes: contentTypes, orientation: orientation, showOutline: showOutline, cutLine: cutLine, showDivLine: showDivLine, setters: { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine }, showCutLine: (pagesEnabled ? itemsPerPage : count) > 1, showDivLine2: hasPairs, middleSections: middleSections, extraSelected: middleSelected, onExtraSelect: handleMiddleSelect })), isPaginated && pages ? (_jsx("div", { className: "word-table-block__pages", children: pages.map((page, i) => (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: itemsPerPage, items: page, onRemoveItem: onRemoveItem, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? cells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined, showFillerAdd: !!onAddItem, contentRef: contentRef })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
291
+ }), !hideControls && (_jsx(TableFilterPanel, { hasImage: hasImage, hasColloquial: hasColloquial, contentTypes: contentTypes, orientation: orientation, showOutline: showOutline, cutLine: cutLine, showDivLine: showDivLine, setters: { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine }, showCutLine: (pagesEnabled ? itemsPerPage : count) > 1, showDivLine2: hasPairs, middleSections: middleSections, extraSelected: middleSelected, onExtraSelect: handleMiddleSelect })), isPaginated && pages ? (_jsx("div", { className: "word-table-block__pages", children: pages.map((page, i) => (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: itemsPerPage, items: page, onRemoveItem: onRemoveItem,
292
+ /* 쪽이 나뉘면 끌어 옮기기가 통째로 꺼졌다(말귀 단어 찾기: 9개부터). 쪽 안의 자리를 전체 자리로 옮겨 넘긴다 —
293
+ 쪽을 넘는 이동은 하지 않는다. */
294
+ onReorder: onReorder && ((from, to) => onReorder(from + i * itemsPerPage, to + i * itemsPerPage)), contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? cells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined, showFillerAdd: !!onAddItem, contentRef: contentRef })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
292
295
  /* PanelRow의 라벨은 시각적으로만 붙어 있고 입력과 연결돼 있지 않아, 낭독기에는
293
296
  이름 없는 입력으로 읽혔다. placeholder는 자동완성 안내문("Tab 또는 space…")이
294
297
  섞여 있어 이름으로 부적절하므로 같은 라벨 문구를 넘긴다. */
@@ -3,13 +3,13 @@ import type { WordSearchResultItem } from '../../types/wordSearch';
3
3
  import './WordTableButton.css';
4
4
  /** @public */
5
5
  export interface WordTableButtonProps {
6
- /** 단어표 내용(썸네일 미리보기 + 개수) */
6
+ /** 단어표 내용(썸네일 미리보기) */
7
7
  items: WordSearchResultItem[];
8
8
  /** 단어표 이름 */
9
9
  name: string;
10
10
  /** 설명 — 제목 아래 body-3로 표시(있을 때만) */
11
11
  description?: string;
12
- /** 생성일(ISO) — 메타(날짜)에 표시. 없으면 개수만. */
12
+ /** 생성일(ISO) — 메타(날짜)에 표시. */
13
13
  createdAt?: string;
14
14
  /** 스토어 발행 배지 표시 */
15
15
  published?: boolean;
@@ -43,6 +43,6 @@ export interface WordTableButtonProps {
43
43
  }
44
44
  /**
45
45
  * @public 단어표를 '문서형' 미리보기 카드로 표현하는 컴포넌트.
46
- * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜·개수 + (발행 배지 / 더보기 / 스토어 푸터).
46
+ * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜 + (발행 배지 / 더보기 / 스토어 푸터).
47
47
  */
48
48
  export default function WordTableButton({ items, name, description, createdAt, published, publishedLabel, shared, sharedLabel, isNew, publisherName, publishedAt, likeCount, downloadCount, onPublisherClick, selected, onClick, onMore, titleNode, className, }: WordTableButtonProps): import("react").JSX.Element;
@@ -9,7 +9,7 @@ import { formatRelativeTime } from '../../utils/date';
9
9
  import './WordTableButton.css';
10
10
  /**
11
11
  * @public 단어표를 '문서형' 미리보기 카드로 표현하는 컴포넌트.
12
- * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜·개수 + (발행 배지 / 더보기 / 스토어 푸터).
12
+ * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜 + (발행 배지 / 더보기 / 스토어 푸터).
13
13
  */
14
14
  export default function WordTableButton({ items, name, description, createdAt, published, publishedLabel, shared, sharedLabel, isNew, publisherName, publishedAt, likeCount, downloadCount, onPublisherClick, selected, onClick, onMore, titleNode, className, }) {
15
15
  const { t } = useTranslation();
@@ -43,7 +43,7 @@ export function useWordTableDisplaySettings(items) {
43
43
  }
44
44
  Promise.all(items.flatMap(item => item.words.map(w => checkImageExists(w.label, w.homonum))))
45
45
  .then(results => { if (!cancelled)
46
- setHasImage(results.some(Boolean)); });
46
+ setHasImage(results.some(Boolean)); }, () => { }); // 거절은 이미지 없음으로 둔다
47
47
  return () => { cancelled = true; };
48
48
  }, [items]);
49
49
  /* eslint-enable react-hooks/set-state-in-effect */
@@ -23,7 +23,7 @@ export interface WordTableBlockProps {
23
23
  tableName?: string;
24
24
  /** 전달하면 외부 데이터 표시 모드, 없으면 편집 모드 */
25
25
  items?: WordSearchResultItem[];
26
- /** 전달하면 저장 팝업을 내부에서 열고 확인 후 name·items·description을 전달.
26
+ /** 전달하면 저장하기가 저장 팝업(`renderSavePopup` 이 그림 — 없으면 아무것도 뜨지 않는다)을 열고 확인 후 name·items·description을 전달.
27
27
  * saveAsNew=true면 '다른 이름으로 저장'(수정 대신 새 표 생성)을 뜻한다. */
28
28
  onSaveComplete?: (name: string, items: WordSearchResultItem[], description?: string, saveAsNew?: boolean) => void;
29
29
  onLoad?: () => void;
@@ -49,7 +49,7 @@ export interface WordTableBlockProps {
49
49
  editingName?: string;
50
50
  /** 지정하면 인쇄하기 왼쪽에 '취소' 버튼을 표시(PagePopup 닫기 등). */
51
51
  onCancel?: () => void;
52
- /** 전달하면 카드 드래그로 순서 변경 가능 */
52
+ /** 전달하면 카드 드래그로 순서 변경 가능. 쪽이 나뉘면 같은 쪽 안에서만 옮기며, 자리는 전체 목록 기준이다. */
53
53
  onReorder?: (fromIndex: number, toIndex: number) => void;
54
54
  /** display 모드에서도 칸 수 조절 허용 */
55
55
  showCellCount?: boolean;
@@ -1,7 +1,3 @@
1
- /**
2
- * barChartData.ts
3
- * BarChart의 순수 데이터 계층 — 날짜→월/주/일 막대 데이터 집계 (React 무관).
4
- */
5
1
  /** 막대 하나의 데이터 — a/b 두 계열. a === -1 이면 미래(데이터 없음)로 표시하지 않음.
6
2
  * parts: 다계열 stacked용 계열별 값(있으면 a는 합계로 사용). */
7
3
  export interface BarData {
@@ -19,7 +15,9 @@ export type MonthBarData = {
19
15
  weekly: BarData[];
20
16
  daily: BarData[];
21
17
  };
22
- /** 날짜별 카운트 포인트 — a/b 2계열 또는 parts(다계열 stacked, a는 합계) */
18
+ /** 날짜별 카운트 포인트 — a/b 2계열 또는 parts(다계열 stacked, a는 합계).
19
+ * `date` 는 **KST 달력 날짜를 기기 달력 자정 Date 로** 옮긴 것: 시각이면 `parseLocalYmd(toKstYmd(createdAt))`,
20
+ * 서버가 KST 로 집계한 'YYYY-MM-DD' 면 `parseLocalYmd(log_date)`. */
23
21
  export interface ChartPoint {
24
22
  date: Date;
25
23
  a: number;