Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/components/ai-edition/EditClipModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<EditClipModal
open
onClose={onClose}
clip={CLIP}
assetMeta={ASSET}
videoSources={[]}
onApply={onApply}
/>,
);
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();
Expand Down
93 changes: 53 additions & 40 deletions src/components/ai-edition/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ export function ModalShell({
open,
onClose,
closeOnEscape = true,
closeOnBackdrop = true,
title,
subtitle,
wide,
footer,
children,
}: BaseModalProps & {
title: string;
Expand All @@ -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");
Expand Down Expand Up @@ -78,7 +86,11 @@ export function ModalShell({
aria-modal="true"
aria-labelledby="modal-title"
>
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
<div
className={styles.modalBackdrop}
aria-hidden
onClick={closeOnBackdrop ? onClose : undefined}
/>
<div className={`${styles.modalCard} ${wide ? styles.wide : ""}`}>
<header className={styles.modalHead}>
<div>
Expand All @@ -96,6 +108,7 @@ export function ModalShell({
</button>
</header>
<div className={styles.modalBody}>{children}</div>
{footer ? <footer className={styles.modalFoot}>{footer}</footer> : null}
</div>
</div>
);
Expand Down Expand Up @@ -918,9 +931,48 @@ export function EditClipModal({
<ModalShell
open={open}
onClose={onClose}
closeOnBackdrop={!hasChanges}
title={t("editClipDialog.title")}
subtitle={assetMeta?.label ?? undefined}
wide
footer={
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
paddingTop: 10,
borderTop: "1px solid var(--border-soft)",
}}
>
<button
type="button"
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={handleReset}
disabled={!hasChanges}
>
{t("editClipDialog.reset")}
</button>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={onClose}
>
{tc("actions.cancel")}
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={handleApply}
disabled={!hasChanges}
>
<Pencil size={14} />
{t("editClipDialog.apply")}
</button>
</div>
</div>
}
>
<div ref={cropFrameRef} style={previewBoxStyle(videoAspectRatio)}>
{cropPreviewSource ? (
Expand Down Expand Up @@ -1088,45 +1140,6 @@ export function EditClipModal({
</div>
</div>
</div>

<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
paddingTop: 10,
marginTop: 10,
flexShrink: 0,
borderTop: "1px solid var(--border-soft)",
}}
>
<button
type="button"
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={handleReset}
disabled={!hasChanges}
>
{t("editClipDialog.reset")}
</button>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={onClose}
>
{tc("actions.cancel")}
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={handleApply}
disabled={!hasChanges}
>
<Pencil size={14} />
{t("editClipDialog.apply")}
</button>
</div>
</div>
</ModalShell>
);
}
Expand Down
5 changes: 5 additions & 0 deletions src/components/ai-edition/NewEditorShell.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading