lyrics-transcriber 0.44.0__py3-none-any.whl → 0.45.0__py3-none-any.whl

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.
Files changed (22) hide show
  1. lyrics_transcriber/frontend/dist/assets/{index-DVoI6Z16.js → index-ZCT0s9MG.js} +2635 -1967
  2. lyrics_transcriber/frontend/dist/assets/index-ZCT0s9MG.js.map +1 -0
  3. lyrics_transcriber/frontend/dist/index.html +1 -1
  4. lyrics_transcriber/frontend/src/App.tsx +1 -1
  5. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
  6. lyrics_transcriber/frontend/src/components/EditModal.tsx +376 -303
  7. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +373 -0
  8. lyrics_transcriber/frontend/src/components/EditWordList.tsx +308 -0
  9. lyrics_transcriber/frontend/src/components/Header.tsx +7 -7
  10. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +458 -62
  11. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +7 -7
  12. lyrics_transcriber/frontend/src/components/WordDivider.tsx +4 -3
  13. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +1 -2
  14. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +68 -46
  15. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  16. {lyrics_transcriber-0.44.0.dist-info → lyrics_transcriber-0.45.0.dist-info}/METADATA +1 -1
  17. {lyrics_transcriber-0.44.0.dist-info → lyrics_transcriber-0.45.0.dist-info}/RECORD +20 -18
  18. lyrics_transcriber/frontend/dist/assets/index-DVoI6Z16.js.map +0 -1
  19. lyrics_transcriber/frontend/src/components/GlobalSyncEditor.tsx +0 -675
  20. {lyrics_transcriber-0.44.0.dist-info → lyrics_transcriber-0.45.0.dist-info}/LICENSE +0 -0
  21. {lyrics_transcriber-0.44.0.dist-info → lyrics_transcriber-0.45.0.dist-info}/WHEEL +0 -0
  22. {lyrics_transcriber-0.44.0.dist-info → lyrics_transcriber-0.45.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,373 @@
1
+ import {
2
+ Box,
3
+ Button,
4
+ Typography,
5
+ IconButton,
6
+ Tooltip,
7
+ Stack
8
+ } from '@mui/material'
9
+ import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'
10
+ import CancelIcon from '@mui/icons-material/Cancel'
11
+ import ZoomInIcon from '@mui/icons-material/ZoomIn'
12
+ import ZoomOutIcon from '@mui/icons-material/ZoomOut'
13
+ import ArrowBackIcon from '@mui/icons-material/ArrowBack'
14
+ import ArrowForwardIcon from '@mui/icons-material/ArrowForward'
15
+ import AutorenewIcon from '@mui/icons-material/Autorenew'
16
+ import PauseCircleOutlineIcon from '@mui/icons-material/PauseCircleOutline'
17
+ import CenterFocusStrongIcon from '@mui/icons-material/CenterFocusStrong'
18
+ import TimelineEditor from './TimelineEditor'
19
+ import { Word } from '../types'
20
+ import { useState, useEffect, useCallback, useRef } from 'react'
21
+
22
+ interface EditTimelineSectionProps {
23
+ words: Word[]
24
+ startTime: number
25
+ endTime: number
26
+ originalStartTime: number | null
27
+ originalEndTime: number | null
28
+ currentStartTime: number | null
29
+ currentEndTime: number | null
30
+ currentTime?: number
31
+ isManualSyncing: boolean
32
+ syncWordIndex: number
33
+ isSpacebarPressed: boolean
34
+ onWordUpdate: (index: number, updates: Partial<Word>) => void
35
+ onPlaySegment?: (time: number) => void
36
+ startManualSync: () => void
37
+ isGlobal?: boolean
38
+ }
39
+
40
+ export default function EditTimelineSection({
41
+ words,
42
+ startTime,
43
+ endTime,
44
+ originalStartTime,
45
+ originalEndTime,
46
+ currentStartTime,
47
+ currentEndTime,
48
+ currentTime,
49
+ isManualSyncing,
50
+ syncWordIndex,
51
+ isSpacebarPressed,
52
+ onWordUpdate,
53
+ onPlaySegment,
54
+ startManualSync,
55
+ isGlobal = false
56
+ }: EditTimelineSectionProps) {
57
+ // Add state for zoom level
58
+ const [zoomLevel, setZoomLevel] = useState(10) // Default 10 seconds visible
59
+ const [visibleStartTime, setVisibleStartTime] = useState(startTime)
60
+ const [visibleEndTime, setVisibleEndTime] = useState(Math.min(startTime + zoomLevel, endTime))
61
+ const [autoScrollEnabled, setAutoScrollEnabled] = useState(true) // Default to enabled
62
+ const timelineRef = useRef<HTMLDivElement>(null)
63
+
64
+ // Initial setup of visible time range
65
+ useEffect(() => {
66
+ if (isGlobal) {
67
+ // For global mode, start at the beginning
68
+ setVisibleStartTime(startTime)
69
+ setVisibleEndTime(Math.min(startTime + zoomLevel, endTime))
70
+ } else {
71
+ // For segment mode, always show the full segment
72
+ setVisibleStartTime(startTime)
73
+ setVisibleEndTime(endTime)
74
+ }
75
+ }, [startTime, endTime, zoomLevel, isGlobal])
76
+
77
+ // Handle playback scrolling with "page turning" approach
78
+ useEffect(() => {
79
+ // Skip if not in global mode, no current time, or auto-scroll is disabled
80
+ if (!isGlobal || !currentTime || !autoScrollEnabled) return
81
+
82
+ // Only scroll when current time is outside or near the edge of the visible window
83
+ if (currentTime < visibleStartTime) {
84
+ // If current time is before visible window, jump to show it at the start
85
+ const newStart = Math.max(startTime, currentTime)
86
+ const newEnd = Math.min(endTime, newStart + zoomLevel)
87
+ setVisibleStartTime(newStart)
88
+ setVisibleEndTime(newEnd)
89
+ } else if (currentTime > visibleEndTime - (zoomLevel * 0.05)) {
90
+ // If current time is near the end of visible window (within 5% of zoom level from the end),
91
+ // jump to the next "page" with current time at 5% from the left
92
+ const pageOffset = zoomLevel * 0.05 // Position current time 5% from the left edge
93
+ const newStart = Math.max(startTime, currentTime - pageOffset)
94
+ const newEnd = Math.min(endTime, newStart + zoomLevel)
95
+
96
+ // Only update if we're actually moving forward
97
+ if (newStart > visibleStartTime) {
98
+ setVisibleStartTime(newStart)
99
+ setVisibleEndTime(newEnd)
100
+ }
101
+ }
102
+ }, [currentTime, visibleStartTime, visibleEndTime, startTime, endTime, zoomLevel, isGlobal, autoScrollEnabled])
103
+
104
+ // Update visible time range when zoom level changes - but don't auto-center on current time
105
+ useEffect(() => {
106
+ if (isGlobal) {
107
+ // Don't auto-center on current time, just adjust the visible window based on zoom level
108
+ // while keeping the left edge fixed (unless it would go out of bounds)
109
+ const newEnd = Math.min(endTime, visibleStartTime + zoomLevel)
110
+
111
+ // If the new end would exceed the total range, adjust the start time
112
+ if (newEnd === endTime) {
113
+ const newStart = Math.max(startTime, endTime - zoomLevel)
114
+ setVisibleStartTime(newStart)
115
+ }
116
+
117
+ setVisibleEndTime(newEnd)
118
+ } else {
119
+ // For segment mode, always show the full segment
120
+ setVisibleStartTime(startTime)
121
+ setVisibleEndTime(endTime)
122
+ }
123
+ }, [zoomLevel, startTime, endTime, isGlobal, visibleStartTime])
124
+
125
+ // Toggle auto-scroll
126
+ const toggleAutoScroll = () => {
127
+ setAutoScrollEnabled(!autoScrollEnabled)
128
+ }
129
+
130
+ // Jump to current playback position
131
+ const jumpToCurrentTime = useCallback(() => {
132
+ if (!isGlobal || !currentTime) return
133
+
134
+ // Center the view around the current time
135
+ const halfZoom = zoomLevel / 2
136
+ let newStart = Math.max(startTime, currentTime - halfZoom)
137
+ const newEnd = Math.min(endTime, newStart + zoomLevel)
138
+
139
+ // Adjust start time if end time hits the boundary
140
+ if (newEnd === endTime) {
141
+ newStart = Math.max(startTime, endTime - zoomLevel)
142
+ }
143
+
144
+ setVisibleStartTime(newStart)
145
+ setVisibleEndTime(newEnd)
146
+ }, [currentTime, zoomLevel, startTime, endTime, isGlobal])
147
+
148
+ // Add keyboard shortcut for toggling auto-scroll
149
+ useEffect(() => {
150
+ const handleKeyDown = (e: KeyboardEvent) => {
151
+ if (isGlobal) {
152
+ // Alt+A to toggle auto-scroll
153
+ if (e.altKey && e.key === 'a') {
154
+ e.preventDefault()
155
+ toggleAutoScroll()
156
+ }
157
+
158
+ // Alt+J to jump to current time
159
+ if (e.altKey && e.key === 'j') {
160
+ e.preventDefault()
161
+ jumpToCurrentTime()
162
+ }
163
+ }
164
+ }
165
+
166
+ window.addEventListener('keydown', handleKeyDown)
167
+ return () => {
168
+ window.removeEventListener('keydown', handleKeyDown)
169
+ }
170
+ }, [isGlobal, toggleAutoScroll, jumpToCurrentTime])
171
+
172
+ // Handle zoom in
173
+ const handleZoomIn = () => {
174
+ if (zoomLevel > 2) { // Minimum zoom level of 2 seconds
175
+ setZoomLevel(zoomLevel - 2)
176
+ }
177
+ }
178
+
179
+ // Handle zoom out
180
+ const handleZoomOut = () => {
181
+ if (zoomLevel < (endTime - startTime)) { // Maximum zoom is the full range
182
+ setZoomLevel(zoomLevel + 2)
183
+ }
184
+ }
185
+
186
+ // Handle horizontal scrolling
187
+ const handleScroll = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
188
+ if (isGlobal && event.deltaX !== 0) {
189
+ event.preventDefault()
190
+
191
+ // Disable auto-scroll when user manually scrolls
192
+ setAutoScrollEnabled(false)
193
+
194
+ // Calculate scroll amount in seconds (scale based on zoom level)
195
+ const scrollAmount = (event.deltaX / 100) * (zoomLevel / 10)
196
+
197
+ // Update visible time range
198
+ let newStart = visibleStartTime + scrollAmount
199
+ let newEnd = visibleEndTime + scrollAmount
200
+
201
+ // Ensure we don't scroll beyond the boundaries
202
+ if (newStart < startTime) {
203
+ newStart = startTime
204
+ newEnd = newStart + zoomLevel
205
+ }
206
+
207
+ if (newEnd > endTime) {
208
+ newEnd = endTime
209
+ newStart = Math.max(startTime, newEnd - zoomLevel)
210
+ }
211
+
212
+ setVisibleStartTime(newStart)
213
+ setVisibleEndTime(newEnd)
214
+ }
215
+ }, [isGlobal, visibleStartTime, visibleEndTime, startTime, endTime, zoomLevel])
216
+
217
+ // Handle scroll left button
218
+ const handleScrollLeft = () => {
219
+ if (!isGlobal) return
220
+
221
+ // Disable auto-scroll when user manually scrolls
222
+ setAutoScrollEnabled(false)
223
+
224
+ // Scroll left by 25% of the current visible range
225
+ const scrollAmount = zoomLevel * 0.25
226
+ const newStart = Math.max(startTime, visibleStartTime - scrollAmount)
227
+ const newEnd = newStart + zoomLevel
228
+
229
+ setVisibleStartTime(newStart)
230
+ setVisibleEndTime(newEnd)
231
+ }
232
+
233
+ // Handle scroll right button
234
+ const handleScrollRight = () => {
235
+ if (!isGlobal) return
236
+
237
+ // Disable auto-scroll when user manually scrolls
238
+ setAutoScrollEnabled(false)
239
+
240
+ // Scroll right by 25% of the current visible range
241
+ const scrollAmount = zoomLevel * 0.25
242
+ const newEnd = Math.min(endTime, visibleEndTime + scrollAmount)
243
+ let newStart = newEnd - zoomLevel
244
+
245
+ // Ensure we don't scroll beyond the start boundary
246
+ if (newStart < startTime) {
247
+ newStart = startTime
248
+ const adjustedNewEnd = Math.min(endTime, newStart + zoomLevel)
249
+ setVisibleEndTime(adjustedNewEnd)
250
+ } else {
251
+ setVisibleEndTime(newEnd)
252
+ }
253
+
254
+ setVisibleStartTime(newStart)
255
+ }
256
+
257
+ // Get the effective time range to display
258
+ const effectiveStartTime = isGlobal ? visibleStartTime : startTime
259
+ const effectiveEndTime = isGlobal ? visibleEndTime : endTime
260
+
261
+ return (
262
+ <>
263
+ <Box
264
+ sx={{ height: '120px', mb: 2 }}
265
+ ref={timelineRef}
266
+ onWheel={handleScroll}
267
+ >
268
+ <TimelineEditor
269
+ words={words}
270
+ startTime={effectiveStartTime}
271
+ endTime={effectiveEndTime}
272
+ onWordUpdate={onWordUpdate}
273
+ currentTime={currentTime}
274
+ onPlaySegment={onPlaySegment}
275
+ />
276
+ </Box>
277
+
278
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
279
+ <Typography variant="body2" color="text.secondary">
280
+ Original Time Range: {originalStartTime?.toFixed(2) ?? 'N/A'} - {originalEndTime?.toFixed(2) ?? 'N/A'}
281
+ <br />
282
+ Current Time Range: {currentStartTime?.toFixed(2) ?? 'N/A'} - {currentEndTime?.toFixed(2) ?? 'N/A'}
283
+ </Typography>
284
+
285
+ <Stack direction="row" spacing={1} alignItems="center">
286
+ {isGlobal && (
287
+ <>
288
+ <Tooltip title="Scroll Left">
289
+ <IconButton
290
+ onClick={handleScrollLeft}
291
+ disabled={visibleStartTime <= startTime}
292
+ size="small"
293
+ >
294
+ <ArrowBackIcon />
295
+ </IconButton>
296
+ </Tooltip>
297
+ <Tooltip title="Zoom Out (Show More Time)">
298
+ <IconButton
299
+ onClick={handleZoomOut}
300
+ disabled={zoomLevel >= (endTime - startTime)}
301
+ size="small"
302
+ >
303
+ <ZoomOutIcon />
304
+ </IconButton>
305
+ </Tooltip>
306
+ <Tooltip title="Zoom In (Show Less Time)">
307
+ <IconButton
308
+ onClick={handleZoomIn}
309
+ disabled={zoomLevel <= 2}
310
+ size="small"
311
+ >
312
+ <ZoomInIcon />
313
+ </IconButton>
314
+ </Tooltip>
315
+ <Tooltip title="Scroll Right">
316
+ <IconButton
317
+ onClick={handleScrollRight}
318
+ disabled={visibleEndTime >= endTime}
319
+ size="small"
320
+ >
321
+ <ArrowForwardIcon />
322
+ </IconButton>
323
+ </Tooltip>
324
+ <Tooltip
325
+ title={autoScrollEnabled ?
326
+ "Disable Auto-Page Turn During Playback (Alt+A)" :
327
+ "Enable Auto-Page Turn During Playback (Alt+A)"}
328
+ >
329
+ <IconButton
330
+ onClick={toggleAutoScroll}
331
+ color={autoScrollEnabled ? "primary" : "default"}
332
+ size="small"
333
+ >
334
+ {autoScrollEnabled ? <AutorenewIcon /> : <PauseCircleOutlineIcon />}
335
+ </IconButton>
336
+ </Tooltip>
337
+ <Tooltip title="Jump to Current Playback Position (Alt+J)">
338
+ <IconButton
339
+ onClick={jumpToCurrentTime}
340
+ disabled={!currentTime}
341
+ size="small"
342
+ >
343
+ <CenterFocusStrongIcon />
344
+ </IconButton>
345
+ </Tooltip>
346
+ </>
347
+ )}
348
+ <Button
349
+ variant={isManualSyncing ? "outlined" : "contained"}
350
+ onClick={startManualSync}
351
+ disabled={!onPlaySegment}
352
+ startIcon={isManualSyncing ? <CancelIcon /> : <PlayCircleOutlineIcon />}
353
+ color={isManualSyncing ? "error" : "primary"}
354
+ >
355
+ {isManualSyncing ? "Cancel Sync" : "Manual Sync"}
356
+ </Button>
357
+ {isManualSyncing && (
358
+ <Box>
359
+ <Typography variant="body2">
360
+ Word {syncWordIndex + 1} of {words.length}: <strong>{words[syncWordIndex]?.text || ''}</strong>
361
+ </Typography>
362
+ <Typography variant="caption" color="text.secondary">
363
+ {isSpacebarPressed ?
364
+ "Holding spacebar... Release when word ends" :
365
+ "Press spacebar when word starts (tap for short words, hold for long words)"}
366
+ </Typography>
367
+ </Box>
368
+ )}
369
+ </Stack>
370
+ </Box>
371
+ </>
372
+ )
373
+ }
@@ -0,0 +1,308 @@
1
+ import {
2
+ Box,
3
+ TextField,
4
+ IconButton,
5
+ Button,
6
+ Pagination,
7
+ Typography
8
+ } from '@mui/material'
9
+ import DeleteIcon from '@mui/icons-material/Delete'
10
+ import SplitIcon from '@mui/icons-material/CallSplit'
11
+ import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
12
+ import { Word } from '../types'
13
+ import { useState, memo, useMemo } from 'react'
14
+ import WordDivider from './WordDivider'
15
+
16
+ interface EditWordListProps {
17
+ words: Word[]
18
+ onWordUpdate: (index: number, updates: Partial<Word>) => void
19
+ onSplitWord: (index: number) => void
20
+ onMergeWords: (index: number) => void
21
+ onAddWord: (index?: number) => void
22
+ onRemoveWord: (index: number) => void
23
+ onSplitSegment?: (wordIndex: number) => void
24
+ onAddSegment?: (beforeIndex: number) => void
25
+ onMergeSegment?: (mergeWithNext: boolean) => void
26
+ isGlobal?: boolean
27
+ }
28
+
29
+ // Create a memoized word row component to prevent re-renders
30
+ const WordRow = memo(function WordRow({
31
+ word,
32
+ index,
33
+ onWordUpdate,
34
+ onSplitWord,
35
+ onRemoveWord,
36
+ wordsLength
37
+ }: {
38
+ word: Word
39
+ index: number
40
+ onWordUpdate: (index: number, updates: Partial<Word>) => void
41
+ onSplitWord: (index: number) => void
42
+ onRemoveWord: (index: number) => void
43
+ wordsLength: number
44
+ }) {
45
+ return (
46
+ <Box sx={{
47
+ display: 'flex',
48
+ gap: 2,
49
+ alignItems: 'center',
50
+ padding: '4px 0',
51
+ }}>
52
+ <TextField
53
+ label={`Word ${index}`}
54
+ value={word.text}
55
+ onChange={(e) => onWordUpdate(index, { text: e.target.value })}
56
+ fullWidth
57
+ size="small"
58
+ />
59
+ <TextField
60
+ label="Start Time"
61
+ value={word.start_time?.toFixed(2) ?? ''}
62
+ onChange={(e) => onWordUpdate(index, { start_time: parseFloat(e.target.value) })}
63
+ type="number"
64
+ inputProps={{ step: 0.01 }}
65
+ sx={{ width: '150px' }}
66
+ size="small"
67
+ />
68
+ <TextField
69
+ label="End Time"
70
+ value={word.end_time?.toFixed(2) ?? ''}
71
+ onChange={(e) => onWordUpdate(index, { end_time: parseFloat(e.target.value) })}
72
+ type="number"
73
+ inputProps={{ step: 0.01 }}
74
+ sx={{ width: '150px' }}
75
+ size="small"
76
+ />
77
+ <IconButton
78
+ onClick={() => onSplitWord(index)}
79
+ title="Split Word"
80
+ sx={{ color: 'primary.main' }}
81
+ size="small"
82
+ >
83
+ <SplitIcon fontSize="small" />
84
+ </IconButton>
85
+ <IconButton
86
+ onClick={() => onRemoveWord(index)}
87
+ disabled={wordsLength <= 1}
88
+ title="Remove Word"
89
+ sx={{ color: 'error.main' }}
90
+ size="small"
91
+ >
92
+ <DeleteIcon fontSize="small" />
93
+ </IconButton>
94
+ </Box>
95
+ );
96
+ });
97
+
98
+ // Memoized word item component that includes the word row and divider
99
+ const WordItem = memo(function WordItem({
100
+ word,
101
+ index,
102
+ onWordUpdate,
103
+ onSplitWord,
104
+ onRemoveWord,
105
+ onAddWord,
106
+ onMergeWords,
107
+ onSplitSegment,
108
+ onAddSegment,
109
+ onMergeSegment,
110
+ wordsLength,
111
+ isGlobal
112
+ }: {
113
+ word: Word
114
+ index: number
115
+ onWordUpdate: (index: number, updates: Partial<Word>) => void
116
+ onSplitWord: (index: number) => void
117
+ onRemoveWord: (index: number) => void
118
+ onAddWord: (index: number) => void
119
+ onMergeWords: (index: number) => void
120
+ onSplitSegment?: (index: number) => void
121
+ onAddSegment?: (index: number) => void
122
+ onMergeSegment?: (mergeWithNext: boolean) => void
123
+ wordsLength: number
124
+ isGlobal: boolean
125
+ }) {
126
+ return (
127
+ <Box key={word.id}>
128
+ <WordRow
129
+ word={word}
130
+ index={index}
131
+ onWordUpdate={onWordUpdate}
132
+ onSplitWord={onSplitWord}
133
+ onRemoveWord={onRemoveWord}
134
+ wordsLength={wordsLength}
135
+ />
136
+
137
+ {/* Word divider with merge/split functionality */}
138
+ {!isGlobal && (
139
+ <WordDivider
140
+ onAddWord={() => onAddWord(index)}
141
+ onMergeWords={() => onMergeWords(index)}
142
+ onSplitSegment={() => onSplitSegment?.(index)}
143
+ onAddSegmentAfter={
144
+ index === wordsLength - 1
145
+ ? () => onAddSegment?.(index + 1)
146
+ : undefined
147
+ }
148
+ onMergeSegment={
149
+ index === wordsLength - 1
150
+ ? () => onMergeSegment?.(true)
151
+ : undefined
152
+ }
153
+ canMerge={index < wordsLength - 1}
154
+ isLast={index === wordsLength - 1}
155
+ sx={{ ml: 15 }}
156
+ />
157
+ )}
158
+ {isGlobal && (
159
+ <WordDivider
160
+ onAddWord={() => onAddWord(index)}
161
+ onMergeWords={index < wordsLength - 1 ? () => onMergeWords(index) : undefined}
162
+ canMerge={index < wordsLength - 1}
163
+ sx={{ ml: 15 }}
164
+ />
165
+ )}
166
+ </Box>
167
+ );
168
+ });
169
+
170
+ export default function EditWordList({
171
+ words,
172
+ onWordUpdate,
173
+ onSplitWord,
174
+ onMergeWords,
175
+ onAddWord,
176
+ onRemoveWord,
177
+ onSplitSegment,
178
+ onAddSegment,
179
+ onMergeSegment,
180
+ isGlobal = false
181
+ }: EditWordListProps) {
182
+ const [replacementText, setReplacementText] = useState('')
183
+ const [page, setPage] = useState(1)
184
+ const pageSize = isGlobal ? 50 : words.length // Use pagination only in global mode
185
+
186
+ const handleReplaceAllWords = () => {
187
+ const newWords = replacementText.trim().split(/\s+/)
188
+ newWords.forEach((text, index) => {
189
+ if (index < words.length) {
190
+ onWordUpdate(index, { text })
191
+ }
192
+ })
193
+ setReplacementText('')
194
+ }
195
+
196
+ // Calculate pagination values
197
+ const pageCount = Math.ceil(words.length / pageSize)
198
+ const startIndex = (page - 1) * pageSize
199
+ const endIndex = Math.min(startIndex + pageSize, words.length)
200
+
201
+ // Get the words for the current page
202
+ const visibleWords = useMemo(() => {
203
+ return isGlobal
204
+ ? words.slice(startIndex, endIndex)
205
+ : words;
206
+ }, [words, isGlobal, startIndex, endIndex]);
207
+
208
+ // Handle page change
209
+ const handlePageChange = (_event: React.ChangeEvent<unknown>, value: number) => {
210
+ setPage(value);
211
+ };
212
+
213
+ return (
214
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flexGrow: 1, minHeight: 0 }}>
215
+ {/* Initial divider with Add Segment Before button */}
216
+ {!isGlobal && (
217
+ <WordDivider
218
+ onAddWord={() => onAddWord(-1)}
219
+ onAddSegmentBefore={() => onAddSegment?.(0)}
220
+ onMergeSegment={() => onMergeSegment?.(false)}
221
+ isFirst={true}
222
+ sx={{ ml: 15 }}
223
+ />
224
+ )}
225
+ {isGlobal && (
226
+ <WordDivider
227
+ onAddWord={() => onAddWord(-1)}
228
+ sx={{ ml: 15 }}
229
+ />
230
+ )}
231
+
232
+ {/* Word list with scrolling */}
233
+ <Box sx={{
234
+ display: 'flex',
235
+ flexDirection: 'column',
236
+ gap: 0.5,
237
+ flexGrow: 1,
238
+ overflowY: 'auto',
239
+ mb: 0,
240
+ pt: 1,
241
+ '&::-webkit-scrollbar': {
242
+ width: '8px',
243
+ },
244
+ '&::-webkit-scrollbar-thumb': {
245
+ backgroundColor: 'rgba(0,0,0,0.2)',
246
+ borderRadius: '4px',
247
+ },
248
+ scrollbarWidth: 'thin',
249
+ msOverflowStyle: 'autohiding-scrollbar',
250
+ }}>
251
+ {visibleWords.map((word, visibleIndex) => {
252
+ const actualIndex = isGlobal ? startIndex + visibleIndex : visibleIndex;
253
+ return (
254
+ <WordItem
255
+ key={word.id}
256
+ word={word}
257
+ index={actualIndex}
258
+ onWordUpdate={onWordUpdate}
259
+ onSplitWord={onSplitWord}
260
+ onRemoveWord={onRemoveWord}
261
+ onAddWord={onAddWord}
262
+ onMergeWords={onMergeWords}
263
+ onSplitSegment={onSplitSegment}
264
+ onAddSegment={onAddSegment}
265
+ onMergeSegment={onMergeSegment}
266
+ wordsLength={words.length}
267
+ isGlobal={isGlobal}
268
+ />
269
+ );
270
+ })}
271
+ </Box>
272
+
273
+ {/* Pagination controls (only in global mode) */}
274
+ {isGlobal && pageCount > 1 && (
275
+ <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', mb: 1 }}>
276
+ <Pagination
277
+ count={pageCount}
278
+ page={page}
279
+ onChange={handlePageChange}
280
+ color="primary"
281
+ size="small"
282
+ />
283
+ <Typography variant="body2" sx={{ ml: 2 }}>
284
+ Showing words {startIndex + 1}-{endIndex} of {words.length}
285
+ </Typography>
286
+ </Box>
287
+ )}
288
+
289
+ <Box sx={{ display: 'flex', gap: 2, mb: 0.6 }}>
290
+ <TextField
291
+ value={replacementText}
292
+ onChange={(e) => setReplacementText(e.target.value)}
293
+ placeholder="Replace all words"
294
+ size="small"
295
+ sx={{ flexGrow: 1, maxWidth: 'calc(100% - 140px)' }}
296
+ />
297
+ <Button
298
+ onClick={handleReplaceAllWords}
299
+ startIcon={<AutoFixHighIcon />}
300
+ size="small"
301
+ sx={{ whiteSpace: 'nowrap' }}
302
+ >
303
+ Replace All
304
+ </Button>
305
+ </Box>
306
+ </Box>
307
+ )
308
+ }