@ihabdevteam/core 0.137.0 → 0.139.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 +70 -0
- package/dist/components.cjs.js +175 -175
- 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 +1 -1
- package/dist/packages/core/src/components/Button.js +10 -1
- package/dist/packages/core/src/components/PassCard/PassCard.js +1 -1
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -1
- package/dist/packages/core/src/components/Popup/Popup.js +26 -3
- package/dist/packages/core/src/components/Table.js +4 -1
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +23 -26
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -3
- package/dist/packages/core/src/components/Training/TrainingBlock.js +20 -3
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +30 -6
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +41 -4
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +9 -0
- package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +2 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +26 -6
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +18 -3
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +9 -3
- package/dist/packages/core/src/components/Training/useNoiseFade.js +3 -1
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +4 -0
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +3 -1
- package/dist/packages/core/src/components/WordTable.js +6 -2
- package/dist/packages/core/src/hooks/useEditableList.js +13 -5
- package/dist/packages/core/src/hooks/usePasswordChange.js +7 -0
- package/dist/packages/core/src/utils/speak.js +9 -0
- package/dist/packages/core/src/utils/wordPairIndex.js +11 -3
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import Button from '../Button';
|
|
6
6
|
import Toast from '../Toast';
|
|
@@ -30,7 +30,13 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
|
|
|
30
30
|
storageKey: 'textbox_fontScale',
|
|
31
31
|
});
|
|
32
32
|
const [noticeMessage, setNoticeMessage] = useState('');
|
|
33
|
-
|
|
33
|
+
/* 초점을 가진 '문장 미리보기' 를 누르면 그 단추가 사라진다 — 펼친 상자의 첫 조작 단추로 초점을 옮긴다 */
|
|
34
|
+
const 상자Ref = useRef(null);
|
|
35
|
+
const 펼친뒤초점Ref = useRef(false);
|
|
36
|
+
const handleActivate = useCallback(() => {
|
|
37
|
+
펼친뒤초점Ref.current = !!상자Ref.current && 상자Ref.current.contains(document.activeElement);
|
|
38
|
+
setLocalStatus("active");
|
|
39
|
+
}, []);
|
|
34
40
|
const handleNoticeClose = useCallback(() => setNoticeMessage(''), []);
|
|
35
41
|
const showNotice = (msg) => {
|
|
36
42
|
try {
|
|
@@ -73,5 +79,14 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
|
|
|
73
79
|
fontSize: `${fontSize}px`,
|
|
74
80
|
lineHeight: `${lineHeight}px`,
|
|
75
81
|
}), [fontSize, lineHeight]);
|
|
76
|
-
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (displayStatus === 'disabled' || !펼친뒤초점Ref.current)
|
|
84
|
+
return;
|
|
85
|
+
펼친뒤초점Ref.current = false;
|
|
86
|
+
const 지금 = document.activeElement;
|
|
87
|
+
if (지금 && 지금 !== document.body)
|
|
88
|
+
return;
|
|
89
|
+
상자Ref.current?.querySelector('.training-text-box__buttons button')?.focus();
|
|
90
|
+
}, [displayStatus]);
|
|
91
|
+
return (_jsxs("div", { ref: 상자Ref, className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
|
|
77
92
|
}
|
|
@@ -29,7 +29,8 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
29
29
|
// 단어가 비면 뒷면으로 플립되는데, 그 애니메이션 동안 앞면이 '단어·발음 없이' 돌아가면 어색하다.
|
|
30
30
|
// 플립 트리거(showBack)는 현재 값으로 두되, 앞면에 그릴 내용은 '마지막으로 채워졌던' 값을 유지한다.
|
|
31
31
|
const [front, setFront] = useState({ label, subLabel, colloquialLabel, homonum: item.homonum });
|
|
32
|
-
|
|
32
|
+
// 낱말이 있으면 넷을 **통째로** 견준다 — 낱말만 보면 같은 낱말에 발음이 새로 오거나 동음이의어 번호가 바뀌어도 앞 값이 남았다
|
|
33
|
+
if (label && (front.label !== label || front.subLabel !== subLabel || front.colloquialLabel !== colloquialLabel || front.homonum !== item.homonum)) {
|
|
33
34
|
setFront({ label, subLabel, colloquialLabel, homonum: item.homonum });
|
|
34
35
|
}
|
|
35
36
|
// ── 재생 상태 ──
|
|
@@ -161,6 +162,9 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
161
162
|
둘이 함께 울렸다. speechSynthesis 만 전역이라 음원 없는 낱말에서는 멀쩡해 보였고,
|
|
162
163
|
그래서 증상이 반만 나타났다. 스피커가 하나이므로 자물쇠도 전역에 둔다. */
|
|
163
164
|
const 멈춤 = () => {
|
|
165
|
+
// 남(다른 카드·다음 누름)이 자리를 가져가며 부르면 이 카드는 더 이상 쥐고 있지 않다 — 기다리던 흐름이 이것을 보고 물러선다
|
|
166
|
+
if (쥔소리Ref.current === 멈춤)
|
|
167
|
+
쥔소리Ref.current = null;
|
|
164
168
|
try {
|
|
165
169
|
audioRef.current?.pause();
|
|
166
170
|
}
|
|
@@ -189,7 +193,9 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
189
193
|
if (fallbackResolver && label) {
|
|
190
194
|
try {
|
|
191
195
|
const url = await Promise.resolve(fallbackResolver(label));
|
|
192
|
-
|
|
196
|
+
/* 기다리는 사이 떠났거나 **소리 자리를 남에게 넘겼으면**(다른 카드를 눌렀거나 같은 카드를 다시 눌렀으면) 끝낸다 —
|
|
197
|
+
늦게 온 합성 주소로 틀어 두 소리가 겹쳤고, 같은 카드를 두 번 누르면 두 번 울려 onPlayDone 도 두 번 나갔다. */
|
|
198
|
+
if (!mountedRef.current || 쥔소리Ref.current !== 멈춤)
|
|
193
199
|
return;
|
|
194
200
|
if (url) {
|
|
195
201
|
setPlaying(true);
|
|
@@ -199,7 +205,7 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
199
205
|
}
|
|
200
206
|
catch { }
|
|
201
207
|
}
|
|
202
|
-
if (!mountedRef.current)
|
|
208
|
+
if (!mountedRef.current || 쥔소리Ref.current !== 멈춤)
|
|
203
209
|
return;
|
|
204
210
|
await playSpeechSynthesis();
|
|
205
211
|
}
|
|
@@ -67,7 +67,9 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
67
67
|
// 볼륨은 setElementVolume 이 그래프면 GainNode, 아니면 요소 volume 에 건다(iOS 실기기는 요소 volume 이 안 먹는다).
|
|
68
68
|
try {
|
|
69
69
|
resumeAudioMeter();
|
|
70
|
-
|
|
70
|
+
// 늦게 resume 돼도 버려진 소음 요소의 미터를 다시 등록하지 않는다
|
|
71
|
+
void routeElementAudio(noiseEl).then((an) => { if (noiseAudioRef.current !== noiseEl)
|
|
72
|
+
return; try {
|
|
71
73
|
registerAnalyserRef.current?.('noise', an);
|
|
72
74
|
}
|
|
73
75
|
catch (e) { } });
|
|
@@ -130,6 +130,10 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
130
130
|
}
|
|
131
131
|
catch (e) { }
|
|
132
132
|
};
|
|
133
|
+
/* 합성이 실패하면(synthesis-failed·not-allowed·language-unavailable) end 없이 error 만 온다 — 끝난 것과 같이 정리한다.
|
|
134
|
+
전에는 재생 상태·소음이 그대로 남고 handleEnd·onDone 이 안 불려 문항이 넘어가지 않았다. 정지·다시 시작이 올린 번호의
|
|
135
|
+
error(취소)는 위 onend 와 같이 넘긴다. */
|
|
136
|
+
utt.onerror = () => { utt.onend?.(new Event('end')); };
|
|
133
137
|
utt.onstart = () => {
|
|
134
138
|
try {
|
|
135
139
|
if (ttsRafRef.current) {
|
|
@@ -45,7 +45,9 @@ export default function PhonemeSelectBlock({ sections, selectedList, count, onCo
|
|
|
45
45
|
const hasLabel = !!value && value !== '없음';
|
|
46
46
|
return (_jsxs("span", { className: cx('phoneme-select-block__cell-jamo-part', value ? '' : 'phoneme-select-block__cell-jamo-part--empty', hasLabel ? '' : 'phoneme-select-block__cell-jamo-part--no-label'), children: [_jsx("span", { className: `phoneme-select-block__cell-jamo-char h1${value === '없음' ? ' phoneme-select-block__cell-jamo-char--excluded' : ''}`, children: value === '없음' ? 'x' : (value || placeholder) }), hasLabel && _jsx("span", { className: "phoneme-select-block__cell-jamo-label caption-2", children: displayLabel ?? label })] }, label));
|
|
47
47
|
}), [sections, placeholder]);
|
|
48
|
-
const infoButton = (_jsxs(_Fragment, { children: [_jsx("div", { "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 40, type: isMobile ? 'dot' : undefined, icon: "info", label: isMobile ? undefined : t('wordPairs.caution', '유의사항'),
|
|
48
|
+
const infoButton = (_jsxs(_Fragment, { children: [_jsx("div", { "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 40, type: isMobile ? 'dot' : undefined, icon: "info", label: isMobile ? undefined : t('wordPairs.caution', '유의사항'),
|
|
49
|
+
// 좁은 화면에서는 아이콘만 남는다 — 이름을 늘 준다
|
|
50
|
+
ariaLabel: t('wordPairs.caution', '유의사항'), selected: !!infoAnchorRect, onClick: (e) => toggleInfo(e.currentTarget) }) }), infoAnchorRect && (_jsx(DescriptionTooltip, { title: t('wordPairs.caution', '유의사항'), items: infoNotes, anchorRect: infoAnchorRect, onClose: handleCloseInfo }))] }));
|
|
49
51
|
return (_jsxs(Section, { type: sectionType, title: title, icon: titleIcon, disc: disc, action: infoButton, className: cx('phoneme-select-block', className), children: [beforeContent, (!hideCountControl || (showInfoButton && !(title || disc))) && (_jsxs("div", { className: "phoneme-select-block__controls", children: [!hideCountControl && (_jsx(SegmentedControl, { options: countOptions, value: String(count), onChange: handleCountChange, size: 40, className: "phoneme-select-block__count-control" })), showInfoButton && !(title || disc) && infoButton] })), _jsx("div", { className: "phoneme-select-block__cells", children: pairMode
|
|
50
52
|
? (_jsxs(_Fragment, { children: [_jsx("div", { className: "phoneme-select-block__cell-group", children: Array.from({ length: count }).map((_, pairIdx) => {
|
|
51
53
|
const sel = selectedList[pairIdx * 2] ?? {};
|
|
@@ -31,7 +31,9 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
|
|
|
31
31
|
const audioRef = useRef(null);
|
|
32
32
|
const [scale, setScale] = useState(1);
|
|
33
33
|
const [playing, setPlaying] = useState(false);
|
|
34
|
-
|
|
34
|
+
/* 받은 주소에 **어느 낱말의 것인지** 를 함께 둔다 — 주소만 들고 있으면 같은 칸의 낱말이 바뀐 뒤 새 주소를 받는 동안(또는
|
|
35
|
+
못 받으면) 누를 때 앞 낱말 소리가 났다(틀린 자극). 지금 낱말의 것일 때만 쓴다. */
|
|
36
|
+
const [받은소리, set받은소리] = useState({ for: '', url: '' });
|
|
35
37
|
const [infoAnchor, setInfoAnchor] = useState(null);
|
|
36
38
|
// 발음(subLabel)이 없으면 단어로 표준발음 조회(앱 주입 resolver)
|
|
37
39
|
const pron = (subLabel && subLabel.length > 0) ? subLabel : (getPronunciationResolver()?.(label, homonum) ?? '');
|
|
@@ -55,6 +57,8 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
|
|
|
55
57
|
arr.push(t('wordTable.info.wordType', { v: expandSlashLabel(wordInfo.wordType, 어종이름(t)), defaultValue: '유형: {{v}}' }));
|
|
56
58
|
return arr.length ? arr : null;
|
|
57
59
|
}, [wordInfo, t]);
|
|
60
|
+
const 소리열쇠 = pron || label;
|
|
61
|
+
const audioUrl = 받은소리.for === 소리열쇠 ? 받은소리.url : '';
|
|
58
62
|
useEffect(() => {
|
|
59
63
|
const resolver = getTTSUrlResolver();
|
|
60
64
|
const ttsText = pron || label;
|
|
@@ -62,7 +66,7 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
|
|
|
62
66
|
return;
|
|
63
67
|
let cancelled = false;
|
|
64
68
|
Promise.resolve(resolver(ttsText)).then(url => { if (!cancelled && url)
|
|
65
|
-
|
|
69
|
+
set받은소리({ for: ttsText, url }); });
|
|
66
70
|
return () => { cancelled = true; };
|
|
67
71
|
}, [pron, label]);
|
|
68
72
|
// 컴포넌트 언마운트 시 발음 재생 중단 — audioRef는 <audio> JSX가 아니라 new Audio()로
|
|
@@ -40,12 +40,18 @@ export function useEditableList(emptyItem, initialList = []) {
|
|
|
40
40
|
/* 갱신은 렌더가 아니라 효과에서 한다 — 렌더 중에 쓰면 커밋되지 않을 렌더의 값이 남는다.
|
|
41
41
|
읽는 곳은 바로 아래 동기화 효과뿐이고, 그것이 뒤에 선언돼 있어 순서도 맞다.
|
|
42
42
|
(효과는 선언 순서대로 돈다.) */
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
/* 편집이 **취소로** 끝나면 그 사이 건너뛴 목록을 받는다 — 전에는 initialList 가 바뀔 때만 돌아, 편집 중 들어온 서버 목록이
|
|
44
|
+
취소한 뒤에도 끝내 안 보였다(방문 예정을 고치다 취소하면 그 사이 다른 방문이 빠진 채). commitEdit 으로 끝난 때는
|
|
45
|
+
방금 커밋한 편집을 옛 목록으로 덮지 않도록 한 번 넘긴다(다음 목록이 오면 그때 받는다). */
|
|
46
|
+
const 커밋으로끝남Ref = useRef(false);
|
|
45
47
|
const prevInitialRef = useRef(null);
|
|
46
48
|
useEffect(() => {
|
|
47
|
-
if (
|
|
49
|
+
if (isEditing)
|
|
48
50
|
return;
|
|
51
|
+
if (커밋으로끝남Ref.current) {
|
|
52
|
+
커밋으로끝남Ref.current = false;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
49
55
|
const prev = prevInitialRef.current;
|
|
50
56
|
const same = prev !== null
|
|
51
57
|
&& prev.length === initialList.length
|
|
@@ -56,7 +62,7 @@ export function useEditableList(emptyItem, initialList = []) {
|
|
|
56
62
|
setList(initialList);
|
|
57
63
|
// 고른 항목도 새 목록의 같은 항목(id)으로 옮긴다 — 옛 참조로 남으면 지우기·수정 대상 찾기가 헛돈다
|
|
58
64
|
setSelected(prev => (prev == null ? prev : initialList.find(v => isSameEditableItem(v, prev)) ?? prev));
|
|
59
|
-
}, [initialList]);
|
|
65
|
+
}, [initialList, isEditing]);
|
|
60
66
|
/* 편집 중이 아닐 때는 편집 폼을 고른 항목에 맞춘다 — 취소하거나(서버 거절로) 되돌려도 폼이 저장 안 된 값을 들고 있어,
|
|
61
67
|
상세 화면에 거절된 값이 보이고 다시 '수정하기' 를 누르면 그 값에서 시작했다. */
|
|
62
68
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
@@ -72,7 +78,9 @@ export function useEditableList(emptyItem, initialList = []) {
|
|
|
72
78
|
const commitEdit = useCallback((updater, updated) => {
|
|
73
79
|
setList(updater);
|
|
74
80
|
setSelected(updated);
|
|
75
|
-
|
|
81
|
+
// 편집 중이었을 때만 표시한다(편집이 아닌데 표시하면 다음 목록 한 번을 헛되이 건너뛴다). 같은 값을 넣는 표시라 두 번 불려도 같다
|
|
82
|
+
setIsEditing(prev => { if (prev)
|
|
83
|
+
커밋으로끝남Ref.current = true; return false; });
|
|
76
84
|
}, []);
|
|
77
85
|
const remove = useCallback((item) => {
|
|
78
86
|
setList(prev => prev.filter(v => !isSameEditableItem(v, item)));
|
|
@@ -15,6 +15,9 @@ export function usePasswordChange(onChangePassword) {
|
|
|
15
15
|
const [pwMsg, setPwMsg] = useState(null);
|
|
16
16
|
const [pwLoading, setPwLoading] = useState(false);
|
|
17
17
|
const clearTimerRef = useRef(null);
|
|
18
|
+
/* 처리 중에는 다시 받지 않는다 — 두 번 누르면 변경이 두 번 나가, 먼저 온 성공 뒤에 늦게 온 "현재 비밀번호 틀림" 이
|
|
19
|
+
남아 바뀌었는데 오류 문구가 떴다(말귀 계정 화면 단추는 로딩 중에도 눌린다) */
|
|
20
|
+
const 처리중Ref = useRef(false);
|
|
18
21
|
useEffect(() => {
|
|
19
22
|
return () => {
|
|
20
23
|
if (clearTimerRef.current !== null)
|
|
@@ -26,6 +29,8 @@ export function usePasswordChange(onChangePassword) {
|
|
|
26
29
|
setPwMsg(null);
|
|
27
30
|
}, []);
|
|
28
31
|
const handleChangePw = useCallback(async () => {
|
|
32
|
+
if (처리중Ref.current)
|
|
33
|
+
return;
|
|
29
34
|
if (!pw.current) {
|
|
30
35
|
setPwMsg({ type: 'error', text: t('account.security.password.currentRequired', '현재 비밀번호를 입력하세요.') });
|
|
31
36
|
return;
|
|
@@ -41,6 +46,7 @@ export function usePasswordChange(onChangePassword) {
|
|
|
41
46
|
return;
|
|
42
47
|
}
|
|
43
48
|
setPwLoading(true);
|
|
49
|
+
처리중Ref.current = true;
|
|
44
50
|
// 콜백이 문구 대신 **거절**하면(망 끊김 등) 로딩이 true 로 남아 단추가 영영 잠겼고,
|
|
45
51
|
// reset() 도 로딩은 안 건드려 풀 길이 없었다. 거절도 오류 문구로 보이고 로딩은 늘 내린다.
|
|
46
52
|
let err;
|
|
@@ -51,6 +57,7 @@ export function usePasswordChange(onChangePassword) {
|
|
|
51
57
|
err = getErrorMessage(e);
|
|
52
58
|
}
|
|
53
59
|
finally {
|
|
60
|
+
처리중Ref.current = false;
|
|
54
61
|
setPwLoading(false);
|
|
55
62
|
}
|
|
56
63
|
if (err) {
|
|
@@ -32,6 +32,15 @@ export function speak(text, { onEnd, rate = 0.9 } = {}) {
|
|
|
32
32
|
catch (e) { }
|
|
33
33
|
}
|
|
34
34
|
: null;
|
|
35
|
+
// 합성이 실패하면 end 없이 error 만 온다 — 끝난 것으로 알린다(안 그러면 onEnd 를 기다리는 쪽이 멈춘 채)
|
|
36
|
+
u.onerror = onEnd
|
|
37
|
+
? function () {
|
|
38
|
+
try {
|
|
39
|
+
onEnd();
|
|
40
|
+
}
|
|
41
|
+
catch (e) { }
|
|
42
|
+
}
|
|
43
|
+
: null;
|
|
35
44
|
synth.speak(u);
|
|
36
45
|
};
|
|
37
46
|
if (synth.getVoices().length > 0) {
|
|
@@ -144,6 +144,8 @@ async function buildIndexAsync(items, onProgress) {
|
|
|
144
144
|
onProgress?.(total, total);
|
|
145
145
|
return result;
|
|
146
146
|
}
|
|
147
|
+
/** 캐시를 비우거나 새 빌드를 시작할 때 오른다 — 늦게 끝난 옛 빌드가 캐시에 쓰지 못하게 */
|
|
148
|
+
let 빌드세대 = 0;
|
|
147
149
|
/**
|
|
148
150
|
* @public
|
|
149
151
|
* 단어 리스트로부터 대립쌍 인덱스를 빌드 (또는 캐시된 결과 반환).
|
|
@@ -162,10 +164,15 @@ export async function buildPairIndex(items, options = {}) {
|
|
|
162
164
|
return memIndex;
|
|
163
165
|
}
|
|
164
166
|
}
|
|
167
|
+
/* 세대가 그대로일 때만 캐시에 담는다 — 빌드 중 clearPairIndexCache() 가 불렸거나 더 새 빌드가 시작됐으면, 늦게 끝난 옛
|
|
168
|
+
빌드가 방금 지운 캐시를 되살리거나 새 인덱스를 옛 것으로 덮었다. 부른 쪽에는 만든 것을 그대로 돌려준다. */
|
|
169
|
+
const 내세대 = ++빌드세대;
|
|
165
170
|
const built = await buildIndexAsync(items, options.onProgress);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
if (내세대 === 빌드세대) {
|
|
172
|
+
memIndex = built;
|
|
173
|
+
memSig = sig;
|
|
174
|
+
saveToLocalStorage(built, sig);
|
|
175
|
+
}
|
|
169
176
|
return built;
|
|
170
177
|
}
|
|
171
178
|
/** @public 현재 메모리에 적재된 인덱스 (없으면 localStorage 시도) */
|
|
@@ -182,6 +189,7 @@ export function getPairIndex() {
|
|
|
182
189
|
}
|
|
183
190
|
/** @public 캐시 초기화 */
|
|
184
191
|
export function clearPairIndexCache() {
|
|
192
|
+
빌드세대 += 1;
|
|
185
193
|
memIndex = null;
|
|
186
194
|
memSig = null;
|
|
187
195
|
try {
|