fix(materials): синхронизация легенды и маркеров на превью и презентации
Легенда и цифры на материале масштабируются от экрана игроков и не обрезаются жёлтой рамкой. Переключение сцен без записи project.json на диск. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
.panel {
|
||||
position: absolute;
|
||||
z-index: 45;
|
||||
min-width: 200px;
|
||||
max-width: min(360px, 42vw);
|
||||
max-height: min(70vh, 520px);
|
||||
overflow: auto;
|
||||
overflow: visible;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
@@ -16,6 +14,7 @@
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useRef } from 'react';
|
||||
|
||||
import type { MaterialLegend, MaterialsOverlayLayout } from '../../../shared/types';
|
||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
|
||||
import { legendPanelVisualLayout } from '../../../shared/types/materialLegend';
|
||||
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
|
||||
|
||||
import styles from './MaterialLegendPanel.module.css';
|
||||
@@ -21,20 +22,24 @@ export function MaterialLegendPanel({
|
||||
}: Props) {
|
||||
const host = useSceneOverlayView();
|
||||
const view = host?.view ?? { w: 1, h: 1 };
|
||||
const viewport = host?.viewport ?? { x: 0, y: 0, w: view.w, h: view.h };
|
||||
const dragRef = useRef<{ startX: number; startY: number; origin: MaterialsOverlayLayout } | null>(
|
||||
null,
|
||||
);
|
||||
const effective = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
|
||||
const w = Math.max(180, Math.min(view.w * 0.36, 340) * effective.scale);
|
||||
const left = effective.cx * view.w - w / 2;
|
||||
const top = effective.cy * view.h - 40;
|
||||
const vis = legendPanelVisualLayout(effective, viewport);
|
||||
|
||||
if (!legend.enabled || legend.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
style={{ left, top, width: w }}
|
||||
style={{
|
||||
left: vis.left,
|
||||
top: vis.top,
|
||||
width: vis.width,
|
||||
transform: `translate(-50%, -50%) scale(${String(vis.scale)})`,
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (!editable || !onLayoutChange) return;
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -17,6 +17,29 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hostViewport {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 41;
|
||||
}
|
||||
|
||||
.markerLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 45;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.legendLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 46;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewportGuide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -65,7 +88,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: fill;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
|
||||
user-select: none;
|
||||
@@ -90,6 +113,24 @@
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.legendMarkerHost {
|
||||
position: absolute;
|
||||
transform-origin: center center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #1e3a5f;
|
||||
border: 2px solid #f5c542;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
z-index: 47;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.handle {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user