diff --git a/src/components/ai-edition/EditClipModal.test.tsx b/src/components/ai-edition/EditClipModal.test.tsx
index b8a42069e..f8d5c271d 100644
--- a/src/components/ai-edition/EditClipModal.test.tsx
+++ b/src/components/ai-edition/EditClipModal.test.tsx
@@ -130,6 +130,79 @@ describe("EditClipModal trim duration readout (#558)", () => {
});
});
+describe("EditClipModal in a short window (#1005)", () => {
+ // The actions used to sit at the end of the scrolling body. In a window too short for the
+ // card, Apply was clipped out of view, and a click where it should have been landed on the
+ // backdrop, which closed the dialog and dropped the new trim and crop without a word.
+ function renderWithSpies() {
+ const onClose = vi.fn();
+ const onApply = vi.fn();
+ renderWithI18n(
+ ,
+ );
+ return { onClose, onApply };
+ }
+
+ function clickBackdrop() {
+ const backdrop = document.querySelector('[class*="modalBackdrop"]');
+ if (!backdrop) throw new Error("no modal backdrop rendered");
+ fireEvent.click(backdrop);
+ }
+
+ it("keeps the actions out of the scrolling body", () => {
+ renderWithSpies();
+ const apply = screen.getByRole("button", { name: "Apply" });
+
+ expect(apply.closest('[class*="modalBody"]')).toBeNull();
+ expect(apply.closest('[class*="modalFoot"]')).not.toBeNull();
+ });
+
+ it("closes on a backdrop click while there is nothing to lose", () => {
+ const { onClose } = renderWithSpies();
+
+ clickBackdrop();
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([
+ [
+ "a trim",
+ () => {
+ fireEvent.pointerDown(screen.getByRole("button", { name: "Adjust clip start" }), {
+ clientX: 0,
+ });
+ act(() => {
+ window.dispatchEvent(new MouseEvent("pointermove", { clientX: 100 }));
+ window.dispatchEvent(new MouseEvent("pointerup"));
+ });
+ },
+ ],
+ ["a crop", () => fireEvent.click(screen.getByRole("button", { name: "1:1" }))],
+ ])("keeps %s through a backdrop click, and drops it on Escape or Cancel", (_, edit) => {
+ const { onClose, onApply } = renderWithSpies();
+ edit();
+
+ clickBackdrop();
+ expect(onClose).not.toHaveBeenCalled();
+ expect(screen.getByRole("button", { name: "Apply" })).toBeEnabled();
+
+ // Those two say "discard"; a click beside the card does not.
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(onClose).toHaveBeenCalledTimes(2);
+ expect(onApply).not.toHaveBeenCalled();
+ });
+});
+
describe("EditClipModal crop from the keyboard", () => {
it("moves the crop with the arrows and resizes it with Shift + the arrows", () => {
const onApply = vi.fn();
diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx
index dfe2f20a7..bc5833e53 100644
--- a/src/components/ai-edition/Modals.tsx
+++ b/src/components/ai-edition/Modals.tsx
@@ -36,9 +36,11 @@ export function ModalShell({
open,
onClose,
closeOnEscape = true,
+ closeOnBackdrop = true,
title,
subtitle,
wide,
+ footer,
children,
}: BaseModalProps & {
title: string;
@@ -47,6 +49,12 @@ export function ModalShell({
/** Off for a dialog that handles Escape itself — two listeners both fire for one
* keypress, and this one's `onClose` wins whatever order they registered in. */
closeOnEscape?: boolean;
+ /** Off while a stray click beside the card would throw away an edit not yet applied.
+ * Escape, Cancel and the close button still discard it: that is what they mean. */
+ closeOnBackdrop?: boolean;
+ /** The dialog's actions, kept out of the scrolling body so a short window never hides
+ * them: a click where an unseen Apply sat used to land on the backdrop. */
+ footer?: ReactNode;
children: ReactNode;
}) {
const tc = useScopedT("common");
@@ -78,7 +86,11 @@ export function ModalShell({
aria-modal="true"
aria-labelledby="modal-title"
>
-
+
@@ -96,6 +108,7 @@ export function ModalShell({
{children}
+ {footer ? : null}
);
@@ -918,9 +931,48 @@ export function EditClipModal({
+
+
+
+
+
+
+ }
>
{cropPreviewSource ? (
@@ -1088,45 +1140,6 @@ export function EditClipModal({
-
-
-
-
-
-
-
-
);
}
diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css
index 30b3c0947..bbeeaacef 100644
--- a/src/components/ai-edition/NewEditorShell.module.css
+++ b/src/components/ai-edition/NewEditorShell.module.css
@@ -2132,6 +2132,11 @@ textarea.control {
gap: 20px;
overflow: auto;
}
+/* A sibling of the body, not inside it: when the card hits its max-height only the body
+ shrinks and scrolls (a scroll container's flex minimum is 0, this one's is its content). */
+.modalFoot {
+ padding: 0 24px 24px;
+}
.btn {
height: 34px;