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:
@@ -271,6 +271,7 @@
|
||||
|
||||
.historyTitle {
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.emptyStory {
|
||||
@@ -356,7 +357,7 @@
|
||||
|
||||
.branchGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -367,6 +368,8 @@
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.branchCardHeader {
|
||||
@@ -383,6 +386,7 @@
|
||||
|
||||
.branchName {
|
||||
font-weight: 900;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.branchCardReturn {
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
@@ -35,6 +35,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
const isVideo = scene?.previewAssetType === 'video';
|
||||
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
|
||||
const autostart = scene?.previewVideoAutostart ?? false;
|
||||
const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
const dur = useMemo(
|
||||
@@ -61,14 +62,18 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
if (!assetId) return;
|
||||
// `target.set` bumps revision and resets anchors; avoid firing on every render.
|
||||
if (vp?.targetAssetId === assetId) return;
|
||||
const sceneKey = session?.project?.currentGraphNodeId ?? session?.currentSceneId ?? '';
|
||||
const prev = lastTargetRef.current;
|
||||
if (prev && prev.sceneKey === sceneKey && prev.assetId === assetId && prev.autostart === autostart) {
|
||||
return;
|
||||
}
|
||||
lastTargetRef.current = { sceneKey, assetId, autostart };
|
||||
void video.dispatch({
|
||||
kind: 'target.set',
|
||||
assetId,
|
||||
autostart,
|
||||
});
|
||||
}, [assetId, isVideo, autostart, vp?.targetAssetId, video]);
|
||||
}, [assetId, isVideo, autostart, session?.currentSceneId, session?.project?.currentGraphNodeId, video]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
@@ -108,6 +113,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
className={styles.video}
|
||||
src={url}
|
||||
playsInline
|
||||
loop={Boolean(scene?.settings?.loopVideo)}
|
||||
preload="auto"
|
||||
onTimeUpdate={() => setTick((x) => x + 1)}
|
||||
onLoadedMetadata={() => setTick((x) => x + 1)}
|
||||
|
||||
@@ -946,6 +946,33 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actionsRowHalf {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span > button {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowVideoChecks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkboxLabel {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -1096,6 +1096,7 @@ export function EditorApp() {
|
||||
previewAssetId={sc?.previewAssetId ?? null}
|
||||
previewAssetType={sc?.previewAssetType ?? null}
|
||||
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
|
||||
previewVideoLoop={sc?.settings.loopVideo ?? false}
|
||||
previewRotationDeg={sc?.previewRotationDeg ?? 0}
|
||||
darkenScene={sc?.darkenScene ?? false}
|
||||
previewBusy={previewBusy}
|
||||
@@ -1108,6 +1109,9 @@ export function EditorApp() {
|
||||
onPreviewVideoAutostartChange={(next) =>
|
||||
void actions.updateScene(sid, { previewVideoAutostart: next })
|
||||
}
|
||||
onPreviewVideoLoopChange={(next) =>
|
||||
void actions.updateScene(sid, { settings: { loopVideo: next } })
|
||||
}
|
||||
onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
|
||||
onTitleChange={(title) => void actions.updateScene(sid, { title })}
|
||||
onDescriptionChange={(description) =>
|
||||
@@ -2322,6 +2326,7 @@ type SceneInspectorProps = {
|
||||
previewAssetId: AssetId | null;
|
||||
previewAssetType: 'image' | 'video' | null;
|
||||
previewVideoAutostart: boolean;
|
||||
previewVideoLoop: boolean;
|
||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||
darkenScene: boolean;
|
||||
previewBusy: boolean;
|
||||
@@ -2330,6 +2335,7 @@ type SceneInspectorProps = {
|
||||
audioRefs: SceneAudioRef[];
|
||||
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
||||
onPreviewVideoAutostartChange: (next: boolean) => void;
|
||||
onPreviewVideoLoopChange: (next: boolean) => void;
|
||||
onDarkenSceneChange: (next: boolean) => void;
|
||||
onTitleChange: (v: string) => void;
|
||||
onDescriptionChange: (v: string) => void;
|
||||
@@ -2456,6 +2462,7 @@ function SceneInspector({
|
||||
previewAssetId,
|
||||
previewAssetType,
|
||||
previewVideoAutostart,
|
||||
previewVideoLoop,
|
||||
previewRotationDeg,
|
||||
darkenScene,
|
||||
previewBusy,
|
||||
@@ -2464,6 +2471,7 @@ function SceneInspector({
|
||||
audioRefs,
|
||||
onAudioRefsChange,
|
||||
onPreviewVideoAutostartChange,
|
||||
onPreviewVideoLoopChange,
|
||||
onDarkenSceneChange,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
@@ -2578,7 +2586,7 @@ function SceneInspector({
|
||||
muted
|
||||
playsInline
|
||||
autoPlay={previewVideoAutostart}
|
||||
loop
|
||||
loop={previewVideoLoop}
|
||||
preload="metadata"
|
||||
className={styles.videoCover}
|
||||
/>
|
||||
@@ -2595,12 +2603,14 @@ function SceneInspector({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.actionsRow}>
|
||||
<div className={styles.actionsRowHalf}>
|
||||
<Button variant="primary" disabled={previewBusy} onClick={onImportPreview}>
|
||||
{previewAssetId ? t('scene.change') : t('campaign.upload')}
|
||||
</Button>
|
||||
{previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : null}
|
||||
{previewAssetId && previewAssetType === 'video' ? (
|
||||
{previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : <span aria-hidden />}
|
||||
</div>
|
||||
{previewAssetId && previewAssetType === 'video' ? (
|
||||
<div className={styles.actionsRowVideoChecks}>
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -2609,8 +2619,19 @@ function SceneInspector({
|
||||
/>
|
||||
<span className={styles.spanSm}>{t('scene.autostart')}</span>
|
||||
</label>
|
||||
) : null}
|
||||
{previewAssetId && previewAssetType === 'image' ? (
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={previewVideoLoop}
|
||||
onChange={(e) => onPreviewVideoLoopChange(e.target.checked)}
|
||||
/>
|
||||
<span className={styles.spanSm}>{t('campaign.loop')}</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
{previewAssetId && previewAssetType === 'image' ? (
|
||||
<>
|
||||
<div className={styles.spacer6} />
|
||||
<Button
|
||||
onClick={() => {
|
||||
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
|
||||
@@ -2619,10 +2640,6 @@ function SceneInspector({
|
||||
>
|
||||
{t('scene.rotate')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{previewAssetId && previewAssetType === 'image' ? (
|
||||
<>
|
||||
<div className={styles.spacer6} />
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
@@ -2879,7 +2896,6 @@ function SceneListCard({
|
||||
</div>
|
||||
<div className={styles.sceneCardBody}>
|
||||
<div className={styles.sceneCardHeader}>
|
||||
{scene.active ? <div className={styles.badgeCurrent}>{t('sceneCard.current')}</div> : null}
|
||||
<div
|
||||
ref={titleRef}
|
||||
className={styles.sceneCardTitle}
|
||||
|
||||
@@ -15,6 +15,17 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legendHeadRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.legendHeadRow .row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -27,6 +27,8 @@ type Props = {
|
||||
/** Крупная карта (окно «Материалы»). */
|
||||
largeMap?: boolean;
|
||||
onChange: (legend: MaterialLegend) => void;
|
||||
onRotate?: () => void;
|
||||
rotateLabel?: string;
|
||||
};
|
||||
|
||||
type DragMode =
|
||||
@@ -55,6 +57,8 @@ export function MaterialLegendEditor({
|
||||
rotationDeg = 0,
|
||||
largeMap = false,
|
||||
onChange,
|
||||
onRotate,
|
||||
rotateLabel = 'Повернуть',
|
||||
}: Props) {
|
||||
const [draft, setDraft] = useState<MaterialLegend>(() => cloneLegend(legend));
|
||||
const [activeItemId, setActiveItemId] = useState<string | null>(null);
|
||||
@@ -404,10 +408,15 @@ export function MaterialLegendEditor({
|
||||
<div className={[styles.legendBlock, largeMap ? styles.legendBlockLarge : ''].filter(Boolean).join(' ')}>
|
||||
{largeMap ? mapBlock : null}
|
||||
|
||||
<label className={styles.row}>
|
||||
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
<span>Легенда</span>
|
||||
</label>
|
||||
<div className={styles.legendHeadRow}>
|
||||
{onRotate ? (
|
||||
<Button onClick={onRotate}>{rotateLabel}</Button>
|
||||
) : null}
|
||||
<label className={styles.row}>
|
||||
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
<span>Легенда</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draft.enabled ? (
|
||||
<>
|
||||
|
||||
@@ -19,7 +19,7 @@ export type MaterialsBrowserProps = {
|
||||
mode: 'editor' | 'runtime';
|
||||
selectedId: MaterialId | null;
|
||||
onSelect: (id: MaterialId | null) => void;
|
||||
activeMaterialId?: MaterialId | null;
|
||||
activeMaterialIds?: readonly MaterialId[];
|
||||
onAdd?: () => void;
|
||||
onEdit?: (material: ProjectMaterial) => void;
|
||||
onDelete?: (materialId: MaterialId) => Promise<void>;
|
||||
@@ -40,7 +40,7 @@ export function MaterialsBrowser({
|
||||
mode,
|
||||
selectedId,
|
||||
onSelect,
|
||||
activeMaterialId = null,
|
||||
activeMaterialIds = [],
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -74,6 +74,7 @@ export function MaterialsBrowser({
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [menuFor]);
|
||||
|
||||
const activeSet = useMemo(() => new Set(activeMaterialIds), [activeMaterialIds]);
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return materials;
|
||||
@@ -126,7 +127,7 @@ export function MaterialsBrowser({
|
||||
key={m.id}
|
||||
material={m}
|
||||
selected={m.id === selectedId}
|
||||
active={m.id === activeMaterialId}
|
||||
active={activeSet.has(m.id)}
|
||||
showMenu={mode === 'editor'}
|
||||
dragging={dragId === m.id}
|
||||
dropPlace={dropPlace?.id === m.id ? dropPlace.place : null}
|
||||
@@ -200,6 +201,16 @@ export function MaterialsBrowser({
|
||||
legend={selected.legend}
|
||||
previewUrl={selectedUrl}
|
||||
rotationDeg={selected.rotationDeg ?? 0}
|
||||
rotateLabel={t('scene.rotate')}
|
||||
onRotate={
|
||||
onRotate
|
||||
? () => {
|
||||
const cur = selected.rotationDeg ?? 0;
|
||||
const next = ((cur + 90) % 360) as 0 | 90 | 180 | 270;
|
||||
onRotate(selected.id, next);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={(next) => {
|
||||
void onLegendChange(selected.id, next);
|
||||
}}
|
||||
@@ -220,7 +231,7 @@ export function MaterialsBrowser({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selected && onRotate ? (
|
||||
{selected && onRotate && !onLegendChange ? (
|
||||
<div className={matStyles.previewActions}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -33,6 +33,28 @@
|
||||
.browserToolbarRow > * {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.browserToolbarRow > * > button {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.browserToolbarZoomRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.browserToolbarFullBtn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.browserToolbarFullBtn > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.browserToolbarHint {
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.musicParams {
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
} from '../../../shared/graph/sceneGraphLineage';
|
||||
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
|
||||
import { RotatedImage } from '../../shared/RotatedImage';
|
||||
import { EllipsisText } from '../../shared/ui/EllipsisText';
|
||||
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
|
||||
import { useAssetUrl } from '../../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './SceneGraph.module.css';
|
||||
@@ -293,7 +295,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.nodeBody}>
|
||||
<div className={styles.title}>{data.title || ui.untitled}</div>
|
||||
<EllipsisText text={data.title || ui.untitled} className={[styles.title, ellipsisStyles.root].join(' ')} />
|
||||
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
|
||||
<div className={styles.musicParams}>
|
||||
{data.hasAnyAudioLoop ? (
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
translateEditorMessage,
|
||||
type EditorLocale,
|
||||
} from './editorMessages';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
|
||||
type EditorI18nContextValue = {
|
||||
locale: EditorLocale;
|
||||
@@ -36,6 +38,15 @@ export function EditorI18nProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const tag = locale === 'ru' ? 'ru-RU' : 'en-US';
|
||||
try {
|
||||
void getDndApi().invoke(ipcChannels.windows.syncChromeTitles, { localeTag: tag });
|
||||
} catch {
|
||||
// preload ещё не готов (редко при первом кадре)
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
// Другие окна Electron (пульт, материалы) подхватывают смену языка из редактора.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
|
||||
@@ -406,7 +406,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'materials.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
|
||||
'materials.tileMenu': 'Меню материала',
|
||||
'materials.windowEmpty': 'Добавьте материалы в редакторе.',
|
||||
'materials.closeOverlay': 'Закрыть материал',
|
||||
'materials.closeOverlay': 'Закрыть материалы',
|
||||
'materials.rotateOverlay': 'Повернуть',
|
||||
'materials.deleteTitle': 'Удаление материала',
|
||||
'materials.deleteConfirm': 'Вы уверены, что хотите удалить материал «{name}»?',
|
||||
@@ -977,7 +977,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'materials.dropHint': 'Drop an image (PNG, JPG, WebP)',
|
||||
'materials.tileMenu': 'Material menu',
|
||||
'materials.windowEmpty': 'Add materials in the editor.',
|
||||
'materials.closeOverlay': 'Close material',
|
||||
'materials.closeOverlay': 'Close materials',
|
||||
'materials.rotateOverlay': 'Rotate',
|
||||
'materials.deleteTitle': 'Delete material',
|
||||
'materials.deleteConfirm': 'Are you sure you want to delete material “{name}”?',
|
||||
|
||||
@@ -59,7 +59,7 @@ export function MaterialsApp() {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (overlay?.activeMaterialId) {
|
||||
if ((overlay?.activeMaterialIds?.length ?? 0) > 0) {
|
||||
void overlayApi.dispatch({ kind: 'hide' });
|
||||
return;
|
||||
}
|
||||
@@ -72,10 +72,10 @@ export function MaterialsApp() {
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [overlay?.activeMaterialId, overlay?.zoomTool, overlayApi]);
|
||||
}, [overlay?.activeMaterialIds, overlay?.zoomTool, overlayApi]);
|
||||
|
||||
const materials = session?.project?.materials ?? [];
|
||||
const activeId = overlay?.activeMaterialId ?? null;
|
||||
const activeIds = overlay?.activeMaterialIds ?? [];
|
||||
const zoomTool = overlay?.zoomTool ?? null;
|
||||
|
||||
return (
|
||||
@@ -88,7 +88,7 @@ export function MaterialsApp() {
|
||||
materials={materials}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
activeMaterialId={activeId}
|
||||
activeMaterialIds={activeIds}
|
||||
onTileActivate={(id) => {
|
||||
const mat = materials.find((m) => m.id === id);
|
||||
void overlayApi.dispatch({
|
||||
@@ -99,7 +99,7 @@ export function MaterialsApp() {
|
||||
}}
|
||||
toolbar={
|
||||
<>
|
||||
<div className={matStyles.browserToolbarRow}>
|
||||
<div className={matStyles.browserToolbarZoomRow}>
|
||||
<Button
|
||||
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
@@ -131,17 +131,19 @@ export function MaterialsApp() {
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{activeId ? (
|
||||
<Button
|
||||
title={t('materials.closeOverlay')}
|
||||
ariaLabel={t('materials.closeOverlay')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({ kind: 'hide' });
|
||||
}}
|
||||
>
|
||||
{t('materials.closeOverlay')}
|
||||
</Button>
|
||||
{activeIds.length > 0 ? (
|
||||
<div className={matStyles.browserToolbarFullBtn}>
|
||||
<Button
|
||||
title={t('materials.closeOverlay')}
|
||||
ariaLabel={t('materials.closeOverlay')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({ kind: 'hide' });
|
||||
}}
|
||||
>
|
||||
{t('materials.closeOverlay')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={matStyles.browserToolbarHint}>
|
||||
{zoomTool === 'zoomIn'
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
|
||||
import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types';
|
||||
import matStyles from '../editor/MaterialsModals.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
@@ -12,6 +13,32 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './NpcsApp.module.css';
|
||||
|
||||
function ZoomInIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path
|
||||
d="M10.5 7.8v5.4M7.8 10.5h5.4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomOutIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M7.8 10.5h5.4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Убрать пустые ветки групп (удобно при поиске). */
|
||||
function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
|
||||
const out: NpcGroupTreeNode[] = [];
|
||||
@@ -137,12 +164,18 @@ export function NpcsApp() {
|
||||
void overlayApi.dispatch({ kind: 'hide' });
|
||||
return;
|
||||
}
|
||||
if (overlay?.zoomTool) {
|
||||
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
|
||||
return;
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [hasActive, overlayApi]);
|
||||
}, [hasActive, overlay?.zoomTool, overlayApi]);
|
||||
|
||||
const zoomTool = overlay?.zoomTool ?? null;
|
||||
|
||||
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
|
||||
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
|
||||
@@ -215,6 +248,45 @@ export function NpcsApp() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={matStyles.browserToolbarRow}>
|
||||
<Button
|
||||
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('npcs.zoomIn')}
|
||||
ariaLabel={t('npcs.zoomIn')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({
|
||||
kind: 'zoomTool.set',
|
||||
tool: zoomTool === 'zoomIn' ? null : 'zoomIn',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant={zoomTool === 'zoomOut' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('npcs.zoomOut')}
|
||||
ariaLabel={t('npcs.zoomOut')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({
|
||||
kind: 'zoomTool.set',
|
||||
tool: zoomTool === 'zoomOut' ? null : 'zoomOut',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={matStyles.browserToolbarHint}>
|
||||
{zoomTool === 'zoomIn'
|
||||
? t('npcs.zoomInHint')
|
||||
: zoomTool === 'zoomOut'
|
||||
? t('npcs.zoomOutHint')
|
||||
: t('npcs.zoomIdleHint')}
|
||||
</div>
|
||||
<div className={styles.toolbarRow}>
|
||||
<Button
|
||||
title={t('npcs.closeOverlay')}
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--stroke, #2a2f3a);
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sideTitle {
|
||||
@@ -23,6 +24,25 @@
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
line-height: 1.35;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.accordionScroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
@@ -30,6 +50,7 @@
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.accordionHead {
|
||||
@@ -147,12 +168,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { EllipsisText } from '../shared/ui/EllipsisText';
|
||||
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
|
||||
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
||||
@@ -318,11 +320,15 @@ export function SceneEditorApp() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.sideTitle}>{scene?.title ?? 'Сцена'}</div>
|
||||
<EllipsisText
|
||||
text={scene?.title ?? 'Сцена'}
|
||||
className={[styles.sideTitle, ellipsisStyles.root].join(' ')}
|
||||
/>
|
||||
<div className={styles.hint}>
|
||||
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранное.
|
||||
</div>
|
||||
|
||||
<div className={styles.accordionScroll}>
|
||||
<div className={styles.accordion}>
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
|
||||
Сетка {gridOpen ? '▾' : '▸'}
|
||||
@@ -461,6 +467,7 @@ export function SceneEditorApp() {
|
||||
Очистить сцену
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className={styles.stage}>
|
||||
|
||||
@@ -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