fix(materials): синхронизация легенды и маркеров на превью и презентации
Легенда и цифры на материале масштабируются от экрана игроков и не обрезаются жёлтой рамкой. Переключение сцен без записи project.json на диск. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-5
@@ -745,11 +745,7 @@ async function main() {
|
||||
const open = projectStore.getOpenProject();
|
||||
if (!open) throw new Error('No open project');
|
||||
const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null;
|
||||
await projectStore.updateProject((p) => ({
|
||||
...p,
|
||||
currentGraphNodeId: graphNodeId,
|
||||
currentSceneId: gn ? gn.sceneId : null,
|
||||
}));
|
||||
projectStore.setSessionPointer(graphNodeId, gn ? gn.sceneId : null);
|
||||
effectsStore.clear();
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
|
||||
@@ -105,6 +105,23 @@ void test('zipStore: listProjects skips unreadable archives', () => {
|
||||
);
|
||||
});
|
||||
|
||||
void test('zipStore: setSessionPointer updates pointer in memory without disk write', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
const m = src.match(/setSessionPointer\([\s\S]*?\n \}/);
|
||||
assert.ok(m);
|
||||
assert.doesNotMatch(m[0], /writeCacheProject|queueSave|enqueueProjectUpdate/);
|
||||
});
|
||||
|
||||
void test('main index: setCurrentGraphNode uses setSessionPointer not updateProject', () => {
|
||||
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
|
||||
const m = src.match(
|
||||
/registerHandler\(ipcChannels\.project\.setCurrentGraphNode[\s\S]*?emitSessionState\(\)/,
|
||||
);
|
||||
assert.ok(m);
|
||||
assert.match(m[0], /setSessionPointer/);
|
||||
assert.doesNotMatch(m[0], /updateProject/);
|
||||
});
|
||||
|
||||
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
||||
const i = src.indexOf('export async function replaceFileAtomic');
|
||||
|
||||
@@ -411,6 +411,18 @@ export class ZipProjectStore {
|
||||
return this.openProject?.project ?? null;
|
||||
}
|
||||
|
||||
/** Только указатель сессии в памяти — без записи project.json на диск. */
|
||||
setSessionPointer(currentGraphNodeId: GraphNodeId | null, currentSceneId: SceneId | null): Project {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
open.project = {
|
||||
...open.project,
|
||||
currentGraphNodeId,
|
||||
currentSceneId,
|
||||
};
|
||||
return open.project;
|
||||
}
|
||||
|
||||
/** Публичный URL для ассетов — кастомная схема `dnd:` (см. `registerDndAssetProtocol`). */
|
||||
getAssetFileUrl(assetId: AssetId): string | null {
|
||||
if (!this.getAssetReadInfo(assetId)) return null;
|
||||
|
||||
@@ -49,7 +49,7 @@ import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../sh
|
||||
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
|
||||
import { useEffectsState } from '../shared/effects/useEffectsState';
|
||||
import { useSceneDarknessState } from '../shared/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 { MaterialLegendPanel } from '../shared/materials/MaterialLegendPanel';
|
||||
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
|
||||
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
|
||||
@@ -2549,12 +2549,7 @@ export function ControlApp() {
|
||||
material,
|
||||
layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
legendLayout:
|
||||
materialsOverlay?.legendLayouts[id] ?? {
|
||||
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
cx: 0.82,
|
||||
cy: 0.5,
|
||||
scale: 0.85,
|
||||
},
|
||||
materialsOverlay?.legendLayouts[id] ?? defaultLegendLayoutForMaterial(),
|
||||
};
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null)
|
||||
@@ -2577,6 +2572,7 @@ export function ControlApp() {
|
||||
const showMaterial = materialItems.length > 0;
|
||||
const showNpcs = npcItems.length > 0;
|
||||
const screenRect = presentationScreenRect;
|
||||
const overlayViewport = screenRect;
|
||||
const showGuide = Boolean(screenRect);
|
||||
if (!showMaterial && !showNpcs && !showGuide) return null;
|
||||
const closes = [
|
||||
@@ -2614,7 +2610,7 @@ export function ControlApp() {
|
||||
<>
|
||||
<SceneOverlayHost
|
||||
active={showMaterial || showNpcs}
|
||||
viewport={screenRect}
|
||||
viewport={overlayViewport}
|
||||
showViewportGuide={showGuide}
|
||||
zoomTool={materialsZoom}
|
||||
{...(materialsZoom
|
||||
@@ -2631,43 +2627,49 @@ export function ControlApp() {
|
||||
}
|
||||
: {})}
|
||||
closes={closes}
|
||||
legendLayer={
|
||||
<>
|
||||
{materialItems.map(({ material, legendLayout }) =>
|
||||
material.legend?.enabled ? (
|
||||
<MaterialLegendPanel
|
||||
key={material.id}
|
||||
legend={material.legend}
|
||||
layout={legendLayout}
|
||||
editable
|
||||
onLayoutChange={(nextLayout) => {
|
||||
void materialsApi.dispatch({
|
||||
kind: 'legendLayout.set',
|
||||
materialId: material.id,
|
||||
layout: nextLayout,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{materialItems.map(({ material, layout, legendLayout }) => (
|
||||
<React.Fragment key={material.id}>
|
||||
<MaterialOverlay
|
||||
embedded
|
||||
assetId={material.assetId}
|
||||
materialId={material.id}
|
||||
layout={layout}
|
||||
editable
|
||||
zoomTool={materialsZoom}
|
||||
rotateLabel={t('materials.rotateOverlay')}
|
||||
onLayoutChange={(nextLayout) => {
|
||||
void materialsApi.dispatch({
|
||||
kind: 'layout.set',
|
||||
materialId: material.id,
|
||||
layout: nextLayout,
|
||||
});
|
||||
}}
|
||||
{...(material.legend?.enabled
|
||||
? { legendMarkers: material.legend.markers ?? [] }
|
||||
: {})}
|
||||
/>
|
||||
{material.legend?.enabled ? (
|
||||
<MaterialLegendPanel
|
||||
legend={material.legend}
|
||||
layout={legendLayout}
|
||||
editable
|
||||
onLayoutChange={(nextLayout) => {
|
||||
void materialsApi.dispatch({
|
||||
kind: 'legendLayout.set',
|
||||
materialId: material.id,
|
||||
layout: nextLayout,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
{materialItems.map(({ material, layout }) => (
|
||||
<MaterialOverlay
|
||||
key={material.id}
|
||||
embedded
|
||||
assetId={material.assetId}
|
||||
materialId={material.id}
|
||||
layout={layout}
|
||||
editable
|
||||
zoomTool={materialsZoom}
|
||||
rotateLabel={t('materials.rotateOverlay')}
|
||||
onLayoutChange={(nextLayout) => {
|
||||
void materialsApi.dispatch({
|
||||
kind: 'layout.set',
|
||||
materialId: material.id,
|
||||
layout: nextLayout,
|
||||
});
|
||||
}}
|
||||
{...(material.legend?.enabled && (material.legend.markers?.length ?? 0) > 0
|
||||
? { legendMarkers: material.legend.markers }
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
{showNpcs ? (
|
||||
<NpcsSceneOverlay
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
LEGEND_PANEL_DESIGN_WIDTH,
|
||||
LEGEND_PANEL_REF_VIEW_W,
|
||||
legendMarkerPositionInContainFrame,
|
||||
legendMarkerPositionInOverlayHost,
|
||||
legendMarkerPositionInViewport,
|
||||
legendMarkerScreenScale,
|
||||
legendPanelVisualLayout,
|
||||
} from './materialLegend';
|
||||
|
||||
void test('legendMarkerPositionInContainFrame: letterboxed 16:9 in square frame', () => {
|
||||
const p = legendMarkerPositionInContainFrame(0.5, 0.5, 400, 400, 1920, 1080);
|
||||
assert.ok(Math.abs(p.left - 200) < 0.01);
|
||||
assert.ok(Math.abs(p.top - 200) < 0.01);
|
||||
});
|
||||
|
||||
void test('legendMarkerPositionInContainFrame: top-left UV at image corner', () => {
|
||||
const p = legendMarkerPositionInContainFrame(0, 0, 400, 400, 1920, 1080);
|
||||
const imageH = (400 * 1080) / 1920;
|
||||
const imageY = (400 - imageH) / 2;
|
||||
assert.ok(Math.abs(p.left - 0) < 0.01);
|
||||
assert.ok(Math.abs(p.top - imageY) < 0.01);
|
||||
});
|
||||
|
||||
void test('legendMarkerPositionInViewport: center marker at frame center', () => {
|
||||
const p = legendMarkerPositionInViewport(0.5, 0.5, { cx: 0.5, cy: 0.5, rotationDeg: 0 }, 400, 300, 800, 600, 800, 600);
|
||||
assert.ok(Math.abs(p.left - 400) < 0.01);
|
||||
assert.ok(Math.abs(p.top - 300) < 0.01);
|
||||
});
|
||||
|
||||
void test('legendMarkerPositionInOverlayHost: adds viewport offset', () => {
|
||||
const p = legendMarkerPositionInOverlayHost(
|
||||
0.5,
|
||||
0.5,
|
||||
{ cx: 0.5, cy: 0.5, rotationDeg: 0 },
|
||||
400,
|
||||
300,
|
||||
800,
|
||||
600,
|
||||
{ x: 100, y: 50, w: 800, h: 600 },
|
||||
);
|
||||
assert.ok(Math.abs(p.left - 500) < 0.01);
|
||||
assert.ok(Math.abs(p.top - 350) < 0.01);
|
||||
});
|
||||
|
||||
void test('legendPanelVisualLayout: same cx/cy stays proportional across screen sizes', () => {
|
||||
const layout = { cx: 0.82, cy: 0.5, scale: 0.85 };
|
||||
const a = legendPanelVisualLayout(layout, { x: 10, y: 20, w: 800, h: 450 });
|
||||
const b = legendPanelVisualLayout(layout, { x: 0, y: 0, w: 1920, h: 1080 });
|
||||
assert.equal(a.width, LEGEND_PANEL_DESIGN_WIDTH);
|
||||
assert.equal(b.width, LEGEND_PANEL_DESIGN_WIDTH);
|
||||
assert.ok(Math.abs(a.left - (10 + 0.82 * 800)) < 0.01);
|
||||
assert.ok(Math.abs(b.left - 0.82 * 1920) < 0.01);
|
||||
assert.ok(Math.abs(a.scale / b.scale - 800 / LEGEND_PANEL_REF_VIEW_W) < 1e-9);
|
||||
});
|
||||
|
||||
void test('legendPanelVisualLayout: size ignores viewport origin (no edge shrink)', () => {
|
||||
const layout = { cx: 0.02, cy: 0.98, scale: 1 };
|
||||
const near = legendPanelVisualLayout(layout, { x: 0, y: 0, w: 1920, h: 1080 });
|
||||
const far = legendPanelVisualLayout({ cx: 0.5, cy: 0.5, scale: 1 }, { x: 0, y: 0, w: 1920, h: 1080 });
|
||||
assert.equal(near.scale, far.scale);
|
||||
assert.equal(near.width, far.width);
|
||||
});
|
||||
|
||||
void test('legendMarkerScreenScale: proportional to player screen, ignores edge', () => {
|
||||
assert.equal(legendMarkerScreenScale(1920), 1);
|
||||
assert.ok(Math.abs(legendMarkerScreenScale(800) - 800 / 1920) < 1e-9);
|
||||
assert.equal(legendMarkerScreenScale(800), legendMarkerScreenScale(800));
|
||||
});
|
||||
@@ -151,3 +151,115 @@ export function legendImageUvToHostPoint(
|
||||
y: contentRect.y + contentRect.h / 2 + (lx * sin + ly * cos),
|
||||
};
|
||||
}
|
||||
|
||||
/** UV маркера → px внутри кадра MaterialOverlay (object-fit: contain). */
|
||||
export function legendMarkerPositionInContainFrame(
|
||||
nx: number,
|
||||
ny: number,
|
||||
frameW: number,
|
||||
frameH: number,
|
||||
imageNaturalW: number,
|
||||
imageNaturalH: number,
|
||||
): { left: number; top: number } {
|
||||
const bw = Math.max(1, frameW);
|
||||
const bh = Math.max(1, frameH);
|
||||
const aw = Math.max(1, imageNaturalW);
|
||||
const ah = Math.max(1, imageNaturalH);
|
||||
const boxAspect = bw / bh;
|
||||
const targetAspect = aw / ah;
|
||||
let ir: ContentRect;
|
||||
if (boxAspect > targetAspect) {
|
||||
const h = bh;
|
||||
const w = h * targetAspect;
|
||||
ir = { x: (bw - w) / 2, y: 0, w, h };
|
||||
} else {
|
||||
const w = bw;
|
||||
const h = w / targetAspect;
|
||||
ir = { x: 0, y: (bh - h) / 2, w, h };
|
||||
}
|
||||
return {
|
||||
left: ir.x + clampLegendCoord(nx) * ir.w,
|
||||
top: ir.y + clampLegendCoord(ny) * ir.h,
|
||||
};
|
||||
}
|
||||
|
||||
/** UV маркера → px внутри viewport SceneOverlayHost (без смещения viewport.x/y). */
|
||||
export function legendMarkerPositionInViewport(
|
||||
nx: number,
|
||||
ny: number,
|
||||
layout: { cx: number; cy: number; rotationDeg: number },
|
||||
frameW: number,
|
||||
frameH: number,
|
||||
imageNaturalW: number,
|
||||
imageNaturalH: number,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
): { left: number; top: number } {
|
||||
const local = legendMarkerPositionInContainFrame(nx, ny, frameW, frameH, imageNaturalW, imageNaturalH);
|
||||
const cx = layout.cx * viewportW;
|
||||
const cy = layout.cy * viewportH;
|
||||
const lx = local.left - frameW / 2;
|
||||
const ly = local.top - frameH / 2;
|
||||
const rad = (layout.rotationDeg * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
return {
|
||||
left: cx + lx * cos - ly * sin,
|
||||
top: cy + lx * sin + ly * cos,
|
||||
};
|
||||
}
|
||||
|
||||
/** UV маркера → px в координатах SceneOverlayHost (учитывает viewport offset и поворот кадра). */
|
||||
export function legendMarkerPositionInOverlayHost(
|
||||
nx: number,
|
||||
ny: number,
|
||||
layout: { cx: number; cy: number; rotationDeg: number },
|
||||
frameW: number,
|
||||
frameH: number,
|
||||
imageNaturalW: number,
|
||||
imageNaturalH: number,
|
||||
viewport: ContentRect,
|
||||
): { left: number; top: number } {
|
||||
const p = legendMarkerPositionInViewport(
|
||||
nx,
|
||||
ny,
|
||||
layout,
|
||||
frameW,
|
||||
frameH,
|
||||
imageNaturalW,
|
||||
imageNaturalH,
|
||||
viewport.w,
|
||||
viewport.h,
|
||||
);
|
||||
return { left: viewport.x + p.left, top: viewport.y + p.top };
|
||||
}
|
||||
|
||||
/** Ширина карточки легенды в design-px при scale=1 на экране 1920px. */
|
||||
export const LEGEND_PANEL_DESIGN_WIDTH = 320;
|
||||
export const LEGEND_PANEL_REF_VIEW_W = 1920;
|
||||
/** Диаметр маркера на материале в design-px (как кружок в списке легенды). */
|
||||
export const LEGEND_MARKER_DESIGN_SIZE = 26;
|
||||
|
||||
/**
|
||||
* Позиция и единый масштаб карточки легенды.
|
||||
* Размер не зависит от близости к краю — только от ширины экрана игроков и layout.scale.
|
||||
*/
|
||||
export function legendPanelVisualLayout(
|
||||
layout: { cx: number; cy: number; scale: number },
|
||||
viewport: ContentRect,
|
||||
): { left: number; top: number; width: number; scale: number } {
|
||||
const vw = Math.max(1, viewport.w);
|
||||
const vh = Math.max(1, viewport.h);
|
||||
const layoutScale = Number.isFinite(layout.scale) && layout.scale > 0 ? layout.scale : 1;
|
||||
return {
|
||||
left: viewport.x + layout.cx * vw,
|
||||
top: viewport.y + layout.cy * vh,
|
||||
width: LEGEND_PANEL_DESIGN_WIDTH,
|
||||
scale: (vw / LEGEND_PANEL_REF_VIEW_W) * layoutScale,
|
||||
};
|
||||
}
|
||||
|
||||
/** Единый масштаб цифр на материале — как у карточки легенды, без сжатия у края. */
|
||||
export function legendMarkerScreenScale(viewportW: number): number {
|
||||
return Math.max(1, viewportW) / LEGEND_PANEL_REF_VIEW_W;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user