Files
DndGamePlayer/app/renderer/shared/RotatedImage.tsx
T
Ivan Fontosh eb127c11c2 feat(scene): shared zoom/pan for control and presentation
Keep effects pinned to map coordinates when the viewport changes; materials/NPC overlays stay screen-fixed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 14:08:19 +08:00

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>
);
}