diff --git a/app/main/index.ts b/app/main/index.ts index 7b68730..b8a6a54 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -47,6 +47,7 @@ import { createEditorWindowDeferred, createWindows, focusEditorWindow, + getPresentationContentSize, getSceneDescriptionContent, isMultiWindowOpen, markAppQuitting, @@ -61,6 +62,7 @@ import { closeSceneEditorWindow, closeNpcsWindow, sendToAppWindows, + syncAllWindowChromeTitles, togglePresentationFullscreen, waitForEditorWindowReady, warmNpcsEditorWindow, @@ -414,6 +416,10 @@ async function main() { closeMultiWindow(); return { ok: true }; }); + registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => { + syncAllWindowChromeTitles(localeTag); + return { ok: true }; + }); registerHandler(ipcChannels.windows.togglePresentationFullscreen, () => { const isFullScreen = togglePresentationFullscreen(); return { ok: true, isFullScreen }; @@ -421,6 +427,10 @@ async function main() { registerHandler(ipcChannels.windows.getMultiWindowState, () => { return { open: isMultiWindowOpen() }; }); + registerHandler(ipcChannels.windows.getPresentationContentSize, () => { + const size = getPresentationContentSize(); + return size ?? { width: null, height: null }; + }); registerHandler(ipcChannels.windows.openSceneDescription, ({ html }) => { openSceneDescriptionWindow(html); return { ok: true }; diff --git a/app/main/materials/materialsOverlayStore.ts b/app/main/materials/materialsOverlayStore.ts index 8f242e3..439c676 100644 --- a/app/main/materials/materialsOverlayStore.ts +++ b/app/main/materials/materialsOverlayStore.ts @@ -1,6 +1,7 @@ import { clampMaterialsLayout, DEFAULT_MATERIALS_OVERLAY_LAYOUT, + defaultLegendLayoutForMaterial, type MaterialId, type MaterialsOverlayEvent, type MaterialsOverlayLayout, @@ -12,9 +13,10 @@ import { function emptyState(): MaterialsOverlayState { return { revision: 1, - activeMaterialId: null, - layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT }, - legendLayout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT, cx: 0.82, cy: 0.5, scale: 0.85 }, + activeMaterialIds: [], + layouts: {}, + legendLayouts: {}, + focusMaterialId: null, zoomTool: null, }; } @@ -26,14 +28,12 @@ function initialLayout(rotationDeg?: number): MaterialsOverlayLayout { }); } -function initialLegendLayout(): MaterialsOverlayLayout { - return clampMaterialsLayout({ - ...DEFAULT_MATERIALS_OVERLAY_LAYOUT, - cx: 0.82, - cy: 0.5, - scale: 0.85, - rotationDeg: 0, - }); +function layoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout { + return state.layouts[materialId] ?? { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT }; +} + +function legendLayoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout { + return state.legendLayouts[materialId] ?? defaultLegendLayoutForMaterial(); } export class MaterialsOverlayStore { @@ -44,14 +44,15 @@ export class MaterialsOverlayStore { } clear(): MaterialsOverlayState { - if (this.state.activeMaterialId === null && this.state.zoomTool === null) { + if (this.state.activeMaterialIds.length === 0 && this.state.zoomTool === null) { return this.state; } this.state = { revision: this.state.revision + 1, - activeMaterialId: null, - layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT }, - legendLayout: initialLegendLayout(), + activeMaterialIds: [], + layouts: {}, + legendLayouts: {}, + focusMaterialId: null, zoomTool: null, }; return this.state; @@ -61,50 +62,98 @@ export class MaterialsOverlayStore { switch (event.kind) { case 'hide': return this.clear(); - case 'show': - this.state = { - revision: this.state.revision + 1, - activeMaterialId: event.materialId, - layout: initialLayout(event.rotationDeg), - legendLayout: initialLegendLayout(), - zoomTool: this.state.zoomTool, - }; - return this.state; - case 'toggle': { - if (this.state.activeMaterialId === event.materialId) { - return this.clear(); + case 'show': { + if (this.state.activeMaterialIds.includes(event.materialId)) { + this.state = { + ...this.state, + revision: this.state.revision + 1, + focusMaterialId: event.materialId, + }; + return this.state; } this.state = { revision: this.state.revision + 1, - activeMaterialId: event.materialId, - layout: initialLayout(event.rotationDeg), - legendLayout: initialLegendLayout(), + activeMaterialIds: [...this.state.activeMaterialIds, event.materialId], + layouts: { + ...this.state.layouts, + [event.materialId]: initialLayout(event.rotationDeg), + }, + legendLayouts: { + ...this.state.legendLayouts, + [event.materialId]: defaultLegendLayoutForMaterial(), + }, + focusMaterialId: event.materialId, + zoomTool: this.state.zoomTool, + }; + return this.state; + } + case 'toggle': { + if (this.state.activeMaterialIds.includes(event.materialId)) { + const activeMaterialIds = this.state.activeMaterialIds.filter((id) => id !== event.materialId); + const layouts = { ...this.state.layouts }; + const legendLayouts = { ...this.state.legendLayouts }; + delete layouts[event.materialId]; + delete legendLayouts[event.materialId]; + const focusMaterialId = + this.state.focusMaterialId === event.materialId + ? (activeMaterialIds[activeMaterialIds.length - 1] ?? null) + : this.state.focusMaterialId; + this.state = { + revision: this.state.revision + 1, + activeMaterialIds, + layouts, + legendLayouts, + focusMaterialId, + zoomTool: activeMaterialIds.length === 0 ? null : this.state.zoomTool, + }; + return this.state; + } + this.state = { + revision: this.state.revision + 1, + activeMaterialIds: [...this.state.activeMaterialIds, event.materialId], + layouts: { + ...this.state.layouts, + [event.materialId]: initialLayout(event.rotationDeg), + }, + legendLayouts: { + ...this.state.legendLayouts, + [event.materialId]: defaultLegendLayoutForMaterial(), + }, + focusMaterialId: event.materialId, zoomTool: this.state.zoomTool, }; return this.state; } case 'layout.set': { - if (this.state.activeMaterialId === null) return this.state; + if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state; this.state = { ...this.state, revision: this.state.revision + 1, - layout: clampMaterialsLayout({ - ...this.state.layout, - ...event.layout, - }), + focusMaterialId: event.materialId, + layouts: { + ...this.state.layouts, + [event.materialId]: clampMaterialsLayout({ + ...layoutFor(this.state, event.materialId), + ...event.layout, + }), + }, }; return this.state; } case 'legendLayout.set': { - if (this.state.activeMaterialId === null) return this.state; + if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state; this.state = { ...this.state, revision: this.state.revision + 1, - legendLayout: clampMaterialsLayout({ - ...this.state.legendLayout, - ...event.layout, - rotationDeg: 0, - }), + focusMaterialId: event.materialId, + legendLayouts: { + ...this.state.legendLayouts, + [event.materialId]: clampMaterialsLayout({ + ...legendLayoutFor(this.state, event.materialId), + ...event.layout, + rotationDeg: 0, + }), + }, }; return this.state; } @@ -118,10 +167,18 @@ export class MaterialsOverlayStore { return this.state; } case 'zoomAt': { - if (this.state.activeMaterialId === null || !this.state.zoomTool) return this.state; + if (this.state.activeMaterialIds.length === 0 || !this.state.zoomTool) return this.state; + const targetId = + (event.materialId && this.state.activeMaterialIds.includes(event.materialId) + ? event.materialId + : null) ?? + this.state.focusMaterialId ?? + this.state.activeMaterialIds[this.state.activeMaterialIds.length - 1] ?? + null; + if (!targetId) return this.state; const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25; const layout: MaterialsOverlayLayout = zoomMaterialsLayoutAt( - this.state.layout, + layoutFor(this.state, targetId), event.nx, event.ny, factor, @@ -129,7 +186,11 @@ export class MaterialsOverlayStore { this.state = { ...this.state, revision: this.state.revision + 1, - layout, + focusMaterialId: targetId, + layouts: { + ...this.state.layouts, + [targetId]: layout, + }, }; return this.state; } @@ -139,8 +200,29 @@ export class MaterialsOverlayStore { } ensureMaterialStillExists(materialIds: ReadonlySet): MaterialsOverlayState { - const active = this.state.activeMaterialId; - if (active === null || materialIds.has(active)) return this.state; - return this.clear(); + const activeMaterialIds = this.state.activeMaterialIds.filter((id) => materialIds.has(id)); + if (activeMaterialIds.length === this.state.activeMaterialIds.length) return this.state; + if (activeMaterialIds.length === 0) return this.clear(); + const layouts: Record = {}; + const legendLayouts: Record = {}; + for (const id of activeMaterialIds) { + const layout = this.state.layouts[id]; + if (layout) layouts[id] = layout; + const legend = this.state.legendLayouts[id]; + if (legend) legendLayouts[id] = legend; + } + const focusMaterialId = + this.state.focusMaterialId && activeMaterialIds.includes(this.state.focusMaterialId) + ? this.state.focusMaterialId + : (activeMaterialIds[activeMaterialIds.length - 1] ?? null); + this.state = { + revision: this.state.revision + 1, + activeMaterialIds, + layouts, + legendLayouts, + focusMaterialId, + zoomTool: this.state.zoomTool, + }; + return this.state; } } diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index ddc1650..42ada21 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -1044,7 +1044,7 @@ export class ZipProjectStore { for (const asset of staged) { assets[asset.id] = asset; if (asset.type !== 'audio') continue; - campaignAudios.push({ assetId: asset.id, autoplay: true, loop: true }); + campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false }); } return { ...p, assets, campaignAudios }; }); diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts index bbd23e0..d0ce921 100644 --- a/app/main/windows/createWindows.editorClose.test.ts +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -41,7 +41,7 @@ void test('createWindows: окно описания сцены закрывае assert.ok(src.includes('closeSceneDescriptionWindow')); assert.ok(src.includes("createWindow('sceneDescription'")); assert.match(src, /export function closeMultiWindow[\s\S]*closeSceneDescriptionWindow/); - assert.match(src, /kind !== 'presentation' && kind !== 'control'[\s\S]*closeSceneDescriptionWindow/); + assert.match(src, /kind !== 'presentation'[\s\S]*closeSceneDescriptionWindow/); }); void test('createWindows: окно материалов закрывается с multi-window', () => { @@ -63,6 +63,12 @@ void test('createWindows: окно НПС закрывается с multi-window assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/); }); +void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => { + const src = readCreateWindows(); + assert.match(src, /kind === 'control'/); + assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closePresentationWindow/); +}); + void test('createWindows: production — loadFile для HTML (не только file://)', () => { const src = readCreateWindows(); assert.ok(src.includes('loadFile')); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index e93e012..371fb24 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { app, BrowserWindow, screen } from 'electron'; -import { windowChromeTitle } from '../../shared/appBranding'; +import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding'; import { ipcChannels } from '../../shared/ipc/contracts'; import { safeConsoleError } from '../safeConsole'; @@ -32,7 +32,16 @@ export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [ const windows = new Map(); +/** Язык заголовков окон (из редактора); иначе `app.getLocale()`. */ +let chromeLocaleTagOverride: string | null = null; + +function resolveChromeLocaleTag(): string { + return chromeLocaleTagOverride ?? app.getLocale(); +} + let appQuitting = false; +/** Защита от каскада close(control) ↔ close(presentation). */ +let closingPlaySession = false; let pendingSceneDescriptionHtml = ''; /** Окно материалов — только колонка списка. */ @@ -60,6 +69,35 @@ function broadcastMultiWindowStateChanged(open: boolean): void { } } +export function getPresentationContentSize(): { width: number; height: number } | null { + const pres = windows.get('presentation'); + if (!pres || pres.isDestroyed()) return null; + const [width, height] = pres.getContentSize(); + if (width <= 0 || height <= 0) return null; + return { width, height }; +} + +function broadcastPresentationContentSize(): void { + const size = getPresentationContentSize(); + if (!size) return; + for (const w of BrowserWindow.getAllWindows()) { + if (w.isDestroyed() || w.webContents.isDestroyed()) continue; + try { + w.webContents.send(ipcChannels.windows.presentationContentSizeChanged, size); + } catch { + /* ignore */ + } + } +} + +function bindPresentationContentSizeTracking(win: BrowserWindow): void { + const emit = () => broadcastPresentationContentSize(); + win.on('resize', emit); + win.on('enter-full-screen', emit); + win.on('leave-full-screen', emit); + win.webContents.once('did-finish-load', emit); +} + function sendSceneDescriptionContent(win: BrowserWindow, html: string): void { if (win.isDestroyed() || win.webContents.isDestroyed()) return; try { @@ -91,6 +129,51 @@ export function sendToAppWindows( } } +function applyWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void { + if (win.isDestroyed()) return; + win.setTitle(windowChromeTitle(kind as AppWindowKind, resolveChromeLocaleTag())); +} + +export function syncAllWindowChromeTitles(localeTag: string): void { + chromeLocaleTagOverride = localeTag.trim() || null; + for (const [kind, win] of windows.entries()) { + applyWindowChromeTitle(win, kind); + } +} + +function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void { + const apply = () => applyWindowChromeTitle(win, kind); + apply(); + win.webContents.on('page-title-updated', (event) => { + event.preventDefault(); + apply(); + }); + win.webContents.on('did-finish-load', () => { + apply(); + }); +} + +/** Закрыть окна сессии, кроме редактора и его дочерних окон. */ +function closePlaySessionAuxiliaryWindows(): void { + closeSceneDescriptionWindow(); + closeMaterialsWindow(); + closeNpcsWindow(); +} + +function closePresentationWindow(): void { + const pres = windows.get('presentation'); + if (pres && !pres.isDestroyed()) { + pres.close(); + } +} + +function closeControlWindow(): void { + const ctrl = windows.get('control'); + if (ctrl && !ctrl.isDestroyed()) { + ctrl.close(); + } +} + function quitAppFromEditorClose(): void { markAppQuitting(); app.quit(); @@ -272,7 +355,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow } } - win.setTitle(windowChromeTitle(kind, app.getLocale())); + bindWindowChromeTitle(win, kind); if ( kind === 'sceneDescription' || kind === 'materials' || @@ -307,14 +390,30 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow quitAppFromEditorClose(); }); } + if (kind === 'control') { + win.on('close', () => { + if (appQuitting || closingPlaySession) return; + closingPlaySession = true; + closePlaySessionAuxiliaryWindows(); + closePresentationWindow(); + }); + } + if (kind === 'presentation') { + bindPresentationContentSizeTracking(win); + win.on('close', () => { + if (appQuitting || closingPlaySession) return; + closingPlaySession = true; + closePlaySessionAuxiliaryWindows(); + closeControlWindow(); + }); + } win.on('closed', () => windows.delete(kind)); win.on('closed', () => { if (kind !== 'presentation' && kind !== 'control') return; const open = windows.has('presentation') || windows.has('control'); if (!open) { - closeSceneDescriptionWindow(); - closeMaterialsWindow(); - closeNpcsWindow(); + closingPlaySession = false; + closePlaySessionAuxiliaryWindows(); } broadcastMultiWindowStateChanged(open); }); @@ -395,16 +494,19 @@ export function openMultiWindow() { createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation }); } broadcastMultiWindowStateChanged(true); + broadcastPresentationContentSize(); } export function closeMultiWindow(): void { - closeSceneDescriptionWindow(); - closeMaterialsWindow(); - closeNpcsWindow(); + closingPlaySession = true; + closePlaySessionAuxiliaryWindows(); const pres = windows.get('presentation'); const ctrl = windows.get('control'); - if (pres) pres.close(); - if (ctrl) ctrl.close(); + if (pres && !pres.isDestroyed()) pres.close(); + if (ctrl && !ctrl.isDestroyed()) ctrl.close(); + if (!windows.has('presentation') && !windows.has('control')) { + closingPlaySession = false; + } } export function isMultiWindowOpen(): boolean { diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index 258db76..aaab6d4 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -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 { diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index b66ada9..9e09397 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -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(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() { ) : (
{t('control.passed')}
)} -
{s?.title ?? (gn ? String(gn.sceneId) : gnId)}
+ ); })} @@ -1759,9 +1800,6 @@ export function ControlApp() { draft={explosionDraft} viewport={previewContentRect} /> - {previewContentRect ? ( - - ) : null}
) : 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 => x !== null) + : []; const activeIds = npcsOverlay?.activeNpcIds ?? []; const npcItems = project && activeIds.length > 0 @@ -2007,9 +2062,11 @@ export function ControlApp() { }) .filter((x): x is NonNullable => 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 ? ( + + ) : null} { - 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 ? ( - { - void materialsApi.dispatch({ kind: 'layout.set', layout }); - }} - {...(activeMaterial.legend?.enabled - ? { legendMarkers: activeMaterial.legend.markers ?? [] } - : {})} - /> - ) : null} - {showMaterial && activeMaterial?.legend?.enabled ? ( - { - void materialsApi.dispatch({ kind: 'legendLayout.set', layout }); - }} - /> - ) : null} + {materialItems.map(({ material, layout, legendLayout }) => ( + + { + void materialsApi.dispatch({ + kind: 'layout.set', + materialId: material.id, + layout: nextLayout, + }); + }} + {...(material.legend?.enabled + ? { legendMarkers: material.legend.markers ?? [] } + : {})} + /> + {material.legend?.enabled ? ( + { + void materialsApi.dispatch({ + kind: 'legendLayout.set', + materialId: material.id, + layout: nextLayout, + }); + }} + /> + ) : null} + + ))} {showNpcs ? ( ) : null} + ); })()}
@@ -2106,7 +2191,10 @@ export function ControlApp() {
{t('control.option', { n: '1' })}
-
{returnSceneTitle}
+ @@ -2119,7 +2207,10 @@ export function ControlApp() { {t('control.option', { n: String(i + 1 + branchOptionOffset) })} -
{o.scene.title || t('control.unnamed')}
+ - {previewAssetId ? : null} - {previewAssetId && previewAssetType === 'video' ? ( + {previewAssetId ? : } + + {previewAssetId && previewAssetType === 'video' ? ( +
- ) : null} - {previewAssetId && previewAssetType === 'image' ? ( + +
+ ) : null} + {previewAssetId && previewAssetType === 'image' ? ( + <> +
- ) : null} -
- {previewAssetId && previewAssetType === 'image' ? ( - <>