File size: 12,738 Bytes
b264787 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | import React, { useState } from 'react';
import { useAppStore } from '../store';
import { RefImage } from '../types';
const EMPTY_CROP = { left: 0, right: 0, top: 0, bottom: 0 };
export const RefImageNode = ({ image }: { image: RefImage }) => {
const { setImages, zoom, selectedNodeIds, setSelectedNodeIds, globalDesaturate, setContextMenu, isClickThrough, isAnnotationMode, focusedImageId, valueMirrorIds } = useAppStore();
const isSelected = selectedNodeIds.includes(image.id);
const isFocusDimmed = focusedImageId !== null && focusedImageId !== image.id;
const isValueMirror = valueMirrorIds.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 [valueMirrorSplit, setValueMirrorSplit] = useState(0.5);
const [isDraggingSplit, setIsDraggingSplit] = useState(false);
const [faviconFailed, setFaviconFailed] = useState(false);
const crop = image.crop || EMPTY_CROP;
const cropL = crop.left || 0, cropR = crop.right || 0, cropT = crop.top || 0, cropB = crop.bottom || 0;
const fullW = image.width / Math.max(0.01, 1 - cropL - cropR);
const fullH = image.height / Math.max(0.01, 1 - cropT - cropB);
const innerLeft = -cropL * fullW;
const innerTop = -cropT * fullH;
const urlLower = image.url.toLowerCase();
const isVideo = !!urlLower.match(/\.(mp4|webm|mov)$/) || image.url.startsWith('data:video/');
const isGif = urlLower.endsWith('.gif') || image.url.startsWith('data:image/gif');
const handlePointerDown = (e: React.PointerEvent) => {
if (isClickThrough || isAnnotationMode) return;
if (e.button === 2) { e.stopPropagation(); return; }
e.stopPropagation();
if (!isSelected) {
setImages(prev => {
let ids = [image.id];
if (image.groupId) ids = prev.filter(i => i.groupId === image.groupId).map(i => i.id);
if (e.shiftKey) setSelectedNodeIds(s => Array.from(new Set([...s, ...ids])));
else setSelectedNodeIds(ids);
return prev;
});
}
setIsDragging(true);
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (isDraggingSplit) {
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setValueMirrorSplit(Math.max(0.05, Math.min(0.95, (e.clientX - rect.left) / rect.width)));
return;
}
if (isDragging && !activeCropEdge && !activeResizeCorner) {
e.stopPropagation();
setImages(prev => {
const targetIds = new Set([...selectedNodeIds, image.id]);
const groupIds = new Set<string>();
prev.forEach(i => { if (targetIds.has(i.id) && i.groupId) groupIds.add(i.groupId); });
return prev.map(i => (targetIds.has(i.id) || (i.groupId && groupIds.has(i.groupId))) ? { ...i, x: i.x + e.movementX / zoom, y: i.y + e.movementY / zoom } : i);
});
}
if (activeResizeCorner) {
e.stopPropagation();
setImages(prev => prev.map(img => {
if (img.id !== image.id) return img;
const ratio = img.width / img.height;
const dx = e.movementX / zoom;
let nw = img.width, nh = img.height, nx = img.x, ny = img.y;
if (activeResizeCorner === 'br') { nw = Math.max(20, img.width + dx); nh = nw / ratio; }
else if (activeResizeCorner === 'bl') { nw = Math.max(20, img.width - dx); nh = nw / ratio; nx = img.x + (img.width - nw); }
else if (activeResizeCorner === 'tr') { nw = Math.max(20, img.width + dx); nh = nw / ratio; ny = img.y + (img.height - nh); }
else if (activeResizeCorner === 'tl') { nw = Math.max(20, img.width - dx); nh = nw / ratio; nx = img.x + (img.width - nw); ny = img.y + (img.height - nh); }
return { ...img, width: nw, height: nh, x: nx, y: ny };
}));
}
if (activeCropEdge) {
e.stopPropagation();
setImages(prev => prev.map(img => {
if (img.id !== image.id) return img;
const c = img.crop || EMPTY_CROP;
const l = c.left || 0, r = c.right || 0, t = c.top || 0, b = c.bottom || 0;
const originalW = img.width / Math.max(0.01, 1 - l - r);
const originalH = img.height / Math.max(0.01, 1 - t - b);
const dx = e.movementX / zoom;
const dy = e.movementY / zoom;
const next = { left: l, right: r, top: t, bottom: b };
let nx = img.x, ny = img.y;
if (activeCropEdge === 'left') next.left = 1 - next.right - Math.max(20, img.width - dx) / originalW;
else if (activeCropEdge === 'right') next.right = 1 - next.left - Math.max(20, img.width + dx) / originalW;
else if (activeCropEdge === 'top') next.top = 1 - next.bottom - Math.max(20, img.height - dy) / originalH;
else if (activeCropEdge === 'bottom') next.bottom = 1 - next.top - Math.max(20, img.height + dy) / originalH;
next.left = Math.max(0, Math.min(next.left, 0.95 - next.right));
next.right = Math.max(0, Math.min(next.right, 0.95 - next.left));
next.top = Math.max(0, Math.min(next.top, 0.95 - next.bottom));
next.bottom = Math.max(0, Math.min(next.bottom, 0.95 - next.top));
const finalW = originalW * (1 - next.left - next.right);
const finalH = originalH * (1 - next.top - next.bottom);
if (activeCropEdge === 'left') nx = img.x + (img.width - finalW);
if (activeCropEdge === 'top') ny = img.y + (img.height - finalH);
return { ...img, x: nx, y: ny, width: finalW, height: finalH, crop: next };
}));
}
};
const handlePointerUp = (e: React.PointerEvent) => {
setIsDragging(false); setActiveResizeCorner(null); setActiveCropEdge(null); setIsDraggingSplit(false);
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
};
const handleResizeStart = (e: React.PointerEvent, corner: 'tl'|'tr'|'bl'|'br') => { e.stopPropagation(); setIsDragging(false); setActiveResizeCorner(corner); (e.target as HTMLElement).setPointerCapture(e.pointerId); };
const handleCropStart = (e: React.PointerEvent, edge: 'left'|'right'|'top'|'bottom') => { e.stopPropagation(); setIsDragging(false); setActiveCropEdge(edge); (e.target as HTMLElement).setPointerCapture(e.pointerId); };
const flipTransform = `scaleX(${image.isFlippedH ? -1 : 1}) scaleY(${image.isFlippedV ? -1 : 1})`;
let filter = '';
if (globalDesaturate || image.isDesaturated) filter = 'grayscale(100%)';
if (isFocusDimmed) filter += ' opacity(0.15)';
const sourceDomain = image.sourceUrl ? (() => { try { return new URL(image.sourceUrl).hostname.replace('www.', ''); } catch { return null; } })() : null;
const mediaStyle: React.CSSProperties = { left: innerLeft, top: innerTop, width: fullW, height: fullH, maxWidth: 'none', maxHeight: 'none', minWidth: 'unset', minHeight: 'unset', transform: flipTransform, transformOrigin: 'center center', objectFit: 'fill' };
return (
<div className={`absolute touch-none group ${isSelected ? 'ring-[1.5px] ring-[#0A84FF] shadow-2xl' : 'shadow-lg'} ${!isFocusDimmed ? 'hover:ring-[1px] hover:ring-white/20' : ''}`} style={{ transform: `translate(${image.x}px, ${image.y}px)`, width: image.width, height: image.height, filter, zIndex: isSelected || focusedImageId === image.id ? 10 : 1, pointerEvents: (isClickThrough || isAnnotationMode || isFocusDimmed) ? 'none' : 'auto', transition: isFocusDimmed ? 'filter 0.2s ease' : undefined, overflow: 'visible' }} 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]); }}>
<div className="absolute inset-0 overflow-hidden">
{isVideo ? <video src={image.url} autoPlay loop muted playsInline controls className="absolute block pointer-events-none" style={mediaStyle} draggable={false} /> : <img src={image.url} alt="Ref" className="absolute pointer-events-none block" draggable={false} style={{ ...mediaStyle, imageRendering: zoom > 2 ? 'pixelated' : 'auto' }} />}
{isValueMirror && !isVideo && <>
<div className="absolute inset-0 pointer-events-none overflow-hidden" style={{ clipPath: `inset(0 0 0 ${valueMirrorSplit * 100}%)` }}><img src={image.url} className="absolute block" draggable={false} style={{ ...mediaStyle, filter: 'grayscale(100%)' }} /></div>
<div className="absolute top-0 bottom-0 w-[3px] bg-white/70 cursor-col-resize z-20 hover:bg-white" style={{ left: `${valueMirrorSplit * 100}%`, transform: 'translateX(-50%)' }} onPointerDown={(e) => { e.stopPropagation(); setIsDraggingSplit(true); (e.target as HTMLElement).setPointerCapture(e.pointerId); }} onPointerUp={handlePointerUp} />
<div className="absolute top-1 left-1 text-[9px] text-white/60 bg-black/40 px-1 rounded pointer-events-none">COLOR</div><div className="absolute top-1 right-1 text-[9px] text-white/60 bg-black/40 px-1 rounded pointer-events-none">VALUE</div>
</>}
</div>
{(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 pointer-events-none">{isVideo ? 'VIDEO' : 'GIF'}</div>}
{sourceDomain && <div className="absolute bottom-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none flex items-center gap-1 bg-black/60 backdrop-blur rounded px-1.5 py-0.5">
{!faviconFailed ? <img src={`https://www.google.com/s2/favicons?domain=${sourceDomain}&sz=16`} onError={() => setFaviconFailed(true)} className="w-3 h-3 rounded-sm bg-white/10" alt="" /> : <span className="w-3 h-3 rounded-sm bg-[#0A84FF] text-white text-[8px] leading-3 text-center font-bold">{sourceDomain[0]?.toUpperCase()}</span>}
<span className="text-[9px] text-white/80 font-medium">{sourceDomain}</span>
</div>}
{isSelected && !isFocusDimmed && <>
<div className="absolute -top-1.5 -left-1.5 w-3 h-3 bg-[#0A84FF] border-[1.5px] border-white rounded-full cursor-nw-resize hover:scale-125 transition-transform z-30 shadow-md" onPointerDown={e => handleResizeStart(e, 'tl')} onPointerUp={handlePointerUp} />
<div className="absolute -top-1.5 -right-1.5 w-3 h-3 bg-[#0A84FF] border-[1.5px] border-white rounded-full cursor-ne-resize hover:scale-125 transition-transform z-30 shadow-md" onPointerDown={e => handleResizeStart(e, 'tr')} onPointerUp={handlePointerUp} />
<div className="absolute -bottom-1.5 -left-1.5 w-3 h-3 bg-[#0A84FF] border-[1.5px] border-white rounded-full cursor-sw-resize hover:scale-125 transition-transform z-30 shadow-md" onPointerDown={e => handleResizeStart(e, 'bl')} onPointerUp={handlePointerUp} />
<div className="absolute -bottom-1.5 -right-1.5 w-3 h-3 bg-[#0A84FF] border-[1.5px] border-white rounded-full cursor-se-resize hover:scale-125 transition-transform z-30 shadow-md" onPointerDown={e => handleResizeStart(e, 'br')} onPointerUp={handlePointerUp} />
<div className="absolute top-1/2 -translate-y-1/2 -left-1.5 w-3 h-8 cursor-col-resize z-20" 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-8 cursor-col-resize z-20" 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-8 cursor-row-resize z-20" 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-8 cursor-row-resize z-20" 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>
);
};
|