From 676eb12d56ecf8733d712279d354a81eddd7ca1b Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 22 Sep 2025 18:49:00 +0700 Subject: [PATCH] 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 --- .../app/src/pages/PageEdit/PageEdit.tsx | 49 +++- .../promptbox/src/lib/PromptBox2D.tsx | 272 +++++------------- .../promptbox/src/lib/PromptBox3D.tsx | 264 ++++------------- .../promptbox/src/lib/PromptBoxEdit.tsx | 62 +++- .../promptbox/src/lib/promptStore.ts | 11 + 5 files changed, 246 insertions(+), 412 deletions(-) diff --git a/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx b/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx index 15aa84b55c..3bba990789 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx @@ -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 => { if ( diff --git a/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx b/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx index c09bbe7127..78d3f5e9de 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx @@ -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(""); 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( - [] - ); 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([ { label: "3:2", @@ -152,10 +143,11 @@ export const PromptBox2D = ({ ); }, [generationCount]); - const fileInputRef = useRef(null); const textareaRef = useRef(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) => { - 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}
- {(referenceImages.length > 0 || uploadingImages.length > 0) && ( -
- {referenceImages.map((image) => ( -
+ {selectedImageModel?.canUseImagePrompt && isImageRowVisible && ( + { + setContent( Reference - -
- ))} - {uploadingImages.map(({ id, file }) => { - const previewUrl = URL.createObjectURL(file); - return ( -
-
- Uploading preview -
-
- -
-
); - })} -
- )} -
- + )} +
- , - action: "upload", - }, - { - label: "Choose from library", - selected: false, - icon: , - 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={ - - } - /> + {selectedImageModel?.canUseImagePrompt && ( + + + + )}