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,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"
|
||||
|
||||
Reference in New Issue
Block a user