ui-pagedraw: bundle BaseImageSelector in lib + adapter showToast slot

Move the upload / gallery picker / blank canvas selector out of the
artcraft Tauri host and into @storyteller/ui-pagedraw so any host gets
the same selector chrome (including Help + Cost buttons) without
duplicating UI code. Hosts now only need to provide platform behaviour
(uploadImage, enqueue*, optional showToast); renderBaseImageSelector
becomes an opt-in override for fully custom selectors.
This commit is contained in:
Tanish Manku committed 2026-05-09 16:22:46 -07:00
1 parent 24e62ab7df
commit 2cbfe7d292
8 files changed
+346 -205

No files matched your search

@@ -1,177 +0,0 @@
import { faExpand, faImages, faPencil } from "@fortawesome/pro-solid-svg-icons";
import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { useState } from "react";
import toast from "react-hot-toast";
import { uploadImage } from "~/components/reusable/UploadModalMedia/uploadImage";
import { UploaderStates } from "~/enums";
import { MediaFilesApi } from "@storyteller/api";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
import { ModelPage } from "@storyteller/ui-model-selector";
import { UploadEntryCard } from "~/components/media/UploadEntryCard";
import { BlankCanvasModal, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
const MAX_GALLERY_SELECTIONS = 1;
type BaseImageSelectorProps = {
onImageSelect: (image: BaseSelectorImage) => void;
showLoading?: boolean;
};
export const BaseImageSelector = ({
onImageSelect,
showLoading = false,
}: BaseImageSelectorProps) => {
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isBlankCanvasModalOpen, setIsBlankCanvasModalOpen] = useState(false);
const handleGalleryClick = () => setIsGalleryModalOpen(true);
const handleGalleryClose = () => {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
const handleImageSelect = (mediaToken: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(mediaToken)) return prev.filter((x) => x !== mediaToken);
if (prev.length >= MAX_GALLERY_SELECTIONS) {
return MAX_GALLERY_SELECTIONS === 1 ? [mediaToken] : prev;
}
return [...prev, mediaToken];
});
};
const handleUseGalleryImages = (selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item || !item.fullImage) {
toast.error("No image selected");
return;
}
const referenceImage: BaseSelectorImage = {
url: item.fullImage,
mediaToken: item.id,
thumbnailUrlTemplate: item.thumbnailUrlTemplate,
};
handleGalleryClose();
onImageSelect(referenceImage);
};
const handleBlankCanvasConfirm = (width: number, height: number) => {
setIsBlankCanvasModalOpen(false);
const blankImage: BaseSelectorImage = {
url: "",
mediaToken: `blank_canvas_${width}x${height}_${Math.random().toString(36).substring(2, 8)}`,
isBlankCanvas: true,
blankCanvasWidth: width,
blankCanvasHeight: height,
};
onImageSelect(blankImage);
};
const handleFileUpload = (files: FileList) => {
setIsLoading(true);
Array.from(files).forEach((file) => {
const reader = new FileReader();
reader.onloadend = () => {
uploadImage({
title: `reference-image-${Math.random().toString(36).substring(2, 15)}`,
assetFile: file,
progressCallback: (newState) => {
if (newState.status === UploaderStates.success && newState.data) {
const mediaToken = newState.data || "";
(async () => {
let finalUrl = reader.result as string;
let thumbnailUrlTemplate = undefined;
try {
const api = new MediaFilesApi();
const result = await api.GetMediaFileByToken({ mediaFileToken: mediaToken });
if (result.success && result.data) {
finalUrl =
result.data.media_links?.cdn_url ||
result.data.public_bucket_url ||
finalUrl;
const mediaLinks = result.data.media_links as
| { thumbnail_template?: string; maybe_thumbnail_template?: string }
| undefined;
thumbnailUrlTemplate =
mediaLinks?.thumbnail_template || mediaLinks?.maybe_thumbnail_template;
}
} catch (e) {
console.warn("Falling back to data URL for uploaded image", e);
}
const referenceImage: BaseSelectorImage = {
mediaToken,
url: finalUrl,
fullImageUrl: finalUrl,
thumbnailUrlTemplate,
};
toast.success("Image uploaded successfully!");
onImageSelect(referenceImage);
setIsLoading(false);
})();
} else if (
newState.status === UploaderStates.assetError ||
newState.status === UploaderStates.imageCreateError
) {
toast.error("Upload failed. Please try again.");
setIsLoading(false);
}
},
});
};
reader.readAsDataURL(file);
});
};
return (
<>
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-ui-panel text-base-fg">
<div className="aspect-video w-full max-w-5xl bg-ui-background">
<UploadEntryCard
icon={faPencil}
title="Edit Image"
description="Click to upload or drag and drop an image here to edit"
accentBackgroundClass="bg-blue-500/40"
accentBorderClass="border-blue-400/30"
accept="image/*"
multiple
onFilesSelected={handleFileUpload}
primaryLabel="Select Image"
secondaryLabel="Pick from Library"
secondaryIcon={faImages}
onSecondaryClick={handleGalleryClick}
tertiaryLabel="Blank Canvas"
tertiaryIcon={faExpand}
onTertiaryClick={() => setIsBlankCanvasModalOpen(true)}
disabled={isLoading || showLoading}
/>
</div>
</div>
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={ModelPage.ImageEditor} />
<HelpMenuButton />
</div>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={MAX_GALLERY_SELECTIONS}
onUseSelected={handleUseGalleryImages}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
<BlankCanvasModal
isOpen={isBlankCanvasModalOpen}
onClose={() => setIsBlankCanvasModalOpen(false)}
onConfirm={handleBlankCanvasConfirm}
/>
</>
);
};
@@ -15,7 +15,7 @@ import {
import { CommonAspectRatio, CommonResolution } from "@storyteller/model-list";
import { useTextToImageGenerationCompleteEvent } from "@storyteller/tauri-events";
import { UploadImageMedia } from "@storyteller/api";
import { BaseImageSelector } from "./BaseImageSelector";
import toast from "react-hot-toast";
// ─── Aspect ratio / resolution mappers ────────────────────────────────────────
const mapAspectRatio = (ratio?: string): CommonAspectRatio | undefined => {
@@ -140,12 +140,11 @@ const useTauriAdapter = (): PageDrawAdapter => {
onEnqueueMeta: (meta) => (window as any).__storeTaskEnqueueMeta?.(meta),
renderBaseImageSelector: ({ onImageSelect, showLoading }) => (
<BaseImageSelector
onImageSelect={onImageSelect}
showLoading={showLoading}
/>
),
showToast: (type, message) => {
if (type === "error") toast.error(message);
else if (type === "success") toast.success(message);
else toast(message);
},
}),
[],
);
+1 -1
View File
@@ -1 +1 @@
{"fileNames":["../../node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/typescript/lib/lib.es2024.d.ts","../../node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/typescript/lib/lib.es2023.intl.d.ts","../../node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2024.collection.d.ts","../../node_modules/typescript/lib/lib.es2024.object.d.ts","../../node_modules/typescript/lib/lib.es2024.promise.d.ts","../../node_modules/typescript/lib/lib.es2024.regexp.d.ts","../../node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2024.string.d.ts","../../node_modules/typescript/lib/lib.esnext.array.d.ts","../../node_modules/typescript/lib/lib.esnext.collection.d.ts","../../node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/typescript/lib/lib.esnext.promise.d.ts","../../node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/typescript/lib/lib.esnext.iterator.d.ts","../../node_modules/typescript/lib/lib.esnext.float16.d.ts","../../node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/@remix-run/node/dist/globals.d.ts","../../node_modules/@types/cookie/index.d.ts","../../node_modules/@remix-run/server-runtime/dist/crypto.d.ts","../../node_modules/@remix-run/server-runtime/dist/cookies.d.ts","../../node_modules/@remix-run/server-runtime/dLine truncated
{"fileNames":["../../node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/typescript/lib/lib.es2024.d.ts","../../node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/typescript/lib/lib.es2023.intl.d.ts","../../node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2024.collection.d.ts","../../node_modules/typescript/lib/lib.es2024.object.d.ts","../../node_modules/typescript/lib/lib.es2024.promise.d.ts","../../node_modules/typescript/lib/lib.es2024.regexp.d.ts","../../node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2024.string.d.ts","../../node_modules/typescript/lib/lib.esnext.array.d.ts","../../node_modules/typescript/lib/lib.esnext.collection.d.ts","../../node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/typescript/lib/lib.esnext.promise.d.ts","../../node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/typescript/lib/lib.esnext.iterator.d.ts","../../node_modules/typescript/lib/lib.esnext.float16.d.ts","../../node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/@remix-run/node/dist/globals.d.ts","../../node_modules/@types/cookie/index.d.ts","../../node_modules/@remix-run/server-runtime/dist/crypto.d.ts","../../node_modules/@remix-run/server-runtime/dist/cookies.d.ts","../../node_modules/@remix-run/server-runtime/dLine truncated
@@ -3,6 +3,7 @@
// Main component
export { default as PageDraw } from "./lib/PageDraw";
export { BlankCanvasModal } from "./lib/BlankCanvasModal";
export { BaseImageSelector } from "./lib/BaseImageSelector";
export { HistoryStack } from "./lib/HistoryStack";
// Store
@@ -0,0 +1,301 @@
import { useCallback, useRef, useState } from "react";
import { faExpand, faImages, faPencil, faUpload } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Button } from "@storyteller/ui-button";
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl, MediaFilesApi } from "@storyteller/api";
import { UploaderStates } from "@storyteller/common";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
import { ModelPage } from "@storyteller/ui-model-selector";
import { BlankCanvasModal } from "./BlankCanvasModal";
import type { PageDrawAdapter } from "./adapter";
import type { BaseSelectorImage } from "./types";
const MAX_GALLERY_SELECTIONS = 1;
interface BaseImageSelectorProps {
adapter: PageDrawAdapter;
onImageSelect: (image: BaseSelectorImage) => void;
showLoading?: boolean;
}
export const BaseImageSelector = ({
adapter,
onImageSelect,
showLoading = false,
}: BaseImageSelectorProps) => {
const [isGalleryOpen, setIsGalleryOpen] = useState(false);
const [gallerySelectedIds, setGallerySelectedIds] = useState<string[]>([]);
const [isBlankCanvasOpen, setIsBlankCanvasOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const toast = (type: "success" | "error" | "info", message: string) => {
if (adapter.showToast) {
adapter.showToast(type, message);
} else if (type === "error") {
console.error(message);
} else {
console.log(message);
}
};
const handleGallerySelectItem = (mediaToken: string) => {
setGallerySelectedIds((prev) => {
if (prev.includes(mediaToken)) return prev.filter((x) => x !== mediaToken);
return [mediaToken];
});
};
const handleGalleryUseSelected = (selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item || !item.fullImage) {
toast("error", "No image selected");
return;
}
setIsGalleryOpen(false);
setGallerySelectedIds([]);
onImageSelect({
url: item.fullImage,
mediaToken: item.id,
thumbnailUrlTemplate: item.thumbnailUrlTemplate,
fullImageUrl: item.fullImage,
});
};
const handleBlankCanvasConfirm = (width: number, height: number) => {
setIsBlankCanvasOpen(false);
onImageSelect({
url: "",
mediaToken: `blank_canvas_${width}x${height}_${Math.random()
.toString(36)
.substring(2, 8)}`,
isBlankCanvas: true,
blankCanvasWidth: width,
blankCanvasHeight: height,
});
};
const handleFiles = useCallback(
(files: FileList | File[]) => {
const file = Array.from(files)[0];
if (!file || !adapter.uploadImage) {
if (!adapter.uploadImage) toast("error", "Image upload not available");
return;
}
setIsUploading(true);
const reader = new FileReader();
reader.onloadend = () => {
adapter.uploadImage!({
title: `pagedraw-${Math.random().toString(36).substring(2, 10)}`,
assetFile: file,
progressCallback: async (state) => {
if (state.status === UploaderStates.success && state.data) {
const mediaToken = state.data;
let finalUrl = reader.result as string;
let thumbnailUrlTemplate: string | undefined;
try {
const api = new MediaFilesApi();
const result = await api.GetMediaFileByToken({
mediaFileToken: mediaToken,
});
if (result.success && result.data) {
finalUrl =
result.data.media_links?.cdn_url ||
result.data.public_bucket_url ||
finalUrl;
const links = result.data.media_links as
| { thumbnail_template?: string; maybe_thumbnail_template?: string }
| undefined;
thumbnailUrlTemplate =
links?.thumbnail_template || links?.maybe_thumbnail_template;
}
} catch {
// fall back to data URL
}
onImageSelect({
url: finalUrl,
mediaToken,
fullImageUrl: finalUrl,
thumbnailUrlTemplate,
});
toast("success", "Image uploaded successfully!");
setIsUploading(false);
} else if (
state.status === UploaderStates.assetError ||
state.status === UploaderStates.imageCreateError
) {
toast("error", "Upload failed. Please try again.");
setIsUploading(false);
}
},
});
};
reader.readAsDataURL(file);
},
[adapter, onImageSelect],
);
const disabled = isUploading || showLoading;
return (
<>
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-ui-panel text-base-fg">
<div className="aspect-video w-full max-w-5xl bg-ui-background">
<UploadCard
disabled={disabled}
onFilesSelected={handleFiles}
onPickFromLibrary={() => setIsGalleryOpen(true)}
onCreateBlankCanvas={() => setIsBlankCanvasOpen(true)}
/>
</div>
</div>
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={ModelPage.ImageEditor} />
<HelpMenuButton />
</div>
<GalleryModal
isOpen={isGalleryOpen}
onClose={() => {
setIsGalleryOpen(false);
setGallerySelectedIds([]);
}}
mode="select"
selectedItemIds={gallerySelectedIds}
onSelectItem={handleGallerySelectItem}
maxSelections={MAX_GALLERY_SELECTIONS}
onUseSelected={handleGalleryUseSelected}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
<BlankCanvasModal
isOpen={isBlankCanvasOpen}
onClose={() => setIsBlankCanvasOpen(false)}
onConfirm={handleBlankCanvasConfirm}
/>
</>
);
};
// ─── UploadCard (drag/drop entry tile) ─────────────────────────────────────────
interface UploadCardProps {
disabled: boolean;
onFilesSelected: (files: FileList) => void;
onPickFromLibrary: () => void;
onCreateBlankCanvas: () => void;
}
const UploadCard = ({
disabled,
onFilesSelected,
onPickFromLibrary,
onCreateBlankCanvas,
}: UploadCardProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragActive, setIsDragActive] = useState(false);
const resetInput = () => {
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleFiles = (files?: FileList | null) => {
if (!files || files.length === 0) return;
onFilesSelected(files);
resetInput();
};
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) setIsDragActive(true);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) setIsDragActive(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
setIsDragActive(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
setIsDragActive(false);
handleFiles(e.dataTransfer?.files);
};
return (
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={twMerge(
"bg-ui-background/60 relative flex h-full flex-col items-center justify-center gap-8 overflow-hidden rounded-2xl border-2 border-dashed border-ui-panel-border p-10 text-center transition-colors",
isDragActive && "border-primary/80 bg-primary/5",
disabled && "pointer-events-none opacity-60",
)}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
disabled={disabled}
/>
<div className="flex flex-col items-center gap-6">
<div className="relative flex h-32 w-32 items-center justify-center rounded-2xl border-2 border-blue-400/30 bg-blue-500/40 shadow-xl backdrop-blur-sm">
<FontAwesomeIcon icon={faPencil} className="text-5xl text-white drop-shadow-lg" />
</div>
<div className="space-y-3">
<h3 className="text-4xl font-bold tracking-tight text-base-fg">Edit Image</h3>
<p className="mx-auto max-w-md text-base leading-relaxed text-base-fg/70">
Click to upload or drag and drop an image here to edit
</p>
</div>
<div className="mt-4 flex flex-wrap justify-center gap-4">
<Button
variant="primary"
icon={faUpload}
onClick={() => !disabled && fileInputRef.current?.click()}
className="px-8 py-3 text-base font-semibold shadow-lg"
disabled={disabled}
>
Select Image
</Button>
<Button
variant="action"
icon={faImages}
onClick={onPickFromLibrary}
className="border-2 px-8 py-3 text-base font-semibold"
disabled={disabled}
>
Pick from Library
</Button>
<Button
variant="action"
icon={faExpand}
onClick={onCreateBlankCanvas}
className="border-2 px-8 py-3 text-base font-semibold"
disabled={disabled}
>
Blank Canvas
</Button>
</div>
</div>
</div>
);
};
@@ -34,6 +34,7 @@ import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
import { GenerationProvider } from "@storyteller/api-enums";
import { HistoryStack } from "./HistoryStack";
import { BaseImageSelector } from "./BaseImageSelector";
import { type BaseSelectorImage } from "./types";
import { EncodeImageBitmapToBase64 } from "./utilities/EncodeImageBitmapToBase64";
import { compositeInWorker, maskInWorker } from "./utilities/generatePipeline";
@@ -198,7 +199,7 @@ function DragScrubButton({
interface Edit3DScrubControlsProps {
nodeId: string;
stageRef: { current: Konva.Stage };
overlayHandle: React.RefObject<Model3DOverlayHandle>;
overlayHandle: React.RefObject<Model3DOverlayHandle | null>;
}
const Edit3DScrubControls = memo(function Edit3DScrubControls({
@@ -351,7 +352,6 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
historyImageBundles,
pendingGenerations,
getAspectRatioDimensions,
finishRemoveBackground,
createImageFromUrl,
createImageFromFile,
createImageFrom3DModel,
@@ -361,7 +361,6 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
removeHistoryImage,
addHistoryImageBundle,
addPendingGeneration,
resolvePendingGeneration,
removePendingGeneration,
clearPendingGenerations,
setAspectRatioType,
@@ -391,8 +390,6 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
(state) => state.setGenerationCount,
);
const useSystemPrompt = promptStoreProvider((state) => state.useSystemPrompt);
const referenceImages = promptStoreProvider((state) => state.referenceImages);
const prompt = promptStoreProvider((state) => state.prompt);
const baseImageKonvaRef = useRef<Konva.Image>({} as Konva.Image);
const baseImageUrl = baseImageInfo?.url;
@@ -524,13 +521,13 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
window.addEventListener(
"gallery-2d-drop",
handleGallery2DDrop as EventListener,
handleGallery2DDrop as unknown as EventListener,
);
return () => {
window.removeEventListener(
"gallery-2d-drop",
handleGallery2DDrop as EventListener,
handleGallery2DDrop as unknown as EventListener,
);
};
}, [createImageFromUrl, createImageFrom3DModel]);
@@ -1113,6 +1110,16 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
// Display image selector on launch, otherwise hide it.
// Also show the selector when a non-blank-canvas image is set but the bitmap is still loading.
if (!baseImageInfo || (!baseImageBitmap && !baseImageInfo.isBlankCanvas)) {
const selectorProps = {
onImageSelect: (image: BaseSelectorImage) => {
addHistoryImageBundle({ images: [image] });
setBaseImageInfo(image);
},
showLoading:
baseImageInfo !== null &&
baseImageBitmap === null &&
!baseImageInfo.isBlankCanvas,
};
return (
<div
className={
@@ -1121,16 +1128,11 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
>
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
{adapter.renderBaseImageSelector({
onImageSelect: (image: BaseSelectorImage) => {
addHistoryImageBundle({ images: [image] });
setBaseImageInfo(image);
},
showLoading:
baseImageInfo !== null &&
baseImageBitmap === null &&
!baseImageInfo.isBlankCanvas,
})}
{adapter.renderBaseImageSelector ? (
adapter.renderBaseImageSelector(selectorProps)
) : (
<BaseImageSelector adapter={adapter} {...selectorProps} />
)}
</div>
</div>
</div>
@@ -44,11 +44,23 @@ export interface PageDrawAdapter {
/** Platform's image upload function, compatible with PromptBox2D's uploadImage prop. */
uploadImage?: UploadMediaFn;
/** Renders the "no base image selected" state — upload card, gallery picker, etc. */
renderBaseImageSelector(props: {
/**
* Optional override for the "no base image selected" view. When omitted,
* the lib renders its built-in BaseImageSelector (upload + gallery + blank
* canvas). Hosts only need to provide this when they want fully custom
* chrome around the selector.
*/
renderBaseImageSelector?: (props: {
onImageSelect: (image: BaseSelectorImage) => void;
showLoading: boolean;
}): ReactNode;
}) => ReactNode;
/**
* Optional toast hook so the lib can surface upload / generation messages
* without picking a host's toast library. When omitted, messages fall back
* to console logging.
*/
showToast?: (type: "success" | "error" | "info", message: string) => void;
/** Optional telemetry hook called just before an enqueue. */
onEnqueueMeta?: (meta: {
@@ -82,6 +82,9 @@
},
{
"path": "../modal/tsconfig.lib.json"
},
{
"path": "../gallery-modal/tsconfig.lib.json"
}
]
}