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:
Ivan Fontosh
2026-08-03 11:28:18 +08:00
parent 61446dacfc
commit 2979d06f1c
41 changed files with 1060 additions and 252 deletions
+5 -1
View File
@@ -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 {
+141 -50
View File
@@ -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={() =>
+10 -4
View File
@@ -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)}
+27
View File
@@ -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;
+27 -11
View File
@@ -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;
+13 -4
View File
@@ -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 ? (
<>
+15 -4
View File
@@ -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 {
+3 -1
View File
@@ -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) => {
+2 -2
View File
@@ -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}”?',
+18 -16
View File
@@ -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'
+73 -1
View File
@@ -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;
+8 -1
View File
@@ -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}>
+31 -23
View File
@@ -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',
};
}
+33
View File
@@ -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;
}