lyrics-transcriber 0.46.0__py3-none-any.whl → 0.48.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-BXOpmKq-.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-BvRLUQmZ.js"></script>
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -185,15 +185,15 @@ export default function EditModal({
185
185
  isGlobal = false,
186
186
  isLoading = false
187
187
  }: EditModalProps) {
188
- console.log('EditModal - Render', {
189
- open,
190
- isGlobal,
191
- isLoading,
192
- hasSegment: !!segment,
193
- segmentIndex,
194
- hasOriginalSegment: !!originalSegment,
195
- hasOriginalTranscribedSegment: !!originalTranscribedSegment
196
- });
188
+ // console.log('EditModal - Render', {
189
+ // open,
190
+ // isGlobal,
191
+ // isLoading,
192
+ // hasSegment: !!segment,
193
+ // segmentIndex,
194
+ // hasOriginalSegment: !!originalSegment,
195
+ // hasOriginalTranscribedSegment: !!originalTranscribedSegment
196
+ // });
197
197
 
198
198
  const [editedSegment, setEditedSegment] = useState<LyricsSegment | null>(segment)
199
199
  const [isPlaying, setIsPlaying] = useState(false)
@@ -233,7 +233,7 @@ export default function EditModal({
233
233
  })
234
234
 
235
235
  const handleClose = useCallback(() => {
236
- console.log('EditModal - handleClose called');
236
+ // console.log('EditModal - handleClose called');
237
237
  cleanupManualSync()
238
238
  onClose()
239
239
  }, [onClose, cleanupManualSync])
@@ -243,12 +243,12 @@ export default function EditModal({
243
243
  const spacebarHandler = handleSpacebar // Capture the current handler
244
244
 
245
245
  if (open) {
246
- console.log('EditModal - Setting up modal spacebar handler', {
247
- hasPlaySegment: !!onPlaySegment,
248
- editedSegmentId: editedSegment?.id,
249
- handlerFunction: spacebarHandler.toString().slice(0, 100),
250
- isLoading
251
- })
246
+ // console.log('EditModal - Setting up modal spacebar handler', {
247
+ // hasPlaySegment: !!onPlaySegment,
248
+ // editedSegmentId: editedSegment?.id,
249
+ // handlerFunction: spacebarHandler.toString().slice(0, 100),
250
+ // isLoading
251
+ // })
252
252
 
253
253
  // Create a function that will be called by the global event listeners
254
254
  const handleKeyEvent = (e: KeyboardEvent) => {
@@ -262,7 +262,7 @@ export default function EditModal({
262
262
  // Only cleanup when the effect is re-run or the modal is closed
263
263
  return () => {
264
264
  if (!open) {
265
- console.log('EditModal - Cleanup: clearing modal spacebar handler')
265
+ // console.log('EditModal - Cleanup: clearing modal spacebar handler')
266
266
  setModalSpacebarHandler(undefined)
267
267
  }
268
268
  }
@@ -289,11 +289,11 @@ export default function EditModal({
289
289
 
290
290
  // All useEffect hooks
291
291
  useEffect(() => {
292
- console.log('EditModal - segment changed', {
293
- hasSegment: !!segment,
294
- segmentId: segment?.id,
295
- wordCount: segment?.words.length
296
- });
292
+ // console.log('EditModal - segment changed', {
293
+ // hasSegment: !!segment,
294
+ // segmentId: segment?.id,
295
+ // wordCount: segment?.words.length
296
+ // });
297
297
  setEditedSegment(segment)
298
298
  }, [segment])
299
299
 
@@ -304,7 +304,7 @@ export default function EditModal({
304
304
  const endTime = editedSegment.end_time ?? 0
305
305
 
306
306
  if (window.isAudioPlaying && currentTime > endTime) {
307
- console.log('Stopping playback: current time exceeded end time')
307
+ // console.log('Stopping playback: current time exceeded end time')
308
308
  window.toggleAudioPlayback?.()
309
309
  cleanupManualSync()
310
310
  }
@@ -519,7 +519,7 @@ export default function EditModal({
519
519
 
520
520
  // Memoize the dialog title to prevent re-renders
521
521
  const dialogTitle = useMemo(() => {
522
- console.log('EditModal - Rendering dialog title', { isLoading, isGlobal });
522
+ // console.log('EditModal - Rendering dialog title', { isLoading, isGlobal });
523
523
 
524
524
  if (isLoading) {
525
525
  return (
@@ -563,24 +563,24 @@ export default function EditModal({
563
563
 
564
564
  // Early return after all hooks and function definitions
565
565
  if (!isLoading && (!segment || !editedSegment || !originalSegment)) {
566
- console.log('EditModal - Early return: missing required data', {
567
- hasSegment: !!segment,
568
- hasEditedSegment: !!editedSegment,
569
- hasOriginalSegment: !!originalSegment,
570
- isLoading
571
- });
566
+ // console.log('EditModal - Early return: missing required data', {
567
+ // hasSegment: !!segment,
568
+ // hasEditedSegment: !!editedSegment,
569
+ // hasOriginalSegment: !!originalSegment,
570
+ // isLoading
571
+ // });
572
572
  return null;
573
573
  }
574
574
  if (!isLoading && !isGlobal && segmentIndex === null) {
575
- console.log('EditModal - Early return: non-global mode with null segmentIndex');
575
+ // console.log('EditModal - Early return: non-global mode with null segmentIndex');
576
576
  return null;
577
577
  }
578
578
 
579
- console.log('EditModal - Rendering dialog content', {
580
- isLoading,
581
- hasEditedSegment: !!editedSegment,
582
- hasOriginalSegment: !!originalSegment
583
- });
579
+ // console.log('EditModal - Rendering dialog content', {
580
+ // isLoading,
581
+ // hasEditedSegment: !!editedSegment,
582
+ // hasOriginalSegment: !!originalSegment
583
+ // });
584
584
 
585
585
  return (
586
586
  <Dialog
@@ -33,7 +33,8 @@ const WordRow = memo(function WordRow({
33
33
  onWordUpdate,
34
34
  onSplitWord,
35
35
  onRemoveWord,
36
- wordsLength
36
+ wordsLength,
37
+ onTabNavigation
37
38
  }: {
38
39
  word: Word
39
40
  index: number
@@ -41,7 +42,17 @@ const WordRow = memo(function WordRow({
41
42
  onSplitWord: (index: number) => void
42
43
  onRemoveWord: (index: number) => void
43
44
  wordsLength: number
45
+ onTabNavigation: (currentIndex: number) => void
44
46
  }) {
47
+ const handleKeyDown = (e: React.KeyboardEvent) => {
48
+ // console.log('KeyDown event:', e.key, 'Shift:', e.shiftKey, 'Index:', index);
49
+ if (e.key === 'Tab' && !e.shiftKey) {
50
+ // console.log('Tab key detected, preventing default and navigating');
51
+ e.preventDefault();
52
+ onTabNavigation(index);
53
+ }
54
+ };
55
+
45
56
  return (
46
57
  <Box sx={{
47
58
  display: 'flex',
@@ -53,8 +64,10 @@ const WordRow = memo(function WordRow({
53
64
  label={`Word ${index}`}
54
65
  value={word.text}
55
66
  onChange={(e) => onWordUpdate(index, { text: e.target.value })}
67
+ onKeyDown={handleKeyDown}
56
68
  fullWidth
57
69
  size="small"
70
+ id={`word-text-${index}`}
58
71
  />
59
72
  <TextField
60
73
  label="Start Time"
@@ -108,7 +121,8 @@ const WordItem = memo(function WordItem({
108
121
  onAddSegment,
109
122
  onMergeSegment,
110
123
  wordsLength,
111
- isGlobal
124
+ isGlobal,
125
+ onTabNavigation
112
126
  }: {
113
127
  word: Word
114
128
  index: number
@@ -122,6 +136,7 @@ const WordItem = memo(function WordItem({
122
136
  onMergeSegment?: (mergeWithNext: boolean) => void
123
137
  wordsLength: number
124
138
  isGlobal: boolean
139
+ onTabNavigation: (currentIndex: number) => void
125
140
  }) {
126
141
  return (
127
142
  <Box key={word.id}>
@@ -132,6 +147,7 @@ const WordItem = memo(function WordItem({
132
147
  onSplitWord={onSplitWord}
133
148
  onRemoveWord={onRemoveWord}
134
149
  wordsLength={wordsLength}
150
+ onTabNavigation={onTabNavigation}
135
151
  />
136
152
 
137
153
  {/* Word divider with merge/split functionality */}
@@ -210,6 +226,60 @@ export default function EditWordList({
210
226
  setPage(value);
211
227
  };
212
228
 
229
+ // Handle tab navigation between word text fields
230
+ const handleTabNavigation = (currentIndex: number) => {
231
+ // console.log('handleTabNavigation called with index:', currentIndex);
232
+ const nextIndex = (currentIndex + 1) % words.length;
233
+ // console.log('Next index calculated:', nextIndex, 'Total words:', words.length);
234
+
235
+ // If the next word is on a different page, change the page
236
+ if (isGlobal && (nextIndex < startIndex || nextIndex >= endIndex)) {
237
+ // console.log('Next word is on different page. Current page:', page, 'startIndex:', startIndex, 'endIndex:', endIndex);
238
+ const nextPage = Math.floor(nextIndex / pageSize) + 1;
239
+ // console.log('Changing to page:', nextPage);
240
+ setPage(nextPage);
241
+
242
+ // Use setTimeout to allow the page change to render before focusing
243
+ setTimeout(() => {
244
+ // console.log('Timeout callback executing, trying to focus element with ID:', `word-text-${nextIndex}`);
245
+ focusWordTextField(nextIndex);
246
+ }, 50);
247
+ } else {
248
+ // console.log('Next word is on same page, trying to focus element with ID:', `word-text-${nextIndex}`);
249
+ focusWordTextField(nextIndex);
250
+ }
251
+ };
252
+
253
+ // Helper function to focus a word text field by index
254
+ const focusWordTextField = (index: number) => {
255
+ // Material-UI TextField uses a more complex structure
256
+ // The actual input is inside the TextField component
257
+ const element = document.getElementById(`word-text-${index}`);
258
+ // console.log('Element found:', !!element);
259
+
260
+ if (element) {
261
+ // Try different selectors to find the input element
262
+ // First try the standard input selector
263
+ let input = element.querySelector('input');
264
+
265
+ // If that doesn't work, try the MUI-specific selector
266
+ if (!input) {
267
+ input = element.querySelector('.MuiInputBase-input');
268
+ }
269
+
270
+ // console.log('Input element found:', !!input);
271
+ if (input) {
272
+ input.focus();
273
+ input.select();
274
+ // console.log('Focus and select called on input');
275
+ } else {
276
+ // As a fallback, try to focus the TextField itself
277
+ // console.log('Trying to focus the TextField itself');
278
+ element.focus();
279
+ }
280
+ }
281
+ };
282
+
213
283
  return (
214
284
  <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flexGrow: 1, minHeight: 0 }}>
215
285
  {/* Initial divider with Add Segment Before button */}
@@ -265,6 +335,7 @@ export default function EditWordList({
265
335
  onMergeSegment={onMergeSegment}
266
336
  wordsLength={words.length}
267
337
  isGlobal={isGlobal}
338
+ onTabNavigation={handleTabNavigation}
268
339
  />
269
340
  );
270
341
  })}
@@ -22,7 +22,8 @@ import {
22
22
  deleteSegment,
23
23
  updateSegment,
24
24
  mergeSegment,
25
- findAndReplace
25
+ findAndReplace,
26
+ deleteWord
26
27
  } from './shared/utils/segmentOperations'
27
28
  import { loadSavedData, saveData, clearSavedData } from './shared/utils/localStorage'
28
29
  import { setupKeyboardHandlers, setModalHandler, getModalState } from './shared/utils/keyboardHandlers'
@@ -79,6 +80,7 @@ interface MemoizedTranscriptionViewProps {
79
80
  currentTime: number
80
81
  anchors: AnchorSequence[]
81
82
  disableHighlighting: boolean
83
+ onDataChange?: (updatedData: CorrectionData) => void
82
84
  }
83
85
 
84
86
  // Create a memoized TranscriptionView component
@@ -93,7 +95,8 @@ const MemoizedTranscriptionView = memo(function MemoizedTranscriptionView({
93
95
  onPlaySegment,
94
96
  currentTime,
95
97
  anchors,
96
- disableHighlighting
98
+ disableHighlighting,
99
+ onDataChange
97
100
  }: MemoizedTranscriptionViewProps) {
98
101
  return (
99
102
  <TranscriptionView
@@ -107,6 +110,7 @@ const MemoizedTranscriptionView = memo(function MemoizedTranscriptionView({
107
110
  onPlaySegment={onPlaySegment}
108
111
  currentTime={disableHighlighting ? undefined : currentTime}
109
112
  anchors={anchors}
113
+ onDataChange={onDataChange}
110
114
  />
111
115
  );
112
116
  });
@@ -237,6 +241,7 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
237
241
  const [originalData] = useState(() => JSON.parse(JSON.stringify(initialData)))
238
242
  const [interactionMode, setInteractionMode] = useState<InteractionMode>('edit')
239
243
  const [isShiftPressed, setIsShiftPressed] = useState(false)
244
+ const [isCtrlPressed, setIsCtrlPressed] = useState(false)
240
245
  const [editModalSegment, setEditModalSegment] = useState<{
241
246
  segment: LyricsSegment
242
247
  index: number
@@ -302,8 +307,9 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
302
307
  })
303
308
  }
304
309
 
305
- const { handleKeyDown, handleKeyUp } = setupKeyboardHandlers({
310
+ const { handleKeyDown, handleKeyUp, cleanup } = setupKeyboardHandlers({
306
311
  setIsShiftPressed,
312
+ setIsCtrlPressed
307
313
  })
308
314
 
309
315
  // Always add keyboard listeners
@@ -316,6 +322,7 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
316
322
  // Reset modifier states when a modal opens
317
323
  if (isAnyModalOpen) {
318
324
  setIsShiftPressed(false)
325
+ setIsCtrlPressed(false)
319
326
  }
320
327
 
321
328
  // Cleanup function
@@ -326,8 +333,10 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
326
333
  window.removeEventListener('keydown', handleKeyDown)
327
334
  window.removeEventListener('keyup', handleKeyUp)
328
335
  document.body.style.userSelect = ''
336
+ // Call the cleanup function to remove window blur/focus listeners
337
+ cleanup()
329
338
  }
330
- }, [setIsShiftPressed, isAnyModalOpen])
339
+ }, [setIsShiftPressed, setIsCtrlPressed, isAnyModalOpen])
331
340
 
332
341
  // Update modal state tracking
333
342
  useEffect(() => {
@@ -343,7 +352,7 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
343
352
  }, [modalContent, editModalSegment, isReviewModalOpen, isAddLyricsModalOpen, isFindReplaceModalOpen, isEditAllModalOpen])
344
353
 
345
354
  // Calculate effective mode based on modifier key states
346
- const effectiveMode = isShiftPressed ? 'highlight' : interactionMode
355
+ const effectiveMode = isCtrlPressed ? 'delete_word' : (isShiftPressed ? 'highlight' : interactionMode)
347
356
 
348
357
  const handleFlash = useCallback((type: FlashType, info?: HighlightInfo) => {
349
358
  setFlashingType(null)
@@ -368,6 +377,16 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
368
377
  console.log('LyricsAnalyzer handleWordClick:', { info });
369
378
  }
370
379
 
380
+ if (effectiveMode === 'delete_word') {
381
+ // Use the shared deleteWord utility function
382
+ const newData = deleteWord(data, info.word_id);
383
+ setData(newData);
384
+
385
+ // Flash to indicate the word was deleted
386
+ handleFlash('word');
387
+ return;
388
+ }
389
+
371
390
  if (effectiveMode === 'highlight') {
372
391
  // Find if this word is part of a correction
373
392
  const correction = data.corrections?.find(c =>
@@ -492,7 +511,7 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
492
511
  });
493
512
  }
494
513
  }
495
- }, [data, effectiveMode, setModalContent]);
514
+ }, [data, effectiveMode, setModalContent, handleFlash, deleteWord]);
496
515
 
497
516
  const handleUpdateSegment = useCallback((updatedSegment: LyricsSegment) => {
498
517
  if (!editModalSegment) return
@@ -907,6 +926,9 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
907
926
  currentTime={currentAudioTime}
908
927
  anchors={data.anchor_sequences}
909
928
  disableHighlighting={isAnyModalOpenMemo}
929
+ onDataChange={(updatedData) => {
930
+ setData(updatedData)
931
+ }}
910
932
  />
911
933
  {!isReadOnly && apiClient && (
912
934
  <Box sx={{
@@ -1,6 +1,7 @@
1
- import { ToggleButton, ToggleButtonGroup, Box, Typography } from '@mui/material';
1
+ import { ToggleButton, ToggleButtonGroup, Box, Typography, Tooltip } from '@mui/material';
2
2
  import HighlightIcon from '@mui/icons-material/Highlight';
3
3
  import EditIcon from '@mui/icons-material/Edit';
4
+ import DeleteIcon from '@mui/icons-material/Delete';
4
5
  import { InteractionMode } from '../types';
5
6
 
6
7
  interface ModeSelectorProps {
@@ -17,7 +18,7 @@ export default function ModeSelector({ effectiveMode, onChange }: ModeSelectorPr
17
18
  <ToggleButtonGroup
18
19
  value={effectiveMode}
19
20
  exclusive
20
- onChange={(_, newMode) => newMode && onChange(newMode)}
21
+ onChange={(_, newMode) => newMode === 'edit' && onChange(newMode)}
21
22
  size="small"
22
23
  sx={{
23
24
  height: '32px',
@@ -28,20 +29,38 @@ export default function ModeSelector({ effectiveMode, onChange }: ModeSelectorPr
28
29
  }
29
30
  }}
30
31
  >
31
- <ToggleButton
32
- value="edit"
33
- title="Click to edit segments and make corrections in the transcription view"
34
- >
35
- <EditIcon sx={{ mr: 0.5, fontSize: '1rem' }} />
36
- Edit
37
- </ToggleButton>
38
- <ToggleButton
39
- value="highlight"
40
- title="Click words in the transcription view to highlight the matching anchor sequence in the reference lyrics. You can also hold SHIFT to temporarily activate this mode."
41
- >
42
- <HighlightIcon sx={{ mr: 0.5, fontSize: '1rem' }} />
43
- Highlight
44
- </ToggleButton>
32
+ <Tooltip title="Default mode; click words to edit that lyrics segment">
33
+ <ToggleButton
34
+ value="edit"
35
+ >
36
+ <EditIcon sx={{ mr: 0.5, fontSize: '1rem' }} />
37
+ Edit
38
+ </ToggleButton>
39
+ </Tooltip>
40
+
41
+ <Tooltip title="Hold SHIFT and click words to highlight the matching anchor sequence in the reference lyrics">
42
+ <span>
43
+ <ToggleButton
44
+ value="highlight"
45
+ disabled
46
+ >
47
+ <HighlightIcon sx={{ mr: 0.5, fontSize: '1rem' }} />
48
+ Highlight
49
+ </ToggleButton>
50
+ </span>
51
+ </Tooltip>
52
+
53
+ <Tooltip title="Hold CTRL and click words to delete them">
54
+ <span>
55
+ <ToggleButton
56
+ value="delete_word"
57
+ disabled
58
+ >
59
+ <DeleteIcon sx={{ mr: 0.5, fontSize: '1rem' }} />
60
+ Delete
61
+ </ToggleButton>
62
+ </span>
63
+ </Tooltip>
45
64
  </ToggleButtonGroup>
46
65
  </Box>
47
66
  );
@@ -6,6 +6,8 @@ import { styled } from '@mui/material/styles'
6
6
  import SegmentDetailsModal from './SegmentDetailsModal'
7
7
  import { TranscriptionWordPosition } from './shared/types'
8
8
  import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'
9
+ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
10
+ import { deleteSegment } from './shared/utils/segmentOperations'
9
11
 
10
12
  const SegmentIndex = styled(Typography)(({ theme }) => ({
11
13
  color: theme.palette.text.secondary,
@@ -48,10 +50,18 @@ export default function TranscriptionView({
48
50
  mode,
49
51
  onPlaySegment,
50
52
  currentTime = 0,
51
- anchors = []
53
+ anchors = [],
54
+ onDataChange
52
55
  }: TranscriptionViewProps) {
53
56
  const [selectedSegmentIndex, setSelectedSegmentIndex] = useState<number | null>(null)
54
57
 
58
+ const handleDeleteSegment = (segmentIndex: number) => {
59
+ if (onDataChange) {
60
+ const updatedData = deleteSegment(data, segmentIndex)
61
+ onDataChange(updatedData)
62
+ }
63
+ }
64
+
55
65
  return (
56
66
  <Paper sx={{ p: 0.8 }}>
57
67
  <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
@@ -125,6 +135,20 @@ export default function TranscriptionView({
125
135
  >
126
136
  {segmentIndex}
127
137
  </SegmentIndex>
138
+ <IconButton
139
+ size="small"
140
+ onClick={() => handleDeleteSegment(segmentIndex)}
141
+ sx={{
142
+ padding: '1px',
143
+ height: '18px',
144
+ width: '18px',
145
+ minHeight: '18px',
146
+ minWidth: '18px'
147
+ }}
148
+ title="Delete segment"
149
+ >
150
+ <DeleteOutlineIcon sx={{ fontSize: '0.9rem', color: 'error.main' }} />
151
+ </IconButton>
128
152
  {segment.start_time !== null && (
129
153
  <IconButton
130
154
  size="small"
@@ -136,6 +160,7 @@ export default function TranscriptionView({
136
160
  minHeight: '18px',
137
161
  minWidth: '18px'
138
162
  }}
163
+ title="Play segment"
139
164
  >
140
165
  <PlayCircleOutlineIcon sx={{ fontSize: '0.9rem' }} />
141
166
  </IconButton>
@@ -97,7 +97,7 @@ export function useWordClick({
97
97
  }
98
98
  }
99
99
 
100
- if (mode === 'highlight' || mode === 'edit') {
100
+ if (mode === 'highlight' || mode === 'edit' || mode === 'delete_word') {
101
101
  if (belongsToAnchor && anchor) {
102
102
  onWordClick?.({
103
103
  word_id: wordId,
@@ -131,7 +131,8 @@ export function useWordClick({
131
131
  gap: undefined
132
132
  })
133
133
  }
134
- } else if (mode === 'details') {
134
+ } else {
135
+ // This is a fallback for any future modes
135
136
  if (belongsToAnchor && anchor) {
136
137
  onElementClick({
137
138
  type: 'anchor',
@@ -83,6 +83,7 @@ export interface TranscriptionViewProps {
83
83
  currentTime?: number
84
84
  anchors?: AnchorSequence[]
85
85
  flashingHandler?: string | null
86
+ onDataChange?: (updatedData: CorrectionData) => void
86
87
  }
87
88
 
88
89
  // Add LinePosition type here since it's used in multiple places
@@ -35,6 +35,16 @@ export const setupKeyboardHandlers = (state: KeyboardState) => {
35
35
  console.log(`Setting up keyboard handlers [${handlerId}]`)
36
36
  }
37
37
 
38
+ // Function to reset modifier key states
39
+ const resetModifierStates = () => {
40
+ if (debugLog) {
41
+ console.log(`Resetting modifier states [${handlerId}]`)
42
+ }
43
+ state.setIsShiftPressed(false)
44
+ state.setIsCtrlPressed?.(false)
45
+ document.body.style.userSelect = ''
46
+ }
47
+
38
48
  const handleKeyDown = (e: KeyboardEvent) => {
39
49
  if (debugLog) {
40
50
  console.log(`Keyboard event captured [${handlerId}]`, {
@@ -59,7 +69,7 @@ export const setupKeyboardHandlers = (state: KeyboardState) => {
59
69
  if (e.key === 'Shift') {
60
70
  state.setIsShiftPressed(true)
61
71
  document.body.style.userSelect = 'none'
62
- } else if (e.key === 'Meta') {
72
+ } else if (e.key === 'Control' || e.key === 'Ctrl' || e.key === 'Meta') {
63
73
  state.setIsCtrlPressed?.(true)
64
74
  } else if (e.key === ' ' || e.code === 'Space') {
65
75
  if (debugLog) {
@@ -102,12 +112,11 @@ export const setupKeyboardHandlers = (state: KeyboardState) => {
102
112
  })
103
113
  }
104
114
 
105
- if (e.key === 'Shift') {
106
- state.setIsShiftPressed(false)
107
- document.body.style.userSelect = ''
108
- } else if (e.key === 'Meta') {
109
- state.setIsCtrlPressed?.(false)
110
- } else if (e.key === ' ' || e.code === 'Space') {
115
+ // Always reset the modifier states regardless of the key which was released
116
+ // to help prevent accidentally getting stuck in a mode or accidentally deleting words
117
+ resetModifierStates()
118
+
119
+ if (e.key === ' ' || e.code === 'Space') {
111
120
  if (debugLog) {
112
121
  console.log('Keyboard handler - Spacebar released', {
113
122
  modalOpen: isModalOpen,
@@ -128,7 +137,35 @@ export const setupKeyboardHandlers = (state: KeyboardState) => {
128
137
  }
129
138
  }
130
139
 
131
- return { handleKeyDown, handleKeyUp }
140
+ // Handle window blur event (user switches tabs or apps)
141
+ const handleWindowBlur = () => {
142
+ if (debugLog) {
143
+ console.log(`Window blur detected [${handlerId}], resetting modifier states`)
144
+ }
145
+ resetModifierStates()
146
+ }
147
+
148
+ // Handle window focus event (user returns to the app)
149
+ const handleWindowFocus = () => {
150
+ if (debugLog) {
151
+ console.log(`Window focus detected [${handlerId}], ensuring modifier states are reset`)
152
+ }
153
+ resetModifierStates()
154
+ }
155
+
156
+ // Add window event listeners
157
+ window.addEventListener('blur', handleWindowBlur)
158
+ window.addEventListener('focus', handleWindowFocus)
159
+
160
+ // Return a cleanup function that includes removing the window event listeners
161
+ return {
162
+ handleKeyDown,
163
+ handleKeyUp,
164
+ cleanup: () => {
165
+ window.removeEventListener('blur', handleWindowBlur)
166
+ window.removeEventListener('focus', handleWindowFocus)
167
+ }
168
+ }
132
169
  }
133
170
 
134
171
  // Export these for external use