mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Text2Image - Image batches: Arrow key support (#1198)
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user