@ihabdevteam/core 0.142.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 +44 -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 +61 -21
- 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/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/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 +1 -1
- package/dist/packages/core/src/components/Popup/Popup.js +5 -2
- package/dist/packages/core/src/components/TextField.js +3 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +3 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +48 -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/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/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
|
@@ -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';
|
|
@@ -157,7 +158,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
157
158
|
audioRef,
|
|
158
159
|
playbackStateRef,
|
|
159
160
|
});
|
|
160
|
-
const { startTTS
|
|
161
|
+
const { startTTS: 합성시작, stopTTS } = useTTSPlayback({
|
|
161
162
|
ttsText,
|
|
162
163
|
playbackRate,
|
|
163
164
|
// TTS 음성 = 문제 소리 = 전체 소리
|
|
@@ -173,6 +174,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
173
174
|
onDoneRef,
|
|
174
175
|
handleEndRef,
|
|
175
176
|
});
|
|
177
|
+
useEffect(() => { stopTTSRef.current = stopTTS; });
|
|
178
|
+
const startTTS = (text) => { 소리잡기('tts'); 합성시작(text); };
|
|
176
179
|
const requestedSources = Array.isArray(soundSrc)
|
|
177
180
|
? soundSrc.filter(Boolean)
|
|
178
181
|
: (soundSrc ? [soundSrc] : []);
|
|
@@ -192,12 +195,50 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
192
195
|
오류" 로만 보고 다시 부르면 **정지를 눌렀는데 소리가 다시 났다**. 시작할 때의 의도가 그대로일 때만 다시 시도한다. */
|
|
193
196
|
const 재생의도Ref = useRef(0);
|
|
194
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); }, []);
|
|
195
235
|
const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
|
|
196
236
|
const 의도 = 재생의도Ref.current;
|
|
197
237
|
for (let i = 0; i < attempts; i++) {
|
|
198
238
|
if (!mountedRef.current || 의도 !== 재생의도Ref.current)
|
|
199
239
|
return false;
|
|
200
240
|
try {
|
|
241
|
+
소리잡기('audio');
|
|
201
242
|
await a.play();
|
|
202
243
|
return true;
|
|
203
244
|
}
|
|
@@ -731,6 +772,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
731
772
|
try {
|
|
732
773
|
if (synth && synth.paused) {
|
|
733
774
|
try {
|
|
775
|
+
소리잡기('tts');
|
|
734
776
|
synth.resume();
|
|
735
777
|
setPlaybackState('playing');
|
|
736
778
|
}
|
|
@@ -847,6 +889,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
847
889
|
const handleStop = () => {
|
|
848
890
|
// 기다리던 트랙 이동·재시도가 정지 뒤에 소리를 내지 않게
|
|
849
891
|
멈춤의도();
|
|
892
|
+
소리놓기();
|
|
850
893
|
if (ttsMode) {
|
|
851
894
|
stopTTS();
|
|
852
895
|
return;
|
|
@@ -949,6 +992,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
949
992
|
const outDelay = 340;
|
|
950
993
|
markCompletedByEnd(true);
|
|
951
994
|
멈춤의도();
|
|
995
|
+
소리놓기();
|
|
952
996
|
try {
|
|
953
997
|
setProgress(1);
|
|
954
998
|
}
|
|
@@ -1094,6 +1138,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1094
1138
|
const fillLeftPercent = Math.max(0, Math.min(100, (progress || 0) * 100));
|
|
1095
1139
|
const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
|
|
1096
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,
|
|
1097
1144
|
// 키보드로 눌렀을 때만 초점을 옮긴다 — 마우스로 누른 사람에게 '재생 / 멈춤' 초점 툴팁이 뜨지 않게
|
|
1098
1145
|
onClick: () => { 듣기초점옮기기Ref.current = 키보드로눌렀나(); hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
|
|
1099
1146
|
// 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
|
|
@@ -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;
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* barChartData.ts
|
|
3
3
|
* BarChart의 순수 데이터 계층 — 날짜→월/주/일 막대 데이터 집계 (React 무관).
|
|
4
|
+
*
|
|
5
|
+
* **날은 KST 달력으로 센다.** 칸은 `getFullYear()/getMonth()/getDate()` 로 나누므로 '지금'도 KST 오늘을 기기 달력 Date 로
|
|
6
|
+
* 옮긴 것(`getTodayKstDate`)을 쓴다 — 전에는 기기 시각이라 해외 기기에서 오늘·이번 주·이번 달이 KST 와 하루 어긋났다.
|
|
4
7
|
*/
|
|
8
|
+
import { getTodayKstDate } from '../utils/date';
|
|
5
9
|
/** 해당 월(0-based)의 첫 월요일 날짜(1~7). 예: 2026-05(금요일 시작) → 4 */
|
|
6
10
|
export function firstMondayOfMonth(year, month0) {
|
|
7
11
|
const dow = new Date(year, month0, 1).getDay(); // 0=일 … 6=토
|
|
@@ -10,7 +14,7 @@ export function firstMondayOfMonth(year, month0) {
|
|
|
10
14
|
/** 오늘이 속한 (월키, 주값) 계산 — 진입 시 기본 선택을 오늘 기준으로 맞춤.
|
|
11
15
|
* 월요일 시작 버킷 기준이며, 첫 월요일 이전 날짜는 이전 달 마지막 주에 속한다. */
|
|
12
16
|
export function todayWeekValue() {
|
|
13
|
-
const now =
|
|
17
|
+
const now = getTodayKstDate();
|
|
14
18
|
let y = now.getFullYear(), m0 = now.getMonth(), date = now.getDate();
|
|
15
19
|
if (date < firstMondayOfMonth(y, m0)) {
|
|
16
20
|
const prevLast = new Date(y, m0, 0); // 이전 달 마지막 날
|
|
@@ -35,7 +39,7 @@ export function todayWeekValue() {
|
|
|
35
39
|
* 다음 달의 오늘 뒤 날은 세지 않는다. 일별(`daily`)은 그 달 날만이다.
|
|
36
40
|
*/
|
|
37
41
|
export function buildMonthChartData(points) {
|
|
38
|
-
const now =
|
|
42
|
+
const now = getTodayKstDate();
|
|
39
43
|
const curKey = `${now.getFullYear()}년 ${now.getMonth() + 1}월`;
|
|
40
44
|
const 달키 = (y, m0) => `${y}년 ${m0 + 1}월`;
|
|
41
45
|
const byMonth = new Map();
|
|
@@ -142,7 +146,7 @@ export function buildMonthChartData(points) {
|
|
|
142
146
|
}
|
|
143
147
|
// 드롭다운에 노출할 월 옵션 (2024년 1월 ~ 현재, 최신순)
|
|
144
148
|
function genMonthOptions() {
|
|
145
|
-
const now =
|
|
149
|
+
const now = getTodayKstDate();
|
|
146
150
|
const curY = now.getFullYear();
|
|
147
151
|
const curM = now.getMonth() + 1;
|
|
148
152
|
const opts = [];
|
|
@@ -88,8 +88,21 @@ export interface BarChartProps {
|
|
|
88
88
|
/** CSS 격자 칸 안에 세운다 — 아래 여백·최소 폭을 풀고 막대를 카드 바닥에 맞춘다. ChartSection `inGrid` 참고. */
|
|
89
89
|
inGrid?: boolean;
|
|
90
90
|
}
|
|
91
|
-
/**
|
|
92
|
-
export
|
|
91
|
+
/** @public getControlDateRange 의 결과 — 기간의 첫날 00:00 KST ~ 끝날 23:59:59.999 KST 순간과, 그 두 날의 KST 달력 날짜 */
|
|
92
|
+
export interface ControlDateRange {
|
|
93
|
+
/** 첫날 00:00:00.000 KST 순간 — 서버에 물을 때 `from.toISOString()` */
|
|
93
94
|
from: Date;
|
|
95
|
+
/** 끝날 23:59:59.999 KST 순간 */
|
|
94
96
|
to: Date;
|
|
95
|
-
|
|
97
|
+
/** 첫날 KST 'YYYY-MM-DD' — 이름표·달력 셈은 `parseLocalYmd(fromYmd)` 로(순간 `from` 을 기기 getter 로 읽으면 해외에서 하루 어긋난다) */
|
|
98
|
+
fromYmd: string;
|
|
99
|
+
/** 끝날 KST 'YYYY-MM-DD' */
|
|
100
|
+
toYmd: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 현재 컨트롤 선택(주/월)의 기간 — 상세보기(기간별 리스트)용. **KST 의 날로 자른다**(해외 기기도 같은 기간).
|
|
104
|
+
*
|
|
105
|
+
* @param offset 몇 기간 앞·뒤인가 — `-1` 이면 앞 달 한 달(달력 달 그대로, 30일 앞이 아니다) · 앞 주 한 주.
|
|
106
|
+
* @public
|
|
107
|
+
*/
|
|
108
|
+
export declare function getControlDateRange(control: BarChartControl, offset?: number): ControlDateRange;
|
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
import { firstMondayOfMonth } from './barChartData';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { getTodayKst, getTodayKstDate, kstStartOfDay } from '../utils/date';
|
|
3
|
+
const ymdOf = (y, m0, d) => {
|
|
4
|
+
const t = new Date(Date.UTC(y, m0, d));
|
|
5
|
+
return `${t.getUTCFullYear()}-${String(t.getUTCMonth() + 1).padStart(2, '0')}-${String(t.getUTCDate()).padStart(2, '0')}`;
|
|
6
|
+
};
|
|
7
|
+
const 범위 = (y, m0, 첫날, 날수) => ({
|
|
8
|
+
from: kstStartOfDay(y, m0, 첫날),
|
|
9
|
+
// 끝은 그날의 **마지막 밀리초까지** — 23:59:59.000 이면 마지막 999ms 에 남은 기록이 목록에서 빠졌다
|
|
10
|
+
to: new Date(kstStartOfDay(y, m0, 첫날 + 날수).getTime() - 1),
|
|
11
|
+
fromYmd: ymdOf(y, m0, 첫날),
|
|
12
|
+
toYmd: ymdOf(y, m0, 첫날 + 날수 - 1),
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* 현재 컨트롤 선택(주/월)의 기간 — 상세보기(기간별 리스트)용. **KST 의 날로 자른다**(해외 기기도 같은 기간).
|
|
16
|
+
*
|
|
17
|
+
* @param offset 몇 기간 앞·뒤인가 — `-1` 이면 앞 달 한 달(달력 달 그대로, 30일 앞이 아니다) · 앞 주 한 주.
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
export function getControlDateRange(control, offset = 0) {
|
|
4
21
|
if (control.period === 'month') {
|
|
5
22
|
const m = control.monthRange.match(/^(\d+)년 (\d+)월$/);
|
|
6
|
-
const
|
|
7
|
-
const y = m ? Number(m[1]) :
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
return
|
|
23
|
+
const today = getTodayKstDate();
|
|
24
|
+
const y = m ? Number(m[1]) : today.getFullYear();
|
|
25
|
+
const m0 = (m ? Number(m[2]) : today.getMonth() + 1) - 1 + offset;
|
|
26
|
+
const 날수 = new Date(Date.UTC(y, m0 + 1, 0)).getUTCDate();
|
|
27
|
+
return 범위(y, m0, 1, 날수);
|
|
11
28
|
}
|
|
12
29
|
const opt = control.weekRanges.find(o => o.value === control.weekRange);
|
|
13
30
|
if (!opt) {
|
|
14
31
|
const n = new Date();
|
|
15
|
-
|
|
32
|
+
const ymd = getTodayKst();
|
|
33
|
+
return { from: n, to: n, fromYmd: ymd, toYmd: ymd };
|
|
16
34
|
}
|
|
17
35
|
const firstMon = firstMondayOfMonth(opt.year, opt.month - 1);
|
|
18
|
-
|
|
19
|
-
const sunday = new Date(monday);
|
|
20
|
-
sunday.setDate(monday.getDate() + 6);
|
|
21
|
-
sunday.setHours(23, 59, 59, 999);
|
|
22
|
-
return { from: monday, to: sunday };
|
|
36
|
+
return 범위(opt.year, opt.month - 1, firstMon + (opt.week + offset) * 7, 7);
|
|
23
37
|
}
|
|
@@ -142,6 +142,7 @@ export { default as BarChart, BarChartControls } from './components/BarChart';
|
|
|
142
142
|
export { useBarChartControl } from './components/useBarChartControl';
|
|
143
143
|
export { buildMonthChartData } from './components/barChartData';
|
|
144
144
|
export { getControlDateRange } from './components/barChartTypes';
|
|
145
|
+
export type { ControlDateRange } from './components/barChartTypes';
|
|
145
146
|
export type { BarChartProps, BarChartControl, BarChartSize, BarData, MonthBarData, ChartPoint } from './components/BarChart';
|
|
146
147
|
export { default as RingChart } from './components/RingChart';
|
|
147
148
|
export type { RingChartProps, RingChartSize, RingChartSegment } from './components/RingChart';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** 창 판과, 그 창이 열릴 때 포커스였던 요소를 적는다 */
|
|
2
|
+
export declare function rememberFocusReturn(panel: Element | null, previouslyFocused: HTMLElement | null): void;
|
|
3
|
+
/** 실제로 돌려줄 곳 — 문서에 붙어 있으면 그대로, 떼어졌으면 그것을 품었던 창이 적어 둔 곳을 따라간다 */
|
|
4
|
+
export declare function resolveFocusReturn(previouslyFocused: HTMLElement | null): HTMLElement | null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 창(Popup·Dialog·TrainingPagePopup)이 닫힐 때 포커스를 돌려줄 곳 — 창끼리 한 표를 쓴다.
|
|
3
|
+
*
|
|
4
|
+
* 창은 열릴 때 그때 포커스였던 요소를 기억했다가 닫힐 때 돌려준다. 그런데 **닫으면서 다음 창을 여는** 흐름
|
|
5
|
+
* (미리보기 → 삭제 확인)에서는 확인 창이 기억한 것이 미리보기 안의 삭제 단추이고, 그 단추는 260ms 뒤
|
|
6
|
+
* 미리보기와 함께 사라진다. 확인 창을 닫으면 떼어진 단추에 focus() 해 아무 일도 없이 body 에 떨어졌다.
|
|
7
|
+
* 각 창의 판(panel) → 그 창이 돌려줄 곳을 적어 두고, 돌려줄 곳이 떼어졌으면 그것을 품었던 창의 돌려줄 곳을 따라 올라간다.
|
|
8
|
+
*/
|
|
9
|
+
const 돌아갈곳표 = new WeakMap();
|
|
10
|
+
/** 창 판과, 그 창이 열릴 때 포커스였던 요소를 적는다 */
|
|
11
|
+
export function rememberFocusReturn(panel, previouslyFocused) {
|
|
12
|
+
if (panel)
|
|
13
|
+
돌아갈곳표.set(panel, previouslyFocused);
|
|
14
|
+
}
|
|
15
|
+
/** 실제로 돌려줄 곳 — 문서에 붙어 있으면 그대로, 떼어졌으면 그것을 품었던 창이 적어 둔 곳을 따라간다 */
|
|
16
|
+
export function resolveFocusReturn(previouslyFocused) {
|
|
17
|
+
let 대상 = previouslyFocused;
|
|
18
|
+
for (let 번 = 0; 대상 && !대상.isConnected && 번 < 10; 번++) {
|
|
19
|
+
let 창 = 대상;
|
|
20
|
+
while (창 && !돌아갈곳표.has(창))
|
|
21
|
+
창 = 창.parentElement;
|
|
22
|
+
if (!창)
|
|
23
|
+
return 대상;
|
|
24
|
+
대상 = 돌아갈곳표.get(창) ?? null;
|
|
25
|
+
}
|
|
26
|
+
return 대상;
|
|
27
|
+
}
|
|
@@ -47,9 +47,12 @@ export function useWordMeanings(rows, fetchMeanings) {
|
|
|
47
47
|
if (row.Meaning)
|
|
48
48
|
memCache.set(meaningKey(row.Word, row.Homonum ?? undefined), row.Meaning);
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
/* 한 줄도 못 받았으면 '뜻 없음' 으로 굳히지 않는다 — 조회 함수는 실패를 빈 배열로 돌려주는 약속이라, 잠깐 끊긴 망이
|
|
51
|
+
그 쪽 낱말들을 새로고침 전까지 '뜻 없음' 으로 묶었다. 받은 게 있으면 그 응답에 빠진 낱말만 없는 것으로 본다. */
|
|
52
|
+
if (got.length > 0)
|
|
53
|
+
for (const k of missing)
|
|
54
|
+
if (!memCache.has(k))
|
|
55
|
+
noMeaning.add(k);
|
|
53
56
|
if (cancelled || got.length === 0)
|
|
54
57
|
return;
|
|
55
58
|
setFetched(v => v + 1);
|
|
@@ -11,9 +11,9 @@ export interface WordFilterSnapshot {
|
|
|
11
11
|
sameStructureOnly: boolean;
|
|
12
12
|
phonemeView: 'char' | 'pron' | 'direct';
|
|
13
13
|
gradeFilter: string[];
|
|
14
|
-
/** 품사
|
|
14
|
+
/** 품사 필터 — 고정 코드(noun·verb·modifier). 라벨(옛 스냅샷)도 받는다. 없으면 되돌릴 때 지금 값을 그대로 둔다. */
|
|
15
15
|
posFilter?: string[];
|
|
16
|
-
/** 어종
|
|
16
|
+
/** 어종 필터 — 고정 코드(native·sino·foreign·mixed). 라벨(옛 스냅샷)도 받는다. 없으면 되돌릴 때 지금 값을 그대로 둔다. */
|
|
17
17
|
wordTypeFilter?: string[];
|
|
18
18
|
}
|
|
19
19
|
/**
|