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
124 changes: 123 additions & 1 deletion components/images/image-lightbox.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ButtonHTMLAttributes, ReactEventHandler, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -817,3 +817,125 @@ describe("ImageLightbox - State Reset", () => {
expect(screen.queryByText("Current")).not.toBeInTheDocument();
});
});

describe("ImageLightbox - Mobile Swipe Navigation", () => {
const mockImage = {
url: "https://example.com/test-image.jpg",
prompt: "A beautiful landscape",
model: "test-model",
width: 1024,
height: 1024,
};

beforeEach(() => {
vi.clearAllMocks();
});

it("navigates to the next media on upward touch swipe", () => {
const onNext = vi.fn();

render(
<ImageLightbox
image={mockImage}
isOpen={true}
onClose={vi.fn()}
mediaNavigation={{
hasNext: true,
hasPrevious: true,
onNext,
onPrevious: vi.fn(),
}}
/>,
);

const swipeRegion = screen.getByTestId("lightbox-swipe-region");
fireEvent.pointerDown(swipeRegion, {
pointerId: 1,
pointerType: "touch",
isPrimary: true,
clientX: 120,
clientY: 300,
});
fireEvent.pointerUp(swipeRegion, {
pointerId: 1,
pointerType: "touch",
isPrimary: true,
clientX: 118,
clientY: 180,
});

expect(onNext).toHaveBeenCalledTimes(1);
});

it("navigates to the previous media on downward touch swipe", () => {
const onPrevious = vi.fn();

render(
<ImageLightbox
image={mockImage}
isOpen={true}
onClose={vi.fn()}
mediaNavigation={{
hasNext: true,
hasPrevious: true,
onNext: vi.fn(),
onPrevious,
}}
/>,
);

const swipeRegion = screen.getByTestId("lightbox-swipe-region");
fireEvent.pointerDown(swipeRegion, {
pointerId: 1,
pointerType: "touch",
isPrimary: true,
clientX: 120,
clientY: 180,
});
fireEvent.pointerUp(swipeRegion, {
pointerId: 1,
pointerType: "touch",
isPrimary: true,
clientX: 122,
clientY: 320,
});

expect(onPrevious).toHaveBeenCalledTimes(1);
});

it("ignores short touch drags", () => {
const onNext = vi.fn();

render(
<ImageLightbox
image={mockImage}
isOpen={true}
onClose={vi.fn()}
mediaNavigation={{
hasNext: true,
hasPrevious: false,
onNext,
onPrevious: vi.fn(),
}}
/>,
);

const swipeRegion = screen.getByTestId("lightbox-swipe-region");
fireEvent.pointerDown(swipeRegion, {
pointerId: 1,
pointerType: "touch",
isPrimary: true,
clientX: 120,
clientY: 240,
});
fireEvent.pointerUp(swipeRegion, {
pointerId: 1,
pointerType: "touch",
isPrimary: true,
clientX: 118,
clientY: 200,
});

expect(onNext).not.toHaveBeenCalled();
});
});
118 changes: 90 additions & 28 deletions components/images/image-lightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type LightboxImage,
useImageLightbox,
} from "@/hooks/use-image-lightbox";
import { useVerticalSwipeNavigation } from "@/hooks/use-vertical-swipe-navigation";
import { getModelConstraints } from "@/lib/config/models";
import type { GeneratedImage } from "@/lib/schemas/pollinations.schema";
import {
Expand All @@ -47,6 +48,12 @@ interface ImageLightboxProps {
image: LightboxImage | null;
isOpen: boolean;
onClose: () => void;
mediaNavigation?: {
hasNext: boolean;
hasPrevious: boolean;
onNext: () => void;
onPrevious: () => void;
};
/** Optional callback when a prompt is inserted from the library (used to update prompt input) */
onInsertPrompt?: (content: string) => void;
}
Expand All @@ -55,6 +62,7 @@ export function ImageLightbox({
image,
isOpen,
onClose,
mediaNavigation,
onInsertPrompt,
}: ImageLightboxProps) {
// Fetch full image details if we only have thumbnail data (no prompt)
Expand Down Expand Up @@ -119,6 +127,7 @@ export function ImageLightbox({

// Zoom state for single-image mode (updated via callback from LightboxMediaDisplay)
const [singleModeZoomed, setSingleModeZoomed] = React.useState(false);
const suppressBackdropClickRef = React.useRef(false);
const resetEditSessionState = React.useCallback(() => {
setEditChain([]);
setSelectedVersionIndex(0);
Expand All @@ -129,11 +138,13 @@ export function ImageLightbox({

React.useEffect(() => {
// Reset local-only edit state whenever the opened image changes.
suppressBackdropClickRef.current = false;
resetEditSessionState();
}, [imageSessionKey, resetEditSessionState]);

React.useEffect(() => {
if (!isOpen) {
suppressBackdropClickRef.current = false;
resetEditSessionState();
}
}, [isOpen, resetEditSessionState]);
Expand Down Expand Up @@ -247,6 +258,15 @@ export function ImageLightbox({
onClose();
}, [onClose]);

const handleBackdropCloseAttempt = React.useCallback(() => {
if (suppressBackdropClickRef.current) {
suppressBackdropClickRef.current = false;
return;
}

handleCloseAttempt();
}, [handleCloseAttempt]);

// ==========================================
// Pane action handlers (used in compare mode)
// ==========================================
Expand Down Expand Up @@ -392,6 +412,34 @@ export function ImageLightbox({
outputHeight,
]);

const isSwipeNavigationEnabled =
isOpen &&
!hasEdits &&
!isZoomed &&
!isEditPanelOpen &&
Boolean(mediaNavigation) &&
(mediaNavigation?.hasNext === true || mediaNavigation?.hasPrevious === true);

const swipeNavigationHandlers = useVerticalSwipeNavigation({
enabled: isSwipeNavigationEnabled,
onSwipeUp: mediaNavigation?.hasNext
? () => {
suppressBackdropClickRef.current = true;
mediaNavigation.onNext();
}
Comment on lines +425 to +429
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress backdrop close for blocked swipe directions

The backdrop-click suppression is only set when hasNext/hasPrevious is true, so at the first or last gallery item a long swipe in the unavailable direction leaves suppressBackdropClickRef unset. In browsers that still emit a click after that touch sequence (the same behavior this ref is guarding against on successful swipes), handleBackdropCloseAttempt will treat it as a real backdrop click and close the lightbox instead of no-oping at the boundary.

Useful? React with 👍 / 👎.

: undefined,
onSwipeDown: mediaNavigation?.hasPrevious
? () => {
suppressBackdropClickRef.current = true;
mediaNavigation.onPrevious();
}
: undefined,
});
const {
touchAction: swipeTouchAction,
...swipeGestureHandlers
} = swipeNavigationHandlers;

return (
<>
<Dialog open={isOpen} onOpenChange={handleDialogOpenChange}>
Expand All @@ -411,29 +459,36 @@ export function ImageLightbox({
<div className="w-full h-full bg-black/80 backdrop-blur-md cursor-default flex items-center justify-center animate-in fade-in duration-150">
<div className="relative w-full h-full">
{isVideo ? (
<div className="relative w-full h-full flex items-center justify-center p-4">
<button
type="button"
aria-label="Close lightbox"
className="absolute inset-0 cursor-default"
onClick={handleCloseAttempt}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsHovering(true)}
onBlur={() => setIsHovering(false)}
/>
<div className="relative shadow-[0_0_50px_rgba(0,0,0,0.5)] rounded-sm group/video z-10">
<MediaPlayer
url={displayImage.url}
<div
className="relative w-full h-full"
data-testid="lightbox-swipe-region"
style={{ touchAction: swipeTouchAction }}
{...swipeGestureHandlers}
>
<div className="relative w-full h-full flex items-center justify-center p-4">
<button
type="button"
aria-label="Close lightbox"
className="absolute inset-0 cursor-default"
onClick={handleBackdropCloseAttempt}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsHovering(true)}
onBlur={() => setIsHovering(false)}
/>
<div className="relative shadow-[0_0_50px_rgba(0,0,0,0.5)] rounded-sm group/video z-10">
<MediaPlayer
url={displayImage.url}
alt={displayImage.prompt || "Generated video"}
contentType={displayImage.contentType}
controls={true}
autoPlay={true}
loop={true}
muted={false}
className="w-auto h-auto max-w-full max-h-full object-contain select-none"
draggable={false}
/>
loop={true}
muted={false}
className="w-auto h-auto max-w-full max-h-full object-contain select-none"
draggable={false}
/>
</div>
</div>
</div>
) : hasEdits ? (
Expand All @@ -456,15 +511,22 @@ export function ImageLightbox({
/>
</div>
) : (
<LightboxMediaDisplay
image={activeImage}
isOpen={isOpen}
isLoadingDetails={isLoadingDetails}
isGenerating={isGenerating}
onHoverChange={setIsHovering}
onBackdropClick={handleCloseAttempt}
onZoomChange={setSingleModeZoomed}
/>
<div
className="w-full h-full"
data-testid="lightbox-swipe-region"
style={{ touchAction: swipeTouchAction }}
{...swipeGestureHandlers}
>
<LightboxMediaDisplay
image={activeImage}
isOpen={isOpen}
isLoadingDetails={isLoadingDetails}
isGenerating={isGenerating}
onHoverChange={setIsHovering}
onBackdropClick={handleBackdropCloseAttempt}
onZoomChange={setSingleModeZoomed}
/>
</div>
)}

{/* Bottom hover zone to trigger overlay (only in single mode) */}
Expand Down
Loading