Replicate PageDraw for PageEdit

This commit is contained in:
Tanish Manku committed 2025-07-07 14:04:06 -07:00
1 parent b8002fdf2e
commit 5256ea5aab
4 files changed
+1773 -127

No files matched your search

@@ -29,25 +29,25 @@ export enum AspectRatioType {
NONE = 'none' // No aspect ratio constraint
}
// Logic to remove background from image nodes would go here
const convertFileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (reader.result) {
resolve(reader.result as string);
} else {
reject(new Error("Failed to convert file to base64."));
}
};
reader.onerror = () => {
reject(new Error("Error reading file."));
};
reader.readAsDataURL(file);
});
// Logic to remove background from image nodes would go here
export const convertFileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (reader.result) {
resolve(reader.result as string);
} else {
reject(new Error("Failed to convert file to base64."));
}
};
reader.onerror = () => {
reject(new Error("Error reading file."));
};
reader.readAsDataURL(file);
});
};
export type ActiveTool = 'select' | 'draw' | 'eraser' | 'backgroundColor' | 'shape';
@@ -57,53 +57,53 @@ interface SceneState {
nodes: Node[];
selectedNodeIds: string[];
lineNodes: LineNode[]; // Add lineNodes to state
// Clipboard
clipboard: (Node | LineNode)[]; // To store copied items
// Toolbar state
activeTool: ActiveTool;
brushColor: string;
brushSize: number;
brushOpacity: number;
fillColor: string;
// Cursor state
cursorPosition: { x: number; y: number } | null;
cursorVisible: boolean;
// 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;
// Node creation helpers
createRectangle: (x: number, y: number, width?: number, height?: number, fill?: string) => void;
createCircle: (x: number, y: number, radius?: number, fill?: string) => void;
createTriangle: (x: number, y: number, width?: number, height?: number, fill?: string) => void;
createImage: (x: number, y: number, source: string | File, width?: number, height?: number) => 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;
@@ -153,7 +153,7 @@ interface SceneState {
getAspectRatioDimensions: () => { width: number; height: number };
}
const generateId = (): string => {
export const generateId = (): string => {
return Math.random().toString(36).substring(2, 9);
};
@@ -184,21 +184,21 @@ export const useSceneStore = create<SceneState>((set, get) => ({
clipboard: [], // Initialize clipboard
history: [],
historyIndex: -1,
// Toolbar initial state
activeTool: 'select',
brushColor: '#000000',
brushSize: 5,
brushOpacity: 1,
fillColor: 'white',
// Cursor initial state
cursorPosition: null,
cursorVisible: false,
// Add initial aspect ratio state
aspectRatioType: AspectRatioType.NONE,
// Actions
addNode: (node: Node) => {
set((state) => {
@@ -209,23 +209,23 @@ export const useSceneStore = create<SceneState>((set, get) => ({
zIndex: nextZ
});
// console.log("New Node with ID:", newNode.id);
let nodes = [...state.nodes, newNode]
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 {
return {
nodes: newNodes,
selectedNodeIds: newSelectedIds
};
@@ -234,17 +234,17 @@ export const useSceneStore = create<SceneState>((set, get) => ({
get().saveState();
}
},
updateNode: (id: string, updates: Partial<Node>, shouldSaveState: boolean = true) => {
set((state) => {
const newNodes = state.nodes.map(node => {
let newZIndex = updates.zIndex !== undefined ? updates.zIndex : node.zIndex
const newZIndex = updates.zIndex !== undefined ? updates.zIndex : node.zIndex
if (node.id === id) {
return new Node({
...node,
return new Node({
...node,
...updates,
zIndex: newZIndex
});
@@ -257,31 +257,31 @@ export const useSceneStore = create<SceneState>((set, get) => ({
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 {
return {
selectedNodeIds: state.selectedNodeIds.filter(nodeId => nodeId !== id)
};
}
// Add to selection
return {
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
@@ -296,9 +296,9 @@ export const useSceneStore = create<SceneState>((set, get) => ({
}
return node;
});
const newState = {
const newState = {
nodes: newNodes
};
@@ -306,16 +306,16 @@ export const useSceneStore = create<SceneState>((set, get) => ({
if (shouldSaveState) {
get().saveState();
}
return newState;
});
},
setNodes: (nodes: Node[]) => {
set({ nodes });
get().saveState();
},
// Node creation helpers
createRectangle: (x: number, y: number, width = 100, height = 100, fill = '#e0e0e0') => {
const node = new Node({
@@ -333,7 +333,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
get().addNode(node);
get().setActiveTool('select'); // Switch to select tool after creating
},
createCircle: (x: number, y: number, radius = 50, fill = '#f0f0f0') => {
const node = new Node({
id: generateId(),
@@ -350,7 +350,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
get().addNode(node);
get().setActiveTool('select'); // Switch to select tool after creating
},
createTriangle: (x: number, y: number, width = 100, height = 100, fill = '#d0d0d0') => {
const node = new Node({
id: generateId(),
@@ -367,12 +367,12 @@ export const useSceneStore = create<SceneState>((set, get) => ({
get().addNode(node);
get().setActiveTool('select'); // Switch to select tool after creating
},
// 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 = {
@@ -398,23 +398,23 @@ export const useSceneStore = create<SceneState>((set, get) => ({
})),
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 {
@@ -424,16 +424,16 @@ export const useSceneStore = create<SceneState>((set, get) => ({
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 () => {
@@ -454,19 +454,19 @@ export const useSceneStore = create<SceneState>((set, get) => ({
}, 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,
@@ -475,21 +475,21 @@ export const useSceneStore = create<SceneState>((set, get) => ({
};
});
},
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 () => {
@@ -510,19 +510,19 @@ export const useSceneStore = create<SceneState>((set, get) => ({
}, 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,
@@ -531,7 +531,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
};
});
},
// Add new line node actions
addLineNode: (lineNode: LineNode, shouldSaveState: boolean = true) => {
set((state) => {
@@ -546,33 +546,33 @@ export const useSceneStore = create<SceneState>((set, get) => ({
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 {
return {
lineNodes: newLineNodes,
selectedNodeIds: newSelectedIds
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,
return {
...node,
...updates,
zIndex: updates.zIndex !== undefined ? updates.zIndex : node.zIndex
zIndex: updates.zIndex !== undefined ? updates.zIndex : node.zIndex
};
}
return node;
@@ -583,7 +583,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
get().saveState();
}
},
moveLineNode: (id: string, dx: number, dy: number) => {
set((state) => {
const newLineNodes = state.lineNodes.map(node => {
@@ -602,7 +602,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
});
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
@@ -629,25 +629,25 @@ export const useSceneStore = create<SceneState>((set, get) => ({
};
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);
}
},
// Update the createImage method to handle both URLs and Files
createImage: (x: number, y: number, source: string | File, width = 200, height = 200) => {
const nodeId = generateId(); // Define nodeId here to use it in the catch block for logging
@@ -665,15 +665,15 @@ export const useSceneStore = create<SceneState>((set, get) => ({
imageUrl: typeof source === 'string' ? source : undefined,
imageFile: typeof source === 'string' ? undefined : source
});
// Load the image asynchronously
node.setImage(source).then(() => {
// Update the node in the store after image loads
get().addNode(node);
//get().updateNode(node.id, node, false);
}).catch((error)=>console.error('Error loading image:', error));
}).catch((error) => console.error('Error loading image:', error));
},
// Action for deleting selected items
@@ -689,9 +689,9 @@ export const useSceneStore = create<SceneState>((set, get) => ({
// After all deletions, ensure selectedNodeIds is empty in the state.
set(state => ({
...state, // Preserve other state properties
selectedNodeIds: []
selectedNodeIds: []
}));
get().saveState(); // Save the final state once
}
},
@@ -746,7 +746,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
// 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.
@@ -816,7 +816,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
// 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) => {
@@ -873,16 +873,16 @@ export const useSceneStore = create<SceneState>((set, get) => ({
aspectRatioType: state.aspectRatioType,
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(',');
@@ -899,7 +899,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
// Recreate nodes
const restoredNodes = sceneData.nodes.map((nodeData: any) => {
const node = new Node(nodeData);
// Handle image restoration
if (node.type === 'image') {
const loadImage = async () => {
@@ -919,10 +919,10 @@ export const useSceneStore = create<SceneState>((set, get) => ({
};
loadImage();
}
return node;
});
set({
nodes: restoredNodes,
lineNodes: sceneData.lineNodes || [],
@@ -932,10 +932,10 @@ export const useSceneStore = create<SceneState>((set, get) => ({
fillColor: sceneData.fillColor || 'white',
aspectRatioType: sceneData.aspectRatioType || AspectRatioType.NONE
});
isRestoring = false;
get().saveState(); // Save the loaded state to history
return true;
} catch (error) {
console.error('Failed to import scene:', error);
@@ -948,14 +948,14 @@ export const useSceneStore = create<SceneState>((set, get) => ({
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);
},
@@ -968,7 +968,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
return false;
}
},
serializeSceneToString: async (): Promise<string> => {
return get().exportSceneAsJson();
},
@@ -987,8 +987,8 @@ export const useSceneStore = create<SceneState>((set, get) => ({
const maxZ = Math.max(...allZIndices, 0) + 1;
return {
nodes: state.nodes.map(node =>
nodeIds.includes(node.id)
nodes: state.nodes.map(node =>
nodeIds.includes(node.id)
? new Node({ ...node, zIndex: maxZ })
: node
),
@@ -1038,7 +1038,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
// Find the highest z-index among selected items
const selectedItems = allItems.filter(item => item.selected);
if (selectedItems.length === 0) return state;
const highestSelectedZ = Math.max(...selectedItems.map(item => item.z));
// Find the closest item above our selection
@@ -1047,7 +1047,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
// If no item above, swap with the highest non-selected item
if (!nextItem) {
const highestNonSelected = allItems.filter(item => !item.selected)
.sort((a, b) => b.z - a.z)[0];
.sort((a, b) => b.z - a.z)[0];
if (!highestNonSelected) return state;
// Swap positions
@@ -1118,7 +1118,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
// If no item below, swap with the lowest non-selected item
if (!nextItem) {
const lowestNonSelected = allItems.filter(item => !item.selected)
.sort((a, b) => a.z - b.z)[0];
.sort((a, b) => a.z - b.z)[0];
if (!lowestNonSelected) return state;
// Swap positions
@@ -1173,24 +1173,24 @@ export const useSceneStore = create<SceneState>((set, get) => ({
const node = get().nodes.find(n => n.id === id);
return node ? node.type === 'image' : false;
});
if (hasImageNodes) {
const firstNode = get().nodes.find(node => nodeIds.includes(node.id) && node.type === 'image');
if (firstNode && firstNode.imageFile) {
try {
const base64Image = await convertFileToBase64(firstNode.imageFile);
const { success, file } = await operation(true, base64Image, "Removing Background.");
if (success && file) {
// Create a new node instance
const updatedNode = new Node({
...firstNode,
imageFile: file
});
// Load the new image
await updatedNode.setImageFromFile(file);
// Update the store with the fully loaded node
set((state: SceneState) => ({
nodes: state.nodes.map((node: Node) => {
@@ -1200,7 +1200,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
return node;
}),
}));
get().saveState();
}
} catch (error) {
@@ -0,0 +1,428 @@
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 { PaintSurface } from "../PageDraw/PaintSurface";
import PromptEditor from "../PageDraw/PromptEditor/PromptEditor";
import { AspectRatioType } from "../PageDraw/stores/SceneState";
import { useEditStore } from "./stores/EditState";
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 transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({});
// 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
});
}
return (
<>
<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
initialPrompt=""
onPromptChange={(prompt: string) => {
console.log("Prompt changed:", prompt);
// Handle prompt changes here
}}
onRandomize={() => {
console.log("Randomize clicked");
// Handle randomize action here
}}
onVary={() => {
console.log("Vary clicked");
// Handle vary action here
}}
onAspectRatioChange={(ratio: string) => {
console.log("Aspect ratio:", ratio);
// Convert ratio string to AspectRatioType enum
const ratioToType = (ratio: string): AspectRatioType => {
switch (ratio) {
case "2:3":
return AspectRatioType.PORTRAIT;
case "3:2":
return AspectRatioType.LANDSCAPE;
case "1:1":
return AspectRatioType.SQUARE;
default:
return AspectRatioType.NONE;
}
};
const aspectRatioType = ratioToType(ratio);
store.setAspectRatioType(aspectRatioType);
onFitPressed()
}}
onEnqueuePressed={onEnqueuedPressed}
/>
</div>
<SideToolbar
className="fixed left-0 top-1/2 z-10 -translate-y-1/2 transform"
onSelect={(): void => {
store.setActiveTool("select");
}}
onAddShape={(shape: "rectangle" | "circle" | "triangle"): void => {
// Calculate center position based on canvas dimensions
const centerX = canvasWidth.current / 3;
const centerY = canvasHeight.current / 3;
if (shape === "rectangle") {
store.createRectangle(centerX, centerY);
} else if (shape === "circle") {
store.createCircle(centerX, centerY);
} else if (shape === "triangle") {
store.createTriangle(centerX, centerY);
}
}}
onPaintBrush={(hex: string, size: number, opacity: number): void => {
store.setActiveTool("draw");
store.setBrushColor(hex);
store.setBrushSize(size);
store.setBrushOpacity(opacity);
}}
onEraser={(size: number): void => {
store.setActiveTool("eraser");
store.setBrushSize(size);
}}
onCanvasBackground={(hex: string): void => {
console.log("Canvas background activated", { color: hex });
// Add background change logic here
// TODO: minor bug needs to update the preview panel
// Debounce also causes issues with real time color change.
store.setFillColor(hex);
}}
onGenerateImage={(): void => {
console.log("Generate image activated");
// Add image generation logic here
}}
onUploadImage={(): void => {
// Create input element dynamically like in PromptEditor
console.log("Upload image activated");
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.style.display = "none"; // Hide the input
// Attach to DOM
document.body.appendChild(input);
input.onchange = (e: Event) => {
console.log("File change event triggered");
const target = e.target as HTMLInputElement;
if (target.files) {
const files = Array.from(target.files);
console.log("Selected files:", files);
const imageFiles = files.filter((file) =>
file.type.startsWith("image/"),
);
console.log("Filtered image files:", imageFiles);
if (imageFiles.length > 0) {
console.log("Uploading images:", imageFiles);
handleImageUpload(imageFiles);
} else {
console.log("No valid image files selected");
}
} else {
console.log("No files selected");
}
// Clean up: remove input from DOM after use
document.body.removeChild(input);
};
// Reset value before click (for same file selection)
input.value = "";
input.click();
}}
onDelete={(): void => {
// This onDelete prop for SideToolbar might still be needed for the button
store.deleteSelectedItems();
}}
activeToolId={store.activeTool}
/>
<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 'LOCK':
store.toggleLock(store.selectedNodeIds);
break;
case 'REMOVE_BACKGROUND':
// returns a success if we have selected images only
await store.removeBackground(store.selectedNodeIds,
async (success: boolean, image_base64: string, message: string) => {
if (!success) {
console.error(message);
return { success: false };
}
try {
const response = await FalBackgroundRemoval({ base64_image: image_base64 });
if (response.status !== "success" || !("payload" in response)) {
console.error("Failed to remove background", response);
return { success: false };
}
const base64String = response.payload?.base64_bytes as string;
const binaryString = atob(base64String);
const bytes = Uint8Array.from(binaryString, c => c.charCodeAt(0));
const blob = new Blob([bytes], { type: "image/png" });
const file = new File([blob], "generated_image.png", { type: blob.type });
return { success: true, file };
} catch (error) {
console.error("Failed to remove background", error);
return { success: false };
}
});
break;
case 'BRING_TO_FRONT':
store.bringToFront(store.selectedNodeIds);
break;
case 'BRING_FORWARD':
store.bringForward(store.selectedNodeIds);
break;
case 'SEND_BACKWARD':
store.sendBackward(store.selectedNodeIds);
break;
case 'SEND_TO_BACK':
store.sendToBack(store.selectedNodeIds);
break;
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;
})}
>
<PaintSurface
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}
/>
</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;
File diff suppressed because it is too large. Load diff
@@ -76,6 +76,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";
export const PageEditor = () => {
useSignals();
@@ -571,6 +572,11 @@ export const PageEditor = () => {
/>
</div>
)}
{tabStore.activeTabId == "EDIT" && (
<div>
<PageEdit />
</div>
)}
</div>
);
};