fix: Source Trail badge favicon fallback so icon always appears before domain
Browse files- 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 |
-
|
| 19 |
-
const
|
| 20 |
-
const cropR = crop.right || 0;
|
| 21 |
-
const
|
| 22 |
-
const
|
| 23 |
-
|
| 24 |
-
const
|
| 25 |
-
|
| 26 |
-
const
|
| 27 |
-
const
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
const
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
if (
|
| 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 |
-
if (img.
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
const
|
| 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 |
-
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 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|