feat: multi-material overlays, presentation guides, and release prebuilds
Align control/presentation with presentation screen rect and darkness z-order; sync window titles and session window cleanup. Pack Win/Mac/Linux with npmRebuild disabled and release-native-prep for classic-level and sharp. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -56,11 +56,21 @@ export function PresentationView({
|
||||
);
|
||||
const scene =
|
||||
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
|
||||
const activeMaterial =
|
||||
session?.project && materialsOverlay?.activeMaterialId
|
||||
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
|
||||
: undefined;
|
||||
const project = session?.project;
|
||||
const activeMaterialItems =
|
||||
project && (materialsOverlay?.activeMaterialIds?.length ?? 0) > 0
|
||||
? (materialsOverlay?.activeMaterialIds ?? [])
|
||||
.map((id) => {
|
||||
const material = (project.materials ?? []).find((m) => m.id === id);
|
||||
if (!material) return null;
|
||||
return {
|
||||
material,
|
||||
layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
legendLayout: materialsOverlay?.legendLayouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
};
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null)
|
||||
: [];
|
||||
const activeNpcItems =
|
||||
project && (npcsOverlay?.activeNpcIds?.length ?? 0) > 0
|
||||
? (npcsOverlay?.activeNpcIds ?? [])
|
||||
@@ -156,7 +166,7 @@ export function PresentationView({
|
||||
src={originalUrl}
|
||||
muted
|
||||
playsInline
|
||||
loop={false}
|
||||
loop={Boolean(scene?.settings?.loopVideo)}
|
||||
preload="auto"
|
||||
onError={() => {
|
||||
// noop: status surfaced in control app; keep presentation clean
|
||||
@@ -200,25 +210,23 @@ export function PresentationView({
|
||||
<ExplosionVideoOverlay state={fxState} viewport={contentRect} />
|
||||
) : null}
|
||||
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
|
||||
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
|
||||
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} style={{ zIndex: 30 }} />
|
||||
) : null}
|
||||
<SceneOverlayHost active={Boolean(activeMaterial) || activeNpcItems.length > 0}>
|
||||
{activeMaterial ? (
|
||||
<MaterialOverlay
|
||||
embedded
|
||||
assetId={activeMaterial.assetId}
|
||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||
{...(activeMaterial.legend?.enabled
|
||||
? { legendMarkers: activeMaterial.legend.markers ?? [] }
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
{activeMaterial?.legend?.enabled ? (
|
||||
<MaterialLegendPanel
|
||||
legend={activeMaterial.legend}
|
||||
layout={materialsOverlay?.legendLayout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||
/>
|
||||
) : 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>
|
||||
))}
|
||||
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
|
||||
</SceneOverlayHost>
|
||||
{showTitle ? (
|
||||
|
||||
@@ -17,6 +17,15 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewportGuide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid rgba(245, 197, 66, 0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.captureZoom {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
|
||||
import type { AssetId, MaterialId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
|
||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
|
||||
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
|
||||
import { useAssetUrl } from '../useAssetImageUrl';
|
||||
@@ -12,6 +12,7 @@ type Corner = 'nw' | 'ne' | 'sw' | 'se';
|
||||
type MaterialOverlayProps = {
|
||||
assetId: AssetId | null;
|
||||
layout: MaterialsOverlayLayout;
|
||||
materialId?: MaterialId;
|
||||
editable?: boolean;
|
||||
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
|
||||
showClose?: boolean;
|
||||
@@ -92,6 +93,7 @@ function localToScreenOffset(localX: number, localY: number, rotationDeg: number
|
||||
export function MaterialOverlay({
|
||||
assetId,
|
||||
layout,
|
||||
materialId,
|
||||
editable = false,
|
||||
zoomTool = null,
|
||||
showClose = false,
|
||||
@@ -366,6 +368,7 @@ export function MaterialOverlay({
|
||||
<div
|
||||
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
|
||||
data-overlay-kind="material"
|
||||
{...(materialId ? { 'data-material-id': materialId } : {})}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
|
||||
import styles from '../materials/MaterialOverlay.module.css';
|
||||
|
||||
import { overlayRootStyle, type SceneOverlayViewport } from './overlayViewport';
|
||||
import { SceneOverlayViewContext } from './SceneOverlayViewContext';
|
||||
|
||||
export type SceneOverlayCloseAction = {
|
||||
@@ -14,6 +15,10 @@ export type SceneOverlayCloseAction = {
|
||||
type SceneOverlayHostProps = {
|
||||
/** Есть ли что показывать (материал и/или NPC). */
|
||||
active: boolean;
|
||||
/** Область картинки сцены (contain); координаты относительно родителя. */
|
||||
viewport?: SceneOverlayViewport | null;
|
||||
/** Рамка видимой области (предпросмотр пульта). */
|
||||
showViewportGuide?: boolean;
|
||||
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
|
||||
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
|
||||
closes?: readonly SceneOverlayCloseAction[];
|
||||
@@ -26,6 +31,8 @@ type SceneOverlayHostProps = {
|
||||
*/
|
||||
export function SceneOverlayHost({
|
||||
active,
|
||||
viewport = null,
|
||||
showViewportGuide = false,
|
||||
zoomTool = null,
|
||||
onZoomAt,
|
||||
closes = [],
|
||||
@@ -58,7 +65,7 @@ export function SceneOverlayHost({
|
||||
|
||||
const ctx = useMemo(() => ({ rootRef, view }), [view]);
|
||||
|
||||
if (!active) return null;
|
||||
if (!active && !showViewportGuide) return null;
|
||||
|
||||
const captureZoom = Boolean(zoomTool && onZoomAt);
|
||||
const zoomCursor =
|
||||
@@ -81,6 +88,7 @@ export function SceneOverlayHost({
|
||||
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={overlayRootStyle(viewport)}
|
||||
role="presentation"
|
||||
onClick={(e) => {
|
||||
if (!captureZoom || !onZoomAt) return;
|
||||
@@ -89,6 +97,7 @@ export function SceneOverlayHost({
|
||||
onZoomAt(nx, ny, e.target);
|
||||
}}
|
||||
>
|
||||
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
|
||||
<div className={styles.dim} />
|
||||
{children}
|
||||
{closes.length > 0 ? (
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export type SceneOverlayViewport = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
export function overlayRootStyle(viewport: SceneOverlayViewport | null | undefined): CSSProperties | undefined {
|
||||
if (!viewport || viewport.w <= 0 || viewport.h <= 0) return undefined;
|
||||
return {
|
||||
left: viewport.x,
|
||||
top: viewport.y,
|
||||
width: viewport.w,
|
||||
height: viewport.h,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Однострочный текст с ellipsis; полный title при обрезке. */
|
||||
export function EllipsisText({ text, className }: Props) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [title, setTitle] = useState<string | undefined>(undefined);
|
||||
|
||||
const syncTitle = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) {
|
||||
setTitle(undefined);
|
||||
return;
|
||||
}
|
||||
setTitle(el.scrollWidth > el.clientWidth + 1 ? text : undefined);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
title={title}
|
||||
onMouseEnter={syncTitle}
|
||||
onMouseLeave={() => setTitle(undefined)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.root {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
Reference in New Issue
Block a user