asdf98 commited on
Commit
b264787
·
verified ·
1 Parent(s): 3c96055

fix: Source Trail badge favicon fallback so icon always appears before domain

Browse files
Files changed (1) hide show
  1. src/components/RefImageNode.tsx +148 -212
src/components/RefImageNode.tsx CHANGED
@@ -1,212 +1,148 @@
1
- import React, { useState } from 'react';
2
- import { useAppStore } from '../store';
3
- import { RefImage } from '../types';
4
-
5
- const EMPTY_CROP = { left: 0, right: 0, top: 0, bottom: 0 };
6
-
7
- export const RefImageNode = ({ image }: { image: RefImage }) => {
8
- const { setImages, zoom, selectedNodeIds, setSelectedNodeIds, globalDesaturate, setContextMenu, isClickThrough, isAnnotationMode, focusedImageId, valueMirrorIds } = useAppStore();
9
- const isSelected = selectedNodeIds.includes(image.id);
10
- const isFocusDimmed = focusedImageId !== null && focusedImageId !== image.id;
11
- const isValueMirror = valueMirrorIds.includes(image.id);
12
- const [isDragging, setIsDragging] = useState(false);
13
- const [activeResizeCorner, setActiveResizeCorner] = useState<'tl'|'tr'|'bl'|'br'|null>(null);
14
- const [activeCropEdge, setActiveCropEdge] = useState<'left'|'right'|'top'|'bottom'|null>(null);
15
- const [valueMirrorSplit, setValueMirrorSplit] = useState(0.5);
16
- const [isDraggingSplit, setIsDraggingSplit] = useState(false);
17
-
18
- const crop = image.crop || EMPTY_CROP;
19
- const cropL = crop.left || 0;
20
- const cropR = crop.right || 0;
21
- const cropT = crop.top || 0;
22
- const cropB = crop.bottom || 0;
23
-
24
- const fullW = image.width / Math.max(0.01, 1 - cropL - cropR);
25
- const fullH = image.height / Math.max(0.01, 1 - cropT - cropB);
26
- const innerLeft = -cropL * fullW;
27
- const innerTop = -cropT * fullH;
28
-
29
- const urlLower = image.url.toLowerCase();
30
- const isVideo = !!urlLower.match(/\.(mp4|webm|mov)$/) || image.url.startsWith('data:video/');
31
- const isGif = urlLower.endsWith('.gif') || image.url.startsWith('data:image/gif');
32
-
33
- const handlePointerDown = (e: React.PointerEvent) => {
34
- if (isClickThrough || isAnnotationMode) return;
35
- if (e.button === 2) { e.stopPropagation(); return; }
36
- e.stopPropagation();
37
- if (!isSelected) {
38
- setImages(prev => {
39
- let ids = [image.id];
40
- if (image.groupId) ids = prev.filter(i => i.groupId === image.groupId).map(i => i.id);
41
- if (e.shiftKey) setSelectedNodeIds(s => Array.from(new Set([...s, ...ids])));
42
- else setSelectedNodeIds(ids);
43
- return prev;
44
- });
45
- }
46
- setIsDragging(true);
47
- e.currentTarget.setPointerCapture(e.pointerId);
48
- };
49
-
50
- const handlePointerMove = (e: React.PointerEvent) => {
51
- if (isDraggingSplit) {
52
- e.stopPropagation();
53
- const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
54
- setValueMirrorSplit(Math.max(0.05, Math.min(0.95, (e.clientX - rect.left) / rect.width)));
55
- return;
56
- }
57
-
58
- if (isDragging && !activeCropEdge && !activeResizeCorner) {
59
- e.stopPropagation();
60
- setImages(prev => {
61
- const targetIds = new Set([...selectedNodeIds, image.id]);
62
- const groupIds = new Set<string>();
63
- prev.forEach(i => { if (targetIds.has(i.id) && i.groupId) groupIds.add(i.groupId); });
64
- 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);
65
- });
66
- }
67
-
68
- if (activeResizeCorner) {
69
- e.stopPropagation();
70
- setImages(prev => prev.map(img => {
71
- if (img.id !== image.id) return img;
72
- const ratio = img.width / img.height;
73
- const dx = e.movementX / zoom;
74
- let nw = img.width, nh = img.height, nx = img.x, ny = img.y;
75
- if (activeResizeCorner === 'br') { nw = Math.max(20, img.width + dx); nh = nw / ratio; }
76
- else if (activeResizeCorner === 'bl') { nw = Math.max(20, img.width - dx); nh = nw / ratio; nx = img.x + (img.width - nw); }
77
- else if (activeResizeCorner === 'tr') { nw = Math.max(20, img.width + dx); nh = nw / ratio; ny = img.y + (img.height - nh); }
78
- 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); }
79
- return { ...img, width: nw, height: nh, x: nx, y: ny };
80
- }));
81
- }
82
-
83
- if (activeCropEdge) {
84
- e.stopPropagation();
85
- setImages(prev => prev.map(img => {
86
- if (img.id !== image.id) return img;
87
- const c = img.crop || EMPTY_CROP;
88
- const l = c.left || 0, r = c.right || 0, t = c.top || 0, b = c.bottom || 0;
89
- const originalW = img.width / Math.max(0.01, 1 - l - r);
90
- const originalH = img.height / Math.max(0.01, 1 - t - b);
91
- const dx = e.movementX / zoom;
92
- const dy = e.movementY / zoom;
93
- const next = { left: l, right: r, top: t, bottom: b };
94
- let nx = img.x;
95
- let ny = img.y;
96
-
97
- if (activeCropEdge === 'left') {
98
- const newVisibleW = Math.max(20, img.width - dx);
99
- next.left = 1 - next.right - newVisibleW / originalW;
100
- } else if (activeCropEdge === 'right') {
101
- const newVisibleW = Math.max(20, img.width + dx);
102
- next.right = 1 - next.left - newVisibleW / originalW;
103
- } else if (activeCropEdge === 'top') {
104
- const newVisibleH = Math.max(20, img.height - dy);
105
- next.top = 1 - next.bottom - newVisibleH / originalH;
106
- } else if (activeCropEdge === 'bottom') {
107
- const newVisibleH = Math.max(20, img.height + dy);
108
- next.bottom = 1 - next.top - newVisibleH / originalH;
109
- }
110
-
111
- next.left = Math.max(0, Math.min(next.left, 0.95 - next.right));
112
- next.right = Math.max(0, Math.min(next.right, 0.95 - next.left));
113
- next.top = Math.max(0, Math.min(next.top, 0.95 - next.bottom));
114
- next.bottom = Math.max(0, Math.min(next.bottom, 0.95 - next.top));
115
-
116
- const finalW = originalW * (1 - next.left - next.right);
117
- const finalH = originalH * (1 - next.top - next.bottom);
118
- if (activeCropEdge === 'left') nx = img.x + (img.width - finalW);
119
- if (activeCropEdge === 'top') ny = img.y + (img.height - finalH);
120
- return { ...img, x: nx, y: ny, width: finalW, height: finalH, crop: next };
121
- }));
122
- }
123
- };
124
-
125
- const handlePointerUp = (e: React.PointerEvent) => {
126
- setIsDragging(false);
127
- setActiveResizeCorner(null);
128
- setActiveCropEdge(null);
129
- setIsDraggingSplit(false);
130
- try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
131
- };
132
-
133
- const handleResizeStart = (e: React.PointerEvent, corner: 'tl'|'tr'|'bl'|'br') => {
134
- e.stopPropagation();
135
- setIsDragging(false);
136
- setActiveResizeCorner(corner);
137
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
138
- };
139
-
140
- const handleCropStart = (e: React.PointerEvent, edge: 'left'|'right'|'top'|'bottom') => {
141
- e.stopPropagation();
142
- setIsDragging(false);
143
- setActiveCropEdge(edge);
144
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
145
- };
146
-
147
- const flipTransform = `scaleX(${image.isFlippedH ? -1 : 1}) scaleY(${image.isFlippedV ? -1 : 1})`;
148
-
149
- let filter = '';
150
- if (globalDesaturate || image.isDesaturated) filter = 'grayscale(100%)';
151
- if (isFocusDimmed) filter += ' opacity(0.15)';
152
-
153
- const sourceDomain = image.sourceUrl ? (() => { try { return new URL(image.sourceUrl).hostname.replace('www.', ''); } catch { return null; } })() : null;
154
-
155
- const mediaStyle: React.CSSProperties = {
156
- left: innerLeft,
157
- top: innerTop,
158
- width: fullW,
159
- height: fullH,
160
- maxWidth: 'none',
161
- maxHeight: 'none',
162
- minWidth: 'unset',
163
- minHeight: 'unset',
164
- transform: flipTransform,
165
- transformOrigin: 'center center',
166
- objectFit: 'fill',
167
- };
168
-
169
- return (
170
- <div
171
- className={`absolute touch-none group ${isSelected ? 'ring-[1.5px] ring-[#0A84FF] shadow-2xl' : 'shadow-lg'} ${!isFocusDimmed ? 'hover:ring-[1px] hover:ring-white/20' : ''}`}
172
- 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' }}
173
- onPointerDown={handlePointerDown}
174
- onPointerMove={handlePointerMove}
175
- onPointerUp={handlePointerUp}
176
- onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, imageId: image.id }); if (!isSelected) setSelectedNodeIds([image.id]); }}
177
- >
178
- <div className="absolute inset-0 overflow-hidden">
179
- {isVideo ? (
180
- <video src={image.url} autoPlay loop muted playsInline controls className="absolute block pointer-events-none" style={mediaStyle} draggable={false} />
181
- ) : (
182
- <img src={image.url} alt="Ref" className="absolute pointer-events-none block" draggable={false} style={{ ...mediaStyle, imageRendering: zoom > 2 ? 'pixelated' : 'auto' }} />
183
- )}
184
-
185
- {isValueMirror && !isVideo && (
186
- <>
187
- <div className="absolute inset-0 pointer-events-none overflow-hidden" style={{ clipPath: `inset(0 0 0 ${valueMirrorSplit * 100}%)` }}>
188
- <img src={image.url} className="absolute block" draggable={false} style={{ ...mediaStyle, filter: 'grayscale(100%)' }} />
189
- </div>
190
- <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} />
191
- <div className="absolute top-1 left-1 text-[9px] text-white/60 bg-black/40 px-1 rounded pointer-events-none">COLOR</div>
192
- <div className="absolute top-1 right-1 text-[9px] text-white/60 bg-black/40 px-1 rounded pointer-events-none">VALUE</div>
193
- </>
194
- )}
195
- </div>
196
-
197
- {(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>}
198
- {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"><img src={`https://www.google.com/s2/favicons?domain=${sourceDomain}&sz=16`} className="w-3 h-3" alt="" /><span className="text-[9px] text-white/80 font-medium">{sourceDomain}</span></div>}
199
-
200
- {isSelected && !isFocusDimmed && <>
201
- <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} />
202
- <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} />
203
- <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} />
204
- <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} />
205
- <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>
206
- <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>
207
- <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>
208
- <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>
209
- </>}
210
- </div>
211
- );
212
- };
 
1
+ import React, { useState } from 'react';
2
+ import { useAppStore } from '../store';
3
+ import { RefImage } from '../types';
4
+
5
+ const EMPTY_CROP = { left: 0, right: 0, top: 0, bottom: 0 };
6
+
7
+ export const RefImageNode = ({ image }: { image: RefImage }) => {
8
+ const { setImages, zoom, selectedNodeIds, setSelectedNodeIds, globalDesaturate, setContextMenu, isClickThrough, isAnnotationMode, focusedImageId, valueMirrorIds } = useAppStore();
9
+ const isSelected = selectedNodeIds.includes(image.id);
10
+ const isFocusDimmed = focusedImageId !== null && focusedImageId !== image.id;
11
+ const isValueMirror = valueMirrorIds.includes(image.id);
12
+ const [isDragging, setIsDragging] = useState(false);
13
+ const [activeResizeCorner, setActiveResizeCorner] = useState<'tl'|'tr'|'bl'|'br'|null>(null);
14
+ const [activeCropEdge, setActiveCropEdge] = useState<'left'|'right'|'top'|'bottom'|null>(null);
15
+ const [valueMirrorSplit, setValueMirrorSplit] = useState(0.5);
16
+ const [isDraggingSplit, setIsDraggingSplit] = useState(false);
17
+ const [faviconFailed, setFaviconFailed] = useState(false);
18
+
19
+ const crop = image.crop || EMPTY_CROP;
20
+ const cropL = crop.left || 0, cropR = crop.right || 0, cropT = crop.top || 0, cropB = crop.bottom || 0;
21
+ const fullW = image.width / Math.max(0.01, 1 - cropL - cropR);
22
+ const fullH = image.height / Math.max(0.01, 1 - cropT - cropB);
23
+ const innerLeft = -cropL * fullW;
24
+ const innerTop = -cropT * fullH;
25
+
26
+ const urlLower = image.url.toLowerCase();
27
+ const isVideo = !!urlLower.match(/\.(mp4|webm|mov)$/) || image.url.startsWith('data:video/');
28
+ const isGif = urlLower.endsWith('.gif') || image.url.startsWith('data:image/gif');
29
+
30
+ const handlePointerDown = (e: React.PointerEvent) => {
31
+ if (isClickThrough || isAnnotationMode) return;
32
+ if (e.button === 2) { e.stopPropagation(); return; }
33
+ e.stopPropagation();
34
+ if (!isSelected) {
35
+ setImages(prev => {
36
+ let ids = [image.id];
37
+ if (image.groupId) ids = prev.filter(i => i.groupId === image.groupId).map(i => i.id);
38
+ if (e.shiftKey) setSelectedNodeIds(s => Array.from(new Set([...s, ...ids])));
39
+ else setSelectedNodeIds(ids);
40
+ return prev;
41
+ });
42
+ }
43
+ setIsDragging(true);
44
+ e.currentTarget.setPointerCapture(e.pointerId);
45
+ };
46
+
47
+ const handlePointerMove = (e: React.PointerEvent) => {
48
+ if (isDraggingSplit) {
49
+ e.stopPropagation();
50
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
51
+ setValueMirrorSplit(Math.max(0.05, Math.min(0.95, (e.clientX - rect.left) / rect.width)));
52
+ return;
53
+ }
54
+ if (isDragging && !activeCropEdge && !activeResizeCorner) {
55
+ e.stopPropagation();
56
+ setImages(prev => {
57
+ const targetIds = new Set([...selectedNodeIds, image.id]);
58
+ const groupIds = new Set<string>();
59
+ prev.forEach(i => { if (targetIds.has(i.id) && i.groupId) groupIds.add(i.groupId); });
60
+ 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);
61
+ });
62
+ }
63
+ if (activeResizeCorner) {
64
+ e.stopPropagation();
65
+ setImages(prev => prev.map(img => {
66
+ if (img.id !== image.id) return img;
67
+ const ratio = img.width / img.height;
68
+ const dx = e.movementX / zoom;
69
+ let nw = img.width, nh = img.height, nx = img.x, ny = img.y;
70
+ if (activeResizeCorner === 'br') { nw = Math.max(20, img.width + dx); nh = nw / ratio; }
71
+ else if (activeResizeCorner === 'bl') { nw = Math.max(20, img.width - dx); nh = nw / ratio; nx = img.x + (img.width - nw); }
72
+ else if (activeResizeCorner === 'tr') { nw = Math.max(20, img.width + dx); nh = nw / ratio; ny = img.y + (img.height - nh); }
73
+ 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); }
74
+ return { ...img, width: nw, height: nh, x: nx, y: ny };
75
+ }));
76
+ }
77
+ if (activeCropEdge) {
78
+ e.stopPropagation();
79
+ setImages(prev => prev.map(img => {
80
+ if (img.id !== image.id) return img;
81
+ const c = img.crop || EMPTY_CROP;
82
+ const l = c.left || 0, r = c.right || 0, t = c.top || 0, b = c.bottom || 0;
83
+ const originalW = img.width / Math.max(0.01, 1 - l - r);
84
+ const originalH = img.height / Math.max(0.01, 1 - t - b);
85
+ const dx = e.movementX / zoom;
86
+ const dy = e.movementY / zoom;
87
+ const next = { left: l, right: r, top: t, bottom: b };
88
+ let nx = img.x, ny = img.y;
89
+ if (activeCropEdge === 'left') next.left = 1 - next.right - Math.max(20, img.width - dx) / originalW;
90
+ else if (activeCropEdge === 'right') next.right = 1 - next.left - Math.max(20, img.width + dx) / originalW;
91
+ else if (activeCropEdge === 'top') next.top = 1 - next.bottom - Math.max(20, img.height - dy) / originalH;
92
+ else if (activeCropEdge === 'bottom') next.bottom = 1 - next.top - Math.max(20, img.height + dy) / originalH;
93
+ next.left = Math.max(0, Math.min(next.left, 0.95 - next.right));
94
+ next.right = Math.max(0, Math.min(next.right, 0.95 - next.left));
95
+ next.top = Math.max(0, Math.min(next.top, 0.95 - next.bottom));
96
+ next.bottom = Math.max(0, Math.min(next.bottom, 0.95 - next.top));
97
+ const finalW = originalW * (1 - next.left - next.right);
98
+ const finalH = originalH * (1 - next.top - next.bottom);
99
+ if (activeCropEdge === 'left') nx = img.x + (img.width - finalW);
100
+ if (activeCropEdge === 'top') ny = img.y + (img.height - finalH);
101
+ return { ...img, x: nx, y: ny, width: finalW, height: finalH, crop: next };
102
+ }));
103
+ }
104
+ };
105
+
106
+ const handlePointerUp = (e: React.PointerEvent) => {
107
+ setIsDragging(false); setActiveResizeCorner(null); setActiveCropEdge(null); setIsDraggingSplit(false);
108
+ try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
109
+ };
110
+ const handleResizeStart = (e: React.PointerEvent, corner: 'tl'|'tr'|'bl'|'br') => { e.stopPropagation(); setIsDragging(false); setActiveResizeCorner(corner); (e.target as HTMLElement).setPointerCapture(e.pointerId); };
111
+ const handleCropStart = (e: React.PointerEvent, edge: 'left'|'right'|'top'|'bottom') => { e.stopPropagation(); setIsDragging(false); setActiveCropEdge(edge); (e.target as HTMLElement).setPointerCapture(e.pointerId); };
112
+
113
+ const flipTransform = `scaleX(${image.isFlippedH ? -1 : 1}) scaleY(${image.isFlippedV ? -1 : 1})`;
114
+ let filter = '';
115
+ if (globalDesaturate || image.isDesaturated) filter = 'grayscale(100%)';
116
+ if (isFocusDimmed) filter += ' opacity(0.15)';
117
+
118
+ const sourceDomain = image.sourceUrl ? (() => { try { return new URL(image.sourceUrl).hostname.replace('www.', ''); } catch { return null; } })() : null;
119
+ 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' };
120
+
121
+ return (
122
+ <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]); }}>
123
+ <div className="absolute inset-0 overflow-hidden">
124
+ {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' }} />}
125
+ {isValueMirror && !isVideo && <>
126
+ <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>
127
+ <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} />
128
+ <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>
129
+ </>}
130
+ </div>
131
+ {(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>}
132
+ {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">
133
+ {!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>}
134
+ <span className="text-[9px] text-white/80 font-medium">{sourceDomain}</span>
135
+ </div>}
136
+ {isSelected && !isFocusDimmed && <>
137
+ <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} />
138
+ <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} />
139
+ <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} />
140
+ <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} />
141
+ <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>
142
+ <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>
143
+ <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>
144
+ <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>
145
+ </>}
146
+ </div>
147
+ );
148
+ };