musealpha / uiprototype2 /src /components /RefImageNode.tsx
asdf98's picture
Upload 112 files
3d7d9b5 verified
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<string>();
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 (
<div
className={`absolute ${isSelected ? 'ring-[1.5px] ring-accent-blue shadow-2xl' : 'shadow-lg'} touch-none group hover:ring-[1px] hover:ring-white/20`}
style={{
transform: `translate(${image.x}px, ${image.y}px)`,
width: image.width,
height: image.height,
filter: globalDesaturate || image.isDesaturated ? 'grayscale(100%)' : 'none',
zIndex: isSelected ? 10 : 1,
pointerEvents: isClickThrough || isAnnotationMode ? 'none' : 'auto'
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onContextMenu={e => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, imageId: image.id });
if (!isSelected) setSelectedNodeIds([image.id]);
}}
>
{isVideo ? (
<video
src={image.url}
autoPlay
loop
muted
playsInline
controls
className={`w-full h-full block ${isGif || isVideo ? 'opacity-100 object-fill' : 'opacity-0 object-contain'}`}
style={{
transform: `scaleX(${image.isFlippedH ? -1 : 1}) scaleY(${image.isFlippedV ? -1 : 1})`,
clipPath: isVideo && image.crop ? `inset(${image.crop.top * 100}% ${image.crop.right * 100}% ${image.crop.bottom * 100}% ${image.crop.left * 100}%)` : 'none'
}}
/>
) : (
<img
src={image.url}
alt="Ref"
className={`w-full h-full pointer-events-none block ${isGif || isVideo ? 'opacity-100 object-fill' : 'opacity-0 object-contain'}`}
draggable={false}
style={{
transform: `scaleX(${image.isFlippedH ? -1 : 1}) scaleY(${image.isFlippedV ? -1 : 1})`,
imageRendering: zoom > 2 ? 'pixelated' : 'auto',
clipPath: isGif && image.crop ? `inset(${image.crop.top * 100}% ${image.crop.right * 100}% ${image.crop.bottom * 100}% ${image.crop.left * 100}%)` : 'none'
}}
/>
)}
{(isGif || isVideo) && (
<div className="absolute top-2 right-2 bg-black/60 backdrop-blur rounded px-1.5 py-0.5 text-[10px] text-white font-medium opacity-0 group-hover:opacity-100 transition-opacity">
{isVideo ? 'VIDEO' : 'GIF'}
</div>
)}
{isSelected && (
<>
{/* Resize corners */}
<div
className="absolute -top-1.5 -left-1.5 w-3 h-3 bg-accent-blue border-[1.5px] border-white rounded-full cursor-nw-resize hover:scale-125 transition-transform z-20 shadow-md"
onPointerDown={(e) => handleResizeStart(e, 'tl')} onPointerUp={handlePointerUp}
/>
<div
className="absolute -top-1.5 -right-1.5 w-3 h-3 bg-accent-blue border-[1.5px] border-white rounded-full cursor-ne-resize hover:scale-125 transition-transform z-20 shadow-md"
onPointerDown={(e) => handleResizeStart(e, 'tr')} onPointerUp={handlePointerUp}
/>
<div
className="absolute -bottom-1.5 -left-1.5 w-3 h-3 bg-accent-blue border-[1.5px] border-white rounded-full cursor-sw-resize hover:scale-125 transition-transform z-20 shadow-md"
onPointerDown={(e) => handleResizeStart(e, 'bl')} onPointerUp={handlePointerUp}
/>
<div
className="absolute -bottom-1.5 -right-1.5 w-3 h-3 bg-accent-blue border-[1.5px] border-white rounded-full cursor-se-resize hover:scale-125 transition-transform z-20 shadow-md"
onPointerDown={(e) => handleResizeStart(e, 'br')} onPointerUp={handlePointerUp}
/>
{/* Crop edges */}
<div className="absolute top-1/2 -translate-y-1/2 -left-1.5 w-3 h-6 cursor-col-resize z-10" onPointerDown={(e) => handleCropStart(e, 'left')} onPointerUp={handlePointerUp}>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 bg-white border border-black/20 rounded-full opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<div className="absolute top-1/2 -translate-y-1/2 -right-1.5 w-3 h-6 cursor-col-resize z-10" onPointerDown={(e) => handleCropStart(e, 'right')} onPointerUp={handlePointerUp}>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 bg-white border border-black/20 rounded-full opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<div className="absolute left-1/2 -translate-x-1/2 -top-1.5 h-3 w-6 cursor-row-resize z-10" onPointerDown={(e) => handleCropStart(e, 'top')} onPointerUp={handlePointerUp}>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-6 h-1 bg-white border border-black/20 rounded-full opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<div className="absolute left-1/2 -translate-x-1/2 -bottom-1.5 h-3 w-6 cursor-row-resize z-10" onPointerDown={(e) => handleCropStart(e, 'bottom')} onPointerUp={handlePointerUp}>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-6 h-1 bg-white border border-black/20 rounded-full opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</>
)}
</div>
);
};