lyrics-transcriber 0.47.0__py3-none-any.whl → 0.49.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.
@@ -5,7 +5,7 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>Lyrics Transcriber Analyzer</title>
8
- <script type="module" crossorigin src="/assets/index-2vK-qVJS.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-BpvPgWoc.js"></script>
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -1,8 +1,10 @@
1
- import { Box, Button, Typography, useMediaQuery, useTheme, Switch, FormControlLabel, Tooltip, Paper } from '@mui/material'
1
+ import { Box, Button, Typography, useMediaQuery, useTheme, Switch, FormControlLabel, Tooltip, Paper, IconButton } from '@mui/material'
2
2
  import LockIcon from '@mui/icons-material/Lock'
3
3
  import UploadFileIcon from '@mui/icons-material/UploadFile'
4
4
  import FindReplaceIcon from '@mui/icons-material/FindReplace'
5
5
  import EditIcon from '@mui/icons-material/Edit'
6
+ import UndoIcon from '@mui/icons-material/Undo'
7
+ import RedoIcon from '@mui/icons-material/Redo'
6
8
  import { CorrectionData, InteractionMode } from '../types'
7
9
  import CorrectionMetrics from './CorrectionMetrics'
8
10
  import ModeSelector from './ModeSelector'
@@ -29,6 +31,10 @@ interface HeaderProps {
29
31
  onHandlerClick?: (handler: string) => void
30
32
  onFindReplace?: () => void
31
33
  onEditAll?: () => void
34
+ onUndo: () => void
35
+ onRedo: () => void
36
+ canUndo: boolean
37
+ canRedo: boolean
32
38
  }
33
39
 
34
40
  export default function Header({
@@ -46,6 +52,10 @@ export default function Header({
46
52
  onHandlerClick,
47
53
  onFindReplace,
48
54
  onEditAll,
55
+ onUndo,
56
+ onRedo,
57
+ canUndo,
58
+ canRedo,
49
59
  }: HeaderProps) {
50
60
  const theme = useTheme()
51
61
  const isMobile = useMediaQuery(theme.breakpoints.down('md'))
@@ -102,8 +112,8 @@ export default function Header({
102
112
  </Box>
103
113
  )}
104
114
 
105
- <Box sx={{
106
- display: 'flex',
115
+ <Box sx={{
116
+ display: 'flex',
107
117
  flexDirection: isMobile ? 'column' : 'row',
108
118
  gap: 1,
109
119
  justifyContent: 'space-between',
@@ -220,8 +230,8 @@ export default function Header({
220
230
  </Box>
221
231
 
222
232
  <Paper sx={{ p: 0.8, mb: 1 }}>
223
- <Box sx={{
224
- display: 'flex',
233
+ <Box sx={{
234
+ display: 'flex',
225
235
  flexDirection: isMobile ? 'column' : 'row',
226
236
  gap: 1,
227
237
  alignItems: isMobile ? 'flex-start' : 'center',
@@ -239,6 +249,46 @@ export default function Header({
239
249
  effectiveMode={effectiveMode}
240
250
  onChange={onModeChange}
241
251
  />
252
+ {!isReadOnly && (
253
+ <Box sx={{ display: 'flex', height: '32px' }}>
254
+ <Tooltip title="Undo (Cmd/Ctrl+Z)">
255
+ <span>
256
+ <IconButton
257
+ size="small"
258
+ onClick={onUndo}
259
+ disabled={!canUndo}
260
+ sx={{
261
+ border: `1px solid ${theme.palette.divider}`,
262
+ borderRadius: '4px',
263
+ mx: 0.25,
264
+ height: '32px',
265
+ width: '32px'
266
+ }}
267
+ >
268
+ <UndoIcon fontSize="small" />
269
+ </IconButton>
270
+ </span>
271
+ </Tooltip>
272
+ <Tooltip title="Redo (Cmd/Ctrl+Shift+Z)">
273
+ <span>
274
+ <IconButton
275
+ size="small"
276
+ onClick={onRedo}
277
+ disabled={!canRedo}
278
+ sx={{
279
+ border: `1px solid ${theme.palette.divider}`,
280
+ borderRadius: '4px',
281
+ mx: 0.25,
282
+ height: '32px',
283
+ width: '32px'
284
+ }}
285
+ >
286
+ <RedoIcon fontSize="small" />
287
+ </IconButton>
288
+ </span>
289
+ </Tooltip>
290
+ </Box>
291
+ )}
242
292
  {!isReadOnly && (
243
293
  <Button
244
294
  variant="outlined"
@@ -20,7 +20,6 @@ import {
20
20
  addSegmentBefore,
21
21
  splitSegment,
22
22
  deleteSegment,
23
- updateSegment,
24
23
  mergeSegment,
25
24
  findAndReplace,
26
25
  deleteWord
@@ -80,6 +79,7 @@ interface MemoizedTranscriptionViewProps {
80
79
  currentTime: number
81
80
  anchors: AnchorSequence[]
82
81
  disableHighlighting: boolean
82
+ onDataChange?: (updatedData: CorrectionData) => void
83
83
  }
84
84
 
85
85
  // Create a memoized TranscriptionView component
@@ -94,7 +94,8 @@ const MemoizedTranscriptionView = memo(function MemoizedTranscriptionView({
94
94
  onPlaySegment,
95
95
  currentTime,
96
96
  anchors,
97
- disableHighlighting
97
+ disableHighlighting,
98
+ onDataChange
98
99
  }: MemoizedTranscriptionViewProps) {
99
100
  return (
100
101
  <TranscriptionView
@@ -108,6 +109,7 @@ const MemoizedTranscriptionView = memo(function MemoizedTranscriptionView({
108
109
  onPlaySegment={onPlaySegment}
109
110
  currentTime={disableHighlighting ? undefined : currentTime}
110
111
  anchors={anchors}
112
+ onDataChange={onDataChange}
111
113
  />
112
114
  );
113
115
  });
@@ -183,6 +185,10 @@ interface MemoizedHeaderProps {
183
185
  onAddLyrics?: () => void
184
186
  onFindReplace?: () => void
185
187
  onEditAll?: () => void
188
+ onUndo: () => void
189
+ onRedo: () => void
190
+ canUndo: boolean
191
+ canRedo: boolean
186
192
  }
187
193
 
188
194
  // Create a memoized Header component
@@ -200,7 +206,11 @@ const MemoizedHeader = memo(function MemoizedHeader({
200
206
  isUpdatingHandlers,
201
207
  onHandlerClick,
202
208
  onFindReplace,
203
- onEditAll
209
+ onEditAll,
210
+ onUndo,
211
+ onRedo,
212
+ canUndo,
213
+ canRedo
204
214
  }: MemoizedHeaderProps) {
205
215
  return (
206
216
  <Header
@@ -218,6 +228,10 @@ const MemoizedHeader = memo(function MemoizedHeader({
218
228
  onHandlerClick={onHandlerClick}
219
229
  onFindReplace={onFindReplace}
220
230
  onEditAll={onEditAll}
231
+ onUndo={onUndo}
232
+ onRedo={onRedo}
233
+ canUndo={canUndo}
234
+ canRedo={canRedo}
221
235
  />
222
236
  );
223
237
  });
@@ -234,7 +248,6 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
234
248
  return availableSources.length > 0 ? availableSources[0] : ''
235
249
  })
236
250
  const [isReviewComplete, setIsReviewComplete] = useState(false)
237
- const [data, setData] = useState(initialData)
238
251
  const [originalData] = useState(() => JSON.parse(JSON.stringify(initialData)))
239
252
  const [interactionMode, setInteractionMode] = useState<InteractionMode>('edit')
240
253
  const [isShiftPressed, setIsShiftPressed] = useState(false)
@@ -260,6 +273,35 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
260
273
  const theme = useTheme()
261
274
  const isMobile = useMediaQuery(theme.breakpoints.down('md'))
262
275
 
276
+ // State history for Undo/Redo
277
+ const [history, setHistory] = useState<CorrectionData[]>([initialData])
278
+ const [historyIndex, setHistoryIndex] = useState(0)
279
+
280
+ // Derived state: the current data based on history index
281
+ const data = history[historyIndex];
282
+
283
+ // Function to update data and manage history
284
+ const updateDataWithHistory = useCallback((newData: CorrectionData, actionDescription?: string) => {
285
+ if (debugLog) {
286
+ console.log(`[DEBUG] updateDataWithHistory: Action - ${actionDescription || 'Unknown'}. Current index: ${historyIndex}, History length: ${history.length}`);
287
+ }
288
+ const newHistory = history.slice(0, historyIndex + 1)
289
+ const deepCopiedNewData = JSON.parse(JSON.stringify(newData));
290
+
291
+ newHistory.push(deepCopiedNewData)
292
+ setHistory(newHistory)
293
+ setHistoryIndex(newHistory.length - 1)
294
+ if (debugLog) {
295
+ console.log(`[DEBUG] updateDataWithHistory: History updated. New index: ${newHistory.length - 1}, New length: ${newHistory.length}`);
296
+ }
297
+ }, [history, historyIndex])
298
+
299
+ // Reset history when initial data changes (e.g., new file loaded)
300
+ useEffect(() => {
301
+ setHistory([initialData])
302
+ setHistoryIndex(0)
303
+ }, [initialData])
304
+
263
305
  // Update debug logging to use new ID-based structure
264
306
  useEffect(() => {
265
307
  if (debugLog) {
@@ -282,16 +324,18 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
282
324
  useEffect(() => {
283
325
  const savedData = loadSavedData(initialData)
284
326
  if (savedData && window.confirm('Found saved progress for this song. Would you like to restore it?')) {
285
- setData(savedData)
327
+ // Replace history with saved data as the initial state
328
+ setHistory([savedData])
329
+ setHistoryIndex(0)
286
330
  }
287
- }, [initialData])
331
+ }, [initialData]) // Keep dependency only on initialData
288
332
 
289
- // Save data
333
+ // Save data - This should save the *current* state, not affect history
290
334
  useEffect(() => {
291
335
  if (!isReadOnly) {
292
- saveData(data, initialData)
336
+ saveData(data, initialData) // Use 'data' derived from history and the initialData prop
293
337
  }
294
- }, [data, isReadOnly, initialData])
338
+ }, [data, isReadOnly, initialData]) // Correct dependencies
295
339
 
296
340
  // Keyboard handlers
297
341
  useEffect(() => {
@@ -377,8 +421,8 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
377
421
  if (effectiveMode === 'delete_word') {
378
422
  // Use the shared deleteWord utility function
379
423
  const newData = deleteWord(data, info.word_id);
380
- setData(newData);
381
-
424
+ updateDataWithHistory(newData, 'delete word'); // Update history
425
+
382
426
  // Flash to indicate the word was deleted
383
427
  handleFlash('word');
384
428
  return;
@@ -508,19 +552,47 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
508
552
  });
509
553
  }
510
554
  }
511
- }, [data, effectiveMode, setModalContent, handleFlash, deleteWord]);
555
+ }, [data, effectiveMode, setModalContent, handleFlash, deleteWord, updateDataWithHistory]);
512
556
 
513
557
  const handleUpdateSegment = useCallback((updatedSegment: LyricsSegment) => {
514
558
  if (!editModalSegment) return
515
- const newData = updateSegment(data, editModalSegment.index, updatedSegment)
516
- setData(newData)
559
+
560
+ if (debugLog) {
561
+ console.log('[DEBUG] handleUpdateSegment: Updating history from modal save', {
562
+ segmentIndex: editModalSegment.index,
563
+ currentHistoryIndex: historyIndex,
564
+ currentHistoryLength: history.length,
565
+ currentSegmentText: history[historyIndex]?.corrected_segments[editModalSegment.index]?.text,
566
+ updatedSegmentText: updatedSegment.text
567
+ });
568
+ }
569
+
570
+ // --- Ensure Immutability Here ---
571
+ const currentData = history[historyIndex];
572
+ const newSegments = currentData.corrected_segments.map((segment, i) =>
573
+ i === editModalSegment.index ? updatedSegment : segment
574
+ );
575
+ const newDataImmutable: CorrectionData = {
576
+ ...currentData,
577
+ corrected_segments: newSegments,
578
+ };
579
+ // --- End Immutability Ensure ---
580
+
581
+ updateDataWithHistory(newDataImmutable, 'update segment');
582
+
583
+ if (debugLog) {
584
+ console.log('[DEBUG] handleUpdateSegment: History updated (async)', {
585
+ newHistoryIndex: historyIndex + 1,
586
+ newHistoryLength: history.length - historyIndex === 1 ? history.length + 1 : historyIndex + 2
587
+ });
588
+ }
517
589
  setEditModalSegment(null)
518
- }, [data, editModalSegment])
590
+ }, [history, historyIndex, editModalSegment, updateDataWithHistory])
519
591
 
520
592
  const handleDeleteSegment = useCallback((segmentIndex: number) => {
521
593
  const newData = deleteSegment(data, segmentIndex)
522
- setData(newData)
523
- }, [data])
594
+ updateDataWithHistory(newData, 'delete segment')
595
+ }, [data, updateDataWithHistory])
524
596
 
525
597
  const handleFinishReview = useCallback(() => {
526
598
  setIsReviewModalOpen(true)
@@ -556,7 +628,9 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
556
628
  const handleResetCorrections = useCallback(() => {
557
629
  if (window.confirm('Are you sure you want to reset all corrections? This cannot be undone.')) {
558
630
  clearSavedData(initialData)
559
- setData(JSON.parse(JSON.stringify(initialData)))
631
+ // Reset history to the original initial data
632
+ setHistory([JSON.parse(JSON.stringify(initialData))])
633
+ setHistoryIndex(0)
560
634
  setModalContent(null)
561
635
  setFlashingType(null)
562
636
  setHighlightInfo(null)
@@ -566,22 +640,22 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
566
640
 
567
641
  const handleAddSegment = useCallback((beforeIndex: number) => {
568
642
  const newData = addSegmentBefore(data, beforeIndex)
569
- setData(newData)
570
- }, [data])
643
+ updateDataWithHistory(newData, 'add segment')
644
+ }, [data, updateDataWithHistory])
571
645
 
572
646
  const handleSplitSegment = useCallback((segmentIndex: number, afterWordIndex: number) => {
573
647
  const newData = splitSegment(data, segmentIndex, afterWordIndex)
574
648
  if (newData) {
575
- setData(newData)
649
+ updateDataWithHistory(newData, 'split segment')
576
650
  setEditModalSegment(null)
577
651
  }
578
- }, [data])
652
+ }, [data, updateDataWithHistory])
579
653
 
580
654
  const handleMergeSegment = useCallback((segmentIndex: number, mergeWithNext: boolean) => {
581
655
  const newData = mergeSegment(data, segmentIndex, mergeWithNext)
582
- setData(newData)
656
+ updateDataWithHistory(newData, 'merge segment')
583
657
  setEditModalSegment(null)
584
- }, [data])
658
+ }, [data, updateDataWithHistory])
585
659
 
586
660
  const handleHandlerToggle = useCallback(async (handler: string, enabled: boolean) => {
587
661
  if (!apiClient) return
@@ -603,7 +677,8 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
603
677
  const newData = await apiClient.updateHandlers(Array.from(currentEnabled))
604
678
 
605
679
  // Update local state with new correction data
606
- setData(newData)
680
+ // This API call returns the *entire* new state, so treat it as a single history step
681
+ updateDataWithHistory(newData, `toggle handler ${handler}`); // Update history
607
682
 
608
683
  // Clear any existing modals or highlights
609
684
  setModalContent(null)
@@ -618,7 +693,7 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
618
693
  } finally {
619
694
  setIsUpdatingHandlers(false);
620
695
  }
621
- }, [apiClient, data.metadata.enabled_handlers, handleFlash])
696
+ }, [apiClient, data.metadata.enabled_handlers, handleFlash, updateDataWithHistory])
622
697
 
623
698
  const handleHandlerClick = useCallback((handler: string) => {
624
699
  if (debugLog) {
@@ -658,21 +733,22 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
658
733
  try {
659
734
  setIsAddingLyrics(true)
660
735
  const newData = await apiClient.addLyrics(source, lyrics)
661
- setData(newData)
736
+ // This API call returns the *entire* new state
737
+ updateDataWithHistory(newData, 'add lyrics'); // Update history
662
738
  } finally {
663
739
  setIsAddingLyrics(false)
664
740
  }
665
- }, [apiClient])
741
+ }, [apiClient, updateDataWithHistory])
666
742
 
667
743
  const handleFindReplace = (findText: string, replaceText: string, options: { caseSensitive: boolean, useRegex: boolean, fullTextMode: boolean }) => {
668
744
  const newData = findAndReplace(data, findText, replaceText, options)
669
- setData(newData)
745
+ updateDataWithHistory(newData, 'find/replace'); // Update history
670
746
  }
671
747
 
672
748
  // Add handler for Edit All functionality
673
749
  const handleEditAll = useCallback(() => {
674
750
  console.log('EditAll - Starting process');
675
-
751
+
676
752
  // Create empty placeholder segments to prevent the modal from closing
677
753
  const placeholderSegment: LyricsSegment = {
678
754
  id: 'loading-placeholder',
@@ -681,31 +757,31 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
681
757
  start_time: 0,
682
758
  end_time: 1
683
759
  };
684
-
760
+
685
761
  // Set placeholder segments first
686
762
  setGlobalEditSegment(placeholderSegment);
687
763
  setOriginalGlobalSegment(placeholderSegment);
688
-
764
+
689
765
  // Show loading state
690
766
  setIsLoadingGlobalEdit(true);
691
767
  console.log('EditAll - Set loading state to true');
692
-
768
+
693
769
  // Open the modal with placeholder data
694
770
  setIsEditAllModalOpen(true);
695
771
  console.log('EditAll - Set modal open to true');
696
-
772
+
697
773
  // Use requestAnimationFrame to ensure the modal with loading state is rendered
698
774
  // before doing the expensive operation
699
775
  requestAnimationFrame(() => {
700
776
  console.log('EditAll - Inside requestAnimationFrame');
701
-
777
+
702
778
  // Use setTimeout to allow the modal to render before doing the expensive operation
703
779
  setTimeout(() => {
704
780
  console.log('EditAll - Inside setTimeout, starting data processing');
705
-
781
+
706
782
  try {
707
783
  console.time('EditAll - Data processing');
708
-
784
+
709
785
  // Create a combined segment with all words from all segments
710
786
  const allWords = data.corrected_segments.flatMap(segment => segment.words)
711
787
  console.log(`EditAll - Collected ${allWords.length} words from all segments`);
@@ -731,18 +807,18 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
731
807
  // Store the original global segment for reset functionality
732
808
  setGlobalEditSegment(globalSegment)
733
809
  console.log('EditAll - Set global edit segment');
734
-
810
+
735
811
  setOriginalGlobalSegment(JSON.parse(JSON.stringify(globalSegment)))
736
812
  console.log('EditAll - Set original global segment');
737
-
813
+
738
814
  // Create the original transcribed global segment for Un-Correct functionality
739
815
  if (originalData.original_segments) {
740
816
  console.log('EditAll - Processing original segments for Un-Correct functionality');
741
-
817
+
742
818
  // Get all words from original segments
743
819
  const originalWords = originalData.original_segments.flatMap((segment: LyricsSegment) => segment.words)
744
820
  console.log(`EditAll - Collected ${originalWords.length} words from original segments`);
745
-
821
+
746
822
  // Sort words by start time
747
823
  const sortedOriginalWords = [...originalWords].sort((a, b) => {
748
824
  const aTime = a.start_time ?? 0
@@ -750,7 +826,7 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
750
826
  return aTime - bTime
751
827
  })
752
828
  console.log('EditAll - Sorted original words by start time');
753
-
829
+
754
830
  // Create the original transcribed global segment
755
831
  const originalTranscribedGlobal: LyricsSegment = {
756
832
  id: 'original-transcribed-global',
@@ -760,14 +836,14 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
760
836
  end_time: sortedOriginalWords[sortedOriginalWords.length - 1]?.end_time ?? null
761
837
  }
762
838
  console.log('EditAll - Created original transcribed global segment');
763
-
839
+
764
840
  setOriginalTranscribedGlobalSegment(originalTranscribedGlobal)
765
841
  console.log('EditAll - Set original transcribed global segment');
766
842
  } else {
767
843
  setOriginalTranscribedGlobalSegment(null)
768
844
  console.log('EditAll - No original segments found, set original transcribed global segment to null');
769
845
  }
770
-
846
+
771
847
  console.timeEnd('EditAll - Data processing');
772
848
  } catch (error) {
773
849
  console.error('Error preparing global edit data:', error);
@@ -864,15 +940,49 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
864
940
  })
865
941
 
866
942
  // Update the data with the new segments
867
- setData({
943
+ const newData = {
868
944
  ...data,
869
945
  corrected_segments: updatedSegments
870
- })
946
+ };
947
+ updateDataWithHistory(newData, 'edit all'); // Update history
871
948
 
872
949
  // Close the modal
873
950
  setIsEditAllModalOpen(false)
874
951
  setGlobalEditSegment(null)
875
- }, [data])
952
+ }, [data, updateDataWithHistory])
953
+
954
+ // Undo/Redo handlers
955
+ const handleUndo = useCallback(() => {
956
+ if (historyIndex > 0) {
957
+ const newIndex = historyIndex - 1;
958
+ if (debugLog) {
959
+ console.log(`[DEBUG] Undo: moving from index ${historyIndex} to ${newIndex}. History length: ${history.length}`);
960
+ }
961
+ setHistoryIndex(newIndex);
962
+ } else {
963
+ if (debugLog) {
964
+ console.log(`[DEBUG] Undo: already at the beginning (index ${historyIndex})`);
965
+ }
966
+ }
967
+ }, [historyIndex, history])
968
+
969
+ const handleRedo = useCallback(() => {
970
+ if (historyIndex < history.length - 1) {
971
+ const newIndex = historyIndex + 1;
972
+ if (debugLog) {
973
+ console.log(`[DEBUG] Redo: moving from index ${historyIndex} to ${newIndex}. History length: ${history.length}`);
974
+ }
975
+ setHistoryIndex(newIndex);
976
+ } else {
977
+ if (debugLog) {
978
+ console.log(`[DEBUG] Redo: already at the end (index ${historyIndex}, history length ${history.length})`);
979
+ }
980
+ }
981
+ }, [historyIndex, history])
982
+
983
+ // Determine if Undo/Redo is possible
984
+ const canUndo = historyIndex > 0
985
+ const canRedo = historyIndex < history.length - 1
876
986
 
877
987
  // Memoize the metric click handlers
878
988
  const metricClickHandlers = useMemo(() => ({
@@ -884,6 +994,72 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
884
994
  // Determine if any modal is open to disable highlighting
885
995
  const isAnyModalOpenMemo = useMemo(() => isAnyModalOpen, [isAnyModalOpen]);
886
996
 
997
+ // Update keyboard handlers to include Undo/Redo shortcuts (Cmd/Ctrl + Z, Cmd/Ctrl + Shift + Z)
998
+ useEffect(() => {
999
+ const { currentModalHandler } = getModalState()
1000
+
1001
+ if (debugLog) {
1002
+ console.log('LyricsAnalyzer - Setting up keyboard effect (incl. Undo/Redo)', {
1003
+ isAnyModalOpen,
1004
+ hasSpacebarHandler: !!currentModalHandler
1005
+ })
1006
+ }
1007
+
1008
+ const { handleKeyDown: baseHandleKeyDown, handleKeyUp, cleanup } = setupKeyboardHandlers({
1009
+ setIsShiftPressed,
1010
+ setIsCtrlPressed
1011
+ })
1012
+
1013
+ const handleKeyDown = (e: KeyboardEvent) => {
1014
+ // Prevent Undo/Redo if a modal is open or input/textarea has focus
1015
+ const targetElement = e.target as HTMLElement;
1016
+ const isInputFocused = targetElement.tagName === 'INPUT' || targetElement.tagName === 'TEXTAREA';
1017
+
1018
+ if (!isAnyModalOpen && !isInputFocused) {
1019
+ const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
1020
+ const modifierKey = isMac ? e.metaKey : e.ctrlKey;
1021
+
1022
+ if (modifierKey && e.key.toLowerCase() === 'z') {
1023
+ e.preventDefault();
1024
+ if (e.shiftKey) {
1025
+ if (canRedo) handleRedo();
1026
+ } else {
1027
+ if (canUndo) handleUndo();
1028
+ }
1029
+ return; // Prevent base handler if we handled undo/redo
1030
+ }
1031
+ }
1032
+
1033
+ // Call original handler for other keys or when conditions not met
1034
+ baseHandleKeyDown(e);
1035
+ };
1036
+
1037
+ // Always add keyboard listeners
1038
+ if (debugLog) {
1039
+ console.log('LyricsAnalyzer - Adding keyboard event listeners (incl. Undo/Redo)')
1040
+ }
1041
+ window.addEventListener('keydown', handleKeyDown)
1042
+ window.addEventListener('keyup', handleKeyUp)
1043
+
1044
+ // Reset modifier states when a modal opens
1045
+ if (isAnyModalOpen) {
1046
+ setIsShiftPressed(false)
1047
+ setIsCtrlPressed(false)
1048
+ }
1049
+
1050
+ // Cleanup function
1051
+ return () => {
1052
+ if (debugLog) {
1053
+ console.log('LyricsAnalyzer - Cleanup effect running (incl. Undo/Redo)')
1054
+ }
1055
+ window.removeEventListener('keydown', handleKeyDown)
1056
+ window.removeEventListener('keyup', handleKeyUp)
1057
+ document.body.style.userSelect = ''
1058
+ // Call the cleanup function to remove window blur/focus listeners
1059
+ cleanup()
1060
+ }
1061
+ }, [setIsShiftPressed, setIsCtrlPressed, isAnyModalOpen, handleUndo, handleRedo, canUndo, canRedo]);
1062
+
887
1063
  return (
888
1064
  <Box sx={{
889
1065
  p: 1,
@@ -907,6 +1083,10 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
907
1083
  onAddLyrics={() => setIsAddLyricsModalOpen(true)}
908
1084
  onFindReplace={() => setIsFindReplaceModalOpen(true)}
909
1085
  onEditAll={handleEditAll}
1086
+ onUndo={handleUndo}
1087
+ onRedo={handleRedo}
1088
+ canUndo={canUndo}
1089
+ canRedo={canRedo}
910
1090
  />
911
1091
 
912
1092
  <Grid container direction={isMobile ? 'column' : 'row'}>
@@ -923,6 +1103,11 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
923
1103
  currentTime={currentAudioTime}
924
1104
  anchors={data.anchor_sequences}
925
1105
  disableHighlighting={isAnyModalOpenMemo}
1106
+ onDataChange={(updatedData) => {
1107
+ // Direct data change from TranscriptionView (e.g., drag-and-drop)
1108
+ // needs to update history
1109
+ updateDataWithHistory(updatedData, 'direct data change');
1110
+ }}
926
1111
  />
927
1112
  {!isReadOnly && apiClient && (
928
1113
  <Box sx={{