fix(materials): синхронизация легенды и маркеров на превью и презентации

Легенда и цифры на материале масштабируются от экрана игроков и не обрезаются жёлтой рамкой. Переключение сцен без записи project.json на диск.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 10:57:50 +08:00
parent ea718a26df
commit 153be98780
17 changed files with 531 additions and 128 deletions
@@ -1,7 +1,13 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { AssetId, MaterialId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
import {
LEGEND_MARKER_DESIGN_SIZE,
legendMarkerPositionInOverlayHost,
legendMarkerScreenScale,
} from '../../../shared/types/materialLegend';
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
import { useAssetUrl } from '../useAssetImageUrl';
@@ -111,6 +117,7 @@ export function MaterialOverlay({
const rootRef = embedded && host ? host.rootRef : localRootRef;
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
const [localView, setLocalView] = useState({ w: 1, h: 1 });
const [markerLayerReady, setMarkerLayerReady] = useState(false);
/** Локальный layout + IPC не чаще 1/frame (лайв, без спама pointermove). */
const [draftLayout, setDraftLayout] = useState<MaterialsOverlayLayout | null>(null);
const pendingLayoutRef = useRef<MaterialsOverlayLayout | null>(null);
@@ -170,6 +177,22 @@ export function MaterialOverlay({
pendingLayoutRef.current = null;
}, [layout]);
useEffect(() => {
if (!url) return;
const img = new Image();
const apply = () => {
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
};
img.onload = apply;
img.src = url;
if (img.complete) apply();
}, [url]);
useLayoutEffect(() => {
if (!embedded) return;
setMarkerLayerReady(Boolean(host?.markerLayerRef.current));
}, [embedded, host, host?.view.w, host?.view.h, natural.w, natural.h, legendMarkers, layout, draftLayout]);
if (!assetId || !url) return null;
const interactive = editable || showClose || Boolean(zoomTool);
@@ -392,18 +415,6 @@ export function MaterialOverlay({
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
}}
/>
{(legendMarkers ?? []).map((m) => (
<div
key={m.id}
className={styles.legendMarker}
style={{
left: `${String(m.nx * 100)}%`,
top: `${String(m.ny * 100)}%`,
}}
>
{m.number}
</div>
))}
{editable && !zoomTool
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
<button
@@ -430,7 +441,46 @@ export function MaterialOverlay({
);
if (embedded) {
return frame;
const overlayViewport = host?.viewport ?? { x: 0, y: 0, w: view.w, h: view.h };
const markerLayer = host?.markerLayerRef.current;
const markerScale = legendMarkerScreenScale(overlayViewport.w);
const markerNodes =
(legendMarkers ?? []).length > 0
? (legendMarkers ?? []).map((m) => {
const pos = legendMarkerPositionInOverlayHost(
m.nx,
m.ny,
effectiveLayout,
w,
h,
natural.w,
natural.h,
overlayViewport,
);
return (
<div
key={m.id}
className={styles.legendMarkerHost}
style={{
left: pos.left,
top: pos.top,
width: LEGEND_MARKER_DESIGN_SIZE,
height: LEGEND_MARKER_DESIGN_SIZE,
transform: `translate(-50%, -50%) scale(${String(markerScale)})`,
}}
>
{m.number}
</div>
);
})
: null;
return (
<>
{frame}
{markerLayerReady && markerLayer && markerNodes ? createPortal(markerNodes, markerLayer) : null}
</>
);
}
return (