Skip to content

Commit 1cc63df

Browse files
committed
fix(editor): suppress editor shortcuts under every modal, not just some
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
1 parent fde996d commit 1cc63df

8 files changed

Lines changed: 245 additions & 71 deletions

File tree

‎src/components/ai-edition/NewEditorShell.dialogShortcuts.test.tsx‎

Lines changed: 44 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
// run underneath the backdrop — Delete destroying the selected region, Ctrl+O stacking a second
66
// aria-modal dialog, `?` stacking the shortcuts dialog on top of the one already there.
77
//
8-
// The guard reads `isDialogOpen()` (EditorDialogsContext, answered from a ref) and
9-
// `isConfigOpen`. Both are asserted through the shell's real keydown handler here.
8+
// The guard asks `isModalOpen()` (lib/ai-edition/modalGuard) — one question about the screen,
9+
// not one flag per dialog. The flag version named the two dialogs whose open state lived in a
10+
// context and missed every modal the shell owns as plain `useState`, so Z/T/C kept adding
11+
// regions under the Export modal (issue #434). The modal opened below is one of those: its
12+
// state is a `useState` in the shell, exactly like Export's.
1013

1114
import "@testing-library/jest-dom";
1215
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
@@ -46,20 +49,12 @@ vi.mock("@/contexts/I18nContext", () => ({
4649
useScopedT: () => (key: string) => key,
4750
}));
4851

49-
import { EditorDialogsProvider, useEditorDialogActions } from "@/contexts/EditorDialogsContext";
52+
import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext";
5053
import { NewEditorShell } from "./NewEditorShell";
5154

52-
let dialogActions: ReturnType<typeof useEditorDialogActions> | null = null;
53-
54-
function CaptureDialogActions() {
55-
dialogActions = useEditorDialogActions();
56-
return null;
57-
}
58-
5955
function renderShell() {
6056
return render(
6157
<EditorDialogsProvider>
62-
<CaptureDialogActions />
6358
<NewEditorShell />
6459
</EditorDialogsProvider>,
6560
);
@@ -72,7 +67,6 @@ function pressOnBody(init: KeyboardEventInit) {
7267

7368
beforeEach(() => {
7469
openConfig.mockClear();
75-
dialogActions = null;
7670
// No preload in jsdom, and no scrolling either; the chat transcript pins itself to the
7771
// bottom on every render.
7872
(window as unknown as { electronAPI?: unknown }).electronAPI = {
@@ -122,6 +116,18 @@ afterEach(() => {
122116
(window as unknown as { electronAPI?: unknown }).electronAPI = undefined;
123117
});
124118

119+
/**
120+
* Ctrl+O is handled before the `hasProject` gate, so it opens the project picker whatever the
121+
* editor's state — the cheapest way to put a real, shell-owned modal on screen. Its handler is
122+
* async (it awaits the unsaved-changes prompt before opening the picker), hence the async act:
123+
* a synchronous assertion would pass whether the guard is there or not.
124+
*/
125+
async function openShellModal() {
126+
await act(async () => {
127+
pressOnBody({ key: "o", ctrlKey: true });
128+
});
129+
}
130+
125131
describe("NewEditorShell shortcuts, with a dialog over the editor", () => {
126132
it("routes ? to the shortcuts dialog while nothing is open", () => {
127133
renderShell();
@@ -131,49 +137,46 @@ describe("NewEditorShell shortcuts, with a dialog over the editor", () => {
131137
expect(openConfig).toHaveBeenCalledTimes(1);
132138
});
133139

134-
it("suppresses ? once a dialog owns the screen, and resumes when it closes", () => {
140+
it("opens the project picker on Ctrl+O while nothing is open", async () => {
135141
renderShell();
136142

137-
act(() => {
138-
dialogActions?.openDialog("providers");
139-
});
140-
pressOnBody({ key: "?" });
141-
expect(openConfig).not.toHaveBeenCalled();
143+
await openShellModal();
142144

143-
act(() => {
144-
dialogActions?.closeDialog();
145-
});
146-
pressOnBody({ key: "?" });
147-
expect(openConfig).toHaveBeenCalledTimes(1);
145+
// The provider dialog is mounted in App.tsx, not here, so the shell renders no dialog of
146+
// its own unless Ctrl+O got through.
147+
expect(screen.getByRole("dialog")).toBeInTheDocument();
148148
});
149149

150-
// Ctrl+O is handled before the `hasProject` gate, so it fired whatever the editor's state —
151-
// this is the one that put a SECOND aria-modal dialog on screen, both of them emitting the
152-
// hardcoded `id="modal-title"`. Its handler is async (it awaits the unsaved-changes prompt
153-
// before opening the picker), hence the async act: a synchronous assertion here would pass
154-
// whether the guard is there or not.
155-
it("opens the project picker on Ctrl+O while nothing is open", async () => {
150+
// The #434 shape: a modal the shell owns as local state, which no context knows about. `?`
151+
// is the observable because it is the one shortcut that survives the `hasProject` gate —
152+
// the keys the issue reports (Z, T, C) sit further down the same handler, behind the same
153+
// single `return`.
154+
it("suppresses ? while a modal the shell itself owns is open, and resumes when it closes", async () => {
156155
renderShell();
157156

158-
await act(async () => {
159-
pressOnBody({ key: "o", ctrlKey: true });
160-
});
157+
await openShellModal();
158+
pressOnBody({ key: "?" });
159+
expect(openConfig).not.toHaveBeenCalled();
161160

162-
// The provider dialog is mounted in App.tsx, not here, so the shell renders no dialog of
163-
// its own unless Ctrl+O got through.
164-
expect(screen.getByRole("dialog")).toBeInTheDocument();
161+
// ModalShell listens for Escape on `document`, so this closes the picker for real
162+
// rather than reaching into the shell's state.
163+
fireEvent.keyDown(document, { key: "Escape" });
164+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
165+
166+
pressOnBody({ key: "?" });
167+
expect(openConfig).toHaveBeenCalledTimes(1);
165168
});
166169

167-
it("suppresses Ctrl+O once a dialog owns the screen", async () => {
170+
// The other half of the same bug: a shortcut that opens a dialog stacked a SECOND one on
171+
// screen under the first, both of them emitting the hardcoded `id="modal-title"`.
172+
it("does not stack a second dialog when Ctrl+N fires under an open modal", async () => {
168173
renderShell();
169174

170-
act(() => {
171-
dialogActions?.openDialog("providers");
172-
});
175+
await openShellModal();
173176
await act(async () => {
174-
pressOnBody({ key: "o", ctrlKey: true });
177+
pressOnBody({ key: "n", ctrlKey: true });
175178
});
176179

177-
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
180+
expect(screen.getAllByRole("dialog")).toHaveLength(1);
178181
});
179182
});

‎src/components/ai-edition/NewEditorShell.tsx‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
applyProbedDuration,
1414
replaceTimeline as replaceTimelineOp,
1515
} from "@/lib/ai-edition/document/timeline";
16+
import { isModalOpen } from "@/lib/ai-edition/modalGuard";
1617
import { type AxcutClip, documentSchema } from "@/lib/ai-edition/schema";
1718
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
1819
import {
@@ -130,12 +131,12 @@ export function NewEditorShell() {
130131
action: "close" | "new" | "open" | "record";
131132
resolve: (choice: UnsavedChoice) => void;
132133
} | null>(null);
133-
const { shortcuts, isMac, isConfigOpen, openConfig: openShortcutsConfig } = useShortcuts();
134+
const { shortcuts, isMac, openConfig: openShortcutsConfig } = useShortcuts();
134135
// The actions half of the dialog context, not the section: this component only ever *opens*
135136
// one, and subscribing it to the open state would re-render the whole editor — timeline,
136-
// preview, transport — twice per dialog interaction. `isDialogOpen` answers the keyboard
137-
// handler below from a ref, which is why it can live in a value that never changes.
138-
const { openDialog, isDialogOpen } = useEditorDialogActions();
137+
// preview, transport — twice per dialog interaction. Whether a dialog is open is a question
138+
// for `isModalOpen`, which answers for every modal rather than for this context's one.
139+
const { openDialog } = useEditorDialogActions();
139140
// Transcription is local and every transcript-driven feature (Smart cuts,
140141
// captions, the transcript pane) needs one, so the editor produces them by
141142
// itself instead of waiting for the user to find the button. This hook is
@@ -861,9 +862,10 @@ export function NewEditorShell() {
861862
// A modal owns the screen. Its own controls are buttons, not text fields, so the two
862863
// guards above let every editor shortcut through underneath it: Delete destroyed the
863864
// selected region behind the backdrop, Ctrl+O stacked a second `aria-modal` dialog on
864-
// top, and `?` stacked the shortcuts dialog. Both flags are reachable now that the
865-
// open state is lifted out of the components that used to own it (#420).
866-
if (isDialogOpen() || isConfigOpen) return;
865+
// top, and `?` stacked the shortcuts dialog. One question about the screen, not one
866+
// flag per dialog — the flag version knew only about the two dialogs whose open state
867+
// happened to live in a context, so Z/T/C kept adding regions under Export (#434).
868+
if (isModalOpen()) return;
867869
const ctrl = e.ctrlKey || e.metaKey;
868870
if (ctrl && e.key === "s") {
869871
e.preventDefault();
@@ -1049,8 +1051,6 @@ export function NewEditorShell() {
10491051
saveDocument,
10501052
copiedClipId,
10511053
openShortcutsConfig,
1052-
isConfigOpen,
1053-
isDialogOpen,
10541054
shortcuts,
10551055
isMac,
10561056
togglePlay,

‎src/components/ui/dialog.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ const DialogContent = React.forwardRef<
3535
<DialogOverlay />
3636
<DialogPrimitive.Content
3737
ref={ref}
38+
// Radix 1.1.15 renders `role="dialog"` but no `aria-modal`, and a modal `Dialog` is
39+
// exactly what this content is. Stated explicitly it also makes these dialogs visible
40+
// to `isModalOpen` (lib/ai-edition/modalGuard), the one predicate the editor's global
41+
// shortcuts ask before acting. Before `{...props}` so a caller can still override it.
42+
aria-modal="true"
3843
className={cn(
3944
"fixed left-[50%] top-[50%] z-[10000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
4045
className,

‎src/contexts/EditorDialogsContext.tsx‎

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createContext, type ReactNode, useContext, useMemo, useRef, useState } from "react";
1+
import { createContext, type ReactNode, useContext, useMemo, useState } from "react";
22

33
// Which of the editor chrome's own dialogs is open, lifted out of the component that used to
44
// own it.
@@ -16,16 +16,17 @@ import { createContext, type ReactNode, useContext, useMemo, useRef, useState }
1616
// Split in two on purpose. The section changes on every open and close, the actions never do.
1717
// `NewEditorShell` owns the timeline, the preview and the transport, and only ever needs to
1818
// *open* a dialog — subscribing it to the section would re-render the whole editor twice per
19-
// dialog interaction, so it takes the actions alone. `isDialogOpen` serves the readers that
20-
// are event handlers rather than renders: it answers from a ref, which is what lets it sit in
21-
// a value whose identity never changes.
19+
// dialog interaction, so it takes the actions alone.
20+
//
21+
// Nothing asks this context whether a dialog is open. It briefly answered that for the editor's
22+
// global shortcuts, which was only ever half an answer — this context knows about its own
23+
// dialogs and about no others (#434). `isModalOpen` (lib/ai-edition/modalGuard) is where that
24+
// question is asked now.
2225
export type EditorDialogSection = "providers";
2326

2427
interface EditorDialogsActions {
2528
openDialog: (section: EditorDialogSection) => void;
2629
closeDialog: () => void;
27-
/** A live answer without a subscription — for event handlers, never for rendering. */
28-
isDialogOpen: () => boolean;
2930
}
3031

3132
// `undefined` is the "no provider above me" marker, so that `null` stays free to mean the real
@@ -49,24 +50,11 @@ export function useEditorDialogActions(): EditorDialogsActions {
4950

5051
export function EditorDialogsProvider({ children }: { children: ReactNode }) {
5152
const [section, setSection] = useState<EditorDialogSection | null>(null);
52-
// Mirrored so `isDialogOpen` can read the current section without the actions value having
53-
// to depend on it. Written by the two openers, not during render and not from an effect:
54-
// during render a discarded one would leave the ref claiming a dialog that never committed,
55-
// and from an effect a keystroke landing between the click and the commit would still get
56-
// the previous answer. `setSection` is called from nowhere else, so the two cannot drift.
57-
const sectionRef = useRef<EditorDialogSection | null>(null);
5853

5954
const actions = useMemo<EditorDialogsActions>(
6055
() => ({
61-
openDialog: (next) => {
62-
sectionRef.current = next;
63-
setSection(next);
64-
},
65-
closeDialog: () => {
66-
sectionRef.current = null;
67-
setSection(null);
68-
},
69-
isDialogOpen: () => sectionRef.current !== null,
56+
openDialog: (next) => setSection(next),
57+
closeDialog: () => setSection(null),
7058
}),
7159
[],
7260
);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// @vitest-environment jsdom
2+
// `isModalOpen` is only as good as the attribute it looks for, and that attribute lives in two
3+
// components rather than in this module. So the contract is asserted against the real ones:
4+
// `ModalShell` (every modal in the ai-edition tree — Export, Open project, New project, Edit
5+
// clip, the unsaved-changes prompt, the AI providers dialog) and `ui/dialog`'s Radix content
6+
// (the shortcuts dialog and the drop-error dialog). Radix 1.1.15 emits `role="dialog"` and no
7+
// `aria-modal` of its own, which is why `dialog.tsx` states it.
8+
9+
import "@testing-library/jest-dom";
10+
import { cleanup, render } from "@testing-library/react";
11+
import { afterEach, describe, expect, it, vi } from "vitest";
12+
13+
vi.mock("@/contexts/I18nContext", () => ({
14+
useScopedT: () => (key: string) => key,
15+
}));
16+
17+
import { ModalShell } from "@/components/ai-edition/Modals";
18+
import { Dialog, DialogContent } from "@/components/ui/dialog";
19+
import { isModalOpen } from "./modalGuard";
20+
21+
afterEach(cleanup);
22+
23+
const noop = () => {
24+
/* nothing to close in these tests */
25+
};
26+
27+
describe("isModalOpen", () => {
28+
it("is false with nothing on screen", () => {
29+
render(<div>the editor</div>);
30+
31+
expect(isModalOpen()).toBe(false);
32+
});
33+
34+
it("is true while a ModalShell is open, false once it closes", () => {
35+
const { rerender } = render(
36+
<ModalShell open onClose={noop} title="Export">
37+
<button type="button">Start export</button>
38+
</ModalShell>,
39+
);
40+
expect(isModalOpen()).toBe(true);
41+
42+
rerender(
43+
<ModalShell open={false} onClose={noop} title="Export">
44+
<button type="button">Start export</button>
45+
</ModalShell>,
46+
);
47+
expect(isModalOpen()).toBe(false);
48+
});
49+
50+
it("is true while a ui/dialog content is open", () => {
51+
render(
52+
<Dialog open>
53+
<DialogContent aria-label="Keyboard shortcuts">
54+
<button type="button">Reset</button>
55+
</DialogContent>
56+
</Dialog>,
57+
);
58+
59+
expect(isModalOpen()).toBe(true);
60+
});
61+
});

‎src/lib/ai-edition/modalGuard.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// One central answer to "is a modal on screen?", for the window-level keydown handlers that
2+
// own the editor's shortcuts (`NewEditorShell`) and its undo/redo (`store/undo`).
3+
//
4+
// It asks the DOM instead of enumerating open-state flags. The flag version covered exactly
5+
// the two dialogs whose open state had been lifted into a context for unrelated reasons (#420)
6+
// — the AI providers dialog and the shortcuts dialog — and silently missed every modal that
7+
// kept its `useState` where it was: Export, Open project, New project, Edit clip, the
8+
// unsaved-changes prompt (issue #434). Each new modal was a new special case nobody would
9+
// remember to add.
10+
//
11+
// Every modal in this tree already announces itself the same way: `ModalShell` and the
12+
// hand-rolled portal in `LeftPanel` render `aria-modal="true"`, and `ui/dialog` passes the same
13+
// attribute to Radix's content. So one selector answers for all of them, including the ones
14+
// that do not exist yet.
15+
16+
/**
17+
* True while a modal owns the screen.
18+
*
19+
* For window-level event handlers, never for rendering: it reads the live DOM, so a component
20+
* that called it during render would not re-render when the answer changed.
21+
*/
22+
export function isModalOpen(): boolean {
23+
return document.querySelector('[aria-modal="true"]') !== null;
24+
}

0 commit comments

Comments
 (0)