From fd0cfe97bde524b794d2b1b3ff560889a92ff5ce Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 23 Mar 2026 20:52:51 -0700 Subject: [PATCH] add arrow key navigation to image generation lightbox (#gen-view-arrow-key) - Left/Right navigates carousel slides (instant jump, loops) - Up/Down navigates between generation batches - Opening a specific image correctly highlights it in the carousel via initialIndex - Add galleryModalLightboxNavPrev/Next signals so TextToImage can provide its own bundle navigation callbacks independently of the gallery's item list - Fix: gallery nav callbacks were undefined for TextToImage-opened lightboxes since generated batches are not in the gallery's flatFilteredItems --- .../app/src/pages/PageImage/TextToImage.tsx | 100 +++++++++++------- .../gallery-modal/src/lib/gallery-modal.tsx | 9 +- .../src/lib/galleryModalSignals.ts | 5 + .../lightbox-modal/src/lib/lightbox-modal.tsx | 38 ++++--- 4 files changed, 95 insertions(+), 57 deletions(-) diff --git a/frontend/apps/artcraft/app/src/pages/PageImage/TextToImage.tsx b/frontend/apps/artcraft/app/src/pages/PageImage/TextToImage.tsx index 8a66448847..0b89ef9aec 100644 --- a/frontend/apps/artcraft/app/src/pages/PageImage/TextToImage.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageImage/TextToImage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { JobContextType, getThumbnailUrl, @@ -31,6 +31,8 @@ import { galleryModalLightboxImage, galleryModalLightboxMediaId, galleryModalLightboxVisible, + galleryModalLightboxNavPrev, + galleryModalLightboxNavNext, } from "@storyteller/ui-gallery-modal"; import { Badge } from "@storyteller/ui-badge"; import { twMerge } from "tailwind-merge"; @@ -125,6 +127,63 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => { // Like Midjourney instead of a "chat history" style. const inverseBatch = [...batches].reverse(); + // Keep a ref so nav callbacks always see the latest batch list without + // needing to be recreated on every render. + const inverseBatchRef = useRef(inverseBatch); + inverseBatchRef.current = inverseBatch; + + const openBatchInLightbox = useCallback( + (targetBatch: (typeof inverseBatch)[0], startIndex = 0) => { + const images = targetBatch.images; + if (!images.length) return; + + const imageUrls = images.map( + (img) => + getThumbnailUrl(img.maybe_thumbnail_template, { width: 3200 }) ?? + img.cdn_url, + ); + const mediaTokens = images.map((img) => img.media_token); + const actionUrls = images.map((img) => img.cdn_url); + + const clickedImg = images[startIndex]; + const batchIndex = inverseBatchRef.current.findIndex( + (b) => b.id === targetBatch.id, + ); + + galleryModalLightboxNavPrev.value = + batchIndex > 0 + ? () => openBatchInLightbox(inverseBatchRef.current[batchIndex - 1]) + : null; + galleryModalLightboxNavNext.value = + batchIndex < inverseBatchRef.current.length - 1 + ? () => openBatchInLightbox(inverseBatchRef.current[batchIndex + 1]) + : null; + + galleryModalLightboxMediaId.value = clickedImg.media_token; + galleryModalLightboxImage.value = { + id: clickedImg.media_token, + label: targetBatch.prompt || "Generated Image", + thumbnail: + getThumbnailUrl(clickedImg.maybe_thumbnail_template, { + width: THUMBNAIL_SIZES.MEDIUM, + }) ?? clickedImg.cdn_url, + fullImage: + getThumbnailUrl(clickedImg.maybe_thumbnail_template, { + width: 2048, + }) ?? clickedImg.cdn_url, + createdAt: new Date(targetBatch.createdAt).toISOString(), + mediaClass: "image" as const, + mediaTokens, + imageUrls, + actionUrls, + initialIndex: startIndex, + thumbnailUrlTemplate: clickedImg.maybe_thumbnail_template, + }; + galleryModalLightboxVisible.value = true; + }, + [], + ); + return (
{