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>
This commit is contained in:
@@ -12,6 +12,7 @@ import { MaterialOverlay } from './materials/MaterialOverlay';
|
||||
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
||||
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
||||
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
||||
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
||||
import styles from './PresentationView.module.css';
|
||||
import { RotatedImage } from './RotatedImage';
|
||||
import { useAssetUrl } from './useAssetImageUrl';
|
||||
@@ -164,13 +165,16 @@ export function PresentationView({
|
||||
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
|
||||
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
|
||||
) : null}
|
||||
{activeMaterial ? (
|
||||
<MaterialOverlay
|
||||
assetId={activeMaterial.assetId}
|
||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||
/>
|
||||
) : null}
|
||||
{activeNpcItems.length > 0 ? <NpcsSceneOverlay items={activeNpcItems} /> : null}
|
||||
<SceneOverlayHost active={Boolean(activeMaterial) || activeNpcItems.length > 0}>
|
||||
{activeMaterial ? (
|
||||
<MaterialOverlay
|
||||
embedded
|
||||
assetId={activeMaterial.assetId}
|
||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||
/>
|
||||
) : null}
|
||||
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
|
||||
</SceneOverlayHost>
|
||||
{showTitle ? (
|
||||
<div className={styles.titleWrap}>
|
||||
<div className={compact ? styles.titleCompact : styles.titleFull}>
|
||||
|
||||
@@ -15,3 +15,24 @@ void test('PxiEffectsOverlay: ограничение FPS тикера для н
|
||||
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
|
||||
assert.ok(src.includes('app.ticker.maxFPS'));
|
||||
});
|
||||
|
||||
void test('PxiEffectsOverlay: imperative setDraft для кисти без React state', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
|
||||
assert.ok(src.includes('setDraft'));
|
||||
assert.ok(src.includes('useImperativeHandle'));
|
||||
assert.ok(src.includes('mergeDraftState'));
|
||||
});
|
||||
|
||||
void test('PxiEffectsOverlay: lazy VFX packs + idle ticker stop', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
|
||||
assert.ok(src.includes('ensureVfxPacksForState'));
|
||||
assert.ok(src.includes('collectNeededVfxPacks'));
|
||||
assert.ok(src.includes('syncTickerForState'));
|
||||
assert.ok(src.includes('app.ticker.stop'));
|
||||
// Eager preload всех наборов при init убран.
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/syncNodes\([\s\S]*?stateRef\.current[\s\S]*?\);\s*void preloadLightningVfxFrameTextures\(pixi\);\s*void preloadElectricAccentFrameTextures\(pixi\);\s*void preloadFogVfxFrameTextures\(pixi\);/,
|
||||
);
|
||||
assert.ok(src.includes('Lazy VFX'));
|
||||
});
|
||||
|
||||
@@ -1,9 +1,140 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
|
||||
|
||||
import type { EffectsState, EffectInstance } from '../../../shared/types/effects';
|
||||
import type {
|
||||
EffectInstance,
|
||||
EffectInstanceType,
|
||||
EffectsState,
|
||||
EffectToolType,
|
||||
} from '../../../shared/types/effects';
|
||||
|
||||
import styles from './PxiEffectsOverlay.module.css';
|
||||
|
||||
export type PixiEffectsOverlayHandle = {
|
||||
/** Draft-штрих без React re-render родителя; null — сброс. */
|
||||
setDraft: (instance: EffectInstance | null) => void;
|
||||
};
|
||||
|
||||
/** Наборы кадровых VFX — подгружаем только по tool / живым instances. */
|
||||
type VfxFramePack =
|
||||
| 'fog'
|
||||
| 'fire'
|
||||
| 'rain'
|
||||
| 'water'
|
||||
| 'lightning'
|
||||
| 'sunbeam'
|
||||
| 'poisonCloud';
|
||||
|
||||
function mergeDraftState(
|
||||
state: EffectsState | null,
|
||||
draft: EffectInstance | null,
|
||||
): EffectsState | null {
|
||||
if (!draft) return state;
|
||||
if (!state) {
|
||||
return {
|
||||
revision: 0,
|
||||
serverNowMs: Date.now(),
|
||||
tool: { tool: 'fog', radiusN: 0.05, intensity: 1 },
|
||||
instances: [draft],
|
||||
};
|
||||
}
|
||||
const rest = state.instances.filter((i) => i.id !== '__draft__');
|
||||
return { ...state, instances: [...rest, draft] };
|
||||
}
|
||||
|
||||
function vfxPacksForTool(tool: EffectToolType): readonly VfxFramePack[] {
|
||||
switch (tool) {
|
||||
case 'fog':
|
||||
return ['fog'];
|
||||
case 'fire':
|
||||
return ['fire'];
|
||||
case 'rain':
|
||||
return ['rain'];
|
||||
case 'water':
|
||||
return ['water'];
|
||||
case 'lightning':
|
||||
return ['lightning'];
|
||||
case 'sunbeam':
|
||||
return ['sunbeam'];
|
||||
case 'poisonCloud':
|
||||
return ['poisonCloud'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function vfxPacksForInstanceType(type: EffectInstanceType): readonly VfxFramePack[] {
|
||||
switch (type) {
|
||||
case 'fog':
|
||||
return ['fog'];
|
||||
case 'fire':
|
||||
return ['fire'];
|
||||
case 'rain':
|
||||
return ['rain'];
|
||||
case 'water':
|
||||
return ['water'];
|
||||
case 'lightning':
|
||||
return ['lightning'];
|
||||
case 'sunbeam':
|
||||
return ['sunbeam'];
|
||||
case 'poisonCloud':
|
||||
return ['poisonCloud'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function collectNeededVfxPacks(state: EffectsState | null): VfxFramePack[] {
|
||||
if (!state) return [];
|
||||
const packs = new Set<VfxFramePack>();
|
||||
for (const p of vfxPacksForTool(state.tool.tool)) packs.add(p);
|
||||
for (const inst of state.instances) {
|
||||
for (const p of vfxPacksForInstanceType(inst.type)) packs.add(p);
|
||||
}
|
||||
return [...packs];
|
||||
}
|
||||
|
||||
function preloadVfxFramePack(pixi: any, pack: VfxFramePack): void {
|
||||
switch (pack) {
|
||||
case 'fog':
|
||||
void preloadFogVfxFrameTextures(pixi);
|
||||
break;
|
||||
case 'fire':
|
||||
void preloadGroundFireVfxFrameTextures(pixi);
|
||||
break;
|
||||
case 'rain':
|
||||
void preloadRainVfxFrameTextures(pixi);
|
||||
break;
|
||||
case 'water':
|
||||
void preloadWaterVfxFrameTextures(pixi);
|
||||
break;
|
||||
case 'lightning':
|
||||
void preloadLightningVfxFrameTextures(pixi);
|
||||
void preloadElectricAccentFrameTextures(pixi);
|
||||
break;
|
||||
case 'sunbeam':
|
||||
void preloadPulseDischargeFrameTextures(pixi);
|
||||
break;
|
||||
case 'poisonCloud':
|
||||
void preloadDustBurstFrameTextures(pixi);
|
||||
break;
|
||||
default: {
|
||||
const _exhaustive: never = pack;
|
||||
void _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureVfxPacksForState(pixi: any, state: EffectsState | null): void {
|
||||
if (!pixi) return;
|
||||
for (const pack of collectNeededVfxPacks(state)) {
|
||||
preloadVfxFramePack(pixi, pack);
|
||||
}
|
||||
}
|
||||
|
||||
function effectsHaveWork(state: EffectsState | null): boolean {
|
||||
return Boolean(state && state.instances.length > 0);
|
||||
}
|
||||
|
||||
const LIGHTNING_VFX_FRAME_COUNT = 19;
|
||||
const LIGHTNING_VFX_FRAME_ASPECT = 420 / 473;
|
||||
const LIGHTNING_VFX_STRIKE_MS = 320;
|
||||
@@ -52,18 +183,81 @@ type Props = {
|
||||
* - Pixi `Application` — это WebGL-рендерер + тикер.
|
||||
* - Мы держим один `Application` на компонент, и при изменении `state` просто перерисовываем сцену.
|
||||
* - Вариант A: рисуем "инстансы эффектов" (данные), а не пиксели.
|
||||
* - Draft кисти идёт через `setDraft` (imperative), без re-render ControlApp.
|
||||
*/
|
||||
export function PixiEffectsOverlay({ state, interactive = false, style, viewport }: Props) {
|
||||
export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(function PixiEffectsOverlay(
|
||||
{ state, interactive = false, style, viewport },
|
||||
ref,
|
||||
) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const appRef = useRef<any>(null);
|
||||
const rootRef = useRef<any>(null);
|
||||
const pixiRef = useRef<any>(null);
|
||||
const nodesRef = useRef<Map<string, any>>(new Map());
|
||||
const committedStateRef = useRef<EffectsState | null>(null);
|
||||
const draftRef = useRef<EffectInstance | null>(null);
|
||||
const stateRef = useRef<EffectsState | null>(null);
|
||||
const timeOffsetRef = useRef(0);
|
||||
const sizeRef = useRef<{ w: number; h: number }>({ w: 1, h: 1 });
|
||||
const viewportRef = useRef<{ x: number; y: number; w: number; h: number }>({ x: 0, y: 0, w: 1, h: 1 });
|
||||
const viewportProvidedRef = useRef(false);
|
||||
/** null = ещё не синхронизировали с Pixi (ticker по умолчанию бежит). */
|
||||
const tickerWantedRef = useRef<boolean | null>(null);
|
||||
|
||||
const syncTickerForState = (merged: EffectsState | null): void => {
|
||||
const app = appRef.current;
|
||||
if (!app?.ticker) return;
|
||||
const want = effectsHaveWork(merged);
|
||||
if (tickerWantedRef.current === want) return;
|
||||
tickerWantedRef.current = want;
|
||||
if (want) {
|
||||
try {
|
||||
app.ticker.start();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
const root = rootRef.current;
|
||||
if (root) {
|
||||
root.x = 0;
|
||||
root.y = 0;
|
||||
}
|
||||
try {
|
||||
app.ticker.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const applyMergedState = (committed: EffectsState | null, draft: EffectInstance | null): void => {
|
||||
committedStateRef.current = committed;
|
||||
draftRef.current = draft;
|
||||
const merged = mergeDraftState(committed, draft);
|
||||
stateRef.current = merged;
|
||||
if (merged) {
|
||||
timeOffsetRef.current = merged.serverNowMs - Date.now();
|
||||
}
|
||||
const pixi = pixiRef.current;
|
||||
const root = rootRef.current;
|
||||
if (!pixi || !root) {
|
||||
syncTickerForState(merged);
|
||||
return;
|
||||
}
|
||||
ensureVfxPacksForState(pixi, merged);
|
||||
syncNodes(pixi, root, nodesRef.current, merged, sizeRef.current, viewportRef.current);
|
||||
syncTickerForState(merged);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
setDraft: (instance) => {
|
||||
applyMergedState(committedStateRef.current, instance);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/** Снижаем resolution на HiDPI — меньше пикселей в WebGL, визуально ок для оверлея эффектов. */
|
||||
const dpr = useMemo(() => Math.min(1.5, window.devicePixelRatio || 1), []);
|
||||
@@ -121,32 +315,28 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
|
||||
if (!viewportProvidedRef.current) {
|
||||
viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
|
||||
}
|
||||
// Lazy VFX: только pack'и для текущего tool / уже размещённых instances (не все наборы сразу).
|
||||
ensureVfxPacksForState(pixi, stateRef.current);
|
||||
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
|
||||
void preloadLightningVfxFrameTextures(pixi);
|
||||
void preloadElectricAccentFrameTextures(pixi);
|
||||
void preloadFogVfxFrameTextures(pixi);
|
||||
void preloadGroundFireVfxFrameTextures(pixi);
|
||||
void preloadRainVfxFrameTextures(pixi);
|
||||
void preloadWaterVfxFrameTextures(pixi);
|
||||
void preloadPulseDischargeFrameTextures(pixi);
|
||||
void preloadDustBurstFrameTextures(pixi);
|
||||
|
||||
// Animation loop: на каждом кадре обновляем свойства инстансов (alpha/дрейф/фликер).
|
||||
// В idle (нет instances/draft) ticker останавливается — см. syncTickerForState.
|
||||
app.ticker.add(() => {
|
||||
const s = stateRef.current;
|
||||
if (!s) return;
|
||||
if (!s || s.instances.length === 0) return;
|
||||
const nowMs = Date.now() + timeOffsetRef.current;
|
||||
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current);
|
||||
|
||||
// Лёгкое “потряхивание” сцены в момент удара молнии.
|
||||
// Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику.
|
||||
const root = rootRef.current;
|
||||
if (root) {
|
||||
const rootNode = rootRef.current;
|
||||
if (rootNode) {
|
||||
const { x, y } = computeSceneShake(s, nowMs, sizeRef.current);
|
||||
root.x = x;
|
||||
root.y = y;
|
||||
rootNode.x = x;
|
||||
rootNode.y = y;
|
||||
}
|
||||
});
|
||||
syncTickerForState(stateRef.current);
|
||||
|
||||
cleanup = () => ro.disconnect();
|
||||
} catch (e) {
|
||||
@@ -181,16 +371,7 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
|
||||
}, [interactive]);
|
||||
|
||||
useEffect(() => {
|
||||
const app = appRef.current;
|
||||
const root = rootRef.current;
|
||||
if (!app || !root) return;
|
||||
stateRef.current = state;
|
||||
if (state) {
|
||||
timeOffsetRef.current = state.serverNowMs - Date.now();
|
||||
}
|
||||
const pixi = pixiRef.current;
|
||||
if (!pixi) return;
|
||||
syncNodes(pixi, root, nodesRef.current, state, sizeRef.current, viewportRef.current);
|
||||
applyMergedState(state, draftRef.current);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -207,7 +388,7 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
|
||||
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');
|
||||
|
||||
return <div ref={hostRef} className={hostClass} style={style} />;
|
||||
}
|
||||
});
|
||||
|
||||
function syncNodes(
|
||||
pixi: any,
|
||||
@@ -236,6 +417,19 @@ function syncNodes(
|
||||
const sig = instanceSig(inst, viewport);
|
||||
const existing = nodes.get(inst.id);
|
||||
if (existing && (existing as any).__sig === sig) continue;
|
||||
// Water draft: перерисовываем Graphics in-place (без destroy/create на каждую точку).
|
||||
if (
|
||||
existing &&
|
||||
inst.id === '__draft__' &&
|
||||
inst.type === 'water' &&
|
||||
(existing as any).__fx?.kind === 'waterDraft'
|
||||
) {
|
||||
const halfW = Math.max(1.5, inst.radiusN * Math.min(viewport.w, viewport.h));
|
||||
redrawWaterDraft((existing as any).__fx.g, inst, viewport, halfW);
|
||||
existing.alpha = Math.max(0.35, Math.min(0.95, inst.opacity * 1.1));
|
||||
(existing as any).__sig = sig;
|
||||
continue;
|
||||
}
|
||||
if (existing) {
|
||||
const fx = (existing as any).__fx;
|
||||
fx?.video?.pause?.();
|
||||
|
||||
@@ -12,6 +12,15 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/** Общий host: клики проходят сквозь dim к сцене; кадры/кнопки ловят сами. */
|
||||
.hostHitThrough {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.captureZoom {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.cursorZoomIn {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
@@ -89,11 +98,22 @@
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.close {
|
||||
.closeStack {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
z-index: 3;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
@@ -105,12 +125,20 @@
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: rgba(24, 24, 32, 0.9);
|
||||
}
|
||||
|
||||
/** Standalone-оверлей (без host): одна кнопка в углу. */
|
||||
.root > .close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
}
|
||||
|
||||
.frameRotate {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
|
||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
|
||||
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
|
||||
import { useAssetUrl } from '../useAssetImageUrl';
|
||||
|
||||
import styles from './MaterialOverlay.module.css';
|
||||
@@ -19,6 +20,8 @@ type MaterialOverlayProps = {
|
||||
rotateLabel?: string;
|
||||
onLayoutChange?: (layout: MaterialsOverlayLayout) => void;
|
||||
onZoomAt?: (nx: number, ny: number) => void;
|
||||
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
function RotateIcon() {
|
||||
@@ -95,11 +98,21 @@ export function MaterialOverlay({
|
||||
rotateLabel = 'Rotate',
|
||||
onLayoutChange,
|
||||
onZoomAt,
|
||||
embedded = false,
|
||||
}: MaterialOverlayProps) {
|
||||
const url = useAssetUrl(assetId);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const host = useSceneOverlayView();
|
||||
const localRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const rootRef = embedded && host ? host.rootRef : localRootRef;
|
||||
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
|
||||
const [view, setView] = useState({ w: 1, h: 1 });
|
||||
const [localView, setLocalView] = useState({ w: 1, h: 1 });
|
||||
/** Локальный layout + IPC не чаще 1/frame (лайв, без спама pointermove). */
|
||||
const [draftLayout, setDraftLayout] = useState<MaterialsOverlayLayout | null>(null);
|
||||
const pendingLayoutRef = useRef<MaterialsOverlayLayout | null>(null);
|
||||
const draftRafRef = useRef(0);
|
||||
const onLayoutChangeRef = useRef(onLayoutChange);
|
||||
onLayoutChangeRef.current = onLayoutChange;
|
||||
const view = embedded && host ? host.view : localView;
|
||||
const dragRef = useRef<
|
||||
| { mode: 'move'; startX: number; startY: number; origin: MaterialsOverlayLayout }
|
||||
| {
|
||||
@@ -124,14 +137,33 @@ export function MaterialOverlay({
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
if (embedded) return;
|
||||
const el = localRootRef.current;
|
||||
if (!el) return;
|
||||
const sync = () => setView({ w: el.clientWidth, h: el.clientHeight });
|
||||
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);
|
||||
setLocalView((prev) => (prev.w === w && prev.h === h ? prev : { w, h }));
|
||||
});
|
||||
};
|
||||
sync();
|
||||
const ro = new ResizeObserver(sync);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [url]);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
if (raf !== 0) window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [embedded, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragRef.current) return;
|
||||
setDraftLayout(null);
|
||||
pendingLayoutRef.current = null;
|
||||
}, [layout]);
|
||||
|
||||
if (!assetId || !url) return null;
|
||||
|
||||
@@ -139,7 +171,7 @@ export function MaterialOverlay({
|
||||
const zoomCursor =
|
||||
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
|
||||
|
||||
const effectiveLayout = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
|
||||
const effectiveLayout = draftLayout ?? layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
|
||||
const rotationDeg = effectiveLayout.rotationDeg ?? 0;
|
||||
const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
|
||||
const w = base.w * effectiveLayout.scale;
|
||||
@@ -167,6 +199,18 @@ export function MaterialOverlay({
|
||||
};
|
||||
};
|
||||
|
||||
const publishDraft = (next: MaterialsOverlayLayout): void => {
|
||||
pendingLayoutRef.current = next;
|
||||
if (draftRafRef.current !== 0) return;
|
||||
draftRafRef.current = window.requestAnimationFrame(() => {
|
||||
draftRafRef.current = 0;
|
||||
const pending = pendingLayoutRef.current;
|
||||
if (!pending) return;
|
||||
setDraftLayout(pending);
|
||||
onLayoutChangeRef.current?.(pending);
|
||||
});
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || !onLayoutChange) return;
|
||||
@@ -174,7 +218,7 @@ export function MaterialOverlay({
|
||||
if (drag.mode === 'rotate') {
|
||||
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
|
||||
const delta = shortestAngleDelta(drag.startPointerAngle, angle);
|
||||
onLayoutChange({
|
||||
publishDraft({
|
||||
...drag.origin,
|
||||
rotationDeg: drag.origin.rotationDeg + delta,
|
||||
});
|
||||
@@ -188,7 +232,7 @@ export function MaterialOverlay({
|
||||
if (drag.mode === 'move') {
|
||||
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
|
||||
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
|
||||
onLayoutChange({
|
||||
publishDraft({
|
||||
...drag.origin,
|
||||
cx: drag.origin.cx + dx,
|
||||
cy: drag.origin.cy + dy,
|
||||
@@ -247,7 +291,7 @@ export function MaterialOverlay({
|
||||
const localCenterY = nextTop + hh / 2;
|
||||
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
|
||||
|
||||
onLayoutChange({
|
||||
publishDraft({
|
||||
...origin,
|
||||
cx: origin.cx + screenOffset.x / Math.max(1, viewW),
|
||||
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
|
||||
@@ -259,6 +303,15 @@ export function MaterialOverlay({
|
||||
dragRef.current = null;
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', endDrag);
|
||||
if (draftRafRef.current !== 0) {
|
||||
window.cancelAnimationFrame(draftRafRef.current);
|
||||
draftRafRef.current = 0;
|
||||
}
|
||||
const finalLayout = pendingLayoutRef.current;
|
||||
if (finalLayout) {
|
||||
setDraftLayout(finalLayout);
|
||||
onLayoutChangeRef.current?.(finalLayout);
|
||||
}
|
||||
};
|
||||
|
||||
const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => {
|
||||
@@ -306,9 +359,70 @@ export function MaterialOverlay({
|
||||
window.addEventListener('pointerup', endDrag);
|
||||
};
|
||||
|
||||
const frame = (
|
||||
<div
|
||||
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
|
||||
data-overlay-kind="material"
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: w,
|
||||
height: h,
|
||||
transform: `rotate(${String(rotationDeg)}deg)`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (zoomTool) return;
|
||||
startDrag(e, 'move');
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className={styles.image}
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: w,
|
||||
height: h,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
|
||||
}}
|
||||
/>
|
||||
{editable && !zoomTool
|
||||
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
|
||||
<button
|
||||
key={corner}
|
||||
type="button"
|
||||
className={[styles.handle, styles[`handle_${corner}`]].join(' ')}
|
||||
aria-label={corner}
|
||||
onPointerDown={(e) => startDrag(e, 'resize', corner)}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{editable && !zoomTool && onLayoutChange ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.frameRotate}
|
||||
aria-label={rotateLabel}
|
||||
title={rotateLabel}
|
||||
onPointerDown={startRotate}
|
||||
>
|
||||
<RotateIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return frame;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
ref={localRootRef}
|
||||
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
@@ -322,61 +436,7 @@ export function MaterialOverlay({
|
||||
}}
|
||||
>
|
||||
<div className={styles.dim} />
|
||||
<div
|
||||
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: w,
|
||||
height: h,
|
||||
transform: `rotate(${String(rotationDeg)}deg)`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (zoomTool) return;
|
||||
startDrag(e, 'move');
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className={styles.image}
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: w,
|
||||
height: h,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
|
||||
}}
|
||||
/>
|
||||
{editable && !zoomTool
|
||||
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
|
||||
<button
|
||||
key={corner}
|
||||
type="button"
|
||||
className={[styles.handle, styles[`handle_${corner}`]].join(' ')}
|
||||
aria-label={corner}
|
||||
onPointerDown={(e) => startDrag(e, 'resize', corner)}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{editable && !zoomTool && onLayoutChange ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.frameRotate}
|
||||
aria-label={rotateLabel}
|
||||
title={rotateLabel}
|
||||
onPointerDown={startRotate}
|
||||
>
|
||||
<RotateIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{frame}
|
||||
{showClose ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { AssetId, NpcId, NpcsOverlayLayout, NpcsZoomTool } from '../../../shared/types';
|
||||
import { DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../../shared/types';
|
||||
import styles from '../materials/MaterialOverlay.module.css';
|
||||
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
|
||||
import { useAssetUrl } from '../useAssetImageUrl';
|
||||
|
||||
type Corner = 'nw' | 'ne' | 'sw' | 'se';
|
||||
@@ -23,6 +24,8 @@ type NpcsSceneOverlayProps = {
|
||||
rotateLabel?: string;
|
||||
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
|
||||
onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void;
|
||||
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
function RotateIcon() {
|
||||
@@ -107,6 +110,11 @@ function NpcAvatarFrame({
|
||||
}) {
|
||||
const url = useAssetUrl(item.assetId);
|
||||
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
|
||||
const [draftLayout, setDraftLayout] = useState<NpcsOverlayLayout | null>(null);
|
||||
const pendingLayoutRef = useRef<NpcsOverlayLayout | null>(null);
|
||||
const draftRafRef = useRef(0);
|
||||
const onLayoutChangeRef = useRef(onLayoutChange);
|
||||
onLayoutChangeRef.current = onLayoutChange;
|
||||
const dragRef = useRef<
|
||||
| { mode: 'move'; startX: number; startY: number; origin: NpcsOverlayLayout }
|
||||
| {
|
||||
@@ -130,9 +138,15 @@ function NpcAvatarFrame({
|
||||
| null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragRef.current) return;
|
||||
setDraftLayout(null);
|
||||
pendingLayoutRef.current = null;
|
||||
}, [item.layout]);
|
||||
|
||||
if (!item.assetId || !url) return null;
|
||||
|
||||
const layout = item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT;
|
||||
const layout = draftLayout ?? item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT;
|
||||
const rotationDeg = layout.rotationDeg ?? 0;
|
||||
const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
|
||||
const w = base.w * layout.scale;
|
||||
@@ -140,6 +154,18 @@ function NpcAvatarFrame({
|
||||
const left = layout.cx * view.w - w / 2;
|
||||
const top = layout.cy * view.h - h / 2;
|
||||
|
||||
const publishDraft = (next: NpcsOverlayLayout): void => {
|
||||
pendingLayoutRef.current = next;
|
||||
if (draftRafRef.current !== 0) return;
|
||||
draftRafRef.current = window.requestAnimationFrame(() => {
|
||||
draftRafRef.current = 0;
|
||||
const pending = pendingLayoutRef.current;
|
||||
if (!pending) return;
|
||||
setDraftLayout(pending);
|
||||
onLayoutChangeRef.current?.(item.npcId, pending);
|
||||
});
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || !onLayoutChange) return;
|
||||
@@ -147,7 +173,7 @@ function NpcAvatarFrame({
|
||||
if (drag.mode === 'rotate') {
|
||||
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
|
||||
const delta = shortestAngleDelta(drag.startPointerAngle, angle);
|
||||
onLayoutChange(item.npcId, {
|
||||
publishDraft({
|
||||
...drag.origin,
|
||||
rotationDeg: drag.origin.rotationDeg + delta,
|
||||
});
|
||||
@@ -161,7 +187,7 @@ function NpcAvatarFrame({
|
||||
if (drag.mode === 'move') {
|
||||
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
|
||||
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
|
||||
onLayoutChange(item.npcId, {
|
||||
publishDraft({
|
||||
...drag.origin,
|
||||
cx: drag.origin.cx + dx,
|
||||
cy: drag.origin.cy + dy,
|
||||
@@ -220,7 +246,7 @@ function NpcAvatarFrame({
|
||||
const localCenterY = nextTop + hh / 2;
|
||||
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
|
||||
|
||||
onLayoutChange(item.npcId, {
|
||||
publishDraft({
|
||||
...origin,
|
||||
cx: origin.cx + screenOffset.x / Math.max(1, viewW),
|
||||
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
|
||||
@@ -232,6 +258,15 @@ function NpcAvatarFrame({
|
||||
dragRef.current = null;
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', endDrag);
|
||||
if (draftRafRef.current !== 0) {
|
||||
window.cancelAnimationFrame(draftRafRef.current);
|
||||
draftRafRef.current = 0;
|
||||
}
|
||||
const finalLayout = pendingLayoutRef.current;
|
||||
if (finalLayout) {
|
||||
setDraftLayout(finalLayout);
|
||||
onLayoutChangeRef.current?.(item.npcId, finalLayout);
|
||||
}
|
||||
};
|
||||
|
||||
const layoutCenterClient = () => {
|
||||
@@ -288,6 +323,7 @@ function NpcAvatarFrame({
|
||||
<div
|
||||
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
|
||||
data-npc-id={item.npcId}
|
||||
data-overlay-kind="npc"
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
@@ -352,19 +388,36 @@ export function NpcsSceneOverlay({
|
||||
rotateLabel = 'Rotate',
|
||||
onLayoutChange,
|
||||
onZoomAt,
|
||||
embedded = false,
|
||||
}: NpcsSceneOverlayProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const [view, setView] = useState({ w: 1, h: 1 });
|
||||
const host = useSceneOverlayView();
|
||||
const localRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const rootRef = embedded && host ? host.rootRef : localRootRef;
|
||||
const [localView, setLocalView] = useState({ w: 1, h: 1 });
|
||||
const view = embedded && host ? host.view : localView;
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
if (embedded) return;
|
||||
const el = localRootRef.current;
|
||||
if (!el) return;
|
||||
const sync = () => setView({ w: el.clientWidth, h: el.clientHeight });
|
||||
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);
|
||||
setLocalView((prev) => (prev.w === w && prev.h === h ? prev : { w, h }));
|
||||
});
|
||||
};
|
||||
sync();
|
||||
const ro = new ResizeObserver(sync);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [items.length]);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
if (raf !== 0) window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [embedded, items.length]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
@@ -389,9 +442,38 @@ export function NpcsSceneOverlay({
|
||||
return raw ? (raw as NpcId) : undefined;
|
||||
};
|
||||
|
||||
const frames = items.map((item) =>
|
||||
onLayoutChange ? (
|
||||
<NpcAvatarFrame
|
||||
key={item.npcId}
|
||||
item={item}
|
||||
view={view}
|
||||
rootRef={rootRef}
|
||||
editable={editable}
|
||||
zoomTool={zoomTool}
|
||||
rotateLabel={rotateLabel}
|
||||
onLayoutChange={onLayoutChange}
|
||||
/>
|
||||
) : (
|
||||
<NpcAvatarFrame
|
||||
key={item.npcId}
|
||||
item={item}
|
||||
view={view}
|
||||
rootRef={rootRef}
|
||||
editable={editable}
|
||||
zoomTool={zoomTool}
|
||||
rotateLabel={rotateLabel}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return <>{frames}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
ref={localRootRef}
|
||||
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
@@ -405,30 +487,7 @@ export function NpcsSceneOverlay({
|
||||
}}
|
||||
>
|
||||
<div className={styles.dim} />
|
||||
{items.map((item) =>
|
||||
onLayoutChange ? (
|
||||
<NpcAvatarFrame
|
||||
key={item.npcId}
|
||||
item={item}
|
||||
view={view}
|
||||
rootRef={rootRef}
|
||||
editable={editable}
|
||||
zoomTool={zoomTool}
|
||||
rotateLabel={rotateLabel}
|
||||
onLayoutChange={onLayoutChange}
|
||||
/>
|
||||
) : (
|
||||
<NpcAvatarFrame
|
||||
key={item.npcId}
|
||||
item={item}
|
||||
view={view}
|
||||
rootRef={rootRef}
|
||||
editable={editable}
|
||||
zoomTool={zoomTool}
|
||||
rotateLabel={rotateLabel}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{frames}
|
||||
{showClose ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
export type SceneOverlayViewContextValue = {
|
||||
rootRef: React.RefObject<HTMLDivElement | null>;
|
||||
view: { w: number; h: number };
|
||||
};
|
||||
|
||||
export const SceneOverlayViewContext = createContext<SceneOverlayViewContextValue | null>(null);
|
||||
|
||||
export function useSceneOverlayView(): SceneOverlayViewContextValue | null {
|
||||
return useContext(SceneOverlayViewContext);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..', '..', '..', '..');
|
||||
|
||||
void test('SceneOverlayHost: один dim для materials + npcs в Control и Presentation', () => {
|
||||
const host = fs.readFileSync(path.join(here, 'SceneOverlayHost.tsx'), 'utf8');
|
||||
const control = fs.readFileSync(path.join(root, 'app/renderer/control/ControlApp.tsx'), 'utf8');
|
||||
const presentation = fs.readFileSync(path.join(root, 'app/renderer/shared/PresentationView.tsx'), 'utf8');
|
||||
const css = fs.readFileSync(
|
||||
path.join(root, 'app/renderer/shared/materials/MaterialOverlay.module.css'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.ok(host.includes('styles.dim'));
|
||||
assert.ok(host.includes('hostHitThrough'));
|
||||
assert.ok(control.includes('SceneOverlayHost'));
|
||||
assert.ok(control.includes('embedded'));
|
||||
assert.ok(presentation.includes('SceneOverlayHost'));
|
||||
assert.ok(presentation.includes('embedded'));
|
||||
|
||||
// Control/Presentation монтируют оверлеи только как embedded внутри host.
|
||||
assert.ok(control.includes('<MaterialOverlay'));
|
||||
assert.ok(control.includes('<NpcsSceneOverlay'));
|
||||
assert.ok(control.includes('embedded'));
|
||||
assert.ok(presentation.includes('embedded'));
|
||||
assert.match(css, /\.hostHitThrough\s*\{[^}]*pointer-events:\s*none/s);
|
||||
});
|
||||
|
||||
void test('MaterialOverlay / NpcsSceneOverlay поддерживают embedded без собственного dim', () => {
|
||||
const material = fs.readFileSync(
|
||||
path.join(root, 'app/renderer/shared/materials/MaterialOverlay.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const npcs = fs.readFileSync(path.join(root, 'app/renderer/shared/npcs/NpcsSceneOverlay.tsx'), 'utf8');
|
||||
assert.ok(material.includes('embedded'));
|
||||
assert.ok(material.includes('useSceneOverlayView'));
|
||||
assert.ok(npcs.includes('embedded'));
|
||||
assert.ok(npcs.includes('useSceneOverlayView'));
|
||||
// В embedded-ветке не рисуем второй dim.
|
||||
assert.match(material, /if \(embedded\) \{\s*return frame;/);
|
||||
assert.match(npcs, /if \(embedded\) \{\s*return <>\{frames\}<\/>;/);
|
||||
});
|
||||
|
||||
void test('overlays: layout IPC live через rAF coalesce, RO host coalesced', () => {
|
||||
const host = fs.readFileSync(path.join(here, 'SceneOverlayHost.tsx'), 'utf8');
|
||||
const material = fs.readFileSync(
|
||||
path.join(root, 'app/renderer/shared/materials/MaterialOverlay.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const npcs = fs.readFileSync(path.join(root, 'app/renderer/shared/npcs/NpcsSceneOverlay.tsx'), 'utf8');
|
||||
|
||||
assert.ok(host.includes('requestAnimationFrame'));
|
||||
assert.ok(host.includes('prev.w === w && prev.h === h'));
|
||||
|
||||
// Лайв: publishDraft шлёт onLayoutChange внутри rAF (не на каждый pointermove).
|
||||
assert.ok(material.includes('publishDraft'));
|
||||
assert.ok(material.includes('onLayoutChangeRef'));
|
||||
assert.match(material, /requestAnimationFrame\(\(\) => \{[\s\S]*?onLayoutChangeRef\.current\?\.\(pending\)/);
|
||||
assert.match(
|
||||
material,
|
||||
/if \(drag\.mode === 'move'\) \{[\s\S]*?publishDraft\(\{[\s\S]*?return;\s*\}/,
|
||||
);
|
||||
|
||||
assert.ok(npcs.includes('publishDraft'));
|
||||
assert.ok(npcs.includes('onLayoutChangeRef'));
|
||||
assert.match(
|
||||
npcs,
|
||||
/requestAnimationFrame\(\(\) => \{[\s\S]*?onLayoutChangeRef\.current\?\.\(item\.npcId, pending\)/,
|
||||
);
|
||||
assert.match(
|
||||
npcs,
|
||||
/if \(drag\.mode === 'move'\) \{[\s\S]*?publishDraft\(\{[\s\S]*?return;\s*\}/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Регресс: списки сцен/материалов не должны дёргать assetFileUrl на каждый mount одного id. */
|
||||
void test('useAssetUrl: module cache + invalidate при смене проекта', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'useAssetImageUrl.ts'), 'utf8');
|
||||
const projectState = fs.readFileSync(path.join(here, '../editor/state/projectState.ts'), 'utf8');
|
||||
|
||||
assert.ok(src.includes('urlCache'));
|
||||
assert.ok(src.includes('invalidateAssetUrlCache'));
|
||||
assert.ok(src.includes('peekAssetUrlCache'));
|
||||
assert.ok(src.includes('session.stateChanged'));
|
||||
assert.match(src, /urlCache\.set\(id,\s*r\.url\)/);
|
||||
assert.match(src, /peekAssetUrlCache\(id\)/);
|
||||
|
||||
assert.ok(projectState.includes('invalidateAssetUrlCache'));
|
||||
assert.match(projectState, /openProject[\s\S]*?invalidateAssetUrlCache\(\)/);
|
||||
assert.match(projectState, /closeProject[\s\S]*?invalidateAssetUrlCache\(\)/);
|
||||
});
|
||||
@@ -1,31 +1,94 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { AssetId } from '../../shared/types';
|
||||
import type { AssetId, ProjectId } from '../../shared/types';
|
||||
|
||||
import { getDndApi } from './dndApi';
|
||||
|
||||
/** Module-level кэш assetId → url; сбрасывается при смене/закрытии проекта. */
|
||||
const urlCache = new Map<AssetId, string | null>();
|
||||
const invalidateListeners = new Set<() => void>();
|
||||
let sessionProjectHooked = false;
|
||||
let lastSessionProjectId: ProjectId | null | undefined;
|
||||
|
||||
function ensureSessionProjectInvalidation(): void {
|
||||
if (sessionProjectHooked) return;
|
||||
sessionProjectHooked = true;
|
||||
try {
|
||||
getDndApi().on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||
const next = state.project?.id ?? null;
|
||||
if (lastSessionProjectId === undefined) {
|
||||
lastSessionProjectId = next;
|
||||
return;
|
||||
}
|
||||
if (lastSessionProjectId !== next) {
|
||||
lastSessionProjectId = next;
|
||||
invalidateAssetUrlCache();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
/* вне Electron / тесты */
|
||||
}
|
||||
}
|
||||
|
||||
export function peekAssetUrlCache(assetId: AssetId): string | null | undefined {
|
||||
return urlCache.has(assetId) ? (urlCache.get(assetId) ?? null) : undefined;
|
||||
}
|
||||
|
||||
export function invalidateAssetUrlCache(): void {
|
||||
urlCache.clear();
|
||||
for (const fn of invalidateListeners) {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает `file://` URL для превью изображения. Пока загрузка или сменился id — `null`.
|
||||
* Возвращает `dnd://` / file URL для превью. Пока загрузка или сменился id — `null`.
|
||||
* Повторные запросы того же id не ходят в IPC, пока кэш не инвалидирован.
|
||||
*/
|
||||
export function useAssetUrl(assetId: AssetId | null | undefined): string | null {
|
||||
ensureSessionProjectInvalidation();
|
||||
const id = assetId ?? null;
|
||||
const [entry, setEntry] = useState<{ assetId: AssetId; url: string | null } | null>(null);
|
||||
const [entry, setEntry] = useState<{ assetId: AssetId; url: string | null } | null>(() => {
|
||||
if (id === null) return null;
|
||||
const hit = peekAssetUrlCache(id);
|
||||
return hit === undefined ? null : { assetId: id, url: hit };
|
||||
});
|
||||
const [epoch, setEpoch] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onInvalidate = () => setEpoch((n) => n + 1);
|
||||
invalidateListeners.add(onInvalidate);
|
||||
return () => {
|
||||
invalidateListeners.delete(onInvalidate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (id === null) {
|
||||
setEntry(null);
|
||||
return undefined;
|
||||
}
|
||||
const hit = peekAssetUrlCache(id);
|
||||
if (hit !== undefined) {
|
||||
setEntry({ assetId: id, url: hit });
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getDndApi()
|
||||
.invoke(ipcChannels.project.assetFileUrl, { assetId: id })
|
||||
.then((r) => {
|
||||
urlCache.set(id, r.url);
|
||||
if (!cancelled) setEntry({ assetId: id, url: r.url });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
}, [id, epoch]);
|
||||
|
||||
if (id === null) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user