import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import styles from './RotatedImage.module.css'; type Mode = 'cover' | 'contain'; type RotatedImageProps = { url: string; rotationDeg: 0 | 90 | 180 | 270; mode: Mode; alt?: string; loading?: React.ImgHTMLAttributes['loading']; decoding?: React.ImgHTMLAttributes['decoding']; /** Высота/ширина полностью контролируются родителем. */ style?: React.CSSProperties; /** Прямоугольник видимого контента (contain/cover) внутри контейнера. */ onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined; }; function useElementSize() { const ref = useRef(null); const [size, setSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 }); useEffect(() => { const el = ref.current; if (!el) return; // clientWidth/Height — локальная вёрстка; getBoundingClientRect учитывает transform предков (React Flow zoom). const readLayoutSize = () => { setSize({ w: el.clientWidth, h: el.clientHeight }); }; const ro = new ResizeObserver(() => { readLayoutSize(); }); ro.observe(el); readLayoutSize(); return () => ro.disconnect(); }, []); return [ref, size] as const; } export function RotatedImage({ url, rotationDeg, mode, alt = '', loading, decoding, style, onContentRectChange, }: RotatedImageProps) { const [ref, size] = useElementSize(); const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null); const imgRef = useRef(null); useLayoutEffect(() => { // If the image is served from cache, onLoad may fire before listeners attach. // Reading from the element itself is the most reliable source. const el = imgRef.current; if (!el) return; if (!el.complete) return; const w0 = el.naturalWidth || 0; const h0 = el.naturalHeight || 0; if (w0 <= 0 || h0 <= 0) return; // eslint-disable-next-line react-hooks/set-state-in-effect, @typescript-eslint/prefer-optional-chain -- read cached dimensions when onLoad may not fire setImgSize((prev) => (prev && prev.w === w0 && prev.h === h0 ? prev : { w: w0, h: h0 })); }, [url]); const scale = useMemo(() => { if (!imgSize) return 1; if (size.w <= 1 || size.h <= 1) return 1; const rotated = rotationDeg === 90 || rotationDeg === 270; const iw = rotated ? imgSize.h : imgSize.w; const ih = rotated ? imgSize.w : imgSize.h; const sx = size.w / iw; const sy = size.h / ih; return mode === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy); }, [imgSize, mode, rotationDeg, size.h, size.w]); useEffect(() => { if (!onContentRectChange) return; if (!imgSize) return; if (size.w <= 1 || size.h <= 1) return; const rotated = rotationDeg === 90 || rotationDeg === 270; // Bounding-box размеров после rotate(): при 90/270 меняются местами. const bw = (rotated ? imgSize.h : imgSize.w) * scale; const bh = (rotated ? imgSize.w : imgSize.h) * scale; const x = (size.w - bw) / 2; const y = (size.h - bh) / 2; onContentRectChange({ x, y, w: bw, h: bh }); }, [imgSize, mode, onContentRectChange, rotationDeg, scale, size.h, size.w]); const w = imgSize ? imgSize.w * scale : undefined; const h = imgSize ? imgSize.h * scale : undefined; return (
{alt} { const el = e.currentTarget; const w0 = el.naturalWidth || 0; const h0 = el.naturalHeight || 0; if (w0 <= 0 || h0 <= 0) return; setImgSize((prev) => { // eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- rule can misfire on React state unions if (prev && prev.w === w0 && prev.h === h0) return prev; return { w: w0, h: h0 }; }); }} style={{ width: w ?? '100%', height: h ?? '100%', objectFit: imgSize ? undefined : mode, transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`, }} />
); }