@ihabdevteam/core 0.321.0 → 0.323.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 +617 -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 +108 -66
- 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/HearingDeviceInfo.d.ts +5 -5
- package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
- 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.d.ts +2 -4
- package/dist/packages/core/src/components/PanelRow.js +8 -15
- 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/DeviceListPopup.d.ts +4 -8
- package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +19 -26
- package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +10 -10
- 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.d.ts +7 -4
- 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 +12 -12
- 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 +83 -83
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +128 -128
- 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 +13 -13
- 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.d.ts +1 -1
- package/dist/packages/core/src/components/Training/oneShotSound.js +14 -14
- package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +2 -2
- package/dist/packages/core/src/components/Training/trainingMissStats.js +22 -22
- 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 +18 -18
- 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 +17 -17
- package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
- package/dist/packages/core/src/components/Word/wordTableRecommend.js +13 -13
- 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 +22 -7
- 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.d.ts +1 -1
- package/dist/packages/core/src/hooks/inputModality.js +14 -14
- 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/useDeviceManagement.d.ts +5 -17
- package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
- 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.d.ts +3 -3
- package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
- 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 +7 -7
- package/dist/packages/core/src/hooks/useTablePaging.js +40 -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.d.ts +3 -1
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +14 -12
- package/dist/packages/core/src/i18n/locales/en.json +13 -2
- package/dist/packages/core/src/i18n/locales/ko.json +12 -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.d.ts +2 -2
- package/dist/packages/core/src/utils/devWarn.js +7 -7
- 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.d.ts +1 -1
- package/dist/packages/core/src/utils/filterPanelSummary.js +16 -16
- 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.d.ts +2 -2
- package/dist/packages/core/src/utils/navScroll.js +6 -6
- 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 +118 -102
- 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/pressable.d.ts +1 -1
- package/dist/packages/core/src/utils/pressable.js +3 -3
- 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/sessionTab.d.ts +7 -7
- package/dist/packages/core/src/utils/sessionTab.js +9 -9
- package/dist/packages/core/src/utils/speak.js +12 -12
- package/dist/packages/core/src/utils/textSort.d.ts +5 -5
- package/dist/packages/core/src/utils/textSort.js +47 -47
- 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 +2 -2
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +48 -48
- 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
|
@@ -36,9 +36,9 @@ function audioDurationOf(a) {
|
|
|
36
36
|
try {
|
|
37
37
|
const s = a.seekable;
|
|
38
38
|
if (s && s.length > 0) {
|
|
39
|
-
const
|
|
40
|
-
if (Number.isFinite(
|
|
41
|
-
return
|
|
39
|
+
const end = s.end(s.length - 1);
|
|
40
|
+
if (Number.isFinite(end) && end > 0)
|
|
41
|
+
return end;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
catch { /* seekable 을 못 읽는 환경 */ }
|
|
@@ -60,11 +60,11 @@ function audioDurationOf(a) {
|
|
|
60
60
|
* 틀 사이가 400ms 를 넘으면 **칸이 보이는지와 함께** 한 번 찍는다. 운영 빌드에서는
|
|
61
61
|
* 아무 일도 안 한다.
|
|
62
62
|
*/
|
|
63
|
-
const
|
|
63
|
+
const SLOW_FRAME_MS = 400;
|
|
64
64
|
/** 이만큼 멀쩡했다가 다시 느려지면 **새 고비**로 센다(ms) */
|
|
65
|
-
const
|
|
65
|
+
const SPIKE_GAP_MS = 2000;
|
|
66
66
|
/** 고비를 이만큼까지만 알린다 — 그 뒤로는 조용히 둔다(콘솔이 넘치면 진짜 봐야 할 것이 묻힌다) */
|
|
67
|
-
const
|
|
67
|
+
const SPIKE_LIMIT = 5;
|
|
68
68
|
export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, sequencer, leadInMs = 0, tailMs = 0, }) {
|
|
69
69
|
const rafRef = useRef(null);
|
|
70
70
|
/**
|
|
@@ -78,11 +78,11 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
|
|
|
78
78
|
* 그래서 **고비마다** 다시 말한다: 틀이 2초 넘게 멀쩡하다가 다시 느려지면 새 고비로 센다.
|
|
79
79
|
* 매 틀 찍지는 않는다(한 고비에 한 번) — 콘솔이 넘치면 진짜 봐야 할 것이 묻힌다.
|
|
80
80
|
*/
|
|
81
|
-
const
|
|
81
|
+
const slowSpikesRef = useRef(0);
|
|
82
82
|
/** 마지막으로 느렸던 때. **아직 없으면 `null`** — 0 을 쓰면 안 된다:
|
|
83
83
|
`performance.now()` 는 쪽이 열린 뒤부터 세므로 첫 1~2초 안에 느려지면
|
|
84
84
|
`지금 - 0` 이 고비사이를 못 넘어 **첫 고비를 통째로 놓친다**(시험이 잡았다). */
|
|
85
|
-
const
|
|
85
|
+
const lastSlowAtRef = useRef(null);
|
|
86
86
|
const stop = useCallback(() => {
|
|
87
87
|
if (rafRef.current === null)
|
|
88
88
|
return;
|
|
@@ -93,46 +93,46 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
|
|
|
93
93
|
// 이미 돌고 있으면 겹쳐 걸지 않는다 — 두 사슬이 같은 값을 다투면 마커가 떤다
|
|
94
94
|
if (rafRef.current !== null)
|
|
95
95
|
return;
|
|
96
|
-
let
|
|
96
|
+
let prevFrame = 0;
|
|
97
97
|
const step = () => {
|
|
98
98
|
/* 틀 사이가 벌어졌나 — 값을 그리기 전에 본다(그리는 일과 무관하다) */
|
|
99
99
|
if (isDev) {
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
100
|
+
const now = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
|
|
101
|
+
if (prevFrame > 0 && now - prevFrame > SLOW_FRAME_MS) {
|
|
102
102
|
/** 멀쩡하다가 다시 느려졌으면 **새 고비**다 — 그때 다시 말한다 */
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
let
|
|
103
|
+
const newSpike = lastSlowAtRef.current === null || now - lastSlowAtRef.current > SPIKE_GAP_MS;
|
|
104
|
+
lastSlowAtRef.current = now;
|
|
105
|
+
if (newSpike && slowSpikesRef.current < SPIKE_LIMIT) {
|
|
106
|
+
slowSpikesRef.current += 1;
|
|
107
|
+
const spikes = slowSpikesRef.current;
|
|
108
|
+
let hiddenReason = null;
|
|
109
109
|
try {
|
|
110
|
-
|
|
110
|
+
hiddenReason = typeof document !== 'undefined' ? document.visibilityState : null;
|
|
111
111
|
}
|
|
112
112
|
catch { /* 문서 없는 데 */ }
|
|
113
|
-
devWarnOnce(`progress-ticker-slow-frames#${
|
|
113
|
+
devWarnOnce(`progress-ticker-slow-frames#${spikes}`, `[ihab-core] 진행 사슬의 틀 사이가 ${Math.round(now - prevFrame)}ms 입니다(보통 16ms).`, hiddenReason === 'hidden'
|
|
114
114
|
? '이 칸이 **숨어 있어** 브라우저가 틀을 줄였습니다 — 재생바·파형은 틀마다 그려지므로, 지금 재는 값은 **사람이 보는 화면과 다릅니다.** 칸을 앞으로 꺼내고 다시 재십시오.'
|
|
115
|
-
: '칸은 보이는데 틀이 느립니다 — 같은 화면에서 무거운 일이 도는지 보십시오. 재생바·파형이 끊겨 보이는 것은 이 탓일 수 있습니다.',
|
|
115
|
+
: '칸은 보이는데 틀이 느립니다 — 같은 화면에서 무거운 일이 도는지 보십시오. 재생바·파형이 끊겨 보이는 것은 이 탓일 수 있습니다.', spikes >= SPIKE_LIMIT ? `(${spikes}번째 — 더는 안 알립니다)` : `(${spikes}번째)`);
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
-
else if (
|
|
118
|
+
else if (prevFrame > 0) {
|
|
119
119
|
/* 멀쩡한 틀이 이어지는 동안에는 고비가 안 자란다 — 위 `새고비` 가 시계로만 가름하므로
|
|
120
120
|
여기서 할 일은 없다. 이 가지는 읽는 사람을 위한 자리다. */
|
|
121
121
|
}
|
|
122
|
-
|
|
122
|
+
prevFrame = now;
|
|
123
123
|
}
|
|
124
124
|
try {
|
|
125
125
|
const a = audioRef.current;
|
|
126
|
-
const
|
|
127
|
-
const
|
|
126
|
+
const track = Array.isArray(sourcesRef.current) ? sourcesRef.current[currentIndexRef.current || 0] : undefined;
|
|
127
|
+
const trackDurationKnown = !!track && Number.isFinite(track.duration) && (track.duration ?? 0) > 0;
|
|
128
128
|
// 트랙 길이를 알면 progressOf 가 그것을 쓴다 — <audio> 길이는 모를 때만 본다
|
|
129
|
-
const
|
|
129
|
+
const totalDuration = trackDurationKnown ? 0 : audioDurationOf(a);
|
|
130
130
|
const fill = fillRef.current;
|
|
131
131
|
/* 뜸이 있으면 **뜸을 포함한 시간선**으로 잰다. 없으면(기본 0) 전과 같은 길이다. */
|
|
132
|
-
const
|
|
132
|
+
const hasPad = (leadInMs || 0) > 0 || (tailMs || 0) > 0;
|
|
133
133
|
/* **지금 뜸이 도는가** — 길이를 몰라도 뜸은 제 길이를 안다(아래 참고) */
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
134
|
+
const inPad = hasPad && (sequencer?.positionRef.current?.phase ?? 'sound') !== 'sound';
|
|
135
|
+
if (totalDuration === null && !inPad) {
|
|
136
136
|
/* 길이를 모른다(스트리밍 등으로 duration 이 Infinity·NaN 이고 되감을 수 있는 끝도 없다) —
|
|
137
137
|
마커를 0 에 붙여 두면 "안 움직이는 재생"으로 읽힌다. 진행을 모르니 마커를 숨긴다. */
|
|
138
138
|
try {
|
|
@@ -148,22 +148,22 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
|
|
|
148
148
|
**아무것도 안 그렸다.** 뜸은 제 길이를 아는 구간이다(`leadInMs`·`tailMs` 를
|
|
149
149
|
여기서 받고 있다). 그래서 길이를 모르면 **소리를 0 으로 둔 시간표**로 잰다 —
|
|
150
150
|
`timelineProgress` 에 그 사정을 적어 뒀다. 사용자가 (86) 에서 직접 짚은 자리다. */
|
|
151
|
-
const
|
|
151
|
+
const durationToUse = trackDurationKnown ? track.duration : (totalDuration || 0);
|
|
152
152
|
/* **어림으로 그리는 자리를 스스로 말하게 한다**(2026-10-06).
|
|
153
153
|
위 한 줄(`총길이 === null`)만 보던 것을 **길이가 0 인 것 전부**로 넓혔다 —
|
|
154
154
|
`<audio>` 가 `duration === 0` 을 주는 동안에도 같은 어림을 그리는데 그때는
|
|
155
155
|
말이 없었다. 말귀가 앱에서 두 번 다 길이를 알고 있었다고 재 왔으므로(0.744 ·
|
|
156
156
|
0.72, `readyState=4`) **이 가지는 지금 길에서는 안 타야 한다.** 타면 그 자리를
|
|
157
157
|
알아야 한다 — 안 타는 것을 **재서** 알 수 있게 띄운다. */
|
|
158
|
-
if (isDev &&
|
|
158
|
+
if (isDev && inPad && !(durationToUse > 0)) {
|
|
159
159
|
devWarnOnce('progress-ticker-pad-without-duration', '[ihab-core] 뜸이 도는데 **소리 길이를 모릅니다** — 재생바가 뜸만으로 어림잡아 움직입니다.', '소리 길이 재기(measureAudioDuration)가 0 을 줬거나 `<audio>` 가 아직 안 실렸습니다. 길이를 알게 되면 마커가 그 순간 **뒤로 물러납니다**(어림은 늘 참보다 큽니다). 이 줄이 보이면 그 자리를 알려 주십시오 — 평소 길에서는 안 나와야 합니다.');
|
|
160
160
|
}
|
|
161
|
-
const pos =
|
|
161
|
+
const pos = hasPad
|
|
162
162
|
? progressWithPad({
|
|
163
163
|
phase: sequencer?.positionRef.current?.phase ?? 'sound',
|
|
164
164
|
phaseElapsedMs: sequencer?.positionRef.current ? Math.max(0, Date.now() - sequencer.positionRef.current.at) : 0,
|
|
165
165
|
currentTime: a && typeof a.currentTime === 'number' ? a.currentTime : 0,
|
|
166
|
-
trackDurationSec:
|
|
166
|
+
trackDurationSec: durationToUse,
|
|
167
167
|
leadInMs: leadInMs || 0,
|
|
168
168
|
tailMs: tailMs || 0,
|
|
169
169
|
})
|
|
@@ -173,7 +173,7 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
|
|
|
173
173
|
index: currentIndexRef.current,
|
|
174
174
|
/* 여기 올 때 `총길이` 는 널이 아니다 — 널이면 위에서 숨기고 왔다(뜸이 없으면 뜸중도 거짓이다).
|
|
175
175
|
타입만 좁혀 준다. */
|
|
176
|
-
audioDuration:
|
|
176
|
+
audioDuration: totalDuration ?? 0,
|
|
177
177
|
});
|
|
178
178
|
try {
|
|
179
179
|
if (fill) {
|
|
@@ -18,7 +18,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
18
18
|
언마운트 뒤에 온 end 가 떠난 화면에 완료를 알렸다. 번호가 다른 end 는 넘긴다. */
|
|
19
19
|
const ttsTokenRef = useRef(0);
|
|
20
20
|
/** 이 훅이 말을 건 적이 있나 — 떠날 때 **제 말만** 끊는다(마운트만 한 단계가 다른 곳의 말을 끊던 것) */
|
|
21
|
-
const
|
|
21
|
+
const spokeRef = useRef(false);
|
|
22
22
|
useEffect(() => {
|
|
23
23
|
// cleanup TTS resources on unmount
|
|
24
24
|
return () => {
|
|
@@ -28,12 +28,12 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
28
28
|
cancelAnimationFrame(ttsRafRef.current);
|
|
29
29
|
ttsRafRef.current = null;
|
|
30
30
|
}
|
|
31
|
-
if (
|
|
31
|
+
if (spokeRef.current && window.speechSynthesis) {
|
|
32
32
|
try {
|
|
33
33
|
window.speechSynthesis.cancel();
|
|
34
34
|
}
|
|
35
35
|
catch (e) { }
|
|
36
|
-
|
|
36
|
+
spokeRef.current = false;
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
catch (e) { }
|
|
@@ -75,11 +75,11 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
75
75
|
}
|
|
76
76
|
catch (e) { }
|
|
77
77
|
const rawTts = text ?? ttsText;
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
|
|
78
|
+
const sentences = (Array.isArray(rawTts) ? rawTts.filter((s) => !!s) : [rawTts || '']);
|
|
79
|
+
if (sentences.length === 0)
|
|
80
|
+
sentences.push('');
|
|
81
81
|
const rate = Math.max(0.3, Math.min(3, playbackRate));
|
|
82
|
-
ttsEstimatedDurationRef.current =
|
|
82
|
+
ttsEstimatedDurationRef.current = sentences.reduce((sum, s) => sum + estimateTTSDuration(s, rate || 1), 0);
|
|
83
83
|
ttsStartRef.current = performance.now();
|
|
84
84
|
setProgress(0);
|
|
85
85
|
setPlaybackState('playing');
|
|
@@ -110,7 +110,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
110
110
|
}
|
|
111
111
|
catch (e) { }
|
|
112
112
|
};
|
|
113
|
-
const
|
|
113
|
+
const onDone = () => {
|
|
114
114
|
if (myToken !== ttsTokenRef.current)
|
|
115
115
|
return;
|
|
116
116
|
try {
|
|
@@ -133,8 +133,8 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
133
133
|
}
|
|
134
134
|
catch (e) { }
|
|
135
135
|
};
|
|
136
|
-
const
|
|
137
|
-
const utt = new SpeechSynthesisUtterance(
|
|
136
|
+
const speakAt = (i) => {
|
|
137
|
+
const utt = new SpeechSynthesisUtterance(sentences[i]);
|
|
138
138
|
try {
|
|
139
139
|
utt.rate = rate;
|
|
140
140
|
}
|
|
@@ -147,21 +147,21 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
147
147
|
if (myToken !== ttsTokenRef.current)
|
|
148
148
|
return;
|
|
149
149
|
// 남은 문장이 있으면 이어 말한다 — 진행 표시는 전체 추정 길이로 계속 흐른다
|
|
150
|
-
if (i <
|
|
150
|
+
if (i < sentences.length - 1) {
|
|
151
151
|
try {
|
|
152
|
-
|
|
152
|
+
speakAt(i + 1);
|
|
153
153
|
}
|
|
154
154
|
catch (e) {
|
|
155
|
-
|
|
155
|
+
onDone();
|
|
156
156
|
}
|
|
157
157
|
return;
|
|
158
158
|
}
|
|
159
|
-
|
|
159
|
+
onDone();
|
|
160
160
|
};
|
|
161
161
|
/* 합성이 실패하면(synthesis-failed·not-allowed·language-unavailable) end 없이 error 만 온다 — 끝난 것과 같이 정리한다.
|
|
162
162
|
전에는 재생 상태·소음이 그대로 남고 handleEnd·onDone 이 안 불려 문항이 넘어가지 않았다. 정지·다시 시작이 올린 번호의
|
|
163
163
|
error(취소)는 onend 와 같이 넘긴다. 중간 문장이 실패하면 남은 문장도 같은 까닭으로 실패하기 쉬워 거기서 끝낸다. */
|
|
164
|
-
utt.onerror = () => {
|
|
164
|
+
utt.onerror = () => { onDone(); };
|
|
165
165
|
utt.onstart = () => {
|
|
166
166
|
if (i > 0)
|
|
167
167
|
return; // 진행 사슬은 첫 문장에서 한 번만 건다
|
|
@@ -174,11 +174,11 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
174
174
|
}
|
|
175
175
|
catch (e) { }
|
|
176
176
|
};
|
|
177
|
-
|
|
177
|
+
spokeRef.current = true;
|
|
178
178
|
synth.speak(utt);
|
|
179
179
|
};
|
|
180
180
|
try {
|
|
181
|
-
|
|
181
|
+
speakAt(0);
|
|
182
182
|
}
|
|
183
183
|
catch (e) {
|
|
184
184
|
try {
|
|
@@ -38,10 +38,10 @@ timeoutMs = 2500, }) {
|
|
|
38
38
|
readyRef.current = ready;
|
|
39
39
|
if (!ready || !resolversRef.current.length)
|
|
40
40
|
return;
|
|
41
|
-
const
|
|
41
|
+
const waiting = resolversRef.current;
|
|
42
42
|
resolversRef.current = [];
|
|
43
|
-
for (const
|
|
44
|
-
|
|
43
|
+
for (const wake of waiting)
|
|
44
|
+
wake(true);
|
|
45
45
|
}, []);
|
|
46
46
|
/** 새 트랙을 물렸다 — 이전 트랙의 '준비됨'을 물려받지 않는다 */
|
|
47
47
|
const markNotReady = useCallback(() => { readyRef.current = false; }, []);
|
|
@@ -53,22 +53,22 @@ timeoutMs = 2500, }) {
|
|
|
53
53
|
if (readyRef.current)
|
|
54
54
|
return Promise.resolve(true);
|
|
55
55
|
return new Promise((resolve) => {
|
|
56
|
-
const
|
|
56
|
+
const settle = (go) => {
|
|
57
57
|
clearTimeout(timer);
|
|
58
|
-
resolversRef.current = resolversRef.current.filter((r) => r !==
|
|
59
|
-
resolve(
|
|
58
|
+
resolversRef.current = resolversRef.current.filter((r) => r !== settle);
|
|
59
|
+
resolve(go);
|
|
60
60
|
};
|
|
61
|
-
const timer = setTimeout(() =>
|
|
62
|
-
resolversRef.current.push(
|
|
61
|
+
const timer = setTimeout(() => settle(true), timeoutMs);
|
|
62
|
+
resolversRef.current.push(settle);
|
|
63
63
|
});
|
|
64
64
|
}, [showSoundWaveForm, showNoiseWaveForm, timeoutMs]);
|
|
65
65
|
/* 화면을 떠날 때 기다리던 것들을 `false`(취소)로 놓아 준다. 매달린 약속·상한 타이머를 남기지 않되,
|
|
66
66
|
`true` 로 풀면 부르는 쪽이 사라진 화면의 재생을 이어 갔다(떼어진 <audio> 에 play). 부르는 쪽은 false 면 멈춘다. */
|
|
67
67
|
useEffect(() => () => {
|
|
68
|
-
const
|
|
68
|
+
const left = resolversRef.current;
|
|
69
69
|
resolversRef.current = [];
|
|
70
|
-
for (const
|
|
71
|
-
|
|
70
|
+
for (const wake of left)
|
|
71
|
+
wake(false);
|
|
72
72
|
}, []);
|
|
73
73
|
return { onReadyChange, waitForReady, markNotReady };
|
|
74
74
|
}
|
|
@@ -29,17 +29,17 @@ export default function UpdateAlert({ title, message, actionLabel, onAction, ico
|
|
|
29
29
|
/* 닫기(×)를 눌러 부모가 알림을 치우면 그 단추에 있던 포커스가 body 로 떨어졌다. 닫기로 떠날 때만(화면 전체가 내려갈 때는 아님)
|
|
30
30
|
떼기 직전(레이아웃 효과 정리 — 아직 문서에 붙어 있다) 알림 앞의 포커스 받을 곳으로 옮긴다. */
|
|
31
31
|
const rootRef = useRef(null);
|
|
32
|
-
const
|
|
32
|
+
const closePressedRef = useRef(false);
|
|
33
33
|
useLayoutEffect(() => {
|
|
34
34
|
const root = rootRef.current;
|
|
35
35
|
/* 낱장과 스택이 함께 뜨면 둘 다 우상단 16px 에 붙어 겹쳤다 — 자리(낱장 자신 또는 감싼 스택)를 올려 문서 순서대로 아래로 쌓는다 */
|
|
36
|
-
const
|
|
37
|
-
const
|
|
36
|
+
const slotEl = root?.parentElement?.closest('.update-alert-stack') ?? root;
|
|
37
|
+
const unregister = slotEl ? registerUpdateAlertSlot(slotEl) : null;
|
|
38
38
|
return () => {
|
|
39
|
-
|
|
40
|
-
if (
|
|
39
|
+
unregister?.();
|
|
40
|
+
if (closePressedRef.current && root && root.contains(document.activeElement))
|
|
41
41
|
moveFocusOutside(root);
|
|
42
42
|
};
|
|
43
43
|
}, []);
|
|
44
|
-
return (_jsxs("div", { ref: rootRef, className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: () => {
|
|
44
|
+
return (_jsxs("div", { ref: rootRef, className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: () => { closePressedRef.current = true; onClose(); }, "aria-label": t('common.close', '닫기'), children: "\u00D7" })), _jsxs("div", { className: "update-alert__top", children: [_jsx("div", { className: "update-alert__icon", children: icon ?? _jsx(Download, { size: 20, strokeWidth: 2 }) }), _jsxs("div", { className: "update-alert__body", children: [_jsx("div", { className: "update-alert__title h4", children: title }), _jsx("div", { className: "update-alert__message body-3", children: message })] })] }), onAction && (_jsx(Button, { htmlType: "button", size: 32, variant: "main", shape: "pill", className: "update-alert__action", onClick: onAction, children: resolvedActionLabel }))] }));
|
|
45
45
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { cx } from '../../utils/cx';
|
|
4
|
+
import { toPhonemeCodes, PHONEME_NONE } from '../../utils/phonemeSectionCodes';
|
|
4
5
|
import { useCallback, useMemo } from 'react';
|
|
5
6
|
import { useTranslation } from 'react-i18next';
|
|
6
7
|
import useMediaQuery from '../../hooks/useMediaQuery';
|
|
@@ -72,7 +73,12 @@ export default function PhonemeSelectBlock({ sections, selectedList, count, onCo
|
|
|
72
73
|
: selectedList.map((selected, i) => (_jsx(FilterPanel, { title: panelTitle(phonemeLabel(i + 1), i === 0), sections: combinationSectionsForSlot ? combinationSectionsForSlot(selected, i) : combinationSections, selected: selected, onSelect: onSelect?.(i), align: "newline" }, i))) }) })), beforeSearchContent && (_jsx("div", { className: "phoneme-select-block__before-search", children: beforeSearchContent })), (onClearPhoneme || onSearch || searchChecking) && (() => {
|
|
73
74
|
// 종성 '제외'(없음)만 선택된 슬롯은 실제 음소를 고른 것으로 치지 않는다 —
|
|
74
75
|
// "받침 없음"은 단독으로는 검색 조건이 되기엔 너무 느슨한 보조 조건이라서다.
|
|
75
|
-
|
|
76
|
+
/* 열쇠를 **코드로 가려** 본다 — 고른값은 FilterPanel 의 절 이름(지금 언어)으로 들어오고
|
|
77
|
+
저장된 표는 옛 한글 열쇠다. `toPhonemeCodes` 가 둘 다 받아 코드로 돌린다. */
|
|
78
|
+
const isEmpty = selectedList.every(sel => {
|
|
79
|
+
const coded = toPhonemeCodes(sel);
|
|
80
|
+
return Object.entries(coded).every(([k, v]) => !v || (k === 'coda' && v === PHONEME_NONE));
|
|
81
|
+
});
|
|
76
82
|
return (_jsxs("div", { className: "phoneme-select-block__search-row", onClick: isEmpty ? onSearchEmpty : undefined, children: [onClearPhoneme && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordPairs.resetOptions', '검색 옵션 초기화'), selected: false, disabled: isEmpty, onClick: isEmpty ? undefined : onClearPhoneme })), onSearch ? (
|
|
77
83
|
/* 확인 중에도 단추를 **바꿔 끼우지 않는다** — 전에는 스피너 칸으로 갈아 끼워, 키보드로 누른 단추가 사라지며 초점이
|
|
78
84
|
body 로 빠졌다. 막힘은 aria-disabled(onDisabledClick)로만 걸어 초점을 남기고, aria-busy·스피너로 알린다. */
|
|
@@ -18,9 +18,9 @@ export default function TableFilterPanel(props) {
|
|
|
18
18
|
const { t } = useTranslation();
|
|
19
19
|
const { title, className, collapsible, defaultCollapsed, collapsed, onCollapsedChange, headerSummary } = props;
|
|
20
20
|
/* 두 갈래(껍데기 모드·표 설정 모드)가 같은 접기 값을 쓴다 — 한쪽만 넘기면 화면마다 다르게 접힌다 */
|
|
21
|
-
const
|
|
21
|
+
const collapseProps = { collapsible, defaultCollapsed, collapsed, onCollapsedChange, headerSummary };
|
|
22
22
|
if (isShellProps(props)) {
|
|
23
|
-
return (_jsx(FilterPanel, { className: className, title: title ?? t('wordTable.filterTitle', '표 설정'), sections: props.sections, selected: props.selected, onSelect: props.onSelect,
|
|
23
|
+
return (_jsx(FilterPanel, { className: className, title: title ?? t('wordTable.filterTitle', '표 설정'), sections: props.sections, selected: props.selected, onSelect: props.onSelect, ...collapseProps, align: "inline" }));
|
|
24
24
|
}
|
|
25
25
|
const { hasImage, hasColloquial, contentTypes, orientation, showOutline, cutLine, showDivLine, setters, showCutLine = false, showDivLine2 = false, middleSections = [], extraSelected = {}, onExtraSelect, showPaperBands = false, showTopInfo = true, showBottomInfo = true, } = props;
|
|
26
26
|
const sections = [
|
|
@@ -42,5 +42,5 @@ export default function TableFilterPanel(props) {
|
|
|
42
42
|
return;
|
|
43
43
|
onExtraSelect?.(value, sectionLabel);
|
|
44
44
|
};
|
|
45
|
-
return (_jsx(FilterPanel, { className: className, title: title ?? t('wordTable.filterTitle', '표 설정'), sections: sections, selected: selected, onSelect: onSelect,
|
|
45
|
+
return (_jsx(FilterPanel, { className: className, title: title ?? t('wordTable.filterTitle', '표 설정'), sections: sections, selected: selected, onSelect: onSelect, ...collapseProps, sectionAlign: "horizontal" }));
|
|
46
46
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { cx } from '../../utils/cx';
|
|
4
|
-
import {
|
|
4
|
+
import { compareKorean } from '../../utils/textSort';
|
|
5
5
|
import { useEscToClose } from '../../hooks/useEscToClose';
|
|
6
6
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
|
7
7
|
import { useTranslation } from 'react-i18next';
|
|
@@ -41,30 +41,30 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
|
|
|
41
41
|
/* **검색어도 결과 수도 없이 items 만 받으면 검색 결과가 아니라 저장된 목록을 보여 주는 것**(말귀 공유 링크 열람 —
|
|
42
42
|
치료사가 표에서 끌어 옮긴 순서가 저장돼 있다). 그때는 받은 순서를 기본으로 둔다 — 가나다로 다시 늘어서
|
|
43
43
|
표의 칸 순서가 사라졌다. 검색 화면들은 결과 수(resultCount)를 늘 주므로 지금처럼 가나다 · 시작 우선이다. */
|
|
44
|
-
const
|
|
45
|
-
const
|
|
44
|
+
const browsing = searchQuery === undefined && resultCount === undefined;
|
|
45
|
+
const defaultSort = hasQuery ? 'match' : browsing ? 'original' : 'ganada';
|
|
46
46
|
// 첫 렌더에 이미 검색어가 있어도 '시작 우선'으로 시작한다 — 아래 조정은 검색어 유무가 **바뀔 때**만 돌아서,
|
|
47
47
|
// 초깃값을 'ganada' 로 고정하면 검색어를 들고 마운트한 화면은 가나다순에 머물렀다.
|
|
48
|
-
const [sortKey, setSortKey] = useState(() =>
|
|
48
|
+
const [sortKey, setSortKey] = useState(() => defaultSort);
|
|
49
49
|
const [pageSizeStr, setPageSizeStr] = useState('50');
|
|
50
50
|
const [page, setPage] = useState(1);
|
|
51
51
|
// 검색어 유무가 바뀌면 그에 맞는 기본 정렬로 되돌린다(렌더 중 상태 조정).
|
|
52
52
|
const [prevHasQuery, setPrevHasQuery] = useState(hasQuery);
|
|
53
53
|
if (prevHasQuery !== hasQuery) {
|
|
54
54
|
setPrevHasQuery(hasQuery);
|
|
55
|
-
setSortKey(
|
|
55
|
+
setSortKey(defaultSort);
|
|
56
56
|
}
|
|
57
|
-
const effectiveSortKey = (sortKey === 'match' && !hasQuery) || (sortKey === 'original' &&
|
|
57
|
+
const effectiveSortKey = (sortKey === 'match' && !hasQuery) || (sortKey === 'original' && !browsing) ? defaultSort : sortKey;
|
|
58
58
|
const sortOptions = useMemo(() => [
|
|
59
59
|
...(hasQuery ? [{ value: 'match', label: t('wordSearch.sort.match', { defaultValue: '시작 우선' }) }] : []),
|
|
60
|
-
...(
|
|
60
|
+
...(browsing ? [{ value: 'original', label: t('wordSearch.sort.original', { defaultValue: '받은 순서' }) }] : []),
|
|
61
61
|
{ value: 'ganada', label: t('wordSearch.sort.ganada', { defaultValue: '가나다순 ↑' }) },
|
|
62
62
|
{ value: 'ganada_desc', label: t('wordSearch.sort.ganadaDesc', { defaultValue: '가나다순 ↓' }) },
|
|
63
63
|
{ value: 'syllable', label: t('wordSearch.sort.syllable', { defaultValue: '음절 수 ↑' }) },
|
|
64
64
|
{ value: 'syllable_desc', label: t('wordSearch.sort.syllableDesc', { defaultValue: '음절 수 ↓' }) },
|
|
65
65
|
{ value: 'grade', label: t('wordSearch.sort.grade', { defaultValue: '등급순 ↑' }) },
|
|
66
66
|
{ value: 'grade_desc', label: t('wordSearch.sort.gradeDesc', { defaultValue: '등급순 ↓' }) },
|
|
67
|
-
], [t, hasQuery,
|
|
67
|
+
], [t, hasQuery, browsing]);
|
|
68
68
|
const pageSizeOptions = useMemo(() => ['10', '25', '50', '100'].map(v => ({
|
|
69
69
|
value: v, label: t('wordSearch.pageSize', { count: Number(v), defaultValue: `${v}개씩 보기` }),
|
|
70
70
|
})), [t]);
|
|
@@ -72,7 +72,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
|
|
|
72
72
|
const firstWord = (it) => it.words?.[0];
|
|
73
73
|
/* '시작 우선' 순위는 검색어가 바뀔 때 판별기를 한 번 만들고 항목마다 한 번만 센다 — 비교할 때마다 검색어를 다시 펼치고
|
|
74
74
|
localeCompare 를 불러 결과가 바뀔 때 23k 개에 38ms 걸렸다. */
|
|
75
|
-
const
|
|
75
|
+
const initialRanks = useMemo(() => {
|
|
76
76
|
const qLen = [...q].length;
|
|
77
77
|
const m = makeWordQueryMatcher(q);
|
|
78
78
|
const memo = new Map();
|
|
@@ -86,7 +86,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
|
|
|
86
86
|
};
|
|
87
87
|
}, [q]);
|
|
88
88
|
/* 정렬 비교자 — flat/그룹 뷰가 공유한다.
|
|
89
|
-
**글자 견주기는
|
|
89
|
+
**글자 견주기는 `compareKorean`(콜레이터 하나) 으로만 한다**(말귀 요청 (113), 2026-10-10). 전에는 '시작 우선'
|
|
90
90
|
갈래만 콜레이터를 쓰고 **여섯 갈래는 `localeCompare(…, 'ko')`** 였다 — 그 부름은 비교마다 콜레이터를
|
|
91
91
|
새로 만든다. 하필 기본 정렬이 `ganada` 라 **주된 길이 느린 쪽**이었다(말귀 실측 6,357개: 30.3ms → 8.1ms).
|
|
92
92
|
이 파일에 콜레이터가 하나 더 있었던 것(`new Intl.Collator('ko')`)도 걷었다 — 같은 결정이 두 자리에
|
|
@@ -104,18 +104,18 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
|
|
|
104
104
|
// 시작 우선 — 검색어로 시작하는 단어를 위로. 앞 q 글자만 잘라 매칭하면 "첫 음절부터 일치"만
|
|
105
105
|
// 남는다(초성 검색도 동일: 'ㄴ' → '나무'가 위). 동순위는 가나다.
|
|
106
106
|
case 'match': {
|
|
107
|
-
return
|
|
107
|
+
return initialRanks(a) - initialRanks(b) || compareKorean(label(a), label(b));
|
|
108
108
|
}
|
|
109
|
-
case 'ganada': return
|
|
110
|
-
case 'ganada_desc': return
|
|
111
|
-
case 'syllable': return syll(a) - syll(b) ||
|
|
112
|
-
case 'syllable_desc': return syll(b) - syll(a) ||
|
|
113
|
-
case 'grade': return grade(a) - grade(b) ||
|
|
114
|
-
case 'grade_desc': return grade(b) - grade(a) ||
|
|
109
|
+
case 'ganada': return compareKorean(label(a), label(b));
|
|
110
|
+
case 'ganada_desc': return compareKorean(label(b), label(a));
|
|
111
|
+
case 'syllable': return syll(a) - syll(b) || compareKorean(label(a), label(b));
|
|
112
|
+
case 'syllable_desc': return syll(b) - syll(a) || compareKorean(label(a), label(b));
|
|
113
|
+
case 'grade': return grade(a) - grade(b) || compareKorean(label(a), label(b));
|
|
114
|
+
case 'grade_desc': return grade(b) - grade(a) || compareKorean(label(a), label(b));
|
|
115
115
|
// 받은 순서 — Array.prototype.sort 는 안정 정렬이라 0 이면 받은 차례가 남는다
|
|
116
116
|
default: return 0;
|
|
117
117
|
}
|
|
118
|
-
}, [effectiveSortKey,
|
|
118
|
+
}, [effectiveSortKey, initialRanks]);
|
|
119
119
|
const sortedItems = useMemo(() => items.slice().sort(sortCompare), [items, sortCompare]);
|
|
120
120
|
const pageSize = Number(pageSizeStr) || 10;
|
|
121
121
|
// 그룹 뷰(대립쌍표 음소대립)도 flat과 동일하게 정렬·페이지네이션한다 — 그룹 순서를 유지하고 그룹 내부를
|