Compare commits

...
8 changed files with 659 additions and 399 deletions
@@ -125,14 +125,14 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
aria-label="navigation"
>
<div className="flex items-center gap-7">
<a href="/">
<div>
<span className="sr-only">ArtCraft</span>
<img
className="h-[24px] w-auto"
src="/resources/images/artcraft-logo-3.png"
alt="Logo ArtCraft"
/>
</a>
</div>
<MenuIconSelector
menuItems={appMenuTabs}
activeMenu={tabStore.activeTabId}
@@ -6,14 +6,15 @@
@custom-variant dark (&:is(.dark *));
.pegboard-bg {
position: fixed;
inset: 0;
z-index: 0;
background-color: #1b1b1b;
/* background-image: radial-gradient(#bbb 1px, transparent 1px),
radial-gradient(#bbb 1px, transparent 1px); */
background-size: 50px 50px;
background-position: 0 0, 15px 15px;
background-position:
0 0,
15px 15px;
}
/*
@theme inline {
@@ -139,18 +140,18 @@
.brush-picker .react-colorful__saturation {
border: 1px solid rgb(255, 255, 255);
border-radius: 15px 15px 15px 15px; /* top-rounded square */
border-radius: 15px 15px 15px 15px; /* top-rounded square */
margin-bottom: 12px;
}
.brush-picker .react-colorful__hue {
border: 1px solid rgba(255, 255, 255, 0.2); /* bar thickness */
border-radius: 30px 30px 30px 30px; /* rounded bottom only */
border: 1px solid rgba(255, 255, 255, 0.2); /* bar thickness */
border-radius: 30px 30px 30px 30px; /* rounded bottom only */
margin-bottom: 12px;
}
.brush-picker .react-colorful__alpha {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 15px 15px 15px 15px;
margin-bottom: 12px;
margin-bottom: 12px;
}
@@ -7,7 +7,6 @@ import {
Circle,
Line,
Image,
RegularPolygon,
Transformer,
} from "react-konva";
import Konva from "konva";
@@ -93,6 +92,7 @@ export const PaintSurface = ({
width: number;
height: number;
} | null>(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<number | null>(
@@ -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<DragEvent>,
_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<DragEvent>,
@@ -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 (
<RegularPolygon
<Line
key={node.id}
id={node.id}
x={node.x + node.width / 2}
y={node.y + node.height / 2}
sides={3}
radius={radius}
x={node.x}
y={node.y}
points={points}
fill={node.fill}
stroke={node.stroke}
strokeWidth={0}
closed={true}
rotation={node.rotation || 0}
scaleX={node.scaleX || 1}
scaleY={node.scaleY || 1}
offsetX={node.offsetX || 0}
offsetY={node.offsetY || 0}
zIndex={node.zIndex}
draggable={draggableIfToolsNotActive(
activeTool,
node.draggable && node.locked == false,
@@ -1116,7 +1171,6 @@ export const PaintSurface = ({
offsetX={node.offsetX || 0}
offsetY={node.offsetY || 0}
listening={false}
zIndex={node.zIndex}
/>
)}
<Image
@@ -1158,10 +1212,18 @@ export const PaintSurface = ({
// Separate function to render transformers on their own layer
const renderTransformers = () => {
// 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 (
<Transformer
key={`transformer-${node.id}`}
@@ -1189,10 +1251,10 @@ export const PaintSurface = ({
"bottom-right",
]}
rotateEnabled={true}
keepRatio={true}
centeredScaling={true}
keepRatio={!isShape}
centeredScaling={false}
padding={5}
shiftBehavior="inverted"
shiftBehavior={isShape ? "normal" : "inverted"}
ignoreStroke={true}
onTransformEnd={(e: Konva.KonvaEventObject<Event>) => {
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<Konva.Node>(`#${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<Konva.Node>(`#${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 */}
<Layer listening={true} zIndex={1000}>
{renderTransformers()}
{/* Multi-select transformer for when multiple nodes are selected */}
{selectedNodeIds.length > 1 && (
<Transformer
ref={multiSelectTransformerRef}
boundBoxFunc={(oldBox, newBox) => {
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();
}}
/>
)}
</Layer>
</Stage>
</div>
@@ -83,8 +83,14 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
? 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<HsvaColor>({
@@ -96,7 +102,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
// 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<SideToolbarProps> = ({
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<SideToolbarProps> = ({
(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<SideToolbarProps> = ({
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 && (
<div className="glass absolute -top-14 left-1/2 -translate-x-1/2 rounded-xl border-2 border-primary/50 shadow-lg">
<div className="relative">
<button
@@ -321,7 +327,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
>
<span
className="inline-block h-5 w-5 rounded-full border-2 border-white"
style={{ backgroundColor: firstSelectedNode?.fill }}
style={{ backgroundColor: firstSelectedColorableNode?.fill }}
/>
</button>
@@ -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
};
}, [onCopy, onPaste, disableWhenInputFocused]);
};
@@ -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]);
};
};
@@ -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
};
};
@@ -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<void>;
redo: () => Promise<void>;
saveState: () => void;
// Add new actions for line nodes
@@ -193,7 +217,8 @@ interface SceneState {
serializeSceneToString: () => Promise<string>;
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<SceneState>)
const getNextZIndex = (nodes: Node[], lineNodes: LineNode[]): number => {
const allZIndices = [
...nodes.map((n, index) => {
//console.log(`Node: ${JSON.stringify(n)}, Index: ${index}`);
return n.zIndex || 0;
}),
...lineNodes.map((n, index) => {
//console.log(`LineNode: ${JSON.stringify(n)}, Index: ${index}`);
return n.zIndex || 0;
}),
...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<SceneState>((set, get) => ({
@@ -246,22 +265,16 @@ export const useSceneStore = create<SceneState>((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<SceneState>((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<SceneState>((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<SceneState>((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<SceneState>((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<SceneState>((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<SceneState>((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<SceneState>((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<string, number>();
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<string, number>();
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<SceneState>((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<string, number>();
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<SceneState>((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<string, number>();
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<SceneState>((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<string, number>();
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 };
}),
};
});