From f367bebe9ec3d5bdc6f83ac8f60295296cb564eb Mon Sep 17 00:00:00 2001 From: Wassim Gharbi Date: Wed, 5 Aug 2026 21:05:47 -0700 Subject: [PATCH] Keep global shortcuts alive after a file picker The desktop File > Open Project... menu clicks a hidden , which keeps focus once the OS picker closes. The global keydown handler bails on any focused , so from that point on space (play/pause), S, Cmd+Z and Delete were dead for the rest of the session. The transcript Import label uses the same hidden-input trick and broke them the same way. File inputs never receive typed text, so exclude them from the guard. Pulled the predicate into isTypingTarget() and reused it in the two TranscriptPanel handlers (Escape, @) that had the same copy-pasted check. Co-Authored-By: Claude Opus 5 (1M context) --- components/Editor.tsx | 5 ++--- components/TranscriptPanel.tsx | 9 +++------ lib/keyboard.ts | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 lib/keyboard.ts diff --git a/components/Editor.tsx b/components/Editor.tsx index e7a20b4..b5fa16d 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -30,6 +30,7 @@ import ModelSelector, { } from "./ModelSelector"; import ImportTranscriptOption from "./ImportTranscriptOption"; import { MODEL_ORDER } from "@/lib/models"; +import { isTypingTarget } from "@/lib/keyboard"; /** How long the desktop mode-change overlay stays up. Matches the macOS * `setBounds(..., animate)` duration plus a small buffer so the layout @@ -254,9 +255,7 @@ export default function Editor() { // (which would double-toggle playback and look like the hotkey "didn't work"). useEffect(() => { const handler = (e: KeyboardEvent) => { - const target = e.target as HTMLElement; - if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) - return; + if (isTypingTarget(e.target)) return; const s = useEditorStore.getState(); if (e.code === "Space" && s.videoEl && !s.exportOpen) { e.preventDefault(); diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx index b5243a5..9534161 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -42,6 +42,7 @@ import { useTranscriptPlayheadFollow } from "@/hooks/useTranscriptPlayheadFollow import { useWordAnchorFloating } from "@/hooks/useWordAnchorFloating"; import { useCutRanges } from "@/hooks/useCutRanges"; import { findActiveWordId, groupWordsBySpeaker } from "@/lib/transcript"; +import { isTypingTarget } from "@/lib/keyboard"; const WordSpan = memo(function WordSpan({ word, @@ -306,9 +307,7 @@ export default function TranscriptPanel() { useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key !== "Escape") return; - const target = e.target as HTMLElement; - if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) - return; + if (isTypingTarget(e.target)) return; if (selectedWordIds.length === 0) return; e.preventDefault(); clearSelection(); @@ -321,9 +320,7 @@ export default function TranscriptPanel() { useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key !== "@" || e.metaKey || e.ctrlKey || e.altKey) return; - const target = e.target as HTMLElement; - if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) - return; + if (isTypingTarget(e.target)) return; if (!selection || assigningSpeaker || correcting) return; e.preventDefault(); openSpeakerAssign(); diff --git a/lib/keyboard.ts b/lib/keyboard.ts new file mode 100644 index 0000000..3f21191 --- /dev/null +++ b/lib/keyboard.ts @@ -0,0 +1,14 @@ +/** + * True when a keystroke belongs to a text field rather than the global + * shortcuts. File inputs are excluded on purpose: the desktop File › Open + * Project… menu and the transcript Import label both click a hidden + * ``, which keeps focus after the picker closes — treating + * that as "typing" silently killed the spacebar play/pause hotkey. + */ +export function isTypingTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + if (!el) return false; + if (el.isContentEditable) return true; + if (el.tagName === "TEXTAREA") return true; + return el.tagName === "INPUT" && (el as HTMLInputElement).type !== "file"; +}