fix(editor): suppress editor shortcuts under every modal, not just some - #437
Conversation
Z, T and C kept adding regions to the timeline behind the Export modal. The guard added in 65aa74e asked `isDialogOpen() || isConfigOpen` — one flag for the AI providers dialog, one for the keyboard shortcuts dialog. Those are the two whose open state had been lifted into a context for an unrelated reason (#420); every other modal keeps its `useState` in the component that mounts it, so the guard could not see Export, Open project, New project, Edit clip or the unsaved-changes prompt. The guards above it only skip inputs, textareas and contentEditable targets, and ModalShell focuses a `tabIndex={-1}` div, so every shortcut sailed through. Ask the screen instead of the flags. `isModalOpen()` looks for an element with `aria-modal="true"`, which ModalShell, LeftPanel's hand-rolled portal and — now that `ui/dialog` states it, since Radix 1.1.15 does not — the two Radix dialogs all render. One predicate, and the modals that do not exist yet are covered for free. `store/undo` gets the same line. NewEditorShell defers Ctrl+Z / Ctrl+Y to that listener's own `window` handler, which had no modal guard at all, so undo rewrote the document under every modal, including the two the shell did suppress. `EditorDialogsActions.isDialogOpen` and the `sectionRef` that mirrored the section for it had no other caller and go with it. Fixes #434
|
Warning Review limit reached
Next review available in: 15 minutes Limit details: You’ve used all 4 included reviews currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?Wait for the limit to reset, then comment An organization admin can change what happens after included review limits in Billing. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (8)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
What
While the Export modal is open, pressing
Z,TorCstill creates zoom / trim / camera regions on the timeline behind it. Same for every other modal the editor shell owns, andCtrl+Zunder any modal — including the two that were otherwise covered.This replaces the guard's list of open-state flags with one central question — is a modal on screen? — answered from the DOM.
Fixes #434
Root cause
65aa74efadded the guard as:Two flags, not two modals:
isDialogOpen()comes fromEditorDialogsContextand answerssectionRef.current !== null, whereEditorDialogSectionhas exactly one member:"providers".isConfigOpencomes fromShortcutsContextand is the Keyboard Shortcuts dialog alone.Those are precisely the two dialogs whose open state had been lifted into a context for an unrelated reason (the app menu, #420). Every other modal keeps its
useStatein the component that mounts it —const [exportOpen, setExportOpen] = useState(false)sits inNewEditorShellitself, in the same component as the handler, and the guard cannot see it. Same for Open project, New project, Edit clip and the unsaved-changes prompt.The two guards preceding it only skip
HTMLInputElement/HTMLTextAreaElement/isContentEditabletargets, andModalShellfocuses atabIndex={-1}div — a div, not a text field — soZ/T/Creachtl.addZoom/tl.addTrim/tl.addCameraFullscreenuntouched. The commit message said as much itself: "Both flags are reachable now that the open state is lifted out of the components that used to own it (#420)" — the fix was scoped to what #420 made reachable, not to the set of modals.Second instance of the same bug:
src/lib/ai-edition/store/undo.tsregisters its ownwindowkeydown listener with the identical input/textarea/contentEditable guards and no modal guard at all, andNewEditorShelldeliberately defersCtrl+Z/Ctrl+Yto it. So undo/redo mutated the timeline under every modal, including the two the shell did suppress — which is exactly what the issue flags as compounding with #433.What changed
New
src/lib/ai-edition/modalGuard.ts— one predicate both callers can import (undo.tsis a store file, so this lives outside the component tree):Every modal in this tree already announces itself that way:
ModalShell(Export, Open project, New project, Edit clip, unsaved changes, AI providers) andLeftPanel's hand-rolled portal both renderaria-modal="true".src/components/ui/dialog.tsx— addedaria-modal="true"toDialogPrimitive.Content. Radix 1.1.15 rendersrole="dialog"with noaria-modal(verified innode_modules/@radix-ui/react-dialog/dist/index.mjs), and this is what foldsShortcutsConfigDialogandEditorEmptyState's drop-error dialog under the same selector. It is placed before{...props}so a caller can still override it, and both consumers are modal dialogs (nomodal={false}anywhere insrc/).Why
[aria-modal="true"]and not a[role="dialog"][data-state="open"]fallback: Radix Popover content also rendersrole="dialog", and popovers are used byColorFieldandV4Timeline. Arole-based selector would silently suppress editor shortcuts under every color picker.src/components/ai-edition/NewEditorShell.tsx— the guard becomesif (isModalOpen()) return;.isConfigOpenandisDialogOpendrop out of the destructures and the effect deps.src/lib/ai-edition/store/undo.ts— the same one-line guard after the target checks.src/contexts/EditorDialogsContext.tsx—NewEditorShellwas the only consumer ofisDialogOpen, so it and thesectionRefthat existed to mirror the section for it are removed. The context goes back to being a section plus two openers, which is the shape its own comment describes wanting.Why not the alternatives
"export"toEditorDialogSectionis a second special case and leaves the other modals broken.undo.tswithout threading a hook through the store layer.e.target.closest('[role="dialog"]')) fails exactly when the bug bites: the app menu closes without restoring focus, soe.targetis usuallydocument.body— the premise of65aa74ef's own commit message.Tests
NewEditorShell.dialogShortcuts.test.tsxused to drive the context (dialogActions.openDialog("providers")), which puts nothing on screen — it asserted the flag, not the behavior. It now opens a modal the shell itself owns viaCtrl+O(localuseState, the same shape as Export) and asserts:?does not reachopenConfigwhile it is open, and does once Escape closes itCtrl+Ndoes not stack a second dialog underneath?is the observable because it is the one shortcut that survives thehasProjectgate; the keys the issue reports (Z,T,C) sit further down the same handler, behind the same singlereturn.Two new files:
src/lib/ai-edition/store/undo.modalGuard.test.tsx—Ctrl+Zleaves the document alone while anaria-modaldialog is on screen, and the edit is still undoable once it is gone.src/lib/ai-edition/modalGuard.test.tsx— pins the attribute the predicate depends on against the realModalShellandui/dialogcomponents, since it lives in them rather than in the guard.Verified failing without the fix
Neutralizing
isModalOpen()toreturn false(the pre-fix state for these tests: neither flag's dialog is on screen in any of them):Stashing only the
dialog.tsxchange:How verified
All run on the final rebased tree (branched from
origin/main, rebased ontob1fc616f).The broad targeted run (rather than only the touched files) is deliberate:
ui/dialogis a shared primitive, so every consumer of it and ofEditorDialogsContextwas exercised. Not manually smoke-tested against the packaged app — the issue's repro is a real Export-modal keypress, worth one pass on Windows before release.Interaction with #433 — read before merging either
claude/fix-433-undo-redotouches two of the same files. The two branches merge with one trivial conflict insrc/lib/ai-edition/store/undo.ts(a straight union of the import list and the keydown guard), and the merged tree is fully green: both typechecks, lint, and 816 tests acrosssrc/lib/ai-edition src/components/ai-edition src/contexts src/components/ui.It is green and still broken. A combined-merge check found a semantic conflict that neither branch's tests catch:
#433 stops using Electron's
role: "undo"and gives the Edit menu its ownCmdOrCtrl+Zaccelerator, forwarded over IPC asmenu-undotorunUndoinundo.ts. That handler checks onlyisTextEditingTarget(document.activeElement). There is noisModalOpen()on that path, and a modal's controls are buttons, not text fields — so the check passes andundo()rewrites the document under the open modal. That is verbatim the bug this PR closes.Reachable on every platform, for two different reasons:
-[NSApplication sendEvent:]before the event reaches the web contents, so the guarded keydown listener never runs. This PR's guard becomes dead code on macOS once [Bug]: Undo/Redo are advertised in the Keyboard Shortcuts modal but do nothing #433 lands.preventDefault(), so the key becomes an unhandled keyboard event, which is exactly how Electron dispatches menu accelerators on those platforms. No modal: the shortcut preventDefaults and the accelerator is suppressed. Modal open: it is not, and the accelerator fires into the unguardedrunUndo.Verified empirically on the merged tree with a throwaway probe (since deleted): the keydown path is correctly guarded and comes back
defaultPrevented === false, whilerunUndo()with anaria-modal="true"node in the DOM rewrote the document and fired the persist callback.Whichever of the two lands second must carry this, in
src/lib/ai-edition/store/undo.ts— text-field check first, so a rename dialog's input still gets the browser's text undo:…and the same in
runRedo, plus a case in #433's"the Edit menu's undo/redo route"describe that appends anaria-modal="true"node and asserts the document is untouched. That block currently clearsdocument.bodyin itsbeforeEach, which incidentally guaranteesisModalOpen()is false — which is why it passes today.🤖 Generated with Claude Code