diff --git a/frontend/apps/editor3d/app/src/pages/PageDraw/stores/SceneState.ts b/frontend/apps/editor3d/app/src/pages/PageDraw/stores/SceneState.ts index 5413e157e0..936c1f58d0 100644 --- a/frontend/apps/editor3d/app/src/pages/PageDraw/stores/SceneState.ts +++ b/frontend/apps/editor3d/app/src/pages/PageDraw/stores/SceneState.ts @@ -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 => { - 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 => { + 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, 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, 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((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((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((set, get) => ({ get().saveState(); } }, - + updateNode: (id: string, updates: Partial, 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((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((set, get) => ({ } return node; }); - - - const newState = { + + + const newState = { nodes: newNodes }; @@ -306,16 +306,16 @@ export const useSceneStore = create((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((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((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((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((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((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((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((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((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((set, get) => ({ }; }); }, - + // Add new line node actions addLineNode: (lineNode: LineNode, shouldSaveState: boolean = true) => { set((state) => { @@ -546,33 +546,33 @@ export const useSceneStore = create((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, 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((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((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((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((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((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((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((set, get) => ({ // Scene save/load actions exportSceneAsJson: async () => { const state = get(); - + // Helper function to convert File to base64 const fileToBase64 = (file: File): Promise => { return new Promise((resolve, reject) => { @@ -873,16 +873,16 @@ export const useSceneStore = create((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((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((set, get) => ({ }; loadImage(); } - + return node; }); - + set({ nodes: restoredNodes, lineNodes: sceneData.lineNodes || [], @@ -932,10 +932,10 @@ export const useSceneStore = create((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((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((set, get) => ({ return false; } }, - + serializeSceneToString: async (): Promise => { return get().exportSceneAsJson(); }, @@ -987,8 +987,8 @@ export const useSceneStore = create((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((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((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((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((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((set, get) => ({ return node; }), })); - + get().saveState(); } } catch (error) { diff --git a/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx b/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx new file mode 100644 index 0000000000..12725f181f --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx @@ -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(1024); + const canvasHeight = useRef(1024); + // Add new state to track if user is selecting + const [isSelecting, setIsSelecting] = useState(false); + const [selectedModel, setSelectedModel] = useState("GPT-4o"); + // Create refs for stage and image + const stageRef = useRef({} 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: , + selected: selectedModel === "GPT-4o", + description: "High quality model", + badges: [{ label: "2 min.", icon: }], + }, + { + label: "FLUX.1 Kontext", + icon: , + selected: selectedModel === "FLUX.1 Kontext", + description: "Fast and high-quality model", + badges: [{ label: "20 sec.", icon: }], + }, + ]; + + 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 ( + <> +
+ { + 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} + /> +
+ { + 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} + /> +
+ { + 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; + })} + > + { + canvasWidth.current = width; + canvasHeight.current = height; + }} + fillColor={store.fillColor} + activeTool={store.activeTool} + brushColor={store.brushColor} + brushSize={store.brushSize} + onSelectionChange={setIsSelecting} + stageRef={stageRef} + transformerRefs={transformerRefs} + /> + +
+
+ + +
+ + ); +}; + +export default PageEdit; diff --git a/frontend/apps/editor3d/app/src/pages/PageEdit/stores/EditState.ts b/frontend/apps/editor3d/app/src/pages/PageEdit/stores/EditState.ts new file mode 100644 index 0000000000..452da992f4 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/PageEdit/stores/EditState.ts @@ -0,0 +1,1212 @@ +import { create } from 'zustand'; +import { LineNode, ActiveTool, AspectRatioType, convertFileToBase64, generateId } from '~/pages/PageDraw/stores/SceneState'; +import { SceneState } from '~/pages/PageEnigma/Editor/Commands'; +import { Node } from "~/pages/PageDraw/Node"; + +interface EditState { + // Nodes + 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, 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, 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: ActiveTool) => 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; + importSceneFromJson: (jsonString: string) => boolean; + saveSceneToFile: () => Promise; + loadSceneFromFile: (file: File) => Promise; + + // Add these two properties + serializeSceneToString: () => Promise; + loadSceneFromString: (jsonString: string) => boolean; + + // New functions + bringToFront: (nodeIds: string[]) => void; + sendToBack: (nodeIds: string[]) => void; + bringForward: (nodeIds: string[]) => void; + sendBackward: (nodeIds: string[]) => void; + removeBackground: (nodeIds: string[], operation: (success: boolean, base64_image: string, message: string) => Promise<{ success: boolean; file?: File }>) => Promise; + + // Add new lock action + toggleLock: (nodeIds: string[]) => void; + + // Add minimal aspect ratio property + aspectRatioType: AspectRatioType; + + // Add aspect ratio action + setAspectRatioType: (type: AspectRatioType) => void; + 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) +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((set, get) => ({ + // Initial state + nodes: [], + lineNodes: [], + selectedNodeIds: [], + 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) => { + 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, 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(); + }, + + // Node creation helpers + createRectangle: (x: number, y: number, width = 100, height = 100, fill = '#e0e0e0') => { + const node = new Node({ + id: generateId(), + type: 'rectangle', + x, + y, + width, + height, + fill, + stroke: '#444', + strokeWidth: 2, + draggable: true + }); + 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(), + type: 'circle', + x, + y, + width: radius * 2, + height: radius * 2, + fill, + stroke: '#333', + strokeWidth: 2, + draggable: true + }); + 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(), + type: 'triangle', + x, + y, + width, + height, + fill, + stroke: '#555', + strokeWidth: 2, + draggable: true + }); + 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 = { + 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, 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); + } + }, + + // 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 + const node = new Node({ + id: nodeId, + type: 'image', + x, + y, + width, + height, + fill: 'transparent', + stroke: '#333', + strokeWidth: 2, + draggable: true, + 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)); + + + }, + + // 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: ActiveTool) => set({ activeTool: tool }), + 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 => { + 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, + 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(','); + 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', + 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); + 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 => { + return get().exportSceneAsJson(); + }, + loadSceneFromString: (jsonString: string): boolean => { + return get().importSceneFromJson(jsonString); + }, + + // New functions + bringToFront: (nodeIds: string[]) => { + set(state => { + // Get highest zIndex from all nodes and line nodes + const allZIndices = [ + ...state.nodes.map(n => n.zIndex || 0), + ...state.lineNodes.map(n => n.zIndex || 0) + ]; + const maxZ = Math.max(...allZIndices, 0) + 1; + + return { + nodes: state.nodes.map(node => + nodeIds.includes(node.id) + ? new Node({ ...node, zIndex: maxZ }) + : node + ), + lineNodes: state.lineNodes.map(node => + nodeIds.includes(node.id) + ? { ...node, zIndex: maxZ } + : node + ) + }; + }); + get().saveState(); + }, + + sendToBack: (nodeIds: string[]) => { + set(state => { + // Get lowest zIndex from all nodes and line nodes + const allZIndices = [ + ...state.nodes.map(n => n.zIndex || 0), + ...state.lineNodes.map(n => n.zIndex || 0) + ]; + const minZ = Math.min(...allZIndices, 0) - 1; + + return { + nodes: state.nodes.map(node => + nodeIds.includes(node.id) + ? new Node({ ...node, zIndex: minZ }) + : node + ), + lineNodes: state.lineNodes.map(node => + nodeIds.includes(node.id) + ? { ...node, zIndex: minZ } + : node + ) + }; + }); + get().saveState(); + }, + + bringForward: (nodeIds: string[]) => { + set(state => { + // Combine all items with their current zIndex + const allItems = [ + ...state.nodes.map(n => ({ id: n.id, z: Math.max(0, n.zIndex || 0), selected: nodeIds.includes(n.id) })), + ...state.lineNodes.map(n => ({ id: n.id, z: Math.max(0, n.zIndex || 0), selected: nodeIds.includes(n.id) })) + ].sort((a, b) => a.z - b.z); + + // 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 + const nextItem = allItems.find(item => !item.selected && item.z > highestSelectedZ); + + // 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]; + if (!highestNonSelected) return state; + + // Swap positions + return { + nodes: state.nodes.map(node => { + if (nodeIds.includes(node.id)) { + return new Node({ ...node, zIndex: highestNonSelected.z }); + } + if (node.id === highestNonSelected.id) { + return new Node({ ...node, zIndex: highestSelectedZ }); + } + return node; + }), + lineNodes: state.lineNodes.map(node => { + if (nodeIds.includes(node.id)) { + return { ...node, zIndex: highestNonSelected.z }; + } + if (node.id === highestNonSelected.id) { + return { ...node, zIndex: highestSelectedZ }; + } + return node; + }) + }; + } + + const newZ = nextItem.z; + return { + nodes: state.nodes.map(node => { + if (nodeIds.includes(node.id)) { + return new Node({ ...node, zIndex: newZ }); + } + if (node.id === nextItem.id) { + return new Node({ ...node, zIndex: highestSelectedZ }); + } + return node; + }), + lineNodes: state.lineNodes.map(node => { + if (nodeIds.includes(node.id)) { + return { ...node, zIndex: newZ }; + } + if (node.id === nextItem.id) { + return { ...node, zIndex: highestSelectedZ }; + } + return node; + }) + }; + }); + get().saveState(); + }, + + sendBackward: (nodeIds: string[]) => { + set(state => { + // Combine all items with their current zIndex + const allItems = [ + ...state.nodes.map(n => ({ id: n.id, z: Math.max(0, n.zIndex || 0), selected: nodeIds.includes(n.id) })), + ...state.lineNodes.map(n => ({ id: n.id, z: Math.max(0, n.zIndex || 0), selected: nodeIds.includes(n.id) })) + ].sort((a, b) => b.z - a.z); + + // Find the lowest z-index among selected items + const selectedItems = allItems.filter(item => item.selected); + if (selectedItems.length === 0) return state; + + const lowestSelectedZ = Math.min(...selectedItems.map(item => item.z)); + + // Find the closest item below our selection + const nextItem = allItems.find(item => !item.selected && item.z < lowestSelectedZ); + + // 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]; + if (!lowestNonSelected) return state; + + // Swap positions + return { + nodes: state.nodes.map(node => { + if (nodeIds.includes(node.id)) { + return new Node({ ...node, zIndex: lowestNonSelected.z }); + } + if (node.id === lowestNonSelected.id) { + return new Node({ ...node, zIndex: lowestSelectedZ }); + } + return node; + }), + lineNodes: state.lineNodes.map(node => { + if (nodeIds.includes(node.id)) { + return { ...node, zIndex: lowestNonSelected.z }; + } + if (node.id === lowestNonSelected.id) { + return { ...node, zIndex: lowestSelectedZ }; + } + return node; + }) + }; + } + + const newZ = nextItem.z; + return { + nodes: state.nodes.map(node => { + if (nodeIds.includes(node.id)) { + return new Node({ ...node, zIndex: newZ }); + } + if (node.id === nextItem.id) { + return new Node({ ...node, zIndex: lowestSelectedZ }); + } + return node; + }), + lineNodes: state.lineNodes.map(node => { + if (nodeIds.includes(node.id)) { + return { ...node, zIndex: newZ }; + } + if (node.id === nextItem.id) { + return { ...node, zIndex: lowestSelectedZ }; + } + return node; + }) + }; + }); + get().saveState(); + }, + removeBackground: async (nodeIds: string[], operation: (success: boolean, base64_image: string, message: string) => Promise<{ success: boolean; file?: File }>) => { + const hasImageNodes = nodeIds.some(id => { + 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) => { + if (node.id === firstNode.id) { + return updatedNode; + } + return node; + }), + })); + + get().saveState(); + } + } catch (error) { + console.error("Error updating image:", error); + } + } + } + }, + + // Add toggleLock action + toggleLock: (nodeIds: string[]) => { + set((state) => { + // Update regular nodes + const updatedNodes = state.nodes.map(node => { + if (nodeIds.includes(node.id)) { + return new Node({ + ...node, + locked: !node.locked + }); + } + return node; + }); + + // Update line nodes + const updatedLineNodes = state.lineNodes.map(node => { + if (nodeIds.includes(node.id)) { + return { + ...node, + locked: !node.locked + }; + } + return node; + }); + + return { + nodes: updatedNodes, + lineNodes: updatedLineNodes + }; + }); + get().saveState(); + }, + + // Add aspect ratio actions + setAspectRatioType: (type: AspectRatioType) => { + set({ aspectRatioType: type }); + get().saveState(); + }, + + getAspectRatioDimensions: () => { + const { aspectRatioType } = get(); + switch (aspectRatioType) { + case AspectRatioType.PORTRAIT: + return { width: 683, height: 1024 }; + case AspectRatioType.LANDSCAPE: + return { width: 1024, height: 683 }; + case AspectRatioType.SQUARE: + return { width: 1024, height: 1024 }; + default: + return { width: 1024, height: 683 }; // Default to landscape + } + }, +})); diff --git a/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx b/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx index 85f8f84e44..acaed3512e 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx @@ -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 = () => { /> )} + {tabStore.activeTabId == "EDIT" && ( +
+ +
+ )} ); };