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:
Ivan Fontosh
2026-07-15 09:49:40 +08:00
parent 35a6e979eb
commit cfa3959fb3
26 changed files with 1795 additions and 60 deletions
+14
View File
@@ -29,12 +29,15 @@ import {
import {
applyDockIconIfNeeded,
closeMultiWindow,
closeSceneDescriptionWindow,
createEditorWindowDeferred,
createWindows,
focusEditorWindow,
getSceneDescriptionContent,
isMultiWindowOpen,
markAppQuitting,
openMultiWindow,
openSceneDescriptionWindow,
togglePresentationFullscreen,
waitForEditorWindowReady,
} from './windows/createWindows';
@@ -308,6 +311,17 @@ async function main() {
registerHandler(ipcChannels.windows.getMultiWindowState, () => {
return { open: isMultiWindowOpen() };
});
registerHandler(ipcChannels.windows.openSceneDescription, ({ html }) => {
openSceneDescriptionWindow(html);
return { ok: true };
});
registerHandler(ipcChannels.windows.closeSceneDescription, () => {
closeSceneDescriptionWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.getSceneDescriptionContent, () => {
return { html: getSceneDescriptionContent() };
});
registerHandler(ipcChannels.project.list, async () => {
const projects = await projectStore.listProjects();
+1
View File
@@ -18,6 +18,7 @@ function channelRequiresLicense(channel: string): boolean {
if (channel.startsWith('license.')) return false;
if (channel.startsWith('app.')) return false;
if (channel === ipcChannels.windows.closeMultiWindow) return false;
if (channel === ipcChannels.windows.closeSceneDescription) return false;
if (channel === ipcChannels.windows.togglePresentationFullscreen) return false;
// Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать».
if (channel === ipcChannels.project.list) return false;
@@ -35,6 +35,15 @@ void test('createWindows: пульт поверх экрана просмотр
assert.ok(src.includes("createWindow('control'"));
});
void test('createWindows: окно описания сцены закрывается с multi-window и отдельно', () => {
const src = readCreateWindows();
assert.ok(src.includes('openSceneDescriptionWindow'));
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/);
});
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
const src = readCreateWindows();
assert.ok(src.includes('loadFile'));
+95 -6
View File
@@ -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;