mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 17:28:24 -04:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54938b9088 | ||
|
|
ac7ffcc0db | ||
|
|
93227aa456 | ||
|
|
586d2acc2b | ||
|
|
685f8460d8 | ||
|
|
c5ea122cf4 | ||
|
|
82d36ebb16 | ||
|
|
abcd631038 | ||
|
|
42eae1e2c2 |
@@ -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 };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user