feat(editor): настройки экрана презентации при запуске сессии

Выбор монитора и полноэкранного режима в настройках; при «Запустить» окно презентации открывается на выбранном дисплее автоматически.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 13:05:50 +08:00
parent 8b28a9b11a
commit 746b862cb9
13 changed files with 437 additions and 13 deletions
+12
View File
@@ -31,6 +31,7 @@ import { SceneMarkerStore } from './effects/sceneMarkerStore';
import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
import { LicenseService } from './license/licenseService';
import { readPresentationSettings, writePresentationSettings } from './preferences/appPreferences';
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { ZipProjectStore } from './project/zipStore';
@@ -81,6 +82,7 @@ import {
waitForEditorWindowReady,
warmNpcsEditorWindow,
} from './windows/createWindows';
import { listDisplayInfos } from './windows/presentationDisplay';
import { TokenPathSessionStore } from './tokens/tokenPathSessionStore';
import { tokenPathTotalLength } from '../shared/types/tokenPath';
import type { TokenPathTargetKind } from '../shared/types/tokenPathSession';
@@ -592,6 +594,16 @@ async function main() {
const size = getPresentationContentSize();
return size ?? { width: null, height: null };
});
registerHandler(ipcChannels.windows.listDisplays, () => {
return { displays: listDisplayInfos() };
});
registerHandler(ipcChannels.preferences.getPresentation, () => {
return { settings: readPresentationSettings(app.getPath('userData')) };
});
registerHandler(ipcChannels.preferences.setPresentation, ({ settings }) => {
const next = writePresentationSettings(app.getPath('userData'), settings);
return { settings: next };
});
registerHandler(ipcChannels.windows.openSceneDescription, ({ html }) => {
openSceneDescriptionWindow(html);
return { ok: true };
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { readPresentationSettings, writePresentationSettings } from './appPreferences';
void test('appPreferences: presentation settings round-trip without clobbering eula', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-prefs-'));
const prefsPath = path.join(dir, 'preferences.json');
fs.writeFileSync(prefsPath, `${JSON.stringify({ eulaAcceptedVersion: 2 }, null, 2)}\n`, 'utf8');
writePresentationSettings(dir, { displayId: 42, fullScreen: false });
const loaded = readPresentationSettings(dir);
assert.equal(loaded.displayId, 42);
assert.equal(loaded.fullScreen, false);
const raw = JSON.parse(fs.readFileSync(prefsPath, 'utf8')) as {
eulaAcceptedVersion?: number;
presentation?: { displayId: number | null; fullScreen: boolean };
};
assert.equal(raw.eulaAcceptedVersion, 2);
assert.equal(raw.presentation?.displayId, 42);
assert.equal(raw.presentation?.fullScreen, false);
});
void test('appPreferences: defaults when preferences missing', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-prefs-'));
const loaded = readPresentationSettings(dir);
assert.equal(loaded.displayId, null);
assert.equal(loaded.fullScreen, true);
});
+47
View File
@@ -0,0 +1,47 @@
import fs from 'node:fs';
import type { PresentationSettings } from '../../shared/types/presentationSettings';
import { DEFAULT_PRESENTATION_SETTINGS } from '../../shared/types/presentationSettings';
import { preferencesPath } from '../license/paths';
type StoredPreferences = {
eulaAcceptedVersion?: number;
presentation?: Partial<PresentationSettings>;
};
function readAll(userData: string): StoredPreferences {
try {
const raw = fs.readFileSync(preferencesPath(userData), 'utf8');
return JSON.parse(raw) as StoredPreferences;
} catch {
return {};
}
}
function writeAll(userData: string, prefs: StoredPreferences): void {
fs.mkdirSync(userData, { recursive: true });
fs.writeFileSync(preferencesPath(userData), `${JSON.stringify(prefs, null, 2)}\n`, 'utf8');
}
export function readPresentationSettings(userData: string): PresentationSettings {
const prefs = readAll(userData);
return {
...DEFAULT_PRESENTATION_SETTINGS,
...prefs.presentation,
};
}
export function writePresentationSettings(
userData: string,
patch: Partial<PresentationSettings>,
): PresentationSettings {
const prefs = readAll(userData);
const next: PresentationSettings = {
...DEFAULT_PRESENTATION_SETTINGS,
...prefs.presentation,
...patch,
};
prefs.presentation = next;
writeAll(userData, prefs);
return next;
}
+26 -6
View File
@@ -2,8 +2,12 @@ import path from 'node:path';
import { app, BrowserWindow, screen } from 'electron';
import type { PresentationSettings } from '../../shared/types/presentationSettings';
import { readPresentationSettings } from '../preferences/appPreferences';
import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding';
import { ipcChannels } from '../../shared/ipc/contracts';
import { resolvePresentationDisplay } from './presentationDisplay';
import { safeConsoleError } from '../safeConsole';
@@ -517,20 +521,36 @@ export function focusEditorWindow(): void {
}
}
export function openMultiWindow() {
export function openMultiWindow(presentationSettings?: PresentationSettings) {
const settings = presentationSettings ?? readPresentationSettings(app.getPath('userData'));
let presentation = windows.get('presentation');
if (!presentation) {
const display = screen.getPrimaryDisplay();
const display = resolvePresentationDisplay(settings.displayId);
const { x, y, width, height } = display.bounds;
presentation = createWindow('presentation');
presentation.setBounds({ x, y, width, height });
presentation.setMenuBarVisibility(false);
presentation.maximize();
if (settings.fullScreen) {
presentation.setFullScreen(true);
} else {
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 });
const editor = windows.get('editor');
const editorDisplay = editor
? screen.getDisplayMatching(editor.getBounds())
: screen.getPrimaryDisplay();
const control = createWindow('control');
const area = editorDisplay.workArea;
const cw = Math.min(1200, Math.max(640, area.width - 80));
const ch = Math.min(800, Math.max(480, area.height - 80));
control.setBounds({
x: area.x + Math.max(0, Math.round((area.width - cw) / 2)),
y: area.y + Math.max(0, Math.round((area.height - ch) / 2)),
width: cw,
height: ch,
});
}
broadcastMultiWindowStateChanged(true);
broadcastPresentationContentSize();
+27
View File
@@ -0,0 +1,27 @@
import { screen, type Display } from 'electron';
import type { DisplayInfo } from '../../shared/types/presentationSettings';
export function listDisplayInfos(): DisplayInfo[] {
const primaryId = screen.getPrimaryDisplay().id;
return screen.getAllDisplays().map((d) => displayToInfo(d, d.id === primaryId));
}
function displayToInfo(display: Display, isPrimary: boolean): DisplayInfo {
const label = display.label?.trim() || `Display ${String(display.id)}`;
return {
id: display.id,
label,
width: display.bounds.width,
height: display.bounds.height,
isPrimary,
};
}
export function resolvePresentationDisplay(displayId: number | null): Display {
if (displayId != null) {
const found = screen.getAllDisplays().find((d) => d.id === displayId);
if (found) return found;
}
return screen.getPrimaryDisplay();
}