FlowWeb / src /features /canvas /components /nodes /ImageNode.jsx
danylokhodus's picture
Optimize ImageNode resizing: use direct DOM updates and set node size to 100% when selected/resizing to make it responsive on all 8 handles without lag
f2546a6
Raw
History Blame Contribute Delete
14.5 kB
import React, { useState, useEffect, useRef } from 'react';
import { Handle, Position, useUpdateNodeInternals, useReactFlow } from '@xyflow/react';
import { Image as ImageIcon, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import axios from 'axios';
const ImageNode = ({ id, data, selected, width, height }) => {
const { t } = useTranslation();
const updateNodeInternals = useUpdateNodeInternals();
const { getZoom, getNode, setNodes } = useReactFlow();
const [imageUrl, setImageUrl] = useState(data.url || '');
const [loading, setLoading] = useState(false);
const [isShiftPressed, setIsShiftPressed] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const imageRatioRef = useRef(1);
// Load large data from backend on mount if not loaded
useEffect(() => {
if (!imageUrl && data.hasLargeData) {
setLoading(true);
const API_URL = import.meta.env.VITE_API_URL;
axios.get(`${API_URL}/TaskNodes/${id}/data`)
.then(res => {
if (res.data && res.data.data) {
setImageUrl(res.data.data);
if (data.updateNodeData) {
data.updateNodeData(id, { url: res.data.data, isLoadedFromBackend: true });
}
}
})
.catch(err => console.error('[ImageNode] Error loading large data:', err))
.finally(() => setLoading(false));
}
}, [id, imageUrl, data.hasLargeData]);
// Sync if data changes from remote
useEffect(() => {
if (data.url && data.url !== imageUrl) {
setImageUrl(data.url);
}
}, [data.url]);
// Re-calculate handles when data dimensions update
useEffect(() => {
updateNodeInternals(id);
}, [id, data.width, data.height, updateNodeInternals]);
// Sync aspect ratio ref if size is changed
useEffect(() => {
if (data.width && data.height && data.height !== 'auto') {
imageRatioRef.current = data.width / data.height;
}
}, [data.width, data.height]);
// Listen to Shift key events globally to toggle aspect ratio constraint
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Shift') {
setIsShiftPressed(true);
}
};
const handleKeyUp = (e) => {
if (e.key === 'Shift') {
setIsShiftPressed(false);
}
};
const handleBlur = () => {
setIsShiftPressed(false);
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', handleBlur);
};
}, []);
const handleImageLoad = (e) => {
const { naturalWidth, naturalHeight } = e.target;
if (naturalWidth && naturalHeight) {
const ratio = naturalWidth / naturalHeight;
imageRatioRef.current = ratio;
// Only set initial size if data.height is 'auto' or not set in pixels
if (!data.height || data.height === 'auto') {
const initialWidth = data.width || 300;
const calculatedHeight = Math.round(initialWidth / ratio);
if (data.updateNodeData) {
data.updateNodeData(id, { width: initialWidth, height: calculatedHeight });
}
}
}
};
const handleResizeStart = (e, direction) => {
e.stopPropagation();
e.preventDefault();
const node = getNode(id);
if (!node) return;
setIsResizing(true);
const startX = e.clientX;
const startY = e.clientY;
const startPos = { ...node.position };
const startSize = {
width: node.width || node.style?.width || data.width || 300,
height: node.height || node.style?.height || data.height || 200
};
const ratio = imageRatioRef.current || (startSize.width / startSize.height) || 1;
const zoom = getZoom();
let finalWidth = startSize.width;
let finalHeight = startSize.height;
let finalX = startPos.x;
let finalY = startPos.y;
const nodeElement = document.querySelector(`.react-flow__node[data-id="${id}"]`);
const handlePointerMove = (moveEvent) => {
const dx = (moveEvent.clientX - startX) / zoom;
const dy = (moveEvent.clientY - startY) / zoom;
const shiftPressed = moveEvent.shiftKey || isShiftPressed;
const isCorner = ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(direction);
const shouldLockRatio = isCorner || shiftPressed;
if (shouldLockRatio) {
// --- PROPORTIONAL RESIZING ---
if (direction === 'right') {
finalWidth = Math.max(50, startSize.width + dx);
finalHeight = Math.round(finalWidth / ratio);
finalY = startPos.y;
} else if (direction === 'left') {
finalWidth = Math.max(50, startSize.width - dx);
finalHeight = Math.round(finalWidth / ratio);
finalX = startPos.x + (startSize.width - finalWidth);
finalY = startPos.y;
} else if (direction === 'bottom') {
finalHeight = Math.max(50, startSize.height + dy);
finalWidth = Math.round(finalHeight * ratio);
finalX = startPos.x;
} else if (direction === 'top') {
finalHeight = Math.max(50, startSize.height - dy);
finalWidth = Math.round(finalHeight * ratio);
finalY = startPos.y + (startSize.height - finalHeight);
finalX = startPos.x;
} else if (direction === 'bottom-right') {
if (Math.abs(dx) > Math.abs(dy)) {
finalWidth = Math.max(50, startSize.width + dx);
finalHeight = Math.round(finalWidth / ratio);
} else {
finalHeight = Math.max(50, startSize.height + dy);
finalWidth = Math.round(finalHeight * ratio);
}
finalX = startPos.x;
finalY = startPos.y;
} else if (direction === 'bottom-left') {
if (Math.abs(dx) > Math.abs(dy)) {
finalWidth = Math.max(50, startSize.width - dx);
finalHeight = Math.round(finalWidth / ratio);
} else {
finalHeight = Math.max(50, startSize.height + dy);
finalWidth = Math.round(finalHeight * ratio);
}
finalX = startPos.x + (startSize.width - finalWidth);
finalY = startPos.y;
} else if (direction === 'top-right') {
if (Math.abs(dx) > Math.abs(dy)) {
finalWidth = Math.max(50, startSize.width + dx);
finalHeight = Math.round(finalWidth / ratio);
} else {
finalHeight = Math.max(50, startSize.height - dy);
finalWidth = Math.round(finalHeight * ratio);
}
finalX = startPos.x;
finalY = startPos.y + (startSize.height - finalHeight);
} else if (direction === 'top-left') {
if (Math.abs(dx) > Math.abs(dy)) {
finalWidth = Math.max(50, startSize.width - dx);
finalHeight = Math.round(finalWidth / ratio);
} else {
finalHeight = Math.max(50, startSize.height - dy);
finalWidth = Math.round(finalHeight * ratio);
}
finalX = startPos.x + (startSize.width - finalWidth);
finalY = startPos.y + (startSize.height - finalHeight);
}
} else {
// --- DISTORTION RESIZING ---
if (direction === 'right') {
finalWidth = Math.max(50, startSize.width + dx);
} else if (direction === 'left') {
finalWidth = Math.max(50, startSize.width - dx);
finalX = startPos.x + (startSize.width - finalWidth);
} else if (direction === 'bottom') {
finalHeight = Math.max(50, startSize.height + dy);
} else if (direction === 'top') {
finalHeight = Math.max(50, startSize.height - dy);
finalY = startPos.y + (startSize.height - finalHeight);
} else if (direction === 'bottom-right') {
finalWidth = Math.max(50, startSize.width + dx);
finalHeight = Math.max(50, startSize.height + dy);
} else if (direction === 'bottom-left') {
finalWidth = Math.max(50, startSize.width - dx);
finalHeight = Math.max(50, startSize.height + dy);
finalX = startPos.x + (startSize.width - finalWidth);
} else if (direction === 'top-right') {
finalWidth = Math.max(50, startSize.width + dx);
finalHeight = Math.max(50, startSize.height - dy);
finalY = startPos.y + (startSize.height - finalHeight);
} else if (direction === 'top-left') {
finalWidth = Math.max(50, startSize.width - dx);
finalHeight = Math.max(50, startSize.height - dy);
finalX = startPos.x + (startSize.width - finalWidth);
finalY = startPos.y + (startSize.height - finalHeight);
}
}
// Direct DOM update for 60fps buttery smooth dragging
if (nodeElement) {
nodeElement.style.width = `${finalWidth}px`;
nodeElement.style.height = `${finalHeight}px`;
nodeElement.style.transform = `translate(${finalX}px, ${finalY}px)`;
}
};
const handlePointerUp = () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
// React state update on drop to sync react-flow store
setNodes((nds) =>
nds.map((n) => {
if (n.id === id) {
return {
...n,
position: { x: finalX, y: finalY },
width: finalWidth,
height: finalHeight,
style: { ...n.style, width: finalWidth, height: finalHeight }
};
}
return n;
})
);
// Save new dimensions and position to database, history and broadcast
if (data.updateNodeData) {
data.updateNodeData(id, {
width: finalWidth,
height: finalHeight,
x: finalX,
y: finalY
});
}
setIsResizing(false);
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
};
const isCustomSized = width !== undefined || isResizing;
const styleWidth = isCustomSized ? '100%' : (data.width || 300);
const styleHeight = isCustomSized ? '100%' : (data.height || 'auto');
// Common handle style & class (Removed transition-all to prevent lag on resize animation)
const handleClass = "absolute w-3.5 h-3.5 bg-surface-container-lowest border-2 border-primary rounded-md shadow-[2px_2px_0px_0px_var(--color-primary)] z-50 hover:scale-110 active:scale-95 transition-transform select-none";
return (
<div
className={`relative group ${
selected ? 'ring-2 ring-primary/40 shadow-xl' : ''
}`}
style={{
width: styleWidth,
height: styleHeight,
minWidth: 50,
minHeight: 50,
}}
>
{selected && (
<>
{/* Custom Dashed Outline */}
<div className="absolute inset-0 border-2 border-dashed border-primary opacity-60 rounded-lg pointer-events-none z-10" />
{/* Corners (Always Proportional) */}
<div
style={{ top: '-7px', left: '-7px' }}
className={`${handleClass} cursor-nwse-resize`}
onPointerDown={(e) => handleResizeStart(e, 'top-left')}
/>
<div
style={{ top: '-7px', right: '-7px' }}
className={`${handleClass} cursor-nesw-resize`}
onPointerDown={(e) => handleResizeStart(e, 'top-right')}
/>
<div
style={{ bottom: '-7px', left: '-7px' }}
className={`${handleClass} cursor-nesw-resize`}
onPointerDown={(e) => handleResizeStart(e, 'bottom-left')}
/>
<div
style={{ bottom: '-7px', right: '-7px' }}
className={`${handleClass} cursor-nwse-resize`}
onPointerDown={(e) => handleResizeStart(e, 'bottom-right')}
/>
{/* Sides (Proportional only if Shift is held) */}
<div
style={{ top: '-7px', left: '50%', marginLeft: '-7px' }}
className={`${handleClass} cursor-ns-resize`}
onPointerDown={(e) => handleResizeStart(e, 'top')}
/>
<div
style={{ bottom: '-7px', left: '50%', marginLeft: '-7px' }}
className={`${handleClass} cursor-ns-resize`}
onPointerDown={(e) => handleResizeStart(e, 'bottom')}
/>
<div
style={{ left: '-7px', top: '50%', marginTop: '-7px' }}
className={`${handleClass} cursor-ew-resize`}
onPointerDown={(e) => handleResizeStart(e, 'left')}
/>
<div
style={{ right: '-7px', top: '50%', marginTop: '-7px' }}
className={`${handleClass} cursor-ew-resize`}
onPointerDown={(e) => handleResizeStart(e, 'right')}
/>
</>
)}
{loading ? (
<div className="w-full h-full min-h-[100px] bg-secondary/5 flex flex-col items-center justify-center gap-2 p-4 text-center border-2 border-dashed border-primary/10">
<Loader2 size={24} className="text-primary animate-spin" />
<p className="text-[10px] font-bold text-primary/40 uppercase tracking-widest">
{t('canvas.image_loading', 'Завантаження...')}
</p>
</div>
) : imageUrl ? (
<img
src={imageUrl}
alt="Canvas Asset"
onLoad={handleImageLoad}
className="w-full h-full object-fill pointer-events-none select-none rounded-lg"
/>
) : (
<div className="w-full h-full min-h-[100px] bg-secondary/5 flex flex-col items-center justify-center gap-2 p-4 text-center border-2 border-dashed border-primary/10">
<ImageIcon size={24} className="text-primary/20" />
<p className="text-[10px] font-bold text-primary/40 uppercase tracking-widest">
{t('canvas.image_drop_hint', 'Drop image here')}
</p>
</div>
)}
{/* Connection points - barely visible */}
<Handle type="target" position={Position.Top} className="!opacity-0 group-hover:!opacity-30" />
<Handle type="source" position={Position.Bottom} className="!opacity-0 group-hover:!opacity-30" />
</div>
);
};
export default ImageNode;