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:
@@ -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*\}/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user