lyrics-transcriber 0.48.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-BvRLUQmZ.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
@@ -186,6 +185,10 @@ interface MemoizedHeaderProps {
186
185
  onAddLyrics?: () => void
187
186
  onFindReplace?: () => void
188
187
  onEditAll?: () => void
188
+ onUndo: () => void
189
+ onRedo: () => void
190
+ canUndo: boolean
191
+ canRedo: boolean
189
192
  }
190
193
 
191
194
  // Create a memoized Header component
@@ -203,7 +206,11 @@ const MemoizedHeader = memo(function MemoizedHeader({
203
206
  isUpdatingHandlers,
204
207
  onHandlerClick,
205
208
  onFindReplace,
206
- onEditAll
209
+ onEditAll,
210
+ onUndo,
211
+ onRedo,
212
+ canUndo,
213
+ canRedo
207
214
  }: MemoizedHeaderProps) {
208
215
  return (
209
216
  <Header
@@ -221,6 +228,10 @@ const MemoizedHeader = memo(function MemoizedHeader({
221
228
  onHandlerClick={onHandlerClick}
222
229
  onFindReplace={onFindReplace}
223
230
  onEditAll={onEditAll}
231
+ onUndo={onUndo}
232
+ onRedo={onRedo}
233
+ canUndo={canUndo}
234
+ canRedo={canRedo}
224
235
  />
225
236
  );
226
237
  });
@@ -237,7 +248,6 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
237
248
  return availableSources.length > 0 ? availableSources[0] : ''
238
249
  })
239
250
  const [isReviewComplete, setIsReviewComplete] = useState(false)
240
- const [data, setData] = useState(initialData)
241
251
  const [originalData] = useState(() => JSON.parse(JSON.stringify(initialData)))
242
252
  const [interactionMode, setInteractionMode] = useState<InteractionMode>('edit')
243
253
  const [isShiftPressed, setIsShiftPressed] = useState(false)
@@ -263,6 +273,35 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
263
273
  const theme = useTheme()
264
274
  const isMobile = useMediaQuery(theme.breakpoints.down('md'))
265
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
+
266
305
  // Update debug logging to use new ID-based structure
267
306
  useEffect(() => {
268
307
  if (debugLog) {
@@ -285,16 +324,18 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
285
324
  useEffect(() => {
286
325
  const savedData = loadSavedData(initialData)
287
326
  if (savedData && window.confirm('Found saved progress for this song. Would you like to restore it?')) {
288
- setData(savedData)
327
+ // Replace history with saved data as the initial state
328
+ setHistory([savedData])
329
+ setHistoryIndex(0)
289
330
  }
290
- }, [initialData])
331
+ }, [initialData]) // Keep dependency only on initialData
291
332
 
292
- // Save data
333
+ // Save data - This should save the *current* state, not affect history
293
334
  useEffect(() => {
294
335
  if (!isReadOnly) {
295
- saveData(data, initialData)
336
+ saveData(data, initialData) // Use 'data' derived from history and the initialData prop
296
337
  }
297
- }, [data, isReadOnly, initialData])
338
+ }, [data, isReadOnly, initialData]) // Correct dependencies
298
339
 
299
340
  // Keyboard handlers
300
341
  useEffect(() => {
@@ -380,8 +421,8 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
380
421
  if (effectiveMode === 'delete_word') {
381
422
  // Use the shared deleteWord utility function
382
423
  const newData = deleteWord(data, info.word_id);
383
- setData(newData);
384
-
424
+ updateDataWithHistory(newData, 'delete word'); // Update history
425
+
385
426
  // Flash to indicate the word was deleted
386
427
  handleFlash('word');
387
428
  return;
@@ -511,19 +552,47 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
511
552
  });
512
553
  }
513
554
  }
514
- }, [data, effectiveMode, setModalContent, handleFlash, deleteWord]);
555
+ }, [data, effectiveMode, setModalContent, handleFlash, deleteWord, updateDataWithHistory]);
515
556
 
516
557
  const handleUpdateSegment = useCallback((updatedSegment: LyricsSegment) => {
517
558
  if (!editModalSegment) return
518
- const newData = updateSegment(data, editModalSegment.index, updatedSegment)
519
- 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
+ }
520
589
  setEditModalSegment(null)
521
- }, [data, editModalSegment])
590
+ }, [history, historyIndex, editModalSegment, updateDataWithHistory])
522
591
 
523
592
  const handleDeleteSegment = useCallback((segmentIndex: number) => {
524
593
  const newData = deleteSegment(data, segmentIndex)
525
- setData(newData)
526
- }, [data])
594
+ updateDataWithHistory(newData, 'delete segment')
595
+ }, [data, updateDataWithHistory])
527
596
 
528
597
  const handleFinishReview = useCallback(() => {
529
598
  setIsReviewModalOpen(true)
@@ -559,7 +628,9 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
559
628
  const handleResetCorrections = useCallback(() => {
560
629
  if (window.confirm('Are you sure you want to reset all corrections? This cannot be undone.')) {
561
630
  clearSavedData(initialData)
562
- setData(JSON.parse(JSON.stringify(initialData)))
631
+ // Reset history to the original initial data
632
+ setHistory([JSON.parse(JSON.stringify(initialData))])
633
+ setHistoryIndex(0)
563
634
  setModalContent(null)
564
635
  setFlashingType(null)
565
636
  setHighlightInfo(null)
@@ -569,22 +640,22 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
569
640
 
570
641
  const handleAddSegment = useCallback((beforeIndex: number) => {
571
642
  const newData = addSegmentBefore(data, beforeIndex)
572
- setData(newData)
573
- }, [data])
643
+ updateDataWithHistory(newData, 'add segment')
644
+ }, [data, updateDataWithHistory])
574
645
 
575
646
  const handleSplitSegment = useCallback((segmentIndex: number, afterWordIndex: number) => {
576
647
  const newData = splitSegment(data, segmentIndex, afterWordIndex)
577
648
  if (newData) {
578
- setData(newData)
649
+ updateDataWithHistory(newData, 'split segment')
579
650
  setEditModalSegment(null)
580
651
  }
581
- }, [data])
652
+ }, [data, updateDataWithHistory])
582
653
 
583
654
  const handleMergeSegment = useCallback((segmentIndex: number, mergeWithNext: boolean) => {
584
655
  const newData = mergeSegment(data, segmentIndex, mergeWithNext)
585
- setData(newData)
656
+ updateDataWithHistory(newData, 'merge segment')
586
657
  setEditModalSegment(null)
587
- }, [data])
658
+ }, [data, updateDataWithHistory])
588
659
 
589
660
  const handleHandlerToggle = useCallback(async (handler: string, enabled: boolean) => {
590
661
  if (!apiClient) return
@@ -606,7 +677,8 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
606
677
  const newData = await apiClient.updateHandlers(Array.from(currentEnabled))
607
678
 
608
679
  // Update local state with new correction data
609
- 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
610
682
 
611
683
  // Clear any existing modals or highlights
612
684
  setModalContent(null)
@@ -621,7 +693,7 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
621
693
  } finally {
622
694
  setIsUpdatingHandlers(false);
623
695
  }
624
- }, [apiClient, data.metadata.enabled_handlers, handleFlash])
696
+ }, [apiClient, data.metadata.enabled_handlers, handleFlash, updateDataWithHistory])
625
697
 
626
698
  const handleHandlerClick = useCallback((handler: string) => {
627
699
  if (debugLog) {
@@ -661,21 +733,22 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
661
733
  try {
662
734
  setIsAddingLyrics(true)
663
735
  const newData = await apiClient.addLyrics(source, lyrics)
664
- setData(newData)
736
+ // This API call returns the *entire* new state
737
+ updateDataWithHistory(newData, 'add lyrics'); // Update history
665
738
  } finally {
666
739
  setIsAddingLyrics(false)
667
740
  }
668
- }, [apiClient])
741
+ }, [apiClient, updateDataWithHistory])
669
742
 
670
743
  const handleFindReplace = (findText: string, replaceText: string, options: { caseSensitive: boolean, useRegex: boolean, fullTextMode: boolean }) => {
671
744
  const newData = findAndReplace(data, findText, replaceText, options)
672
- setData(newData)
745
+ updateDataWithHistory(newData, 'find/replace'); // Update history
673
746
  }
674
747
 
675
748
  // Add handler for Edit All functionality
676
749
  const handleEditAll = useCallback(() => {
677
750
  console.log('EditAll - Starting process');
678
-
751
+
679
752
  // Create empty placeholder segments to prevent the modal from closing
680
753
  const placeholderSegment: LyricsSegment = {
681
754
  id: 'loading-placeholder',
@@ -684,31 +757,31 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
684
757
  start_time: 0,
685
758
  end_time: 1
686
759
  };
687
-
760
+
688
761
  // Set placeholder segments first
689
762
  setGlobalEditSegment(placeholderSegment);
690
763
  setOriginalGlobalSegment(placeholderSegment);
691
-
764
+
692
765
  // Show loading state
693
766
  setIsLoadingGlobalEdit(true);
694
767
  console.log('EditAll - Set loading state to true');
695
-
768
+
696
769
  // Open the modal with placeholder data
697
770
  setIsEditAllModalOpen(true);
698
771
  console.log('EditAll - Set modal open to true');
699
-
772
+
700
773
  // Use requestAnimationFrame to ensure the modal with loading state is rendered
701
774
  // before doing the expensive operation
702
775
  requestAnimationFrame(() => {
703
776
  console.log('EditAll - Inside requestAnimationFrame');
704
-
777
+
705
778
  // Use setTimeout to allow the modal to render before doing the expensive operation
706
779
  setTimeout(() => {
707
780
  console.log('EditAll - Inside setTimeout, starting data processing');
708
-
781
+
709
782
  try {
710
783
  console.time('EditAll - Data processing');
711
-
784
+
712
785
  // Create a combined segment with all words from all segments
713
786
  const allWords = data.corrected_segments.flatMap(segment => segment.words)
714
787
  console.log(`EditAll - Collected ${allWords.length} words from all segments`);
@@ -734,18 +807,18 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
734
807
  // Store the original global segment for reset functionality
735
808
  setGlobalEditSegment(globalSegment)
736
809
  console.log('EditAll - Set global edit segment');
737
-
810
+
738
811
  setOriginalGlobalSegment(JSON.parse(JSON.stringify(globalSegment)))
739
812
  console.log('EditAll - Set original global segment');
740
-
813
+
741
814
  // Create the original transcribed global segment for Un-Correct functionality
742
815
  if (originalData.original_segments) {
743
816
  console.log('EditAll - Processing original segments for Un-Correct functionality');
744
-
817
+
745
818
  // Get all words from original segments
746
819
  const originalWords = originalData.original_segments.flatMap((segment: LyricsSegment) => segment.words)
747
820
  console.log(`EditAll - Collected ${originalWords.length} words from original segments`);
748
-
821
+
749
822
  // Sort words by start time
750
823
  const sortedOriginalWords = [...originalWords].sort((a, b) => {
751
824
  const aTime = a.start_time ?? 0
@@ -753,7 +826,7 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
753
826
  return aTime - bTime
754
827
  })
755
828
  console.log('EditAll - Sorted original words by start time');
756
-
829
+
757
830
  // Create the original transcribed global segment
758
831
  const originalTranscribedGlobal: LyricsSegment = {
759
832
  id: 'original-transcribed-global',
@@ -763,14 +836,14 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
763
836
  end_time: sortedOriginalWords[sortedOriginalWords.length - 1]?.end_time ?? null
764
837
  }
765
838
  console.log('EditAll - Created original transcribed global segment');
766
-
839
+
767
840
  setOriginalTranscribedGlobalSegment(originalTranscribedGlobal)
768
841
  console.log('EditAll - Set original transcribed global segment');
769
842
  } else {
770
843
  setOriginalTranscribedGlobalSegment(null)
771
844
  console.log('EditAll - No original segments found, set original transcribed global segment to null');
772
845
  }
773
-
846
+
774
847
  console.timeEnd('EditAll - Data processing');
775
848
  } catch (error) {
776
849
  console.error('Error preparing global edit data:', error);
@@ -867,15 +940,49 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
867
940
  })
868
941
 
869
942
  // Update the data with the new segments
870
- setData({
943
+ const newData = {
871
944
  ...data,
872
945
  corrected_segments: updatedSegments
873
- })
946
+ };
947
+ updateDataWithHistory(newData, 'edit all'); // Update history
874
948
 
875
949
  // Close the modal
876
950
  setIsEditAllModalOpen(false)
877
951
  setGlobalEditSegment(null)
878
- }, [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
879
986
 
880
987
  // Memoize the metric click handlers
881
988
  const metricClickHandlers = useMemo(() => ({
@@ -887,6 +994,72 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
887
994
  // Determine if any modal is open to disable highlighting
888
995
  const isAnyModalOpenMemo = useMemo(() => isAnyModalOpen, [isAnyModalOpen]);
889
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
+
890
1063
  return (
891
1064
  <Box sx={{
892
1065
  p: 1,
@@ -910,6 +1083,10 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
910
1083
  onAddLyrics={() => setIsAddLyricsModalOpen(true)}
911
1084
  onFindReplace={() => setIsFindReplaceModalOpen(true)}
912
1085
  onEditAll={handleEditAll}
1086
+ onUndo={handleUndo}
1087
+ onRedo={handleRedo}
1088
+ canUndo={canUndo}
1089
+ canRedo={canRedo}
913
1090
  />
914
1091
 
915
1092
  <Grid container direction={isMobile ? 'column' : 'row'}>
@@ -927,7 +1104,9 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
927
1104
  anchors={data.anchor_sequences}
928
1105
  disableHighlighting={isAnyModalOpenMemo}
929
1106
  onDataChange={(updatedData) => {
930
- setData(updatedData)
1107
+ // Direct data change from TranscriptionView (e.g., drag-and-drop)
1108
+ // needs to update history
1109
+ updateDataWithHistory(updatedData, 'direct data change');
931
1110
  }}
932
1111
  />
933
1112
  {!isReadOnly && apiClient && (
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: lyrics-transcriber
3
- Version: 0.48.0
3
+ Version: 0.49.0
4
4
  Summary: Automatically create synchronised lyrics files in ASS and MidiCo LRC formats with word-level timestamps, using Whisper and lyrics from Genius and Spotify
5
5
  License: MIT
6
6
  Author: Andrew Beveridge
@@ -26,9 +26,9 @@ lyrics_transcriber/frontend/.yarn/install-state.gz,sha256=kcgQ-S9HvdNHexkXQVt18L
26
26
  lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs,sha256=KTYy2KCV2OpHhussV5jIPDdUSr7RftMRhqPsRUmgfAY,2765465
27
27
  lyrics_transcriber/frontend/.yarnrc.yml,sha256=0hZQ1OTcPqTUNBqQeme4VFkIzrsabHNzLtc_M-wSgIM,66
28
28
  lyrics_transcriber/frontend/README.md,sha256=-D6CAfKTT7Y0V3EjlZ2fMy7fyctFQ4x2TJ9vx6xtccM,1607
29
- lyrics_transcriber/frontend/dist/assets/index-BvRLUQmZ.js,sha256=3akGaBOgucoh5DcEciZsigZY_W63wSITtGcGUAbCWl0,1230045
30
- lyrics_transcriber/frontend/dist/assets/index-BvRLUQmZ.js.map,sha256=pLFjgGWRyykfiuvKv-i9EvM_tKH8iVma8-A54rlPvlM,2618510
31
- lyrics_transcriber/frontend/dist/index.html,sha256=CD308I_i01AYU345UgtZmtDMVdOtuoIeENjGBv81U70,400
29
+ lyrics_transcriber/frontend/dist/assets/index-BpvPgWoc.js,sha256=KdL6UuSIiP9poxBxCPmBkh0b7kMWzvxX0oIOTk1UKbE,1235484
30
+ lyrics_transcriber/frontend/dist/assets/index-BpvPgWoc.js.map,sha256=VLKx5Pu8Z6CqaJUFha9V9IR5Py8lmarwW4IGq81mdQA,2632916
31
+ lyrics_transcriber/frontend/dist/index.html,sha256=0bU3wDz5WFd63vGKK1pTN5gI6BUzjAQYHiN_GOZmsug,400
32
32
  lyrics_transcriber/frontend/dist/vite.svg,sha256=SnSK_UQ5GLsWWRyDTEAdrjPoeGGrXbrQgRw6O0qSFPs,1497
33
33
  lyrics_transcriber/frontend/eslint.config.js,sha256=3ADH23ANA4NNBKFy6nCVk65e8bx1DrVd_FIaYNnhuqA,734
34
34
  lyrics_transcriber/frontend/index.html,sha256=KfqJVONzpUyPIwV73nZRiCWlwLnFWeB3z0vzxDPNudU,376
@@ -45,8 +45,8 @@ lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx,sha256=74dKgU
45
45
  lyrics_transcriber/frontend/src/components/EditWordList.tsx,sha256=atl-9Z-24U-KWojwo0apTy1Y9DbQGoVo2dFX4P-1Z9E,13681
46
46
  lyrics_transcriber/frontend/src/components/FileUpload.tsx,sha256=fwn2rMWtMLPTZLREMb3ps4prSf9nzxGwnjmeC6KYsJA,2383
47
47
  lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx,sha256=U7duKns4IqNXwbWFbQfdyaswnvkSRpfsU0UG__-Serc,20192
48
- lyrics_transcriber/frontend/src/components/Header.tsx,sha256=PKFMDITEyC77ndk0emOsU6LFbPTLkWmkTLxEcndKPJY,11657
49
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx,sha256=EPCq1B8lZGXqrdcDj33YhWFfoJc7Jh3ULW_mEmc22mE,41605
48
+ lyrics_transcriber/frontend/src/components/Header.tsx,sha256=jQzWy1yMh7s4D7x5-LU763nh1FvDS9ViiFmc5KkRnlE,14176
49
+ lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx,sha256=lHzuY7pP94BS2qoUjToWvN1AXye5KC9CUUTaIBN27mI,49362
50
50
  lyrics_transcriber/frontend/src/components/ModeSelector.tsx,sha256=HnBAK_gFgNBJLtMC_ESMVdUapDjmqmoLX8pQeyHfpOw,2651
51
51
  lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx,sha256=-9_ljoaadHjAAvia_vhEUgCajPv2jFnRDVR6VWN_a0w,4166
52
52
  lyrics_transcriber/frontend/src/components/ReferenceView.tsx,sha256=thpf2ojge9pvZojUnGaiQroeHfBmxhUesO7945RGSfk,9499
@@ -148,8 +148,8 @@ lyrics_transcriber/transcribers/base_transcriber.py,sha256=T3m4ZCwZ9Bpv6Jvb2hNcn
148
148
  lyrics_transcriber/transcribers/whisper.py,sha256=YcCB1ic9H6zL1GS0jD0emu8-qlcH0QVEjjjYB4aLlIQ,13260
149
149
  lyrics_transcriber/types.py,sha256=d73cDstrEI_tVgngDYYYFwjZNs6OVBuAB_QDkga7dWA,19841
150
150
  lyrics_transcriber/utils/word_utils.py,sha256=-cMGpj9UV4F6IsoDKAV2i1aiqSO8eI91HMAm_igtVMk,958
151
- lyrics_transcriber-0.48.0.dist-info/LICENSE,sha256=BiPihPDxhxIPEx6yAxVfAljD5Bhm_XG2teCbPEj_m0Y,1069
152
- lyrics_transcriber-0.48.0.dist-info/METADATA,sha256=WtzJ53EgshfdtuUw-lhOWZqfXbKEsqmKIkqrkaSP7_8,6017
153
- lyrics_transcriber-0.48.0.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
154
- lyrics_transcriber-0.48.0.dist-info/entry_points.txt,sha256=kcp-bSFkCACAEA0t166Kek0HpaJUXRo5SlF5tVrqNBU,216
155
- lyrics_transcriber-0.48.0.dist-info/RECORD,,
151
+ lyrics_transcriber-0.49.0.dist-info/LICENSE,sha256=BiPihPDxhxIPEx6yAxVfAljD5Bhm_XG2teCbPEj_m0Y,1069
152
+ lyrics_transcriber-0.49.0.dist-info/METADATA,sha256=Zo2iq0ReIMLXzxqqbgffCL6EvAGnUNER_Ezx8LXgREY,6017
153
+ lyrics_transcriber-0.49.0.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
154
+ lyrics_transcriber-0.49.0.dist-info/entry_points.txt,sha256=kcp-bSFkCACAEA0t166Kek0HpaJUXRo5SlF5tVrqNBU,216
155
+ lyrics_transcriber-0.49.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.1.1
2
+ Generator: poetry-core 2.1.2
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any