Files
DndGamePlayer/app/renderer/shared/sceneOverlay/SceneOverlayHost.tsx
T
Ivan Fontosh d9fbecf5a7 perf: cut ControlApp re-renders and lazy-load VFX packs
Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:58:16 +08:00

117 lines
3.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 { SceneOverlayViewContext } from './SceneOverlayViewContext';
export type SceneOverlayCloseAction = {
key: string;
label: string;
onClose: () => void;
};
type SceneOverlayHostProps = {
/** Есть ли что показывать (материал и/или NPC). */
active: boolean;
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
closes?: readonly SceneOverlayCloseAction[];
children: React.ReactNode;
};
/**
* Общий слой подложки для Materials + NPCs: один root и один `.dim`.
* Кадры остаются в дочерних оверлеях (`embedded`).
*/
export function SceneOverlayHost({
active,
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]);
const ctx = useMemo(() => ({ rootRef, view }), [view]);
if (!active) 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}>
<div
ref={rootRef}
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 className={styles.dim} />
{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>
);
}