mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 17:38:10 +00:00
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:
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 @@
|
||||
{"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user