fix(play): ask before Escape ends session

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-30 08:53:48 +08:00
parent 143d141aa6
commit d228b9aab7
11 changed files with 131 additions and 2 deletions
+5
View File
@@ -94,6 +94,7 @@ import {
closeSceneEditorWindow,
closeTokenPathEditorWindow,
closeNpcsWindow,
requestCloseMultiWindowConfirm,
sendToAppWindows,
setDiceWindowClosedListener,
setTimerWindowClosedListener,
@@ -756,6 +757,10 @@ async function main() {
emitTokenGridSnapState();
return { ok: true };
});
registerHandler(ipcChannels.windows.requestCloseMultiWindowConfirm, () => {
requestCloseMultiWindowConfirm();
return { ok: true };
});
registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => {
syncAllWindowChromeTitles(localeTag);
return { ok: true };
+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.requestCloseMultiWindowConfirm) return false;
if (channel === ipcChannels.windows.closeSceneDescription) return false;
if (channel === ipcChannels.windows.closeMaterials) return false;
if (channel === ipcChannels.windows.closeNpcs) return false;
@@ -52,6 +52,13 @@ void test('createWindows: окно материалов закрывается
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
});
void test('createWindows: Escape запрашивает confirm на пульте', () => {
const src = readCreateWindows();
assert.ok(src.includes('export function requestCloseMultiWindowConfirm'));
assert.ok(src.includes('closeMultiWindowConfirmRequested'));
assert.match(src, /requestCloseMultiWindowConfirm[\s\S]*ctrl\.focus\(\)/);
});
void test('createWindows: окно плейлистов — minWidth 640', () => {
const src = readCreateWindows();
assert.ok(src.includes('PLAYLISTS_WINDOW_MIN_WIDTH = 640'));
+10
View File
@@ -684,6 +684,16 @@ export function closeMultiWindow(): void {
}
}
/** Фокус пульта + событие: Escape с презентации спрашивает подтверждение там. */
export function requestCloseMultiWindowConfirm(): void {
const ctrl = windows.get('control');
if (!ctrl || ctrl.isDestroyed()) return;
if (ctrl.isMinimized()) ctrl.restore();
ctrl.show();
ctrl.focus();
ctrl.webContents.send(ipcChannels.windows.closeMultiWindowConfirmRequested, {});
}
export function isMultiWindowOpen(): boolean {
return windows.has('presentation') || windows.has('control');
}
+62
View File
@@ -203,6 +203,7 @@ export function ControlApp() {
y: number;
placementId: string;
} | null>(null);
const [stopConfirmOpen, setStopConfirmOpen] = useState(false);
const [sceneView, sceneViewApi] = useSceneViewState();
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
@@ -1155,6 +1156,27 @@ export function ControlApp() {
};
}, [npcMapSpawnDrag.dragging, npcMapSpawnDragApi, snapNormActive]);
useEffect(() => {
return api.on(ipcChannels.windows.closeMultiWindowConfirmRequested, () => {
setStopConfirmOpen(true);
});
}, [api]);
useEffect(() => {
if (npcMapSpawnDrag.dragging) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.preventDefault();
if (stopConfirmOpen) {
setStopConfirmOpen(false);
return;
}
setStopConfirmOpen(true);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [npcMapSpawnDrag.dragging, stopConfirmOpen]);
useEffect(() => {
if (!markersInteractive) {
setNpcSessionCtxMenu(null);
@@ -3670,6 +3692,46 @@ export function ControlApp() {
document.body,
)
: null}
{stopConfirmOpen
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => setStopConfirmOpen(false)}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog} data-testid="stop-confirm-modal">
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('control.stopConfirm.title')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => setStopConfirmOpen(false)}
>
×
</button>
</div>
<div>{t('control.stopConfirm.body')}</div>
<div className={styles.modalFooter}>
<Button onClick={() => setStopConfirmOpen(false)}>{t('common.cancel')}</Button>
<Button
variant="primary"
data-testid="stop-confirm-accept"
onClick={() => {
setStopConfirmOpen(false);
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
}}
>
{t('control.stopConfirm.confirm')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</div>
);
}
@@ -316,3 +316,13 @@ void test('ControlApp: загрузка камп. аудио — useEffect за
assert.ok(!/\bproject\b/.test(depList));
assert.ok(!/\bcampaignAudioRefs\b/.test(depList));
});
void test('ControlApp: Escape → своя модалка подтверждения завершения показа', () => {
const src = readControlApp();
assert.ok(src.includes('closeMultiWindowConfirmRequested'));
assert.ok(src.includes('stopConfirmOpen'));
assert.ok(src.includes('control.stopConfirm.title'));
assert.ok(src.includes('data-testid="stop-confirm-modal"'));
assert.ok(!src.includes('showMessageBox'));
assert.ok(!src.includes('window.confirm'));
});
@@ -762,6 +762,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.snapTokensToGrid': 'Привязка токенов к сетке',
'control.snapTokensToGridNoGrid': 'Сетка на текущей сцене выключена — привязка не применяется',
'control.stopPresentation': 'Выключить',
'control.stopConfirm.title': 'Завершить показ?',
'control.stopConfirm.body': 'Закрыть пульт и презентацию и вернуться в редактор?',
'control.stopConfirm.confirm': 'Завершить',
'control.showPlayers': 'Показать игроков',
'control.hidePlayers': 'Скрыть игроков',
'control.videoBrushHint':
@@ -1519,6 +1522,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.snapTokensToGrid': 'Snap tokens to grid',
'control.snapTokensToGridNoGrid': 'Grid is off on this scene — snap has no effect',
'control.stopPresentation': 'Turn off',
'control.stopConfirm.title': 'End presentation?',
'control.stopConfirm.body': 'Close the control panel and presentation and return to the editor?',
'control.stopConfirm.confirm': 'End',
'control.showPlayers': 'Show players',
'control.hidePlayers': 'Hide players',
'control.videoBrushHint':
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
void test('PresentationApp: Escape запрашивает confirm, не закрывает сразу', () => {
const src = fs.readFileSync(path.join(here, 'PresentationApp.tsx'), 'utf8');
assert.ok(src.includes('requestCloseMultiWindowConfirm'));
assert.match(
src,
/e\.key === 'Escape'[\s\S]*requestCloseMultiWindowConfirm/,
);
assert.doesNotMatch(
src,
/e\.key === 'Escape'[\s\S]*closeMultiWindow/,
);
});
@@ -23,7 +23,8 @@ export function PresentationApp() {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
e.preventDefault();
void api.invoke(ipcChannels.windows.requestCloseMultiWindowConfirm, {});
return;
}
if (e.altKey && (e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter')) {
+7
View File
@@ -169,6 +169,8 @@ export const ipcChannels = {
windows: {
openMultiWindow: 'windows.openMultiWindow',
closeMultiWindow: 'windows.closeMultiWindow',
requestCloseMultiWindowConfirm: 'windows.requestCloseMultiWindowConfirm',
closeMultiWindowConfirmRequested: 'windows.closeMultiWindowConfirmRequested',
togglePresentationFullscreen: 'windows.togglePresentationFullscreen',
getMultiWindowState: 'windows.getMultiWindowState',
multiWindowStateChanged: 'windows.multiWindowStateChanged',
@@ -430,6 +432,7 @@ export type IpcEventMap = {
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
[ipcChannels.windows.closeMultiWindowConfirmRequested]: Record<string, never>;
[ipcChannels.windows.presentationContentSizeChanged]: { width: number; height: number };
[ipcChannels.windows.sceneDescriptionContent]: { html: string };
[ipcChannels.windows.instructionsSection]: { section: string };
@@ -791,6 +794,10 @@ export type IpcInvokeMap = {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.requestCloseMultiWindowConfirm]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.togglePresentationFullscreen]: {
req: Record<string, never>;
res: { ok: true; isFullScreen: boolean };
+1 -1
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/shared/fieldEffectStampPoints.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/ensureParentDir.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/gameSystems/gameSystemsStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/shared/fieldEffectStampPoints.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/presentation/PresentationApp.escapeConfirm.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/ensureParentDir.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/gameSystems/gameSystemsStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
"format": "prettier . --check",
"format:write": "prettier . --write",
"postinstall": "patch-package",