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:
@@ -4,14 +4,20 @@
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 16px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.remote {
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-height: calc(100vh - 32px);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.remoteTitle {
|
||||
@@ -456,10 +462,51 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audioControls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
align-items: stretch;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.audioTransport {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.audioVolumeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audioVolumeIcon {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.audioVolumeIcon svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.audioVolume {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
accent-color: var(--accent-fill-solid);
|
||||
}
|
||||
|
||||
.scrubFill {
|
||||
|
||||
+398
-435
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,304 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Button } from '../shared/ui/controls';
|
||||
|
||||
import styles from './ControlApp.module.css';
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
if (!Number.isFinite(sec) || sec < 0) return '0:00';
|
||||
const s = Math.floor(sec);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function clampAudioGain(v: number): number {
|
||||
if (!Number.isFinite(v)) return 1;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
function VolumeSpeakerIcon({ gain }: { gain: number }) {
|
||||
if (gain <= 0.001) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (gain < 0.5) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export type ControlAudioCardProps = {
|
||||
assetId: string;
|
||||
name: string;
|
||||
autoplay: boolean;
|
||||
loop: boolean;
|
||||
statusLabel: string;
|
||||
statusDetail?: string;
|
||||
extraBadge?: React.ReactNode;
|
||||
audioEl: HTMLAudioElement | null;
|
||||
initialGain: number;
|
||||
gainMap: Map<string, number>;
|
||||
playTitle: string;
|
||||
playLabel: string;
|
||||
pauseLabel: string;
|
||||
stopLabel: string;
|
||||
volumeLabel: string;
|
||||
modeAutoLabel: string;
|
||||
modeManualLabel: string;
|
||||
loopLabel: string;
|
||||
onceLabel: string;
|
||||
scrubSeekLabel: string;
|
||||
durationUnknownLabel: string;
|
||||
/** Редкий bump родителя (play/pause/error) — не для scrub. */
|
||||
onStatusChange: () => void;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onStop: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Карточка трека: scrub/time обновляются локально (RAF → DOM), без ре-рендера всего ControlApp.
|
||||
*/
|
||||
export function ControlAudioCard({
|
||||
assetId,
|
||||
name,
|
||||
autoplay,
|
||||
loop,
|
||||
statusLabel,
|
||||
statusDetail,
|
||||
extraBadge,
|
||||
audioEl,
|
||||
initialGain,
|
||||
gainMap,
|
||||
playTitle,
|
||||
playLabel,
|
||||
pauseLabel,
|
||||
stopLabel,
|
||||
volumeLabel,
|
||||
modeAutoLabel,
|
||||
modeManualLabel,
|
||||
loopLabel,
|
||||
onceLabel,
|
||||
scrubSeekLabel,
|
||||
durationUnknownLabel,
|
||||
onStatusChange,
|
||||
onPlay,
|
||||
onPause,
|
||||
onStop,
|
||||
}: ControlAudioCardProps) {
|
||||
const scrubRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrubFillRef = useRef<HTMLDivElement | null>(null);
|
||||
const curTimeRef = useRef<HTMLDivElement | null>(null);
|
||||
const durTimeRef = useRef<HTMLDivElement | null>(null);
|
||||
const [gainUi, setGainUi] = useState(() => clampAudioGain(initialGain));
|
||||
const onStatusChangeRef = useRef(onStatusChange);
|
||||
onStatusChangeRef.current = onStatusChange;
|
||||
|
||||
useEffect(() => {
|
||||
setGainUi(clampAudioGain(gainMap.get(assetId) ?? initialGain));
|
||||
}, [assetId, gainMap, initialGain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioEl) return;
|
||||
let raf = 0;
|
||||
|
||||
const paint = (): void => {
|
||||
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
|
||||
const cur = audioEl.currentTime && Number.isFinite(audioEl.currentTime) ? audioEl.currentTime : 0;
|
||||
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
||||
if (scrubFillRef.current) {
|
||||
scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
|
||||
}
|
||||
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
|
||||
if (durTimeRef.current) durTimeRef.current.textContent = dur ? formatTime(dur) : '—:—';
|
||||
if (scrubRef.current) {
|
||||
scrubRef.current.setAttribute('aria-valuemin', '0');
|
||||
scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0));
|
||||
scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur)));
|
||||
scrubRef.current.title = dur > 0 ? scrubSeekLabel : durationUnknownLabel;
|
||||
scrubRef.current.classList.toggle(styles.audioScrubPointer ?? 'audioScrubPointer', dur > 0);
|
||||
scrubRef.current.classList.toggle(styles.audioScrubDefault ?? 'audioScrubDefault', dur <= 0);
|
||||
}
|
||||
};
|
||||
|
||||
const stopLoop = (): void => {
|
||||
if (raf !== 0) {
|
||||
window.cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const loopPaint = (): void => {
|
||||
paint();
|
||||
if (!audioEl.paused) {
|
||||
raf = window.requestAnimationFrame(loopPaint);
|
||||
} else {
|
||||
raf = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const startLoop = (): void => {
|
||||
stopLoop();
|
||||
raf = window.requestAnimationFrame(loopPaint);
|
||||
};
|
||||
|
||||
const onPlayEv = (): void => {
|
||||
startLoop();
|
||||
onStatusChangeRef.current();
|
||||
};
|
||||
const onPauseEv = (): void => {
|
||||
stopLoop();
|
||||
paint();
|
||||
onStatusChangeRef.current();
|
||||
};
|
||||
const onEndedEv = (): void => {
|
||||
stopLoop();
|
||||
paint();
|
||||
onStatusChangeRef.current();
|
||||
};
|
||||
const onMetaEv = (): void => {
|
||||
paint();
|
||||
onStatusChangeRef.current();
|
||||
};
|
||||
|
||||
audioEl.addEventListener('play', onPlayEv);
|
||||
audioEl.addEventListener('pause', onPauseEv);
|
||||
audioEl.addEventListener('ended', onEndedEv);
|
||||
audioEl.addEventListener('canplay', onMetaEv);
|
||||
audioEl.addEventListener('error', onMetaEv);
|
||||
paint();
|
||||
if (!audioEl.paused) startLoop();
|
||||
|
||||
return () => {
|
||||
stopLoop();
|
||||
audioEl.removeEventListener('play', onPlayEv);
|
||||
audioEl.removeEventListener('pause', onPauseEv);
|
||||
audioEl.removeEventListener('ended', onEndedEv);
|
||||
audioEl.removeEventListener('canplay', onMetaEv);
|
||||
audioEl.removeEventListener('error', onMetaEv);
|
||||
};
|
||||
}, [audioEl, durationUnknownLabel, scrubSeekLabel]);
|
||||
|
||||
const seekByClientX = (clientX: number): void => {
|
||||
if (!audioEl || !scrubRef.current) return;
|
||||
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
|
||||
if (!dur) return;
|
||||
const rect = scrubRef.current.getBoundingClientRect();
|
||||
const next = (clientX - rect.left) / Math.max(1, rect.width);
|
||||
audioEl.currentTime = Math.max(0, Math.min(dur, next * dur));
|
||||
const cur = audioEl.currentTime;
|
||||
const pct = Math.max(0, Math.min(1, cur / dur));
|
||||
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
|
||||
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
|
||||
};
|
||||
|
||||
const applyGain = (v: number): void => {
|
||||
const g = clampAudioGain(v);
|
||||
gainMap.set(assetId, g);
|
||||
if (audioEl) {
|
||||
try {
|
||||
audioEl.volume = g;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setGainUi(g);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.audioCard}>
|
||||
<div className={styles.audioMeta}>
|
||||
<div className={styles.audioName}>{name}</div>
|
||||
<div className={styles.audioBadges}>
|
||||
<div>{autoplay ? modeAutoLabel : modeManualLabel}</div>
|
||||
<div>{loop ? loopLabel : onceLabel}</div>
|
||||
<div title={statusDetail}>{statusLabel}</div>
|
||||
{extraBadge}
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
<div
|
||||
ref={scrubRef}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
className={[styles.audioScrub, styles.audioScrubDefault].join(' ')}
|
||||
onKeyDown={(e) => {
|
||||
if (!audioEl) return;
|
||||
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
|
||||
if (!dur) return;
|
||||
if (e.key === 'ArrowLeft') audioEl.currentTime = Math.max(0, audioEl.currentTime - 5);
|
||||
if (e.key === 'ArrowRight') audioEl.currentTime = Math.min(dur, audioEl.currentTime + 5);
|
||||
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(audioEl.currentTime);
|
||||
const pct = Math.max(0, Math.min(1, audioEl.currentTime / dur));
|
||||
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
|
||||
}}
|
||||
onClick={(e) => seekByClientX(e.clientX)}
|
||||
>
|
||||
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
|
||||
</div>
|
||||
<div className={styles.timeRow}>
|
||||
<div ref={curTimeRef}>0:00</div>
|
||||
<div ref={durTimeRef}>—:—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.audioControls}>
|
||||
<div className={styles.audioTransport}>
|
||||
<Button variant="primary" title={playTitle} ariaLabel={playLabel} onClick={onPlay}>
|
||||
▶
|
||||
</Button>
|
||||
<Button title={pauseLabel} ariaLabel={pauseLabel} onClick={onPause}>
|
||||
❚❚
|
||||
</Button>
|
||||
<Button
|
||||
title={stopLabel}
|
||||
ariaLabel={stopLabel}
|
||||
onClick={() => {
|
||||
onStop();
|
||||
if (scrubFillRef.current) scrubFillRef.current.style.width = '0%';
|
||||
if (curTimeRef.current) curTimeRef.current.textContent = '0:00';
|
||||
}}
|
||||
>
|
||||
■
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.audioVolumeRow}>
|
||||
<span className={styles.audioVolumeIcon} aria-hidden>
|
||||
<VolumeSpeakerIcon gain={gainUi} />
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={gainUi}
|
||||
disabled={!audioEl}
|
||||
className={styles.audioVolume}
|
||||
aria-label={volumeLabel}
|
||||
title={`${volumeLabel}: ${String(Math.round(gainUi * 100))}%`}
|
||||
onChange={(e) => applyGain(Number(e.currentTarget.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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));
|
||||
|
||||
/** Регресс: RAF в ControlApp бампил оба audio-tick на каждом кадре → полный ре-рендер пульта. */
|
||||
void test('ControlApp: нет per-frame RAF setState для аудио scrub', () => {
|
||||
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
|
||||
const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8');
|
||||
|
||||
assert.doesNotMatch(app, /\banyPlaying\b/);
|
||||
// Старый паттерн: RAF tick → оба set*AudioStateTick.
|
||||
assert.doesNotMatch(
|
||||
app,
|
||||
/const tick = \(\) => \{\s*setSceneAudioStateTick/,
|
||||
'корневой RAF-тик аудио удалён',
|
||||
);
|
||||
assert.doesNotMatch(app, /requestAnimationFrame\s*\(\s*tick\s*\)/);
|
||||
|
||||
assert.ok(app.includes('ControlAudioCard'));
|
||||
assert.ok(card.includes('requestAnimationFrame'), 'scrub крутится локально в карточке');
|
||||
assert.ok(card.includes('scrubFillRef'), 'прогресс пишется в DOM, не через setState корня');
|
||||
assert.ok(card.includes('setGainUi'), 'громкость обновляет только карточку');
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
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));
|
||||
|
||||
/** Регресс: scheduleDraftRepaint бампил draftFxTick → полный ре-рендер ControlApp на кадр штриха. */
|
||||
void test('ControlApp: draft кисти без корневого draftFxTick', () => {
|
||||
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
|
||||
const pixi = fs.readFileSync(
|
||||
path.join(here, '../shared/effects/PxiEffectsOverlay.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.doesNotMatch(app, /\bdraftFxTick\b/);
|
||||
assert.doesNotMatch(app, /\bsetDraftFxTick\b/);
|
||||
assert.doesNotMatch(app, /\bfxMergedState\b/);
|
||||
|
||||
assert.ok(app.includes('scheduleDraftRepaint'));
|
||||
assert.ok(app.includes('pushDraftToPixi'));
|
||||
assert.ok(app.includes('effectsOverlayRef'));
|
||||
assert.match(app, /scheduleDraftRepaint[\s\S]*?pushDraftToPixi\(\)/);
|
||||
assert.doesNotMatch(
|
||||
app,
|
||||
/scheduleDraftRepaint[\s\S]*?setDraftFxTick/,
|
||||
'RAF draft не трогает React state корня',
|
||||
);
|
||||
|
||||
assert.ok(pixi.includes('PixiEffectsOverlayHandle'));
|
||||
assert.ok(pixi.includes('setDraft'));
|
||||
assert.ok(pixi.includes('mergeDraftState'));
|
||||
assert.ok(pixi.includes('forwardRef'));
|
||||
});
|
||||
@@ -152,6 +152,31 @@ void test('ControlApp: музыка разделена на сцену и кам
|
||||
assert.match(src, /pause campaign\./i);
|
||||
});
|
||||
|
||||
void test('ControlApp: у каждой аудиозаписи есть регулятор громкости под транспортом', () => {
|
||||
const src = readControlApp();
|
||||
const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8');
|
||||
const css = readControlAppCss();
|
||||
assert.ok(src.includes('ControlAudioCard'));
|
||||
assert.ok(src.includes("t('control.volume')"));
|
||||
assert.ok(src.includes('sceneAudioGainRef'));
|
||||
assert.ok(src.includes('campaignAudioGainRef'));
|
||||
assert.ok(src.includes('applyAudioGain'));
|
||||
assert.ok(card.includes('VolumeSpeakerIcon'));
|
||||
assert.ok(card.includes('styles.audioVolume'));
|
||||
assert.ok(card.includes('styles.audioVolumeRow'));
|
||||
assert.match(css, /\.audioControls[\s\S]*?flex-direction:\s*column/);
|
||||
assert.match(css, /\.audioVolumeRow\b/);
|
||||
assert.match(css, /\.audioVolumeIcon\b/);
|
||||
assert.match(css, /\.audioVolume\b/);
|
||||
});
|
||||
|
||||
void test('ControlApp: весь контент скроллится в окне, отступы сверху и снизу равны', () => {
|
||||
const css = readControlAppCss();
|
||||
assert.match(css, /\.page\s*\{[^}]*padding:\s*16px/s);
|
||||
assert.match(css, /\.page\s*\{[^}]*overflow:\s*auto/s);
|
||||
assert.doesNotMatch(css, /\.rightStack\s*\{[^}]*overflow-y:\s*auto/s);
|
||||
});
|
||||
|
||||
void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => {
|
||||
const src = readControlApp();
|
||||
const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/;
|
||||
|
||||
@@ -588,6 +588,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.transportPlay': 'Воспроизведение',
|
||||
'control.transportPause': 'Пауза',
|
||||
'control.transportStop': 'Стоп',
|
||||
'control.volume': 'Громкость',
|
||||
},
|
||||
en: {
|
||||
'common.close': 'Close',
|
||||
@@ -1125,6 +1126,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.transportPlay': 'Play',
|
||||
'control.transportPause': 'Pause',
|
||||
'control.transportStop': 'Stop',
|
||||
'control.volume': 'Volume',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
SceneId,
|
||||
} from '../../../shared/types';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl';
|
||||
|
||||
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
|
||||
|
||||
@@ -277,6 +278,8 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
projectDataEpochRef.current += 1;
|
||||
const epoch = projectDataEpochRef.current;
|
||||
openInFlightRef.current = null;
|
||||
// URL ассетов зависят от открытого проекта — сбрасываем renderer-кэш.
|
||||
invalidateAssetUrlCache();
|
||||
|
||||
const job = (async () => {
|
||||
setState((s) => ({ ...s, openingProjectId: id }));
|
||||
@@ -308,6 +311,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
const closeProject = async () => {
|
||||
projectDataEpochRef.current += 1;
|
||||
openInFlightRef.current = null;
|
||||
invalidateAssetUrlCache();
|
||||
try {
|
||||
await api.invoke(ipcChannels.project.close, {});
|
||||
} finally {
|
||||
|
||||
@@ -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