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 (
{selected && ( <> {/* Custom Dashed Outline */}
{/* Corners (Always Proportional) */}
handleResizeStart(e, 'top-left')} />
handleResizeStart(e, 'top-right')} />
handleResizeStart(e, 'bottom-left')} />
handleResizeStart(e, 'bottom-right')} /> {/* Sides (Proportional only if Shift is held) */}
handleResizeStart(e, 'top')} />
handleResizeStart(e, 'bottom')} />
handleResizeStart(e, 'left')} />
handleResizeStart(e, 'right')} /> )} {loading ? (

{t('canvas.image_loading', 'Завантаження...')}

) : imageUrl ? ( Canvas Asset ) : (

{t('canvas.image_drop_hint', 'Drop image here')}

)} {/* Connection points - barely visible */}
); }; export default ImageNode;