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(null); const markerLayerRef = useRef(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 ( {active ?
: null}
{ if (!captureZoom || !onZoomAt) return; e.stopPropagation(); const { nx, ny } = toNorm(e.clientX, e.clientY); onZoomAt(nx, ny, e.target); }} >
{showViewportGuide ?
: null} {children} {closes.length > 0 ? (
{closes.map((c) => ( ))}
) : null}
{legendLayer ?
{legendLayer}
: null}
); }