mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Code cleanup: Delete PageEdit dead code and migrate shared types to ui-pagedraw (#1281)
PageEdit was never rendered in the app. Removes all PageEdit files (~4000 lines) and migrates the shared types (BaseSelectorImage, ImageBundle) and HistoryStack component to @storyteller/ui-pagedraw which already owns the canonical definitions.
This commit is contained in:
@@ -71,7 +71,7 @@ import { useRemoveBackgroundStore } from "~/pages/PageRemoveBackground/RemoveBac
|
||||
import { TabId, useTabStore } from "~/pages/Stores/TabState";
|
||||
import { authentication } from "~/signals";
|
||||
import { setLogoutStates } from "~/signals/authentication/utilities";
|
||||
import { BaseSelectorImage } from "../../../pages/PageEdit/BaseImageSelector";
|
||||
import type { BaseSelectorImage } from "@storyteller/ui-pagedraw";
|
||||
import { AppsQuickMenu } from "./AppsQuickMenu";
|
||||
import { SceneTitleInput } from "./SceneTitleInput";
|
||||
import { TaskQueue } from "./TaskQueue";
|
||||
|
||||
@@ -43,8 +43,7 @@ import {
|
||||
useCostBreakdownModalStore,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import { HistoryStack, ImageBundle } from "../PageEdit/HistoryStack";
|
||||
import type { BaseSelectorImage } from "../PageEdit/BaseImageSelector";
|
||||
import { HistoryStack, type ImageBundle, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
|
||||
|
||||
const ANGLES_MODEL_PAGE = ModelPage.Angles;
|
||||
|
||||
@@ -562,12 +561,6 @@ export const Angles = () => {
|
||||
selectedImageToken={selectedImageToken}
|
||||
onImageSelect={handleHistoryImageSelect}
|
||||
onImageRemove={handleHistoryImageRemove}
|
||||
onNewImageBundle={() => {
|
||||
/* handled by our own event listener */
|
||||
}}
|
||||
onResolvePending={() => {
|
||||
/* handled by our own event listener */
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { 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";
|
||||
|
||||
export interface BaseSelectorImage {
|
||||
url: string;
|
||||
mediaToken: string;
|
||||
|
||||
// Link to the thumbnail *template* URL of the image
|
||||
// This is not a URL itself! It must be converted into a URL by replacing parameters.
|
||||
thumbnailUrlTemplate?: string;
|
||||
|
||||
// Link to the full image URL of the full image asset
|
||||
fullImageUrl?: string;
|
||||
}
|
||||
|
||||
export type BaseImageSelectorProps = {
|
||||
onImageSelect: (imageUrl: BaseSelectorImage) => void;
|
||||
showLoading?: boolean;
|
||||
};
|
||||
|
||||
const MAX_GALLERY_SELECTIONS = 1;
|
||||
|
||||
export const BaseImageSelector = ({
|
||||
onImageSelect,
|
||||
showLoading = false,
|
||||
}: BaseImageSelectorProps) => {
|
||||
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
|
||||
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [isLoading, setIsLoading] = 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);
|
||||
const maxSelections = 1;
|
||||
if (prev.length >= maxSelections) {
|
||||
return maxSelections === 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,
|
||||
};
|
||||
sendImageEvent(referenceImage);
|
||||
};
|
||||
|
||||
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) => {
|
||||
console.debug("Upload progress:", newState.data);
|
||||
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: thumbnailUrlTemplate,
|
||||
};
|
||||
|
||||
toast.success("Image uploaded successfully!");
|
||||
sendImageEvent(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);
|
||||
});
|
||||
};
|
||||
|
||||
const sendImageEvent = (image: BaseSelectorImage) => {
|
||||
handleGalleryClose();
|
||||
onImageSelect(image);
|
||||
};
|
||||
|
||||
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}
|
||||
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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,312 +0,0 @@
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useImageEditCompleteEvent } from "@storyteller/tauri-events";
|
||||
import {
|
||||
faClockRotateLeft,
|
||||
faTrashAlt,
|
||||
faTrashXmark,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Fragment, useEffect, useRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { BaseSelectorImage } from "./BaseImageSelector";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
isActionReminderOpen,
|
||||
showActionReminder,
|
||||
} from "@storyteller/ui-action-reminder-modal";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
export interface ImageBundle {
|
||||
images: BaseSelectorImage[];
|
||||
}
|
||||
|
||||
interface HistoryStackProps {
|
||||
onClear: () => void;
|
||||
onImageSelect?: (image: BaseSelectorImage) => void;
|
||||
onImageRemove?: (image: BaseSelectorImage) => void;
|
||||
imageBundles: ImageBundle[];
|
||||
onNewImageBundle?: (newBundle: ImageBundle) => void;
|
||||
pendingPlaceholders?: { id: string; count: number }[];
|
||||
onResolvePending?: (id: string) => void;
|
||||
selectedImageToken?: string;
|
||||
blurredBackgroundUrl?: string;
|
||||
}
|
||||
|
||||
export const HistoryStack = ({
|
||||
onClear,
|
||||
onImageSelect = () => {},
|
||||
onImageRemove = () => {},
|
||||
imageBundles,
|
||||
onNewImageBundle = () => {},
|
||||
pendingPlaceholders = [],
|
||||
onResolvePending = () => {},
|
||||
selectedImageToken,
|
||||
blurredBackgroundUrl,
|
||||
}: HistoryStackProps) => {
|
||||
const handleSelectWithPreload = (image: BaseSelectorImage) => {
|
||||
const preload = new Image();
|
||||
preload.crossOrigin = "anonymous";
|
||||
(
|
||||
preload as HTMLImageElement & { decoding?: "sync" | "async" | "auto" }
|
||||
).decoding = "async";
|
||||
preload.src = image.url;
|
||||
const select = () => onImageSelect(image);
|
||||
// Prefer decode() when available to ensure the image is fully decoded before swap
|
||||
// Fallback to onload/onerror which also ensures layout is ready
|
||||
const decodeFn = (
|
||||
preload as HTMLImageElement & { decode?: () => Promise<void> }
|
||||
).decode;
|
||||
if (typeof decodeFn === "function") {
|
||||
decodeFn.call(preload).then(select).catch(select);
|
||||
} else {
|
||||
preload.onload = select;
|
||||
preload.onerror = select;
|
||||
}
|
||||
};
|
||||
|
||||
useImageEditCompleteEvent(async (event) => {
|
||||
const newBundle: ImageBundle = {
|
||||
images: event.edited_images.map(
|
||||
(editedImage) =>
|
||||
({
|
||||
url: editedImage.cdn_url,
|
||||
mediaToken: editedImage.media_token,
|
||||
thumbnailUrlTemplate: editedImage.maybe_thumbnail_template,
|
||||
fullImageUrl: editedImage.cdn_url,
|
||||
}) as BaseSelectorImage,
|
||||
),
|
||||
};
|
||||
|
||||
onNewImageBundle(newBundle);
|
||||
// Resolve any matching pending placeholders using subscriber id
|
||||
if (event.maybe_frontend_subscriber_id) {
|
||||
onResolvePending(event.maybe_frontend_subscriber_id);
|
||||
}
|
||||
// Do not auto-select the new image; only scroll to top for visibility
|
||||
setTimeout(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// This is used to force image reloads in different sessions
|
||||
// and prevent fetching CORS-tainted images from cache
|
||||
const sessionRandBuster = useRef(Math.random());
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const prevPendingCountRef = useRef<number>(0);
|
||||
|
||||
const handleClear = () => {
|
||||
onClear();
|
||||
};
|
||||
|
||||
const handleOnImageRemove = (baseImage: BaseSelectorImage) => {
|
||||
onImageRemove(baseImage);
|
||||
};
|
||||
|
||||
// Scroll to top when new pending placeholders are added (after enqueue)
|
||||
useEffect(() => {
|
||||
const current = pendingPlaceholders.length;
|
||||
if (current > prevPendingCountRef.current) {
|
||||
setTimeout(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, 0);
|
||||
}
|
||||
prevPendingCountRef.current = current;
|
||||
}, [pendingPlaceholders.length]);
|
||||
|
||||
const getImageThumbnailSource = (image: BaseSelectorImage) => {
|
||||
if (!!image.thumbnailUrlTemplate) {
|
||||
// NB: this is the preferred way to populate the history stack thumbnails
|
||||
return image.thumbnailUrlTemplate.replace("{WIDTH}", "256");
|
||||
}
|
||||
if (image.url.startsWith("data:") || image.url.startsWith("blob:")) {
|
||||
return image.url;
|
||||
}
|
||||
console.warn("Using older image source for history stack image", image);
|
||||
return `${image.url}?historystack+${sessionRandBuster.current}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-auto w-20 rounded-lg">
|
||||
<div className="glass rounded-lg p-1.5">
|
||||
<div className="mb-2 flex w-full items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faClockRotateLeft}
|
||||
className="p-1 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={
|
||||
"scrollbar-hidden flex max-h-[50vh] flex-col items-center justify-start gap-1 overflow-y-auto"
|
||||
}
|
||||
>
|
||||
{/* Pending placeholders first (newest at top) */}
|
||||
{(() => {
|
||||
const reversed = [...pendingPlaceholders].slice().reverse();
|
||||
return reversed.map((p, idx) => (
|
||||
<Fragment key={`pending-group-${p.id}`}>
|
||||
{Array.from({ length: Math.max(1, p.count || 1) }).map(
|
||||
(_, i) => (
|
||||
<div
|
||||
key={`pending-${p.id}-${i}`}
|
||||
className="relative w-full"
|
||||
>
|
||||
<div className="st-loading-tile relative aspect-square w-full overflow-hidden rounded-lg">
|
||||
{blurredBackgroundUrl && (
|
||||
<img
|
||||
src={
|
||||
blurredBackgroundUrl?.startsWith("data:") ||
|
||||
blurredBackgroundUrl?.startsWith("blob:")
|
||||
? blurredBackgroundUrl
|
||||
: `${blurredBackgroundUrl}?placeholderbg`
|
||||
}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-80 blur-lg"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[var(--st-divider)] border-t-[var(--st-fg)]" />
|
||||
</div>
|
||||
{/* SVG running border (single solid line) */}
|
||||
<svg
|
||||
className="st-border-svg"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<rect
|
||||
className="st-border-solid"
|
||||
x="1"
|
||||
y="1"
|
||||
width="98"
|
||||
height="98"
|
||||
rx="16"
|
||||
ry="16"
|
||||
pathLength="200"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{idx < reversed.length - 1 && (
|
||||
<hr className="my-1.5 h-0.5 min-h-0.5 w-3/4 rounded-md border-none bg-[var(--st-divider)]" />
|
||||
)}
|
||||
</Fragment>
|
||||
));
|
||||
})()}
|
||||
|
||||
{pendingPlaceholders.length > 0 && (
|
||||
<hr className="my-1.5 h-0.5 min-h-0.5 w-3/4 rounded-md border-none bg-[var(--st-divider)]" />
|
||||
)}
|
||||
|
||||
{/* Completed images below placeholders, newest bundles first */}
|
||||
{[...imageBundles]
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((bundle, index) => (
|
||||
<Fragment key={index}>
|
||||
{bundle.images.map((image) => (
|
||||
<Button
|
||||
key={image.mediaToken}
|
||||
className={twMerge(
|
||||
"group relative aspect-square h-full w-full shrink-0 overflow-hidden rounded-lg border-2 bg-transparent p-0 hover:bg-transparent hover:opacity-80",
|
||||
selectedImageToken === image.mediaToken &&
|
||||
"border-primary hover:opacity-100",
|
||||
)}
|
||||
onClick={() => handleSelectWithPreload(image)}
|
||||
>
|
||||
<img
|
||||
src={getImageThumbnailSource(image)}
|
||||
alt=""
|
||||
// NB(bt,2025-10-09): We shouldn't need CORS here.
|
||||
//crossOrigin="anonymous"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
className="absolute -right-0 -top-0 flex h-5 w-5 items-center justify-center rounded-bl-lg bg-red/50 opacity-0 transition-opacity hover:bg-red/80 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showActionReminder({
|
||||
reminderType: "default",
|
||||
title: "Remove Image",
|
||||
primaryActionIcon: faTrashXmark,
|
||||
primaryActionBtnClassName: "bg-red hover:bg-red/80",
|
||||
message: (
|
||||
<>
|
||||
<p className="text-base-fg text-sm opacity-70">
|
||||
Are you sure you want to remove this image from the history stack? This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
<p className="text-base-fg text-sm opacity-70">
|
||||
Images removed here can still be found in the library.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
primaryActionText: "Delete",
|
||||
onPrimaryAction: () => {
|
||||
handleOnImageRemove(image);
|
||||
isActionReminderOpen.value = false;
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faTrashAlt}
|
||||
className="text-base-fg h-full w-full text-[13px]"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
{index < imageBundles.length - 1 && (
|
||||
<hr
|
||||
className="my-1.5 h-0.5 min-h-0.5 w-3/4 rounded-md border-none bg-[var(--st-divider)]"
|
||||
key={"hr" + index}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-center">
|
||||
<div className="glass w-fit rounded-xl border-2 border-red/50 shadow-lg hover:border-red/80">
|
||||
<div className="relative h-full">
|
||||
<Tooltip
|
||||
content="Reset All"
|
||||
position="left"
|
||||
closeOnClick={true}
|
||||
className="ms-1 rounded-md bg-red px-3 py-1"
|
||||
delay={100}
|
||||
>
|
||||
<button
|
||||
className="text-base-fg flex h-10 w-10 items-center justify-center rounded-lg border-2 border-transparent transition-colors hover:bg-red/50"
|
||||
onClick={() =>
|
||||
showActionReminder({
|
||||
reminderType: "default",
|
||||
title: "Reset All",
|
||||
primaryActionIcon: faTrashXmark,
|
||||
primaryActionBtnClassName: "bg-red hover:bg-red/80",
|
||||
message: (
|
||||
<p className="text-base-fg text-sm opacity-70">
|
||||
Are you sure you want to reset all? This will clear all
|
||||
your work and cannot be undone.
|
||||
</p>
|
||||
),
|
||||
primaryActionText: "Reset all",
|
||||
onPrimaryAction: () => {
|
||||
handleClear();
|
||||
isActionReminderOpen.value = false;
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="h-5 w-5 text-xl" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import React from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faXmark, faTrashXmark } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
showActionReminder,
|
||||
isActionReminderOpen,
|
||||
} from "@storyteller/ui-action-reminder-modal";
|
||||
|
||||
interface LeftResetStackProps {
|
||||
onReset: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LeftResetStack: React.FC<LeftResetStackProps> = ({
|
||||
onReset,
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`glass rounded-xl border-2 border-red/50 shadow-lg hover:border-red/80 ${className}`}
|
||||
>
|
||||
<div className="relative h-full">
|
||||
<Tooltip
|
||||
content="Reset All"
|
||||
position="right"
|
||||
closeOnClick={true}
|
||||
className="ms-1 rounded-md bg-red px-3 py-1"
|
||||
delay={100}
|
||||
>
|
||||
<button
|
||||
className="text-base-fg flex h-10 w-10 items-center justify-center rounded-lg border-2 border-transparent transition-colors hover:bg-red/50"
|
||||
onClick={() =>
|
||||
showActionReminder({
|
||||
reminderType: "default",
|
||||
title: "Reset All",
|
||||
primaryActionIcon: faTrashXmark,
|
||||
primaryActionBtnClassName: "bg-red hover:bg-red/80",
|
||||
message: (
|
||||
<p className="text-base-fg text-sm opacity-70">
|
||||
Are you sure you want to reset all? This will clear all your
|
||||
work and cannot be undone.
|
||||
</p>
|
||||
),
|
||||
primaryActionText: "Reset all",
|
||||
onPrimaryAction: () => {
|
||||
onReset();
|
||||
isActionReminderOpen.value = false;
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="h-5 w-5 text-xl" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeftResetStack;
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faEyeDropper } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { SliderV2 } from "@storyteller/ui-sliderv2";
|
||||
import { HsvaColorPicker, HsvaColor } from "react-colorful";
|
||||
import { hsvaToHex, hexToHsva } from "@uiw/color-convert";
|
||||
|
||||
export interface MarkerToolControlBarProps {
|
||||
currentSize: number;
|
||||
currentColor: string;
|
||||
activeTool: string;
|
||||
showColorPicker: boolean;
|
||||
onSizeChange?: (size: number) => void;
|
||||
onColorChange?: (color: string) => void;
|
||||
}
|
||||
|
||||
function useDebounced<T extends (...args: A) => void, A extends unknown[]>(
|
||||
fn: T,
|
||||
ms = 75,
|
||||
) {
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
return (...args: A) => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
const MarkerToolControlBar = ({
|
||||
currentSize,
|
||||
currentColor,
|
||||
activeTool,
|
||||
showColorPicker: showColorPickerProp,
|
||||
onSizeChange,
|
||||
onColorChange,
|
||||
}: MarkerToolControlBarProps) => {
|
||||
const [showColorPickerPanel, setShowColorPickerPanel] = useState(false);
|
||||
const [hsva, setHsva] = useState<HsvaColor>(hexToHsva(currentColor));
|
||||
|
||||
const isEraser = activeTool === "eraser";
|
||||
const shouldShowColorPicker = showColorPickerProp && !isEraser;
|
||||
const maxSize = isEraser ? 50 : 20;
|
||||
|
||||
const sendColor = useDebounced<(hex: string) => void, [string]>(
|
||||
(hex: string) => {
|
||||
onColorChange?.(hex);
|
||||
},
|
||||
75,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute left-1/2 top-20 flex -translate-x-1/2 flex-col gap-3">
|
||||
<div className="glass flex w-[400px] items-center gap-2 rounded-xl p-2">
|
||||
{shouldShowColorPicker && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowColorPickerPanel(!showColorPickerPanel)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg border-2 border-transparent text-white transition-colors hover:bg-white/10"
|
||||
>
|
||||
<span
|
||||
className="inline-block h-5 w-5 rounded-full border-2 border-white"
|
||||
style={{ backgroundColor: currentColor }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showColorPickerPanel && (
|
||||
<div
|
||||
className="absolute left-0 top-12 z-50 rounded-xl border border-[#404040] bg-[#303030] transition-all duration-200 ease-in-out"
|
||||
onMouseLeave={() => setShowColorPickerPanel(false)}
|
||||
>
|
||||
<div className="glass relative w-fit rounded-2xl p-4 shadow-lg">
|
||||
<button className="bg-zinc-700 text-zinc-300 hover:bg-zinc-600 absolute left-4 top-4 flex h-8 w-8 items-center justify-center rounded-full">
|
||||
<FontAwesomeIcon icon={faEyeDropper} size="sm" />
|
||||
</button>
|
||||
|
||||
<HsvaColorPicker
|
||||
color={hsva}
|
||||
onChange={(c) => {
|
||||
setHsva(c);
|
||||
const hex = hsvaToHex(c);
|
||||
sendColor(hex);
|
||||
}}
|
||||
className="brush-picker"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SliderV2
|
||||
min={1}
|
||||
max={maxSize}
|
||||
value={currentSize}
|
||||
onChange={(size) => onSizeChange?.(size)}
|
||||
step={1}
|
||||
innerLabel={"Size " + currentSize + "pt"}
|
||||
showDecrement
|
||||
showIncrement
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkerToolControlBar;
|
||||
@@ -1,759 +0,0 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import Konva from "konva"; // just for types
|
||||
import {
|
||||
EnqueueEditImage,
|
||||
EnqueueImageInpaint,
|
||||
EnqueueEditImageSize,
|
||||
EnqueueEditImageResolution,
|
||||
EnqueueEditImageRequest,
|
||||
EnqueueImageInpaintRequest,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { PromptsApi } from "@storyteller/api";
|
||||
import { RefImage, usePromptEditStore } from "@storyteller/ui-promptbox";
|
||||
import { ContextMenuContainer, useCopyPasteHotkeys, useDeleteHotkeys, useUndoRedoHotkeys } from "@storyteller/ui-pagedraw";
|
||||
import PromptEditor from "./PromptEditor/PromptEditor";
|
||||
import { ActiveEditTool, useEditStore } from "./stores/EditState";
|
||||
import { EditPaintSurface } from "./EditPaintSurface";
|
||||
import { normalizeCanvas } from "../../Helpers/CanvasHelpers";
|
||||
import { BaseImageSelector, BaseSelectorImage } from "./BaseImageSelector";
|
||||
import MarkerToolControlBar from "./MarkerToolControlBar";
|
||||
import {
|
||||
ClassyModelSelector,
|
||||
useSelectedImageModel,
|
||||
useSelectedProviderForModel,
|
||||
ModelPage,
|
||||
IMAGE_EDITOR_PAGE_MODEL_LIST,
|
||||
//ProviderSelector,
|
||||
//PROVIDER_LOOKUP_BY_PAGE,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { CommonAspectRatio, ImageModel } from "@storyteller/model-list";
|
||||
import { HistoryStack, ImageBundle } from "./HistoryStack";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import {
|
||||
CostCalculatorButton,
|
||||
useCostBreakdownModalStore,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.ImageEditor;
|
||||
|
||||
const PageEdit = () => {
|
||||
//useStateSceneLoader();
|
||||
|
||||
const selectedImageModel: ImageModel | undefined =
|
||||
useSelectedImageModel(PAGE_ID);
|
||||
|
||||
const selectedProvider: GenerationProvider | undefined =
|
||||
useSelectedProviderForModel(PAGE_ID, selectedImageModel?.id);
|
||||
|
||||
const imageCredits = useCostBreakdownModalStore(
|
||||
(s) => s.estimatedCreditsByPage[PAGE_ID],
|
||||
);
|
||||
|
||||
// State for canvas dimensions
|
||||
const canvasWidth = useRef<number>(1024);
|
||||
const canvasHeight = useRef<number>(1024);
|
||||
|
||||
// Add new state to track if user is selecting
|
||||
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
||||
|
||||
// Create refs for stage and image
|
||||
const stageRef = useRef<Konva.Stage>({} as Konva.Stage);
|
||||
const leftPanelRef = useRef<Konva.Layer>({} as Konva.Layer);
|
||||
const baseImageKonvaRef = useRef<Konva.Image>({} as Konva.Image);
|
||||
const transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({});
|
||||
const [pendingGenerations, setPendingGenerations] = useState<
|
||||
{ id: string; count: number }[]
|
||||
>([]);
|
||||
const [generationCount, setGenerationCount] = useState<number>(1);
|
||||
|
||||
// New aspect ratio state will begin to phase out old aspect ratio
|
||||
const [commonAspectRatio, setCommonAspectRatio] = useState<
|
||||
CommonAspectRatio | undefined
|
||||
>(undefined);
|
||||
|
||||
// Use the Zustand store
|
||||
const store = useEditStore();
|
||||
const baseImageUrl = store.baseImageInfo?.url;
|
||||
const addHistoryImageBundle = useEditStore(
|
||||
(state) => state.addHistoryImageBundle,
|
||||
);
|
||||
const removeHistoryImage = useEditStore((state) => state.removeHistoryImage);
|
||||
const historyImageBundles = useEditStore(
|
||||
(state) => state.historyImageBundles,
|
||||
);
|
||||
const referenceImages = usePromptEditStore((s) => s.referenceImages);
|
||||
|
||||
// Pass store actions directly as callbacks
|
||||
useDeleteHotkeys({ onDelete: store.deleteSelectedItems });
|
||||
useUndoRedoHotkeys({ undo: store.undo, redo: store.redo });
|
||||
useCopyPasteHotkeys({
|
||||
onCopy: store.copySelectedItems,
|
||||
onPaste: store.pasteItems,
|
||||
});
|
||||
|
||||
// Listen for gallery drag and drop events
|
||||
useEffect(() => {
|
||||
const handleGallery2DDrop = (event: CustomEvent) => {
|
||||
const { item, canvasPosition } = event.detail;
|
||||
console.log("Received 2D gallery drop:", { item, canvasPosition });
|
||||
|
||||
// Get the stage to transform coordinates properly
|
||||
const stage = stageRef.current;
|
||||
if (!stage) {
|
||||
console.error(
|
||||
"Stage reference not available for coordinate transformation",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Transform canvas coordinates to stage coordinates
|
||||
// Account for stage position, scale, and transformations
|
||||
const stageX = stage.x();
|
||||
const stageY = stage.y();
|
||||
const scaleX = stage.scaleX();
|
||||
const scaleY = stage.scaleY();
|
||||
|
||||
const stagePoint = {
|
||||
x: (canvasPosition.x - stageX) / scaleX,
|
||||
y: (canvasPosition.y - stageY) / scaleY,
|
||||
};
|
||||
|
||||
console.log("Transformed stage coordinates:", stagePoint);
|
||||
|
||||
// Use the direct URL for the image
|
||||
const imageUrl = item.fullImage || item.thumbnail;
|
||||
if (!imageUrl) {
|
||||
console.error("No image URL available for dropped item");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Creating image from URL:", imageUrl);
|
||||
|
||||
// Use the store's createImageFromUrl method directly
|
||||
store.createImageFromUrl(stagePoint.x, stagePoint.y, imageUrl);
|
||||
|
||||
console.log(
|
||||
`Created image "${item.label}" at stage position:`,
|
||||
stagePoint,
|
||||
);
|
||||
};
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener(
|
||||
"gallery-2d-drop",
|
||||
handleGallery2DDrop as EventListener,
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"gallery-2d-drop",
|
||||
handleGallery2DDrop as EventListener,
|
||||
);
|
||||
};
|
||||
}, [store]);
|
||||
|
||||
const onFitPressed = useCallback(async () => {
|
||||
// Get the stage and its container dimensions
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
// Get container dimensions
|
||||
const containerWidth = stage.container().offsetWidth;
|
||||
const containerHeight = stage.container().offsetHeight;
|
||||
|
||||
// Get canvas dimensions from store aspect ratio
|
||||
const canvasW = store.getAspectRatioDimensions().width;
|
||||
const canvasH = store.getAspectRatioDimensions().height;
|
||||
|
||||
// Account for top toolbar and bottom prompt box
|
||||
// Top toolbar (MarkerToolControlBar): ~140px from top (toolbar + padding)
|
||||
// Bottom prompt box: ~200px from bottom (can vary with reference images)
|
||||
// Mode selector above prompt: ~60px
|
||||
const hasTopToolbar =
|
||||
store.activeTool === "marker" ||
|
||||
store.activeTool === "eraser" ||
|
||||
store.activeTool === "edit";
|
||||
const topToolbarHeight = hasTopToolbar ? 140 : 80;
|
||||
|
||||
// Check if model supports masking or is nano banana (has mode selector)
|
||||
const hasModeSelectorUI =
|
||||
selectedImageModel?.usesInpaintingMask ||
|
||||
selectedImageModel?.isNanoBananaModel();
|
||||
const bottomUIHeight = hasModeSelectorUI ? 260 : 200;
|
||||
const totalVerticalReserved = topToolbarHeight + bottomUIHeight;
|
||||
|
||||
// Calculate optimal scale to fit the canvas in the container
|
||||
const paddingFactor = 0.95; // horizontal padding (left/right)
|
||||
const scaleX = (containerWidth * paddingFactor) / canvasW;
|
||||
const availableHeight = Math.max(
|
||||
0,
|
||||
containerHeight - totalVerticalReserved,
|
||||
);
|
||||
const scaleY = availableHeight / canvasH;
|
||||
|
||||
// Use the smaller scale to ensure the canvas fits in both dimensions
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
// Limit the scale between reasonable bounds (0.1 to 3.0)
|
||||
const boundedScale = Math.max(0.1, Math.min(3.0, scale));
|
||||
|
||||
// Apply the calculated scale
|
||||
stage.scale({ x: boundedScale, y: boundedScale });
|
||||
|
||||
// Calculate position to center canvas in available space (between toolbars)
|
||||
const verticalOffset = (topToolbarHeight - bottomUIHeight) / 2;
|
||||
stage.position({
|
||||
x: (containerWidth - canvasW * boundedScale) / 2,
|
||||
y: (containerHeight - canvasH * boundedScale) / 2 + verticalOffset,
|
||||
});
|
||||
}, [selectedImageModel, store]);
|
||||
|
||||
// Auto-fit when the base image becomes available and stage is ready
|
||||
// Also re-fit when active tool or model changes (as UI layout changes)
|
||||
useEffect(() => {
|
||||
if (!store.baseImageBitmap) return;
|
||||
if (!stageRef.current) return;
|
||||
// Defer to ensure container has laid out with correct size and toolbars have rendered
|
||||
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,
|
||||
store.activeTool,
|
||||
selectedImageModel,
|
||||
]);
|
||||
|
||||
// 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 (
|
||||
!stageRef.current ||
|
||||
!leftPanelRef.current ||
|
||||
!baseImageKonvaRef.current
|
||||
) {
|
||||
console.error("Stage or left panel ref is not available");
|
||||
throw new Error("Stage or left panel or base image ref is not available");
|
||||
}
|
||||
|
||||
const layer = leftPanelRef.current;
|
||||
|
||||
// Get the canvas area that's covered by the image/rectangle
|
||||
const rect = baseImageKonvaRef.current;
|
||||
const layerCrop = layer.toCanvas({
|
||||
x: stageRef.current.x(),
|
||||
y: stageRef.current.y(),
|
||||
width: rect.width() * stageRef.current.scaleX(),
|
||||
height: rect.height() * stageRef.current.scaleY(),
|
||||
pixelRatio: 1 / stageRef.current.scaleX(),
|
||||
});
|
||||
|
||||
// Using the pixelRatio scaling may result in off-by-one rounding errors,
|
||||
// So we re-fit the image to a canvas of precise size.
|
||||
const fittedCanvas = normalizeCanvas(
|
||||
layerCrop,
|
||||
rect.width(),
|
||||
rect.height(),
|
||||
);
|
||||
|
||||
// Convert colored canvas to alpha mask
|
||||
// NOTE: This isn't needed because the tauri backend uses the alpha channel anyway
|
||||
// drawAlphaMask(fittedCanvas, rect.width(), rect.height());
|
||||
|
||||
const blob = await fittedCanvas.convertToBlob({ type: "image/png" });
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
return new Uint8Array(arrayBuffer);
|
||||
};
|
||||
|
||||
const getCompositeCanvasFile = async (): Promise<File | null> => {
|
||||
if (
|
||||
!stageRef.current ||
|
||||
!leftPanelRef.current ||
|
||||
!baseImageKonvaRef.current ||
|
||||
!store.baseImageBitmap
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = baseImageKonvaRef.current;
|
||||
const width = rect.width();
|
||||
const height = rect.height();
|
||||
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.drawImage(store.baseImageBitmap, 0, 0, width, height);
|
||||
|
||||
const markerLayerCanvas = leftPanelRef.current.toCanvas({
|
||||
x: stageRef.current.x(),
|
||||
y: stageRef.current.y(),
|
||||
width: rect.width() * stageRef.current.scaleX(),
|
||||
height: rect.height() * stageRef.current.scaleY(),
|
||||
pixelRatio: 1 / stageRef.current.scaleX(),
|
||||
});
|
||||
const fittedMarkerCanvas = normalizeCanvas(
|
||||
markerLayerCanvas,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
ctx.drawImage(fittedMarkerCanvas, 0, 0, width, height);
|
||||
|
||||
const blob = await canvas.convertToBlob({ type: "image/png" });
|
||||
const uuid = crypto.randomUUID();
|
||||
return new File([blob], `${uuid}.png`, { type: "image/png" });
|
||||
};
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
async (
|
||||
prompt: string,
|
||||
options?: {
|
||||
aspectRatio?: string;
|
||||
resolution?: string;
|
||||
images?: RefImage[];
|
||||
selectedProvider?: GenerationProvider;
|
||||
},
|
||||
) => {
|
||||
const editedImageToken = store.baseImageInfo?.mediaToken;
|
||||
|
||||
if (!editedImageToken) {
|
||||
console.error("Base image is not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper to map aspect ratio string to enum
|
||||
const mapAspectRatio = (
|
||||
ratio?: string,
|
||||
): EnqueueEditImageSize | undefined => {
|
||||
switch (ratio) {
|
||||
case "auto":
|
||||
return EnqueueEditImageSize.Auto;
|
||||
case "wide":
|
||||
return EnqueueEditImageSize.Wide;
|
||||
case "tall":
|
||||
return EnqueueEditImageSize.Tall;
|
||||
case "square":
|
||||
return EnqueueEditImageSize.Square;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to map resolution string to enum
|
||||
const mapResolution = (
|
||||
res?: string,
|
||||
): EnqueueEditImageResolution | undefined => {
|
||||
switch (res) {
|
||||
case "1k":
|
||||
return EnqueueEditImageResolution.OneK;
|
||||
case "2k":
|
||||
return EnqueueEditImageResolution.TwoK;
|
||||
case "4k":
|
||||
return EnqueueEditImageResolution.FourK;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const subscriberId: string =
|
||||
crypto?.randomUUID?.() ??
|
||||
`inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
window.__storeTaskEnqueueMeta?.({
|
||||
prompt,
|
||||
refImageUrls: (options?.images || [])
|
||||
.map((img) => img.url)
|
||||
.filter(Boolean),
|
||||
modelType:
|
||||
(selectedImageModel as any)?.tauriId || String(selectedImageModel),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
if (selectedImageModel?.editingIsInpainting) {
|
||||
// CASE 1 - INPAINTING (Only a few models do this!)
|
||||
const arrayBuffer = await getMaskArrayBuffer();
|
||||
const request: EnqueueImageInpaintRequest = {
|
||||
model: selectedImageModel,
|
||||
image_media_token: editedImageToken,
|
||||
mask_image_raw_bytes: arrayBuffer,
|
||||
prompt: prompt,
|
||||
image_count: generationCount,
|
||||
frontend_caller: "image_editor",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
};
|
||||
if (options?.selectedProvider) {
|
||||
request.provider = options.selectedProvider;
|
||||
}
|
||||
//if (selectedImageModel?.supportsNewAspectRatio()) {
|
||||
// request.common_aspect_ratio = commonAspectRatio;
|
||||
//}
|
||||
result = await EnqueueImageInpaint(request);
|
||||
} else if (selectedImageModel?.isNanoBananaModel()) {
|
||||
// CASE 2 - NANO BANANA
|
||||
const compositeFile = await getCompositeCanvasFile();
|
||||
if (!compositeFile) {
|
||||
console.error("Failed to create composite canvas");
|
||||
return;
|
||||
}
|
||||
const api = new PromptsApi();
|
||||
const snapshotResult = await api.uploadSceneSnapshot({
|
||||
screenshot: compositeFile,
|
||||
});
|
||||
if (!snapshotResult.data) {
|
||||
console.error("Failed to upload scene snapshot");
|
||||
return;
|
||||
}
|
||||
const imgs = options?.images || [];
|
||||
const request: EnqueueEditImageRequest = {
|
||||
model: selectedImageModel,
|
||||
scene_image_media_token: snapshotResult.data,
|
||||
image_media_tokens: imgs
|
||||
.map((img) => img.mediaToken)
|
||||
.filter((t) => t.length > 0),
|
||||
prompt: prompt,
|
||||
image_count: generationCount,
|
||||
frontend_caller: "image_editor",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
aspect_ratio: mapAspectRatio(options?.aspectRatio),
|
||||
image_resolution: mapResolution(options?.resolution),
|
||||
};
|
||||
if (options?.selectedProvider) {
|
||||
request.provider = options.selectedProvider;
|
||||
}
|
||||
if (selectedImageModel?.supportsNewAspectRatio()) {
|
||||
request.common_aspect_ratio = commonAspectRatio;
|
||||
}
|
||||
result = await EnqueueEditImage(request);
|
||||
} else {
|
||||
// CASE 3 - DEFAULT
|
||||
const imgs = options?.images || [];
|
||||
const request: EnqueueEditImageRequest = {
|
||||
model: selectedImageModel,
|
||||
image_media_tokens: [
|
||||
editedImageToken,
|
||||
...imgs
|
||||
.filter((img) => img.mediaToken !== editedImageToken)
|
||||
.map((img) => img.mediaToken),
|
||||
].filter((t) => t.length > 0),
|
||||
disable_system_prompt: true,
|
||||
prompt: prompt,
|
||||
image_count: generationCount,
|
||||
frontend_caller: "image_editor",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
aspect_ratio: mapAspectRatio(options?.aspectRatio),
|
||||
image_resolution: mapResolution(options?.resolution),
|
||||
};
|
||||
if (options?.selectedProvider) {
|
||||
request.provider = options.selectedProvider;
|
||||
}
|
||||
if (selectedImageModel?.supportsNewAspectRatio()) {
|
||||
request.common_aspect_ratio = commonAspectRatio;
|
||||
}
|
||||
result = await EnqueueEditImage(request);
|
||||
}
|
||||
|
||||
if (result?.status === "success") {
|
||||
setPendingGenerations((prev) => [
|
||||
...prev,
|
||||
{ id: subscriberId as string, count: generationCount },
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
setPendingGenerations((prev) =>
|
||||
prev.filter((p) => p.id !== subscriberId),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
generationCount,
|
||||
selectedImageModel,
|
||||
store.baseImageInfo?.mediaToken,
|
||||
referenceImages,
|
||||
],
|
||||
);
|
||||
|
||||
const isNanoBananaModel = selectedImageModel?.isNanoBananaModel();
|
||||
|
||||
const supportsMaskedInpainting =
|
||||
selectedImageModel?.usesInpaintingMask ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (isNanoBananaModel) {
|
||||
if (store.activeTool !== "marker" && store.activeTool !== "eraser") {
|
||||
store.setActiveTool("marker");
|
||||
}
|
||||
} else if (
|
||||
!supportsMaskedInpainting &&
|
||||
(store.activeTool !== "select" || store.lineNodes.length > 0)
|
||||
) {
|
||||
store.setActiveTool("select");
|
||||
store.clearLineNodes();
|
||||
} else if (
|
||||
supportsMaskedInpainting &&
|
||||
(store.activeTool === "select" || store.activeTool === "marker")
|
||||
) {
|
||||
store.setActiveTool("edit");
|
||||
}
|
||||
}, [store, supportsMaskedInpainting, isNanoBananaModel]);
|
||||
|
||||
/*
|
||||
*
|
||||
* Only component logic below this
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
// Display image selector on launch, otherwise hide it
|
||||
// Also show loading state if info is set but image is loading
|
||||
if (!store.baseImageInfo || !store.baseImageBitmap) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full items-center justify-center p-8"
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<BaseImageSelector
|
||||
onImageSelect={(image: BaseSelectorImage) => {
|
||||
addHistoryImageBundle({ images: [image] });
|
||||
store.setBaseImageInfo(image);
|
||||
}}
|
||||
showLoading={
|
||||
store.baseImageInfo !== null && store.baseImageInfo === null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 -z-10 bg-ui-background" />
|
||||
{(store.activeTool === "marker" || store.activeTool === "eraser") &&
|
||||
isNanoBananaModel && (
|
||||
<div
|
||||
className={`preserve-aspect-ratio fixed left-1/2 top-0 z-10 -translate-x-1/2 transform ${
|
||||
isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
<MarkerToolControlBar
|
||||
currentSize={
|
||||
store.activeTool === "eraser"
|
||||
? store.eraserBrushSize
|
||||
: store.markerBrushSize
|
||||
}
|
||||
currentColor={store.markerColor}
|
||||
activeTool={store.activeTool}
|
||||
showColorPicker={true}
|
||||
onSizeChange={
|
||||
store.activeTool === "eraser"
|
||||
? store.setEraserBrushSize
|
||||
: store.setMarkerBrushSize
|
||||
}
|
||||
onColorChange={store.setMarkerColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(store.activeTool === "edit" || store.activeTool === "eraser") &&
|
||||
supportsMaskedInpainting && (
|
||||
<div
|
||||
className={`preserve-aspect-ratio fixed left-1/2 top-0 z-10 -translate-x-1/2 transform ${
|
||||
isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
<MarkerToolControlBar
|
||||
currentSize={
|
||||
store.activeTool === "eraser"
|
||||
? store.eraserBrushSize
|
||||
: store.brushSize
|
||||
}
|
||||
currentColor={store.brushColor}
|
||||
activeTool={store.activeTool}
|
||||
showColorPicker={false}
|
||||
onSizeChange={
|
||||
store.activeTool === "eraser"
|
||||
? store.setEraserBrushSize
|
||||
: store.setBrushSize
|
||||
}
|
||||
onColorChange={store.setBrushColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`preserve-aspect-ratio fixed right-4 top-1/2 z-10 -translate-y-1/2 transform ${
|
||||
isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
<HistoryStack
|
||||
onClear={() => {
|
||||
store.RESET();
|
||||
setPendingGenerations([]);
|
||||
}}
|
||||
imageBundles={historyImageBundles}
|
||||
pendingPlaceholders={pendingGenerations}
|
||||
blurredBackgroundUrl={baseImageUrl}
|
||||
onImageSelect={(baseImage) => {
|
||||
store.clearLineNodes();
|
||||
store.setBaseImageInfo(baseImage);
|
||||
}}
|
||||
onImageRemove={(baseImage) => {
|
||||
if (
|
||||
pendingGenerations.length === 0 &&
|
||||
store.historyImageBundles.length === 1 &&
|
||||
store.historyImageBundles[0].images.length <= 1
|
||||
) {
|
||||
store.RESET();
|
||||
} else {
|
||||
removeHistoryImage(baseImage);
|
||||
}
|
||||
}}
|
||||
onNewImageBundle={(newBundle: ImageBundle) => {
|
||||
addHistoryImageBundle(newBundle);
|
||||
}}
|
||||
onResolvePending={(id: string) =>
|
||||
setPendingGenerations((prev) => prev.filter((p) => p.id !== id))
|
||||
}
|
||||
selectedImageToken={store.baseImageInfo?.mediaToken}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`preserve-aspect-ratio fixed bottom-0 left-1/2 z-10 -translate-x-1/2 transform ${
|
||||
isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
<PromptEditor
|
||||
selectedImageModel={selectedImageModel}
|
||||
selectedProvider={selectedProvider}
|
||||
commonAspectRatio={commonAspectRatio}
|
||||
setCommonAspectRatio={setCommonAspectRatio}
|
||||
onModeChange={(mode: string) => {
|
||||
store.setActiveTool(mode as ActiveEditTool);
|
||||
}}
|
||||
selectedMode={store.activeTool}
|
||||
onGenerateClick={handleGenerate}
|
||||
onFitPressed={onFitPressed}
|
||||
isDisabled={false}
|
||||
generationCount={generationCount}
|
||||
onGenerationCountChange={setGenerationCount}
|
||||
supportsMaskedInpainting={supportsMaskedInpainting}
|
||||
isNanoBananaModel={isNanoBananaModel}
|
||||
onUndo={store.undo}
|
||||
onRedo={store.redo}
|
||||
credits={imageCredits}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-0">
|
||||
<ContextMenuContainer
|
||||
onAction={(e, action) => {
|
||||
if (action === "contextMenu") {
|
||||
const hasSelection = store.selectedNodeIds.length > 0;
|
||||
if (hasSelection) {
|
||||
console.log("An item is selected.");
|
||||
// You can add additional actions here based on the selection
|
||||
return true;
|
||||
} else {
|
||||
console.log("No item is selected.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
onMenuAction={async (action) => {
|
||||
switch (action) {
|
||||
case "DUPLICATE":
|
||||
store.copySelectedItems();
|
||||
store.pasteItems();
|
||||
break;
|
||||
case "DELETE":
|
||||
store.deleteSelectedItems();
|
||||
break;
|
||||
default:
|
||||
// No action needed for unhandled cases
|
||||
}
|
||||
}}
|
||||
isLocked={store.selectedNodeIds.some((id) => {
|
||||
const node = store.nodes.find((n) => n.id === id);
|
||||
const lineNode = store.lineNodes.find((n) => n.id === id);
|
||||
return (node?.locked || lineNode?.locked) ?? false;
|
||||
})}
|
||||
>
|
||||
<EditPaintSurface
|
||||
nodes={store.nodes}
|
||||
selectedNodeIds={store.selectedNodeIds}
|
||||
onCanvasSizeChange={(width: number, height: number): void => {
|
||||
canvasWidth.current = width;
|
||||
canvasHeight.current = height;
|
||||
}}
|
||||
//fillColor={store.fillColor}
|
||||
activeTool={store.activeTool}
|
||||
brushColor={"rgba(39, 187, 245, 0.54)"}
|
||||
brushSize={store.brushSize}
|
||||
markerBrushSize={store.markerBrushSize}
|
||||
eraserBrushSize={store.eraserBrushSize}
|
||||
markerColor={store.markerColor}
|
||||
onSelectionChange={setIsSelecting}
|
||||
stageRef={stageRef}
|
||||
transformerRefs={transformerRefs}
|
||||
leftPanelRef={leftPanelRef}
|
||||
baseImageRef={baseImageKonvaRef}
|
||||
/>
|
||||
</ContextMenuContainer>
|
||||
</div>
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={IMAGE_EDITOR_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-base-fg opacity-80 hover:opacity-100"
|
||||
showIconsInList
|
||||
triggerLabel="Model"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<CostCalculatorButton modelPage={PAGE_ID} />
|
||||
<HelpMenuButton />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageEdit;
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from "react";
|
||||
import { PromptBoxEdit, PromptBoxEditProps } from "@storyteller/ui-promptbox";
|
||||
import { uploadImage } from "../../../components/reusable/UploadModalMedia/uploadImage";
|
||||
|
||||
const PromptEditor: React.FC<PromptBoxEditProps> = (props) => {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col space-y-2">
|
||||
<PromptBoxEdit {...props} uploadImage={uploadImage} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(PromptEditor);
|
||||
@@ -1,43 +0,0 @@
|
||||
export type AspectRatio = '1:1' | '3:2' | '2:3';
|
||||
|
||||
export interface ImageStyle {
|
||||
id: string;
|
||||
url: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface PromptTextAreaProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
images: ImageStyle[];
|
||||
onImageUpdate: (id: string, updates: Partial<ImageStyle>) => void;
|
||||
onImageRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
export interface AIStrengthSliderProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
inset?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ControlButtonsProps {
|
||||
aspectRatio: AspectRatio;
|
||||
onAspectRatioChange: (ratio: AspectRatio) => void;
|
||||
onStyleClick: () => void;
|
||||
onImageStyleClick: () => void;
|
||||
onRandomizeClick: () => void;
|
||||
onVaryClick: () => void;
|
||||
}
|
||||
|
||||
export interface ImageStyleSelectorProps {
|
||||
onImageSelect: (image: File) => void;
|
||||
}
|
||||
|
||||
export interface ImagePreviewProps {
|
||||
image: ImageStyle;
|
||||
onUpdate: (id: string, updates: Partial<ImageStyle>) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -86,7 +86,6 @@ import {
|
||||
import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal";
|
||||
import PageDraw from "../PageDraw/PageDraw";
|
||||
import { useTabStore } from "../Stores/TabState";
|
||||
import PageEdit from "../PageEdit/PageEdit";
|
||||
import { ImageModel } from "@storyteller/model-list";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Main component
|
||||
export { default as PageDraw } from "./lib/PageDraw";
|
||||
export { BlankCanvasModal } from "./lib/BlankCanvasModal";
|
||||
export { HistoryStack } from "./lib/HistoryStack";
|
||||
|
||||
// Store
|
||||
export { useSceneStore, generateId } from "./lib/stores/SceneState";
|
||||
|
||||
Reference in New Issue
Block a user