musealpha / src /components /RefImageNode.tsx
asdf98's picture
fix: Source Trail badge favicon fallback so icon always appears before domain
b264787 verified
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>
);
};