| 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> |
| ); |
| }; |
|
|