@ihabdevteam/core 0.322.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 +517 -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 +54 -16
- 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 +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.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 +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/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.js +45 -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
|
@@ -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
|
}
|
|
@@ -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();
|
|
@@ -104,7 +104,7 @@ 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
109
|
case 'ganada': return compareKorean(label(a), label(b));
|
|
110
110
|
case 'ganada_desc': return compareKorean(label(b), label(a));
|
|
@@ -115,7 +115,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
|
|
|
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과 동일하게 정렬·페이지네이션한다 — 그룹 순서를 유지하고 그룹 내부를
|
|
@@ -12,7 +12,7 @@ import Popup from '../Popup/Popup';
|
|
|
12
12
|
import WordTable, { WORD_TABLE_PAPER } from '../WordTable';
|
|
13
13
|
import { chunkItems } from '../wordTableLayout';
|
|
14
14
|
import TableFilterPanel from './TableFilterPanel';
|
|
15
|
-
import { buildItemsPerPageSection, buildPageNumberSection,
|
|
15
|
+
import { buildItemsPerPageSection, buildPageNumberSection, parsePageNumberPosition, itemsPerPageFromChoice } from './wordTableSettings';
|
|
16
16
|
import Toast from '../Toast';
|
|
17
17
|
import { useToastState } from '../../hooks/useToastState';
|
|
18
18
|
import { getImageResolver, checkImageExists } from '../../utils/imageConfig';
|
|
@@ -24,24 +24,24 @@ import { CELL_COUNTS } from './wordTableBlockTypes';
|
|
|
24
24
|
// ── 컴포넌트 ───────────────────────────────────────────────
|
|
25
25
|
/** @public */
|
|
26
26
|
/** 편집 칸의 낱말 → 저장 항목의 낱말. 편집 칸에서 보이던 구어체 발음·등급·품사·유형을 그대로 옮긴다. */
|
|
27
|
-
function
|
|
27
|
+
function toResultWord(e) {
|
|
28
28
|
return {
|
|
29
29
|
label: e.word, subLabel: e.pronunciation, colloquialLabel: e.colloquialPronunciation, homonum: e.homonum,
|
|
30
30
|
grade: e.grade, syllables: [...e.word].length, pos: e.pos, wordType: e.wordType,
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
/** 동음이의어 번호까지 넣은 낱말들로 만든 id — 첫 낱말만 쓰면 '배'(1)·'배'(2), '가다|나다'·'가다|다다' 가 같은 id 였다 */
|
|
34
|
-
function
|
|
34
|
+
function itemId(entries) {
|
|
35
35
|
return entries.map(e => (e.homonum != null ? `${e.word}#${e.homonum}` : e.word)).join('|');
|
|
36
36
|
}
|
|
37
37
|
/** 표 안에서 같은 낱말을 두 칸에 넣었을 때도 id 가 겹치지 않게 뒤에 ~2, ~3 을 붙인다 */
|
|
38
|
-
function
|
|
39
|
-
const
|
|
38
|
+
function withUniqueIds(list) {
|
|
39
|
+
const used = new Set();
|
|
40
40
|
return list.map(it => {
|
|
41
41
|
let id = it.id;
|
|
42
|
-
for (let n = 2;
|
|
42
|
+
for (let n = 2; used.has(id); n++)
|
|
43
43
|
id = `${it.id}~${n}`;
|
|
44
|
-
|
|
44
|
+
used.add(id);
|
|
45
45
|
return id === it.id ? it : { ...it, id };
|
|
46
46
|
});
|
|
47
47
|
}
|
|
@@ -78,14 +78,14 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
|
|
|
78
78
|
const visibleCells = cells.slice(0, cellCount);
|
|
79
79
|
/* 띠를 켠 표면 설정 판에 '상단 정보'·'하단 정보' 고르개가 선다 — 스위치를 따로 두지
|
|
80
80
|
않는다(부르는 쪽이 둘을 어긋나게 줄 일이 없게). 고른 값은 `brand.top`·`bottom` 을 덮는다. */
|
|
81
|
-
const
|
|
81
|
+
const showBand = Boolean(brand?.show);
|
|
82
82
|
const [showTopInfo, setShowTopInfo] = useState(true);
|
|
83
83
|
const [showBottomInfo, setShowBottomInfo] = useState(true);
|
|
84
|
-
const
|
|
84
|
+
const band = brand && { ...brand, top: showTopInfo, bottom: showBottomInfo };
|
|
85
85
|
// 공통 단어 표 설정 상태
|
|
86
86
|
const [orientation, setOrientation] = useState('landscape');
|
|
87
|
-
const [contentTypes, setContentTypes] = useState(['
|
|
88
|
-
const [cutLine, setCutLine] = useState('
|
|
87
|
+
const [contentTypes, setContentTypes] = useState(['image', 'word', 'standardPron']);
|
|
88
|
+
const [cutLine, setCutLine] = useState('show');
|
|
89
89
|
const [showOutline, setShowOutline] = useState(true);
|
|
90
90
|
const [showDivLine, setShowDivLine] = useState(true);
|
|
91
91
|
// 인쇄 캡처용 그리드 콘텐츠 ref (단일 페이지)
|
|
@@ -209,7 +209,7 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
|
|
|
209
209
|
const middleSelected = {
|
|
210
210
|
...((isEditable || showCellCount) ? { [t('wordTable.filter.columns', '칸 수')]: `${count}칸` } : {}),
|
|
211
211
|
...(pagesEnabled && (items?.length ?? 0) > 0 ? { [t(hasPairs ? 'wordTable.filter.itemsPerPagePair' : 'wordTable.filter.itemsPerPageWord')]: `${effectiveItemsPerPage}개` } : {}),
|
|
212
|
-
...(isPaginated ? { [t('wordTable.filter.pageNumber', '페이지 번호')]:
|
|
212
|
+
...(isPaginated ? { [t('wordTable.filter.pageNumber', '페이지 번호')]: pageNumberPosition } : {}),
|
|
213
213
|
};
|
|
214
214
|
const handleMiddleSelect = (value, sectionLabel) => {
|
|
215
215
|
if (sectionLabel === t('wordTable.filter.columns', '칸 수')) {
|
|
@@ -229,14 +229,14 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
|
|
|
229
229
|
};
|
|
230
230
|
// 저장 팝업용 현재 items / preview
|
|
231
231
|
const saveItems = isEditable
|
|
232
|
-
?
|
|
233
|
-
id:
|
|
234
|
-
words: c.map(
|
|
232
|
+
? withUniqueIds(visibleCells.filter((c) => c !== null && c.length > 0).map(c => ({
|
|
233
|
+
id: itemId(c),
|
|
234
|
+
words: c.map(toResultWord),
|
|
235
235
|
})))
|
|
236
236
|
: items ?? [];
|
|
237
237
|
/* 저장한 뒤에는 **숨은 칸(cellCount 뒤)을 비운다** — 2칸으로 줄여 저장한 표를 4칸으로 늘리면 저장본에 없던
|
|
238
238
|
셋째·넷째 낱말이 다시 떠, 저장한 것과 화면이 달랐다. 저장하지 않고 줄였다 늘리는 동안에는 그대로 들고 있는다. */
|
|
239
|
-
const
|
|
239
|
+
const clearHiddenCells = () => {
|
|
240
240
|
if (isEditable)
|
|
241
241
|
setCells(prev => (prev.length > cellCount ? prev.slice(0, cellCount) : prev));
|
|
242
242
|
};
|
|
@@ -250,13 +250,13 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
|
|
|
250
250
|
if (savedLower)
|
|
251
251
|
setSavedNames(prev => prev.includes(savedLower) ? prev : [...prev, savedLower]);
|
|
252
252
|
onSaveComplete(trimmedName, saveItems, trimmedDescription || undefined, asNew);
|
|
253
|
-
|
|
253
|
+
clearHiddenCells();
|
|
254
254
|
return;
|
|
255
255
|
}
|
|
256
256
|
// saveOnly: 이름 입력 팝업 없이 바로 저장(이름은 상위에서 처리)
|
|
257
257
|
if (saveOnly && onSaveComplete) {
|
|
258
258
|
onSaveComplete('', saveItems, undefined, asNew);
|
|
259
|
-
|
|
259
|
+
clearHiddenCells();
|
|
260
260
|
return;
|
|
261
261
|
}
|
|
262
262
|
if (onSaveComplete) {
|
|
@@ -279,7 +279,7 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
|
|
|
279
279
|
if (!saveAsTrimmed || saveAsCollides || !onSaveComplete)
|
|
280
280
|
return;
|
|
281
281
|
onSaveComplete(saveAsTrimmed, saveItems, trimmedDescription || undefined, true);
|
|
282
|
-
|
|
282
|
+
clearHiddenCells();
|
|
283
283
|
setShowSaveAsPopup(false);
|
|
284
284
|
};
|
|
285
285
|
// 저장 버튼 활성 조건 — 인라인 모드에선 이름 필수·중복(others) 불가. 항목이 비어도 불가.
|
|
@@ -307,7 +307,7 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
|
|
|
307
307
|
items: saveItems,
|
|
308
308
|
existingNames: getSavedTables().map(tb => tb.name),
|
|
309
309
|
saveAsNew,
|
|
310
|
-
onSave: (name, description) => { onSaveComplete(name, saveItems, description, saveAsNew);
|
|
310
|
+
onSave: (name, description) => { onSaveComplete(name, saveItems, description, saveAsNew); clearHiddenCells(); setShowSavePopup(false); },
|
|
311
311
|
onCancel: () => setShowSavePopup(false),
|
|
312
312
|
}), (isEditable || onAddItem) && activeCell !== null && renderWordSearch?.({
|
|
313
313
|
cellIndex: activeCell,
|
|
@@ -316,25 +316,25 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
|
|
|
316
316
|
const entry = entries[0];
|
|
317
317
|
if (entry) {
|
|
318
318
|
// 이미 있는 항목과 id 가 겹치면(같은 낱말의 다른 동음이의어·같은 낱말) onRemoveItem(id) 가 둘을 함께 지운다
|
|
319
|
-
const
|
|
320
|
-
let id =
|
|
321
|
-
for (let n = 2;
|
|
322
|
-
id = `${
|
|
323
|
-
onAddItem({ id, words: [
|
|
319
|
+
const usedIds = new Set((items ?? []).map(it => it.id));
|
|
320
|
+
let id = itemId([entry]);
|
|
321
|
+
for (let n = 2; usedIds.has(id); n++)
|
|
322
|
+
id = `${itemId([entry])}~${n}`;
|
|
323
|
+
onAddItem({ id, words: [toResultWord(entry)] });
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
326
|
setActiveCell(null);
|
|
327
327
|
},
|
|
328
328
|
onClose: handleCloseWordSearch,
|
|
329
|
-
}), !hideControls && (_jsx(TableFilterPanel, { title: filterTitle, collapsible: filterCollapsible, defaultCollapsed: filterDefaultCollapsed, collapsed: filterCollapsed, onCollapsedChange: onFilterCollapsedChange, headerSummary: filterHeaderSummary, hasImage: hasImage, hasColloquial: hasColloquial, contentTypes: contentTypes, orientation: orientation, showOutline: showOutline, cutLine: cutLine, showDivLine: showDivLine, setters: { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine, setShowTopInfo, setShowBottomInfo }, showCutLine: (pagesEnabled ? itemsPerPage : count) > 1, showDivLine2: hasPairs, middleSections: middleSections, extraSelected: middleSelected, onExtraSelect: handleMiddleSelect, showPaperBands:
|
|
329
|
+
}), !hideControls && (_jsx(TableFilterPanel, { title: filterTitle, collapsible: filterCollapsible, defaultCollapsed: filterDefaultCollapsed, collapsed: filterCollapsed, onCollapsedChange: onFilterCollapsedChange, headerSummary: filterHeaderSummary, hasImage: hasImage, hasColloquial: hasColloquial, contentTypes: contentTypes, orientation: orientation, showOutline: showOutline, cutLine: cutLine, showDivLine: showDivLine, setters: { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine, setShowTopInfo, setShowBottomInfo }, showCutLine: (pagesEnabled ? itemsPerPage : count) > 1, showDivLine2: hasPairs, middleSections: middleSections, extraSelected: middleSelected, onExtraSelect: handleMiddleSelect, showPaperBands: showBand, showTopInfo: showTopInfo, showBottomInfo: showBottomInfo })), 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,
|
|
330
330
|
/* 쪽이 나뉘면 끌어 옮기기가 통째로 꺼졌다(말귀 단어 찾기: 9개부터). 쪽 안의 자리를 전체 자리로 옮겨 넘긴다 —
|
|
331
331
|
쪽을 넘는 이동은 하지 않는다. */
|
|
332
332
|
onReorder: onReorder && ((from, to) => onReorder(from + i * itemsPerPage, to + i * itemsPerPage)),
|
|
333
333
|
/* 안쪽 표는 제 쪽 안의 번호를 준다 — 부르는 쪽에는 **표 전체 기준**으로 넘긴다
|
|
334
334
|
(위 `onReorder` 와 같은 셈이다. 쪽이 몇으로 나뉘었는지는 부르는 쪽 일이 아니다). */
|
|
335
|
-
renderCellAction: renderCellAction && ((n, item) => renderCellAction(n + i * itemsPerPage, item)), brand:
|
|
335
|
+
renderCellAction: renderCellAction && ((n, item) => renderCellAction(n + i * itemsPerPage, item)), brand: band, code: code, 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 ? visibleCells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined,
|
|
336
336
|
/* 편집 모드에는 얹을 항목이 없다 — 칸을 손으로 채우는 꼴이다 */
|
|
337
|
-
renderCellAction: !isEditable ? renderCellAction : undefined, brand:
|
|
337
|
+
renderCellAction: !isEditable ? renderCellAction : undefined, brand: band, code: code, showFillerAdd: !!onAddItem, contentRef: contentRef })), !hideControls && !saveOnly && (onReset || actionsLeftExtra != null) && (_jsxs("div", { className: "word-table-block__table-actions", children: [onReset && (_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: resolvedResetLabel, onClick: onReset })), actionsLeftExtra] })), 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', '이 단어표에 대한 제목을 입력하세요.'),
|
|
338
338
|
/* PanelRow의 라벨은 시각적으로만 붙어 있고 입력과 연결돼 있지 않아, 낭독기에는
|
|
339
339
|
이름 없는 입력으로 읽혔다. placeholder는 자동완성 안내문("Tab 또는 space…")이
|
|
340
340
|
섞여 있어 이름으로 부적절하므로 같은 라벨 문구를 넘긴다. */
|