import React, { useRef, useLayoutEffect } from 'react'; import { useGraphicsContext, Position } from './GraphicsContext'; import { useStyles, useTheme } from '../../core'; interface NodeProps { id: string; label: string; position: { x: number; y: number }; size?: { width: number, height: number }; inputs: { id: string; label: string }[]; outputs: { id: string; label: string }[]; children?: React.ReactNode; onContextMenu?: (event: React.MouseEvent, nodeId: string) => void; } export const Node: React.FC = ({ id, label, position, size, inputs, outputs, children, onContextMenu }) => { const { startConnecting, stopConnecting, setNodes, zoom, registerSocketPositions } = useGraphicsContext(); const { theme } = useTheme(); const createStyle = useStyles('graphics-node'); const dragState = useRef({ isDragging: false, startPos: { x: 0, y: 0 }, startMouse: { x: 0, y: 0 } }); const nodeRef = useRef(null); const socketRefs = useRef>({}); const resizeState = useRef<{ isResizing: boolean; handle: string; startPos: Position; startSize: { width: number; height: number }; startMouse: Position; } | null>(null); useLayoutEffect(() => { const element = nodeRef.current; if (!element) return; const calculateAndRegisterPositions = () => { if (!nodeRef.current) return; const nodeRect = nodeRef.current.getBoundingClientRect(); const positions: Record = {}; Object.entries(socketRefs.current).forEach(([socketId, socketEl]) => { if (socketEl) { const socketRect = socketEl.getBoundingClientRect(); positions[socketId] = { x: (socketRect.left - nodeRect.left + socketRect.width / 2) / zoom, y: (socketRect.top - nodeRect.top + socketRect.height / 2) / zoom, }; } }); if (Object.keys(positions).length > 0) { registerSocketPositions(id, positions); } }; // Initial calculation and for prop changes calculateAndRegisterPositions(); // Setup observer for subsequent size changes from content const observer = new ResizeObserver(calculateAndRegisterPositions); observer.observe(element); return () => observer.disconnect(); }, [id, inputs.length, outputs.length, registerSocketPositions, zoom, size]); const handleDragMouseDown = (e: React.MouseEvent) => { // Prevent pan from starting when dragging a node e.stopPropagation(); dragState.current = { isDragging: true, startPos: { ...position }, startMouse: { x: e.clientX, y: e.clientY }, }; const handleMouseMove = (moveEvent: MouseEvent) => { if (!dragState.current.isDragging) return; const dx = (moveEvent.clientX - dragState.current.startMouse.x) / zoom; const dy = (moveEvent.clientY - dragState.current.startMouse.y) / zoom; setNodes(prev => prev.map(node => node.id === id ? { ...node, position: { x: dragState.current.startPos.x + dx, y: dragState.current.startPos.y + dy } } : node ) ); }; const handleMouseUp = () => { dragState.current.isDragging = false; window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); }; const handleResizeMouseDown = (e: React.MouseEvent, handle: string) => { e.stopPropagation(); const initialWidth = size?.width ?? nodeRef.current?.offsetWidth ?? 180; const initialHeight = size?.height ?? nodeRef.current?.offsetHeight ?? 100; resizeState.current = { isResizing: true, handle, startPos: { ...position }, startSize: { width: initialWidth, height: initialHeight }, startMouse: { x: e.clientX, y: e.clientY }, }; const handleMouseMove = (moveEvent: MouseEvent) => { if (!resizeState.current?.isResizing) return; const dx = (moveEvent.clientX - resizeState.current.startMouse.x) / zoom; const dy = (moveEvent.clientY - resizeState.current.startMouse.y) / zoom; let newWidth = resizeState.current.startSize.width; let newHeight = resizeState.current.startSize.height; let newX = resizeState.current.startPos.x; let newY = resizeState.current.startPos.y; const minWidth = 180; const minHeight = 120; if (handle.includes('right')) { newWidth = Math.max(minWidth, resizeState.current.startSize.width + dx); } if (handle.includes('bottom')) { newHeight = Math.max(minHeight, resizeState.current.startSize.height + dy); } if (handle.includes('left')) { const calculatedWidth = resizeState.current.startSize.width - dx; if (calculatedWidth >= minWidth) { newWidth = calculatedWidth; newX = resizeState.current.startPos.x + dx; } } if (handle.includes('top')) { const calculatedHeight = resizeState.current.startSize.height - dy; if (calculatedHeight >= minHeight) { newHeight = calculatedHeight; newY = resizeState.current.startPos.y + dy; } } setNodes(prev => prev.map(node => node.id === id ? { ...node, position: { x: newX, y: newY }, size: { width: newWidth, height: newHeight } } : node ) ); }; const handleMouseUp = () => { resizeState.current = null; window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); }; const nodeClass = createStyle({ position: 'absolute', backgroundColor: theme.colors.backgroundSecondary, border: `1px solid ${theme.colors.border}`, borderRadius: '8px', boxShadow: '0 4px 12px rgba(0,0,0,0.5)', userSelect: 'none', pointerEvents: 'auto', // Capture mouse events on the node display: 'flex', flexDirection: 'column', '@supports (backdrop-filter: none) or (-webkit-backdrop-filter: none)': { backdropFilter: 'blur(16px)', }, }); const headerClass = createStyle({ padding: '8px 12px', borderBottom: `1px solid ${theme.colors.border}`, fontWeight: 600, fontSize: '14px', color: theme.colors.text, cursor: 'grab', }); const bodyClass = createStyle({ display: 'grid', gridTemplateColumns: '1fr 1fr', flex: 1, minHeight: 0, }); const contentClass = createStyle({ padding: children ? '8px 12px' : '0', gridColumn: '1 / -1', }); const socketsContainerClass = (isOutput: boolean) => createStyle({ display: 'flex', flexDirection: 'column', gap: '8px', padding: '8px 12px', alignItems: isOutput ? 'flex-end' : 'flex-start', justifyContent: 'space-around', height: '100%', }); const socketClass = (isOutput: boolean) => createStyle({ display: 'flex', alignItems: 'center', gap: '8px', flexDirection: isOutput ? 'row-reverse' : 'row', }); const socketHandleClass = createStyle({ width: '12px', height: '12px', backgroundColor: theme.colors.background, border: `2px solid ${theme.colors.secondary}`, borderRadius: '50%', cursor: 'crosshair', transition: 'background-color 0.2s, border-color 0.2s', '&:hover': { backgroundColor: theme.colors.primary, borderColor: theme.colors.primary, } }); const resizeHandleBase = { position: 'absolute' as 'absolute', backgroundColor: 'transparent', zIndex: 10, }; const handleClasses: Record = { 'top-left': createStyle({...resizeHandleBase, top: '-5px', left: '-5px', width: '10px', height: '10px', cursor: 'nwse-resize'}), 'top-right': createStyle({...resizeHandleBase, top: '-5px', right: '-5px', width: '10px', height: '10px', cursor: 'nesw-resize'}), 'bottom-left': createStyle({...resizeHandleBase, bottom: '-5px', left: '-5px', width: '10px', height: '10px', cursor: 'nesw-resize'}), 'bottom-right': createStyle({...resizeHandleBase, bottom: '-5px', right: '-5px', width: '10px', height: '10px', cursor: 'nwse-resize'}), 'top': createStyle({...resizeHandleBase, top: '-5px', left: '5px', right: '5px', height: '10px', cursor: 'ns-resize'}), 'bottom': createStyle({...resizeHandleBase, bottom: '-5px', left: '5px', right: '5px', height: '10px', cursor: 'ns-resize'}), 'left': createStyle({...resizeHandleBase, top: '5px', bottom: '5px', left: '-5px', width: '10px', cursor: 'ew-resize'}), 'right': createStyle({...resizeHandleBase, top: '5px', bottom: '5px', right: '-5px', width: '10px', cursor: 'ew-resize'}), }; return (
{ e.preventDefault(); e.stopPropagation(); onContextMenu?.(e, id); }} data-node-id={id} > {Object.entries(handleClasses).map(([handleName, className]) => (
handleResizeMouseDown(e, handleName)} /> ))}
{label}
{inputs.map(socket => (
{ socketRefs.current[socket.id] = el; }} className={socketHandleClass} onMouseDown={(e) => { e.stopPropagation(); startConnecting(id, socket.id, 'input', e); }} onMouseUp={() => stopConnecting(id, socket.id, 'input')} /> {socket.label}
))}
{outputs.map(socket => (
{ socketRefs.current[socket.id] = el; }} className={socketHandleClass} onMouseDown={(e) => { e.stopPropagation(); startConnecting(id, socket.id, 'output', e); }} onMouseUp={() => stopConnecting(id, socket.id, 'output')} /> {socket.label}
))}
{children &&
{children}
}
); };