mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-27 04:00:10 -04:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f892f7f746 | ||
|
|
48eb0b6c0c | ||
|
|
e3b9c6eba0 | ||
|
|
f8ff7493d0 | ||
|
|
34b8ac81ec | ||
|
|
2081e1fa30 | ||
|
|
3a7e272332 | ||
|
|
56faca60e4 | ||
|
|
23cdf65fd7 | ||
|
|
4b8e8c126b | ||
|
|
e7e8030d4b | ||
|
|
5256ea5aab | ||
|
|
b8002fdf2e |
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user