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:
Tanish Manku
2026-04-01 09:29:17 -07:00
committed by GitHub
parent 3bc55c761c
commit bbfea2ae96
13 changed files with 3 additions and 4045 deletions
@@ -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";