2D: Add blank canvas option with preset sizes (#1275)

This commit is contained in:
Tanish Manku
2026-03-31 15:28:22 -07:00
committed by GitHub
parent 2f73e861d1
commit 11011dacaf
10 changed files with 379 additions and 33 deletions
@@ -19,6 +19,9 @@ interface UploadEntryCardProps {
secondaryLabel?: string;
secondaryIcon?: IconDefinition;
onSecondaryClick?: () => void;
tertiaryLabel?: string;
tertiaryIcon?: IconDefinition;
onTertiaryClick?: () => void;
disabled?: boolean;
}
@@ -36,6 +39,9 @@ export const UploadEntryCard = ({
secondaryLabel,
secondaryIcon,
onSecondaryClick,
tertiaryLabel,
tertiaryIcon,
onTertiaryClick,
disabled,
}: UploadEntryCardProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -162,6 +168,17 @@ export const UploadEntryCard = ({
{secondaryLabel}
</Button>
)}
{tertiaryLabel && onTertiaryClick && (
<Button
variant="action"
icon={tertiaryIcon}
onClick={onTertiaryClick}
className="border-2 px-8 py-3 text-base font-semibold"
disabled={disabled}
>
{tertiaryLabel}
</Button>
)}
</div>
</div>
</div>
@@ -0,0 +1,177 @@
import { faExpand, faImages, faPencil } from "@fortawesome/pro-solid-svg-icons";
import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { useState } from "react";
import toast from "react-hot-toast";
import { uploadImage } from "~/components/reusable/UploadModalMedia/uploadImage";
import { UploaderStates } from "~/enums";
import { MediaFilesApi } from "@storyteller/api";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
import { ModelPage } from "@storyteller/ui-model-selector";
import { UploadEntryCard } from "~/components/media/UploadEntryCard";
import { BlankCanvasModal, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
const MAX_GALLERY_SELECTIONS = 1;
type BaseImageSelectorProps = {
onImageSelect: (image: BaseSelectorImage) => void;
showLoading?: boolean;
};
export const BaseImageSelector = ({
onImageSelect,
showLoading = false,
}: BaseImageSelectorProps) => {
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isBlankCanvasModalOpen, setIsBlankCanvasModalOpen] = useState(false);
const handleGalleryClick = () => setIsGalleryModalOpen(true);
const handleGalleryClose = () => {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
const handleImageSelect = (mediaToken: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(mediaToken)) return prev.filter((x) => x !== mediaToken);
if (prev.length >= MAX_GALLERY_SELECTIONS) {
return MAX_GALLERY_SELECTIONS === 1 ? [mediaToken] : prev;
}
return [...prev, mediaToken];
});
};
const handleUseGalleryImages = (selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item || !item.fullImage) {
toast.error("No image selected");
return;
}
const referenceImage: BaseSelectorImage = {
url: item.fullImage,
mediaToken: item.id,
thumbnailUrlTemplate: item.thumbnailUrlTemplate,
};
handleGalleryClose();
onImageSelect(referenceImage);
};
const handleBlankCanvasConfirm = (width: number, height: number) => {
setIsBlankCanvasModalOpen(false);
const blankImage: BaseSelectorImage = {
url: "",
mediaToken: `blank_canvas_${width}x${height}_${Math.random().toString(36).substring(2, 8)}`,
isBlankCanvas: true,
blankCanvasWidth: width,
blankCanvasHeight: height,
};
onImageSelect(blankImage);
};
const handleFileUpload = (files: FileList) => {
setIsLoading(true);
Array.from(files).forEach((file) => {
const reader = new FileReader();
reader.onloadend = () => {
uploadImage({
title: `reference-image-${Math.random().toString(36).substring(2, 15)}`,
assetFile: file,
progressCallback: (newState) => {
if (newState.status === UploaderStates.success && newState.data) {
const mediaToken = newState.data || "";
(async () => {
let finalUrl = reader.result as string;
let thumbnailUrlTemplate = undefined;
try {
const api = new MediaFilesApi();
const result = await api.GetMediaFileByToken({ mediaFileToken: mediaToken });
if (result.success && result.data) {
finalUrl =
result.data.media_links?.cdn_url ||
result.data.public_bucket_url ||
finalUrl;
const mediaLinks = result.data.media_links as
| { thumbnail_template?: string; maybe_thumbnail_template?: string }
| undefined;
thumbnailUrlTemplate =
mediaLinks?.thumbnail_template || mediaLinks?.maybe_thumbnail_template;
}
} catch (e) {
console.warn("Falling back to data URL for uploaded image", e);
}
const referenceImage: BaseSelectorImage = {
mediaToken,
url: finalUrl,
fullImageUrl: finalUrl,
thumbnailUrlTemplate,
};
toast.success("Image uploaded successfully!");
onImageSelect(referenceImage);
setIsLoading(false);
})();
} else if (
newState.status === UploaderStates.assetError ||
newState.status === UploaderStates.imageCreateError
) {
toast.error("Upload failed. Please try again.");
setIsLoading(false);
}
},
});
};
reader.readAsDataURL(file);
});
};
return (
<>
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-ui-panel text-base-fg">
<div className="aspect-video w-full max-w-5xl bg-ui-background">
<UploadEntryCard
icon={faPencil}
title="Edit Image"
description="Click to upload or drag and drop an image here to edit"
accentBackgroundClass="bg-blue-500/40"
accentBorderClass="border-blue-400/30"
accept="image/*"
multiple
onFilesSelected={handleFileUpload}
primaryLabel="Select Image"
secondaryLabel="Pick from Library"
secondaryIcon={faImages}
onSecondaryClick={handleGalleryClick}
tertiaryLabel="Blank Canvas"
tertiaryIcon={faExpand}
onTertiaryClick={() => setIsBlankCanvasModalOpen(true)}
disabled={isLoading || showLoading}
/>
</div>
</div>
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={ModelPage.ImageEditor} />
<HelpMenuButton />
</div>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={MAX_GALLERY_SELECTIONS}
onUseSelected={handleUseGalleryImages}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
<BlankCanvasModal
isOpen={isBlankCanvasModalOpen}
onClose={() => setIsBlankCanvasModalOpen(false)}
onConfirm={handleBlankCanvasConfirm}
/>
</>
);
};
@@ -18,9 +18,7 @@ import {
} from "@storyteller/tauri-api";
import { useImageEditCompleteEvent } from "@storyteller/tauri-events";
import { UploadImageMedia } from "@storyteller/api";
import {
BaseImageSelector,
} from "../PageEdit/BaseImageSelector";
import { BaseImageSelector } from "./BaseImageSelector";
// ─── Aspect ratio / resolution mappers ────────────────────────────────────────
const mapAspectRatio = (ratio?: string): EnqueueEditImageSize | undefined => {
@@ -2,6 +2,7 @@
// Main component
export { default as PageDraw } from "./lib/PageDraw";
export { BlankCanvasModal } from "./lib/BlankCanvasModal";
// Store
export { useSceneStore, generateId } from "./lib/stores/SceneState";
@@ -0,0 +1,123 @@
import { useState } from "react";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { twMerge } from "tailwind-merge";
interface BlankCanvasModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (width: number, height: number) => void;
}
interface ResolutionPreset {
label: string;
width: number;
height: number;
}
const PRESETS: { group: string; items: ResolutionPreset[] }[] = [
{
group: "Square",
items: [
{ label: "512 × 512", width: 512, height: 512 },
{ label: "768 × 768", width: 768, height: 768 },
{ label: "1024 × 1024", width: 1024, height: 1024 },
{ label: "1536 × 1536", width: 1536, height: 1536 },
],
},
{
group: "Landscape",
items: [
{ label: "HD 1280 × 720", width: 1280, height: 720 },
{ label: "Full HD 1920 × 1080", width: 1920, height: 1080 },
{ label: "QHD 2560 × 1440", width: 2560, height: 1440 },
],
},
{
group: "Portrait",
items: [
{ label: "720 × 1280", width: 720, height: 1280 },
{ label: "1080 × 1920", width: 1080, height: 1920 },
],
},
];
export const BlankCanvasModal = ({
isOpen,
onClose,
onConfirm,
}: BlankCanvasModalProps) => {
const [selected, setSelected] = useState<ResolutionPreset | null>(null);
const handleConfirm = () => {
if (!selected) return;
onConfirm(selected.width, selected.height);
setSelected(null);
};
const handleClose = () => {
setSelected(null);
onClose();
};
return (
<Modal
isOpen={isOpen}
title="Choose Canvas Size"
onClose={handleClose}
width={560}
>
<div className="flex flex-col gap-5 p-1">
{PRESETS.map((group) => (
<div key={group.group}>
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-base-fg/50">
{group.group}
</p>
<div className="flex flex-wrap gap-2">
{group.items.map((preset) => {
const isSelected =
selected?.width === preset.width &&
selected?.height === preset.height;
const maxW = 56;
const maxH = 36;
const scale = Math.min(maxW / preset.width, maxH / preset.height);
const previewW = Math.round(preset.width * scale);
const previewH = Math.round(preset.height * scale);
return (
<button
key={`${preset.width}x${preset.height}`}
onClick={() => setSelected(preset)}
className={twMerge(
"flex flex-col items-center gap-2 rounded-xl border-2 bg-ui-panel px-4 py-3 text-sm font-medium text-base-fg transition-colors hover:border-primary/60",
isSelected
? "border-primary bg-primary/10"
: "border-ui-panel-border",
)}
>
<div
className="rounded-sm border border-base-fg/20 bg-white"
style={{ width: previewW, height: previewH }}
/>
<span className="whitespace-nowrap text-xs">
{preset.label}
</span>
</button>
);
})}
</div>
</div>
))}
<div className="mt-2 flex justify-end gap-3">
<Button variant="action" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" onClick={handleConfirm} disabled={!selected}>
Create Canvas
</Button>
</div>
</div>
</Modal>
);
};
@@ -193,11 +193,15 @@ export const HistoryStack = ({
)}
onClick={() => handleSelectWithPreload(image)}
>
<img
src={getImageThumbnailSource(image)}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
{image.isBlankCanvas ? (
<div className="absolute inset-0 h-full w-full bg-white" />
) : (
<img
src={getImageThumbnailSource(image)}
alt=""
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) => {
@@ -563,9 +563,8 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
}, [getAspectRatioDimensions, createImageFromFile]);
const getCompositeCanvasFile = useCallback(async (): Promise<File | null> => {
if (!stageRef.current || !baseImageKonvaRef.current || !baseImageBitmap) {
return null;
}
if (!stageRef.current || !baseImageKonvaRef.current) return null;
if (!baseImageInfo?.isBlankCanvas && !baseImageBitmap) return null;
const editsLayer = stageRef.current
.getLayers()
@@ -584,7 +583,12 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(baseImageBitmap, 0, 0, width, height);
if (baseImageBitmap) {
ctx.drawImage(baseImageBitmap, 0, 0, width, height);
} else {
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, height);
}
const markerLayerCanvas = editsLayer.toCanvas({
x: stageRef.current.x(),
@@ -603,7 +607,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
const blob = await canvas.convertToBlob({ type: "image/png" });
const uuid = crypto.randomUUID();
return new File([blob], `${uuid}.png`, { type: "image/png" });
}, [baseImageBitmap]);
}, [baseImageBitmap, baseImageInfo]);
const handleGenerate = useCallback(
async (
@@ -778,7 +782,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
}, [activeTool, supportsMaskedInpainting]);
useEffect(() => {
if (!baseImageBitmap) {
if (!baseImageBitmap && !baseImageInfo?.isBlankCanvas) {
return;
}
@@ -807,7 +811,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
autoFitCanvas();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseImageBitmap]);
}, [baseImageBitmap, baseImageInfo?.isBlankCanvas]);
const handleSelectTool = useCallback(() => setActiveTool("select"), [setActiveTool]);
@@ -930,8 +934,9 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
[editing3DNodeId, drawNodes],
);
// Display image selector on launch, otherwise hide it
if (!baseImageInfo || !baseImageBitmap) {
// Display image selector on launch, otherwise hide it.
// Also show the selector when a non-blank-canvas image is set but the bitmap is still loading.
if (!baseImageInfo || (!baseImageBitmap && !baseImageInfo.isBlankCanvas)) {
return (
<div
className={
@@ -945,7 +950,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
addHistoryImageBundle({ images: [image] });
setBaseImageInfo(image);
},
showLoading: baseImageInfo !== null && baseImageBitmap === null,
showLoading: baseImageInfo !== null && baseImageBitmap === null && !baseImageInfo.isBlankCanvas,
})}
</div>
</div>
@@ -1684,6 +1684,16 @@ export const PaintSurface = ({
zIndex={-1}
/>
{!baseImageBitmap && (
<Rect
x={0}
y={0}
width={getAspectRatioDimensions().width}
height={getAspectRatioDimensions().height}
fill="white"
listening={false}
/>
)}
<Image
ref={baseImageRef}
x={0}
@@ -1343,10 +1343,15 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
},
getAspectRatioDimensions: () => {
const baseImageInfo = get().baseImageBitmap;
const bitmap = get().baseImageBitmap;
if (baseImageInfo) {
return { width: baseImageInfo.width, height: baseImageInfo.height };
if (bitmap) {
return { width: bitmap.width, height: bitmap.height };
}
const info = get().baseImageInfo;
if (info?.isBlankCanvas && info.blankCanvasWidth && info.blankCanvasHeight) {
return { width: info.blankCanvasWidth, height: info.blankCanvasHeight };
}
const { aspectRatioType } = get();
@@ -1403,13 +1408,9 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
});
}
set({ baseImageInfo: image });
const imgBitmap = new Image();
imgBitmap.onload = () => {
const applyImageNodes = (bitmap: HTMLImageElement | null) => {
const previousNodeData = get().historyImageNodeMap.get(image);
if (previousNodeData) {
// Restore draw nodes, re-instantiating Node objects
const restoredDrawNodes = previousNodeData.drawNodes.map((nodeData) => {
if (nodeData.type === "line") return nodeData as LineNode;
const node = new Node(nodeData as SerializedNodeData);
@@ -1421,16 +1422,23 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
}
return node;
});
set({
drawNodes: restoredDrawNodes,
inpaintLineNodes: previousNodeData.inpaintLineNodes,
});
set({ drawNodes: restoredDrawNodes, inpaintLineNodes: previousNodeData.inpaintLineNodes, baseImageBitmap: bitmap });
} else {
set({ drawNodes: [], inpaintLineNodes: [] });
set({ drawNodes: [], inpaintLineNodes: [], baseImageBitmap: bitmap });
}
};
set({ baseImageBitmap: imgBitmap });
if (image.isBlankCanvas) {
set({ baseImageInfo: image });
applyImageNodes(null);
return;
}
set({ baseImageInfo: image });
const imgBitmap = new Image();
imgBitmap.onload = () => {
applyImageNodes(imgBitmap);
};
imgBitmap.onerror = (event) => {
console.error("Failed to load base image, discarding", event);
@@ -5,6 +5,9 @@ export interface BaseSelectorImage {
mediaToken: string;
thumbnailUrlTemplate?: string;
fullImageUrl?: string;
isBlankCanvas?: boolean;
blankCanvasWidth?: number;
blankCanvasHeight?: number;
}
export interface ImageBundle {