fix(materials): синхронизация легенды и маркеров на превью и презентации

Легенда и цифры на материале масштабируются от экрана игроков и не обрезаются жёлтой рамкой. Переключение сцен без записи project.json на диск.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 10:57:50 +08:00
parent ea718a26df
commit 153be98780
17 changed files with 531 additions and 128 deletions
@@ -17,7 +17,7 @@ type SceneOverlayHostProps = {
active: boolean;
/**
* Область раскладки кадров материалов/NPC (и жёлтой рамки).
* На пульте — прямоугольник соотношения сторон презентации; на презентации обычно не задаётся (весь экран).
* На пульте — экран игроков (жёлтая рамка); на презентации не задаётся (весь экран).
*/
viewport?: SceneOverlayViewport | null;
/** Жёлтая рамка видимой области презентации (предпросмотр пульта). Без dim. */
@@ -25,7 +25,10 @@ type SceneOverlayHostProps = {
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
closes?: readonly SceneOverlayCloseAction[];
/** Кадры материалов/NPC — внутри viewport (overflow hidden). */
children: React.ReactNode;
/** Блоки легенды — вне viewport, не обрезаются жёлтой рамкой. */
legendLayer?: React.ReactNode;
};
/**
@@ -41,12 +44,19 @@ export function SceneOverlayHost({
onZoomAt,
closes = [],
children,
legendLayer = null,
}: SceneOverlayHostProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const outerRef = useRef<HTMLDivElement | null>(null);
const markerLayerRef = useRef<HTMLDivElement | null>(null);
const [view, setView] = useState({ w: 1, h: 1 });
const layoutViewport = useMemo((): SceneOverlayViewport => {
if (viewport && viewport.w > 0 && viewport.h > 0) return viewport;
return { x: 0, y: 0, w: view.w, h: view.h };
}, [viewport, view.h, view.w]);
useEffect(() => {
const el = rootRef.current;
const el = outerRef.current;
if (!el) return;
let raf = 0;
const sync = () => {
@@ -67,7 +77,15 @@ export function SceneOverlayHost({
};
}, [active, showViewportGuide, viewport]);
const ctx = useMemo(() => ({ rootRef, view }), [view]);
const ctx = useMemo(
() => ({
rootRef: outerRef,
markerLayerRef,
view: { w: layoutViewport.w, h: layoutViewport.h },
viewport: layoutViewport,
}),
[layoutViewport],
);
if (!active && !showViewportGuide) return null;
@@ -76,12 +94,11 @@ export function SceneOverlayHost({
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
const toNorm = (clientX: number, clientY: number) => {
const root = rootRef.current;
if (!root) return { nx: 0.5, ny: 0.5 };
const r = root.getBoundingClientRect();
const root = outerRef.current;
if (!root || layoutViewport.w <= 0 || layoutViewport.h <= 0) return { nx: 0.5, ny: 0.5 };
return {
nx: (clientX - r.left) / Math.max(1, r.width),
ny: (clientY - r.top) / Math.max(1, r.height),
nx: (clientX - root.getBoundingClientRect().left - layoutViewport.x) / layoutViewport.w,
ny: (clientY - root.getBoundingClientRect().top - layoutViewport.y) / layoutViewport.h,
};
};
@@ -89,11 +106,10 @@ export function SceneOverlayHost({
<SceneOverlayViewContext.Provider value={ctx}>
{active ? <div className={styles.dim} aria-hidden /> : null}
<div
ref={rootRef}
ref={outerRef}
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
.filter(Boolean)
.join(' ')}
style={overlayRootStyle(viewport)}
role="presentation"
onClick={(e) => {
if (!captureZoom || !onZoomAt) return;
@@ -102,27 +118,35 @@ export function SceneOverlayHost({
onZoomAt(nx, ny, e.target);
}}
>
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
{children}
{closes.length > 0 ? (
<div className={styles.closeStack}>
{closes.map((c) => (
<button
key={c.key}
type="button"
className={styles.close}
onClick={(e) => {
e.stopPropagation();
c.onClose();
}}
aria-label={c.label}
title={c.label}
>
×
</button>
))}
</div>
) : null}
<div ref={markerLayerRef} className={styles.markerLayer} />
<div
className={styles.hostViewport}
style={overlayRootStyle(layoutViewport)}
role="presentation"
>
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
{children}
{closes.length > 0 ? (
<div className={styles.closeStack}>
{closes.map((c) => (
<button
key={c.key}
type="button"
className={styles.close}
onClick={(e) => {
e.stopPropagation();
c.onClose();
}}
aria-label={c.label}
title={c.label}
>
×
</button>
))}
</div>
) : null}
</div>
{legendLayer ? <div className={styles.legendLayer}>{legendLayer}</div> : null}
</div>
</SceneOverlayViewContext.Provider>
);