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();
}
+18
View File
@@ -59,6 +59,7 @@ import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/Edito
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals';
import { LaunchPlayersModal } from './LaunchPlayersModal';
import { PresentationSettingsModal } from './PresentationSettingsModal';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState';
@@ -160,6 +161,7 @@ export function EditorApp() {
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
const [runMenuOpen, setRunMenuOpen] = useState(false);
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
const runSplitRef = useRef<HTMLDivElement | null>(null);
@@ -1320,6 +1322,18 @@ export function EditorApp() {
{t('menu.checkUpdates')}
</button>
) : null}
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setPresentationSettingsOpen(true);
}}
>
{t('menu.presentationSettings')}
</button>
<div className={styles.fileMenuSubHost} role="presentation">
<button
type="button"
@@ -1638,6 +1652,10 @@ export function EditorApp() {
}}
/>
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
<PresentationSettingsModal
open={presentationSettingsOpen}
onClose={() => setPresentationSettingsOpen(false)}
/>
{USERS_BRANCH_FEATURES_ENABLED ? (
<>
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
@@ -0,0 +1,71 @@
.dialog {
width: min(480px, calc(100vw - 48px));
}
.hint {
margin: 0 0 12px;
color: var(--text2);
font-size: var(--text-sm);
line-height: 1.4;
}
.section {
display: grid;
gap: 8px;
margin-bottom: 14px;
}
.sectionTitle {
font-size: 11px;
font-weight: 700;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.list {
display: grid;
gap: 4px;
max-height: 240px;
overflow: auto;
padding: 8px;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
background: var(--color-overlay-dark-3);
}
.row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
color: var(--text);
font-size: var(--text-sm);
line-height: 1.35;
}
.row:hover {
background: var(--accent-fill-soft);
}
.rowMeta {
color: var(--text2);
font-size: 12px;
}
.fullScreenRow {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 4px;
cursor: pointer;
font-size: var(--text-sm);
}
.loading {
padding: 10px 8px;
color: var(--text2);
font-size: var(--text-sm);
}
@@ -0,0 +1,139 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { DisplayInfo, PresentationSettings } from '../../shared/types/presentationSettings';
import { getDndApi } from '../shared/dndApi';
import { Button } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import modalStyles from './PresentationSettingsModal.module.css';
export function PresentationSettingsModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const { t } = useEditorI18n();
const api = getDndApi();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [displays, setDisplays] = useState<DisplayInfo[]>([]);
const [draft, setDraft] = useState<PresentationSettings>({ displayId: null, fullScreen: true });
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
void (async () => {
try {
const [{ displays: list }, { settings }] = await Promise.all([
api.invoke(ipcChannels.windows.listDisplays, {}),
api.invoke(ipcChannels.preferences.getPresentation, {}),
]);
if (cancelled) return;
setDisplays(list);
setDraft(settings);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [api, open]);
if (!open) return null;
const save = async () => {
setSaving(true);
try {
const primaryId = displays.find((d) => d.isPrimary)?.id ?? null;
const settings: PresentationSettings = {
...draft,
displayId: draft.displayId === primaryId ? null : draft.displayId,
};
await api.invoke(ipcChannels.preferences.setPresentation, { settings });
onClose();
} finally {
setSaving(false);
}
};
const isDisplaySelected = (d: DisplayInfo) =>
draft.displayId === d.id || (draft.displayId === null && d.isPrimary);
const displayLabel = (d: DisplayInfo) => {
const primary = d.isPrimary ? ` (${t('settings.presentation.primary')})` : '';
return `${d.label}${primary} — ${d.width}×${d.height}`;
};
return createPortal(
<>
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
<div
className={[styles.modalDialog, modalStyles.dialog].join(' ')}
role="dialog"
aria-modal="true"
data-testid="presentation-settings-modal"
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('settings.presentation.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div>
<p className={modalStyles.hint}>{t('settings.presentation.hint')}</p>
<section className={modalStyles.section}>
<div className={modalStyles.sectionTitle}>{t('settings.presentation.display')}</div>
{loading ? (
<div className={modalStyles.loading}>{t('settings.presentation.loading')}</div>
) : (
<div className={modalStyles.list} role="radiogroup" aria-label={t('settings.presentation.display')}>
{displays.map((d) => (
<label key={d.id} className={modalStyles.row}>
<input
type="radio"
name="presentation-display"
checked={isDisplaySelected(d)}
onChange={() => setDraft((prev) => ({ ...prev, displayId: d.id }))}
data-testid={`presentation-display-${d.id}`}
/>
<span>{displayLabel(d)}</span>
</label>
))}
</div>
)}
</section>
<label className={modalStyles.fullScreenRow}>
<input
type="checkbox"
checked={draft.fullScreen}
onChange={(e) => setDraft((prev) => ({ ...prev, fullScreen: e.target.checked }))}
data-testid="presentation-fullscreen"
/>
<span>{t('settings.presentation.fullScreen')}</span>
</label>
</div>
<div className={styles.modalFooter}>
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button variant="primary" onClick={() => void save()} disabled={loading || saving} data-testid="presentation-settings-save">
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}
+24 -6
View File
@@ -130,6 +130,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'menu.enterKey': 'Указать ключ',
'menu.aboutLicense': 'О лицензии',
'menu.checkUpdates': 'Проверить обновления',
'menu.presentationSettings': 'Экран презентации',
'menu.language': 'Язык',
'menu.langRu': 'Русский',
'menu.langEn': 'English',
@@ -215,7 +216,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.session.title': 'Запуск сессии',
'help.section.session.body':
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео, эффекты, токены и материалы — без ваших кнопок.',
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nЭкран для игроков можно заранее выбрать в «Настройки» → «Экран презентации»: монитор и полноэкранный режим. При «Запустить» окно презентации откроется на выбранном экране автоматически.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.',
'help.section.controlPanel.title': 'Пульт управления',
'help.section.controlPanel.body':
@@ -235,7 +236,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.presentation.title': 'Экран презентации',
'help.section.presentation.body':
'«Презентация» — то, что видят игроки: картинка или видео сцены (с учётом поворота из редактора) по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны. Токены на карте и большие аватары НПС с чекбокса «Показать» видны, но не интерактивны. Линию пути игроки видят только если мастер включил «Показать путь».\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» и при необходимости снова закрывает участки «Кистью Закрытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Перенесите окно на экран для игроков и при необходимости спрячьте панель задач.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.',
'«Презентация» — то, что видят игроки: картинка или видео сцены (с учётом поворота из редактора) по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны. Токены на карте и большие аватары НПС с чекбокса «Показать» видны, но не интерактивны. Линию пути игроки видят только если мастер включил «Показать путь».\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» и при необходимости снова закрывает участки «Кистью Закрытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Монитор и полноэкранный режим задаются в «Настройки» → «Экран презентации»; при запуске сессии окно открывается на выбранном экране.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.',
'help.section.importExport.title': 'Импорт и экспорт',
'help.section.importExport.body':
@@ -243,7 +244,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.settings.title': 'Настройки, язык и обновления',
'help.section.settings.body':
'«Настройки» → «Указать ключ» — ввести или сменить лицензию. «О лицензии» — проверить статус и срок.\n\n«Проверить обновления» (в установленной версии при активной лицензии) — если есть новая версия, можно скачать и перезапустить программу.\n\n«Язык» → «Русский» или «English» — меняет язык интерфейса. Выбор сохраняется между запусками.\n\nНомер версии — в шапке справа от меню. «О приложении» → «О программе» — о продукте и контактах поддержки.',
'«Настройки» → «Указать ключ» — ввести или сменить лицензию. «О лицензии» — проверить статус и срок.\n\n«Экран презентации» — выбрать монитор для окна игроков и включить или выключить полноэкранный режим. Настройки сохраняются и применяются при каждом «Запустить».\n\n«Проверить обновления» (в установленной версии при активной лицензии) — если есть новая версия, можно скачать и перезапустить программу.\n\n«Язык» → «Русский» или «English» — меняет язык интерфейса. Выбор сохраняется между запусками.\n\nНомер версии — в шапке справа от меню. «О приложении» → «О программе» — о продукте и контактах поддержки.',
'settings.presentation.title': 'Экран презентации',
'settings.presentation.hint':
'При запуске сессии окно «Презентация» откроется на выбранном мониторе. Пульт управления всегда открывается на основном экране.',
'settings.presentation.display': 'Монитор',
'settings.presentation.fullScreen': 'Полноэкранный режим',
'settings.presentation.primary': 'основной',
'settings.presentation.loading': 'Загрузка списка мониторов…',
'updates.dialogTitle': 'Обновления',
'updates.checking': 'Проверка наличия обновлений…',
@@ -770,6 +779,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'menu.enterKey': 'Enter license key',
'menu.aboutLicense': 'About license',
'menu.checkUpdates': 'Check for updates',
'menu.presentationSettings': 'Presentation display',
'menu.language': 'Language',
'menu.langRu': 'Русский',
'menu.langEn': 'English',
@@ -855,7 +865,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.session.title': 'Starting a session',
'help.section.session.body':
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Turn off or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, effects, tokens, and materials — not your buttons.',
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nChoose the player screen ahead of time in Settings → Presentation display: pick a monitor and fullscreen mode. When you click Run, the presentation window opens on that display automatically.\n\nReturn to prep:\n\n1) On the control panel, click Turn off or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.',
'help.section.controlPanel.title': 'Control panel',
'help.section.controlPanel.body':
@@ -875,7 +885,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.presentation.title': 'Presentation screen',
'help.section.presentation.body':
'Presentation is what players see: the scene image or video (with rotation from the editor) according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens. Map tokens and large NPC avatars from the Show checkbox are visible but not interactive. Players see a path line only if the GM turned on Show path.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush and can cover areas again with the Closing brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Move the window to the display players watch and hide the taskbar if needed.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.',
'Presentation is what players see: the scene image or video (with rotation from the editor) according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens. Map tokens and large NPC avatars from the Show checkbox are visible but not interactive. Players see a path line only if the GM turned on Show path.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush and can cover areas again with the Closing brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Monitor and fullscreen mode are set in Settings → Presentation display; when you start a session, the window opens on that display.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.',
'help.section.importExport.title': 'Import and export',
'help.section.importExport.body':
@@ -883,7 +893,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.settings.title': 'Settings, language and updates',
'help.section.settings.body':
'Settings → Enter license key — add or change your license. About license — check status and expiry.\n\nCheck for updates (installed app with active license) — if a new version exists, you can download and restart.\n\nLanguage → Русский or English changes the interface. Your choice is saved between launches.\n\nThe version number is in the header, right of the menus. About → About opens product info and support contacts.',
'Settings → Enter license key — add or change your license. About license — check status and expiry.\n\nPresentation display — choose the monitor for the player window and turn fullscreen on or off. Settings are saved and applied every time you click Run.\n\nCheck for updates (installed app with active license) — if a new version exists, you can download and restart.\n\nLanguage → Русский or English changes the interface. Your choice is saved between launches.\n\nThe version number is in the header, right of the menus. About → About opens product info and support contacts.',
'settings.presentation.title': 'Presentation display',
'settings.presentation.hint':
'When you start a session, the Presentation window opens on the selected monitor. The control panel always opens on your primary display.',
'settings.presentation.display': 'Display',
'settings.presentation.fullScreen': 'Fullscreen',
'settings.presentation.primary': 'primary',
'settings.presentation.loading': 'Loading displays…',
'updates.dialogTitle': 'Updates',
'updates.checking': 'Checking for updates…',
+19
View File
@@ -24,6 +24,8 @@ import type {
PlayerTeamId,
PlayerImageOffset,
PlayersUpsertProgressEvent,
PresentationSettings,
DisplayInfo,
Scene,
SceneDarknessEvent,
SceneDarknessState,
@@ -160,6 +162,11 @@ export const ipcChannels = {
syncChromeTitles: 'windows.syncChromeTitles',
getPresentationContentSize: 'windows.getPresentationContentSize',
presentationContentSizeChanged: 'windows.presentationContentSizeChanged',
listDisplays: 'windows.listDisplays',
},
preferences: {
getPresentation: 'preferences.getPresentation',
setPresentation: 'preferences.setPresentation',
},
session: {
stateChanged: 'session.stateChanged',
@@ -687,6 +694,18 @@ export type IpcInvokeMap = {
req: Record<string, never>;
res: { width: number; height: number } | { width: null; height: null };
};
[ipcChannels.windows.listDisplays]: {
req: Record<string, never>;
res: { displays: DisplayInfo[] };
};
[ipcChannels.preferences.getPresentation]: {
req: Record<string, never>;
res: { settings: PresentationSettings };
};
[ipcChannels.preferences.setPresentation]: {
req: { settings: PresentationSettings };
res: { settings: PresentationSettings };
};
[ipcChannels.windows.openSceneDescription]: {
req: { html: string };
res: { ok: true };
+1
View File
@@ -16,6 +16,7 @@ export * from './tokenPath';
export * from './tokenPathSession';
export * from './tokenPathPlayback';
export * from './scenePreviewRotation';
export * from './presentationSettings';
export * from './sceneTraps';
export * from './sceneView';
export * from './videoPlayback';
+19
View File
@@ -0,0 +1,19 @@
/** Настройки окна презентации (хранятся в userData/preferences.json). */
export type PresentationSettings = {
/** `null` — основной монитор системы. */
displayId: number | null;
fullScreen: boolean;
};
export type DisplayInfo = {
id: number;
label: string;
width: number;
height: number;
isPrimary: boolean;
};
export const DEFAULT_PRESENTATION_SETTINGS: PresentationSettings = {
displayId: null,
fullScreen: true,
};