Spaces:
Running
Running
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 | 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; | |