| 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;
|
|
|
| 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 => {
|
|
|
| const explicitTargetIds = new Set([...selectedNodeIds, image.id]);
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
| 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));
|
|
|
|
|
| const finalWidth = fullW * (1 - newCrop.left - newCrop.right);
|
| const finalHeight = fullH * (1 - newCrop.top - newCrop.bottom);
|
|
|
|
|
| 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>
|
| );
|
| };
|
|
|