Files
DndGamePlayer/app/renderer/shared/sceneOverlay/SceneOverlayHost.tsx
T
Ivan Fontosh 4456eb0277 feat(control): grid snap, NPC context actions, and overlay dim fix
Re-enable users-branch UI, snap session tokens to square/hex grid from the control preview, refine inactive/open-info NPC menus, block marker actions while an effect brush is active, and dim materials/NPC overlays only when they are open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:48:59 +08:00

130 lines
4.4 KiB
TypeScript
Raw 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[];
children: React.ReactNode;
};
/**
* Общий слой подложки для Materials + NPCs.
* Dim — только при `active`, на весь родитель (экран презентации / рамка превью пульта).
* Кадры остаются в дочерних оверлеях (`embedded`) внутри `viewport`.
*/
export function SceneOverlayHost({
active,
viewport = null,
showViewportGuide = false,
zoomTool = null,
onZoomAt,
closes = [],
children,
}: SceneOverlayHostProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const [view, setView] = useState({ w: 1, h: 1 });
useEffect(() => {
const el = rootRef.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, view }), [view]);
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 = rootRef.current;
if (!root) return { nx: 0.5, ny: 0.5 };
const r = root.getBoundingClientRect();
return {
nx: (clientX - r.left) / Math.max(1, r.width),
ny: (clientY - r.top) / Math.max(1, r.height),
};
};
return (
<SceneOverlayViewContext.Provider value={ctx}>
{active ? <div className={styles.dim} aria-hidden /> : null}
<div
ref={rootRef}
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
.filter(Boolean)
.join(' ')}
style={overlayRootStyle(viewport)}
role="presentation"
onClick={(e) => {
if (!captureZoom || !onZoomAt) return;
e.stopPropagation();
const { nx, ny } = toNorm(e.clientX, e.clientY);
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>
</SceneOverlayViewContext.Provider>
);
}