Files
DndGamePlayer/app/main/windows/createWindows.ts
T
Ivan Fontosh 61446dacfc fix(ui): hide Electron menu bar and polish editor chrome
Drop the system View menu across windows, show the open project name above Game properties, and keep scene list kebab visible with truncated titles and overflow tooltips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:21:26 +08:00

656 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
autoHideMenuBar: true,
...(kind === 'sceneDescription'
? {
minWidth: 520,
minHeight: 420,
}
: {}),
...(kind === 'materials'
? {
width: MATERIALS_WINDOW_WIDTH,
height: MATERIALS_WINDOW_HEIGHT,
minWidth: 260,
maxWidth: 360,
minHeight: 480,
}
: {}),
...(kind === 'npcsEditor'
? {
width: NPCS_EDITOR_WINDOW_WIDTH,
height: NPCS_EDITOR_WINDOW_HEIGHT,
minWidth: 1100,
minHeight: 640,
}
: {}),
...(kind === 'sceneEditor'
? {
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
}
: {}),
...(kind === 'npcs'
? {
width: NPCS_WINDOW_WIDTH,
height: NPCS_WINDOW_HEIGHT,
minWidth: 560,
maxWidth: 900,
minHeight: 480,
}
: {}),
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();
}