Compare commits

...
16 changed files with 2797 additions and 8 deletions
@@ -0,0 +1,14 @@
export const normalizeCanvas = (canvas: HTMLCanvasElement, width: number, height: number): HTMLCanvasElement => {
const newCanvas = document.createElement("canvas");
newCanvas.width = width;
newCanvas.height = height;
const ctx = newCanvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get canvas context");
}
ctx.imageSmoothingEnabled = true;
ctx.drawImage(canvas, 0, 0, width, height);
return newCanvas;
}
@@ -6,6 +6,7 @@ import {
faFilm,
faPaintbrush,
faImage,
faPenNib,
// faGem,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
@@ -73,6 +74,11 @@ const appMenuTabs: MenuIconItem[] = [
label: "Image to Video",
icon: <FontAwesomeIcon icon={faFilm} />,
},
{
id: "EDIT",
label: "Edit Image",
icon: <FontAwesomeIcon icon={faPenNib} />,
}
];
export const topNavMediaId = signal<string>("");
@@ -19,7 +19,7 @@ import { useRightPanelLayoutManagement } from "./hooks/useRightPanelLayoutManage
import { useStageCentering } from "./hooks/useCenteredStage";
import { useGlobalMouseUp } from "./hooks/useGlobalMouseUp";
type MiraiProps = {
export type MiraiProps = {
nodes: Node[];
selectedNodeIds: string[];
onCanvasSizeChange?: (width: number, height: number) => void;
@@ -19,6 +19,7 @@ export type LineNode = {
offsetY?: number;
zIndex: number;
locked?: boolean; // Add locked property
globalCompositeOperation?: string; // Add globalCompositeOperation property
};
// Add this enum at the top of the file with other types
@@ -30,7 +31,7 @@ export enum AspectRatioType {
}
// Logic to remove background from image nodes would go here
const convertFileToBase64 = (file: File): Promise<string> => {
export const convertFileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -114,6 +115,7 @@ interface SceneState {
shouldSaveState: boolean,
) => void;
selectNode: (id: string | null, isMultiSelect?: boolean) => void;
moveNode: (
id: string,
x: number,
@@ -135,6 +137,7 @@ interface SceneState {
fill?: string,
) => void;
createCircle: (x: number, y: number, radius?: number, fill?: string) => void;
createTriangle: (
x: number,
y: number,
@@ -248,7 +251,7 @@ interface SceneState {
getAspectRatioDimensions: () => { width: number; height: number };
}
const generateId = (): string => {
export const generateId = (): string => {
return Math.random().toString(36).substring(2, 9);
};
@@ -273,6 +276,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
clipboard: [],
history: [],
historyIndex: -1,
activeTool: "select",
brushColor: "#000000",
brushSize: 5,
@@ -280,6 +284,8 @@ export const useSceneStore = create<SceneState>((set, get) => ({
fillColor: "white",
currentShape: null,
shapeColor: "#4d79b3",
// Cursor initial state
cursorPosition: null,
cursorVisible: false,
@@ -296,6 +302,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
zIndex: nextZ,
});
// console.log("New Node with ID:", newNode.id);
const nodes = [...state.nodes, newNode];
// console.log("Nodes after update");
// console.log(nodes);
@@ -307,6 +314,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
removeNode: (id: string, shouldSaveState: boolean = true) => {
set((state) => {
// Remove the node
const newNodes = state.nodes.filter((node) => node.id !== id);
// Update selection state
@@ -324,6 +332,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
}
},
updateNode: (
id: string,
updates: Partial<Node>,
@@ -0,0 +1,171 @@
import { faImage } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi";
import { Button } from "@storyteller/ui-button";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { useRef, useState } from "react";
import toast from "react-hot-toast";
import { uploadImage } from "~/components/reusable/UploadModalMedia/uploadImage";
import { UploaderStates } from "~/enums";
export interface BaseSelectorImage {
id: string;
url: string;
mediaToken: string;
}
export type BaseImageSelectorProps = {
onImageSelect: (imageUrl: BaseSelectorImage) => void;
showLoading?: boolean;
}
const MAX_GALLERY_SELECTIONS = 1;
export const BaseImageSelector = ({
onImageSelect,
showLoading = false,
}: BaseImageSelectorProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
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 = (id: string) => {
// If already selected, deselect it
// Else if not selected and under max limit, select it
if (selectedGalleryImages.includes(id)) {
setSelectedGalleryImages([]);
} else {
setSelectedGalleryImages([id]);
}
}
const handleUseGalleryImages = (selectedItems: GalleryItem[]) => {
// We only want one file
if (selectedItems.length !== 1) {
return;
}
const item = selectedItems[0];
if (!item.fullImage) return;
const referenceImage: BaseSelectorImage = {
id: Math.random().toString(36).substring(7),
url: item.fullImage,
mediaToken: item.id,
};
sendImageEvent(referenceImage);
}
const handleUploadClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files) {
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 referenceImage: BaseSelectorImage = {
id: Math.random().toString(36).substring(7),
url: reader.result as string,
mediaToken: newState.data || "",
};
toast.success("Image uploaded successfully!");
sendImageEvent(referenceImage);
} 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="h-1/2 w-1/2 flex items-center justify-center bg-ui-background border-ui-panel-border border-8">
{isLoading || showLoading ? (
<div className="flex flex-col items-center gap-4">
<span>Uploading image...</span>
<LoadingSpinner />
</div>
) :
<div className="flex flex-col gap-8">
<FontAwesomeIcon icon={faImage} className="text-6xl text-ui-controls" />
<span className="text-ui-controls text-lg ml-2">Click to upload or drag and drop an image here to edit</span>
<div className="mt-4 flex justify-center gap-2">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
multiple
/>
<Button
variant="primary"
onClick={handleUploadClick}
>
Upload an image
</Button>
<Button
variant="secondary"
onClick={handleGalleryClick}
>
Select from library
</Button>
</div>
</div>
}
</div>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={MAX_GALLERY_SELECTIONS}
onUseSelected={handleUseGalleryImages}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</>
)
}
@@ -0,0 +1,51 @@
import { faPlusCircle, faMinusCircle, faDrawCircle, faEraser, faPaintbrush } from "@fortawesome/pro-regular-svg-icons";
import { EditOperation } from "./stores/EditState";
import { ButtonIconSelect } from "@storyteller/ui-button-icon-select";
import { SliderV2 } from "@storyteller/ui-sliderv2";
import { faPaintBrush } from "@fortawesome/pro-solid-svg-icons";
export interface DrawToolControlBarProps {
currentMode: EditOperation;
currentSize: number;
onModeChange?: (mode: EditOperation) => void;
onSizeChange?: (size: number) => void;
}
const DrawToolControlBar = ({
currentMode,
currentSize,
onModeChange,
onSizeChange = () => { }
}: DrawToolControlBarProps) => {
const modes = [
{
value: "add",
icon: faPaintBrush,
text: "Draw",
tooltip: "Draw strokes",
},
{
value: "minus",
icon: faEraser,
text: "Erase",
tooltip: "Erases strokes",
},
];
return (
<div className="absolute top-16 left-1/2 flex -translate-x-1/2 flex-col gap-3">
<div className="glass w-[400px] flex gap-2 rounded-xl p-2 items-center">
<ButtonIconSelect
options={modes}
// @ts-expect-error - TypeScript doesn't recognize the value as EditOperation
onOptionChange={onModeChange}
selectedOption={currentMode}
/>
<SliderV2 min={1} max={50} value={currentSize} onChange={onSizeChange} step={1} innerLabel={"Size " + currentSize + "pt"} showDecrement showIncrement />
</div>
</div>
)
}
export default DrawToolControlBar;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,344 @@
import { useState, useRef, useEffect } from "react";
// https://github.com/SaladTechnologies/comfyui-api
import { PopoverItem, PopoverMenu } from "@storyteller/ui-popover";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClock, faImage } from "@fortawesome/pro-solid-svg-icons";
import Konva from "konva"; // just for types
import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap"
import {
FalBackgroundRemoval,
} from "@storyteller/tauri-api";
import { ContextMenuContainer } from "../PageDraw/components/ui/ContextMenu";
import SideToolbar from "../PageDraw/components/ui/SideToolbar";
import { useCopyPasteHotkeys } from "../PageDraw/hooks/useCopyPasteHotkeys";
import { useDeleteHotkeys } from "../PageDraw/hooks/useDeleteHotkeys";
import { useUndoRedoHotkeys } from "../PageDraw/hooks/useUndoRedoHotkeys";
import { captureStageImageBitmap } from "../PageDraw/hooks/useUpdateSnapshot";
import PromptEditor from "./PromptEditor/PromptEditor";
import { AspectRatioType } from "../PageDraw/stores/SceneState";
import { ActiveEditTool, useEditStore } from "./stores/EditState";
import { EditPaintSurface } from "./EditPaintSurface";
import { normalizeCanvas } from "~/Helpers/CanvasHelpers";
import { BaseImageSelector, BaseSelectorImage } from "./BaseImageSelector";
import DrawToolControlBar from "./DrawToolControlBar";
const PageEdit = () => {
//useStateSceneLoader();
// 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);
const [selectedModel, setSelectedModel] = useState<string>("GPT-4o");
// Create refs for stage and image
const stageRef = useRef<Konva.Stage>({} as Konva.Stage);
const leftPanelRef = useRef<Konva.Layer>({} as Konva.Layer);
const rectRef = useRef<Konva.Rect>({} as Konva.Rect);
const transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({});
const [baseImage, setBaseImage] = useState<BaseSelectorImage | null>(null);
// Use the Zustand store
const store = useEditStore();
// 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 handleImageUpload = (files: File[]): void => {
// Place images at center of viewport with offset for multiple images
const centerX = 512; // leftPanelWidth / 2
const centerY = 512; // leftPanelHeight / 2
console.log("Image upload started with files:", files);
console.log("Center coordinates:", { centerX, centerY });
files.forEach((file, index) => {
console.log(`Processing file ${index}:`, file.name);
store.createImageFromFile(
centerX + index * 60, // Offset each image
centerY + index * 60,
file,
);
console.log(`Created image at position:`, {
x: centerX + index * 60,
y: centerY + index * 60,
});
});
};
const modelList: PopoverItem[] = [
{
label: "GPT-4o",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
selected: selectedModel === "GPT-4o",
description: "High quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
label: "FLUX.1 Kontext",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
selected: selectedModel === "FLUX.1 Kontext",
description: "Fast and high-quality model",
badges: [{ label: "20 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
];
const handleModelSelect = (item: PopoverItem) => {
setSelectedModel(item.label);
};
const onEnqueuedPressed = async () => {
// takes snap shot and then a global variable in the engine will invoke the inference.
const image = await captureStageImageBitmap(stageRef, transformerRefs);
if (!image) {
console.error("Failed to capture stage image");
return;
} else {
setCanvasRenderBitmap(image);
}
};
const onFitPressed = async () => {
// Get the stage and its container dimensions
const stage = stageRef.current;
// 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;
// Calculate position to center canvas in container
stage.position({
x: (containerWidth - canvasW) / 2,
y: (containerHeight - canvasH) / 2
});
}
// Create a function to use the left layer ref and download the bitmap from it
const downloadLeftPanelBitmap = () => {
if (!stageRef.current || !leftPanelRef.current || !rectRef.current) {
console.error("Stage or left panel ref is not available");
return;
}
const layer = leftPanelRef.current;
// Get the canvas area that's covered by the image/rectangle
const rect = rectRef.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());
const downloadCallback = (blob: Blob | null) => {
if (!blob) {
console.error("Failed to create blob from canvas");
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "artcraft_snapshot.png";
link.click();
}
fittedCanvas.toBlob(downloadCallback, "image/png", 1.0);
};
// 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={"h-screen w-full flex items-center justify-center bg-ui-panel"}>
<BaseImageSelector
onImageSelect={(image: BaseSelectorImage) => {
store.setBaseImageInfo(image);
}}
showLoading={store.baseImageInfo !== null && store.baseImageInfo === null}
/>
</div>
)
}
return (
<>
<div
className={`preserve-aspect-ratio fixed top-0 left-1/2 z-10 -translate-x-1/2 transform ${isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
style={{ display: store.activeTool === "edit" ? "block" : "none" }}
>
<DrawToolControlBar
currentMode={store.editOperation}
currentSize={store.brushSize}
onModeChange={store.setEditOperation}
onSizeChange={store.setBrushSize}
/>
</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
onModeChange={(mode: string) => { store.setActiveTool(mode as ActiveEditTool) }}
selectedMode={store.activeTool}
onGenerateClick={downloadLeftPanelBitmap}
/>
</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={store.brushColor}
brushSize={store.brushSize}
onSelectionChange={setIsSelecting}
stageRef={stageRef}
transformerRefs={transformerRefs}
leftPanelRef={leftPanelRef}
baseImageRef={rectRef}
/>
</ContextMenuContainer>
</div>
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
<PopoverMenu
items={modelList}
onSelect={handleModelSelect}
mode="hoverSelect"
panelTitle="Select Model"
panelClassName="min-w-[280px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
triggerLabel="Model"
/>
<button
className="bg-transparent p-2 text-lg text-white hover:text-white hover:bg-white/20 rounded transition"
onClick={onFitPressed}
>
Fit
</button>
</div>
</>
);
};
export default PageEdit;
@@ -0,0 +1,26 @@
import React from "react";
import { PromptBoxEdit, PromptBoxEditProps } from "@storyteller/ui-promptbox";
// Set this value on when enqueue is pressed nasty global variable.
import { JobProvider } from "~/pages/PageDraw/JobContext";
const PromptEditor: React.FC<PromptBoxEditProps> = ({
onModeChange,
selectedMode,
onGenerateClick,
}) => {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col space-y-2">
<JobProvider>
<PromptBoxEdit
onModeChange={onModeChange}
selectedMode={selectedMode}
onGenerateClick={onGenerateClick}
isDisabled={false}
/>
</JobProvider>
</div>
);
};
export default PromptEditor;
@@ -0,0 +1,43 @@
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;
}
@@ -0,0 +1,871 @@
import { create } from 'zustand';
import { LineNode, generateId } from '~/pages/PageDraw/stores/SceneState';
import { Node } from "~/pages/PageDraw/Node";
import { BaseSelectorImage } from '../BaseImageSelector';
export type ActiveEditTool = 'select' | 'edit' | 'expand';
export type EditOperation = 'add' | 'minus';
interface EditState {
// Nodes
nodes: Node[];
selectedNodeIds: string[];
lineNodes: LineNode[]; // Add lineNodes to state
// Clipboard
clipboard: (Node | LineNode)[]; // To store copied items
// Toolbar state
activeTool: ActiveEditTool;
editOperation: EditOperation;
brushColor: string;
brushSize: number;
brushOpacity: number;
fillColor: string;
// Cursor state
cursorPosition: { x: number; y: number } | null;
cursorVisible: boolean;
// Base image state
baseImageInfo: BaseSelectorImage | null;
baseImageBitmap: HTMLImageElement | null;
setBaseImageInfo: (image: BaseSelectorImage | null) => void;
// Actions
addNode: (node: Node) => void;
removeNode: (id: string, shouldSaveState?: boolean) => void;
updateNode: (id: string, updates: Partial<Node>, shouldSaveState: boolean) => void;
selectNode: (id: string | null, isMultiSelect?: boolean) => void;
moveNode: (id: string, x: number, y: number, dx?: number, dy?: number, shouldSaveState?: boolean) => void;
// Batch operations
setNodes: (nodes: Node[]) => void;
// History management
history: { nodes: Node[]; lineNodes: LineNode[] }[];
historyIndex: number;
undo: () => void;
redo: () => void;
saveState: () => void;
// Add new actions for line nodes
addLineNode: (lineNode: LineNode, shouldSaveState: boolean) => void;
removeLineNode: (id: string, shouldSaveState?: boolean) => void;
updateLineNode: (id: string, updates: Partial<LineNode>, shouldSaveState: boolean) => void;
moveLineNode: (id: string, dx: number, dy: number) => void;
// Add a specific method for file uploads
createImageFromFile: (x: number, y: number, file: File, width?: number, height?: number) => void;
// Add method for URL-based images
createImageFromUrl: (x: number, y: number, url: string, width?: number, height?: number) => void;
// Action for deleting selected items
deleteSelectedItems: () => void;
// Clipboard actions
copySelectedItems: () => void;
pasteItems: () => void;
// Toolbar actions
setActiveTool: (tool: ActiveEditTool) => void;
setEditOperation: (mode: EditOperation) => void;
setBrushColor: (color: string) => void;
setBrushOpacity: (opacity: number) => void;
setBrushSize: (size: number) => void;
setFillColor: (color: string) => void;
// Cursor actions
setCursorPosition: (position: { x: number; y: number } | null) => void;
setCursorVisible: (visible: boolean) => void;
// Scene save/load actions
exportSceneAsJson: () => Promise<string>;
importSceneFromJson: (jsonString: string) => boolean;
saveSceneToFile: () => Promise<void>;
loadSceneFromFile: (file: File) => Promise<boolean>;
// Add these two properties
serializeSceneToString: () => Promise<string>;
loadSceneFromString: (jsonString: string) => boolean;
// Add aspect ratio action
getAspectRatioDimensions: () => { width: number; height: number };
}
// Add a flag to prevent saving state during restoration
let isRestoring = false;
// Add this helper function at the top of the store (before create<SceneState>)
const getNextZIndex = (nodes: Node[], lineNodes: LineNode[]): number => {
const allZIndices = [
...nodes.map((n, index) => {
//console.log(`Node: ${JSON.stringify(n)}, Index: ${index}`);
return n.zIndex || 0;
}),
...lineNodes.map((n, index) => {
//console.log(`LineNode: ${JSON.stringify(n)}, Index: ${index}`);
return n.zIndex || 0;
})
];
return allZIndices.length > 0 ? Math.max(...allZIndices) + 1 : 0;
};
export const useEditStore = create<EditState>((set, get) => ({
// Initial state
nodes: [],
lineNodes: [],
selectedNodeIds: [],
clipboard: [], // Initialize clipboard
history: [],
historyIndex: -1,
// Toolbar initial state
activeTool: 'select', // Default to 'select' tool
editOperation: 'add', // Default to 'add' operation
brushColor: '#000000',
brushSize: 30,
brushOpacity: 1,
fillColor: 'rgba(0, 0, 255, 0.5)',
// Cursor initial state
cursorPosition: null,
cursorVisible: false,
// Base image initial state
baseImageInfo: null,
baseImageBitmap: null,
// Actions
addNode: (node: Node) => {
set((state) => {
const nextZ = getNextZIndex(state.nodes, state.lineNodes);
console.log(`NextZ for node ID ${node.id}: ${nextZ}`);
const newNode = new Node({
...node,
zIndex: nextZ
});
// console.log("New Node with ID:", newNode.id);
const nodes = [...state.nodes, newNode]
console.log("Nodes after update")
console.log(nodes)
return { nodes: nodes };
});
get().saveState();
},
removeNode: (id: string, shouldSaveState: boolean = true) => {
set((state) => {
// Remove the node
const newNodes = state.nodes.filter(node => node.id !== id);
// Update selection state
const newSelectedIds = state.selectedNodeIds.filter(nodeId => nodeId !== id);
return {
nodes: newNodes,
selectedNodeIds: newSelectedIds
};
});
if (shouldSaveState) {
get().saveState();
}
},
updateNode: (id: string, updates: Partial<Node>, shouldSaveState: boolean = true) => {
set((state) => {
const newNodes = state.nodes.map(node => {
const newZIndex = updates.zIndex !== undefined ? updates.zIndex : node.zIndex
if (node.id === id) {
return new Node({
...node,
...updates,
zIndex: newZIndex
});
}
return node;
});
return { nodes: newNodes };
});
if (shouldSaveState && !isRestoring) {
get().saveState();
}
},
selectNode: (id: string | null, isMultiSelect = false) => {
set((state) => {
if (!id) {
return { selectedNodeIds: [] };
}
if (isMultiSelect) {
// Toggle selection if already selected
if (state.selectedNodeIds.includes(id)) {
return {
selectedNodeIds: state.selectedNodeIds.filter(nodeId => nodeId !== id)
};
}
// Add to selection
return {
selectedNodeIds: [...state.selectedNodeIds, id]
};
}
// Single select
return { selectedNodeIds: [id] };
});
},
moveNode: (id: string, x: number, y: number, dx?: number, dy?: number, shouldSaveState: boolean = false) => {
set((state) => {
// Handle regular nodes
const newNodes = state.nodes.map(node => {
if (node.id === id) {
node.setPosition(x, y);
return node;
}
if (state.selectedNodeIds.includes(node.id) && dx !== undefined && dy !== undefined) {
node.setPosition(node.x + dx, node.y + dy);
return node;
}
return node;
});
const newState = {
nodes: newNodes
};
// Only save state if explicitly requested
if (shouldSaveState) {
get().saveState();
}
return newState;
});
},
setNodes: (nodes: Node[]) => {
set({ nodes });
get().saveState();
},
// History management
saveState: () => {
// Don't save state if we're in the middle of restoring
if (isRestoring) return;
set((state) => {
// Create a deep copy but exclude non-serializable properties
const serializableState = {
nodes: state.nodes.map(node => ({
id: node.id,
x: node.x,
y: node.y,
width: node.width,
height: node.height,
fill: node.fill,
type: node.type,
stroke: node.stroke,
strokeWidth: node.strokeWidth,
draggable: node.draggable,
imageUrl: node.imageUrl,
imageFile: node.imageFile,
backgroundColor: node.backgroundColor,
rotation: node.rotation || 0,
scaleX: node.scaleX || 1,
scaleY: node.scaleY || 1,
offsetX: node.offsetX || 0, // Include offset in history
offsetY: node.offsetY || 0, // Include offset in history
})),
lineNodes: JSON.parse(JSON.stringify(state.lineNodes))
};
const newHistory = state.history.slice(0, state.historyIndex + 1);
newHistory.push(serializableState);
return {
history: newHistory,
historyIndex: newHistory.length - 1,
};
});
},
undo: () => {
set((state) => {
if (state.historyIndex < 0) return state;
const newIndex = state.historyIndex - 1;
// If we're going back to before the first saved state, return to initial empty state
if (newIndex < 0) {
return {
nodes: [],
lineNodes: [],
selectedNodeIds: [],
historyIndex: newIndex
};
}
const previousState = state.history[newIndex];
// Set the restoring flag
isRestoring = true;
// Recreate nodes and immediately start loading images
const restoredNodes = previousState.nodes.map(nodeData => {
const node = new Node(nodeData as any);
// If it's an image node, start loading the image immediately
if (node.type === 'image' && (node.imageUrl || node.imageFile)) {
const loadImage = async () => {
try {
if (node.imageUrl) {
await node.setImageFromUrl(node.imageUrl);
} else if (node.imageFile) {
await node.setImageFromFile(node.imageFile);
}
// Update the specific node without triggering state save
get().updateNode(node.id, node, false);
} catch (error) {
console.error('Failed to restore image:', error);
} finally {
// Reset the flag after a delay to ensure all async operations complete
setTimeout(() => {
isRestoring = false;
}, 100);
}
};
loadImage();
}
return node;
});
// Reset the flag if no images to load
const hasImages = restoredNodes.some(node => node.type === 'image');
if (!hasImages) {
isRestoring = false;
}
return {
nodes: restoredNodes,
lineNodes: previousState.lineNodes,
selectedNodeIds: [], // Clear selection on undo
historyIndex: newIndex
};
});
},
redo: () => {
set((state) => {
if (state.historyIndex >= state.history.length - 1) return state;
const newIndex = state.historyIndex + 1;
const nextState = state.history[newIndex];
// Set the restoring flag
isRestoring = true;
// Recreate nodes and immediately start loading images
const restoredNodes = nextState.nodes.map(nodeData => {
const node = new Node(nodeData as any);
// If it's an image node, start loading the image immediately
if (node.type === 'image' && (node.imageUrl || node.imageFile)) {
const loadImage = async () => {
try {
if (node.imageUrl) {
await node.setImageFromUrl(node.imageUrl);
} else if (node.imageFile) {
await node.setImageFromFile(node.imageFile);
}
// Update the specific node without triggering state save
get().updateNode(node.id, node, false);
} catch (error) {
console.error('Failed to restore image:', error);
} finally {
// Reset the flag after a delay to ensure all async operations complete
setTimeout(() => {
isRestoring = false;
}, 100);
}
};
loadImage();
}
return node;
});
// Reset the flag if no images to load
const hasImages = restoredNodes.some(node => node.type === 'image');
if (!hasImages) {
isRestoring = false;
}
return {
nodes: restoredNodes,
lineNodes: nextState.lineNodes,
selectedNodeIds: [], // Clear selection on redo
historyIndex: newIndex
};
});
},
// Add new line node actions
addLineNode: (lineNode: LineNode, shouldSaveState: boolean = true) => {
set((state) => {
const nextZ = getNextZIndex(state.nodes, state.lineNodes);
const newLineNode = {
...lineNode,
zIndex: nextZ
};
return { lineNodes: [...state.lineNodes, newLineNode] };
});
if (shouldSaveState) {
get().saveState();
}
},
removeLineNode: (id: string, shouldSaveState: boolean = true) => {
set((state) => {
// Remove the line node
const newLineNodes = state.lineNodes.filter(node => node.id !== id);
// Update selection state
const newSelectedIds = state.selectedNodeIds.filter(nodeId => nodeId !== id);
return {
lineNodes: newLineNodes,
selectedNodeIds: newSelectedIds
};
});
if (shouldSaveState) {
get().saveState();
}
},
updateLineNode: (id: string, updates: Partial<LineNode>, shouldSaveState: boolean = true) => {
set((state) => {
const newLineNodes = state.lineNodes.map(node => {
if (node.id === id) {
return {
...node,
...updates,
zIndex: updates.zIndex !== undefined ? updates.zIndex : node.zIndex
};
}
return node;
});
return { lineNodes: newLineNodes };
});
if (shouldSaveState) {
get().saveState();
}
},
moveLineNode: (id: string, dx: number, dy: number) => {
set((state) => {
const newLineNodes = state.lineNodes.map(node => {
if (node.id === id) {
return {
...node,
points: node.points.map((point, index) => {
// Even indices are x coordinates, odd are y
return index % 2 === 0 ? point + dx : point + dy;
})
};
}
return node;
});
return { lineNodes: newLineNodes };
});
get().saveState();
},
// Add a specific method for file uploads
createImageFromFile: (x: number, y: number, file: File, width?: number, height?: number) => {
// Auto-detect dimensions from the image if not provided
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target?.result as string;
if (dataUrl) {
const img = new Image();
img.onload = () => {
const aspectRatio = img.naturalWidth / img.naturalHeight;
// Increase default size to 512px width while maintaining aspect ratio
const finalWidth = width || Math.min(img.naturalWidth, 512);
const finalHeight = height || finalWidth / aspectRatio;
console.log('Image loaded with dimensions:', {
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
finalWidth: finalWidth,
finalHeight: finalHeight
});
get().createImage(x, y, file, finalWidth, finalHeight);
};
img.src = dataUrl;
}
};
reader.readAsDataURL(file);
},
// Add method for URL-based images
createImageFromUrl: async (x: number, y: number, url: string, width?: number, height?: number) => {
try {
// Fetch the image and convert to blob
const response = await fetch(url);
const blob = await response.blob();
// Create a File from the blob
const filename = url.split('/').pop() || 'image.png';
const file = new File([blob], filename, { type: blob.type });
// Use existing createImage function
get().createImageFromFile(x, y, file, width, height);
} catch (error) {
console.error("Error loading image from URL:", url, error);
}
},
setBaseImageInfo: (image: BaseSelectorImage | null) => {
set({ baseImageInfo: image });
if (!image) {
return;
}
// Load the image bitmap as well
const imgBitmap = new Image();
imgBitmap.onload = () => {
set({ baseImageBitmap: imgBitmap });
};
imgBitmap.onerror = () => {
console.error('Failed to load base image, discarding');
imgBitmap.src = ''; // Clear the source to avoid memory leaks
set({ baseImageInfo: null, baseImageBitmap: null });
};
imgBitmap.crossOrigin = 'anonymous';
imgBitmap.src = image.url;
},
// Action for deleting selected items
deleteSelectedItems: () => {
const initialSelectedIds = [...get().selectedNodeIds]; // Operate on a copy of the original selection
if (initialSelectedIds.length > 0) {
initialSelectedIds.forEach(id => {
get().removeNode(id, false); // Pass shouldSaveState = false
get().removeLineNode(id, false); // Pass shouldSaveState = false
});
// After all deletions, ensure selectedNodeIds is empty in the state.
set(state => ({
...state, // Preserve other state properties
selectedNodeIds: []
}));
get().saveState(); // Save the final state once
}
},
// Clipboard actions
copySelectedItems: () => {
set((state) => {
const selectedNodes = state.nodes.filter(node => state.selectedNodeIds.includes(node.id));
const selectedLineNodes = state.lineNodes.filter(lineNode => state.selectedNodeIds.includes(lineNode.id));
// Deep copy nodes. For Node instances, we create new instances.
const copiedNodes = selectedNodes.map(node => {
// Create a new Node instance with the properties of the old one.
// This ensures methods are available if needed.
// For imageFile (File object), the reference is copied. The new Node
// instance will need to handle loading this File object again if necessary.
const nodeData = { ...node }; // Shallow copy properties first
// If Node class has complex internal state not covered by spread, adjust accordingly
return new Node(nodeData);
});
// Deep copy line nodes (plain objects)
const copiedLineNodes = JSON.parse(JSON.stringify(selectedLineNodes));
return { clipboard: [...copiedNodes, ...copiedLineNodes] };
});
},
pasteItems: () => {
const { clipboard } = get();
if (clipboard.length === 0) return;
const newPastedItemIds: string[] = [];
const offset = 20; // Offset for pasted items
const nodesToAdd: Node[] = [];
const lineNodesToAdd: LineNode[] = [];
clipboard.forEach(item => {
const newId = generateId();
newPastedItemIds.push(newId);
if (item instanceof Node) {
// It's a regular Node (shape or image)
// Create a new Node instance for the pasted item
const pastedNodeData = {
...(item as Node), // Spread existing properties
id: newId,
x: (item as Node).x + offset,
y: (item as Node).y + offset,
// imageFile and imageUrl are carried over from 'item'
// The new Node instance's constructor or setImage method will handle loading
};
const newNodeInstance = new Node(pastedNodeData);
// If it's an image, and its dimensions might change upon load,
// we need to ensure the store is updated after the image is loaded.
// The Node constructor itself might call setImage.
// Similar to createImage, an updateNode call after image load is robust.
if (newNodeInstance.type === 'image' && (newNodeInstance.imageUrl || newNodeInstance.imageFile)) {
const source = newNodeInstance.imageUrl || newNodeInstance.imageFile;
if (source) {
newNodeInstance.setImage(source)
.then(() => {
if (get().nodes.find(n => n.id === newId)) {
const { id, x, y, width, height, fill, stroke, strokeWidth, draggable, imageUrl, imageFile, rotation, scaleX, scaleY, offsetX, offsetY } = newNodeInstance;
get().updateNode(id, { x, y, width, height, fill, stroke, strokeWidth, draggable, imageUrl, imageFile, rotation, scaleX, scaleY, offsetX, offsetY }, false);
}
})
.catch(error => {
console.error(`Failed to load image for pasted node ${newId}:`, error);
});
}
}
nodesToAdd.push(newNodeInstance);
} else if (item && typeof item === 'object' && 'points' in item && 'type' in item && item.type === 'line') {
// It's a LineNode (duck-typing for safety)
const lineNode = item as LineNode;
const pastedLineNode: LineNode = {
...lineNode,
id: newId,
points: lineNode.points.map((point, index) => {
return index % 2 === 0 ? point + offset : point + offset; // Offset x and y
}),
// Ensure optional properties are handled:
// If x,y were part of LineNode for positioning the whole line, offset them too.
// The current LineNode type doesn't enforce top-level x,y for the line itself for dragging,
// but if it did, they'd be offset here.
// The points themselves define the line's position.
x: lineNode.x !== undefined ? lineNode.x + offset : undefined,
y: lineNode.y !== undefined ? lineNode.y + offset : undefined,
};
lineNodesToAdd.push(pastedLineNode);
}
});
set(state => {
const newNodes = [...state.nodes, ...nodesToAdd];
const newLineNodes = [...state.lineNodes, ...lineNodesToAdd];
return {
nodes: newNodes,
lineNodes: newLineNodes,
selectedNodeIds: newPastedItemIds // Select the newly pasted items
};
});
get().saveState();
},
// Toolbar actions
setActiveTool: (tool: ActiveEditTool) => set({ activeTool: tool }),
setEditOperation: (mode: EditOperation) => set({ editOperation: mode }),
setBrushColor: (color: string) => set({ brushColor: color }),
setBrushSize: (size: number) => set({ brushSize: size }),
setFillColor: (color: string) => set({ fillColor: color }),
setBrushOpacity: (opacity: number) => set({ brushOpacity: opacity }),
// Cursor actions
setCursorPosition: (position: { x: number; y: number } | null) => set({ cursorPosition: position }),
setCursorVisible: (visible: boolean) => set({ cursorVisible: visible }),
// Scene save/load actions
exportSceneAsJson: async () => {
const state = get();
// Helper function to convert File to base64
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
// Process nodes and convert File objects to base64
const processedNodes = await Promise.all(
state.nodes.map(async (node) => {
const nodeData = {
id: node.id,
x: node.x,
y: node.y,
width: node.width,
height: node.height,
fill: node.fill,
type: node.type,
stroke: node.stroke,
strokeWidth: node.strokeWidth,
draggable: node.draggable,
imageUrl: node.imageUrl,
backgroundColor: node.backgroundColor,
rotation: node.rotation || 0,
scaleX: node.scaleX || 1,
scaleY: node.scaleY || 1,
offsetX: node.offsetX || 0,
offsetY: node.offsetY || 0,
};
// Convert imageFile to base64 if it exists
if (node.imageFile && node.imageFile instanceof File) {
try {
const base64 = await fileToBase64(node.imageFile);
return { ...nodeData, imageDataUrl: base64 };
} catch (error) {
console.error('Failed to convert image file to base64:', error);
}
}
return nodeData;
})
);
const sceneData = {
nodes: processedNodes,
lineNodes: JSON.parse(JSON.stringify(state.lineNodes)),
brushColor: state.brushColor,
brushSize: state.brushSize,
fillColor: state.fillColor,
version: "1.0"
};
return JSON.stringify(sceneData, null, 2);
},
importSceneFromJson: (jsonString: string) => {
try {
const sceneData = JSON.parse(jsonString);
isRestoring = true;
// Helper function to convert base64 to File
const base64ToFile = (base64: string, filename: string): File => {
const arr = base64.split(',');
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png';
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
};
// Recreate nodes
const restoredNodes = sceneData.nodes.map((nodeData: any) => {
const node = new Node(nodeData);
// Handle image restoration
if (node.type === 'image') {
const loadImage = async () => {
try {
if (nodeData.imageDataUrl) {
// Restore from base64 data URL
const file = base64ToFile(nodeData.imageDataUrl, `restored-image-${node.id}.png`);
await node.setImageFromFile(file);
} else if (node.imageUrl) {
// Restore from URL
await node.setImageFromUrl(node.imageUrl);
}
get().updateNode(node.id, node, false);
} catch (error) {
console.error('Failed to restore image:', error);
}
};
loadImage();
}
return node;
});
set({
nodes: restoredNodes,
lineNodes: sceneData.lineNodes || [],
selectedNodeIds: [],
brushColor: sceneData.brushColor || '#000000',
brushSize: sceneData.brushSize || 5,
fillColor: sceneData.fillColor || 'white',
});
isRestoring = false;
get().saveState(); // Save the loaded state to history
return true;
} catch (error) {
console.error('Failed to import scene:', error);
isRestoring = false;
return false;
}
},
saveSceneToFile: async () => {
const jsonString = await get().exportSceneAsJson();
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `mirai-scene-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
},
loadSceneFromFile: async (file: File) => {
try {
const text = await file.text();
return get().importSceneFromJson(text);
} catch (error) {
console.error('Failed to load scene from file:', error);
return false;
}
},
serializeSceneToString: async (): Promise<string> => {
return get().exportSceneAsJson();
},
loadSceneFromString: (jsonString: string): boolean => {
return get().importSceneFromJson(jsonString);
},
// Add aspect ratio actions
getAspectRatioDimensions: () => {
// First we check the base image info if it exists
// Otherwise we use default landscape
const baseImageInfo = get().baseImageBitmap;
if (!baseImageInfo) {
return { width: 1024, height: 683 }; // Default to landscape
}
// If base image exists, use its dimensions
return { width: baseImageInfo.width, height: baseImageInfo.height };
}
}));
@@ -77,6 +77,7 @@ 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 { IMAGE_MODELS_BY_LABEL } from "@storyteller/model-list";
import { ModelInfo } from "@storyteller/model-list";
@@ -583,6 +584,11 @@ export const PageEditor = () => {
/>
</div>
)}
{tabStore.activeTabId == "EDIT" && (
<div>
<PageEdit />
</div>
)}
</div>
);
};
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { useSceneStore } from '../PageDraw/stores/SceneState';
export type TabId = '2D' | '3D' | 'VIDEO' | 'IMAGE';
export type TabId = '2D' | '3D' | 'VIDEO' | 'IMAGE' | 'EDIT';
interface TabState {
// Current active tab
@@ -18,12 +18,12 @@ interface TabState {
}
export const useTabStore = create<TabState>((set, get) => ({
activeTabId: '2D',
activeTabId: 'EDIT',
tabData: {},
setActiveTab: async (newTabId) => {
const currentTabId = get().activeTabId;
// Don't do anything if we're already on this tab
if (currentTabId === newTabId) return true;
@@ -1,4 +1,5 @@
export * from "./lib/PromptBox2D";
export * from "./lib/PromptBox3D";
export * from "./lib/PromptBoxEdit";
export * from "./lib/PromptBoxVideo";
export * from "./lib/PromptBoxImage";
@@ -564,8 +564,8 @@ export const PromptBox2D = ({
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => {}}
onBlur={() => {}}
onFocus={() => { }}
onBlur={() => { }}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
@@ -0,0 +1,153 @@
import {
faEdit,
faExpand,
faMessageCheck,
faMessageXmark,
faMousePointer,
faSparkles,
faSpinnerThird
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSignals } from "@preact/signals-react/runtime";
import { Button, ToggleButton } from "@storyteller/ui-button";
import { ButtonIconSelect } from "@storyteller/ui-button-icon-select";
import { Tooltip } from "@storyteller/ui-tooltip";
import { useEffect, useRef, useState } from "react";
export interface PromptBoxEditProps {
onModeChange?: (mode: string) => void;
selectedMode?: string;
onGenerateClick: () => void;
isDisabled?: boolean;
}
export const PromptBoxEdit = ({
onModeChange: onModeSelectionChange,
selectedMode,
onGenerateClick,
isDisabled
}: PromptBoxEditProps) => {
useSignals();
const [prompt, setPrompt] = useState("");
const [useSystemPrompt, setUseSystemPrompt] = useState(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
});
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
const pastedText = e.clipboardData.getData("text").trim();
setPrompt(pastedText);
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
e.stopPropagation();
setPrompt(e.target.value);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Stop propagation of keyboard events to prevent them from reaching the canvas
e.stopPropagation();
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
// handleEnqueue();
}
};
const modes = [
{
value: "select",
icon: faMousePointer,
text: "Select",
tooltip: "Selection mode",
},
{
value: "edit",
icon: faEdit,
text: "Edit Region",
tooltip: "Edit area for inpainting",
},
{
value: "expand",
icon: faExpand,
text: "Expand",
tooltip: "Expand area for outpainting",
},
];
return (
<>
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
<div className="glass w-[730px] rounded-xl p-4">
<div className="flex justify-center gap-2">
<textarea
ref={textareaRef}
rows={1}
placeholder="Describe your image..."
className="text-md mb-2 max-h-[5.5em] flex-1 resize-none overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none"
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => { }}
onBlur={() => { }}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ButtonIconSelect
options={modes}
onOptionChange={onModeSelectionChange}
selectedOption={selectedMode}
/>
<Tooltip
content={
useSystemPrompt
? "Use system prompt: ON"
: "Use system prompt: OFF"
}
position="top"
className="z-50"
delay={200}
>
<ToggleButton
isActive={useSystemPrompt}
icon={faMessageXmark}
activeIcon={faMessageCheck}
onClick={() => setUseSystemPrompt(!useSystemPrompt)}
/>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Button
className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
icon={!isDisabled ? faSparkles : undefined}
onClick={onGenerateClick}
disabled={isDisabled || !prompt.trim()}
>
{isDisabled ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg"
/>
) : (
"Generate"
)}
</Button>
</div>
</div>
</div>
</div>
</>
);
};