Files
Ivan Fontosh 153be98780 fix(materials): синхронизация легенды и маркеров на превью и презентации
Легенда и цифры на материале масштабируются от экрана игроков и не обрезаются жёлтой рамкой. Переключение сцен без записи project.json на диск.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:57:50 +08:00

154 lines
5.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import styles from '../materials/MaterialOverlay.module.css';
import { overlayRootStyle, type SceneOverlayViewport } from './overlayViewport';
import { SceneOverlayViewContext } from './SceneOverlayViewContext';
export type SceneOverlayCloseAction = {
key: string;
label: string;
onClose: () => void;
};
type SceneOverlayHostProps = {
/** Есть ли что показывать (материал и/или NPC). */
active: boolean;
/**
* Область раскладки кадров материалов/NPC (и жёлтой рамки).
* На пульте — экран игроков (жёлтая рамка); на презентации не задаётся (весь экран).
*/
viewport?: SceneOverlayViewport | null;
/** Жёлтая рамка видимой области презентации (предпросмотр пульта). Без dim. */
showViewportGuide?: boolean;
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;
};
/**
* Общий слой подложки для Materials + NPCs.
* Dim — только при `active`, на весь родитель (экран презентации / рамка превью пульта).
* Кадры остаются в дочерних оверлеях (`embedded`) внутри `viewport`.
*/
export function SceneOverlayHost({
active,
viewport = null,
showViewportGuide = false,
zoomTool = null,
onZoomAt,
closes = [],
children,
legendLayer = null,
}: SceneOverlayHostProps) {
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 = outerRef.current;
if (!el) return;
let raf = 0;
const sync = () => {
if (raf !== 0) return;
raf = window.requestAnimationFrame(() => {
raf = 0;
const w = Math.max(1, el.clientWidth);
const h = Math.max(1, el.clientHeight);
setView((prev) => (prev.w === w && prev.h === h ? prev : { w, h }));
});
};
sync();
const ro = new ResizeObserver(sync);
ro.observe(el);
return () => {
ro.disconnect();
if (raf !== 0) window.cancelAnimationFrame(raf);
};
}, [active, showViewportGuide, viewport]);
const ctx = useMemo(
() => ({
rootRef: outerRef,
markerLayerRef,
view: { w: layoutViewport.w, h: layoutViewport.h },
viewport: layoutViewport,
}),
[layoutViewport],
);
if (!active && !showViewportGuide) return null;
const captureZoom = Boolean(zoomTool && onZoomAt);
const zoomCursor =
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
const toNorm = (clientX: number, clientY: number) => {
const root = outerRef.current;
if (!root || layoutViewport.w <= 0 || layoutViewport.h <= 0) return { nx: 0.5, ny: 0.5 };
return {
nx: (clientX - root.getBoundingClientRect().left - layoutViewport.x) / layoutViewport.w,
ny: (clientY - root.getBoundingClientRect().top - layoutViewport.y) / layoutViewport.h,
};
};
return (
<SceneOverlayViewContext.Provider value={ctx}>
{active ? <div className={styles.dim} aria-hidden /> : null}
<div
ref={outerRef}
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
.filter(Boolean)
.join(' ')}
role="presentation"
onClick={(e) => {
if (!captureZoom || !onZoomAt) return;
e.stopPropagation();
const { nx, ny } = toNorm(e.clientX, e.clientY);
onZoomAt(nx, ny, e.target);
}}
>
<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>
);
}