{isVideo ? (
-
-
) : (
-
+
+
+
)}
{/* 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",
+ };
+}