Compare commits

...
Author SHA1 Message Date
Tanish Manku b96dc7ec18 move blank canvas modal ownership to BaseImageSelector
BlankCanvasModal is now self-contained within BaseImageSelector — it
manages its own modal state, creates the blank BaseSelectorImage on
confirm, and calls onImageSelect like any other selection. PageDraw and
the adapter no longer need to know about blank canvas at all.

Also fixes invalid JSX from the previous placement of BlankCanvasModal
as a sibling outside the early-return's root element.

- adapter.ts: remove onBlankCanvasClick from renderBaseImageSelector props
- PageDraw.tsx (lib): remove modal state, import, and call-site reference
- index.ts: export BlankCanvasModal for app consumption
- BaseImageSelector.tsx (app): own modal state + confirm logic; import BlankCanvasModal from lib
- PageDraw.tsx (app): remove onBlankCanvasClick pass-through from adapter
2026-03-31 15:13:01 -07:00
Tanish Manku d56967ea80 add blank canvas option to PageDraw
Adds a "Blank Canvas" third option alongside the existing upload and
gallery buttons. The user picks a resolution from a preset grid modal
(Square, Landscape, Portrait) and the canvas opens at that size with a
white background — no image is loaded or stored.

- types.ts: add isBlankCanvas/blankCanvasWidth/blankCanvasHeight to BaseSelectorImage
- adapter.ts: pass onBlankCanvasClick callback to renderBaseImageSelector
- BlankCanvasModal.tsx: new resolution picker modal in the lib
- PageDraw.tsx (lib): manage modal state; fix gate/loading/auto-fit/composite export for null bitmap
- SceneState.ts: blank canvas branch in setBaseImageInfo skips bitmap load; getAspectRatioDimensions uses blankCanvas dimensions; extract applyImageNodes helper to remove duplication
- PaintSurface.tsx: white Konva Rect background when baseImageBitmap is null
- HistoryStack.tsx: CSS white div thumbnail for blank canvas entries
- UploadEntryCard.tsx: add optional tertiary button
- BaseImageSelector.tsx (app): new file co-located with adapter, replaces PageEdit import, wires onBlankCanvasClick tertiary button
2026-03-31 15:06:32 -07:00
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 {