From d228b9aab7e9c94697479fd4f1c95a1c08f7f53f Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Wed, 30 Sep 2026 08:53:48 +0800 Subject: [PATCH] fix(play): ask before Escape ends session Co-authored-by: Cursor --- app/main/index.ts | 5 ++ app/main/ipc/router.ts | 1 + .../windows/createWindows.editorClose.test.ts | 7 +++ app/main/windows/createWindows.ts | 10 +++ app/renderer/control/ControlApp.tsx | 62 +++++++++++++++++++ .../control/controlApp.effectsPanel.test.ts | 10 +++ app/renderer/editor/i18n/editorMessages.ts | 6 ++ .../PresentationApp.escapeConfirm.test.ts | 20 ++++++ app/renderer/presentation/PresentationApp.tsx | 3 +- app/shared/ipc/contracts.ts | 7 +++ package.json | 2 +- 11 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 app/renderer/presentation/PresentationApp.escapeConfirm.test.ts diff --git a/app/main/index.ts b/app/main/index.ts index 1d7b68f..c1a0d36 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -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 }; diff --git a/app/main/ipc/router.ts b/app/main/ipc/router.ts index 759579b..f63c269 100644 --- a/app/main/ipc/router.ts +++ b/app/main/ipc/router.ts @@ -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; diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts index b81e937..03f4820 100644 --- a/app/main/windows/createWindows.editorClose.test.ts +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -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')); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index 5d92efa..5ae4982 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -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'); } diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index c44a5be..aa72219 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -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(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( + <> + + +
{t('control.stopConfirm.body')}
+
+ + +
+ + , + document.body, + ) + : null} ); } diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts index 8fe3bdb..9d0c484 100644 --- a/app/renderer/control/controlApp.effectsPanel.test.ts +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -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')); +}); diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index bfe1655..debb3dd 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -762,6 +762,9 @@ export const EDITOR_MESSAGES: Record> = { '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> = { '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': diff --git a/app/renderer/presentation/PresentationApp.escapeConfirm.test.ts b/app/renderer/presentation/PresentationApp.escapeConfirm.test.ts new file mode 100644 index 0000000..f7d360b --- /dev/null +++ b/app/renderer/presentation/PresentationApp.escapeConfirm.test.ts @@ -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/, + ); +}); diff --git a/app/renderer/presentation/PresentationApp.tsx b/app/renderer/presentation/PresentationApp.tsx index 214db99..4bdfe22 100644 --- a/app/renderer/presentation/PresentationApp.tsx +++ b/app/renderer/presentation/PresentationApp.tsx @@ -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')) { diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index f396375..b72acf8 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -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; [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; res: { ok: true }; }; + [ipcChannels.windows.requestCloseMultiWindowConfirm]: { + req: Record; + res: { ok: true }; + }; [ipcChannels.windows.togglePresentationFullscreen]: { req: Record; res: { ok: true; isFullScreen: boolean }; diff --git a/package.json b/package.json index 56d05ca..67e2f3e 100644 --- a/package.json +++ b/package.json @@ -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",