From 40d22b8ad0aa7f2811ea89b02ce46ce214462b6d Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 11 Jul 2025 15:58:33 +0700 Subject: [PATCH] 2d bug fixes (#711) **fixes:** - artcraft logo link removed - buggy layering system fixed - no more circle resize jumping issue - undo stack doesn't break images anymore - undo, redo, copy, paste, delete keybinds work on mac now - white bar at bottom removed --- .../src/components/signaled/TopBar/TopBar.tsx | 4 +- .../editor3d/app/src/pages/PageDraw/App.css | 13 +- .../app/src/pages/PageDraw/PaintSurface.tsx | 243 ++++++- .../PageDraw/components/ui/SideToolbar.tsx | 22 +- .../PageDraw/hooks/useCopyPasteHotkeys.ts | 56 +- .../pages/PageDraw/hooks/useDeleteHotkeys.ts | 57 +- .../PageDraw/hooks/useUndoRedoHotkeys.ts | 36 +- .../src/pages/PageDraw/stores/SceneState.ts | 627 ++++++++++-------- 8 files changed, 659 insertions(+), 399 deletions(-) diff --git a/frontend/apps/editor3d/app/src/components/signaled/TopBar/TopBar.tsx b/frontend/apps/editor3d/app/src/components/signaled/TopBar/TopBar.tsx index fc8d7b6c95..372d9855fc 100644 --- a/frontend/apps/editor3d/app/src/components/signaled/TopBar/TopBar.tsx +++ b/frontend/apps/editor3d/app/src/components/signaled/TopBar/TopBar.tsx @@ -125,14 +125,14 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => { aria-label="navigation" >
- +
ArtCraft Logo ArtCraft - +
(null); + const [isMiddleMousePressed, setIsMiddleMousePressed] = useState(false); useLayoutEffect(() => { const updateDimensions = () => { @@ -180,7 +180,8 @@ export const PaintSurface = ({ activeTool !== "draw" && activeTool !== "eraser" && activeTool !== "shape" && - nodeDraggable + nodeDraggable && + !isMiddleMousePressed ); }; @@ -211,6 +212,11 @@ export const PaintSurface = ({ "button" in e.evt && ((e.evt as MouseEvent).button === 1 || (e.evt as MouseEvent).button === 2) ) { + if ((e.evt as MouseEvent).button === 1) { + setIsMiddleMousePressed(true); + setIsDragging(true); + return; + } if (!isTransformerTarget) { setIsDragging(true); } @@ -541,6 +547,7 @@ export const PaintSurface = ({ setCurrentShapeId(null); setShapeStartPoint(null); setShapePreview(null); + setIsMiddleMousePressed(false); }; const [lastPinchDistance, setLastPinchDistance] = useState( @@ -783,6 +790,12 @@ export const PaintSurface = ({ return; } + // Prevent middle mouse button from interacting with nodes (canvas panning only) + if ("button" in e.evt && (e.evt as MouseEvent).button === 1) { + setIsMiddleMousePressed(true); + return; + } + if ("button" in e.evt && (e.evt as MouseEvent).button === 2) { const node = store.nodes.find((n) => n.id === nodeId); const lineNode = store.lineNodes.find((n) => n.id === nodeId); @@ -809,6 +822,15 @@ export const PaintSurface = ({ return; } + // If the node is already selected and we're not multi-selecting, preserve the current selection + // This allows dragging multiple selected nodes without changing the selection + const isAlreadySelected = selectedNodeIds.includes(nodeId); + + if (isAlreadySelected && !isMultiSelect && selectedNodeIds.length > 1) { + // Don't change selection - preserve multi-select for dragging + return; + } + store.selectNode(nodeId, isMultiSelect); }; @@ -859,6 +881,7 @@ export const PaintSurface = ({ newXMove = targetNode.x() - movingNode.width / 2; newYMove = targetNode.y() - movingNode.height / 2; } + store.moveNode(nodeId, newXMove, newYMove, dx, dy, false); }; @@ -905,15 +928,42 @@ export const PaintSurface = ({ }; const handleLineDragMove = ( e: Konva.KonvaEventObject, - _nodeId: string, + nodeId: string, ) => { - void _nodeId; const targetNode = e.target as Konva.Node & { lastX?: number; lastY?: number; }; + + const dx = targetNode.x() - (targetNode.lastX || targetNode.x()); + const dy = targetNode.y() - (targetNode.lastY || targetNode.y()); + targetNode.lastX = targetNode.x(); targetNode.lastY = targetNode.y(); + + // Move all selected nodes if multiple are selected + if (selectedNodeIds.length > 1 && selectedNodeIds.includes(nodeId)) { + selectedNodeIds.forEach((id) => { + if (id !== nodeId) { + const isLineNode = store.lineNodes.find((ln) => ln.id === id); + if (isLineNode) { + store.moveLineNode(id, dx, dy); + } else { + const regularNode = nodes.find((n) => n.id === id); + if (regularNode) { + store.moveNode( + id, + regularNode.x + dx, + regularNode.y + dy, + dx, + dy, + false, + ); + } + } + } + }); + } }; const handleLineDragEnd = ( e: Konva.KonvaEventObject, @@ -973,7 +1023,6 @@ export const PaintSurface = ({ rotation={lineNode.rotation || 0} offsetX={lineNode.offsetX || 0} offsetY={lineNode.offsetY || 0} - zIndex={lineNode.zIndex} listening={listeningEnabled} /> ); @@ -996,7 +1045,6 @@ export const PaintSurface = ({ scaleY={node.scaleY || 1} offsetX={node.offsetX || 0} offsetY={node.offsetY || 0} - zIndex={node.zIndex} draggable={draggableIfToolsNotActive( activeTool, node.draggable && node.locked == false, @@ -1035,7 +1083,6 @@ export const PaintSurface = ({ scaleY={node.scaleY || 1} offsetX={node.offsetX || 0} offsetY={node.offsetY || 0} - zIndex={node.zIndex} draggable={draggableIfToolsNotActive( activeTool, node.draggable && node.locked == false, @@ -1058,25 +1105,33 @@ export const PaintSurface = ({ } if (node.type === "triangle") { - const radius = Math.max(node.width, node.height) / 2; + const points = [ + node.width / 2, + 0, + 0, + node.height, + node.width, + node.height, + node.width / 2, + 0, + ]; return ( - )} { + // Don't render individual transformers when multiple nodes are selected + if (selectedNodeIds.length > 1) return null; + return [...nodes, ...store.lineNodes].map((node) => { const isSelected = selectedNodeIds.includes(node.id); if (!isSelected) return null; + const isShape = + node.type === "rectangle" || + node.type === "circle" || + node.type === "triangle"; + return ( ) => { const konvaNode = e.target; @@ -1201,11 +1263,23 @@ export const PaintSurface = ({ const finalRotation = konvaNode.rotation(); const finalScaleX = konvaNode.scaleX(); const finalScaleY = konvaNode.scaleY(); - const finalX = konvaNode.x(); - const finalY = konvaNode.y(); + let finalX = konvaNode.x(); + let finalY = konvaNode.y(); const finalOffsetX = konvaNode.offsetX(); const finalOffsetY = konvaNode.offsetY(); + // Find the corresponding node in the store to check its type + const storeNode = store.nodes.find((n) => n.id === nodeId); + + // For circles, convert from center positioning back to top-left positioning + if (storeNode && storeNode.type === "circle") { + const width = storeNode.width; + const height = storeNode.height; + + finalX = finalX - width / 2; + finalY = finalY - height / 2; + } + const isLineNode = store.lineNodes.find((ln) => ln.id === nodeId); if (isLineNode) { @@ -1245,30 +1319,51 @@ export const PaintSurface = ({ useEffect(() => { if (!stageRef.current) return; + + // Handle individual transformers (only visible when single selection) Object.entries(transformerRefs.current).forEach(([nodeId, transformer]) => { if (!transformer) return; const isSelected = selectedNodeIds.includes(nodeId); - transformer.visible(isSelected); + const shouldShowIndividual = isSelected && selectedNodeIds.length === 1; - if (isSelected) { + transformer.visible(shouldShowIndividual); + + if (shouldShowIndividual) { const node = stageRef.current!.findOne(`#${nodeId}`); if (node) { transformer.nodes([node]); transformer.getLayer()?.batchDraw(); } + } else { + // Clear transformer nodes when not showing individual transformer + transformer.nodes([]); } }); - if (multiSelectTransformerRef.current && selectedNodeIds.length > 1) { - const nodesToTransform = selectedNodeIds - .map((id) => stageRef.current!.findOne(`#${id}`)) - .filter((n): n is Konva.Node => Boolean(n)); - if (nodesToTransform.length > 0) { - multiSelectTransformerRef.current.nodes(nodesToTransform); + // Handle multi-select transformer (only visible when multiple selections) + if (multiSelectTransformerRef.current) { + if (selectedNodeIds.length > 1) { + const nodesToTransform = selectedNodeIds + .map((id) => stageRef.current!.findOne(`#${id}`)) + .filter((n): n is Konva.Node => Boolean(n)); + if (nodesToTransform.length > 0) { + multiSelectTransformerRef.current.nodes(nodesToTransform); + multiSelectTransformerRef.current.visible(true); + multiSelectTransformerRef.current.getLayer()?.batchDraw(); + } + } else { + // Clear multi-select transformer when not needed + multiSelectTransformerRef.current.nodes([]); + multiSelectTransformerRef.current.visible(false); multiSelectTransformerRef.current.getLayer()?.batchDraw(); } } + + // Force stage redraw to ensure transformers update properly + if (stageRef.current) { + stageRef.current.batchDraw(); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedNodeIds]); @@ -1363,6 +1458,98 @@ export const PaintSurface = ({ {/* Separate layer for transformers - no clipping so they remain visible when nodes extend beyond canvas */} {renderTransformers()} + {/* Multi-select transformer for when multiple nodes are selected */} + {selectedNodeIds.length > 1 && ( + { + const minSize = 5; + if (newBox.width < minSize || newBox.height < minSize) { + return oldBox; + } + return newBox; + }} + enabledAnchors={[ + "top-left", + "top-center", + "top-right", + "middle-right", + "middle-left", + "bottom-left", + "bottom-center", + "bottom-right", + ]} + rotateEnabled={true} + keepRatio={true} + centeredScaling={false} + padding={5} + ignoreStroke={true} + onTransformEnd={() => { + // Update all selected nodes after multi-select transform + selectedNodeIds.forEach((nodeId) => { + const konvaNode = stageRef.current!.findOne(`#${nodeId}`); + if (konvaNode) { + const finalRotation = konvaNode.rotation(); + const finalScaleX = konvaNode.scaleX(); + const finalScaleY = konvaNode.scaleY(); + let finalX = konvaNode.x(); + let finalY = konvaNode.y(); + const finalOffsetX = konvaNode.offsetX(); + const finalOffsetY = konvaNode.offsetY(); + + // Find the corresponding node in the store to check its type + const storeNode = store.nodes.find( + (n) => n.id === nodeId, + ); + + // For circles, convert from center positioning back to top-left positioning + if (storeNode && storeNode.type === "circle") { + const width = storeNode.width; + const height = storeNode.height; + finalX = finalX - width / 2; + finalY = finalY - height / 2; + } + + const isLineNode = store.lineNodes.find( + (ln) => ln.id === nodeId, + ); + + if (isLineNode) { + store.updateLineNode( + nodeId, + { + x: finalX, + y: finalY, + rotation: finalRotation, + scaleX: finalScaleX, + scaleY: finalScaleY, + offsetX: finalOffsetX, + offsetY: finalOffsetY, + }, + false, // Don't save state for each node + ); + } else { + store.updateNode( + nodeId, + { + x: finalX, + y: finalY, + rotation: finalRotation, + scaleX: finalScaleX, + scaleY: finalScaleY, + offsetX: finalOffsetX, + offsetY: finalOffsetY, + }, + false, // Don't save state for each node + ); + } + } + }); + // Save state once after all updates + store.saveState(); + }} + /> + )}
diff --git a/frontend/apps/editor3d/app/src/pages/PageDraw/components/ui/SideToolbar.tsx b/frontend/apps/editor3d/app/src/pages/PageDraw/components/ui/SideToolbar.tsx index c737aca465..73c550639c 100644 --- a/frontend/apps/editor3d/app/src/pages/PageDraw/components/ui/SideToolbar.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageDraw/components/ui/SideToolbar.tsx @@ -83,8 +83,14 @@ const SideToolbar: React.FC = ({ ? store.nodes.filter((node) => store.selectedNodeIds.includes(node.id)) : []; - // Get first selected node for color preview (when multiple selected, show first one's color) - const firstSelectedNode = selectedNodes.length > 0 ? selectedNodes[0] : null; + // Get only colorable nodes (shapes, not images) + const selectedColorableNodes = selectedNodes.filter( + (node) => node.type !== "image", + ); + + // Get first selected colorable node for color preview (when multiple selected, show first one's color) + const firstSelectedColorableNode = + selectedColorableNodes.length > 0 ? selectedColorableNodes[0] : null; // State for shape color picker const [shapeHsva, setShapeHsva] = useState({ @@ -96,7 +102,7 @@ const SideToolbar: React.FC = ({ // Update shape color picker when selection changes or store's shapeColor changes useEffect(() => { - const colorToUse = firstSelectedNode?.fill || store.shapeColor; + const colorToUse = firstSelectedColorableNode?.fill || store.shapeColor; if (colorToUse && colorToUse.startsWith("#")) { // Convert hex to HSVA (simplified - might want to change to proper converter later) @@ -120,7 +126,7 @@ const SideToolbar: React.FC = ({ setShapeHsva({ h, s: s * 100, v: v * 100, a: 1 }); } - }, [firstSelectedNode?.fill, store.shapeColor]); + }, [firstSelectedColorableNode?.fill, store.shapeColor]); const sendPaint = useDebounced< (hex: string, size: number, opacity: number) => void, @@ -136,11 +142,11 @@ const SideToolbar: React.FC = ({ (hex: string) => { store.setShapeColor(hex); - selectedNodes.forEach((node) => { + selectedColorableNodes.forEach((node) => { store.updateNode(node.id, { fill: hex }, false); }); - if (selectedNodes.length > 0) { + if (selectedColorableNodes.length > 0) { store.saveState(); } }, @@ -312,7 +318,7 @@ const SideToolbar: React.FC = ({ className={`glass ml-4 flex flex-col items-center gap-3 rounded-xl p-1.5 shadow-lg ${className}`} > {/* Shape Color Picker - appears above toolbar when shape is selected */} - {selectedNodes.length > 0 && ( + {selectedColorableNodes.length > 0 && (
diff --git a/frontend/apps/editor3d/app/src/pages/PageDraw/hooks/useCopyPasteHotkeys.ts b/frontend/apps/editor3d/app/src/pages/PageDraw/hooks/useCopyPasteHotkeys.ts index 028935aa5a..84eb53b376 100644 --- a/frontend/apps/editor3d/app/src/pages/PageDraw/hooks/useCopyPasteHotkeys.ts +++ b/frontend/apps/editor3d/app/src/pages/PageDraw/hooks/useCopyPasteHotkeys.ts @@ -1,42 +1,56 @@ -import { useEffect } from 'react'; +import { useEffect } from "react"; interface UseCopyPasteHotkeysProps { onCopy: () => void; onPaste: () => void; // Optional: to disable hotkeys when an input is focused - disableWhenInputFocused?: boolean; + disableWhenInputFocused?: boolean; } -export const useCopyPasteHotkeys = ({ - onCopy, - onPaste, - disableWhenInputFocused = true +export const useCopyPasteHotkeys = ({ + onCopy, + onPaste, + disableWhenInputFocused = true, }: UseCopyPasteHotkeysProps): void => { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (disableWhenInputFocused) { const target = event.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { return; // Ignore if typing in an input, textarea, etc. } } - if (event.ctrlKey || event.metaKey) { // Ctrl or Cmd key - if (event.key === 'c' || event.key === 'C') { - event.preventDefault(); // Prevent browser's default copy action - console.log("Copy") - onCopy(); - } else if (event.key === 'v' || event.key === 'V') { - event.preventDefault(); // Prevent browser's default paste action - console.log("Paste") - onPaste(); - } + const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); + const isC = event.key.toLowerCase() === "c"; + const isV = event.key.toLowerCase() === "v"; + + // Copy: Cmd+C (Mac) or Ctrl+C (Windows/Linux) + const isCopy = + (isMac ? event.metaKey : event.ctrlKey) && isC && !event.shiftKey; + + // Paste: Cmd+V (Mac) or Ctrl+V (Windows/Linux) + const isPaste = + (isMac ? event.metaKey : event.ctrlKey) && isV && !event.shiftKey; + + if (isCopy) { + event.preventDefault(); + console.log("Copy action triggered"); + onCopy(); + } else if (isPaste) { + event.preventDefault(); + console.log("Paste action triggered"); + onPaste(); } }; - window.addEventListener('keydown', handleKeyDown); + window.addEventListener("keydown", handleKeyDown); return () => { - window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener("keydown", handleKeyDown); }; - }, [onCopy, onPaste, disableWhenInputFocused]); // Add disableWhenInputFocused to dependencies -}; \ No newline at end of file + }, [onCopy, onPaste, disableWhenInputFocused]); +}; diff --git a/frontend/apps/editor3d/app/src/pages/PageDraw/hooks/useDeleteHotkeys.ts b/frontend/apps/editor3d/app/src/pages/PageDraw/hooks/useDeleteHotkeys.ts index fbdfe85d53..f5bbb31350 100644 --- a/frontend/apps/editor3d/app/src/pages/PageDraw/hooks/useDeleteHotkeys.ts +++ b/frontend/apps/editor3d/app/src/pages/PageDraw/hooks/useDeleteHotkeys.ts @@ -1,38 +1,51 @@ -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback } from "react"; interface DeleteHotkeysProps { onDelete: () => void; target?: Document | HTMLElement; } -export const useDeleteHotkeys = ({ onDelete, target = document }: DeleteHotkeysProps): void => { - const handleKeyDown = useCallback((event: KeyboardEvent) => { - // Check if the event target is an input field or contentEditable element - const target = event.target as HTMLElement; - if (target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || +export const useDeleteHotkeys = ({ + onDelete, + target = document, +}: DeleteHotkeysProps): void => { + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + // Check if the event target is an input field or contentEditable element + const target = event.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || target.isContentEditable || - target.getAttribute('role') === 'textbox') { - return; // Don't prevent default for input fields - } + target.getAttribute("role") === "textbox" + ) { + return; // Don't prevent default for input fields + } - const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; - const isDelete = event.key === 'Delete' || - event.key === 'Backspace' || - (isMac && event.metaKey && event.key === 'Backspace'); + const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); + const isDelete = + event.key === "Delete" || + event.key === "Backspace" || + (isMac && event.metaKey && event.key === "Backspace"); - if (isDelete) { - event.preventDefault(); - onDelete(); - } - }, [onDelete]); + if (isDelete) { + event.preventDefault(); + console.log("Delete action triggered"); + onDelete(); + } + }, + [onDelete], + ); useEffect(() => { const currentTarget = target || document; - currentTarget.addEventListener('keydown', handleKeyDown as EventListener); + currentTarget.addEventListener("keydown", handleKeyDown as EventListener); return () => { - currentTarget.removeEventListener('keydown', handleKeyDown as EventListener); + currentTarget.removeEventListener( + "keydown", + handleKeyDown as EventListener, + ); }; }, [handleKeyDown, target]); -}; \ No newline at end of file +}; diff --git a/frontend/apps/editor3d/app/src/pages/PageDraw/hooks/useUndoRedoHotkeys.ts b/frontend/apps/editor3d/app/src/pages/PageDraw/hooks/useUndoRedoHotkeys.ts index e638cf2b3c..de7b50c1b2 100644 --- a/frontend/apps/editor3d/app/src/pages/PageDraw/hooks/useUndoRedoHotkeys.ts +++ b/frontend/apps/editor3d/app/src/pages/PageDraw/hooks/useUndoRedoHotkeys.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect } from "react"; interface UndoRedoHotkeysProps { undo: () => void; @@ -6,19 +6,20 @@ interface UndoRedoHotkeysProps { target?: Document | HTMLElement; // Optional target, defaults to document } -export const useUndoRedoHotkeys = ({ undo, redo, target = document }: UndoRedoHotkeysProps): void => { +export const useUndoRedoHotkeys = ({ + undo, + redo, + target = document, +}: UndoRedoHotkeysProps): void => { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; - const isZ = event.key.toLowerCase() === 'z'; - const isY = event.key.toLowerCase() === 'y'; + const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); + const isZ = event.key.toLowerCase() === "z"; + const isY = event.key.toLowerCase() === "y"; - // Use a flag to ensure we only call undo/redo once per event, - // even if multiple conditions for redo are met. - let actionPerformed = false; + const isUndo = + (isMac ? event.metaKey : event.ctrlKey) && isZ && !event.shiftKey; - const isUndo = (isMac ? event.metaKey : event.ctrlKey) && isZ && !event.shiftKey; - // Redo conditions: // 1. Mac: Cmd + Shift + Z // 2. Windows/Linux: Ctrl + Y @@ -29,23 +30,24 @@ export const useUndoRedoHotkeys = ({ undo, redo, target = document }: UndoRedoHo if (isUndo) { event.preventDefault(); - console.log('Undo action triggered'); + console.log("Undo action triggered"); undo(); - actionPerformed = true; } else if (isRedoMac || isRedoWinY || isRedoWinShiftZ) { event.preventDefault(); - console.log('Redo action triggered'); + console.log("Redo action triggered"); redo(); - actionPerformed = true; } }; const currentTarget = target || document; // Ensure 'keydown' is used as some systems might not fire 'keypress' for Ctrl/Cmd combinations - currentTarget.addEventListener('keydown', handleKeyDown as EventListener); + currentTarget.addEventListener("keydown", handleKeyDown as EventListener); return () => { - currentTarget.removeEventListener('keydown', handleKeyDown as EventListener); + currentTarget.removeEventListener( + "keydown", + handleKeyDown as EventListener, + ); }; }, [undo, redo, target]); // Re-run effect if callbacks or target change -}; \ No newline at end of file +}; 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 c92e5e2515..a608aed255 100644 --- a/frontend/apps/editor3d/app/src/pages/PageDraw/stores/SceneState.ts +++ b/frontend/apps/editor3d/app/src/pages/PageDraw/stores/SceneState.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { Node } from "../Node"; +import { Node, NodeType } from "../Node"; // Add LineNode type export type LineNode = { @@ -57,6 +57,30 @@ export type ActiveTool = | "backgroundColor" | "shape"; +// Type for serialized node data stored in history +type SerializedNodeData = { + id: string; + x: number; + y: number; + width: number; + height: number; + fill: string; + type: NodeType; + stroke: string; + strokeWidth: number; + draggable: boolean; + imageUrl?: string; + imageFile?: File; + backgroundColor?: string; + rotation: number; + scaleX: number; + scaleY: number; + offsetX: number; + offsetY: number; + zIndex: number; + locked: boolean; +}; + interface SceneState { // Nodes nodes: Node[]; @@ -127,10 +151,10 @@ interface SceneState { ) => void; // History management - history: { nodes: Node[]; lineNodes: LineNode[] }[]; + history: { nodes: SerializedNodeData[]; lineNodes: LineNode[] }[]; historyIndex: number; - undo: () => void; - redo: () => void; + undo: () => Promise; + redo: () => Promise; saveState: () => void; // Add new actions for line nodes @@ -193,7 +217,8 @@ interface SceneState { serializeSceneToString: () => Promise; loadSceneFromString: (jsonString: string) => boolean; - // New functions + // Layer management functions + normalizeZIndices: () => void; bringToFront: (nodeIds: string[]) => void; sendToBack: (nodeIds: string[]) => void; bringForward: (nodeIds: string[]) => void; @@ -228,17 +253,11 @@ 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; - }), + ...nodes.map((n) => n.zIndex || 0), + ...lineNodes.map((n) => n.zIndex || 0), ]; - return allZIndices.length > 0 ? Math.max(...allZIndices) + 1 : 0; + return allZIndices.length > 0 ? Math.max(...allZIndices) + 1 : 1; }; export const useSceneStore = create((set, get) => ({ @@ -246,22 +265,16 @@ export const useSceneStore = create((set, get) => ({ nodes: [], lineNodes: [], selectedNodeIds: [], - clipboard: [], // Initialize clipboard + clipboard: [], history: [], historyIndex: -1, - - // Toolbar initial state activeTool: "select", brushColor: "#000000", brushSize: 5, brushOpacity: 1, fillColor: "white", - // Shape tool initial state currentShape: null, - // Default color for new shapes shapeColor: "#4d79b3", - - // Cursor initial state cursorPosition: null, cursorVisible: false, @@ -272,15 +285,15 @@ export const useSceneStore = create((set, get) => ({ addNode: (node: Node) => { set((state) => { const nextZ = getNextZIndex(state.nodes, state.lineNodes); - console.log(`NextZ for node ID ${node.id}: ${nextZ}`); + // console.log(`NextZ for node ID ${node.id}: ${nextZ}`); const newNode = new Node({ ...node, zIndex: nextZ, }); // console.log("New Node with ID:", newNode.id); - let nodes = [...state.nodes, newNode]; - console.log("Nodes after update"); - console.log(nodes); + const nodes = [...state.nodes, newNode]; + // console.log("Nodes after update"); + // console.log(nodes); return { nodes: nodes }; }); get().saveState(); @@ -313,7 +326,7 @@ export const useSceneStore = create((set, get) => ({ ) => { set((state) => { const newNodes = state.nodes.map((node) => { - let newZIndex = + const newZIndex = updates.zIndex !== undefined ? updates.zIndex : node.zIndex; if (node.id === id) { @@ -496,6 +509,8 @@ export const useSceneStore = create((set, get) => ({ scaleY: node.scaleY || 1, offsetX: node.offsetX || 0, // Include offset in history offsetY: node.offsetY || 0, // Include offset in history + zIndex: node.zIndex || 0, + locked: node.locked || false, })), lineNodes: JSON.parse(JSON.stringify(state.lineNodes)), }; @@ -510,126 +525,103 @@ export const useSceneStore = create((set, get) => ({ }); }, - undo: () => { - set((state) => { - if (state.historyIndex < 0) return state; + undo: async () => { + const state = get(); + if (state.historyIndex < 0) return; - const newIndex = state.historyIndex - 1; + 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, - }; - } + // If we're going back to before the first saved state, return to initial empty state + if (newIndex < 0) { + set({ + nodes: [], + lineNodes: [], + selectedNodeIds: [], + historyIndex: newIndex, + }); + return; + } - const previousState = state.history[newIndex]; + const previousState = state.history[newIndex]; - // Set the restoring flag - isRestoring = true; + // 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); + // Recreate nodes and load images BEFORE setting state + const restoredNodes = await Promise.all( + previousState.nodes.map(async (nodeData: SerializedNodeData) => { + const node = new Node(nodeData); - // If it's an image node, start loading the image immediately + // If it's an image node, load the image before returning the node 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); + try { + if (node.imageUrl) { + await node.setImageFromUrl(node.imageUrl); + } else if (node.imageFile) { + await node.setImageFromFile(node.imageFile); } - }; - - loadImage(); + } catch (error) { + console.error("Failed to restore image:", error); + } } return node; - }); + }), + ); - // Reset the flag if no images to load - const hasImages = restoredNodes.some((node) => node.type === "image"); - if (!hasImages) { - isRestoring = false; - } + // Reset the flag + isRestoring = false; - return { - nodes: restoredNodes, - lineNodes: previousState.lineNodes, - selectedNodeIds: [], // Clear selection on undo - historyIndex: newIndex, - }; + // Set the state with fully loaded nodes + set({ + nodes: restoredNodes, + lineNodes: previousState.lineNodes, + selectedNodeIds: [], // Clear selection on undo + historyIndex: newIndex, }); }, - redo: () => { - set((state) => { - if (state.historyIndex >= state.history.length - 1) return state; + redo: async () => { + const state = get(); + if (state.historyIndex >= state.history.length - 1) return; - const newIndex = state.historyIndex + 1; - const nextState = state.history[newIndex]; + const newIndex = state.historyIndex + 1; + const nextState = state.history[newIndex]; - // Set the restoring flag - isRestoring = true; + // 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); + // Recreate nodes and load images BEFORE setting state + const restoredNodes = await Promise.all( + nextState.nodes.map(async (nodeData: SerializedNodeData) => { + const node = new Node(nodeData); - // If it's an image node, start loading the image immediately + // If it's an image node, load the image before returning the node 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); + try { + if (node.imageUrl) { + await node.setImageFromUrl(node.imageUrl); + } else if (node.imageFile) { + await node.setImageFromFile(node.imageFile); } - }; - - loadImage(); + } catch (error) { + console.error("Failed to restore image:", error); + } } return node; - }); + }), + ); - // Reset the flag if no images to load - const hasImages = restoredNodes.some((node) => node.type === "image"); - if (!hasImages) { - isRestoring = false; - } + // Reset the flag + isRestoring = false; - return { - nodes: restoredNodes, - lineNodes: nextState.lineNodes, - selectedNodeIds: [], // Clear selection on redo - historyIndex: newIndex, - }; + // Set the state with fully loaded nodes + set({ + nodes: restoredNodes, + lineNodes: nextState.lineNodes, + selectedNodeIds: [], // Clear selection on redo + historyIndex: newIndex, }); }, @@ -729,12 +721,12 @@ export const useSceneStore = create((set, get) => ({ // 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, - }); + // 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; @@ -1082,34 +1074,36 @@ export const useSceneStore = create((set, get) => ({ }; // Recreate nodes - const restoredNodes = sceneData.nodes.map((nodeData: any) => { - const node = new Node(nodeData); + const restoredNodes = sceneData.nodes.map( + (nodeData: SerializedNodeData & { imageDataUrl?: string }) => { + 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); + // 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); } - get().updateNode(node.id, node, false); - } catch (error) { - console.error("Failed to restore image:", error); - } - }; - loadImage(); - } + }; + loadImage(); + } - return node; - }); + return node; + }, + ); set({ nodes: restoredNodes, @@ -1164,25 +1158,88 @@ export const useSceneStore = create((set, get) => ({ return get().importSceneFromJson(jsonString); }, - // New functions - bringToFront: (nodeIds: string[]) => { + // Helper function to normalize all z-indices + normalizeZIndices: () => { 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; + // Get all items and sort by current z-index + const allItems = [ + ...state.nodes.map((n) => ({ + id: n.id, + z: n.zIndex || 0, + type: "node" as const, + })), + ...state.lineNodes.map((n) => ({ + id: n.id, + z: n.zIndex || 0, + type: "lineNode" as const, + })), + ].sort((a, b) => a.z - b.z); + + // Reassign z-indices starting from 1 (above canvas background at -1) + const zIndexMap = new Map(); + allItems.forEach((item, index) => { + zIndexMap.set(item.id, index + 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, - ), + nodes: state.nodes.map((node) => { + const newZ = zIndexMap.get(node.id) || 1; + return new Node({ ...node, zIndex: newZ }); + }), + lineNodes: state.lineNodes.map((node) => { + const newZ = zIndexMap.get(node.id) || 1; + return { ...node, zIndex: newZ }; + }), + }; + }); + }, + + // Layer management functions + bringToFront: (nodeIds: string[]) => { + set((state) => { + if (nodeIds.length === 0) return state; + + // First normalize all z-indices to ensure clean state + const allItems = [ + ...state.nodes.map((n) => ({ + id: n.id, + z: n.zIndex || 0, + type: "node" as const, + })), + ...state.lineNodes.map((n) => ({ + id: n.id, + z: n.zIndex || 0, + type: "lineNode" as const, + })), + ].sort((a, b) => a.z - b.z); + + // Create normalized z-index mapping + const zIndexMap = new Map(); + let currentZ = 1; // Start from 1 to stay above canvas background + + // Assign z-indices: non-selected items first, then selected items on top + allItems.forEach((item) => { + if (!nodeIds.includes(item.id)) { + zIndexMap.set(item.id, currentZ++); + } + }); + + // Put selected items on top + allItems.forEach((item) => { + if (nodeIds.includes(item.id)) { + zIndexMap.set(item.id, currentZ++); + } + }); + + return { + nodes: state.nodes.map((node) => { + const newZ = zIndexMap.get(node.id) || 1; + return new Node({ ...node, zIndex: newZ }); + }), + lineNodes: state.lineNodes.map((node) => { + const newZ = zIndexMap.get(node.id) || 1; + return { ...node, zIndex: newZ }; + }), }; }); get().saveState(); @@ -1190,22 +1247,52 @@ export const useSceneStore = create((set, get) => ({ 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; + if (nodeIds.length === 0) return state; + + // Get all items sorted by current z-index + const allItems = [ + ...state.nodes.map((n) => ({ + id: n.id, + z: n.zIndex || 0, + type: "node" as const, + })), + ...state.lineNodes.map((n) => ({ + id: n.id, + z: n.zIndex || 0, + type: "lineNode" as const, + })), + ].sort((a, b) => a.z - b.z); + + // Create normalized z-index mapping + const zIndexMap = new Map(); + let currentZ = 1; // Start from 1 to stay above canvas background + + // Get selected items and sort by current z-index (highest first) + // This ensures the item that was on top goes to the very back + const selectedItems = allItems + .filter((item) => nodeIds.includes(item.id)) + .sort((a, b) => b.z - a.z); // Reverse order: highest z-index first + + // Assign z-indices: selected items first (in reverse order), then non-selected items + selectedItems.forEach((item) => { + zIndexMap.set(item.id, currentZ++); + }); + + allItems.forEach((item) => { + if (!nodeIds.includes(item.id)) { + zIndexMap.set(item.id, currentZ++); + } + }); 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, - ), + nodes: state.nodes.map((node) => { + const newZ = zIndexMap.get(node.id) || 1; + return new Node({ ...node, zIndex: newZ }); + }), + lineNodes: state.lineNodes.map((node) => { + const newZ = zIndexMap.get(node.id) || 1; + return { ...node, zIndex: newZ }; + }), }; }); get().saveState(); @@ -1213,80 +1300,55 @@ export const useSceneStore = create((set, get) => ({ bringForward: (nodeIds: string[]) => { set((state) => { - // Combine all items with their current zIndex + if (nodeIds.length === 0) return state; + + // Get all items sorted by current z-index (this represents the layer stack) const allItems = [ ...state.nodes.map((n) => ({ id: n.id, - z: Math.max(0, n.zIndex || 0), - selected: nodeIds.includes(n.id), + z: n.zIndex || 0, + type: "node" as const, })), ...state.lineNodes.map((n) => ({ id: n.id, - z: Math.max(0, n.zIndex || 0), - selected: nodeIds.includes(n.id), + z: n.zIndex || 0, + type: "lineNode" as const, })), ].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; + // Create array to track new layer order + const newOrder = [...allItems]; - const highestSelectedZ = Math.max(...selectedItems.map((item) => item.z)); + // Process selected items from highest to lowest z-index to avoid conflicts + const selectedIndices = allItems + .map((item, index) => ({ item, index })) + .filter(({ item }) => nodeIds.includes(item.id)) + .sort((a, b) => b.index - a.index); // Sort by position in array (highest z-index first) - // Find the closest item above our selection - const nextItem = allItems.find( - (item) => !item.selected && item.z > highestSelectedZ, - ); + // Move each selected item forward by one position if possible + selectedIndices.forEach(({ index }) => { + if (index < newOrder.length - 1) { + // Swap with the item directly in front of it + const temp = newOrder[index]; + newOrder[index] = newOrder[index + 1]; + newOrder[index + 1] = temp; + } + }); - // 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; + // Reassign z-indices based on new order (starting from 1 to stay above canvas) + const zIndexMap = new Map(); + newOrder.forEach((item, index) => { + zIndexMap.set(item.id, index + 1); + }); - // 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; + const newZ = zIndexMap.get(node.id) || 1; + return new Node({ ...node, zIndex: newZ }); }), 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; + const newZ = zIndexMap.get(node.id) || 1; + return { ...node, zIndex: newZ }; }), }; }); @@ -1295,80 +1357,55 @@ export const useSceneStore = create((set, get) => ({ sendBackward: (nodeIds: string[]) => { set((state) => { - // Combine all items with their current zIndex + if (nodeIds.length === 0) return state; + + // Get all items sorted by current z-index (this represents the layer stack) const allItems = [ ...state.nodes.map((n) => ({ id: n.id, - z: Math.max(0, n.zIndex || 0), - selected: nodeIds.includes(n.id), + z: n.zIndex || 0, + type: "node" as const, })), ...state.lineNodes.map((n) => ({ id: n.id, - z: Math.max(0, n.zIndex || 0), - selected: nodeIds.includes(n.id), + z: n.zIndex || 0, + type: "lineNode" as const, })), - ].sort((a, b) => b.z - a.z); + ].sort((a, b) => a.z - b.z); - // Find the lowest z-index among selected items - const selectedItems = allItems.filter((item) => item.selected); - if (selectedItems.length === 0) return state; + // Create array to track new layer order + const newOrder = [...allItems]; - const lowestSelectedZ = Math.min(...selectedItems.map((item) => item.z)); + // Process selected items from lowest to highest z-index to avoid conflicts + const selectedIndices = allItems + .map((item, index) => ({ item, index })) + .filter(({ item }) => nodeIds.includes(item.id)) + .sort((a, b) => a.index - b.index); // Sort by position in array (lowest z-index first) - // Find the closest item below our selection - const nextItem = allItems.find( - (item) => !item.selected && item.z < lowestSelectedZ, - ); + // Move each selected item backward by one position if possible + selectedIndices.forEach(({ index }) => { + if (index > 0) { + // Swap with the item directly behind it + const temp = newOrder[index]; + newOrder[index] = newOrder[index - 1]; + newOrder[index - 1] = temp; + } + }); - // 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; + // Reassign z-indices based on new order (starting from 1 to stay above canvas) + const zIndexMap = new Map(); + newOrder.forEach((item, index) => { + zIndexMap.set(item.id, index + 1); + }); - // 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; + const newZ = zIndexMap.get(node.id) || 1; + return new Node({ ...node, zIndex: newZ }); }), 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; + const newZ = zIndexMap.get(node.id) || 1; + return { ...node, zIndex: newZ }; }), }; });