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:
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
|
||||
import { fitAspectRect } from '../../shared/geometry/fitAspectRect';
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
isNodeInSideStoryline,
|
||||
listSideStoryStarts,
|
||||
} from '../../shared/graph/sceneGraphLineage';
|
||||
import type { GraphNodeId, Scene, SceneId, SceneViewCamera } from '../../shared/types';
|
||||
import type { GraphNodeId, MaterialId, Scene, SceneId, SceneViewCamera } from '../../shared/types';
|
||||
import {
|
||||
DEFAULT_SCENE_VIEW_CAMERA,
|
||||
sceneViewPanBy,
|
||||
@@ -42,6 +43,8 @@ import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
|
||||
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
|
||||
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
import { EllipsisText } from '../shared/ui/EllipsisText';
|
||||
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
|
||||
import { Surface } from '../shared/ui/Surface';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
@@ -191,10 +194,25 @@ export function ControlApp() {
|
||||
w: number;
|
||||
h: number;
|
||||
} | null>(null);
|
||||
const [presentationContentSize, setPresentationContentSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const previewContentRectRef = useRef(previewContentRect);
|
||||
previewContentRectRef.current = previewContentRect;
|
||||
const previewSizeRef = useRef(previewSize);
|
||||
previewSizeRef.current = previewSize;
|
||||
|
||||
const presentationScreenRect = useMemo(() => {
|
||||
if (!presentationContentSize) return null;
|
||||
if (previewSize.w <= 1 || previewSize.h <= 1) return null;
|
||||
return fitAspectRect(
|
||||
previewSize.w,
|
||||
previewSize.h,
|
||||
presentationContentSize.width,
|
||||
presentationContentSize.height,
|
||||
);
|
||||
}, [presentationContentSize, previewSize.h, previewSize.w]);
|
||||
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
|
||||
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const draftPaintRafRef = useRef(0);
|
||||
@@ -229,12 +247,32 @@ export function ControlApp() {
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
return api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
|
||||
const refreshPresentationSize = () => {
|
||||
void api.invoke(ipcChannels.windows.getPresentationContentSize, {}).then((size) => {
|
||||
if (size.width == null || size.height == null) {
|
||||
setPresentationContentSize(null);
|
||||
return;
|
||||
}
|
||||
setPresentationContentSize({ width: size.width, height: size.height });
|
||||
});
|
||||
};
|
||||
refreshPresentationSize();
|
||||
const offSize = api.on(ipcChannels.windows.presentationContentSizeChanged, (size) => {
|
||||
setPresentationContentSize({ width: size.width, height: size.height });
|
||||
});
|
||||
const offMw = api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
|
||||
if (!open) {
|
||||
mainStoryReturnRef.current = null;
|
||||
setMainStoryReturnGraphNodeId(null);
|
||||
setPresentationContentSize(null);
|
||||
return;
|
||||
}
|
||||
refreshPresentationSize();
|
||||
});
|
||||
return () => {
|
||||
offSize();
|
||||
offMw();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1695,7 +1733,10 @@ export function ControlApp() {
|
||||
) : (
|
||||
<div className={styles.historyMuted}>{t('control.passed')}</div>
|
||||
)}
|
||||
<div className={styles.historyTitle}>{s?.title ?? (gn ? String(gn.sceneId) : gnId)}</div>
|
||||
<EllipsisText
|
||||
text={s?.title ?? (gn ? String(gn.sceneId) : gnId)}
|
||||
className={[styles.historyTitle, ellipsisStyles.root].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -1759,9 +1800,6 @@ export function ControlApp() {
|
||||
draft={explosionDraft}
|
||||
viewport={previewContentRect}
|
||||
/>
|
||||
{previewContentRect ? (
|
||||
<SceneDarknessOverlay state={sdState} overlayAlpha={0.5} viewport={previewContentRect} />
|
||||
) : null}
|
||||
<div
|
||||
ref={brushCursorElRef}
|
||||
className={styles.brushCursor}
|
||||
@@ -1987,11 +2025,28 @@ export function ControlApp() {
|
||||
</>
|
||||
) : null}
|
||||
{(() => {
|
||||
const activeMaterial =
|
||||
session?.project && materialsOverlay?.activeMaterialId
|
||||
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
|
||||
: undefined;
|
||||
const project = session?.project;
|
||||
const materialIds = materialsOverlay?.activeMaterialIds ?? [];
|
||||
const materialItems =
|
||||
project && materialIds.length > 0
|
||||
? materialIds
|
||||
.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,
|
||||
cx: 0.82,
|
||||
cy: 0.5,
|
||||
scale: 0.85,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null)
|
||||
: [];
|
||||
const activeIds = npcsOverlay?.activeNpcIds ?? [];
|
||||
const npcItems =
|
||||
project && activeIds.length > 0
|
||||
@@ -2007,9 +2062,11 @@ export function ControlApp() {
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null)
|
||||
: [];
|
||||
const showMaterial = Boolean(activeMaterial);
|
||||
const showMaterial = materialItems.length > 0;
|
||||
const showNpcs = npcItems.length > 0;
|
||||
if (!showMaterial && !showNpcs) return null;
|
||||
const screenRect = presentationScreenRect;
|
||||
const showGuide = Boolean(screenRect) && !isVideoPreviewScene;
|
||||
if (!showMaterial && !showNpcs && !showGuide) return null;
|
||||
const closes = [
|
||||
...(showMaterial
|
||||
? [
|
||||
@@ -2035,52 +2092,79 @@ export function ControlApp() {
|
||||
: []),
|
||||
];
|
||||
const materialsZoom = materialsOverlay?.zoomTool ?? null;
|
||||
const materialIdFromTarget = (target: EventTarget | null) => {
|
||||
if (!(target instanceof Element)) return undefined;
|
||||
const frame = target.closest('[data-material-id]');
|
||||
const raw = frame?.getAttribute('data-material-id');
|
||||
return raw ? (raw as MaterialId) : undefined;
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{previewContentRect && currentScene?.darkenScene ? (
|
||||
<SceneDarknessOverlay
|
||||
state={sdState}
|
||||
overlayAlpha={0.5}
|
||||
viewport={previewContentRect}
|
||||
style={{ zIndex: 30 }}
|
||||
/>
|
||||
) : null}
|
||||
<SceneOverlayHost
|
||||
active
|
||||
active={showMaterial || showNpcs}
|
||||
viewport={screenRect}
|
||||
showViewportGuide={showGuide}
|
||||
zoomTool={materialsZoom}
|
||||
{...(materialsZoom
|
||||
? {
|
||||
onZoomAt: (nx: number, ny: number) => {
|
||||
void materialsApi.dispatch({ kind: 'zoomAt', nx, ny });
|
||||
onZoomAt: (nx: number, ny: number, evTarget?: EventTarget | null) => {
|
||||
const mid = materialIdFromTarget(evTarget ?? null);
|
||||
void materialsApi.dispatch({
|
||||
kind: 'zoomAt',
|
||||
nx,
|
||||
ny,
|
||||
...(mid ? { materialId: mid } : {}),
|
||||
});
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
closes={closes}
|
||||
>
|
||||
{showMaterial && activeMaterial ? (
|
||||
<MaterialOverlay
|
||||
embedded
|
||||
assetId={activeMaterial.assetId}
|
||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||
editable
|
||||
zoomTool={materialsZoom}
|
||||
rotateLabel={t('materials.rotateOverlay')}
|
||||
onLayoutChange={(layout) => {
|
||||
void materialsApi.dispatch({ kind: 'layout.set', layout });
|
||||
}}
|
||||
{...(activeMaterial.legend?.enabled
|
||||
? { legendMarkers: activeMaterial.legend.markers ?? [] }
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
{showMaterial && activeMaterial?.legend?.enabled ? (
|
||||
<MaterialLegendPanel
|
||||
legend={activeMaterial.legend}
|
||||
layout={
|
||||
materialsOverlay?.legendLayout ?? {
|
||||
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
cx: 0.82,
|
||||
cy: 0.5,
|
||||
scale: 0.85,
|
||||
}
|
||||
}
|
||||
editable
|
||||
onLayoutChange={(layout) => {
|
||||
void materialsApi.dispatch({ kind: 'legendLayout.set', layout });
|
||||
}}
|
||||
/>
|
||||
) : 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>
|
||||
))}
|
||||
{showNpcs ? (
|
||||
<NpcsSceneOverlay
|
||||
embedded
|
||||
@@ -2093,6 +2177,7 @@ export function ControlApp() {
|
||||
/>
|
||||
) : null}
|
||||
</SceneOverlayHost>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
@@ -2106,7 +2191,10 @@ export function ControlApp() {
|
||||
<div className={styles.branchCardHeader}>
|
||||
<div className={styles.branchOption}>{t('control.option', { n: '1' })}</div>
|
||||
</div>
|
||||
<div className={styles.branchName}>{returnSceneTitle}</div>
|
||||
<EllipsisText
|
||||
text={returnSceneTitle}
|
||||
className={[styles.branchName, ellipsisStyles.root].join(' ')}
|
||||
/>
|
||||
<Button variant="primary" onClick={returnToMainStoryline}>
|
||||
{t('control.returnToMainStory')}
|
||||
</Button>
|
||||
@@ -2119,7 +2207,10 @@ export function ControlApp() {
|
||||
{t('control.option', { n: String(i + 1 + branchOptionOffset) })}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.branchName}>{o.scene.title || t('control.unnamed')}</div>
|
||||
<EllipsisText
|
||||
text={o.scene.title || t('control.unnamed')}
|
||||
className={[styles.branchName, ellipsisStyles.root].join(' ')}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
|
||||
Reference in New Issue
Block a user