Text2Image - Image batches: Arrow key support (#1198)

This commit is contained in:
Tanish Manku
2026-03-23 20:57:50 -07:00
committed by GitHub
parent 516d740e97
commit 8d731f1155
4 changed files with 97 additions and 51 deletions
@@ -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 (
<div
ref={containerRef}
@@ -223,43 +282,8 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
<button
key={img.media_token}
onClick={() => {
const lightboxItem = {
id: img.media_token,
label: batch.prompt || "Generated Image",
thumbnail:
getThumbnailUrl(
img.maybe_thumbnail_template,
{ width: THUMBNAIL_SIZES.MEDIUM },
) ?? img.cdn_url,
fullImage:
getThumbnailUrl(
img.maybe_thumbnail_template,
{ width: 2048 },
) ?? img.cdn_url,
createdAt: new Date(
batch.createdAt,
).toISOString(),
mediaClass: "image" as const,
mediaTokens: batch.images.map(
(image) => image.media_token,
),
imageUrls: batch.images.map(
(image) =>
getThumbnailUrl(
image.maybe_thumbnail_template,
{ width: 3200 },
) ?? image.cdn_url,
),
actionUrls: batch.images.map(
(image) => image.cdn_url,
),
thumbnailUrlTemplate:
img.maybe_thumbnail_template,
};
galleryModalLightboxMediaId.value =
lightboxItem.id;
galleryModalLightboxImage.value = lightboxItem;
galleryModalLightboxVisible.value = true;
const startIndex = batch.images.indexOf(img);
openBatchInLightbox(batch, startIndex);
}}
className="aspect-square w-full overflow-hidden rounded-lg transition-opacity duration-200 hover:cursor-pointer hover:opacity-75"
>
@@ -32,6 +32,8 @@ import {
galleryModalLightboxMediaId,
galleryModalLightboxImage,
galleryModalLightboxVisible,
galleryModalLightboxNavPrev,
galleryModalLightboxNavNext,
} from "./galleryModalSignals";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
@@ -701,6 +703,8 @@ export const GalleryModal = React.memo(
lightboxVisibleSignal.value = false;
lightboxImageSignal.value = null;
galleryModalLightboxMediaId.value = null;
galleryModalLightboxNavPrev.value = null;
galleryModalLightboxNavNext.value = null;
}, []);
const handleCloseGallery = useCallback(() => {
@@ -1293,6 +1297,7 @@ export const GalleryModal = React.memo(
imageUrls={imageUrls}
actionUrls={actionUrls}
mediaTokens={(lightboxImageSignal.value as any)?.mediaTokens}
initialIndex={(lightboxImageSignal.value as any)?.initialIndex}
batchImageToken={
(lightboxImageSignal.value as any)?.batchImageToken
}
@@ -1311,8 +1316,8 @@ export const GalleryModal = React.memo(
onMake3DObjectClicked={onMake3DObjectClicked}
onMake3DWorldClicked={onMake3DWorldClicked}
onRecreateClicked={onRecreateClicked}
onNavigatePrev={handleNavigatePrev}
onNavigateNext={handleNavigateNext}
onNavigatePrev={galleryModalLightboxNavPrev.value ?? handleNavigatePrev}
onNavigateNext={galleryModalLightboxNavNext.value ?? handleNavigateNext}
onNavigateToMedia={handleNavigateToMedia}
/>
)}
@@ -9,3 +9,8 @@ export const galleryModalVisibleViewMode = signal(false);
export const galleryModalLightboxMediaId = signal<string | null>(null);
export const galleryModalLightboxVisible = signal(false);
export const galleryModalLightboxImage = signal<any>(null);
// Custom nav callbacks – set by pages that manage their own item list (e.g.
// TextToImage). Null when opened from the gallery browse, in which case the
// gallery's own computed handlers are used instead.
export const galleryModalLightboxNavPrev = signal<(() => void) | null>(null);
export const galleryModalLightboxNavNext = signal<(() => void) | null>(null);
@@ -108,6 +108,7 @@ interface LightboxModalProps {
onNavigatePrev?: () => void;
onNavigateNext?: () => void;
onNavigateToMedia?: (mediaToken: string) => void;
initialIndex?: number;
}
export function LightboxModal({
@@ -138,6 +139,7 @@ export function LightboxModal({
onNavigatePrev,
onNavigateNext,
onNavigateToMedia,
initialIndex,
}: LightboxModalProps) {
// NB(bt,2025-06-14): We add ?cors=1 to the image url to prevent caching "sec-fetch-mode: no-cors" from
// the <image> tag request from being cached. If we then drag it into the canvas after it's been cached,
@@ -423,9 +425,10 @@ export function LightboxModal({
}, [emblaMainApi, onSelect]);
useEffect(() => {
setSelectedIndex(0);
emblaMainApi?.scrollTo(0, true);
emblaThumbsApi?.scrollTo(0, true);
const idx = initialIndex ?? 0;
setSelectedIndex(idx);
emblaMainApi?.scrollTo(idx, true);
emblaThumbsApi?.scrollTo(idx, true);
}, [batchImageToken, imageUrl, emblaMainApi, emblaThumbsApi]);
const selectedImageUrl = effectiveImageUrls[selectedIndex] ?? null;
@@ -471,25 +474,32 @@ export function LightboxModal({
[mediaClass, batchImages],
);
// Keyboard navigation for gallery prev/next
// Keyboard navigation:
// Left / Right → carousel slide (loop wraps automatically)
// Up / Down → previous / next bundle
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Don't navigate if user is interacting with an input, textarea, etc.
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === "ArrowLeft" && onNavigatePrev) {
if (e.key === "ArrowLeft") {
e.preventDefault();
onNavigatePrev();
} else if (e.key === "ArrowRight" && onNavigateNext) {
emblaMainApi?.scrollPrev(true);
} else if (e.key === "ArrowRight") {
e.preventDefault();
onNavigateNext();
emblaMainApi?.scrollNext(true);
} else if (e.key === "ArrowUp") {
e.preventDefault();
onNavigatePrev?.();
} else if (e.key === "ArrowDown") {
e.preventDefault();
onNavigateNext?.();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onNavigatePrev, onNavigateNext]);
}, [isOpen, emblaMainApi, onNavigatePrev, onNavigateNext]);
return (
<>
@@ -511,7 +521,9 @@ export function LightboxModal({
</Modal.DragHandle>
{/* content grid */}
<div className="flex h-full gap-4">
<div
className="flex h-full gap-4"
>
{/* image panel - flexible width */}
<div className="group/nav relative flex h-full flex-1 items-center justify-center overflow-hidden rounded-l-xl bg-black/30">
{!selectedImageUrl ? (