remaining promptbox updates and inpainting centering fix (#834)

- 2d, 3d and inpainting promptboxes now support the same model-dynamic
image prompt row
- fixerd the centering issue for the inpainting canvas
This commit is contained in:
BFlat authored and GitHub committed 2025-09-22 18:49:00 +07:00
1 parent bab9cf76f0
commit 676eb12d56
5 files changed
+246 -412

No files matched your search

@@ -140,10 +140,15 @@ const PageEdit = () => {
const canvasH = store.getAspectRatioDimensions().height;
// Calculate optimal scale to fit the canvas in the container
// Use a padding factor to leave some margin around the canvas
const paddingFactor = 0.95; // 5% padding on all sides
// Leave horizontal margin via factor, and explicit vertical padding
const paddingFactor = 0.95; // horizontal padding (left/right)
const verticalPaddingPx = 128; // explicit top/bottom margin in pixels
const scaleX = (containerWidth * paddingFactor) / canvasW;
const scaleY = (containerHeight * paddingFactor) / canvasH;
const availableHeight = Math.max(
0,
containerHeight - verticalPaddingPx * 2,
);
const scaleY = availableHeight / canvasH;
// Use the smaller scale to ensure the canvas fits in both dimensions
const scale = Math.min(scaleX, scaleY);
@@ -154,7 +159,7 @@ const PageEdit = () => {
// Apply the calculated scale
stage.scale({ x: boundedScale, y: boundedScale });
// Calculate position to center canvas in container
// Calculate position to center canvas in container (creates ~verticalPaddingPx margins)
stage.position({
x: (containerWidth - canvasW * boundedScale) / 2,
y: (containerHeight - canvasH * boundedScale) / 2,
@@ -163,6 +168,42 @@ const PageEdit = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-fit when the base image becomes available and stage is ready
useEffect(() => {
if (!store.baseImageBitmap) return;
if (!stageRef.current) return;
// Defer to ensure container has laid out with correct size
const id = requestAnimationFrame(() => {
const id2 = requestAnimationFrame(() => {
void onFitPressed();
});
return () => cancelAnimationFrame(id2);
});
return () => cancelAnimationFrame(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [store.baseImageBitmap, stageRef.current]);
// Re-center on window resize when an image is loaded
useEffect(() => {
let timeoutId: number | undefined;
const handleResize = () => {
if (!store.baseImageBitmap) return;
if (!stageRef.current) return;
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
requestAnimationFrame(() => {
void onFitPressed();
});
}, 150);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [store.baseImageBitmap, stageRef.current, onFitPressed]);
// Create a function to use the left layer ref and download the bitmap from it
const getMaskArrayBuffer = async (): Promise<Uint8Array> => {
if (
@@ -7,7 +7,6 @@ import {
UploaderState,
UploaderStates,
} from "@storyteller/common";
import { downloadFileFromUrl } from "@storyteller/api";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Button, ToggleButton } from "@storyteller/ui-button";
@@ -17,10 +16,6 @@ import {
faMessageCheck,
faSparkles,
faSpinnerThird,
faTimes,
faPlus,
faUpload,
faImages,
faFrame,
faCopy,
} from "@fortawesome/pro-solid-svg-icons";
@@ -32,7 +27,6 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { IsDesktopApp } from "@storyteller/tauri-utils";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { PromptsApi } from "@storyteller/api";
import {
EnqueueContextualEditImage,
@@ -42,6 +36,8 @@ import { usePrompt2DStore, RefImage } from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
import { getCapabilitiesForModel } from "@storyteller/model-list";
import { ImageModel } from "@storyteller/model-list";
import { ImagePromptRow } from "./ImagePromptRow";
import { twMerge } from "tailwind-merge";
export type AspectRatio = "1:1" | "3:2" | "2:3";
@@ -77,7 +73,7 @@ export const PromptBox2D = ({
useSignals();
const [isModalOpen, setIsModalOpen] = useState(false);
const [content, setContent] = useState("");
const [content, setContent] = useState<React.ReactNode>("");
const { addJobToken } = useJobContext();
//const { lastRenderedBitmap } = useCanvasSignal();
@@ -91,14 +87,9 @@ export const PromptBox2D = ({
const setGenerationCount = usePrompt2DStore((s) => s.setGenerationCount);
const [isEnqueueing, setIsEnqueueing] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[]
);
const referenceImages = usePrompt2DStore((s) => s.referenceImages);
const setReferenceImages = usePrompt2DStore((s) => s.setReferenceImages);
const [uploadingImages, setUploadingImages] = useState<
{ id: string; file: File }[]
>([]);
const [showImagePrompts, setShowImagePrompts] = useState(false);
const [aspectRatioList, setAspectRatioList] = useState<PopoverItem[]>([
{
label: "3:2",
@@ -152,10 +143,11 @@ export const PromptBox2D = ({
);
}, [generationCount]);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const isImageRowVisible =
showImagePrompts ||
(selectedImageModel?.canUseImagePrompt && referenceImages.length > 0) ||
false;
useEffect(() => {
if (textareaRef.current) {
@@ -175,111 +167,7 @@ export const PromptBox2D = ({
);
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files) {
Array.from(files).forEach((file) => {
const uploadId = Math.random().toString(36).substring(7);
setUploadingImages((prev) => [...prev, { id: uploadId, file }]);
const reader = new FileReader();
reader.onloadend = () => {
uploadImage({
title: `reference-image-${Math.random()
.toString(36)
.substring(2, 15)}`,
assetFile: file,
progressCallback: (newState) => {
console.debug("Upload progress:", newState.data);
if (newState.status === UploaderStates.success && newState.data) {
const referenceImage: RefImage = {
id: Math.random().toString(36).substring(7),
url: reader.result as string,
file,
mediaToken: newState.data || "",
};
setReferenceImages([...referenceImages, referenceImage]);
setUploadingImages((prev) =>
prev.filter((img) => img.id !== uploadId)
);
toast.success("Reference image added.");
} else if (
newState.status === UploaderStates.assetError ||
newState.status === UploaderStates.imageCreateError
) {
setUploadingImages((prev) =>
prev.filter((img) => img.id !== uploadId)
);
toast.error("Upload failed. Please try again.");
}
},
});
};
reader.readAsDataURL(file);
});
}
};
const handleRemoveReference = (id: string) => {
setReferenceImages(referenceImages.filter((img) => img.id !== id));
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleGallerySelect = () => setIsGalleryModalOpen(true);
const handleGalleryClose = () => {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
const handleImageSelect = (id: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(id)) {
return prev.filter((imageId) => imageId !== id);
}
if (prev.length >= 4) {
return prev;
}
return [...prev, id];
});
};
const handleGalleryImages = (selectedItems: GalleryItem[]) => {
const newRefs = [...referenceImages];
selectedItems.forEach((item) => {
if (!item.fullImage) return;
newRefs.push({
id: Math.random().toString(36).substring(7),
url: item.fullImage,
file: new File([], "library-image"),
mediaToken: item.id,
});
});
setReferenceImages(newRefs);
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
const handleAction = (action: string) => {
switch (action) {
case "upload":
handleUploadClick();
break;
case "gallery":
handleGallerySelect();
break;
default:
console.log("Unknown action:", action);
}
};
// Image prompt row replaces legacy upload/gallery UI
const handleGenerationCountSelect = (selectedItem: PopoverItem) => {
const count = parseInt(selectedItem.label, 10);
@@ -502,85 +390,70 @@ export const PromptBox2D = ({
{content}
</Modal>
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
{(referenceImages.length > 0 || uploadingImages.length > 0) && (
<div className="flex w-full gap-2">
{referenceImages.map((image) => (
<div
key={image.id}
className="glass relative aspect-square w-20 rounded-lg"
>
{selectedImageModel?.canUseImagePrompt && isImageRowVisible && (
<ImagePromptRow
visible={true}
maxImagePromptCount={Math.max(
1,
selectedImageModel?.maxImagePromptCount ?? 1
)}
allowUpload={true}
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
uploadImage={uploadImage as any}
onImageClick={(image) => {
setContent(
<img
src={image.url}
alt="Reference"
className="h-full w-full rounded-lg object-cover"
alt="Reference preview"
className="w-full h-full object-contain"
/>
<button
onClick={() => handleRemoveReference(image.id)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-md transition-colors hover:bg-black"
>
<FontAwesomeIcon icon={faTimes} className="h-2.5 w-2.5" />
</button>
</div>
))}
{uploadingImages.map(({ id, file }) => {
const previewUrl = URL.createObjectURL(file);
return (
<div
key={id}
className="glass relative aspect-square w-20 overflow-hidden rounded-lg"
>
<div className="absolute inset-0">
<img
src={previewUrl}
alt="Uploading preview"
className="h-full w-full object-cover blur-sm"
/>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<FontAwesomeIcon
icon={faSpinnerThird}
className="h-6 w-6 animate-spin text-white"
/>
</div>
</div>
);
})}
</div>
)}
<div className="glass w-[730px] rounded-xl p-4">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
multiple
setIsModalOpen(true);
}}
/>
)}
<div
className={twMerge(
"glass w-[730px] rounded-xl p-4",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"rounded-t-none"
)}
>
<div className="flex justify-center gap-2">
<PopoverMenu
mode="button"
panelTitle="Add Image"
items={[
{
label: "Upload from device",
selected: false,
icon: <FontAwesomeIcon icon={faUpload} className="h-4 w-4" />,
action: "upload",
},
{
label: "Choose from library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
action: "gallery",
},
]}
onPanelAction={handleAction}
showIconsInList
buttonClassName="bg-transparent hover:bg-transparent py-1.5 px-0 pr-1 m-0 hover:opacity-50 transition-opacity duration-100 ring-0 border-none focus:ring-0 outline-none"
triggerIcon={
<FontAwesomeIcon icon={faPlus} className="text-xl" />
}
/>
{selectedImageModel?.canUseImagePrompt && (
<Tooltip
content="Add Image"
position="top"
closeOnClick={true}
className={twMerge(isImageRowVisible && "hidden opacity-0")}
>
<Button
variant="action"
className={twMerge(
"h-8 w-8 p-0 bg-transparent hover:bg-transparent group transition-all border-0 shadow-none",
isImageRowVisible && "text-primary"
)}
onClick={() => setShowImagePrompts((prev) => !prev)}
>
<svg
width="24"
height="20"
viewBox="0 0 24 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="group-hover:opacity-100 opacity-80 transition-all"
>
<path
opacity="1"
d="M2.66667 2H16C16.3667 2 16.6667 2.3 16.6667 2.66667V6.1125C17.1 6.04167 17.5458 6 18 6C18.225 6 18.4458 6.00833 18.6667 6.02917V2.66667C18.6667 1.19583 17.4708 0 16 0H2.66667C1.19583 0 0 1.19583 0 2.66667V16C0 17.4708 1.19583 18.6667 2.66667 18.6667H11.5C11.0625 18.0583 10.7083 17.3875 10.4542 16.6667H2.66667C2.3 16.6667 2 16.3667 2 16V2.66667C2 2.3 2.3 2 2.66667 2ZM11.8625 7.49167C11.6833 7.1875 11.3542 7 11 7C10.6458 7 10.3167 7.1875 10.1375 7.49167L8.2 10.7833L7.48333 9.75833C7.29583 9.49167 6.99167 9.33333 6.6625 9.33333C6.33333 9.33333 6.02917 9.49167 5.84167 9.75833L3.50833 13.0917C3.29583 13.3958 3.26667 13.7958 3.44167 14.125C3.61667 14.4542 3.9625 14.6667 4.33333 14.6667H10.0292C10.0125 14.4458 10 14.225 10 14C10 11.7833 10.9 9.77917 12.3542 8.33333L11.8625 7.49583V7.49167ZM5.33333 6.66667C6.07083 6.66667 6.66667 6.07083 6.66667 5.33333C6.66667 4.59583 6.07083 4 5.33333 4C4.59583 4 4 4.59583 4 5.33333C4 6.07083 4.59583 6.66667 5.33333 6.66667ZM18 20C21.3125 20 24 17.3125 24 14C24 10.6875 21.3125 8 18 8C14.6875 8 12 10.6875 12 14C12 17.3125 14.6875 20 18 20ZM18.6667 11.3333V13.3333H20.6667C21.0333 13.3333 21.3333 13.6333 21.3333 14C21.3333 14.3667 21.0333 14.6667 20.6667 14.6667H18.6667V16.6667C18.6667 17.0333 18.3667 17.3333 18 17.3333C17.6333 17.3333 17.3333 17.0333 17.3333 16.6667V14.6667H15.3333C14.9667 14.6667 14.6667 14.3667 14.6667 14C14.6667 13.6333 14.9667 13.3333 15.3333 13.3333H17.3333V11.3333C17.3333 10.9667 17.6333 10.6667 18 10.6667C18.3667 10.6667 18.6667 10.9667 18.6667 11.3333Z"
fill="currentColor"
/>
</svg>
</Button>
</Tooltip>
)}
<textarea
ref={textareaRef}
@@ -689,17 +562,6 @@ export const PromptBox2D = ({
</div>
</div>
</div>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={4}
onUseSelected={handleGalleryImages}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</>
);
};
@@ -1,17 +1,12 @@
import { useState, useRef, useEffect } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { IsDesktopApp } from "@storyteller/tauri-utils";
import { downloadFileFromUrl } from "@storyteller/api";
import {
faPlus,
faCamera,
faSave,
faSparkles,
faUpload,
faImages,
faDownload,
faSpinnerThird,
faTimes,
faMessageCheck,
faMessageXmark,
} from "@fortawesome/pro-solid-svg-icons";
@@ -28,7 +23,6 @@ import { Button, ToggleButton } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
CameraAspectRatio,
UploaderStates,
DomLevels,
Camera,
FocalLengthDragging,
@@ -40,7 +34,6 @@ import { toast } from "@storyteller/ui-toaster";
import { EngineApi } from "@storyteller/api";
import { CameraSettingsModal } from "@storyteller/ui-camera-settings-modal";
import { twMerge } from "tailwind-merge";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { Modal } from "@storyteller/ui-modal";
import { Signal } from "@preact/signals-react";
import {
@@ -51,9 +44,10 @@ import {
// waitForSoraLogin,
} from "@storyteller/tauri-api";
// import { showActionReminder } from "@storyteller/ui-action-reminder-modal";
import { usePrompt3DStore, RefImage } from "./promptStore";
import { usePrompt3DStore } from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
import { ImageModel } from "@storyteller/model-list";
import { ImagePromptRow } from "./ImagePromptRow";
interface PromptBox3DProps {
cameras: Signal<Camera[]>;
@@ -116,9 +110,7 @@ export const PromptBox3D = ({
const [isEnqueueing, setIsEnqueueing] = useState(false);
const referenceImages = usePrompt3DStore((s) => s.referenceImages);
const setReferenceImages = usePrompt3DStore((s) => s.setReferenceImages);
const [uploadingImages, setUploadingImages] = useState<
{ id: string; file: File }[]
>([]);
const [showImagePrompts, setShowImagePrompts] = useState(false);
const [aspectRatioList, setAspectRatioList] = useState<PopoverItem[]>([
{
label: "3:2",
@@ -137,10 +129,10 @@ export const PromptBox3D = ({
},
]);
const [isCameraSettingsOpen, setIsCameraSettingsOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[]
);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const isImageRowVisible =
showImagePrompts ||
(selectedImageModel?.canUseImagePrompt && referenceImages.length > 0) ||
false;
useEffect(() => {
if (textareaRef.current) {
@@ -198,109 +190,7 @@ export const PromptBox3D = ({
onAspectRatioSelect(newRatio);
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files) {
Array.from(files).forEach((file) => {
const uploadId = Math.random().toString(36).substring(7);
setUploadingImages((prev) => [...prev, { id: uploadId, file }]);
const reader = new FileReader();
reader.onloadend = () => {
uploadImage({
title: `reference-image-${Math.random()
.toString(36)
.substring(2, 15)}`,
assetFile: file,
progressCallback: (newState) => {
console.debug("Upload progress:", newState.data);
if (newState.status === UploaderStates.success && newState.data) {
const referenceImage: RefImage = {
id: Math.random().toString(36).substring(7),
url: reader.result as string,
file,
mediaToken: newState.data || "",
};
setReferenceImages([...referenceImages, referenceImage]);
setUploadingImages((prev) =>
prev.filter((img) => img.id !== uploadId)
);
console.log("Reference image added:", referenceImage);
} else if (
newState.status === UploaderStates.assetError ||
newState.status === UploaderStates.imageCreateError
) {
setUploadingImages((prev) =>
prev.filter((img) => img.id !== uploadId)
);
console.error("Upload failed");
}
},
});
};
reader.readAsDataURL(file);
});
}
};
const handleRemoveReference = (id: string) => {
setReferenceImages(referenceImages.filter((img) => img.id !== id));
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleGallerySelect = () => setIsGalleryModalOpen(true);
const handleGalleryClose = () => {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
const handleImageSelect = (id: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(id)) {
return prev.filter((imageId) => imageId !== id);
}
if (prev.length >= 4) {
return prev;
}
return [...prev, id];
});
};
const handleGalleryImages = (selectedItems: GalleryItem[]) => {
const newRefs = [...referenceImages];
selectedItems.forEach((item) => {
if (!item.fullImage) return;
newRefs.push({
id: Math.random().toString(36).substring(7),
url: item.fullImage,
file: new File([], "gallery-image"),
mediaToken: item.id,
});
});
setReferenceImages(newRefs);
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
const handleAction = (action: string) => {
switch (action) {
case "upload":
handleUploadClick();
break;
case "gallery":
handleGallerySelect();
break;
default:
console.log("Unknown action:", action);
}
};
// ImagePromptRow will handle uploads and gallery internally
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
e.preventDefault();
@@ -560,90 +450,71 @@ export const PromptBox3D = ({
{content}
</Modal>
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
{(referenceImages.length > 0 || uploadingImages.length > 0) && (
<div className="flex w-full gap-2">
{referenceImages.map((image) => (
<div
key={image.id}
className="glass relative aspect-square w-20 rounded-lg"
>
{selectedImageModel?.canUseImagePrompt && isImageRowVisible && (
<ImagePromptRow
visible={true}
maxImagePromptCount={Math.max(
1,
selectedImageModel?.maxImagePromptCount ?? 1
)}
allowUpload={true}
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
uploadImage={uploadImage as any}
onImageClick={(image) => {
setContent(
<img
src={image.url}
alt="Reference"
className="h-full w-full rounded-lg object-cover"
alt="Reference preview"
className="w-full h-full object-contain"
/>
<button
onClick={() => handleRemoveReference(image.id)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-md transition-colors hover:bg-black"
>
<FontAwesomeIcon icon={faTimes} className="h-2.5 w-2.5" />
</button>
</div>
))}
{uploadingImages.map(({ id, file }) => {
const previewUrl = URL.createObjectURL(file);
return (
<div
key={id}
className="glass relative aspect-square w-20 overflow-hidden rounded-lg"
>
<div className="absolute inset-0">
<img
src={previewUrl}
alt="Uploading preview"
className="h-full w-full object-cover blur-sm"
/>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<FontAwesomeIcon
icon={faSpinnerThird}
className="h-6 w-6 animate-spin text-white"
/>
</div>
</div>
);
})}
</div>
setIsModalOpen(true);
}}
/>
)}
<div
className={twMerge(
"glass w-[730px] rounded-xl p-4",
isPromptBoxFocused.value ? "!border !border-primary" : ""
isPromptBoxFocused.value ? "!border !border-primary" : "",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"rounded-t-none"
)}
>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
multiple
/>
<div className="flex justify-center gap-2">
<PopoverMenu
mode="button"
panelTitle="Add Image"
items={[
{
label: "Upload from device",
selected: false,
icon: <FontAwesomeIcon icon={faUpload} className="h-4 w-4" />,
action: "upload",
},
{
label: "Choose from library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
action: "gallery",
},
]}
onPanelAction={handleAction}
showIconsInList
buttonClassName="bg-transparent hover:bg-transparent py-1.5 px-0 pr-1 m-0 hover:opacity-50 transition-opacity duration-100 ring-0 border-none focus:ring-0 outline-none"
triggerIcon={
<FontAwesomeIcon icon={faPlus} className="text-xl" />
}
/>
{selectedImageModel?.canUseImagePrompt && (
<Tooltip
content="Add Image"
position="top"
closeOnClick={true}
className={twMerge(isImageRowVisible && "hidden opacity-0")}
>
<Button
variant="action"
className={twMerge(
"h-8 w-8 p-0 bg-transparent hover:bg-transparent group transition-all border-0 shadow-none",
isImageRowVisible && "text-primary"
)}
onClick={() => setShowImagePrompts((prev) => !prev)}
>
<svg
width="24"
height="20"
viewBox="0 0 24 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="group-hover:opacity-100 opacity-80 transition-all"
>
<path
opacity="1"
d="M2.66667 2H16C16.3667 2 16.6667 2.3 16.6667 2.66667V6.1125C17.1 6.04167 17.5458 6 18 6C18.225 6 18.4458 6.00833 18.6667 6.02917V2.66667C18.6667 1.19583 17.4708 0 16 0H2.66667C1.19583 0 0 1.19583 0 2.66667V16C0 17.4708 1.19583 18.6667 2.66667 18.6667H11.5C11.0625 18.0583 10.7083 17.3875 10.4542 16.6667H2.66667C2.3 16.6667 2 16.3667 2 16V2.66667C2 2.3 2.3 2 2.66667 2ZM11.8625 7.49167C11.6833 7.1875 11.3542 7 11 7C10.6458 7 10.3167 7.1875 10.1375 7.49167L8.2 10.7833L7.48333 9.75833C7.29583 9.49167 6.99167 9.33333 6.6625 9.33333C6.33333 9.33333 6.02917 9.49167 5.84167 9.75833L3.50833 13.0917C3.29583 13.3958 3.26667 13.7958 3.44167 14.125C3.61667 14.4542 3.9625 14.6667 4.33333 14.6667H10.0292C10.0125 14.4458 10 14.225 10 14C10 11.7833 10.9 9.77917 12.3542 8.33333L11.8625 7.49583V7.49167ZM5.33333 6.66667C6.07083 6.66667 6.66667 6.07083 6.66667 5.33333C6.66667 4.59583 6.07083 4 5.33333 4C4.59583 4 4 4.59583 4 5.33333C4 6.07083 4.59583 6.66667 5.33333 6.66667ZM18 20C21.3125 20 24 17.3125 24 14C24 10.6875 21.3125 8 18 8C14.6875 8 12 10.6875 12 14C12 17.3125 14.6875 20 18 20ZM18.6667 11.3333V13.3333H20.6667C21.0333 13.3333 21.3333 13.6333 21.3333 14C21.3333 14.3667 21.0333 14.6667 20.6667 14.6667H18.6667V16.6667C18.6667 17.0333 18.3667 17.3333 18 17.3333C17.6333 17.3333 17.3333 17.0333 17.3333 16.6667V14.6667H15.3333C14.9667 14.6667 14.6667 14.3667 14.6667 14C14.6667 13.6333 14.9667 13.3333 15.3333 13.3333H17.3333V11.3333C17.3333 10.9667 17.6333 10.6667 18 10.6667C18.3667 10.6667 18.6667 10.9667 18.6667 11.3333Z"
fill="currentColor"
/>
</svg>
</Button>
</Tooltip>
)}
<textarea
ref={textareaRef}
@@ -819,17 +690,6 @@ export const PromptBox3D = ({
focalLengthDragging={focalLengthDragging}
/>
</div>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={4}
onUseSelected={handleGalleryImages}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</>
);
};
@@ -17,6 +17,8 @@ import { Tooltip } from "@storyteller/ui-tooltip";
import { useEffect, useRef, useState } from "react";
import { ImageModel, getCapabilitiesForModel } from "@storyteller/model-list";
import { twMerge } from "tailwind-merge";
import { ImagePromptRow, type UploadImageFn } from "./ImagePromptRow";
import { usePromptEditStore } from "./promptStore";
export interface PromptBoxEditProps {
onModeChange?: (mode: string) => void;
@@ -29,6 +31,7 @@ export interface PromptBoxEditProps {
onGenerationCountChange?: (count: number) => void;
supportsMaskedInpainting?: boolean;
isEnqueueing?: boolean;
uploadImage?: UploadImageFn;
}
export const PromptBoxEdit = ({
@@ -42,6 +45,7 @@ export const PromptBoxEdit = ({
generationCount: generationCountProp,
onGenerationCountChange,
supportsMaskedInpainting = false,
uploadImage,
}: PromptBoxEditProps) => {
const [prompt, setPrompt] = useState("");
const [useSystemPrompt, setUseSystemPrompt] = useState(true);
@@ -50,12 +54,19 @@ export const PromptBoxEdit = ({
typeof generationCountProp === "number" ? generationCountProp : 1
);
const [internalEnqueueing, setInternalEnqueueing] = useState(false);
const referenceImages = usePromptEditStore((s) => s.referenceImages);
const setReferenceImages = usePromptEditStore((s) => s.setReferenceImages);
const [showImagePrompts, setShowImagePrompts] = useState(false);
const [generationCountList, setGenerationCountList] = useState<PopoverItem[]>(
[]
);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isImageRowVisible =
(selectedImageModel?.canUseImagePrompt &&
(showImagePrompts || referenceImages.length > 0)) ||
false;
useEffect(() => {
if (textareaRef.current) {
@@ -193,13 +204,62 @@ export const PromptBoxEdit = ({
return (
<>
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
{selectedImageModel?.canUseImagePrompt && isImageRowVisible && (
<ImagePromptRow
visible={true}
maxImagePromptCount={Math.max(
1,
selectedImageModel?.maxImagePromptCount ?? 1
)}
allowUpload={true}
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
uploadImage={uploadImage}
onImageClick={() => {}}
/>
)}
<div
className={twMerge(
"glass w-[730px] rounded-xl p-4",
isFocused && "ring-1 ring-primary border-primary"
isFocused && "ring-1 ring-primary border-primary",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"rounded-t-none"
)}
>
<div className="flex justify-center gap-2">
{selectedImageModel?.canUseImagePrompt && (
<Tooltip
content="Add Image"
position="top"
closeOnClick={true}
className={twMerge(isImageRowVisible && "hidden opacity-0")}
>
<Button
variant="action"
className={twMerge(
"h-8 w-8 p-0 bg-transparent hover:bg-transparent group transition-all border-0 shadow-none",
isImageRowVisible && "text-primary"
)}
onClick={() => setShowImagePrompts((prev) => !prev)}
>
<svg
width="24"
height="20"
viewBox="0 0 24 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="group-hover:opacity-100 opacity-80 transition-all"
>
<path
opacity="1"
d="M2.66667 2H16C16.3667 2 16.6667 2.3 16.6667 2.66667V6.1125C17.1 6.04167 17.5458 6 18 6C18.225 6 18.4458 6.00833 18.6667 6.02917V2.66667C18.6667 1.19583 17.4708 0 16 0H2.66667C1.19583 0 0 1.19583 0 2.66667V16C0 17.4708 1.19583 18.6667 2.66667 18.6667H11.5C11.0625 18.0583 10.7083 17.3875 10.4542 16.6667H2.66667C2.3 16.6667 2 16.3667 2 16V2.66667C2 2.3 2.3 2 2.66667 2ZM11.8625 7.49167C11.6833 7.1875 11.3542 7 11 7C10.6458 7 10.3167 7.1875 10.1375 7.49167L8.2 10.7833L7.48333 9.75833C7.29583 9.49167 6.99167 9.33333 6.6625 9.33333C6.33333 9.33333 6.02917 9.49167 5.84167 9.75833L3.50833 13.0917C3.29583 13.3958 3.26667 13.7958 3.44167 14.125C3.61667 14.4542 3.9625 14.6667 4.33333 14.6667H10.0292C10.0125 14.4458 10 14.225 10 14C10 11.7833 10.9 9.77917 12.3542 8.33333L11.8625 7.49583V7.49167ZM5.33333 6.66667C6.07083 6.66667 6.66667 6.07083 6.66667 5.33333C6.66667 4.59583 6.07083 4 5.33333 4C4.59583 4 4 4.59583 4 5.33333C4 6.07083 4.59583 6.66667 5.33333 6.66667ZM18 20C21.3125 20 24 17.3125 24 14C24 10.6875 21.3125 8 18 8C14.6875 8 12 10.6875 12 14C12 17.3125 14.6875 20 18 20ZM18.6667 11.3333V13.3333H20.6667C21.0333 13.3333 21.3333 13.6333 21.3333 14C21.3333 14.3667 21.0333 14.6667 20.6667 14.6667H18.6667V16.6667C18.6667 17.0333 18.3667 17.3333 18 17.3333C17.6333 17.3333 17.3333 17.0333 17.3333 16.6667V14.6667H15.3333C14.9667 14.6667 14.6667 14.3667 14.6667 14C14.6667 13.6333 14.9667 13.3333 15.3333 13.3333H17.3333V11.3333C17.3333 10.9667 17.6333 10.6667 18 10.6667C18.3667 10.6667 18.6667 10.9667 18.6667 11.3333Z"
fill="currentColor"
/>
</svg>
</Button>
</Tooltip>
)}
<textarea
ref={textareaRef}
rows={1}
@@ -112,3 +112,14 @@ export const usePromptVideoStore = create<PromptVideoStore>()((set) => ({
setReferenceImages: (referenceImages) => set({ referenceImages }),
setEndFrameImage: (endFrameImage) => set({ endFrameImage }),
}));
// ----- Edit Prompt Box Store -----
interface PromptEditStore {
referenceImages: RefImage[];
setReferenceImages: (images: RefImage[]) => void;
}
export const usePromptEditStore = create<PromptEditStore>()((set) => ({
referenceImages: [],
setReferenceImages: (referenceImages) => set({ referenceImages }),
}));