fix(materials): синхронизация легенды и маркеров на превью и презентации
Легенда и цифры на материале масштабируются от экрана игроков и не обрезаются жёлтой рамкой. Переключение сцен без записи project.json на диск. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user