import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; const DEFAULT_SIZE = 256; const GAP = 8; const EDGE_MARGIN = 8; interface ThumbnailHoverPreviewProps { /** The image to enlarge; null shows nothing on hover. */ src: string | null; children: ReactNode; className?: string; /** Edge length of the square preview, in px. */ size?: number; } /** * A thumbnail that shows a larger copy of itself while the pointer rests on it * (#3313). Desktop only: a touch screen has no hover. The preview is portalled * and fixed-positioned, so a parent with overflow:hidden (a batch group's card) * cannot clip it. It sits to the right of the thumbnail, or to the left when * there is no room, and is kept inside the viewport vertically. */ export function ThumbnailHoverPreview({ src, children, className, size = DEFAULT_SIZE }: ThumbnailHoverPreviewProps) { const anchorRef = useRef(null); const [position, setPosition] = useState<{ left: number; top: number } | null>(null); const show = useCallback(() => { if (!src || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return; const rect = anchorRef.current?.getBoundingClientRect(); if (!rect) return; const rightOf = rect.right + GAP; const left = rightOf + size + EDGE_MARGIN <= window.innerWidth ? rightOf : Math.max(EDGE_MARGIN, rect.left - GAP - size); const top = Math.max(EDGE_MARGIN, Math.min(rect.top, window.innerHeight - size - EDGE_MARGIN)); setPosition({ left, top }); }, [src, size]); const hide = useCallback(() => setPosition(null), []); // A scroll moves the thumbnail out from under the fixed preview. useEffect(() => { if (!position) return; window.addEventListener('scroll', hide, true); return () => window.removeEventListener('scroll', hide, true); }, [position, hide]); return (
{children} {position && src && createPortal( , document.body, )}
); }