eb127c11c2
Keep effects pinned to map coordinates when the viewport changes; materials/NPC overlays stay screen-fixed. Co-authored-by: Cursor <cursoragent@cursor.com>
149 lines
5.3 KiB
TypeScript
149 lines
5.3 KiB
TypeScript
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import {
|
|
DEFAULT_SCENE_VIEW_CAMERA,
|
|
type SceneViewCamera,
|
|
} from '../../shared/types/sceneView';
|
|
|
|
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<HTMLImageElement>['loading'];
|
|
decoding?: React.ImgHTMLAttributes<HTMLImageElement>['decoding'];
|
|
/** Высота/ширина полностью контролируются родителем. */
|
|
style?: React.CSSProperties;
|
|
/** Зум/пан поверх contain/cover (только для mode=contain в сценах). */
|
|
viewCamera?: SceneViewCamera | null;
|
|
/** Прямоугольник видимого контента (contain/cover) внутри контейнера. */
|
|
onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined;
|
|
};
|
|
|
|
function useElementSize<T extends HTMLElement>() {
|
|
const ref = useRef<T | null>(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,
|
|
viewCamera = null,
|
|
onContentRectChange,
|
|
}: RotatedImageProps) {
|
|
const [ref, size] = useElementSize<HTMLDivElement>();
|
|
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null);
|
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
|
|
|
const cam = viewCamera ?? DEFAULT_SCENE_VIEW_CAMERA;
|
|
const viewScale = mode === 'contain' ? Math.max(1, cam.scale) : 1;
|
|
const viewOx = mode === 'contain' ? cam.ox : 0.5;
|
|
const viewOy = mode === 'contain' ? cam.oy : 0.5;
|
|
|
|
useLayoutEffect(() => {
|
|
// If the image is served from cache, onLoad may fire before listeners attach.
|
|
// Reading from the <img> 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 <img> dimensions when onLoad may not fire
|
|
setImgSize((prev) => (prev && prev.w === w0 && prev.h === h0 ? prev : { w: w0, h: h0 }));
|
|
}, [url]);
|
|
|
|
const fitScale = 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]);
|
|
|
|
const scale = fitScale * viewScale;
|
|
|
|
const contentRect = useMemo(() => {
|
|
if (!imgSize) return null;
|
|
if (size.w <= 1 || size.h <= 1) return null;
|
|
const rotated = rotationDeg === 90 || rotationDeg === 270;
|
|
const bw = (rotated ? imgSize.h : imgSize.w) * scale;
|
|
const bh = (rotated ? imgSize.w : imgSize.h) * scale;
|
|
const x = size.w / 2 - viewOx * bw;
|
|
const y = size.h / 2 - viewOy * bh;
|
|
return { x, y, w: bw, h: bh };
|
|
}, [imgSize, rotationDeg, scale, size.h, size.w, viewOx, viewOy]);
|
|
|
|
useEffect(() => {
|
|
if (!onContentRectChange || !contentRect) return;
|
|
onContentRectChange(contentRect);
|
|
}, [contentRect, onContentRectChange]);
|
|
|
|
const w = imgSize ? imgSize.w * scale : undefined;
|
|
const h = imgSize ? imgSize.h * scale : undefined;
|
|
const leftPx = contentRect ? contentRect.x + contentRect.w / 2 : undefined;
|
|
const topPx = contentRect ? contentRect.y + contentRect.h / 2 : undefined;
|
|
|
|
return (
|
|
<div ref={ref} className={styles.root} style={style}>
|
|
<img
|
|
ref={imgRef}
|
|
alt={alt}
|
|
src={url}
|
|
loading={loading}
|
|
decoding={decoding}
|
|
draggable={false}
|
|
className={styles.img}
|
|
onLoad={(e) => {
|
|
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%',
|
|
left: leftPx !== undefined ? `${String(leftPx)}px` : '50%',
|
|
top: topPx !== undefined ? `${String(topPx)}px` : '50%',
|
|
objectFit: imgSize ? undefined : mode,
|
|
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|