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
+25 -16
View File
@@ -9,7 +9,7 @@ import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
import { useEffectsState } from './effects/useEffectsState';
import { useSceneDarknessState } from './effects/useSceneDarknessState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT, defaultLegendLayoutForMaterial } from '../../shared/types';
import { SceneGridOverlay } from './grid/SceneGridOverlay';
import { MaterialLegendPanel } from './materials/MaterialLegendPanel';
import { MaterialOverlay } from './materials/MaterialOverlay';
@@ -99,7 +99,7 @@ export function PresentationView({
return {
material,
layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
legendLayout: materialsOverlay?.legendLayouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
legendLayout: materialsOverlay?.legendLayouts[id] ?? defaultLegendLayoutForMaterial(),
};
})
.filter((x): x is NonNullable<typeof x> => x !== null)
@@ -285,20 +285,29 @@ export function PresentationView({
contentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} style={{ zIndex: 30 }} />
) : null}
<SceneOverlayHost active={activeMaterialItems.length > 0 || activeNpcItems.length > 0}>
{activeMaterialItems.map(({ material, layout, legendLayout }) => (
<React.Fragment key={material.id}>
<MaterialOverlay
embedded
assetId={material.assetId}
layout={layout}
materialId={material.id}
{...(material.legend?.enabled ? { legendMarkers: material.legend.markers ?? [] } : {})}
/>
{material.legend?.enabled ? (
<MaterialLegendPanel legend={material.legend} layout={legendLayout} />
) : null}
</React.Fragment>
<SceneOverlayHost
active={activeMaterialItems.length > 0 || activeNpcItems.length > 0}
legendLayer={
<>
{activeMaterialItems.map(({ material, legendLayout }) =>
material.legend?.enabled ? (
<MaterialLegendPanel key={material.id} legend={material.legend} layout={legendLayout} />
) : null,
)}
</>
}
>
{activeMaterialItems.map(({ material, layout }) => (
<MaterialOverlay
key={material.id}
embedded
assetId={material.assetId}
layout={layout}
materialId={material.id}
{...(material.legend?.enabled && (material.legend.markers?.length ?? 0) > 0
? { legendMarkers: material.legend.markers }
: {})}
/>
))}
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
</SceneOverlayHost>
@@ -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 (
@@ -78,7 +78,9 @@
}
.namePlate {
max-width: 100%;
width: max-content;
min-width: 100%;
max-width: none;
padding: 4px 10px;
border-radius: 6px;
background: rgba(9, 9, 11, 0.88);
@@ -88,6 +90,4 @@
line-height: 1.3;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -17,7 +17,7 @@ type SceneOverlayHostProps = {
active: boolean;
/**
* Область раскладки кадров материалов/NPC (и жёлтой рамки).
* На пульте — прямоугольник соотношения сторон презентации; на презентации обычно не задаётся (весь экран).
* На пульте — экран игроков (жёлтая рамка); на презентации не задаётся (весь экран).
*/
viewport?: SceneOverlayViewport | null;
/** Жёлтая рамка видимой области презентации (предпросмотр пульта). Без dim. */
@@ -25,7 +25,10 @@ type SceneOverlayHostProps = {
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
closes?: readonly SceneOverlayCloseAction[];
/** Кадры материалов/NPC — внутри viewport (overflow hidden). */
children: React.ReactNode;
/** Блоки легенды — вне viewport, не обрезаются жёлтой рамкой. */
legendLayer?: React.ReactNode;
};
/**
@@ -41,12 +44,19 @@ export function SceneOverlayHost({
onZoomAt,
closes = [],
children,
legendLayer = null,
}: SceneOverlayHostProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const outerRef = useRef<HTMLDivElement | null>(null);
const markerLayerRef = useRef<HTMLDivElement | null>(null);
const [view, setView] = useState({ w: 1, h: 1 });
const layoutViewport = useMemo((): SceneOverlayViewport => {
if (viewport && viewport.w > 0 && viewport.h > 0) return viewport;
return { x: 0, y: 0, w: view.w, h: view.h };
}, [viewport, view.h, view.w]);
useEffect(() => {
const el = rootRef.current;
const el = outerRef.current;
if (!el) return;
let raf = 0;
const sync = () => {
@@ -67,7 +77,15 @@ export function SceneOverlayHost({
};
}, [active, showViewportGuide, viewport]);
const ctx = useMemo(() => ({ rootRef, view }), [view]);
const ctx = useMemo(
() => ({
rootRef: outerRef,
markerLayerRef,
view: { w: layoutViewport.w, h: layoutViewport.h },
viewport: layoutViewport,
}),
[layoutViewport],
);
if (!active && !showViewportGuide) return null;
@@ -76,12 +94,11 @@ export function SceneOverlayHost({
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();
const root = outerRef.current;
if (!root || layoutViewport.w <= 0 || layoutViewport.h <= 0) return { nx: 0.5, ny: 0.5 };
return {
nx: (clientX - r.left) / Math.max(1, r.width),
ny: (clientY - r.top) / Math.max(1, r.height),
nx: (clientX - root.getBoundingClientRect().left - layoutViewport.x) / layoutViewport.w,
ny: (clientY - root.getBoundingClientRect().top - layoutViewport.y) / layoutViewport.h,
};
};
@@ -89,11 +106,10 @@ export function SceneOverlayHost({
<SceneOverlayViewContext.Provider value={ctx}>
{active ? <div className={styles.dim} aria-hidden /> : null}
<div
ref={rootRef}
ref={outerRef}
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
.filter(Boolean)
.join(' ')}
style={overlayRootStyle(viewport)}
role="presentation"
onClick={(e) => {
if (!captureZoom || !onZoomAt) return;
@@ -102,27 +118,35 @@ export function SceneOverlayHost({
onZoomAt(nx, ny, e.target);
}}
>
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
{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 ref={markerLayerRef} className={styles.markerLayer} />
<div
className={styles.hostViewport}
style={overlayRootStyle(layoutViewport)}
role="presentation"
>
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
{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>
{legendLayer ? <div className={styles.legendLayer}>{legendLayer}</div> : null}
</div>
</SceneOverlayViewContext.Provider>
);
@@ -1,8 +1,16 @@
import React, { createContext, useContext } from 'react';
import type { SceneOverlayViewport } from './overlayViewport';
export type SceneOverlayViewContextValue = {
/** Внешний контейнер оверлея (полная область превью / презентации). */
rootRef: React.RefObject<HTMLDivElement | null>;
/** Слой маркеров легенды на материале (вне clipped viewport). */
markerLayerRef: React.RefObject<HTMLDivElement | null>;
/** Размеры области раскладки материалов (viewport.w/h). */
view: { w: number; h: number };
/** Смещение и размер viewport внутри rootRef (contentRect на презентации). */
viewport: SceneOverlayViewport | null;
};
export const SceneOverlayViewContext = createContext<SceneOverlayViewContextValue | null>(null);
@@ -46,7 +46,7 @@ void test('MaterialOverlay / NpcsSceneOverlay поддерживают embedded
assert.ok(npcs.includes('embedded'));
assert.ok(npcs.includes('useSceneOverlayView'));
// В embedded-ветке не рисуем второй dim.
assert.match(material, /if \(embedded\) \{\s*return frame;/);
assert.match(material, /if \(embedded\) \{[\s\S]*createPortal/);
assert.match(npcs, /if \(embedded\) \{\s*return <>\{frames\}<\/>;/);
});
@@ -76,8 +76,53 @@ void test('overlays: layout IPC live через rAF coalesce, RO host coalesced'
npcs,
/requestAnimationFrame\(\(\) => \{[\s\S]*?onLayoutChangeRef\.current\?\.\(item\.npcId, pending\)/,
);
assert.match(
npcs,
/if \(drag\.mode === 'move'\) \{[\s\S]*?publishDraft\(\{[\s\S]*?return;\s*\}/,
assert.match(npcs, /if \(drag\.mode === 'move'\) \{[\s\S]*?publishDraft\(\{[\s\S]*?return;\s*\}/,
);
});
void test('SceneOverlayHost: presentation and control share player-screen viewport for overlays', () => {
const presentation = fs.readFileSync(path.join(root, 'app/renderer/shared/PresentationView.tsx'), 'utf8');
const control = fs.readFileSync(path.join(root, 'app/renderer/control/ControlApp.tsx'), 'utf8');
const host = fs.readFileSync(path.join(here, 'SceneOverlayHost.tsx'), 'utf8');
assert.ok(presentation.includes('<SceneOverlayHost'));
assert.doesNotMatch(presentation, /<SceneOverlayHost[\s\S]*?viewport=\{contentRect\}/);
assert.ok(control.includes('overlayViewport'));
assert.ok(control.includes('const overlayViewport = screenRect'));
assert.ok(control.includes('legendLayer'));
assert.ok(host.includes('legendLayer'));
});
void test('MaterialLegendPanel: uniform scale, no inner scroll', () => {
const panel = fs.readFileSync(
path.join(root, 'app/renderer/shared/materials/MaterialLegendPanel.tsx'),
'utf8',
);
const css = fs.readFileSync(
path.join(root, 'app/renderer/shared/materials/MaterialLegendPanel.module.css'),
'utf8',
);
assert.ok(panel.includes('legendPanelVisualLayout'));
assert.match(css, /overflow:\s*visible/);
assert.doesNotMatch(css, /overflow:\s*auto/);
assert.doesNotMatch(css, /max-height/);
});
void test('MaterialOverlay: legend markers use viewport positioning', () => {
const material = fs.readFileSync(
path.join(root, 'app/renderer/shared/materials/MaterialOverlay.tsx'),
'utf8',
);
const css = fs.readFileSync(
path.join(root, 'app/renderer/shared/materials/MaterialOverlay.module.css'),
'utf8',
);
const host = fs.readFileSync(path.join(here, 'SceneOverlayHost.tsx'), 'utf8');
assert.ok(material.includes('legendMarkerPositionInOverlayHost'));
assert.ok(material.includes('legendMarkerScreenScale'));
assert.ok(material.includes('createPortal'));
assert.ok(material.includes('markerLayerRef'));
assert.match(css, /object-fit:\s*contain/);
assert.ok(host.includes('legendLayer'));
assert.ok(host.includes('markerLayer'));
assert.ok(host.includes('hostViewport'));
});
@@ -125,6 +125,17 @@ void test('EditorApp: split Run button and launch-with-players modal', () => {
assert.ok(editor.includes('top.runWithPlayers'));
});
void test('PlayerTokenView: name plate shows full text without ellipsis', () => {
const css = fs.readFileSync(
path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'),
'utf8',
);
const block = css.match(/\.namePlate\s*\{[^}]+\}/)?.[0] ?? '';
assert.match(block, /width:\s*max-content/);
assert.doesNotMatch(block, /text-overflow:\s*ellipsis/);
assert.doesNotMatch(block, /overflow:\s*hidden/);
});
void test('NpcsEditorApp: token appearance via PlayerTokenView', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
assert.ok(src.includes('PlayerTokenView'));