import React, { useState } from 'react'; import { useAppStore } from '../store'; import { RefImage } from '../types'; export const RefImageNode = ({ image }: { image: RefImage }) => { const { setImages, zoom, selectedNodeIds, setSelectedNodeIds, globalDesaturate, setContextMenu, isClickThrough, isAnnotationMode } = useAppStore(); const isSelected = selectedNodeIds.includes(image.id); const [isDragging, setIsDragging] = useState(false); const [activeResizeCorner, setActiveResizeCorner] = useState<'tl' | 'tr' | 'bl' | 'br' | null>(null); const [activeCropEdge, setActiveCropEdge] = useState<'left' | 'right' | 'top' | 'bottom' | null>(null); const handlePointerDown = (e: React.PointerEvent) => { if (isClickThrough || isAnnotationMode) return; // Allow event to bubble to canvas for drawing if (e.button === 2) { e.stopPropagation(); return; } e.stopPropagation(); if (!isSelected) { setImages(prevImages => { let idsToSelect = [image.id]; if (image.groupId) { idsToSelect = prevImages.filter(img => img.groupId === image.groupId).map(img => img.id); } if (e.shiftKey) { setSelectedNodeIds(prev => Array.from(new Set([...prev, ...idsToSelect]))); } else { setSelectedNodeIds(idsToSelect); } return prevImages; }); } setIsDragging(true); e.currentTarget.setPointerCapture(e.pointerId); }; const handlePointerMove = (e: React.PointerEvent) => { if (isDragging && !activeCropEdge) { e.stopPropagation(); setImages(prev => { // Collect all explicit targets const explicitTargetIds = new Set([...selectedNodeIds, image.id]); // Find any group IDs associated with the targets const affectedGroupIds = new Set(); prev.forEach(img => { if (explicitTargetIds.has(img.id) && img.groupId) { affectedGroupIds.add(img.groupId); } }); return prev.map(img => { if (explicitTargetIds.has(img.id) || (img.groupId && affectedGroupIds.has(img.groupId))) { return { ...img, x: img.x + e.movementX / zoom, y: img.y + e.movementY / zoom }; } return img; }); }); } if (activeResizeCorner) { e.stopPropagation(); setImages(prev => prev.map(img => { if (img.id === image.id) { const currentRatio = img.width / img.height; let dx = e.movementX / zoom; let newWidth = img.width; let newHeight = img.height; let newX = img.x; let newY = img.y; if (activeResizeCorner === 'br') { newWidth = Math.max(20, img.width + dx); newHeight = newWidth / currentRatio; } else if (activeResizeCorner === 'bl') { newWidth = Math.max(20, img.width - dx); newHeight = newWidth / currentRatio; newX = img.x + (img.width - newWidth); } else if (activeResizeCorner === 'tr') { newWidth = Math.max(20, img.width + dx); newHeight = newWidth / currentRatio; newY = img.y + (img.height - newHeight); } else if (activeResizeCorner === 'tl') { newWidth = Math.max(20, img.width - dx); newHeight = newWidth / currentRatio; newX = img.x + (img.width - newWidth); newY = img.y + (img.height - newHeight); } return { ...img, width: newWidth, height: newHeight, x: newX, y: newY }; } return img; })); } if (activeCropEdge) { e.stopPropagation(); setImages(prev => prev.map(img => { if (img.id === image.id) { const crop = img.crop || { left: 0, right: 0, top: 0, bottom: 0 }; const fullW = img.width / (1 - crop.left - crop.right); const fullH = img.height / (1 - crop.top - crop.bottom); let newX = img.x; let newY = img.y; let newCrop = { ...crop }; const dx = e.movementX / zoom; const dy = e.movementY / zoom; if (activeCropEdge === 'right') { const newWidth = Math.max(10, img.width + dx); newCrop.right = 1 - newCrop.left - (newWidth / fullW); } else if (activeCropEdge === 'left') { const newWidth = Math.max(10, img.width - dx); const appliedDx = img.width - newWidth; newX = img.x + appliedDx; newCrop.left = 1 - newCrop.right - (newWidth / fullW); } else if (activeCropEdge === 'bottom') { const newHeight = Math.max(10, img.height + dy); newCrop.bottom = 1 - newCrop.top - (newHeight / fullH); } else if (activeCropEdge === 'top') { const newHeight = Math.max(10, img.height - dy); const appliedDy = img.height - newHeight; newY = img.y + appliedDy; newCrop.top = 1 - newCrop.bottom - (newHeight / fullH); } // constrain bounds newCrop.left = Math.max(0, Math.min(newCrop.left, 1 - newCrop.right - 0.01)); newCrop.right = Math.max(0, Math.min(newCrop.right, 1 - newCrop.left - 0.01)); newCrop.top = Math.max(0, Math.min(newCrop.top, 1 - newCrop.bottom - 0.01)); newCrop.bottom = Math.max(0, Math.min(newCrop.bottom, 1 - newCrop.top - 0.01)); // recalculate width/height based on constrained crop const finalWidth = fullW * (1 - newCrop.left - newCrop.right); const finalHeight = fullH * (1 - newCrop.top - newCrop.bottom); // recalculate x, y for constrained top/left if (activeCropEdge === 'left') { const actualDx = img.width - finalWidth; newX = img.x + actualDx; } else if (activeCropEdge === 'top') { const actualDy = img.height - finalHeight; newY = img.y + actualDy; } return { ...img, width: finalWidth, height: finalHeight, x: newX, y: newY, crop: newCrop }; } return img; })); } }; const handlePointerUp = (e: React.PointerEvent) => { setIsDragging(false); setActiveResizeCorner(null); setActiveCropEdge(null); e.currentTarget.releasePointerCapture(e.pointerId); }; const handleResizeStart = (e: React.PointerEvent, corner: 'tl' | 'tr' | 'bl' | 'br') => { e.stopPropagation(); setActiveResizeCorner(corner); (e.target as HTMLElement).setPointerCapture(e.pointerId); }; const handleCropStart = (e: React.PointerEvent, edge: 'left' | 'right' | 'top' | 'bottom') => { e.stopPropagation(); setActiveCropEdge(edge); (e.target as HTMLElement).setPointerCapture(e.pointerId); }; const isGif = image.url.toLowerCase().includes('.gif') || image.url.startsWith('data:image/gif'); const isVideo = image.url.toLowerCase().match(/\.(mp4|webm|mov)$/) || image.url.startsWith('data:video/'); return (
{ e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, imageId: image.id }); if (!isSelected) setSelectedNodeIds([image.id]); }} > {isVideo ? (