@ihabdevteam/core 0.322.0 → 0.324.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 +557 -0
- package/dist/components.cjs.js +183 -183
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +187 -187
- package/dist/ihabdevteam-core.d.ts +55 -17
- package/dist/packages/core/src/components/Accordion.js +3 -3
- package/dist/packages/core/src/components/Alert.js +2 -2
- package/dist/packages/core/src/components/BarChart.js +120 -119
- package/dist/packages/core/src/components/BottomNav.js +18 -18
- package/dist/packages/core/src/components/BulkBar.js +25 -25
- package/dist/packages/core/src/components/Button.js +16 -16
- package/dist/packages/core/src/components/Calendar.js +17 -17
- package/dist/packages/core/src/components/ChartSection.js +30 -30
- package/dist/packages/core/src/components/CountTile.js +38 -38
- package/dist/packages/core/src/components/DatePicker.js +59 -59
- package/dist/packages/core/src/components/Dialog.js +14 -14
- package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
- package/dist/packages/core/src/components/FilterPanel.js +6 -6
- package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
- package/dist/packages/core/src/components/GameIntro.js +1 -1
- package/dist/packages/core/src/components/Icon.js +2 -2
- package/dist/packages/core/src/components/ItemActions.js +16 -16
- package/dist/packages/core/src/components/LandingHeader.js +6 -6
- package/dist/packages/core/src/components/LineChart.js +24 -24
- package/dist/packages/core/src/components/Loader.js +5 -5
- package/dist/packages/core/src/components/NoticeBox.js +3 -3
- package/dist/packages/core/src/components/PageTitle.js +2 -2
- package/dist/packages/core/src/components/Pagination.js +4 -4
- package/dist/packages/core/src/components/Panel.js +2 -2
- package/dist/packages/core/src/components/PanelRow.js +6 -13
- package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
- package/dist/packages/core/src/components/PaymentPlan.js +5 -5
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +18 -25
- package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +7 -7
- package/dist/packages/core/src/components/Popup/Popup.js +27 -27
- package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
- package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
- package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
- package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
- package/dist/packages/core/src/components/Rating.js +20 -20
- package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
- package/dist/packages/core/src/components/RingChart.js +5 -5
- package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
- package/dist/packages/core/src/components/SegmentedControl.js +34 -34
- package/dist/packages/core/src/components/SideNav.js +37 -37
- package/dist/packages/core/src/components/Slider.js +2 -2
- package/dist/packages/core/src/components/SoundCompass.js +5 -5
- package/dist/packages/core/src/components/Table.js +257 -98
- package/dist/packages/core/src/components/Tabs.js +12 -12
- package/dist/packages/core/src/components/TextField.js +34 -34
- package/dist/packages/core/src/components/Toast.js +6 -6
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
- package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
- package/dist/packages/core/src/components/TopBanner.js +28 -28
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
- package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
- package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingBlock.js +11 -11
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
- package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
- package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
- package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +81 -81
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +127 -127
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
- package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
- package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
- package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
- package/dist/packages/core/src/components/Training/oneShotSound.js +13 -13
- package/dist/packages/core/src/components/Training/trainingMissStats.js +20 -20
- package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
- package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
- package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
- package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
- package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
- package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
- package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
- package/dist/packages/core/src/components/UpdateAlert.js +6 -6
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
- package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +10 -10
- package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
- package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
- package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
- package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
- package/dist/packages/core/src/components/Word/wordScoreStats.js +15 -15
- package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
- package/dist/packages/core/src/components/Word/wordTableRecommend.js +11 -11
- package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
- package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
- package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
- package/dist/packages/core/src/components/WordTable.js +130 -130
- package/dist/packages/core/src/components/barChartData.d.ts +16 -1
- package/dist/packages/core/src/components/barChartData.js +49 -29
- package/dist/packages/core/src/components/barChartTypes.js +8 -8
- package/dist/packages/core/src/components/tableTrackList.js +11 -11
- package/dist/packages/core/src/components/updateAlertStack.js +28 -28
- package/dist/packages/core/src/components/useBarChartControl.js +4 -6
- package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
- package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
- package/dist/packages/core/src/components/wordTableLayout.d.ts +17 -4
- package/dist/packages/core/src/components/wordTableLayout.js +5 -5
- package/dist/packages/core/src/context/I18nProvider.js +6 -6
- package/dist/packages/core/src/hooks/focusReturn.js +27 -27
- package/dist/packages/core/src/hooks/inputModality.js +13 -13
- package/dist/packages/core/src/hooks/touchToggle.js +39 -39
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
- package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
- package/dist/packages/core/src/hooks/useEditableList.js +4 -4
- package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
- package/dist/packages/core/src/hooks/useFontScale.js +6 -6
- package/dist/packages/core/src/hooks/useIcon.js +7 -7
- package/dist/packages/core/src/hooks/useListControls.js +2 -2
- package/dist/packages/core/src/hooks/usePageResetOnChange.js +3 -3
- package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
- package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
- package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
- package/dist/packages/core/src/hooks/useTablePaging.d.ts +0 -11
- package/dist/packages/core/src/hooks/useTablePaging.js +29 -34
- package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
- package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
- package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -7
- package/dist/packages/core/src/i18n/locales/en.json +14 -2
- package/dist/packages/core/src/i18n/locales/ko.json +13 -1
- package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
- package/dist/packages/core/src/utils/audioMeter.js +5 -5
- package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
- package/dist/packages/core/src/utils/audioPeaks.js +18 -18
- package/dist/packages/core/src/utils/blockGrid.js +4 -4
- package/dist/packages/core/src/utils/chunkSkew.js +9 -9
- package/dist/packages/core/src/utils/clipboard.js +3 -3
- package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
- package/dist/packages/core/src/utils/date.js +12 -12
- package/dist/packages/core/src/utils/devWarn.js +4 -4
- package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
- package/dist/packages/core/src/utils/deviceKind.js +53 -10
- package/dist/packages/core/src/utils/errorMessage.js +7 -7
- package/dist/packages/core/src/utils/filterPanelSummary.js +14 -14
- package/dist/packages/core/src/utils/format.js +4 -4
- package/dist/packages/core/src/utils/getTrainingData.js +9 -9
- package/dist/packages/core/src/utils/imageConfig.js +17 -17
- package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
- package/dist/packages/core/src/utils/navScroll.js +2 -2
- package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
- package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
- package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
- package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
- package/dist/packages/core/src/utils/phonemePairSearch.js +129 -105
- package/dist/packages/core/src/utils/phonemePairSearchTypes.d.ts +2 -0
- package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
- package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
- package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
- package/dist/packages/core/src/utils/safeHref.js +4 -4
- package/dist/packages/core/src/utils/savedWordTables.js +5 -5
- package/dist/packages/core/src/utils/speak.js +12 -12
- package/dist/packages/core/src/utils/textSort.d.ts +0 -10
- package/dist/packages/core/src/utils/textSort.js +38 -48
- package/dist/packages/core/src/utils/theme.js +3 -3
- package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
- package/dist/packages/core/src/utils/ttsConfig.js +2 -2
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +1 -1
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +65 -45
- package/dist/packages/core/src/utils/wordContrast.js +3 -3
- package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
- package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
- package/dist/packages/core/src/utils/wornDate.js +14 -14
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +4 -4
- package/package.json +1 -1
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { compareKorean } from '../../utils/textSort';
|
|
19
19
|
/** 같은 시각 두 행의 차례 — 둘 다 id 가 있을 때만 가린다(숫자는 수 크기, 그 밖은 글자 사전순). 못 가리면 0. */
|
|
20
|
-
function
|
|
20
|
+
function sameTimeOrder(a, b) {
|
|
21
21
|
if (a == null || b == null || a === '' || b === '')
|
|
22
22
|
return 0;
|
|
23
23
|
if (typeof a === 'number' && typeof b === 'number')
|
|
@@ -39,16 +39,16 @@ function 같은시각차례(a, b) {
|
|
|
39
39
|
* (최신 먼저 넘기면 같은 시각 행의 최근이 뒤집힌다. 시각이 다른 행은 차례와 상관없이 맞다.)
|
|
40
40
|
*/
|
|
41
41
|
export function summarizeMisses(records) {
|
|
42
|
-
const
|
|
42
|
+
const byKey = new Map();
|
|
43
43
|
let counted = 0;
|
|
44
44
|
let skippedNoTitle = 0;
|
|
45
45
|
/* 최근 것을 알려면 시간순으로 훑어야 한다. 서버는 최신순으로 주지만 여기서 다시
|
|
46
46
|
정렬한다 — 부르는 쪽의 차례에 기대면 그쪽이 바뀔 때 조용히 어긋난다. */
|
|
47
|
-
const
|
|
48
|
-
.map((r,
|
|
49
|
-
.sort((a, b) => a.r.answeredAt.localeCompare(b.r.answeredAt) ||
|
|
47
|
+
const byTime = records
|
|
48
|
+
.map((r, at) => ({ r, at }))
|
|
49
|
+
.sort((a, b) => a.r.answeredAt.localeCompare(b.r.answeredAt) || sameTimeOrder(a.r.id, b.r.id) || a.at - b.at)
|
|
50
50
|
.map(x => x.r);
|
|
51
|
-
for (const r of
|
|
51
|
+
for (const r of byTime) {
|
|
52
52
|
// 요약·시간 행은 문항이 아니다. 틀림으로 세면 한 판마다 오답이 늘어난다.
|
|
53
53
|
if (r.isCorrect == null)
|
|
54
54
|
continue;
|
|
@@ -60,7 +60,7 @@ export function summarizeMisses(records) {
|
|
|
60
60
|
counted++;
|
|
61
61
|
const id = r.itemId?.trim() || title;
|
|
62
62
|
const key = `${r.trainingType}|${id}`;
|
|
63
|
-
const cur =
|
|
63
|
+
const cur = byKey.get(key) ?? {
|
|
64
64
|
trainingType: r.trainingType, itemId: id, title,
|
|
65
65
|
tried: 0, wrong: 0,
|
|
66
66
|
latestCorrect: r.isCorrect, latestAt: r.answeredAt, lastSelected: r.selected,
|
|
@@ -84,9 +84,9 @@ export function summarizeMisses(records) {
|
|
|
84
84
|
if (at !== 0)
|
|
85
85
|
cur.setIds.unshift(r.setId);
|
|
86
86
|
}
|
|
87
|
-
|
|
87
|
+
byKey.set(key, cur);
|
|
88
88
|
}
|
|
89
|
-
const items = [
|
|
89
|
+
const items = [...byKey.entries()].map(([key, v]) => ({
|
|
90
90
|
key,
|
|
91
91
|
trainingType: v.trainingType,
|
|
92
92
|
itemId: v.itemId,
|
|
@@ -126,13 +126,13 @@ export function nextMisses(items, limit = 8) {
|
|
|
126
126
|
* 못 잡은 것이 많은 유형이 먼저. 같으면 유형 키로 갈라 차례를 못 박는다.
|
|
127
127
|
*/
|
|
128
128
|
export function missesByType(items) {
|
|
129
|
-
const
|
|
129
|
+
const byKey = new Map();
|
|
130
130
|
for (const i of items) {
|
|
131
|
-
const arr =
|
|
131
|
+
const arr = byKey.get(i.trainingType) ?? [];
|
|
132
132
|
arr.push(i);
|
|
133
|
-
|
|
133
|
+
byKey.set(i.trainingType, arr);
|
|
134
134
|
}
|
|
135
|
-
const out = [
|
|
135
|
+
const out = [...byKey.entries()].map(([trainingType, list]) => ({
|
|
136
136
|
trainingType,
|
|
137
137
|
items: list,
|
|
138
138
|
pending: list.filter(i => !i.latestCorrect).length,
|
|
@@ -162,7 +162,7 @@ export const SENTENCE_TRAINING_TYPES = ['LISTEN_IN_NOISE', 'SHORT_STORY', 'LONG_
|
|
|
162
162
|
* 밀렸다"고 해 놓고 두 개짜리 세트가 나온다 — 몇 개를 못 담았는지 함께 돌려준다.
|
|
163
163
|
*/
|
|
164
164
|
export function reviewSetPlan(items) {
|
|
165
|
-
const
|
|
165
|
+
const byKey = new Map();
|
|
166
166
|
let noSet = 0;
|
|
167
167
|
for (const i of items) {
|
|
168
168
|
if (i.latestCorrect)
|
|
@@ -173,17 +173,17 @@ export function reviewSetPlan(items) {
|
|
|
173
173
|
continue;
|
|
174
174
|
}
|
|
175
175
|
const key = `${i.trainingType}|${setId}`;
|
|
176
|
-
const cur =
|
|
176
|
+
const cur = byKey.get(key) ?? { setId, trainingType: i.trainingType, pending: 0 };
|
|
177
177
|
cur.pending++;
|
|
178
|
-
|
|
178
|
+
byKey.set(key, cur);
|
|
179
179
|
}
|
|
180
180
|
/* 못 잡은 것이 많은 세트가 먼저. 같으면 유형·uuid 로 갈라 차례를 못 박는다 —
|
|
181
181
|
같은 자료에 두 번 물어 순서가 다르면 사람은 값이 바뀐 줄 안다. */
|
|
182
|
-
const
|
|
183
|
-
const all = [
|
|
182
|
+
const byOrder = (a, b) => b.pending - a.pending || a.trainingType.localeCompare(b.trainingType) || a.setId.localeCompare(b.setId);
|
|
183
|
+
const all = [...byKey.values()];
|
|
184
184
|
return {
|
|
185
|
-
sentence: all.filter(r => SENTENCE_TRAINING_TYPES.includes(r.trainingType)).sort(
|
|
186
|
-
word: all.filter(r => !SENTENCE_TRAINING_TYPES.includes(r.trainingType)).sort(
|
|
185
|
+
sentence: all.filter(r => SENTENCE_TRAINING_TYPES.includes(r.trainingType)).sort(byOrder),
|
|
186
|
+
word: all.filter(r => !SENTENCE_TRAINING_TYPES.includes(r.trainingType)).sort(byOrder),
|
|
187
187
|
noSet,
|
|
188
188
|
};
|
|
189
189
|
}
|
|
@@ -44,11 +44,11 @@ export function splitTrainingVariants(value) {
|
|
|
44
44
|
}
|
|
45
45
|
return [String(value)];
|
|
46
46
|
}
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
return
|
|
50
|
-
const s = typeof i18n?.t === "function" ? i18n.t(key, { defaultValue:
|
|
51
|
-
return typeof s === "string" && s ? s :
|
|
47
|
+
const labelOf = (key, fallback) => {
|
|
48
|
+
if (!fallback)
|
|
49
|
+
return fallback;
|
|
50
|
+
const s = typeof i18n?.t === "function" ? i18n.t(key, { defaultValue: fallback }) : undefined;
|
|
51
|
+
return typeof s === "string" && s ? s : fallback;
|
|
52
52
|
};
|
|
53
53
|
/** @public */
|
|
54
54
|
export function resolveTrainingPopupMeta(trainingData, depth, fallbackTitle) {
|
|
@@ -71,8 +71,8 @@ export function resolveTrainingPopupMeta(trainingData, depth, fallbackTitle) {
|
|
|
71
71
|
: undefined,
|
|
72
72
|
// 카테고리 이름은 지금 언어로(topic.* · topic.sub.*, 사전에 없으면 등록된 이름)
|
|
73
73
|
iconTooltip: [
|
|
74
|
-
category ?
|
|
75
|
-
subCategory ?
|
|
74
|
+
category ? labelOf(`topic.${category.key}`, category.label) : undefined,
|
|
75
|
+
subCategory ? labelOf(`topic.sub.${subCategory.key}`, subCategory.label) : undefined,
|
|
76
76
|
].filter(Boolean).join(" / ") || undefined,
|
|
77
77
|
};
|
|
78
78
|
}
|
|
@@ -55,7 +55,7 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
55
55
|
const playingRef = useRef(false);
|
|
56
56
|
/* 지금 미리 듣기를 멈추는 함수 — 스피커 자물쇠(currentSound.ts)에 건다. 다른 블록 미리 듣기·훈련 소리가 울리면 이것이 불린다.
|
|
57
57
|
전에는 자물쇠에 참여하지 않아 바구니의 블록 둘을 잇달아 누르면 두 문장이 함께 울렸다. */
|
|
58
|
-
const
|
|
58
|
+
const stopRef = useRef(null);
|
|
59
59
|
const previewBtnRef = useRef(null);
|
|
60
60
|
const [previewHover, setPreviewHover] = useState(false);
|
|
61
61
|
// downloadSound()로 만든 blob URL만 추적 — getSignedOrPublicUrl()이 반환하는 http(s) URL은
|
|
@@ -134,8 +134,8 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
134
134
|
e.stopPropagation();
|
|
135
135
|
e.preventDefault();
|
|
136
136
|
if (playingRef.current) {
|
|
137
|
-
if (
|
|
138
|
-
stopSoundIfOwner(
|
|
137
|
+
if (stopRef.current)
|
|
138
|
+
stopSoundIfOwner(stopRef.current);
|
|
139
139
|
playingRef.current = false;
|
|
140
140
|
try {
|
|
141
141
|
previewAudioRef.current?.pause();
|
|
@@ -150,13 +150,13 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
150
150
|
return;
|
|
151
151
|
/* pause() 만으로는 ended 가 오지 않아 기다리던 약속이 풀리지 않는다 — 멈출 때 풀어
|
|
152
152
|
반복을 끝낸다. 그 일을 `playOneShot().stop()` 이 맡는다((85) 3단계로 한 자리에 모았다). */
|
|
153
|
-
let
|
|
154
|
-
const
|
|
153
|
+
let currentSound = null;
|
|
154
|
+
const stop = () => {
|
|
155
155
|
playingRef.current = false;
|
|
156
|
-
|
|
156
|
+
currentSound?.stop();
|
|
157
157
|
};
|
|
158
|
-
|
|
159
|
-
const release = claimSound(
|
|
158
|
+
stopRef.current = stop;
|
|
159
|
+
const release = claimSound(stop);
|
|
160
160
|
playingRef.current = true;
|
|
161
161
|
setPlaying(true);
|
|
162
162
|
for (let i = 0; i < previewUrls.length; i++) {
|
|
@@ -166,16 +166,16 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
166
166
|
if (!url)
|
|
167
167
|
continue;
|
|
168
168
|
setPlayingIndex(i);
|
|
169
|
-
|
|
170
|
-
previewAudioRef.current =
|
|
169
|
+
currentSound = playOneShot(url);
|
|
170
|
+
previewAudioRef.current = currentSound.el;
|
|
171
171
|
/* 끝까지 들었으면 다음 소리로. 못 틀었거나(error·거절) 멈췄으면 거기서 끝낸다 —
|
|
172
172
|
전에는 끝까지 들음은 `resolve`, 못 틀음은 `reject`, 멈춤은 또 `resolve` 라 길이 셋이었다. */
|
|
173
|
-
if (!(await
|
|
173
|
+
if (!(await currentSound.done))
|
|
174
174
|
break;
|
|
175
175
|
}
|
|
176
176
|
release();
|
|
177
|
-
if (
|
|
178
|
-
|
|
177
|
+
if (stopRef.current === stop) {
|
|
178
|
+
stopRef.current = null;
|
|
179
179
|
playingRef.current = false;
|
|
180
180
|
setPlaying(false);
|
|
181
181
|
setPlayingIndex(-1);
|
|
@@ -184,8 +184,8 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
184
184
|
// Pause and release audio on unmount
|
|
185
185
|
useEffect(() => {
|
|
186
186
|
return () => {
|
|
187
|
-
if (
|
|
188
|
-
stopSoundIfOwner(
|
|
187
|
+
if (stopRef.current)
|
|
188
|
+
stopSoundIfOwner(stopRef.current);
|
|
189
189
|
playingRef.current = false;
|
|
190
190
|
try {
|
|
191
191
|
previewAudioRef.current?.pause();
|
|
@@ -21,35 +21,35 @@ export function useFloatingFocus(open, findRoot, returnTo) {
|
|
|
21
21
|
if (!open || typeof document === 'undefined')
|
|
22
22
|
return;
|
|
23
23
|
let raf = null;
|
|
24
|
-
let
|
|
25
|
-
const
|
|
24
|
+
let framesLeft = 10; // Tooltip 은 위치를 잰 뒤에야 보인다 — 보이기 전 focus() 는 먹지 않아 몇 틀 다시 해 본다
|
|
25
|
+
const tryFocus = () => {
|
|
26
26
|
raf = null;
|
|
27
27
|
const root = findRootRef.current();
|
|
28
|
-
const
|
|
28
|
+
const firstTarget = root
|
|
29
29
|
? Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).find((el) => !el.closest('[aria-hidden="true"]'))
|
|
30
30
|
: undefined;
|
|
31
|
-
if (
|
|
31
|
+
if (firstTarget) {
|
|
32
32
|
try {
|
|
33
|
-
|
|
33
|
+
firstTarget.focus();
|
|
34
34
|
}
|
|
35
35
|
catch (e) { /* 떼어진 노드 */ }
|
|
36
|
-
if (document.activeElement ===
|
|
36
|
+
if (document.activeElement === firstTarget)
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
39
|
-
if (
|
|
40
|
-
raf = requestAnimationFrame(
|
|
39
|
+
if (--framesLeft > 0)
|
|
40
|
+
raf = requestAnimationFrame(tryFocus);
|
|
41
41
|
};
|
|
42
|
-
raf = requestAnimationFrame(
|
|
42
|
+
raf = requestAnimationFrame(tryFocus);
|
|
43
43
|
return () => {
|
|
44
44
|
if (raf !== null)
|
|
45
45
|
cancelAnimationFrame(raf);
|
|
46
46
|
// 닫혔다(또는 떠난다) — 초점이 창과 함께 사라져 body 로 떨어졌을 때만 연 단추로 돌려준다
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
47
|
+
const active = document.activeElement;
|
|
48
|
+
if (!active || active === document.body || !document.contains(active) || !!active.closest('.tooltip-content')) {
|
|
49
|
+
const returnButton = returnToRef.current();
|
|
50
|
+
if (returnButton) {
|
|
51
51
|
try {
|
|
52
|
-
|
|
52
|
+
returnButton.focus();
|
|
53
53
|
}
|
|
54
54
|
catch (e) { /* 떼어진 노드 */ }
|
|
55
55
|
}
|
|
@@ -38,7 +38,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, sequence
|
|
|
38
38
|
/* **"지금 시간표가 도는가" 는 진행기에게 묻는다**((85) 2c, 2026-10-06). 전에는 자리를
|
|
39
39
|
받아 **제 손으로 셈했다**(`p.phase !== 'sound' && p.at > 0`) — 같은 셈이 틱·토글·여기
|
|
40
40
|
세 벌이었다. 셈이 한 자리에 있어야 "뜸이란 무엇인가" 가 갈라지지 않는다. */
|
|
41
|
-
const
|
|
41
|
+
const inPad = useCallback(() => !!sequencer?.isRunning(), [sequencer]);
|
|
42
42
|
const cancelNoiseFade = useCallback(() => {
|
|
43
43
|
if (noiseFadeRafRef.current !== null) {
|
|
44
44
|
cancelAnimationFrame(noiseFadeRafRef.current);
|
|
@@ -215,7 +215,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, sequence
|
|
|
215
215
|
`뜸중()` 만 지워도 시험 108 개가 다 통과한다. 둘이 같은 뜻이 아니라 **오늘 같이
|
|
216
216
|
갈 뿐**이라 남겨 둔다. 지우려면 "뜸이면 playbackState 가 'playing' 이다" 를 먼저
|
|
217
217
|
문으로 세워라 — 안 세우고 지우면 다음에 그 하나가 안 따라간다. */
|
|
218
|
-
if (mainPlaying ||
|
|
218
|
+
if (mainPlaying || inPad() || playbackStateRef.current === 'playing') {
|
|
219
219
|
misses = 0;
|
|
220
220
|
return;
|
|
221
221
|
}
|
|
@@ -226,7 +226,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, sequence
|
|
|
226
226
|
}
|
|
227
227
|
}, 400);
|
|
228
228
|
return () => clearInterval(iv);
|
|
229
|
-
}, [fadeOutNoise, noiseInUse, audioRef, playbackStateRef,
|
|
229
|
+
}, [fadeOutNoise, noiseInUse, audioRef, playbackStateRef, inPad]);
|
|
230
230
|
// 탭 백그라운드 전환 시 소음 즉시 정지 — 백그라운드 탭에서는 requestAnimationFrame이
|
|
231
231
|
// 멈추거나 크게 스로틀돼 fadeOutNoise의 볼륨 램프가 완주하지 못할 수 있어, 페이드 없이
|
|
232
232
|
// 바로 pause한다.
|
|
@@ -249,14 +249,14 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, sequence
|
|
|
249
249
|
if (!noiseEl || !noiseEl.paused)
|
|
250
250
|
return;
|
|
251
251
|
const a = audioRef.current;
|
|
252
|
-
const mainPlaying = (!!a && !a.paused && !a.ended) ||
|
|
253
|
-
const
|
|
254
|
-
if (mainPlaying &&
|
|
255
|
-
fadeInNoise(
|
|
252
|
+
const mainPlaying = (!!a && !a.paused && !a.ended) || inPad() || playbackStateRef.current === 'playing';
|
|
253
|
+
const current = noiseSettingsRef.current;
|
|
254
|
+
if (mainPlaying && current?.enabled && current.volume > 0)
|
|
255
|
+
fadeInNoise(current.volume);
|
|
256
256
|
};
|
|
257
257
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
258
258
|
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
259
|
-
}, [cancelNoiseFade, fadeInNoise, audioRef, playbackStateRef,
|
|
259
|
+
}, [cancelNoiseFade, fadeInNoise, audioRef, playbackStateRef, inPad]);
|
|
260
260
|
// noiseSettingsRef 최신화 (재생 이벤트 핸들러 클로저에서 사용)
|
|
261
261
|
useEffect(() => {
|
|
262
262
|
noiseSettingsRef.current = noisePlayer
|
|
@@ -264,8 +264,8 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, sequence
|
|
|
264
264
|
: null;
|
|
265
265
|
}, [noisePlayer, noisePlayer?.settings.enabled, noiseVolume, noisePlayer?.settings.type]);
|
|
266
266
|
/* 새 소음 파일의 canplay 를 기다리는 핸들러 — 다음 종류 변경·떠날 때 뗀다(옛 파일 기다림이 새 설정 위에서 울리지 않게) */
|
|
267
|
-
const
|
|
268
|
-
useEffect(() => () => {
|
|
267
|
+
const canplayOffRef = useRef(null);
|
|
268
|
+
useEffect(() => () => { canplayOffRef.current?.(); canplayOffRef.current = null; }, []);
|
|
269
269
|
// 소음 설정 변경 → 오디오 반영
|
|
270
270
|
useEffect(() => {
|
|
271
271
|
const ns = noisePlayer?.settings;
|
|
@@ -292,24 +292,24 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, sequence
|
|
|
292
292
|
noiseEl.src = url;
|
|
293
293
|
noiseEl.load();
|
|
294
294
|
const mainAudio = audioRef.current;
|
|
295
|
-
if ((mainAudio && !mainAudio.paused) ||
|
|
296
|
-
|
|
295
|
+
if ((mainAudio && !mainAudio.paused) || inPad()) {
|
|
296
|
+
canplayOffRef.current?.();
|
|
297
297
|
const h = () => {
|
|
298
298
|
noiseEl.removeEventListener('canplay', h);
|
|
299
|
-
if (
|
|
300
|
-
|
|
299
|
+
if (canplayOffRef.current === off)
|
|
300
|
+
canplayOffRef.current = null;
|
|
301
301
|
// 파일 로드가 끝난 "이 시점"에 메인이 여전히 재생 중인지 재확인 —
|
|
302
302
|
// 로드가 느리면 예약 시점과 실행 시점 사이에 메인이 끝났을 수 있다.
|
|
303
303
|
/* 볼륨·켜짐은 **지금** 설정으로 — 효과 때 붙잡은 값으로 켜면, 불러오는 동안 치료사가 비율을 0.3 으로 내렸거나 소음을
|
|
304
304
|
껐어도 canplay 에서 1 로 다시 켜져 끝까지 났다(난이도가 뜻보다 어려워지고 기록과 들린 소리가 어긋남). */
|
|
305
305
|
const cur = audioRef.current;
|
|
306
|
-
const
|
|
307
|
-
const
|
|
308
|
-
if (
|
|
309
|
-
fadeInNoise(
|
|
306
|
+
const current = noiseSettingsRef.current;
|
|
307
|
+
const running = (!!cur && !cur.paused && !cur.ended) || inPad();
|
|
308
|
+
if (running && current?.enabled && current.volume > 0)
|
|
309
|
+
fadeInNoise(current.volume);
|
|
310
310
|
};
|
|
311
|
-
const
|
|
312
|
-
|
|
311
|
+
const off = () => noiseEl.removeEventListener('canplay', h);
|
|
312
|
+
canplayOffRef.current = off;
|
|
313
313
|
noiseEl.addEventListener('canplay', h);
|
|
314
314
|
}
|
|
315
315
|
});
|
|
@@ -324,7 +324,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, sequence
|
|
|
324
324
|
const mainAudio = audioRef.current;
|
|
325
325
|
// speechSynthesis TTS 경로는 메인 <audio> 없이 재생된다 — 감시 타이머처럼 playbackState 도 본다(안 보면 비율만 바꿔도 소음이 멈췄다)
|
|
326
326
|
// `뜸중()` 은 여기서도 겹옷이다 — 위 감시 타이머 옆 주석 참고.
|
|
327
|
-
const mainPlaying = (!!mainAudio && !mainAudio.paused && !mainAudio.ended) ||
|
|
327
|
+
const mainPlaying = (!!mainAudio && !mainAudio.paused && !mainAudio.ended) || inPad() || playbackStateRef.current === 'playing';
|
|
328
328
|
if (mainPlaying && pendingNoiseUrlRef.current) {
|
|
329
329
|
/* 종류를 바꾼 뒤 150ms 페이드아웃 안에 다른 설정을 바꾸면, 아래 볼륨만 옮기는 갈래가 파일을 바꿀 페이드를 지워 화면의 종류와
|
|
330
330
|
들리는 종류가 문장 끝까지 달랐다 — 기다리는 파일이 있으면 fadeInNoise 가 그것을 끼운다. */
|
|
@@ -358,7 +358,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, sequence
|
|
|
358
358
|
}
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
|
-
}, [noisePlayer?.settings, noisePlayer?.settings.enabled, noiseVolume, noisePlayer?.settings.type, cancelNoiseFade, fadeInNoise, fadeOutNoise, audioRef, playbackStateRef, noiseSettingsRef,
|
|
361
|
+
}, [noisePlayer?.settings, noisePlayer?.settings.enabled, noiseVolume, noisePlayer?.settings.type, cancelNoiseFade, fadeInNoise, fadeOutNoise, audioRef, playbackStateRef, noiseSettingsRef, inPad]);
|
|
362
362
|
// 소음 재생 속도(playbackRate) 반영 — 0.75~1.25. 엘리먼트 속성이라 src 변경에도 유지됨
|
|
363
363
|
useEffect(() => {
|
|
364
364
|
const el = noiseAudioRef.current;
|
|
@@ -29,9 +29,9 @@ export function useObjectUrls() {
|
|
|
29
29
|
}, []);
|
|
30
30
|
/** 계속 쓸 것만 남기고 나머지는 되돌려준다 */
|
|
31
31
|
const keepOnly = useCallback((keep) => {
|
|
32
|
-
const
|
|
32
|
+
const keepSet = new Set([...keep].filter(Boolean));
|
|
33
33
|
urlsRef.current = urlsRef.current.filter(u => {
|
|
34
|
-
if (
|
|
34
|
+
if (keepSet.has(u))
|
|
35
35
|
return true;
|
|
36
36
|
try {
|
|
37
37
|
URL.revokeObjectURL(u);
|
|
@@ -31,7 +31,7 @@ export interface SequencerPosition {
|
|
|
31
31
|
/** 뜸에서 멈춘 자리 — **어느 뜸의 몇 ms 지점**이었나. 이을 때 남은 만큼만 채우는 데 쓴다 */
|
|
32
32
|
export interface SequencerPause {
|
|
33
33
|
phase: Exclude<PadPhase, 'sound'>;
|
|
34
|
-
|
|
34
|
+
elapsed: number;
|
|
35
35
|
}
|
|
36
36
|
export interface PlaybackSequencer {
|
|
37
37
|
/** 틱·소음·토글이 읽는다 — 매 틀 읽히므로 ref 다 */
|
|
@@ -85,7 +85,7 @@ export interface PlaybackSequencer {
|
|
|
85
85
|
* 자리만 그만큼 **뒤로 밀어 둔다** — 그래야 재생바가 멈춘 값에서 이어진다. 안 밀면 남은
|
|
86
86
|
* 시간만 기다리면서 바는 0 부터 다시 긴다(그 어긋남이 (90)·(91) 의 튐이었다).
|
|
87
87
|
*/
|
|
88
|
-
waitPad: (ms: number, phase: Exclude<PadPhase, 'sound'>,
|
|
88
|
+
waitPad: (ms: number, phase: Exclude<PadPhase, 'sound'>, alreadyElapsed?: number) => Promise<boolean>;
|
|
89
89
|
/** 소리 구간으로 들어간다 */
|
|
90
90
|
enterSound: () => void;
|
|
91
91
|
/**
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
3
3
|
export function usePlaybackSequencer({ mountedRef }) {
|
|
4
4
|
const positionRef = useRef({ phase: 'sound', at: 0 });
|
|
5
|
-
const
|
|
6
|
-
const
|
|
5
|
+
const generationRef = useRef(0);
|
|
6
|
+
const padTimerRef = useRef(null);
|
|
7
7
|
/**
|
|
8
8
|
* **그만둘 때 타이머를 걷지 않는다 — 세대로 가린다.**
|
|
9
9
|
*
|
|
@@ -19,45 +19,45 @@ export function usePlaybackSequencer({ mountedRef }) {
|
|
|
19
19
|
* 걷어 버리면 세대를 볼 일이 없다. 둘 중 하나를 골라야 하는 자리였다.)
|
|
20
20
|
*/
|
|
21
21
|
/** 멈출 때의 자리 — 어느 뜸의 몇 ms 지점이었나. 뜸에서 안 멈췄으면 `null` */
|
|
22
|
-
const
|
|
22
|
+
const pausedAtRef = useRef(null);
|
|
23
23
|
/* **`abort()` 보다 먼저 부른다** — `abort()` 는 자리를 비우므로 뒤에 부르면 "어느 구간에서
|
|
24
24
|
멈췄나" 를 이미 잃는다. `pause()` 가 그 자리를 보고 적고, 그다음에 `abort()` 가 걷는다.
|
|
25
25
|
(`abort()` 가 이 적바림까지 지우게 했다가 못이 울렸다 — 멈춘 자리를 바로 지워 버려
|
|
26
26
|
이을 때 앞 소리가 또 났다. `멈춤의도()` 안에서 둘이 잇따라 불린다.) */
|
|
27
27
|
const pause = useCallback(() => {
|
|
28
28
|
const p = positionRef.current;
|
|
29
|
-
const
|
|
29
|
+
const inPad = p.phase !== 'sound' && p.at > 0;
|
|
30
30
|
/* **타이머는 안 걷는다** — 위 긴 설명 그대로다. 걷으면 약속이 안 풀려 뒷정리가 통째로
|
|
31
31
|
안 돈다. 바로 뒤따르는 `abort()` 가 세대를 올리므로 돌던 뜸은 깨어나 거짓을 받고
|
|
32
32
|
조용히 물러난다. 여기서는 **어디까지 갔었나**만 적는다. */
|
|
33
|
-
|
|
34
|
-
? { phase: p.phase,
|
|
33
|
+
pausedAtRef.current = inPad
|
|
34
|
+
? { phase: p.phase, elapsed: Math.max(0, Date.now() - p.at) }
|
|
35
35
|
: null;
|
|
36
36
|
}, []);
|
|
37
37
|
const resume = useCallback(() => {
|
|
38
|
-
const v =
|
|
39
|
-
|
|
38
|
+
const v = pausedAtRef.current;
|
|
39
|
+
pausedAtRef.current = null;
|
|
40
40
|
return v;
|
|
41
41
|
}, []);
|
|
42
42
|
const abort = useCallback(() => {
|
|
43
|
-
|
|
43
|
+
generationRef.current += 1;
|
|
44
44
|
positionRef.current = { phase: 'sound', at: 0 };
|
|
45
45
|
}, []);
|
|
46
|
-
const waitPad = useCallback((ms, phase,
|
|
46
|
+
const waitPad = useCallback((ms, phase, alreadyElapsed = 0) => {
|
|
47
47
|
if (!(ms > 0))
|
|
48
48
|
return Promise.resolve(true);
|
|
49
|
-
const
|
|
49
|
+
const myGeneration = generationRef.current;
|
|
50
50
|
/* 자리를 **이미 흐른 만큼 뒤로 밀어 둔다** — 재생바가 그 값에서 이어진다(위 설명) */
|
|
51
|
-
positionRef.current = { phase, at: Date.now() - Math.max(0,
|
|
51
|
+
positionRef.current = { phase, at: Date.now() - Math.max(0, alreadyElapsed) };
|
|
52
52
|
return new Promise((res) => {
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
55
|
-
|
|
53
|
+
const myTimer = setTimeout(() => {
|
|
54
|
+
if (padTimerRef.current === myTimer)
|
|
55
|
+
padTimerRef.current = null;
|
|
56
56
|
/* **깨어날 때 제 세대인지 본다.** 앞 기다림을 안 걷으므로 둘이 함께 깨어날 수 있는데,
|
|
57
57
|
그때 낡은 쪽은 거짓을 받고 조용히 물러난다 — 사슬이 겹치지 않는 자리가 여기다. */
|
|
58
|
-
res(!!mountedRef.current &&
|
|
58
|
+
res(!!mountedRef.current && myGeneration === generationRef.current);
|
|
59
59
|
}, ms);
|
|
60
|
-
|
|
60
|
+
padTimerRef.current = myTimer;
|
|
61
61
|
});
|
|
62
62
|
}, [mountedRef]);
|
|
63
63
|
const enterSound = useCallback(() => { positionRef.current = { phase: 'sound', at: Date.now() }; }, []);
|
|
@@ -68,14 +68,14 @@ export function usePlaybackSequencer({ mountedRef }) {
|
|
|
68
68
|
const p = positionRef.current;
|
|
69
69
|
return p.phase !== 'sound' && p.at > 0;
|
|
70
70
|
}, []);
|
|
71
|
-
const generation = useCallback(() =>
|
|
72
|
-
const isCurrent = useCallback((gen) => gen ===
|
|
73
|
-
const __bumpGenerationForTest = useCallback(() => {
|
|
71
|
+
const generation = useCallback(() => generationRef.current, []);
|
|
72
|
+
const isCurrent = useCallback((gen) => gen === generationRef.current, []);
|
|
73
|
+
const __bumpGenerationForTest = useCallback(() => { generationRef.current += 1; }, []);
|
|
74
74
|
/* 화면을 떠나면 반드시 걷는다 — 마지막 그물 */
|
|
75
75
|
useEffect(() => () => {
|
|
76
|
-
if (
|
|
77
|
-
clearTimeout(
|
|
78
|
-
|
|
76
|
+
if (padTimerRef.current) {
|
|
77
|
+
clearTimeout(padTimerRef.current);
|
|
78
|
+
padTimerRef.current = null;
|
|
79
79
|
}
|
|
80
80
|
}, []);
|
|
81
81
|
/**
|