@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.
- package/CHANGELOG.md +73 -0
- package/dist/components.cjs.js +176 -176
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +182 -182
- package/dist/ihabdevteam-core.d.ts +81 -29
- package/dist/packages/core/src/components/BarChart.d.ts +1 -1
- package/dist/packages/core/src/components/BarChart.js +4 -3
- package/dist/packages/core/src/components/BottomNav.js +4 -0
- package/dist/packages/core/src/components/BulkBar.js +15 -4
- package/dist/packages/core/src/components/Button.js +10 -4
- package/dist/packages/core/src/components/DatePicker.js +5 -1
- package/dist/packages/core/src/components/Dialog.js +3 -1
- package/dist/packages/core/src/components/Dropdown.js +6 -2
- package/dist/packages/core/src/components/FileUpload.js +3 -1
- package/dist/packages/core/src/components/FilterPanel.js +3 -2
- package/dist/packages/core/src/components/PanelRow.js +5 -0
- package/dist/packages/core/src/components/PaymentPlan.d.ts +2 -2
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -1
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +5 -2
- package/dist/packages/core/src/components/Popup/Popup.d.ts +6 -6
- package/dist/packages/core/src/components/Popup/Popup.js +11 -9
- package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +1 -1
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +1 -1
- package/dist/packages/core/src/components/TextField.js +3 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +7 -1
- package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +7 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +52 -2
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +3 -1
- package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -1
- package/dist/packages/core/src/components/Training/useAudioPreview.js +28 -3
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +19 -5
- package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
- package/dist/packages/core/src/components/Word/WordTableButton.d.ts +3 -3
- package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +1 -1
- package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +2 -2
- package/dist/packages/core/src/components/barChartData.d.ts +3 -5
- package/dist/packages/core/src/components/barChartData.js +7 -3
- package/dist/packages/core/src/components/barChartTypes.d.ts +16 -3
- package/dist/packages/core/src/components/barChartTypes.js +27 -13
- package/dist/packages/core/src/components.d.ts +1 -0
- package/dist/packages/core/src/hooks/focusReturn.d.ts +4 -0
- package/dist/packages/core/src/hooks/focusReturn.js +27 -0
- package/dist/packages/core/src/hooks/inputModality.d.ts +2 -0
- package/dist/packages/core/src/hooks/inputModality.js +31 -0
- package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +12 -0
- package/dist/packages/core/src/hooks/useDocumentTitle.js +7 -6
- package/dist/packages/core/src/hooks/useEditableList.js +3 -3
- package/dist/packages/core/src/hooks/useWordMeanings.js +6 -3
- package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +2 -2
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +54 -17
- package/dist/packages/core/src/utils/date.d.ts +18 -0
- package/dist/packages/core/src/utils/date.js +23 -0
- package/dist/packages/core/src/utils/format.js +2 -1
- package/dist/packages/core/src/utils/imageConfig.d.ts +2 -0
- package/dist/packages/core/src/utils/imageConfig.js +34 -5
- package/dist/packages/core/src/utils/josa.js +2 -0
- package/dist/packages/core/src/utils/koreanSyllable.d.ts +5 -0
- package/dist/packages/core/src/utils/koreanSyllable.js +69 -43
- package/dist/packages/core/src/utils/phonemePairSearch.js +17 -0
- package/dist/packages/core/src/utils/textSort.d.ts +10 -0
- package/dist/packages/core/src/utils/textSort.js +16 -11
- package/dist/packages/core/src/utils/timeoutSignal.d.ts +5 -0
- package/dist/packages/core/src/utils/timeoutSignal.js +12 -0
- package/dist/packages/core/src/utils/ttsConfig.js +2 -1
- package/dist/packages/core/src/utils/wordContrast.js +6 -4
- package/dist/packages/core/src/utils/wordTrainingPool.js +14 -5
- package/dist/packages/core/src/utils.d.ts +1 -1
- package/dist/packages/core/src/utils.js +1 -1
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 && !
|
|
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)
|
|
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
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
/** 전달하면 저장
|
|
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;
|