feat(materials): add campaign materials overlay for sessions

Let GMs manage and show images over the scene from the editor and control panel, with zoom tools, help docs, and full ru/en i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-17 11:05:38 +08:00
parent 195d4be086
commit 61875be857
39 changed files with 2521 additions and 22 deletions
+116 -3
View File
@@ -1,5 +1,9 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
import { openDialogFilterLabel } from '../shared/appBranding';
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
import {
PROJECT_ZIP_OPEN_DIALOG_FILTER,
@@ -15,6 +19,7 @@ import { EffectsStore } from './effects/effectsStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
import { LicenseService } from './license/licenseService';
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
import { ZipProjectStore } from './project/zipStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { installAutoUpdater } from './update/installAutoUpdater';
@@ -36,8 +41,10 @@ import {
getSceneDescriptionContent,
isMultiWindowOpen,
markAppQuitting,
openMaterialsWindow,
openMultiWindow,
openSceneDescriptionWindow,
closeMaterialsWindow,
togglePresentationFullscreen,
waitForEditorWindowReady,
} from './windows/createWindows';
@@ -130,6 +137,7 @@ function installAppMenuForSession(): void {
const effectsStore = new EffectsStore();
const sceneDarknessStore = new SceneDarknessStore();
const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore();
function emitEffectsState(): void {
const state = effectsStore.getState();
@@ -138,6 +146,22 @@ function emitEffectsState(): void {
}
}
function emitMaterialsOverlayState(): void {
const state = materialsOverlayStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.materialsOverlay.stateChanged, { state });
}
}
function syncMaterialsOverlayWithProject(project: Project | null): void {
if (!project) {
materialsOverlayStore.clear();
return;
}
const ids = new Set((project.materials ?? []).map((m) => m.id));
materialsOverlayStore.ensureMaterialStillExists(ids);
}
function emitSceneDarknessState(): void {
const state = sceneDarknessStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
@@ -196,6 +220,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
createWindows();
emitSessionState();
emitEffectsState();
emitMaterialsOverlayState();
emitVideoState();
return;
}
@@ -209,6 +234,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
createWindows();
emitSessionState();
emitEffectsState();
emitMaterialsOverlayState();
emitVideoState();
return;
}
@@ -246,6 +272,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
emitSessionState();
emitEffectsState();
emitMaterialsOverlayState();
emitVideoState();
}
@@ -322,6 +349,23 @@ async function main() {
registerHandler(ipcChannels.windows.getSceneDescriptionContent, () => {
return { html: getSceneDescriptionContent() };
});
registerHandler(ipcChannels.windows.openMaterials, () => {
openMaterialsWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeMaterials, () => {
closeMaterialsWindow();
return { ok: true };
});
registerHandler(ipcChannels.materialsOverlay.getState, () => {
return { state: materialsOverlayStore.getState() };
});
registerHandler(ipcChannels.materialsOverlay.dispatch, ({ event }) => {
materialsOverlayStore.dispatch(event);
emitMaterialsOverlayState();
return { ok: true };
});
registerHandler(ipcChannels.project.list, async () => {
const projects = await projectStore.listProjects();
@@ -347,8 +391,10 @@ async function main() {
registerHandler(ipcChannels.project.close, async () => {
await projectStore.closeOpenProject();
effectsStore.clear();
materialsOverlayStore.clear();
sceneDarknessStore.resetSession();
emitEffectsState();
emitMaterialsOverlayState();
emitSceneDarknessState();
emitSessionState();
return { ok: true };
@@ -363,9 +409,11 @@ async function main() {
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
effectsStore.clear();
materialsOverlayStore.clear();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitEffectsState();
emitMaterialsOverlayState();
emitSceneDarknessState();
emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
@@ -380,9 +428,11 @@ async function main() {
currentSceneId: gn ? gn.sceneId : null,
}));
effectsStore.clear();
materialsOverlayStore.clear();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitEffectsState();
emitMaterialsOverlayState();
emitSceneDarknessState();
emitSessionState();
const p = projectStore.getOpenProject();
@@ -413,7 +463,7 @@ async function main() {
properties: ['openFile', 'multiSelections'],
filters: [
{
name: 'Видео и аудио',
name: openDialogFilterLabel('videoAndAudio', app.getLocale()),
extensions: ['mp4', 'webm', 'mov', 'mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
@@ -436,7 +486,7 @@ async function main() {
properties: ['openFile', 'multiSelections'],
filters: [
{
name: 'Аудио',
name: openDialogFilterLabel('audio', app.getLocale()),
extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
@@ -457,6 +507,69 @@ async function main() {
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.upsertMaterial, async ({ materialId, name, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath && !materialId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) {
throw new Error('Material image is required');
}
filePath = filePaths[0];
}
const project = await projectStore.upsertMaterial({
...(materialId ? { materialId } : {}),
name,
...(filePath ? { filePath } : {}),
});
syncMaterialsOverlayWithProject(project);
emitMaterialsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteMaterial, async ({ materialId }) => {
const project = await projectStore.deleteMaterial(materialId);
syncMaterialsOverlayWithProject(project);
emitMaterialsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setMaterialsOrder, async ({ materialIds }) => {
const project = await projectStore.setMaterialsOrder(materialIds);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setMaterialRotation, async ({ materialId, rotationDeg }) => {
const project = await projectStore.setMaterialRotation(materialId, rotationDeg);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.pickMaterialImage, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) return { canceled: true as const };
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath) {
@@ -464,7 +577,7 @@ async function main() {
properties: ['openFile'],
filters: [
{
name: 'Изображения и видео',
name: openDialogFilterLabel('imagesAndVideo', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'mp4', 'webm', 'mov'],
},
],
+1
View File
@@ -19,6 +19,7 @@ function channelRequiresLicense(channel: string): boolean {
if (channel.startsWith('app.')) return false;
if (channel === ipcChannels.windows.closeMultiWindow) return false;
if (channel === ipcChannels.windows.closeSceneDescription) return false;
if (channel === ipcChannels.windows.closeMaterials) return false;
if (channel === ipcChannels.windows.togglePresentationFullscreen) return false;
// Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать».
if (channel === ipcChannels.project.list) return false;
+109
View File
@@ -0,0 +1,109 @@
import {
clampMaterialsLayout,
DEFAULT_MATERIALS_OVERLAY_LAYOUT,
type MaterialId,
type MaterialsOverlayEvent,
type MaterialsOverlayLayout,
type MaterialsOverlayState,
type MaterialsZoomTool,
zoomMaterialsLayoutAt,
} from '../../shared/types';
function emptyState(): MaterialsOverlayState {
return {
revision: 1,
activeMaterialId: null,
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
zoomTool: null,
};
}
export class MaterialsOverlayStore {
private state: MaterialsOverlayState = emptyState();
getState(): MaterialsOverlayState {
return this.state;
}
clear(): MaterialsOverlayState {
if (this.state.activeMaterialId === null && this.state.zoomTool === null) {
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeMaterialId: null,
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
zoomTool: null,
};
return this.state;
}
dispatch(event: MaterialsOverlayEvent): MaterialsOverlayState {
switch (event.kind) {
case 'hide':
return this.clear();
case 'show':
this.state = {
revision: this.state.revision + 1,
activeMaterialId: event.materialId,
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
zoomTool: this.state.zoomTool,
};
return this.state;
case 'toggle': {
if (this.state.activeMaterialId === event.materialId) {
return this.clear();
}
this.state = {
revision: this.state.revision + 1,
activeMaterialId: event.materialId,
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'layout.set': {
if (this.state.activeMaterialId === null) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
layout: clampMaterialsLayout(event.layout),
};
return this.state;
}
case 'zoomTool.set': {
const tool: MaterialsZoomTool = event.tool;
this.state = {
...this.state,
revision: this.state.revision + 1,
zoomTool: tool,
};
return this.state;
}
case 'zoomAt': {
if (this.state.activeMaterialId === null || !this.state.zoomTool) return this.state;
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
const layout: MaterialsOverlayLayout = zoomMaterialsLayoutAt(
this.state.layout,
event.nx,
event.ny,
factor,
);
this.state = {
...this.state,
revision: this.state.revision + 1,
layout,
};
return this.state;
}
default:
return this.state;
}
}
ensureMaterialStillExists(materialIds: ReadonlySet<MaterialId>): MaterialsOverlayState {
const active = this.state.activeMaterialId;
if (active === null || materialIds.has(active)) return this.state;
return this.clear();
}
}
+5 -2
View File
@@ -22,9 +22,10 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
},
},
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
} as unknown as Project;
const s = collectReferencedAssetIds(p);
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'pr', 'th', 'v1'].sort());
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'mat1', 'pr', 'th', 'v1'].sort());
});
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
@@ -65,12 +66,14 @@ void test('reconcileAssetFiles: снимает осиротевшие assets и
...base,
scenes: {},
campaignAudios: [],
materials: [],
assets: { orphan: asset } as Project['assets'],
};
const next: Project = {
...base,
scenes: {},
campaignAudios: [],
materials: [],
assets: { orphan: asset } as Project['assets'],
};
@@ -115,7 +118,7 @@ void test('reconcileAssetFiles: удаляет файл при исключен
} as unknown as Project;
const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] };
const next: Project = { ...base, campaignAudios: [], assets: {} as Project['assets'] };
const next: Project = { ...base, campaignAudios: [], materials: [], assets: {} as Project['assets'] };
const out = await reconcileAssetFiles(prev, next, tmp);
assert.deepEqual(out.assets, {});
+1
View File
@@ -14,6 +14,7 @@ export function collectReferencedAssetIds(p: Project): Set<AssetId> {
for (const au of sc.media.audios) refs.add(au.assetId);
}
for (const au of p.campaignAudios) refs.add(au.assetId);
for (const m of p.materials ?? []) refs.add(m.assetId);
return refs;
}
+151 -2
View File
@@ -43,8 +43,8 @@ import type {
SceneId,
} from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import type { AssetId, GraphNodeId } from '../../shared/types/ids';
import { asAssetId, asGraphNodeId, asProjectId } from '../../shared/types/ids';
import type { AssetId, GraphNodeId, MaterialId } from '../../shared/types/ids';
import { asAssetId, asGraphNodeId, asMaterialId, asProjectId } from '../../shared/types/ids';
import { getAppSemanticVersion } from '../versionInfo';
import { reconcileAssetFiles } from './assetPrune';
@@ -225,6 +225,7 @@ export class ZipProjectStore {
sceneListOrder: [],
assets: {},
campaignAudios: [],
materials: [],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
@@ -1002,6 +1003,137 @@ export class ZipProjectStore {
return latest;
}
/**
* Создаёт или обновляет материал кампании.
* При создании `filePath` обязателен; при обновлении можно сменить только имя или только картинку.
*/
async upsertMaterial(input: {
materialId?: MaterialId;
name: string;
filePath?: string;
}): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const name = input.name.trim();
if (name.length < 1) throw new Error('Material name is required');
const nameKey = name.toLowerCase();
const existing = open.project.materials ?? [];
const editingId = input.materialId ?? null;
if (existing.some((m) => m.id !== editingId && m.name.trim().toLowerCase() === nameKey)) {
throw new Error('Material name already exists');
}
let nextAssetId: AssetId | null = null;
let stagedAsset: MediaAsset | null = null;
if (input.filePath) {
const kind = classifyMediaPath(input.filePath);
if (kind?.type !== 'image') throw new Error('Material must be an image (png/jpg/webp)');
const ext = path.extname(input.filePath).toLowerCase();
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
throw new Error('Material must be an image (png/jpg/webp)');
}
let buf = await fs.readFile(input.filePath);
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
// keep original buffer
}
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId());
const orig = path.basename(input.filePath);
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, buf);
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
nextAssetId = id;
}
await this.updateProject((p) => {
const materials = [...(p.materials ?? [])];
const assets = { ...p.assets };
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
if (editingId) {
const idx = materials.findIndex((m) => m.id === editingId);
if (idx < 0) throw new Error('Material not found');
const prev = materials[idx]!;
const assetId = nextAssetId ?? prev.assetId;
materials[idx] = {
id: editingId,
name,
assetId,
rotationDeg: prev.rotationDeg ?? 0,
};
} else {
if (!nextAssetId) throw new Error('Material image is required');
materials.push({
id: asMaterialId(`mat_${this.randomId()}`),
name,
assetId: nextAssetId,
rotationDeg: 0,
});
}
return { ...p, assets, materials };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setMaterialRotation(
materialId: MaterialId,
rotationDeg: 0 | 90 | 180 | 270,
): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const materials = (p.materials ?? []).map((m) =>
m.id === materialId ? { ...m, rotationDeg } : m,
);
return { ...p, materials };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async deleteMaterial(materialId: MaterialId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
materials: (p.materials ?? []).filter((m) => m.id !== materialId),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setMaterialsOrder(materialIds: MaterialId[]): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const byId = new Map((p.materials ?? []).map((m) => [m.id, m]));
const next: NonNullable<Project['materials']> = [];
for (const id of materialIds) {
const m = byId.get(id);
if (m) {
next.push(m);
byId.delete(id);
}
}
for (const m of byId.values()) next.push(m);
return { ...p, materials: next };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async saveNow(): Promise<void> {
const open = this.openProject;
if (!open) return;
@@ -1717,6 +1849,22 @@ function normalizeProject(p: Project): Project {
return null;
})
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
const rawMaterials = (p as unknown as { materials?: unknown[] }).materials;
const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
.map((m) => {
if (!m || typeof m !== 'object') return null;
const obj = m as { id?: string; name?: string; assetId?: AssetId; rotationDeg?: number };
if (!obj.id || !obj.assetId || typeof obj.name !== 'string') return null;
const name = obj.name.trim();
if (!name) return null;
const rot = obj.rotationDeg;
const rotationDeg: 0 | 90 | 180 | 270 = rot === 90 || rot === 180 || rot === 270 ? rot : 0;
return { id: asMaterialId(String(obj.id)), name, assetId: obj.assetId, rotationDeg };
})
.filter(
(x): x is { id: MaterialId; name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270 } =>
Boolean(x),
);
const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string };
const createdWithAppVersion = (() => {
const c = metaRaw.createdWithAppVersion?.trim();
@@ -1737,6 +1885,7 @@ function normalizeProject(p: Project): Project {
},
scenes,
campaignAudios,
materials,
sceneGraphNodes,
sceneGraphEdges,
currentGraphNodeId,
@@ -39,11 +39,19 @@ void test('createWindows: окно описания сцены закрывае
const src = readCreateWindows();
assert.ok(src.includes('openSceneDescriptionWindow'));
assert.ok(src.includes('closeSceneDescriptionWindow'));
assert.ok(src.includes("createWindow('sceneDescription')"));
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/);
});
void test('createWindows: окно материалов закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openMaterialsWindow'));
assert.ok(src.includes('closeMaterialsWindow'));
assert.ok(src.includes("createWindow('materials'"));
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
});
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
const src = readCreateWindows();
assert.ok(src.includes('loadFile'));
+66 -2
View File
@@ -8,13 +8,17 @@ import { ipcChannels } from '../../shared/ipc/contracts';
import { getBootSplashWindow } from './bootWindow';
import { loadBrandingWindowIcon } from './brandingIcon';
type WindowKind = 'editor' | 'presentation' | 'control' | 'sceneDescription';
type WindowKind = 'editor' | 'presentation' | 'control' | 'sceneDescription' | 'materials';
const windows = new Map<WindowKind, BrowserWindow>();
let appQuitting = false;
let pendingSceneDescriptionHtml = '';
/** Окно материалов — только колонка списка. */
const MATERIALS_WINDOW_WIDTH = 300;
const MATERIALS_WINDOW_HEIGHT = 720;
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
function broadcastMultiWindowStateChanged(open: boolean): void {
for (const w of BrowserWindow.getAllWindows()) {
@@ -71,6 +75,8 @@ function pageNameForKind(kind: WindowKind): string {
return 'control.html';
case 'sceneDescription':
return 'sceneDescription.html';
case 'materials':
return 'materials.html';
}
}
@@ -137,6 +143,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
if (kind === 'editor') return { width: 1280, height: 800 };
if (kind === 'control') return { width: 1200, height: 800 };
if (kind === 'sceneDescription') return { width: 720, height: 640 };
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
return { width: 1280, height: 800 };
}
@@ -154,6 +161,16 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
autoHideMenuBar: true,
}
: {}),
...(kind === 'materials'
? {
width: MATERIALS_WINDOW_WIDTH,
height: MATERIALS_WINDOW_HEIGHT,
minWidth: 260,
maxWidth: 360,
minHeight: 480,
autoHideMenuBar: true,
}
: {}),
show: false,
backgroundColor: '#09090B',
...(icon ? { icon } : {}),
@@ -178,7 +195,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
}
win.setTitle(windowChromeTitle(kind, app.getLocale()));
if (kind === 'sceneDescription') {
if (kind === 'sceneDescription' || kind === 'materials') {
win.setMenuBarVisibility(false);
}
@@ -209,6 +226,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
const open = windows.has('presentation') || windows.has('control');
if (!open) {
closeSceneDescriptionWindow();
closeMaterialsWindow();
}
broadcastMultiWindowStateChanged(open);
});
@@ -293,6 +311,7 @@ export function openMultiWindow() {
export function closeMultiWindow(): void {
closeSceneDescriptionWindow();
closeMaterialsWindow();
const pres = windows.get('presentation');
const ctrl = windows.get('control');
if (pres) pres.close();
@@ -310,6 +329,13 @@ export function closeSceneDescriptionWindow(): void {
}
}
export function closeMaterialsWindow(): void {
const win = windows.get('materials');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function getSceneDescriptionContent(): string {
return pendingSceneDescriptionHtml;
}
@@ -349,6 +375,44 @@ export function openSceneDescriptionWindow(html: string): void {
});
}
/** Полоса материалов: фиксированная ширина плитки, переиспользование окна. */
export function openMaterialsWindow(): void {
const existing = windows.get('materials');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
const b = existing.getBounds();
existing.setBounds({
x: b.x,
y: b.y,
width: MATERIALS_WINDOW_WIDTH,
height: MATERIALS_WINDOW_HEIGHT,
});
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('materials', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, dw - width - 24)),
y: Math.round(y + (dh - height) / 2),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
export function togglePresentationFullscreen(): boolean {
const pres = windows.get('presentation');
if (!pres) return false;