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(); 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 (
{ e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, imageId: image.id }); if (!isSelected) setSelectedNodeIds([image.id]); }}>
{isVideo ?