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