mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 09:28:11 +00:00
Replicate PageDraw for PageEdit
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user