diff --git a/components/images/image-lightbox.test.tsx b/components/images/image-lightbox.test.tsx index d1a9f9f..be14f22 100644 --- a/components/images/image-lightbox.test.tsx +++ b/components/images/image-lightbox.test.tsx @@ -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"; @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + }); +}); diff --git a/components/images/image-lightbox.tsx b/components/images/image-lightbox.tsx index 591e041..0b3c165 100644 --- a/components/images/image-lightbox.tsx +++ b/components/images/image-lightbox.tsx @@ -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 { @@ -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; } @@ -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) @@ -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); @@ -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]); @@ -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) // ========================================== @@ -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(); + } + : undefined, + onSwipeDown: mediaNavigation?.hasPrevious + ? () => { + suppressBackdropClickRef.current = true; + mediaNavigation.onPrevious(); + } + : undefined, + }); + const { + touchAction: swipeTouchAction, + ...swipeGestureHandlers + } = swipeNavigationHandlers; + return ( <> @@ -411,29 +459,36 @@ export function ImageLightbox({
{isVideo ? ( -
-
) : hasEdits ? ( @@ -456,15 +511,22 @@ export function ImageLightbox({ />
) : ( - +
+ +
)} {/* Bottom hover zone to trigger overlay (only in single mode) */} diff --git a/components/studio/layout/studio-shell.tsx b/components/studio/layout/studio-shell.tsx index c0b9781..c10d85c 100644 --- a/components/studio/layout/studio-shell.tsx +++ b/components/studio/layout/studio-shell.tsx @@ -62,6 +62,7 @@ import { useSubscriptionStatus } from "@/hooks/use-subscription-status"; import { getModel, getModelSupportsNegativePrompt } from "@/lib/config/models"; import { showAuthRequiredToast, showErrorToast } from "@/lib/errors"; import type { + GeneratedImage, ImageGenerationParams, VideoGenerationParams, VideoModel, @@ -445,16 +446,6 @@ export function StudioShell({ return () => window.removeEventListener("keydown", handleKeyDown); }, [handleGenerateClick]); - // ======================================== - // Gallery Image Selection Handler - // ======================================== - const handleSelectGalleryImage = React.useCallback( - (image: ThumbnailData) => { - studioUI.openLightbox(image); - }, - [studioUI], - ); - // ======================================== // Gallery Images for Reference Image Pickers // ======================================== @@ -509,6 +500,94 @@ export function StudioShell({ ? extendedGalleryImages : baseGalleryImages; + const [lightboxUsesGalleryNavigation, setLightboxUsesGalleryNavigation] = + React.useState(false); + + // ======================================== + // Gallery Image Selection Handler + // ======================================== + const handleSelectGalleryImage = React.useCallback( + (image: ThumbnailData) => { + setLightboxUsesGalleryNavigation(true); + studioUI.openLightbox(image); + }, + [studioUI], + ); + + const currentLightboxGalleryIndex = React.useMemo(() => { + if (!lightboxUsesGalleryNavigation || !studioUI.lightboxImage) { + return -1; + } + + const lightboxImageId = + studioUI.lightboxImage._id ?? studioUI.lightboxImage.id ?? null; + + if (lightboxImageId) { + return galleryImages.findIndex( + (image) => image.id === lightboxImageId || image._id === lightboxImageId, + ); + } + + const lightboxImageUrl = + studioUI.lightboxImage.originalUrl ?? studioUI.lightboxImage.url; + + return galleryImages.findIndex( + (image) => + image.originalUrl === lightboxImageUrl || image.url === lightboxImageUrl, + ); + }, [galleryImages, lightboxUsesGalleryNavigation, studioUI.lightboxImage]); + + const handleNextLightboxMedia = React.useCallback(() => { + const nextImage = galleryImages[currentLightboxGalleryIndex + 1]; + if (!nextImage) { + return; + } + + studioUI.setLightboxImage(nextImage); + }, [currentLightboxGalleryIndex, galleryImages, studioUI]); + + const handlePreviousLightboxMedia = React.useCallback(() => { + const previousImage = galleryImages[currentLightboxGalleryIndex - 1]; + if (!previousImage) { + return; + } + + studioUI.setLightboxImage(previousImage); + }, [currentLightboxGalleryIndex, galleryImages, studioUI]); + + const lightboxMediaNavigation = React.useMemo(() => { + if (!isMobile || !lightboxUsesGalleryNavigation || currentLightboxGalleryIndex < 0) { + return undefined; + } + + return { + hasNext: currentLightboxGalleryIndex < galleryImages.length - 1, + hasPrevious: currentLightboxGalleryIndex > 0, + onNext: handleNextLightboxMedia, + onPrevious: handlePreviousLightboxMedia, + }; + }, [ + currentLightboxGalleryIndex, + galleryImages.length, + handleNextLightboxMedia, + handlePreviousLightboxMedia, + isMobile, + lightboxUsesGalleryNavigation, + ]); + + const handleCanvasLightboxOpen = React.useCallback( + (image: GeneratedImage | null) => { + setLightboxUsesGalleryNavigation(false); + studioUI.openLightbox(image); + }, + [studioUI], + ); + + const handleLightboxClose = React.useCallback(() => { + setLightboxUsesGalleryNavigation(false); + studioUI.closeLightbox(); + }, [studioUI]); + // ======================================== // Regenerate Handler // ======================================== @@ -706,7 +785,7 @@ export function StudioShell({ : 0 : undefined } - onOpenLightbox={studioUI.openLightbox} + onOpenLightbox={handleCanvasLightboxOpen} onRegenerate={handleRegenerate} /> ); @@ -764,7 +843,8 @@ export function StudioShell({ { // Insert the prompt into the prompt section via the manager's ref promptManager.promptSectionRef.current?.setPrompt(content); diff --git a/hooks/use-vertical-swipe-navigation.ts b/hooks/use-vertical-swipe-navigation.ts new file mode 100644 index 0000000..d04f817 --- /dev/null +++ b/hooks/use-vertical-swipe-navigation.ts @@ -0,0 +1,119 @@ +"use client"; + +import * as React from "react"; + +interface UseVerticalSwipeNavigationOptions { + enabled: boolean; + onSwipeUp?: () => void; + onSwipeDown?: () => void; + minSwipeDistance?: number; + directionLockRatio?: number; +} + +interface UseVerticalSwipeNavigationResult { + onPointerDown: React.PointerEventHandler; + onPointerMove: React.PointerEventHandler; + onPointerUp: React.PointerEventHandler; + onPointerCancel: React.PointerEventHandler; + touchAction: React.CSSProperties["touchAction"]; +} + +const DEFAULT_MIN_SWIPE_DISTANCE = 72; +const DEFAULT_DIRECTION_LOCK_RATIO = 1.2; + +type SwipeState = { + pointerId: number; + startX: number; + startY: number; +}; + +export function useVerticalSwipeNavigation({ + enabled, + onSwipeUp, + onSwipeDown, + minSwipeDistance = DEFAULT_MIN_SWIPE_DISTANCE, + directionLockRatio = DEFAULT_DIRECTION_LOCK_RATIO, +}: UseVerticalSwipeNavigationOptions): UseVerticalSwipeNavigationResult { + const swipeStateRef = React.useRef(null); + + const resetSwipeState = React.useCallback(() => { + swipeStateRef.current = null; + }, []); + + React.useEffect(() => resetSwipeState, [resetSwipeState]); + + const onPointerDown = React.useCallback>( + (event) => { + if (!enabled || event.pointerType !== "touch" || !event.isPrimary) { + return; + } + + swipeStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + }; + }, + [enabled], + ); + + const onPointerMove = React.useCallback>( + (event) => { + const swipeState = swipeStateRef.current; + if (!enabled || !swipeState || swipeState.pointerId !== event.pointerId) { + return; + } + + const deltaX = Math.abs(event.clientX - swipeState.startX); + const deltaY = Math.abs(event.clientY - swipeState.startY); + + if (deltaY > 8 && deltaY > deltaX * directionLockRatio) { + event.preventDefault(); + } + }, + [directionLockRatio, enabled], + ); + + const onPointerUp = React.useCallback>( + (event) => { + const swipeState = swipeStateRef.current; + if (!enabled || !swipeState || swipeState.pointerId !== event.pointerId) { + return; + } + + const deltaX = event.clientX - swipeState.startX; + const deltaY = event.clientY - swipeState.startY; + resetSwipeState(); + + if ( + Math.abs(deltaY) < minSwipeDistance || + Math.abs(deltaY) <= Math.abs(deltaX) * directionLockRatio + ) { + return; + } + + if (deltaY < 0) { + onSwipeUp?.(); + return; + } + + onSwipeDown?.(); + }, + [directionLockRatio, enabled, minSwipeDistance, onSwipeDown, onSwipeUp, resetSwipeState], + ); + + const onPointerCancel = React.useCallback>( + () => { + resetSwipeState(); + }, + [resetSwipeState], + ); + + return { + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + touchAction: enabled ? "pan-x pinch-zoom" : "auto", + }; +}