ThumbnailHoverPreview.tsx 2.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
  1. import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
  2. import { createPortal } from 'react-dom';
  3. const DEFAULT_SIZE = 256;
  4. const GAP = 8;
  5. const EDGE_MARGIN = 8;
  6. interface ThumbnailHoverPreviewProps {
  7. /** The image to enlarge; null shows nothing on hover. */
  8. src: string | null;
  9. children: ReactNode;
  10. className?: string;
  11. /** Edge length of the square preview, in px. */
  12. size?: number;
  13. }
  14. /**
  15. * A thumbnail that shows a larger copy of itself while the pointer rests on it
  16. * (#3313). Desktop only: a touch screen has no hover. The preview is portalled
  17. * and fixed-positioned, so a parent with overflow:hidden (a batch group's card)
  18. * cannot clip it. It sits to the right of the thumbnail, or to the left when
  19. * there is no room, and is kept inside the viewport vertically.
  20. */
  21. export function ThumbnailHoverPreview({ src, children, className, size = DEFAULT_SIZE }: ThumbnailHoverPreviewProps) {
  22. const anchorRef = useRef<HTMLDivElement>(null);
  23. const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
  24. const show = useCallback(() => {
  25. if (!src || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
  26. const rect = anchorRef.current?.getBoundingClientRect();
  27. if (!rect) return;
  28. const rightOf = rect.right + GAP;
  29. const left = rightOf + size + EDGE_MARGIN <= window.innerWidth
  30. ? rightOf
  31. : Math.max(EDGE_MARGIN, rect.left - GAP - size);
  32. const top = Math.max(EDGE_MARGIN, Math.min(rect.top, window.innerHeight - size - EDGE_MARGIN));
  33. setPosition({ left, top });
  34. }, [src, size]);
  35. const hide = useCallback(() => setPosition(null), []);
  36. // A scroll moves the thumbnail out from under the fixed preview.
  37. useEffect(() => {
  38. if (!position) return;
  39. window.addEventListener('scroll', hide, true);
  40. return () => window.removeEventListener('scroll', hide, true);
  41. }, [position, hide]);
  42. return (
  43. <div ref={anchorRef} className={className} onMouseEnter={show} onMouseLeave={hide}>
  44. {children}
  45. {position && src && createPortal(
  46. <img
  47. src={src}
  48. alt=""
  49. data-testid="thumbnail-hover-preview"
  50. style={{ left: position.left, top: position.top, width: size, height: size }}
  51. className="fixed z-[60] pointer-events-none object-cover rounded-lg shadow-2xl border-2 border-bambu-dark-tertiary bg-bambu-dark"
  52. />,
  53. document.body,
  54. )}
  55. </div>
  56. );
  57. }