e687303c57
Warm and show the NPC window sooner, lazy-load ReactFlow/TipTap, overlay save progress like materials, and fix the undefined controlStyles crash after creating an NPC. Co-authored-by: Cursor <cursoragent@cursor.com>
660 lines
21 KiB
TypeScript
660 lines
21 KiB
TypeScript
import path from 'node:path';
|
||
|
||
import { app, BrowserWindow, screen } from 'electron';
|
||
|
||
import { windowChromeTitle } from '../../shared/appBranding';
|
||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||
|
||
import { safeConsoleError } from '../safeConsole';
|
||
|
||
import { getBootSplashWindow } from './bootWindow';
|
||
import { loadBrandingWindowIcon } from './brandingIcon';
|
||
|
||
export type WindowKind =
|
||
| 'editor'
|
||
| 'presentation'
|
||
| 'control'
|
||
| 'sceneDescription'
|
||
| 'materials'
|
||
| 'npcsEditor'
|
||
| 'sceneEditor'
|
||
| 'npcs';
|
||
|
||
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
||
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
||
'presentation',
|
||
'control',
|
||
'materials',
|
||
'npcs',
|
||
'npcsEditor',
|
||
'sceneEditor',
|
||
] as const;
|
||
|
||
const windows = new Map<WindowKind, BrowserWindow>();
|
||
|
||
let appQuitting = false;
|
||
let pendingSceneDescriptionHtml = '';
|
||
|
||
/** Окно материалов — только колонка списка. */
|
||
const MATERIALS_WINDOW_WIDTH = 300;
|
||
const MATERIALS_WINDOW_HEIGHT = 720;
|
||
|
||
/** Редактор НПС — как основной редактор, шире инспектор. */
|
||
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
||
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
||
|
||
/** Пульт НПС: деталь слева + список справа. */
|
||
const NPCS_WINDOW_WIDTH = 720;
|
||
const NPCS_WINDOW_HEIGHT = 720;
|
||
|
||
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
|
||
function broadcastMultiWindowStateChanged(open: boolean): void {
|
||
for (const w of BrowserWindow.getAllWindows()) {
|
||
if (w.isDestroyed()) continue;
|
||
if (w.webContents.isDestroyed()) continue;
|
||
try {
|
||
w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open });
|
||
} catch {
|
||
/* окно могло закрыться между проверкой и send */
|
||
}
|
||
}
|
||
}
|
||
|
||
function sendSceneDescriptionContent(win: BrowserWindow, html: string): void {
|
||
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
|
||
try {
|
||
win.webContents.send(ipcChannels.windows.sceneDescriptionContent, { html });
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
|
||
export function markAppQuitting(): void {
|
||
appQuitting = true;
|
||
}
|
||
|
||
/** Точечная рассылка в известные окна приложения (без splash / чужих BrowserWindow). */
|
||
export function sendToAppWindows(
|
||
channel: string,
|
||
payload: unknown,
|
||
kinds: readonly WindowKind[] = SESSION_STATE_WINDOW_KINDS,
|
||
): void {
|
||
for (const kind of kinds) {
|
||
const win = windows.get(kind);
|
||
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) continue;
|
||
try {
|
||
win.webContents.send(channel, payload);
|
||
} catch {
|
||
/* окно могло закрыться между проверкой и send */
|
||
}
|
||
}
|
||
}
|
||
|
||
function quitAppFromEditorClose(): void {
|
||
markAppQuitting();
|
||
app.quit();
|
||
}
|
||
|
||
function isDev() {
|
||
return process.env.NODE_ENV === 'development' || process.env.VITE_DEV_SERVER_URL !== undefined;
|
||
}
|
||
|
||
/** Вне dev-сервера на Windows с `loadFile` + preload иногда ломается sandbox; оставляем изоляцию через preload/contextBridge. */
|
||
function shouldUseRendererSandbox(): boolean {
|
||
if (process.env.VITE_DEV_SERVER_URL) return true;
|
||
return process.platform !== 'win32';
|
||
}
|
||
|
||
function getRendererHtmlPath(kind: WindowKind): string {
|
||
return path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`);
|
||
}
|
||
|
||
function pageNameForKind(kind: WindowKind): string {
|
||
switch (kind) {
|
||
case 'editor':
|
||
return 'editor.html';
|
||
case 'presentation':
|
||
return 'presentation.html';
|
||
case 'control':
|
||
return 'control.html';
|
||
case 'sceneDescription':
|
||
return 'sceneDescription.html';
|
||
case 'materials':
|
||
return 'materials.html';
|
||
case 'npcsEditor':
|
||
return 'npcsEditor.html';
|
||
case 'sceneEditor':
|
||
return 'sceneEditor.html';
|
||
case 'npcs':
|
||
return 'npcs.html';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* В production `loadURL(file://…)` на Windows с asar иногда даёт чёрный экран;
|
||
* `loadFile` корректно открывает HTML из asar и на Windows, и на macOS.
|
||
*/
|
||
function loadWindowPage(win: BrowserWindow, kind: WindowKind): void {
|
||
const dev = process.env.VITE_DEV_SERVER_URL;
|
||
if (dev) {
|
||
void win.loadURL(new URL(pageNameForKind(kind), dev).toString());
|
||
return;
|
||
}
|
||
void win.loadFile(getRendererHtmlPath(kind));
|
||
}
|
||
|
||
function getPreloadPath(): string {
|
||
return path.join(app.getAppPath(), 'dist', 'preload', 'index.cjs');
|
||
}
|
||
|
||
/** macOS: в Dock — тот же растр, что и у окон (ICO/PNG из brandingIcon). */
|
||
export function applyDockIconIfNeeded(): void {
|
||
if (process.platform !== 'darwin' || !app.dock) return;
|
||
const icon = loadBrandingWindowIcon();
|
||
if (!icon || icon.isEmpty()) return;
|
||
try {
|
||
app.dock.setIcon(icon);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
type CreateWindowOpts = {
|
||
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
||
parent?: BrowserWindow;
|
||
/** Не показывать окно до явного `show()` (экран загрузки / прогрев НПС). */
|
||
deferVisibility?: boolean;
|
||
};
|
||
|
||
/**
|
||
* Только `ready-to-show` на части систем (первый холодный старт Windows) не приходит вовремя —
|
||
* окно остаётся с `show: false` и кажется «зависшим». Дублируем показ по `did-finish-load` и таймауту.
|
||
*/
|
||
function ensureWindowBecomesVisible(win: BrowserWindow): void {
|
||
let shown = false;
|
||
const showOnce = (): void => {
|
||
if (shown) return;
|
||
if (win.isDestroyed()) return;
|
||
shown = true;
|
||
win.show();
|
||
};
|
||
|
||
win.once('ready-to-show', showOnce);
|
||
win.webContents.once('did-finish-load', () => {
|
||
showOnce();
|
||
});
|
||
const safetyTimer = setTimeout(showOnce, 8000);
|
||
win.once('closed', () => {
|
||
clearTimeout(safetyTimer);
|
||
});
|
||
}
|
||
|
||
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 };
|
||
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
||
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
||
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
||
return { width: 1280, height: 800 };
|
||
}
|
||
|
||
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
||
const deferShow = opts?.deferVisibility === true;
|
||
const icon = loadBrandingWindowIcon();
|
||
const size = windowSizeForKind(kind);
|
||
const win = new BrowserWindow({
|
||
width: size.width,
|
||
height: size.height,
|
||
...(kind === 'sceneDescription'
|
||
? {
|
||
minWidth: 520,
|
||
minHeight: 420,
|
||
autoHideMenuBar: true,
|
||
}
|
||
: {}),
|
||
...(kind === 'materials'
|
||
? {
|
||
width: MATERIALS_WINDOW_WIDTH,
|
||
height: MATERIALS_WINDOW_HEIGHT,
|
||
minWidth: 260,
|
||
maxWidth: 360,
|
||
minHeight: 480,
|
||
autoHideMenuBar: true,
|
||
}
|
||
: {}),
|
||
...(kind === 'npcsEditor'
|
||
? {
|
||
width: NPCS_EDITOR_WINDOW_WIDTH,
|
||
height: NPCS_EDITOR_WINDOW_HEIGHT,
|
||
minWidth: 1100,
|
||
minHeight: 640,
|
||
autoHideMenuBar: true,
|
||
}
|
||
: {}),
|
||
...(kind === 'sceneEditor'
|
||
? {
|
||
width: 1280,
|
||
height: 800,
|
||
minWidth: 960,
|
||
minHeight: 600,
|
||
autoHideMenuBar: true,
|
||
}
|
||
: {}),
|
||
...(kind === 'npcs'
|
||
? {
|
||
width: NPCS_WINDOW_WIDTH,
|
||
height: NPCS_WINDOW_HEIGHT,
|
||
minWidth: 560,
|
||
maxWidth: 900,
|
||
minHeight: 480,
|
||
autoHideMenuBar: true,
|
||
}
|
||
: {}),
|
||
show: false,
|
||
backgroundColor: '#09090B',
|
||
...(icon ? { icon } : {}),
|
||
...(opts?.parent ? { parent: opts.parent } : {}),
|
||
webPreferences: {
|
||
contextIsolation: true,
|
||
sandbox: shouldUseRendererSandbox(),
|
||
nodeIntegration: false,
|
||
devTools: isDev() || process.env.DND_OPEN_DEVTOOLS === '1',
|
||
preload: getPreloadPath(),
|
||
autoplayPolicy: 'no-user-gesture-required',
|
||
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
|
||
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
|
||
},
|
||
});
|
||
if (icon) {
|
||
try {
|
||
win.setIcon(icon);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
win.setTitle(windowChromeTitle(kind, app.getLocale()));
|
||
if (
|
||
kind === 'sceneDescription' ||
|
||
kind === 'materials' ||
|
||
kind === 'npcsEditor' ||
|
||
kind === 'sceneEditor' ||
|
||
kind === 'npcs'
|
||
) {
|
||
win.setMenuBarVisibility(false);
|
||
}
|
||
|
||
win.webContents.on('preload-error', (_event, preloadPath, error) => {
|
||
safeConsoleError(`[preload-error] ${preloadPath}:`, error);
|
||
});
|
||
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
|
||
if (errorCode === -3) return;
|
||
if (!isMainFrame) return;
|
||
safeConsoleError(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
|
||
});
|
||
win.webContents.on('render-process-gone', (_event, details) => {
|
||
safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
|
||
});
|
||
|
||
if (!deferShow) {
|
||
ensureWindowBecomesVisible(win);
|
||
}
|
||
loadWindowPage(win, kind);
|
||
if (kind === 'editor') {
|
||
win.on('close', (e) => {
|
||
if (appQuitting) return;
|
||
e.preventDefault();
|
||
quitAppFromEditorClose();
|
||
});
|
||
}
|
||
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();
|
||
}
|
||
broadcastMultiWindowStateChanged(open);
|
||
});
|
||
windows.set(kind, win);
|
||
return win;
|
||
}
|
||
|
||
export function createWindows() {
|
||
if (!windows.has('editor')) {
|
||
createWindow('editor');
|
||
}
|
||
}
|
||
|
||
/** Редактор создаётся скрытым до окончания экрана загрузки. */
|
||
export function createEditorWindowDeferred(): BrowserWindow {
|
||
const existing = windows.get('editor');
|
||
if (existing) {
|
||
return existing;
|
||
}
|
||
return createWindow('editor', { deferVisibility: true });
|
||
}
|
||
|
||
/** Дождаться первой отрисовки редактора (готовность к показу без чёрного экрана). */
|
||
export function waitForEditorWindowReady(win: BrowserWindow): Promise<void> {
|
||
return new Promise<void>((resolve) => {
|
||
let settled = false;
|
||
const timer = setTimeout(() => {
|
||
if (!settled) {
|
||
settled = true;
|
||
resolve(undefined);
|
||
}
|
||
}, 35000);
|
||
const finish = (): void => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
resolve(undefined);
|
||
};
|
||
win.once('ready-to-show', finish);
|
||
win.webContents.once('did-finish-load', finish);
|
||
}).then(
|
||
() =>
|
||
new Promise<void>((r) => {
|
||
setTimeout(r, 120);
|
||
}),
|
||
);
|
||
}
|
||
|
||
export function focusEditorWindow(): void {
|
||
const splash = getBootSplashWindow();
|
||
if (splash && !splash.isDestroyed()) {
|
||
splash.focus();
|
||
return;
|
||
}
|
||
const win = windows.get('editor');
|
||
if (win) {
|
||
if (win.isMinimized()) win.restore();
|
||
win.show();
|
||
win.focus();
|
||
} else {
|
||
createWindows();
|
||
}
|
||
}
|
||
|
||
export function openMultiWindow() {
|
||
let presentation = windows.get('presentation');
|
||
if (!presentation) {
|
||
const display = screen.getPrimaryDisplay();
|
||
const { x, y, width, height } = display.bounds;
|
||
presentation = createWindow('presentation');
|
||
presentation.setBounds({ x, y, width, height });
|
||
presentation.setMenuBarVisibility(false);
|
||
presentation.maximize();
|
||
}
|
||
if (!windows.has('control')) {
|
||
// macOS: parent-child window binding moves child with the parent (unlike Windows behavior we want).
|
||
// Keep control window independent on darwin.
|
||
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
|
||
}
|
||
broadcastMultiWindowStateChanged(true);
|
||
}
|
||
|
||
export function closeMultiWindow(): void {
|
||
closeSceneDescriptionWindow();
|
||
closeMaterialsWindow();
|
||
closeNpcsWindow();
|
||
const pres = windows.get('presentation');
|
||
const ctrl = windows.get('control');
|
||
if (pres) pres.close();
|
||
if (ctrl) ctrl.close();
|
||
}
|
||
|
||
export function isMultiWindowOpen(): boolean {
|
||
return windows.has('presentation') || windows.has('control');
|
||
}
|
||
|
||
export function closeSceneDescriptionWindow(): void {
|
||
const win = windows.get('sceneDescription');
|
||
if (win && !win.isDestroyed()) {
|
||
win.close();
|
||
}
|
||
}
|
||
|
||
export function closeMaterialsWindow(): void {
|
||
const win = windows.get('materials');
|
||
if (win && !win.isDestroyed()) {
|
||
win.close();
|
||
}
|
||
}
|
||
|
||
export function closeNpcsEditorWindow(): void {
|
||
const win = windows.get('npcsEditor');
|
||
if (win && !win.isDestroyed()) {
|
||
win.close();
|
||
}
|
||
}
|
||
|
||
export function closeSceneEditorWindow(): void {
|
||
const win = windows.get('sceneEditor');
|
||
if (win && !win.isDestroyed()) {
|
||
win.close();
|
||
}
|
||
}
|
||
|
||
export function closeNpcsWindow(): void {
|
||
const win = windows.get('npcs');
|
||
if (win && !win.isDestroyed()) {
|
||
win.close();
|
||
}
|
||
}
|
||
|
||
export function getSceneDescriptionContent(): string {
|
||
return pendingSceneDescriptionHtml;
|
||
}
|
||
|
||
/** Одно окно описания: переиспользовать существующее или создать новое. */
|
||
export function openSceneDescriptionWindow(html: string): void {
|
||
pendingSceneDescriptionHtml = html;
|
||
const existing = windows.get('sceneDescription');
|
||
if (existing && !existing.isDestroyed()) {
|
||
if (existing.isMinimized()) existing.restore();
|
||
existing.show();
|
||
existing.focus();
|
||
existing.moveTop();
|
||
sendSceneDescriptionContent(existing, html);
|
||
return;
|
||
}
|
||
|
||
// Держим поверх пульта/презентации, иначе окно уходит под полноэкранный экран просмотра.
|
||
const parent = windows.get('control') ?? windows.get('presentation');
|
||
const win = createWindow('sceneDescription', 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 + (dw - width) / 2),
|
||
y: Math.round(y + (dh - height) / 2),
|
||
width,
|
||
height,
|
||
});
|
||
win.webContents.once('did-finish-load', () => {
|
||
sendSceneDescriptionContent(win, pendingSceneDescriptionHtml);
|
||
if (!win.isDestroyed()) {
|
||
win.show();
|
||
win.focus();
|
||
win.moveTop();
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Полоса материалов: фиксированная ширина плитки, переиспользование окна. */
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
|
||
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
||
const parent = windows.get('editor');
|
||
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) / 2)),
|
||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||
width,
|
||
height,
|
||
});
|
||
}
|
||
|
||
/** Прогрев окна НПС в фоне после открытия проекта — клик «НПС» не ждёт холодной загрузки. */
|
||
export function warmNpcsEditorWindow(): void {
|
||
const existing = windows.get('npcsEditor');
|
||
if (existing && !existing.isDestroyed()) return;
|
||
const parent = windows.get('editor');
|
||
createWindow('npcsEditor', {
|
||
...(parent ? { parent } : {}),
|
||
deferVisibility: true,
|
||
});
|
||
}
|
||
|
||
/** Редактор НПС: отдельное окно с графом и инспектором. */
|
||
export function openNpcsEditorWindow(): void {
|
||
const existing = windows.get('npcsEditor');
|
||
if (existing && !existing.isDestroyed()) {
|
||
if (existing.isMinimized()) existing.restore();
|
||
positionNpcsEditorWindow(existing);
|
||
existing.show();
|
||
existing.focus();
|
||
existing.moveTop();
|
||
return;
|
||
}
|
||
|
||
const parent = windows.get('editor');
|
||
const win = createWindow('npcsEditor', {
|
||
...(parent ? { parent } : {}),
|
||
deferVisibility: true,
|
||
});
|
||
positionNpcsEditorWindow(win);
|
||
// Показываем сразу (тёмный фон), не дожидаясь полной загрузки React/ReactFlow.
|
||
win.show();
|
||
win.focus();
|
||
win.moveTop();
|
||
win.webContents.once('did-finish-load', () => {
|
||
if (!win.isDestroyed()) {
|
||
win.focus();
|
||
win.moveTop();
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Редактор сцены: расстановка ловушек и легенды материалов. */
|
||
export function openSceneEditorWindow(): void {
|
||
const existing = windows.get('sceneEditor');
|
||
if (existing && !existing.isDestroyed()) {
|
||
if (existing.isMinimized()) existing.restore();
|
||
existing.show();
|
||
existing.focus();
|
||
existing.moveTop();
|
||
return;
|
||
}
|
||
|
||
const parent = windows.get('editor');
|
||
const win = createWindow('sceneEditor', 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) / 2)),
|
||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||
width,
|
||
height,
|
||
});
|
||
win.webContents.once('did-finish-load', () => {
|
||
if (!win.isDestroyed()) {
|
||
win.show();
|
||
win.focus();
|
||
win.moveTop();
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Пульт НПС: список + описание выбранного; оверлей аватара на сцене. */
|
||
export function openNpcsWindow(): void {
|
||
const existing = windows.get('npcs');
|
||
if (existing && !existing.isDestroyed()) {
|
||
if (existing.isMinimized()) existing.restore();
|
||
const b = existing.getBounds();
|
||
existing.setBounds({
|
||
x: b.x,
|
||
y: b.y,
|
||
width: Math.min(NPCS_WINDOW_WIDTH, Math.max(560, b.width)),
|
||
height: Math.max(NPCS_WINDOW_HEIGHT, b.height),
|
||
});
|
||
existing.show();
|
||
existing.focus();
|
||
existing.moveTop();
|
||
return;
|
||
}
|
||
|
||
const parent = windows.get('control') ?? windows.get('presentation');
|
||
const win = createWindow('npcs', 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;
|
||
const next = !pres.isFullScreen();
|
||
pres.setFullScreen(next);
|
||
return pres.isFullScreen();
|
||
}
|