@ihabdevteam/core 0.135.0 → 0.136.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 +75 -0
- package/dist/components.cjs.js +177 -177
- package/dist/components.esm.css +2 -2
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +181 -181
- package/dist/ihabdevteam-core.d.ts +19 -3
- package/dist/packages/core/src/components/Alert.js +1 -1
- package/dist/packages/core/src/components/Button.css +5 -0
- package/dist/packages/core/src/components/Button.js +3 -3
- package/dist/packages/core/src/components/Checkbox.js +10 -2
- package/dist/packages/core/src/components/CodeBox.js +2 -1
- package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +2 -0
- package/dist/packages/core/src/components/IconGridMenu.js +25 -22
- package/dist/packages/core/src/components/Popup/ListPopup.js +3 -1
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +6 -5
- package/dist/packages/core/src/components/Popup/Popup.css +9 -0
- package/dist/packages/core/src/components/Popup/Popup.d.ts +9 -1
- package/dist/packages/core/src/components/Popup/Popup.js +2 -2
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +5 -3
- package/dist/packages/core/src/components/ProgressBar.js +5 -2
- package/dist/packages/core/src/components/ProgressIndicator.js +6 -1
- package/dist/packages/core/src/components/Rating.js +3 -1
- package/dist/packages/core/src/components/SideNav.js +6 -3
- package/dist/packages/core/src/components/SplashLogo.js +1 -2
- package/dist/packages/core/src/components/StatChart.js +1 -1
- package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +6 -5
- package/dist/packages/core/src/components/Training/TrainingBlockButton.css +57 -0
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +5 -3
- package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +2 -1
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +4 -3
- package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +36 -26
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +7 -4
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -1
- package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -3
- package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +3 -1
- package/dist/packages/core/src/components/Training/trainingMissStats.js +9 -2
- package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
- package/dist/packages/core/src/components/Word/WordTableButton.js +3 -0
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +5 -1
- package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -2
- package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -2
- package/dist/packages/core/src/components/buttonTypes.d.ts +6 -0
- package/dist/packages/core/src/components/useCalendarMonth.js +15 -17
- package/dist/packages/core/src/components/useToastLifecycle.js +7 -2
- package/dist/packages/core/src/context/I18nProvider.js +12 -3
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +13 -2
- package/dist/packages/core/src/i18n/locales/en.json +1 -0
- package/dist/packages/core/src/i18n/locales/ko.json +1 -0
- package/dist/packages/core/src/utils/date.js +26 -5
- package/dist/packages/core/src/utils/speak.js +23 -3
- package/dist/packages/core/src/utils/textSort.js +12 -1
- package/dist/packages/core/src/utils/ttsConfig.js +10 -8
- 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
|
@@ -24,7 +24,8 @@ export default function TrainingPagePopupTitle({ title, subtitle, desc, depth, i
|
|
|
24
24
|
const subtitleText = typeof subtitle === 'string' ? subtitle.trim() : subtitle;
|
|
25
25
|
const hasName = !!(titleText || subtitleText);
|
|
26
26
|
const resolvedTitle = hasName ? (titleText || subtitleText) : t('common.emptyTrainingName', '훈련 이름이 비어 있습니다.');
|
|
27
|
-
|
|
27
|
+
// subtitle 을 이미 제목으로 썼으면(title 이 빔) depth 폴백에 다시 쓰지 않는다 — 같은 글이 두 줄로 찍혔다
|
|
28
|
+
const rawDepth = depthText || (titleText ? subtitle : undefined);
|
|
28
29
|
// 모바일: " · " 또는 " / " 구분자가 있으면 마지막 세그먼트만 사용
|
|
29
30
|
const resolvedDepth = (isMobile && typeof rawDepth === 'string' && (rawDepth.includes(' · ') || rawDepth.includes(' / ')))
|
|
30
31
|
? rawDepth.split(/\s[·/]\s/).pop()
|
|
@@ -41,7 +41,8 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
|
|
|
41
41
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
42
42
|
useEffect(() => {
|
|
43
43
|
setSelectedIndex(null);
|
|
44
|
-
|
|
44
|
+
/* 카드 ref 는 비우지 않는다 — 마운트 때도 도는 이 effect 가 막 붙은 ref 를 지워, 따로 쓴 단계에서는 다시 그려질
|
|
45
|
+
일이 없어 '모두 듣기' 가 소리 하나 없이 끝났다. 빠진 카드는 ref 콜백이 null 을 넣고, 재생은 보이는 칸까지만 돈다. */
|
|
45
46
|
}, [itemsKey]);
|
|
46
47
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
47
48
|
const handlePlayDone = useCallback((item, i) => {
|
|
@@ -66,7 +67,7 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
|
|
|
66
67
|
return;
|
|
67
68
|
setPlayingAll(true);
|
|
68
69
|
try {
|
|
69
|
-
for (const cardRef of cardRefs.current) {
|
|
70
|
+
for (const cardRef of cardRefs.current.slice(0, shown.length)) {
|
|
70
71
|
if (!cardRef)
|
|
71
72
|
continue;
|
|
72
73
|
await withTimeout(cardRef.play(), PLAY_ALL_CARD_TIMEOUT_MS);
|
|
@@ -75,7 +76,7 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
|
|
|
75
76
|
finally {
|
|
76
77
|
setPlayingAll(false);
|
|
77
78
|
}
|
|
78
|
-
}, [playingAll, currentState]);
|
|
79
|
+
}, [playingAll, currentState, shown.length]);
|
|
79
80
|
const handleSelect = useCallback((item, i) => {
|
|
80
81
|
/* 끝낸 단계(done)에서는 고를 수 없다 — 단추는 `!== 'disabled'` 로 열려 있었는데
|
|
81
82
|
기록은 `=== 'active'` 일 때만 나가서, **화면 선택은 바뀌는데 점수는 그대로**였다.
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useMemo, useState, useEffect } from 'react';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
4
5
|
import TrainingStep from './TrainingStep';
|
|
5
6
|
import TrainingWordCard from './TrainingWordCard';
|
|
6
7
|
import './TrainingStep.css';
|
|
7
8
|
import './TrainingStepCard.css';
|
|
8
9
|
/** @public */
|
|
9
|
-
export default function TrainingStepCardOrder({ index, stepIndex, isLast = false, title
|
|
10
|
+
export default function TrainingStepCardOrder({ index, stepIndex, isLast = false, title, choices = [], onDone, onChange, }) {
|
|
11
|
+
const { t } = useTranslation();
|
|
10
12
|
const idx = typeof index === 'number' ? index : -1;
|
|
11
13
|
const currentState = typeof stepIndex !== 'number'
|
|
12
14
|
? 'disabled'
|
|
@@ -17,42 +19,50 @@ export default function TrainingStepCardOrder({ index, stepIndex, isLast = false
|
|
|
17
19
|
: 'disabled';
|
|
18
20
|
// selectedOrder[i] = 선택 순서 (1-based), null = 미선택
|
|
19
21
|
const [selectedOrder, setSelectedOrder] = useState(() => choices.map(() => null));
|
|
20
|
-
// choices 변경 시 초기화 (choicesKey로 내용까지 비교 — 개수만이 아닌 실제 단어 변경도 감지)
|
|
21
|
-
|
|
22
|
+
// choices 변경 시 초기화 (choicesKey로 내용까지 비교 — 개수만이 아닌 실제 단어 변경도 감지).
|
|
23
|
+
// 열쇠는 JSON 으로 — '|' 로 잇고 가르면 낱말에 '|' 가 든 경우 칸 수가 늘어 모두 골라도 끝나지 않았다.
|
|
24
|
+
const choicesKey = JSON.stringify(choices);
|
|
25
|
+
const choiceCount = choices.length;
|
|
22
26
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
23
27
|
useEffect(() => {
|
|
24
|
-
setSelectedOrder(
|
|
28
|
+
setSelectedOrder(Array.from({ length: choiceCount }, () => null));
|
|
29
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
25
30
|
}, [choicesKey]);
|
|
26
31
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
27
32
|
const nextRank = useMemo(() => selectedOrder.filter(v => v !== null).length + 1, [selectedOrder]);
|
|
33
|
+
/* 다음 배열은 updater **밖에서** 셈하고 알린다 — updater 안에서 onChange·onDone 을 부르면 StrictMode 에서 한 번
|
|
34
|
+
클릭에 두 번씩 나갔다. 풀 때도 알린다(전에는 풀면 부모가 옛 순서를 그대로 들고 있었다). */
|
|
28
35
|
const handleSelect = useCallback((i) => {
|
|
29
36
|
if (currentState !== 'active')
|
|
30
37
|
return;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
const prev = selectedOrder;
|
|
39
|
+
let arr;
|
|
40
|
+
if (prev[i] != null) {
|
|
41
|
+
// 이미 선택된 카드 → 해당 순서 이후 선택 모두 해제
|
|
42
|
+
const rank = prev[i];
|
|
43
|
+
arr = prev.map(v => (v !== null && v >= rank ? null : v));
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
38
46
|
// 미선택 카드 → 다음 순서 부여
|
|
47
|
+
arr = [...prev];
|
|
39
48
|
arr[i] = nextRank;
|
|
40
|
-
|
|
49
|
+
}
|
|
50
|
+
setSelectedOrder(arr);
|
|
51
|
+
const payload = { selectedOrder: arr, words: choices, __ts: Date.now() };
|
|
52
|
+
try {
|
|
53
|
+
if (typeof onChange === 'function')
|
|
54
|
+
onChange(payload);
|
|
55
|
+
}
|
|
56
|
+
catch { }
|
|
57
|
+
// 새로 골라 모두 선택됐으면 완료
|
|
58
|
+
if (prev[i] == null && arr.length > 0 && arr.every(v => v !== null)) {
|
|
41
59
|
try {
|
|
42
|
-
if (typeof
|
|
43
|
-
|
|
60
|
+
if (typeof onDone === 'function')
|
|
61
|
+
onDone(payload);
|
|
44
62
|
}
|
|
45
63
|
catch { }
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
onDone(payload);
|
|
51
|
-
}
|
|
52
|
-
catch { }
|
|
53
|
-
}
|
|
54
|
-
return arr;
|
|
55
|
-
});
|
|
56
|
-
}, [currentState, nextRank, onChange, onDone, choices]);
|
|
57
|
-
return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: title, onDone: onDone, children: _jsx("div", { className: "training-step__cards", children: choices.map((word, i) => (_jsx(TrainingWordCard, { item: { word }, disabled: currentState === 'disabled', selectable: true, selected: selectedOrder[i] !== null, orderRank: selectedOrder[i], onSelect: () => handleSelect(i), flipDelay: i * 120, hideLabel: false, hideAudio: true }, `${word}-${i}`))) }) }));
|
|
64
|
+
}
|
|
65
|
+
}, [currentState, selectedOrder, nextRank, onChange, onDone, choices]);
|
|
66
|
+
const resolvedTitle = title ?? t('training.selectInOrder', '들은 순서대로 카드를 선택하세요.');
|
|
67
|
+
return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, onDone: onDone, children: _jsx("div", { className: "training-step__cards", children: choices.map((word, i) => (_jsx(TrainingWordCard, { item: { word }, disabled: currentState === 'disabled', selectable: true, selected: selectedOrder[i] !== null, orderRank: selectedOrder[i], onSelect: () => handleSelect(i), flipDelay: i * 120, hideLabel: false, hideAudio: true }, `${word}-${i}`))) }) }));
|
|
58
68
|
}
|
|
@@ -54,10 +54,13 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
|
|
|
54
54
|
return context;
|
|
55
55
|
return "";
|
|
56
56
|
}, [context]);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
/* 문장이 바뀌면 **그 렌더에서** 가린다 — 타이머로 한 틱 뒤에 가리면, 앞 문제에서 펼쳐 둔 상자에 다음 문제 문장이
|
|
58
|
+
잠깐 드러났다(정답 문장이 번쩍). 렌더 중 상태 조정은 React 가 권하는 꼴이다. */
|
|
59
|
+
const [보던문장, set보던문장] = useState(content);
|
|
60
|
+
if (보던문장 !== content) {
|
|
61
|
+
set보던문장(content);
|
|
62
|
+
setLocalStatus("disabled");
|
|
63
|
+
}
|
|
61
64
|
const displayStatus = state === "disabled" ? localStatus : state;
|
|
62
65
|
const stateClass = displayStatus === "disabled"
|
|
63
66
|
? "training-text-box--disabled"
|
|
@@ -87,5 +87,8 @@ export default function TrainingStepTitle({ title, children, className = "", sta
|
|
|
87
87
|
setPreviewContent(null);
|
|
88
88
|
}
|
|
89
89
|
}, [onPreviewTextBtn, previewText, showPreview]);
|
|
90
|
-
return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive
|
|
90
|
+
return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive,
|
|
91
|
+
// 재생 중에는 native disabled 를 달지 않는다 — 키보드로 누른 단추에 disabled 가 붙으면 초점이 body 로 빠졌다.
|
|
92
|
+
// onDisabledClick 을 주면 Button 은 aria-disabled 로만 막고 클릭은 여기서 버린다.
|
|
93
|
+
onDisabledClick: playAllActive && state !== 'disabled' ? () => { } : undefined })), usePreviewTextBtn && (_jsx("div", { ref: btnRef, children: _jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "eye", label: isMobile ? undefined : t('training.sentencePreview', '문장 미리보기'), ariaLabel: t('training.sentencePreview', '문장 미리보기'), selected: showPreview, onClick: handlePreviewButtonClick, variant: state === 'disabled' ? 'disabled' : 'default', disabled: !previewText && typeof onPreviewTextBtn !== 'function' }) }))] }), showPreview && (_jsx(TextBoxTooltip, { text: (previewContent ?? previewText), label: t('training.sentencePreview', '문장 미리보기'), anchorRect: anchorRect, onClose: handlePreviewClose, closeOnOutsideClick: false, isMobilePopup: true }))] })) : null] }));
|
|
91
94
|
}
|
|
@@ -93,13 +93,19 @@ export default function WordTablePopup({ items, words, title, subtitle, emptyTex
|
|
|
93
93
|
const customFooter = useMemo(() => (renderFooter ? renderFooter({ print: handlePrint, openNewWindow }) : undefined), [renderFooter, handlePrint, openNewWindow]);
|
|
94
94
|
// 메뉴 '인쇄하기' 진입: 팝업이 열리고 표가 렌더된 직후 인쇄를 1회 자동 시작.
|
|
95
95
|
// 짧은 지연은 WordTable 레이아웃 확정용 — 인쇄 iframe 쪽이 이미지 로드는 별도로 기다린다(wordTablePrint).
|
|
96
|
+
/* '했다' 는 **타이머가 실제로 울렸을 때** 적는다 — 걸 때 적으면 StrictMode 의 두 번 실행이나 100ms 안에 인쇄 설정이
|
|
97
|
+
바뀌어(handlePrint 가 새로 만들어져) 앞 타이머가 걷힌 뒤 다시 걸지 못해, 인쇄가 한 번도 안 떴다. 부르는 것은 최신 print. */
|
|
96
98
|
const autoPrintDone = useRef(false);
|
|
99
|
+
const printRef = useRef(handlePrint);
|
|
100
|
+
useEffect(() => { printRef.current = handlePrint; }, [handlePrint]);
|
|
97
101
|
useEffect(() => {
|
|
98
102
|
if (!autoPrint || autoPrintDone.current)
|
|
99
103
|
return;
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
const id = window.setTimeout(() => {
|
|
105
|
+
autoPrintDone.current = true;
|
|
106
|
+
printRef.current();
|
|
107
|
+
}, 100);
|
|
102
108
|
return () => window.clearTimeout(id);
|
|
103
|
-
}, [autoPrint
|
|
109
|
+
}, [autoPrint]);
|
|
104
110
|
return (_jsxs(Popup, { title: title ?? t('keyWords.title', '주요 단어'), subtitle: subtitle, size: "large", className: cx('word-table-popup', className), buttons: footerButtons, footer: customFooter, footerClassName: footerClassName, beforeClose: beforeClose, onClose: onClose, children: [tableItems.length > 0 ? (_jsxs("div", { className: "word-table-popup__body", children: [showSettings && (_jsx(TableFilterPanel, { sections: filterSections, selected: filterSelected, onSelect: handleSelect })), isPaginated ? (_jsx("div", { className: "word-table-popup__pages", children: pages.map((page, i) => (_jsx(WordTable, { items: page, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition, scoring: scoring, scoreIndexOffset: pageStart[i], marks: 표에줄채점, onMarkClick: handleMarkClick }, i))) })) : (_jsx("div", { className: "word-table-popup__table", children: _jsx(WordTable, { items: tableItems, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: contentRef, fitHeight: true, scoring: scoring, marks: 표에줄채점, onMarkClick: handleMarkClick }) }))] })) : (_jsx("p", { className: "word-table-popup__empty body-3", children: emptyText ?? t('keyWords.empty', '추출된 주요 단어가 없습니다.') })), _jsx(Toast, { message: toastMsg, position: "top", onClose: handleToastClose }, toastKey)] }));
|
|
105
111
|
}
|
|
@@ -41,8 +41,10 @@ export interface TrainingBlockButtonProps {
|
|
|
41
41
|
* - `small`: 120×120px 축소 카드 (행 펼치기 영역 등 좁은 공간용)
|
|
42
42
|
* - `list`: 너비 200px · 최소 높이 68px 가로형 항목
|
|
43
43
|
* - `compact`: 툴팁 등 좁은 공간용 소형 리스트 항목 (전체 너비 × 높이 40px, 작은 아이콘)
|
|
44
|
+
* - `row`: 전체 너비 가로 행 — [아이콘][유형·제목][문항수]. 미리 듣기 단추는 없다.
|
|
45
|
+
* 좁은 화면에서 정사각형 카드 대신 쓴다(카드는 폭이 줄면 내용이 눌린다). 유형 줄은 `showTypeLabel` 일 때.
|
|
44
46
|
*/
|
|
45
|
-
size?: 'square' | 'small' | 'list' | 'compact';
|
|
47
|
+
size?: 'square' | 'small' | 'list' | 'compact' | 'row';
|
|
46
48
|
renderPopup?: (props: TrainingBlockButtonPopupProps) => ReactNode;
|
|
47
49
|
completedIds?: Set<string>;
|
|
48
50
|
/** 선택 상태 ID Set. 포함된 항목(완료가 아닌 것)에 체크 배지를 표시 — 완료와 같은 배지이고, `hideCheckBadge` 면 숨는다. */
|
|
@@ -57,8 +57,15 @@ export function summarizeMisses(records) {
|
|
|
57
57
|
cur.lastSelected = r.selected;
|
|
58
58
|
/* 세트는 **최근 것이 앞**이어야 한다 — 다음 훈련을 짤 때 옛 세트를 먼저 집으면
|
|
59
59
|
그 사이 세트가 바뀌었을 수 있다. 시간순으로 훑고 있으니 앞에 넣는다. */
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
/* 이미 있던 세트를 다시 풀었으면 **빼서 다시 앞으로** — 건너뛰기만 하면 A→B→A 순서에서 [B, A] 로 남아
|
|
61
|
+
복습이 가장 최근 세트(A) 대신 옛 세트(B)로 짜였다. */
|
|
62
|
+
if (r.setId) {
|
|
63
|
+
const at = cur.setIds.indexOf(r.setId);
|
|
64
|
+
if (at > 0)
|
|
65
|
+
cur.setIds.splice(at, 1);
|
|
66
|
+
if (at !== 0)
|
|
67
|
+
cur.setIds.unshift(r.setId);
|
|
68
|
+
}
|
|
62
69
|
모음.set(key, cur);
|
|
63
70
|
}
|
|
64
71
|
const items = [...모음.entries()].map(([key, v]) => ({
|
|
@@ -20,7 +20,7 @@ import { formatYmd, getTodayKst } from '../../utils/date';
|
|
|
20
20
|
import { getSavedTables } from '../../utils/savedWordTables';
|
|
21
21
|
import { devWarnOnce } from '../../utils/devWarn';
|
|
22
22
|
import { useWordTablePrint } from './useWordTablePrint';
|
|
23
|
-
import { CELL_COUNTS } from './wordTableBlockTypes';
|
|
23
|
+
import { CELL_COUNTS, ITEMS_PER_PAGE_OPTIONS } from './wordTableBlockTypes';
|
|
24
24
|
// ── 컴포넌트 ───────────────────────────────────────────────
|
|
25
25
|
/** @public */
|
|
26
26
|
export default function WordTableBlock({ title, titleIcon, disc, items, onSaveComplete, onLoad, onRemoveItem, onReset, resetLabel, saveButtonLabel, secondarySaveButtonLabel, onReorder, showCellCount, onAddItem, className, renderSavePopup, renderWordSearch, hideControls, saveOnly, tableName, nameValue, onNameChange, descriptionValue, onDescriptionChange, namePlaceholder, descriptionPlaceholder, existingNames, editingName, onCancel }) {
|
|
@@ -191,7 +191,10 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
191
191
|
setDisplayCellCount(parseInt(value));
|
|
192
192
|
}
|
|
193
193
|
else if (sectionLabel === t(hasPairs ? 'wordTable.filter.itemsPerPagePair' : 'wordTable.filter.itemsPerPageWord')) {
|
|
194
|
-
|
|
194
|
+
/* 목록에 없는 개수(= 그때의 '전체' 칸)를 고르면 **전체** 로 기억한다 — 숫자로 담으면 항목이 늘었을 때 고른 값('10개')이
|
|
195
|
+
선택지([20, 8, 4, 2, 1])에 없어 켜진 칸이 없는데 표는 10개씩 나뉘었다. */
|
|
196
|
+
const n = parseInt(value);
|
|
197
|
+
setItemsPerPageSetting(ITEMS_PER_PAGE_OPTIONS.includes(n) ? n : Number.POSITIVE_INFINITY);
|
|
195
198
|
}
|
|
196
199
|
else if (sectionLabel === t('wordTable.filter.pageNumber', '페이지 번호')) {
|
|
197
200
|
setPageNumberPosition(parsePageNumberPosition(value));
|
|
@@ -16,6 +16,9 @@ export default function WordTableButton({ items, name, description, createdAt, p
|
|
|
16
16
|
const renaming = titleNode != null;
|
|
17
17
|
const hasStoreFooter = publisherName != null || publishedAt != null || likeCount != null || downloadCount != null;
|
|
18
18
|
const handleKey = (e) => {
|
|
19
|
+
// 카드 안 단추('더보기'·게시자)에서 누른 키는 그 단추 몫이다 — 가로채면 카드가 열리고 단추 클릭은 막혔다(pressable 과 같은 규칙)
|
|
20
|
+
if (e.target !== e.currentTarget)
|
|
21
|
+
return;
|
|
19
22
|
if (!renaming && (e.key === 'Enter' || e.key === ' ')) {
|
|
20
23
|
e.preventDefault();
|
|
21
24
|
onClick?.();
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { useMemo, useState, useCallback, useEffect } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { chunkItems } from '../wordTableLayout';
|
|
5
|
+
import { ITEMS_PER_PAGE_OPTIONS } from './wordTableBlockTypes';
|
|
5
6
|
import { getImageResolver, checkImageExists } from '../../utils/imageConfig';
|
|
6
7
|
import { buildCompositionSection, buildOrientationSection, buildBorderSection, buildCutLineSection, buildDivLineSection, buildItemsPerPageSection, buildPageNumberSection, buildTableSettingsSelected, applyTableSettingsSelect, PAGE_NUM_POSITION_VALUE, parsePageNumberPosition, } from './wordTableSettings';
|
|
7
8
|
/**
|
|
@@ -68,7 +69,10 @@ export function useWordTableDisplaySettings(items) {
|
|
|
68
69
|
if (applyTableSettingsSelect(t, value, sectionLabel, { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine }))
|
|
69
70
|
return;
|
|
70
71
|
if (sectionLabel === t('wordTable.filter.itemsPerPagePair', '페이지당 대립쌍 수') || sectionLabel === t('wordTable.filter.itemsPerPageWord', '페이지당 단어 수')) {
|
|
71
|
-
|
|
72
|
+
/* 목록에 없는 개수(= 그때의 '전체' 칸)를 고르면 **전체** 로 기억한다 — 숫자로 담으면 항목이 늘었을 때 고른 값('10개')이
|
|
73
|
+
선택지([20, 8, 4, 2, 1])에 없어 켜진 칸이 없는데 표는 10개씩 나뉘었다. */
|
|
74
|
+
const n = parseInt(value);
|
|
75
|
+
setItemsPerPageSetting(ITEMS_PER_PAGE_OPTIONS.includes(n) ? n : Number.POSITIVE_INFINITY);
|
|
72
76
|
}
|
|
73
77
|
else if (sectionLabel === t('wordTable.filter.pageNumber', '페이지 번호')) {
|
|
74
78
|
setPageNumberPosition(parsePageNumberPosition(value));
|
|
@@ -62,12 +62,17 @@ export function tableTrends(records) {
|
|
|
62
62
|
}
|
|
63
63
|
const out = [];
|
|
64
64
|
for (const v of 모음.values()) {
|
|
65
|
-
|
|
65
|
+
/* 매긴 칸이 하나도 없는 기록은 흐름에서 뺀다 — 정답률 0%로 넣으면 [0, 0.5] 처럼 나아진 적 없는 표가 +50%로 읽혔다
|
|
66
|
+
(summarize 는 같은 경우를 null 로 따로 둔다). 모두 비었으면 그 표는 흐름이 없다. */
|
|
67
|
+
const 매긴수 = (r) => Object.values(r.marks).filter(m => m === 'o' || m === 'triangle' || m === 'x').length;
|
|
68
|
+
const rows = v.rows.filter(r => 매긴수(r) > 0).sort((a, b) => a.scoredAt.localeCompare(b.scoredAt));
|
|
69
|
+
if (rows.length === 0)
|
|
70
|
+
continue;
|
|
66
71
|
const rates = rows.map(r => {
|
|
67
72
|
const vals = Object.values(r.marks);
|
|
68
73
|
const o = vals.filter(m => m === 'o').length;
|
|
69
74
|
const tri = vals.filter(m => m === 'triangle').length;
|
|
70
|
-
return 점수(o, tri,
|
|
75
|
+
return 점수(o, tri, 매긴수(r));
|
|
71
76
|
});
|
|
72
77
|
out.push({
|
|
73
78
|
tableName: v.tableName,
|
|
@@ -17,6 +17,8 @@ function escapeHtml(s) {
|
|
|
17
17
|
export function sanitizeFileName(name) {
|
|
18
18
|
return name.replace(/[\\/:*?"<>|]/g, '_').trim();
|
|
19
19
|
}
|
|
20
|
+
/** 아직 되돌리지 못한 탭 제목 되돌리기(인쇄가 끝나기 전 다음 인쇄가 올 때) */
|
|
21
|
+
let 제목되돌리기 = null;
|
|
20
22
|
/**
|
|
21
23
|
* 캡처한 콘텐츠 엘리먼트들을 화면 밖 숨김 iframe에서 인쇄한다. 전역 @media print(모든
|
|
22
24
|
* .word-table 노출)나 팝업 차단의 간섭 없이 넘긴 엘리먼트만 격리 인쇄한다. WordTableBlock의
|
|
@@ -28,10 +30,21 @@ export function printWordTableElements(elements, paperW, paperH, fileName = '',
|
|
|
28
30
|
return;
|
|
29
31
|
const safeFileName = fileName ? sanitizeFileName(fileName) : '';
|
|
30
32
|
// "PDF로 저장" 기본 파일명은 iframe이 아니라 최상위 탭(document)의 title에서 온다.
|
|
33
|
+
/* 탭 제목은 **어느 쪽 afterprint 가 와도** 되돌린다 — 브라우저에 따라 iframe 인쇄의 print 이벤트가 그 프레임에만 가서,
|
|
34
|
+
최상위에만 걸면 제목이 표 이름으로 남았고, 다음 인쇄가 그 표 이름을 '원래 제목' 으로 잡아 영영 안 돌아왔다.
|
|
35
|
+
되돌리지 못한 앞 인쇄가 있으면 먼저 되돌린 뒤 원래 제목을 잡는다. */
|
|
36
|
+
제목되돌리기?.();
|
|
37
|
+
let restore = null;
|
|
31
38
|
if (safeFileName) {
|
|
32
39
|
const prevTitle = document.title;
|
|
33
40
|
document.title = safeFileName;
|
|
34
|
-
|
|
41
|
+
restore = () => {
|
|
42
|
+
document.title = prevTitle;
|
|
43
|
+
window.removeEventListener('afterprint', restore);
|
|
44
|
+
if (제목되돌리기 === restore)
|
|
45
|
+
제목되돌리기 = null;
|
|
46
|
+
};
|
|
47
|
+
제목되돌리기 = restore;
|
|
35
48
|
window.addEventListener('afterprint', restore);
|
|
36
49
|
}
|
|
37
50
|
const iframe = document.createElement('iframe');
|
|
@@ -45,7 +58,7 @@ export function printWordTableElements(elements, paperW, paperH, fileName = '',
|
|
|
45
58
|
doc.open();
|
|
46
59
|
doc.write(captureHtml(elements, paperW, paperH, true, safeFileName, header));
|
|
47
60
|
doc.close();
|
|
48
|
-
iframe.contentWindow?.addEventListener('afterprint', () => setTimeout(() => iframe.remove(), 0));
|
|
61
|
+
iframe.contentWindow?.addEventListener('afterprint', () => { restore?.(); setTimeout(() => iframe.remove(), 0); });
|
|
49
62
|
}
|
|
50
63
|
// 인쇄 시 PDF로 저장할 때 OS가 제안하는 기본 파일명은 문서 <title>에서 온다.
|
|
51
64
|
export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '', header = '') {
|
|
@@ -87,6 +87,12 @@ export interface ButtonProps {
|
|
|
87
87
|
title?: string;
|
|
88
88
|
items: string[];
|
|
89
89
|
} | null;
|
|
90
|
+
/**
|
|
91
|
+
* 누르는 단추가 아니라 **툴팁을 보여 주는 칩** 이다 — 커서를 손가락이 아닌 기본 화살표로 둔다.
|
|
92
|
+
* 모양(바탕·hover)과 포커스·툴팁은 그대로다. `onClick` 없이 `showTooltipInfo`·`tooltipDescription` 만 다는
|
|
93
|
+
* 자리(점수 칩 등)에 쓴다. 막힌 단추(`disabled`)와는 다르다 — 흐려지지 않는다.
|
|
94
|
+
*/
|
|
95
|
+
tooltipOnly?: boolean;
|
|
90
96
|
iconSize?: 'small' | 'medium' | 'large';
|
|
91
97
|
iconColor?: string;
|
|
92
98
|
/**
|
|
@@ -29,22 +29,20 @@ export function useCalendarMonth(value) {
|
|
|
29
29
|
setViewM(next.m);
|
|
30
30
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
31
|
}, [value]);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}, [viewM]);
|
|
48
|
-
const setViewYM = useCallback((y, m) => { setViewY(y); setViewM(m); }, []);
|
|
32
|
+
/* 달 넘김은 **지금 가리키는 연·월 하나** 에서 센다 — 갈래는 렌더 때의 viewM 으로, 갱신은 함수형으로 나눠 하던 때는
|
|
33
|
+
한 핸들러에서 두 번 부르면(2월에서 prevMonth 두 번) (2026, -1) 이 됐다. 부른 즉시 ref 도 옮겨 둔다. */
|
|
34
|
+
const ymRef = useRef({ y: viewY, m: viewM });
|
|
35
|
+
useEffect(() => { ymRef.current = { y: viewY, m: viewM }; }, [viewY, viewM]);
|
|
36
|
+
const moveMonth = useCallback((d) => {
|
|
37
|
+
const total = ymRef.current.y * 12 + ymRef.current.m + d;
|
|
38
|
+
const y = Math.floor(total / 12);
|
|
39
|
+
const m = total - y * 12;
|
|
40
|
+
ymRef.current = { y, m };
|
|
41
|
+
setViewY(y);
|
|
42
|
+
setViewM(m);
|
|
43
|
+
}, []);
|
|
44
|
+
const prevMonth = useCallback(() => moveMonth(-1), [moveMonth]);
|
|
45
|
+
const nextMonth = useCallback(() => moveMonth(1), [moveMonth]);
|
|
46
|
+
const setViewYM = useCallback((y, m) => { ymRef.current = { y, m }; setViewY(y); setViewM(m); }, []);
|
|
49
47
|
return { viewY, viewM, prevMonth, nextMonth, setViewYM };
|
|
50
48
|
}
|
|
@@ -83,8 +83,13 @@ export function useToastLifecycle({ message, position, duration, onClose }) {
|
|
|
83
83
|
// 이미 보이는 중 → 텍스트만 업데이트, 애니메이션 재실행 없음
|
|
84
84
|
if (showingRef.current) {
|
|
85
85
|
if (DURATION_MS > 0 && !hideTimerRef.current) {
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
/* 멈춘 채(마우스·포커스가 올라가 있는 채) 문구가 바뀌면 시계를 새로 걸지 않는다 — 전에는 멈춤을 풀고 걸어
|
|
87
|
+
읽고 있는 알림이 손 밑에서 사라졌고, 떠날 때의 resume 은 멈춤이 이미 풀려 아무것도 안 했다.
|
|
88
|
+
새 문구의 시간은 **처음부터** 남겨 두고 resume 이 그만큼 돌린다. */
|
|
89
|
+
if (멈춤Ref.current)
|
|
90
|
+
남은Ref.current = DURATION_MS;
|
|
91
|
+
else
|
|
92
|
+
사라짐걸기.current(DURATION_MS);
|
|
88
93
|
}
|
|
89
94
|
lastMessageRef.current = message;
|
|
90
95
|
/* 여기서 건 사라짐 타이머도 떠날 때 걷는다 — 빈 정리 함수라 문구를 바꾼 뒤 언마운트하면 타이머가 살아
|
|
@@ -3,13 +3,22 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { useEffect } from 'react';
|
|
4
4
|
import i18n, { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../i18n';
|
|
5
5
|
import { I18nContext } from './I18nContext';
|
|
6
|
+
function 지원코드(locale) {
|
|
7
|
+
const 있나 = (c) => SUPPORTED_LOCALES.some(l => l.code === c);
|
|
8
|
+
if (있나(locale))
|
|
9
|
+
return locale;
|
|
10
|
+
const 기본 = locale.split(/[-_]/)[0]?.toLowerCase() ?? '';
|
|
11
|
+
return 있나(기본) ? 기본 : DEFAULT_LOCALE;
|
|
12
|
+
}
|
|
6
13
|
/** @public */
|
|
7
14
|
export function I18nProvider({ locale = DEFAULT_LOCALE, children }) {
|
|
15
|
+
/* 지역이 붙은 코드('en-US'·'ko_KR')는 기본 언어로 읽는다 — 전에는 목록에 없다고 기본(ko)으로 떨어져 영어를 달라 했는데
|
|
16
|
+
한국어가 나오고, context 의 locale 은 'en-US' 로 남아 둘이 갈렸다. context 에도 실제로 건 코드를 넣는다. */
|
|
17
|
+
const code = 지원코드(locale);
|
|
8
18
|
useEffect(() => {
|
|
9
|
-
const code = SUPPORTED_LOCALES.find(l => l.code === locale) ? locale : DEFAULT_LOCALE;
|
|
10
19
|
if (i18n.language !== code) {
|
|
11
20
|
i18n.changeLanguage(code);
|
|
12
21
|
}
|
|
13
|
-
}, [
|
|
14
|
-
return (_jsx(I18nContext.Provider, { value: { locale, supportedLocales: SUPPORTED_LOCALES }, children: children }));
|
|
22
|
+
}, [code]);
|
|
23
|
+
return (_jsx(I18nContext.Provider, { value: { locale: code, supportedLocales: SUPPORTED_LOCALES }, children: children }));
|
|
15
24
|
}
|
|
@@ -25,6 +25,8 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
|
|
|
25
25
|
const after = (ms, fn) => { timers.current.push(setTimeout(fn, ms)); };
|
|
26
26
|
useEffect(() => () => clearTimers(), []);
|
|
27
27
|
const answerId = queue[index]?.id ?? null;
|
|
28
|
+
/** 이미 고른 문항 번호 — 새 문항이 대기에 들어가면 풀린다(playQuestion) */
|
|
29
|
+
const 고름잠금 = useRef(null);
|
|
28
30
|
// ── playback: 모든 스피커를 순서대로 ──────────────────────────────────
|
|
29
31
|
const runPlayback = useCallback(() => {
|
|
30
32
|
clearTimers();
|
|
@@ -47,6 +49,7 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
|
|
|
47
49
|
onDoneRef.current?.({ score: scoreRef.current, total: q.length });
|
|
48
50
|
return;
|
|
49
51
|
}
|
|
52
|
+
고름잠금.current = null;
|
|
50
53
|
setIndex(i);
|
|
51
54
|
setSelectedId(null);
|
|
52
55
|
setPhase('playing');
|
|
@@ -82,17 +85,25 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
|
|
|
82
85
|
}, [setScoreTracked]);
|
|
83
86
|
// autoStart / 입력 변경 시 재시작 — start/reset이 여러 상태를 동기 초기화하지만 의도된 동작이다
|
|
84
87
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
88
|
+
/* 배치가 바뀌었는지는 **스피커 id 들**로 본다 — 개수만 보면 수가 같은 다른 배치(HORIZON_7 ↔ PRESET_27 가로)로 바꿔도
|
|
89
|
+
초기화되지 않아 옛 문제 줄이 남고 정답 id 가 새 배치에 없어 무엇을 골라도 오답이었다. */
|
|
90
|
+
const 배치열쇠 = speakers.map((sp) => sp.id).join('|');
|
|
85
91
|
useEffect(() => {
|
|
86
92
|
if (autoStart)
|
|
87
93
|
start();
|
|
88
94
|
else
|
|
89
95
|
reset();
|
|
96
|
+
// 배치·모드가 바뀌거나 떠날 때 앞 판의 예약을 걷는다
|
|
97
|
+
return () => clearTimers();
|
|
90
98
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
91
|
-
}, [mode,
|
|
99
|
+
}, [mode, 배치열쇠, autoStart]);
|
|
92
100
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
93
101
|
const onSelectSpeaker = useCallback((id) => {
|
|
94
|
-
|
|
102
|
+
/* 같은 대기에서 **한 번만** 받는다 — 다시 그려지기 전에 같은 핸들러가 두 번 불리면(렌더 시점의 phase 만 보면) 점수가 +2,
|
|
103
|
+
다음 문제가 두 번 예약됐다. 고르는 순간 ref 로 닫는다. */
|
|
104
|
+
if (phase !== 'waiting' || mode === 'playback' || 고름잠금.current === index)
|
|
95
105
|
return;
|
|
106
|
+
고름잠금.current = index;
|
|
96
107
|
setSelectedId(id);
|
|
97
108
|
const correct = id === queue[index]?.id;
|
|
98
109
|
if (correct)
|
|
@@ -16,12 +16,24 @@
|
|
|
16
16
|
* 영어로 보더라도 가리키는 날은 같아야 한다.
|
|
17
17
|
*/
|
|
18
18
|
const KST = 'Asia/Seoul';
|
|
19
|
+
/* 못 읽는 값(빈 문자열·깨진 날짜)이면 null — 아래 형식 함수들이 화면에 "Invalid Date" 를 그대로 내던 것을 빈 글자로 막는다
|
|
20
|
+
(같은 파일의 toKstYmd·formatYmd 는 이미 막고 있었다) */
|
|
21
|
+
const 읽은날 = (iso) => {
|
|
22
|
+
const d = new Date(iso);
|
|
23
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
24
|
+
};
|
|
19
25
|
export function formatDateKo(iso) {
|
|
20
|
-
|
|
26
|
+
const d = 읽은날(iso);
|
|
27
|
+
if (!d)
|
|
28
|
+
return '';
|
|
29
|
+
return d.toLocaleDateString('ko-KR', { timeZone: KST, year: 'numeric', month: 'short', day: 'numeric' });
|
|
21
30
|
}
|
|
22
31
|
/** "2024년 1월 15일 오후 03:30" (long month + time) */
|
|
23
32
|
export function formatDateLongKo(iso) {
|
|
24
|
-
|
|
33
|
+
const d = 읽은날(iso);
|
|
34
|
+
if (!d)
|
|
35
|
+
return '';
|
|
36
|
+
return d.toLocaleString('ko-KR', {
|
|
25
37
|
timeZone: KST,
|
|
26
38
|
year: 'numeric', month: 'long', day: 'numeric',
|
|
27
39
|
hour: '2-digit', minute: '2-digit',
|
|
@@ -35,12 +47,18 @@ export function formatDateLongKo(iso) {
|
|
|
35
47
|
*/
|
|
36
48
|
export function formatDateLocale(iso, lang) {
|
|
37
49
|
const locale = lang.startsWith('ko') ? 'ko-KR' : 'en-US';
|
|
38
|
-
|
|
50
|
+
const d = 읽은날(iso);
|
|
51
|
+
if (!d)
|
|
52
|
+
return '';
|
|
53
|
+
return d.toLocaleDateString(locale, { timeZone: KST, year: 'numeric', month: 'short', day: 'numeric' });
|
|
39
54
|
}
|
|
40
55
|
/** formatDateLongKo의 로케일 인식 버전 — en: "January 15, 2024, 03:30 PM" */
|
|
41
56
|
export function formatDateLongLocale(iso, lang) {
|
|
42
57
|
const locale = lang.startsWith('ko') ? 'ko-KR' : 'en-US';
|
|
43
|
-
|
|
58
|
+
const d = 읽은날(iso);
|
|
59
|
+
if (!d)
|
|
60
|
+
return '';
|
|
61
|
+
return d.toLocaleString(locale, {
|
|
44
62
|
timeZone: KST,
|
|
45
63
|
year: 'numeric', month: 'long', day: 'numeric',
|
|
46
64
|
hour: '2-digit', minute: '2-digit',
|
|
@@ -48,7 +66,10 @@ export function formatDateLongLocale(iso, lang) {
|
|
|
48
66
|
}
|
|
49
67
|
/** "2024. 01. 15. 오후 03:30" (2-digit month + time) */
|
|
50
68
|
export function formatDatetimeKo(iso) {
|
|
51
|
-
|
|
69
|
+
const d = 읽은날(iso);
|
|
70
|
+
if (!d)
|
|
71
|
+
return '';
|
|
72
|
+
return d.toLocaleString('ko-KR', {
|
|
52
73
|
timeZone: KST,
|
|
53
74
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
54
75
|
hour: '2-digit', minute: '2-digit',
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/* 목소리 목록을 기다리는 말 — 하나만 둔다. 전에는 기다리는 리스너가 부를 때마다 줄을 서고 stopSpeaking 이 치우지 못해,
|
|
2
|
+
`speak('하나'); speak('둘'); stopSpeaking()` 뒤 목소리가 뜨는 순간 멈췄던 말이 모두 나왔다. */
|
|
3
|
+
let 기다림 = null;
|
|
4
|
+
/** voiceschanged 가 끝내 안 오는 환경(목소리 없음)에서도 말은 건다 — 안 그러면 onEnd 가 영영 안 불렸다 */
|
|
5
|
+
const 목소리기다림MS = 1500;
|
|
6
|
+
function 기다림치우기() {
|
|
7
|
+
기다림?.치우기();
|
|
8
|
+
기다림 = null;
|
|
9
|
+
}
|
|
1
10
|
/** @public */
|
|
2
11
|
export function speak(text, { onEnd, rate = 0.9 } = {}) {
|
|
3
12
|
if (typeof window === 'undefined' || typeof SpeechSynthesisUtterance === 'undefined' || !('speechSynthesis' in window)) {
|
|
@@ -5,7 +14,9 @@ export function speak(text, { onEnd, rate = 0.9 } = {}) {
|
|
|
5
14
|
return;
|
|
6
15
|
}
|
|
7
16
|
const synth = window.speechSynthesis;
|
|
17
|
+
기다림치우기();
|
|
8
18
|
const doSpeak = () => {
|
|
19
|
+
기다림치우기();
|
|
9
20
|
synth.cancel();
|
|
10
21
|
const u = new SpeechSynthesisUtterance(text);
|
|
11
22
|
const voices = synth.getVoices();
|
|
@@ -23,13 +34,22 @@ export function speak(text, { onEnd, rate = 0.9 } = {}) {
|
|
|
23
34
|
: null;
|
|
24
35
|
synth.speak(u);
|
|
25
36
|
};
|
|
26
|
-
if (synth.getVoices().length > 0)
|
|
37
|
+
if (synth.getVoices().length > 0) {
|
|
27
38
|
doSpeak();
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const 시계 = setTimeout(doSpeak, 목소리기다림MS);
|
|
42
|
+
synth.addEventListener('voiceschanged', doSpeak, { once: true });
|
|
43
|
+
기다림 = {
|
|
44
|
+
치우기: () => {
|
|
45
|
+
clearTimeout(시계);
|
|
46
|
+
synth.removeEventListener('voiceschanged', doSpeak);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
30
49
|
}
|
|
31
50
|
/** @public */
|
|
32
51
|
export function stopSpeaking() {
|
|
52
|
+
기다림치우기();
|
|
33
53
|
if (typeof window !== 'undefined' && 'speechSynthesis' in window)
|
|
34
54
|
window.speechSynthesis.cancel();
|
|
35
55
|
}
|