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:
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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