feat(scenes): rich scene descriptions and control viewer window
Add TipTap editing in the editor and an Electron window on the control panel to read the current scene description. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,11 +8,12 @@ import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { getBootSplashWindow } from './bootWindow';
|
||||
import { loadBrandingWindowIcon } from './brandingIcon';
|
||||
|
||||
type WindowKind = 'editor' | 'presentation' | 'control';
|
||||
type WindowKind = 'editor' | 'presentation' | 'control' | 'sceneDescription';
|
||||
|
||||
const windows = new Map<WindowKind, BrowserWindow>();
|
||||
|
||||
let appQuitting = false;
|
||||
let pendingSceneDescriptionHtml = '';
|
||||
|
||||
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
|
||||
function broadcastMultiWindowStateChanged(open: boolean): void {
|
||||
@@ -27,6 +28,15 @@ function broadcastMultiWindowStateChanged(open: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -51,6 +61,19 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* В production `loadURL(file://…)` на Windows с asar иногда даёт чёрный экран;
|
||||
* `loadFile` корректно открывает HTML из asar и на Windows, и на macOS.
|
||||
@@ -58,9 +81,7 @@ function getRendererHtmlPath(kind: WindowKind): string {
|
||||
function loadWindowPage(win: BrowserWindow, kind: WindowKind): void {
|
||||
const dev = process.env.VITE_DEV_SERVER_URL;
|
||||
if (dev) {
|
||||
const page =
|
||||
kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html';
|
||||
void win.loadURL(new URL(page, dev).toString());
|
||||
void win.loadURL(new URL(pageNameForKind(kind), dev).toString());
|
||||
return;
|
||||
}
|
||||
void win.loadFile(getRendererHtmlPath(kind));
|
||||
@@ -112,12 +133,27 @@ function ensureWindowBecomesVisible(win: BrowserWindow): void {
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
return { width: 1280, height: 800 };
|
||||
}
|
||||
|
||||
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
||||
const deferEditor = kind === 'editor' && opts?.deferVisibility === true;
|
||||
const icon = loadBrandingWindowIcon();
|
||||
const size = windowSizeForKind(kind);
|
||||
const win = new BrowserWindow({
|
||||
width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280,
|
||||
height: 800,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
...(kind === 'sceneDescription'
|
||||
? {
|
||||
minWidth: 520,
|
||||
minHeight: 420,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
show: false,
|
||||
backgroundColor: '#09090B',
|
||||
...(icon ? { icon } : {}),
|
||||
@@ -142,6 +178,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
}
|
||||
|
||||
win.setTitle(windowChromeTitle(kind, app.getLocale()));
|
||||
if (kind === 'sceneDescription') {
|
||||
win.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
win.webContents.on('preload-error', (_event, preloadPath, error) => {
|
||||
console.error(`[preload-error] ${preloadPath}:`, error);
|
||||
@@ -168,6 +207,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
win.on('closed', () => {
|
||||
if (kind !== 'presentation' && kind !== 'control') return;
|
||||
const open = windows.has('presentation') || windows.has('control');
|
||||
if (!open) {
|
||||
closeSceneDescriptionWindow();
|
||||
}
|
||||
broadcastMultiWindowStateChanged(open);
|
||||
});
|
||||
windows.set(kind, win);
|
||||
@@ -250,6 +292,7 @@ export function openMultiWindow() {
|
||||
}
|
||||
|
||||
export function closeMultiWindow(): void {
|
||||
closeSceneDescriptionWindow();
|
||||
const pres = windows.get('presentation');
|
||||
const ctrl = windows.get('control');
|
||||
if (pres) pres.close();
|
||||
@@ -260,6 +303,52 @@ 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 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 togglePresentationFullscreen(): boolean {
|
||||
const pres = windows.get('presentation');
|
||||
if (!pres) return false;
|
||||
|
||||
Reference in New Issue
Block a user