inpainting history styling (#782)

- history stack styling
- selected image highlighted
- left reset all button with confirm modal before action
This commit is contained in:
BFlat authored and GitHub committed 2025-08-25 23:14:46 +07:00
1 parent 22ab666ab5
commit 5afaecd282
6 files changed
+220 -57

No files matched your search

@@ -22,6 +22,7 @@ import {
GalleryModal,
galleryModalVisibleViewMode,
galleryModalVisibleDuringDrag,
galleryModalLightboxVisible,
} from "@storyteller/ui-gallery-modal";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip";
@@ -48,6 +49,8 @@ import {
useTauriWindowControls,
useTauriPlatform,
} from "@storyteller/tauri-utils";
import { useEditStore } from "../../../pages/PageEdit/stores/EditState";
import { BaseSelectorImage } from "../../../pages/PageEdit/BaseImageSelector";
interface Props {
pageName: string;
@@ -137,6 +140,30 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
}
};
const handleEditFromGallery = async (url: string, mediaId?: string) => {
try {
// Reset editor state
useEditStore.getState().RESET();
// Switch to EDIT tab
useTabStore.getState().setActiveTab("EDIT");
// Select the image for editing
const baseImage: BaseSelectorImage = {
url,
mediaToken: mediaId || "",
};
useEditStore.getState().setBaseImageInfo(baseImage);
// Close gallery modal and lightbox if open
galleryModalVisibleViewMode.value = false;
galleryModalVisibleDuringDrag.value = false;
galleryModalLightboxVisible.value = false;
} catch (e) {
// no-op
}
};
const getPageTitle = (): string => {
switch (tabStore.activeTabId) {
case "2D":
@@ -171,7 +198,9 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
>
<div className="flex items-center gap-3" data-tauri-drag-region>
<div className="mr-2" data-tauri-drag-region>
<span className="sr-only" data-tauri-drag-region>ArtCraft</span>
<span className="sr-only" data-tauri-drag-region>
ArtCraft
</span>
<img
className="h-[24px] w-auto"
src="/resources/images/artcraft-logo-3.png"
@@ -295,7 +324,11 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
globalAccountLogoutCallback={() => setLogoutStates()}
/>
<GalleryModal mode="view" onDownloadClicked={downloadFile} />
<GalleryModal
mode="view"
onDownloadClicked={downloadFile}
onEditClicked={handleEditFromGallery}
/>
</>
);
};
@@ -1,7 +1,8 @@
import { Button } from "@storyteller/ui-button";
import { EditedImage, useImageEditCompleteEvent } from "@storyteller/tauri-events";
import { useImageEditCompleteEvent } from "@storyteller/tauri-events";
import { faTrash } from "@fortawesome/pro-solid-svg-icons";
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import { BaseSelectorImage } from "./BaseImageSelector";
export interface ImageBundle {
@@ -16,43 +17,74 @@ interface HistoryStackProps {
export const HistoryStack = ({
onClear,
onImageSelect = () => { },
onImageSelect = () => {},
startingBundles = [],
}: HistoryStackProps) => {
const [imageBundles, setImageBundles] = useState<ImageBundle[]>(startingBundles);
const [imageBundles, setImageBundles] =
useState<ImageBundle[]>(startingBundles);
const [selectedImageToken, setSelectedImageToken] = useState<string | null>(
startingBundles.length > 0 && startingBundles[0].images.length > 0
? startingBundles[0].images[0].mediaToken
: null,
);
useImageEditCompleteEvent(async (event) => {
const newBundle: ImageBundle = {
images: event.edited_images.map((editedImage) => ({
url: editedImage.cdn_url,
mediaToken: editedImage.media_token,
} as BaseSelectorImage)),
images: event.edited_images.map(
(editedImage) =>
({
url: editedImage.cdn_url,
mediaToken: editedImage.media_token,
}) as BaseSelectorImage,
),
};
imageBundles.push(newBundle);
setImageBundles(imageBundles);
setImageBundles([...imageBundles, newBundle]);
const newlySelected = newBundle.images[0];
if (newlySelected) {
setSelectedImageToken(newlySelected.mediaToken);
onImageSelect(newlySelected);
}
});
const handleClear = () => {
setImageBundles(startingBundles);
setSelectedImageToken(null);
onClear();
}
};
return (
<div className="w-16 h-auto max-h-1/2 overflow-y-auto rounded-lg bg-white">
<div className="flex flex-col-reverse items-center justify-center">
<Button icon={faTrash} type="reset" onClick={handleClear} />
<div className="max-h-1/2 glass h-auto w-16 overflow-y-auto rounded-lg p-1">
<div className="flex flex-col-reverse items-center justify-center gap-2">
<Button
className="h-7 w-full rounded-md opacity-80"
iconClassName="h-3 w-3"
icon={faTrash}
type="reset"
onClick={handleClear}
variant="destructive"
/>
{imageBundles.map((bundle) => (
<>
<hr className="border-none bg-red w-full rounded-md h-2" />
<hr className="h-0.5 w-3/4 rounded-md border-none bg-white/10" />
{bundle.images.map((image, imgIndex) => (
<Button key={imgIndex} className="w-full h-24" onClick={() => { onImageSelect(image) }}>
<Button
key={imgIndex}
className={twMerge(
"aspect-square h-full w-full border-2 border-white bg-transparent p-0 hover:bg-transparent hover:opacity-80",
selectedImageToken === image.mediaToken &&
"border-primary hover:opacity-100",
)}
onClick={() => {
setSelectedImageToken(image.mediaToken);
onImageSelect(image);
}}
>
{/* TODO: Fix CORS issue here */}
<img
src={image.url + "?historystack+" + Math.random()}
alt=""
crossOrigin="anonymous"
className="w-full h-full rounded-lg"
className="h-full w-full rounded-lg object-cover"
/>
</Button>
))}
@@ -61,4 +93,4 @@ export const HistoryStack = ({
</div>
</div>
);
}
};
@@ -0,0 +1,61 @@
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="flex h-10 w-10 items-center justify-center rounded-lg border-2 border-transparent text-white 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-sm text-white/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;
@@ -23,6 +23,7 @@ import {
import { ModelInfo } from "@storyteller/model-list";
import { useImageEditCompleteEvent } from "@storyteller/tauri-events";
import { HistoryStack, ImageBundle } from "./HistoryStack";
import LeftResetStack from "./LeftResetStack";
const PAGE_ID: ModelPage = ModelPage.ImageEditor;
@@ -140,7 +141,6 @@ const PageEdit = () => {
};
}, [store]);
const onFitPressed = useCallback(async () => {
// Get the stage and its container dimensions
const stage = stageRef.current;
@@ -188,7 +188,6 @@ const PageEdit = () => {
// When baseImageBitmap changes from null to an actual image, call onFitPressed
// Use a slight delay to ensure the image dimensions are properly loaded
onFitPressed();
}, [onFitPressed, store.baseImageBitmap]);
// Create a function to use the left layer ref and download the bitmap from it
@@ -228,37 +227,45 @@ const PageEdit = () => {
return new Uint8Array(arrayBuffer);
};
const handleGenerate = useCallback(async (prompt: string) => {
if (isEnqueuing) {
console.warn("Already enqueuing an image, please wait.");
return;
}
const handleGenerate = useCallback(
async (prompt: string) => {
if (isEnqueuing) {
console.warn("Already enqueuing an image, please wait.");
return;
}
setIsEnqueuing(true);
setIsEnqueuing(true);
const editedImageToken = store.baseImageInfo?.mediaToken;
const editedImageToken = store.baseImageInfo?.mediaToken;
if (!editedImageToken) {
console.error("Base image is not available");
return;
}
if (!editedImageToken) {
console.error("Base image is not available");
return;
}
// TODO: Call inference API here
const arrayBuffer = await getMaskArrayBuffer();
// TODO: Call inference API here
const arrayBuffer = await getMaskArrayBuffer();
try {
await EnqueueImageInpaint({
model: selectedModelInfo,
image_media_token: editedImageToken,
mask_image_raw_bytes: arrayBuffer,
prompt: prompt,
image_count: generationCount,
frontend_caller: "image_editor",
});
} finally {
setIsEnqueuing(false);
}
}, [generationCount, isEnqueuing, selectedModelInfo, store.baseImageInfo?.mediaToken]);
try {
await EnqueueImageInpaint({
model: selectedModelInfo,
image_media_token: editedImageToken,
mask_image_raw_bytes: arrayBuffer,
prompt: prompt,
image_count: generationCount,
frontend_caller: "image_editor",
});
} finally {
setIsEnqueuing(false);
}
},
[
generationCount,
isEnqueuing,
selectedModelInfo,
store.baseImageInfo?.mediaToken,
],
);
// Display image selector on launch, otherwise hide it
// Also show loading state if info is set but image is loading
@@ -284,8 +291,9 @@ const PageEdit = () => {
return (
<>
<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"
}`}
className={`preserve-aspect-ratio fixed left-1/2 top-0 z-10 -translate-x-1/2 transform ${
isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
style={{ display: store.activeTool === "edit" ? "block" : "none" }}
>
<DrawToolControlBar
@@ -296,21 +304,32 @@ const PageEdit = () => {
/>
</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"
}`}
className={`preserve-aspect-ratio fixed left-4 top-1/2 z-10 -translate-y-1/2 transform ${
isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
>
<LeftResetStack onReset={() => store.RESET()} />
</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(); }}
onClear={() => {
store.RESET();
}}
startingBundles={[{ images: [store.baseImageInfo] } as ImageBundle]}
onImageSelect={(baseImage) => {
store.clearLineNodes();
store.setBaseImageInfo(baseImage)
store.setBaseImageInfo(baseImage);
}}
/>
</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"
}`}
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
modelInfo={selectedModelInfo}
@@ -72,6 +72,7 @@ interface GalleryModalProps {
) => Promise<void>;
isOpen?: boolean;
forceFilter?: string;
onEditClicked?: (url: string, media_id?: string) => Promise<void> | void;
}
export const GalleryModal = React.memo(
@@ -86,6 +87,7 @@ export const GalleryModal = React.memo(
onAddToSceneClicked,
isOpen,
forceFilter,
onEditClicked,
}: GalleryModalProps) => {
const [loading, setLoading] = useState(false);
// Lightbox state is now handled via signals
@@ -658,6 +660,7 @@ export const GalleryModal = React.memo(
onDownloadClicked={onDownloadClicked}
onAddToSceneClicked={onAddToSceneClicked}
mediaClass={lightboxImageSignal.value?.mediaClass}
onEditClicked={onEditClicked}
/>
)}
</>
@@ -1,7 +1,7 @@
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import dayjs from "dayjs";
import { faDownToLine } from "@fortawesome/pro-solid-svg-icons";
import { faDownToLine, faPencil } from "@fortawesome/pro-solid-svg-icons";
import {
EnqueueImageTo3dObject,
EnqueueImageTo3dObjectModel,
@@ -40,6 +40,7 @@ interface LightboxModalProps {
) => Promise<void>;
mediaClass?: string;
onPromptCopy?: (prompt: string) => void;
onEditClicked?: (url: string, media_id?: string) => Promise<void> | void;
}
export function LightboxModal({
@@ -57,6 +58,7 @@ export function LightboxModal({
onDownloadClicked,
onAddToSceneClicked,
mediaClass,
onEditClicked,
}: LightboxModalProps) {
// NB(bt,2025-06-14): We add ?cors=1 to the image url to prevent caching "sec-fetch-mode: no-cors" from
// the <image> tag request from being cached. If we then drag it into the canvas after it's been cached,
@@ -402,6 +404,19 @@ export function LightboxModal({
3D
</Button>
{onEditClicked && downloadUrl && (
<Button
icon={faPencil}
onClick={async (e) => {
e.stopPropagation();
gtagEvent("edit_image_clicked");
await onEditClicked(downloadUrl, mediaId);
}}
>
Edit
</Button>
)}
{onAddToSceneClicked && downloadUrl && (
<Button
onClick={async (e) => {