From 978cb8c4013e8bdfa95bc671bdb12b731a168725 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 23 Feb 2026 19:54:58 -0800 Subject: [PATCH] Ensure overlay modal grabs input --- src/core/services/ipc.test.ts | 3 +- src/core/services/overlay-bridge.ts | 4 +- src/core/services/overlay-manager.test.ts | 28 +++ src/core/services/overlay-manager.ts | 13 +- src/core/services/overlay-window.ts | 4 +- src/main.ts | 53 ++++- src/main/overlay-runtime.test.ts | 218 ++++++++++++++++++ src/main/overlay-runtime.ts | 127 +++++++--- .../overlay-bootstrap-main-deps.test.ts | 12 + .../runtime/overlay-bootstrap-main-deps.ts | 8 +- .../overlay-window-factory-main-deps.test.ts | 10 +- .../overlay-window-factory-main-deps.ts | 26 ++- .../runtime/overlay-window-factory.test.ts | 16 ++ src/main/runtime/overlay-window-factory.ts | 13 +- .../overlay-window-runtime-handlers.test.ts | 6 + .../overlay-window-runtime-handlers.ts | 10 + src/preload.ts | 5 +- src/renderer/error-recovery.test.ts | 35 +++ src/renderer/error-recovery.ts | 2 +- src/renderer/modals/kiku.ts | 1 + src/renderer/style.css | 10 + src/renderer/utils/platform.ts | 15 +- src/shared/ipc/contracts.ts | 2 +- src/types.ts | 4 +- 24 files changed, 562 insertions(+), 63 deletions(-) create mode 100644 src/main/overlay-runtime.test.ts diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index ee51c6c..0e6bbc8 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -233,5 +233,6 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync'); - assert.deepEqual(modals, ['subsync']); + handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku'); + assert.deepEqual(modals, ['subsync', 'kiku']); }); diff --git a/src/core/services/overlay-bridge.ts b/src/core/services/overlay-bridge.ts index a5fc509..0326b3b 100644 --- a/src/core/services/overlay-bridge.ts +++ b/src/core/services/overlay-bridge.ts @@ -63,6 +63,8 @@ export function createFieldGroupingCallbackRuntime(options: { getResolver: options.getResolver, setResolver: options.setResolver, sendRequestToVisibleOverlay: (data) => - options.sendToVisibleOverlay('kiku:field-grouping-request', data), + options.sendToVisibleOverlay('kiku:field-grouping-request', data, { + restoreOnModalClose: 'kiku' as T, + }), }); } diff --git a/src/core/services/overlay-manager.test.ts b/src/core/services/overlay-manager.test.ts index 1a45d52..d969137 100644 --- a/src/core/services/overlay-manager.test.ts +++ b/src/core/services/overlay-manager.test.ts @@ -11,6 +11,7 @@ test('overlay manager initializes with empty windows and hidden overlays', () => assert.equal(manager.getMainWindow(), null); assert.equal(manager.getInvisibleWindow(), null); assert.equal(manager.getSecondaryWindow(), null); + assert.equal(manager.getModalWindow(), null); assert.equal(manager.getVisibleOverlayVisible(), false); assert.equal(manager.getInvisibleOverlayVisible(), false); assert.deepEqual(manager.getOverlayWindows(), []); @@ -27,14 +28,19 @@ test('overlay manager stores window references and returns stable window order', const secondaryWindow = { isDestroyed: () => false, } as unknown as Electron.BrowserWindow; + const modalWindow = { + isDestroyed: () => false, + } as unknown as Electron.BrowserWindow; manager.setMainWindow(visibleWindow); manager.setInvisibleWindow(invisibleWindow); manager.setSecondaryWindow(secondaryWindow); + manager.setModalWindow(modalWindow); assert.equal(manager.getMainWindow(), visibleWindow); assert.equal(manager.getInvisibleWindow(), invisibleWindow); assert.equal(manager.getSecondaryWindow(), secondaryWindow); + assert.equal(manager.getModalWindow(), modalWindow); assert.equal(manager.getOverlayWindow('visible'), visibleWindow); assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow); assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]); @@ -51,6 +57,9 @@ test('overlay manager excludes destroyed windows', () => { manager.setSecondaryWindow({ isDestroyed: () => true, } as unknown as Electron.BrowserWindow); + manager.setModalWindow({ + isDestroyed: () => false, + } as unknown as Electron.BrowserWindow); assert.equal(manager.getOverlayWindows().length, 1); }); @@ -93,6 +102,10 @@ test('overlay manager broadcasts to non-destroyed windows', () => { manager.setMainWindow(aliveWindow); manager.setInvisibleWindow(deadWindow); manager.setSecondaryWindow(secondaryWindow); + manager.setModalWindow({ + isDestroyed: () => false, + webContents: { send: () => {} }, + } as unknown as Electron.BrowserWindow); manager.broadcastToOverlayWindows('x', 1, 'a'); assert.deepEqual(calls, [ @@ -123,9 +136,17 @@ test('overlay manager applies bounds by layer', () => { invisibleCalls.push(bounds); }, } as unknown as Electron.BrowserWindow; + const modalCalls: Electron.Rectangle[] = []; + const modalWindow = { + isDestroyed: () => false, + setBounds: (bounds: Electron.Rectangle) => { + modalCalls.push(bounds); + }, + } as unknown as Electron.BrowserWindow; manager.setMainWindow(visibleWindow); manager.setInvisibleWindow(invisibleWindow); manager.setSecondaryWindow(secondaryWindow); + manager.setModalWindow(modalWindow); manager.setOverlayWindowBounds('visible', { x: 10, @@ -145,12 +166,19 @@ test('overlay manager applies bounds by layer', () => { width: 10, height: 11, }); + manager.setModalWindowBounds({ + x: 80, + y: 90, + width: 100, + height: 110, + }); assert.deepEqual(visibleCalls, [{ x: 10, y: 20, width: 30, height: 40 }]); assert.deepEqual(invisibleCalls, [ { x: 1, y: 2, width: 3, height: 4 }, { x: 8, y: 9, width: 10, height: 11 }, ]); + assert.deepEqual(modalCalls, [{ x: 80, y: 90, width: 100, height: 110 }]); }); test('runtime-option and debug broadcasts use expected channels', () => { diff --git a/src/core/services/overlay-manager.ts b/src/core/services/overlay-manager.ts index 42633c6..942d771 100644 --- a/src/core/services/overlay-manager.ts +++ b/src/core/services/overlay-manager.ts @@ -1,4 +1,4 @@ -import { BrowserWindow } from 'electron'; +import type { BrowserWindow } from 'electron'; import { RuntimeOptionState, WindowGeometry } from '../../types'; import { updateOverlayWindowBounds } from './overlay-window'; @@ -11,9 +11,12 @@ export interface OverlayManager { setInvisibleWindow: (window: BrowserWindow | null) => void; getSecondaryWindow: () => BrowserWindow | null; setSecondaryWindow: (window: BrowserWindow | null) => void; + getModalWindow: () => BrowserWindow | null; + setModalWindow: (window: BrowserWindow | null) => void; getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null; setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void; setSecondaryWindowBounds: (geometry: WindowGeometry) => void; + setModalWindowBounds: (geometry: WindowGeometry) => void; getVisibleOverlayVisible: () => boolean; setVisibleOverlayVisible: (visible: boolean) => void; getInvisibleOverlayVisible: () => boolean; @@ -26,6 +29,7 @@ export function createOverlayManager(): OverlayManager { let mainWindow: BrowserWindow | null = null; let invisibleWindow: BrowserWindow | null = null; let secondaryWindow: BrowserWindow | null = null; + let modalWindow: BrowserWindow | null = null; let visibleOverlayVisible = false; let invisibleOverlayVisible = false; @@ -42,6 +46,10 @@ export function createOverlayManager(): OverlayManager { setSecondaryWindow: (window) => { secondaryWindow = window; }, + getModalWindow: () => modalWindow, + setModalWindow: (window) => { + modalWindow = window; + }, getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow), setOverlayWindowBounds: (layer, geometry) => { updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow); @@ -49,6 +57,9 @@ export function createOverlayManager(): OverlayManager { setSecondaryWindowBounds: (geometry) => { updateOverlayWindowBounds(geometry, secondaryWindow); }, + setModalWindowBounds: (geometry) => { + updateOverlayWindowBounds(geometry, modalWindow); + }, getVisibleOverlayVisible: () => visibleOverlayVisible, setVisibleOverlayVisible: (visible) => { visibleOverlayVisible = visible; diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 3428c11..1bb1762 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -5,7 +5,7 @@ import { createLogger } from '../../logger'; const logger = createLogger('main:overlay-window'); -export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary'; +export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal'; export function updateOverlayWindowBounds( geometry: WindowGeometry, @@ -71,6 +71,7 @@ export function createOverlayWindow( resizable: false, hasShadow: false, focusable: true, + acceptFirstMouse: true, webPreferences: { preload: path.join(__dirname, '..', '..', 'preload.js'), contextIsolation: true, @@ -115,6 +116,7 @@ export function createOverlayWindow( } window.webContents.on('before-input-event', (event, input) => { + if (kind === 'modal') return; if (!options.isOverlayVisible(kind)) return; if (!options.tryHandleOverlayShortcutLocalFallback(input)) return; event.preventDefault(); diff --git a/src/main.ts b/src/main.ts index dbe3ef8..7c1a9e3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -567,6 +567,26 @@ process.on('SIGTERM', () => { }); const overlayManager = createOverlayManager(); +let overlayModalInputExclusive = false; +let syncOverlayShortcutsForModal: (isActive: boolean) => void = () => {}; + +const handleModalInputStateChange = (isActive: boolean): void => { + if (overlayModalInputExclusive === isActive) return; + overlayModalInputExclusive = isActive; + if (isActive) { + const modalWindow = overlayManager.getModalWindow(); + if (modalWindow && !modalWindow.isDestroyed()) { + modalWindow.setIgnoreMouseEvents(false); + modalWindow.setAlwaysOnTop(true, 'screen-saver', 1); + modalWindow.focus(); + if (!modalWindow.webContents.isFocused()) { + modalWindow.webContents.focus(); + } + } + } + syncOverlayShortcutsForModal(isActive); +}; + const buildOverlayContentMeasurementStoreMainDepsHandler = createBuildOverlayContentMeasurementStoreMainDepsHandler({ now: () => Date.now(), @@ -575,6 +595,10 @@ const buildOverlayContentMeasurementStoreMainDepsHandler = const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({ getMainWindow: () => overlayManager.getMainWindow(), getInvisibleWindow: () => overlayManager.getInvisibleWindow(), + getModalWindow: () => overlayManager.getModalWindow(), + createModalWindow: () => createModalWindow(), + getModalGeometry: () => getCurrentOverlayGeometry(), + setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry), }); const overlayContentMeasurementStoreMainDeps = buildOverlayContentMeasurementStoreMainDepsHandler(); const overlayContentMeasurementStore = createOverlayContentMeasurementStore( @@ -582,6 +606,9 @@ const overlayContentMeasurementStore = createOverlayContentMeasurementStore( ); const overlayModalRuntime = createOverlayModalRuntimeService( buildOverlayModalRuntimeMainDepsHandler(), + { + onModalStateChange: (isActive: boolean) => handleModalInputStateChange(isActive), + }, ); const appState = createAppState({ mpvSocketPath: getDefaultSocketPath(), @@ -789,6 +816,13 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( }, })(), ); +syncOverlayShortcutsForModal = (isActive: boolean): void => { + if (isActive) { + overlayShortcutsRuntime.unregisterOverlayShortcuts(); + } else { + overlayShortcutsRuntime.syncOverlayShortcuts(); + } +}; const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler( { @@ -2216,6 +2250,7 @@ function applyOverlayRegions(layer: 'visible' | 'invisible', geometry: WindowGeo const regions = splitOverlayGeometryForSecondaryBar(geometry); overlayManager.setOverlayWindowBounds(layer, regions.primary); overlayManager.setSecondaryWindowBounds(regions.secondary); + overlayManager.setModalWindowBounds(geometry); syncSecondaryOverlayWindowVisibility(); } @@ -2276,10 +2311,20 @@ async function ensureYomitanExtensionLoaded(): Promise { return yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); } -function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary'): BrowserWindow { +function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary' | 'modal'): BrowserWindow { return createOverlayWindowHandler(kind); } +function createModalWindow(): BrowserWindow { + const existingWindow = overlayManager.getModalWindow(); + if (existingWindow && !existingWindow.isDestroyed()) { + return existingWindow; + } + const window = createModalWindowHandler(); + overlayManager.setModalWindowBounds(getCurrentOverlayGeometry()); + return window; +} + function createSecondaryWindow(): BrowserWindow { const existingWindow = overlayManager.getSecondaryWindow(); if (existingWindow && !existingWindow.isDestroyed()) { @@ -2742,6 +2787,7 @@ const { createMainWindow: createMainWindowHandler, createInvisibleWindow: createInvisibleWindowHandler, createSecondaryWindow: createSecondaryWindowHandler, + createModalWindow: createModalWindowHandler, } = createOverlayWindowRuntimeHandlers({ createOverlayWindowDeps: { createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), @@ -2763,14 +2809,17 @@ const { overlayManager.setMainWindow(null); } else if (windowKind === 'invisible') { overlayManager.setInvisibleWindow(null); - } else { + } else if (windowKind === 'secondary') { overlayManager.setSecondaryWindow(null); + } else { + overlayManager.setModalWindow(null); } }, }, setMainWindow: (window) => overlayManager.setMainWindow(window), setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window), setSecondaryWindow: (window) => overlayManager.setSecondaryWindow(window), + setModalWindow: (window) => overlayManager.setModalWindow(window), }); const { resolveTrayIconPath: resolveTrayIconPathHandler, diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts new file mode 100644 index 0000000..f89b26e --- /dev/null +++ b/src/main/overlay-runtime.test.ts @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createOverlayModalRuntimeService } from './overlay-runtime'; + +type MockWindow = { + destroyed: boolean; + visible: boolean; + focused: boolean; + ignoreMouseEvents: boolean; + webContentsFocused: boolean; + showCount: number; + hideCount: number; + sent: unknown[][]; + loading: boolean; + loadCallbacks: Array<() => void>; +}; + +function createMockWindow(): MockWindow & { + isDestroyed: () => boolean; + isVisible: () => boolean; + isFocused: () => boolean; + setIgnoreMouseEvents: (ignore: boolean) => void; + getShowCount: () => number; + getHideCount: () => number; + show: () => void; + hide: () => void; + focus: () => void; + webContents: { + focused: boolean; + isLoading: () => boolean; + send: (channel: string, payload?: unknown) => void; + isFocused: () => boolean; + once: (event: 'did-finish-load', cb: () => void) => void; + focus: () => void; + }; +} { + const state: MockWindow = { + destroyed: false, + visible: false, + focused: false, + ignoreMouseEvents: false, + webContentsFocused: false, + showCount: 0, + hideCount: 0, + sent: [], + loading: false, + loadCallbacks: [], + }; + return { + ...state, + isDestroyed: () => state.destroyed, + isVisible: () => state.visible, + isFocused: () => state.focused, + setIgnoreMouseEvents: (ignore: boolean) => { + state.ignoreMouseEvents = ignore; + }, + getShowCount: () => state.showCount, + getHideCount: () => state.hideCount, + show: () => { + state.visible = true; + state.showCount += 1; + }, + hide: () => { + state.visible = false; + state.hideCount += 1; + }, + focus: () => { + state.focused = true; + }, + webContents: { + isLoading: () => state.loading, + send: (channel, payload) => { + if (payload === undefined) { + state.sent.push([channel]); + return; + } + state.sent.push([channel, payload]); + }, + focused: false, + isFocused: () => state.webContentsFocused, + once: (_event, cb) => { + state.loadCallbacks.push(cb); + }, + focus: () => { + state.webContentsFocused = true; + }, + }, + }; +} + +test('sendToActiveOverlayWindow targets modal window with full geometry and tracks close restore', () => { + const window = createMockWindow(); + const calls: string[] = []; + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getInvisibleWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => { + calls.push('create-modal-window'); + return window as never; + }, + getModalGeometry: () => ({ x: 10, y: 20, width: 300, height: 200 }), + setModalWindowBounds: (geometry) => { + calls.push(`bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`); + }, + }); + + const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + assert.equal(sent, true); + assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), true); + assert.deepEqual(calls, ['bounds:10,20,300,200']); + assert.equal(window.getShowCount(), 1); + assert.equal(window.isFocused(), true); + assert.deepEqual(window.sent, [['runtime-options:open']]); +}); + +test('sendToActiveOverlayWindow creates modal window lazily when absent', () => { + const window = createMockWindow(); + let modalWindow: ReturnType | null = null; + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getInvisibleWindow: () => null, + getModalWindow: () => modalWindow as never, + createModalWindow: () => { + modalWindow = window; + return modalWindow as never; + }, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + assert.equal( + runtime.sendToActiveOverlayWindow('jimaku:open', undefined, { restoreOnModalClose: 'jimaku' }), + true, + ); + assert.deepEqual(window.sent, [['jimaku:open']]); +}); + +test('handleOverlayModalClosed hides modal window only after all pending modals close', () => { + const window = createMockWindow(); + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getInvisibleWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => window as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + runtime.sendToActiveOverlayWindow('subsync:open-manual', { sourceTracks: [] }, { + restoreOnModalClose: 'subsync', + }); + + runtime.handleOverlayModalClosed('runtime-options'); + assert.equal(window.getHideCount(), 0); + + runtime.handleOverlayModalClosed('subsync'); + assert.equal(window.getHideCount(), 1); +}); + +test('modal runtime notifies callers when modal input state becomes active/inactive', () => { + const window = createMockWindow(); + const state: boolean[] = []; + const runtime = createOverlayModalRuntimeService( + { + getMainWindow: () => null, + getInvisibleWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => window as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }, + { + onModalStateChange: (active: boolean): void => { + state.push(active); + }, + }, + ); + + runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + runtime.sendToActiveOverlayWindow('subsync:open-manual', { sourceTracks: [] }, { + restoreOnModalClose: 'subsync', + }); + assert.deepEqual(state, [true]); + + runtime.handleOverlayModalClosed('runtime-options'); + assert.deepEqual(state, [true]); + + runtime.handleOverlayModalClosed('subsync'); + assert.deepEqual(state, [true, false]); +}); + +test('handleOverlayModalClosed hides modal window for single kiku modal', () => { + const window = createMockWindow(); + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getInvisibleWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => window as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + runtime.sendToActiveOverlayWindow('kiku:field-grouping-open', { test: true }, { + restoreOnModalClose: 'kiku', + }); + runtime.handleOverlayModalClosed('kiku'); + + assert.equal(window.getHideCount(), 1); + assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0); +}); diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 6df4523..b77ff78 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -1,11 +1,16 @@ import type { BrowserWindow } from 'electron'; +import type { WindowGeometry } from '../types'; -type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku'; +type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku' | 'kiku'; type OverlayHostLayer = 'visible' | 'invisible'; export interface OverlayWindowResolver { getMainWindow: () => BrowserWindow | null; getInvisibleWindow: () => BrowserWindow | null; + getModalWindow: () => BrowserWindow | null; + createModalWindow: () => BrowserWindow | null; + getModalGeometry: () => WindowGeometry; + setModalWindowBounds: (geometry: WindowGeometry) => void; } export interface OverlayModalRuntime { @@ -19,9 +24,34 @@ export interface OverlayModalRuntime { getRestoreVisibleOverlayOnModalClose: () => Set; } -export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): OverlayModalRuntime { +export interface OverlayModalRuntimeOptions { + onModalStateChange?: (isActive: boolean) => void; +} + +export function createOverlayModalRuntimeService( + deps: OverlayWindowResolver, + options: OverlayModalRuntimeOptions = {}, +): OverlayModalRuntime { const restoreVisibleOverlayOnModalClose = new Set(); - const overlayModalAutoShownLayer = new Map(); + let modalActive = false; + + const notifyModalStateChange = (nextState: boolean): void => { + if (modalActive === nextState) return; + modalActive = nextState; + options.onModalStateChange?.(nextState); + }; + + const resolveModalWindow = (): BrowserWindow | null => { + const existingWindow = deps.getModalWindow(); + if (existingWindow && !existingWindow.isDestroyed()) { + return existingWindow; + } + const createdWindow = deps.createModalWindow(); + if (!createdWindow || createdWindow.isDestroyed()) { + return null; + } + return createdWindow; + }; const getTargetOverlayWindow = (): { window: BrowserWindow; @@ -41,6 +71,15 @@ export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): O return null; }; + const showModalWindow = (window: BrowserWindow): void => { + window.show(); + window.setIgnoreMouseEvents(false); + window.focus(); + if (!window.webContents.isFocused()) { + window.webContents.focus(); + } + }; + const showOverlayWindowForModal = (window: BrowserWindow, layer: OverlayHostLayer): void => { if (layer === 'invisible' && typeof window.showInactive === 'function') { window.showInactive(); @@ -57,39 +96,66 @@ export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): O payload?: unknown, runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, ): boolean => { + const restoreOnModalClose = runtimeOptions?.restoreOnModalClose; + + const sendNow = (window: BrowserWindow): void => { + if (payload === undefined) { + window.webContents.send(channel); + } else { + window.webContents.send(channel, payload); + } + }; + + if (restoreOnModalClose) { + const modalWindow = resolveModalWindow(); + if (!modalWindow) return false; + + deps.setModalWindowBounds(deps.getModalGeometry()); + const wasVisible = modalWindow.isVisible(); + const wasModalActive = restoreVisibleOverlayOnModalClose.size > 0; + restoreVisibleOverlayOnModalClose.add(restoreOnModalClose); + if (!wasModalActive) { + notifyModalStateChange(true); + } + + if (!wasVisible) { + showModalWindow(modalWindow); + } else if (!modalWindow.isFocused()) { + showModalWindow(modalWindow); + } + + if (modalWindow.webContents.isLoading()) { + modalWindow.webContents.once('did-finish-load', () => { + if (modalWindow && !modalWindow.isDestroyed() && !modalWindow.webContents.isLoading()) { + sendNow(modalWindow); + } + }); + return true; + } + + sendNow(modalWindow); + return true; + } + const target = getTargetOverlayWindow(); if (!target) return false; const { window: targetWindow, layer } = target; const wasVisible = targetWindow.isVisible(); - const restoreOnModalClose = runtimeOptions?.restoreOnModalClose; - - const sendNow = (): void => { - if (payload === undefined) { - targetWindow.webContents.send(channel); - } else { - targetWindow.webContents.send(channel, payload); - } - }; - if (!wasVisible) { showOverlayWindowForModal(targetWindow, layer); } - if (!wasVisible && restoreOnModalClose) { - restoreVisibleOverlayOnModalClose.add(restoreOnModalClose); - overlayModalAutoShownLayer.set(restoreOnModalClose, layer); - } if (targetWindow.webContents.isLoading()) { targetWindow.webContents.once('did-finish-load', () => { if (targetWindow && !targetWindow.isDestroyed() && !targetWindow.webContents.isLoading()) { - sendNow(); + sendNow(targetWindow); } }); return true; } - sendNow(); + sendNow(targetWindow); return true; }; @@ -102,24 +168,13 @@ export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): O const handleOverlayModalClosed = (modal: OverlayHostedModal): void => { if (!restoreVisibleOverlayOnModalClose.has(modal)) return; restoreVisibleOverlayOnModalClose.delete(modal); - const layer = overlayModalAutoShownLayer.get(modal); - overlayModalAutoShownLayer.delete(modal); - if (!layer) return; - const shouldKeepLayerVisible = [...restoreVisibleOverlayOnModalClose].some( - (pendingModal) => overlayModalAutoShownLayer.get(pendingModal) === layer, - ); - if (shouldKeepLayerVisible) return; - - if (layer === 'visible') { - const mainWindow = deps.getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.hide(); - } - return; + const modalWindow = deps.getModalWindow(); + if (!modalWindow || modalWindow.isDestroyed()) return; + if (restoreVisibleOverlayOnModalClose.size === 0) { + notifyModalStateChange(false); } - const invisibleWindow = deps.getInvisibleWindow(); - if (invisibleWindow && !invisibleWindow.isDestroyed()) { - invisibleWindow.hide(); + if (restoreVisibleOverlayOnModalClose.size === 0) { + modalWindow.hide(); } }; diff --git a/src/main/runtime/overlay-bootstrap-main-deps.test.ts b/src/main/runtime/overlay-bootstrap-main-deps.test.ts index 1e897a2..8b1f600 100644 --- a/src/main/runtime/overlay-bootstrap-main-deps.test.ts +++ b/src/main/runtime/overlay-bootstrap-main-deps.test.ts @@ -20,11 +20,23 @@ test('overlay content measurement store main deps builder maps callbacks', () => test('overlay modal runtime main deps builder maps window resolvers', () => { const mainWindow = { id: 'main' }; const invisibleWindow = { id: 'invisible' }; + const modalWindow = { id: 'modal' }; + const calls: string[] = []; const deps = createBuildOverlayModalRuntimeMainDepsHandler({ getMainWindow: () => mainWindow as never, getInvisibleWindow: () => invisibleWindow as never, + getModalWindow: () => modalWindow as never, + createModalWindow: () => modalWindow as never, + getModalGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }), + setModalWindowBounds: (geometry) => + calls.push(`modal-bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`), })(); assert.equal(deps.getMainWindow(), mainWindow); assert.equal(deps.getInvisibleWindow(), invisibleWindow); + assert.equal(deps.getModalWindow(), modalWindow); + assert.equal(deps.createModalWindow(), modalWindow); + assert.deepEqual(deps.getModalGeometry(), { x: 1, y: 2, width: 3, height: 4 }); + deps.setModalWindowBounds({ x: 10, y: 20, width: 30, height: 40 }); + assert.deepEqual(calls, ['modal-bounds:10,20,30,40']); }); diff --git a/src/main/runtime/overlay-bootstrap-main-deps.ts b/src/main/runtime/overlay-bootstrap-main-deps.ts index dfea96c..dc59a34 100644 --- a/src/main/runtime/overlay-bootstrap-main-deps.ts +++ b/src/main/runtime/overlay-bootstrap-main-deps.ts @@ -14,9 +14,15 @@ export function createBuildOverlayContentMeasurementStoreMainDepsHandler( }); } -export function createBuildOverlayModalRuntimeMainDepsHandler(deps: OverlayWindowResolver) { +export function createBuildOverlayModalRuntimeMainDepsHandler( + deps: OverlayWindowResolver, +) { return (): OverlayWindowResolver => ({ getMainWindow: () => deps.getMainWindow(), getInvisibleWindow: () => deps.getInvisibleWindow(), + getModalWindow: () => deps.getModalWindow(), + createModalWindow: () => deps.createModalWindow(), + getModalGeometry: () => deps.getModalGeometry(), + setModalWindowBounds: (geometry) => deps.setModalWindowBounds(geometry), }); } diff --git a/src/main/runtime/overlay-window-factory-main-deps.test.ts b/src/main/runtime/overlay-window-factory-main-deps.test.ts index 0203cd1..862ad77 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.test.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { createBuildCreateInvisibleWindowMainDepsHandler, createBuildCreateMainWindowMainDepsHandler, + createBuildCreateModalWindowMainDepsHandler, createBuildCreateOverlayWindowMainDepsHandler, createBuildCreateSecondaryWindowMainDepsHandler, } from './overlay-window-factory-main-deps'; @@ -47,5 +48,12 @@ test('overlay window factory main deps builders return mapped handlers', () => { const secondaryDeps = buildSecondaryDeps(); secondaryDeps.setSecondaryWindow(null); - assert.deepEqual(calls, ['set-main', 'set-invisible', 'set-secondary']); + const buildModalDeps = createBuildCreateModalWindowMainDepsHandler({ + createOverlayWindow: () => ({ id: 'modal' }), + setModalWindow: () => calls.push('set-modal'), + }); + const modalDeps = buildModalDeps(); + modalDeps.setModalWindow(null); + + assert.deepEqual(calls, ['set-main', 'set-invisible', 'set-secondary', 'set-modal']); }); diff --git a/src/main/runtime/overlay-window-factory-main-deps.ts b/src/main/runtime/overlay-window-factory-main-deps.ts index fae8f9a..5667130 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.ts @@ -1,15 +1,15 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { createOverlayWindowCore: ( - kind: 'visible' | 'invisible' | 'secondary', + kind: 'visible' | 'invisible' | 'secondary' | 'modal', options: { isDev: boolean; overlayDebugVisualizationEnabled: boolean; ensureOverlayWindowLevel: (window: TWindow) => void; onRuntimeOptionsChanged: () => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; - isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary') => boolean; + isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; - onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary') => void; + onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => void; }, ) => TWindow; isDev: boolean; @@ -17,9 +17,9 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { ensureOverlayWindowLevel: (window: TWindow) => void; onRuntimeOptionsChanged: () => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; - isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary') => boolean; + isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; - onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary') => void; + onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => void; }) { return () => ({ createOverlayWindowCore: deps.createOverlayWindowCore, @@ -35,7 +35,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { } export function createBuildCreateMainWindowMainDepsHandler(deps: { - createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow; + createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow; setMainWindow: (window: TWindow | null) => void; }) { return () => ({ @@ -45,7 +45,7 @@ export function createBuildCreateMainWindowMainDepsHandler(deps: { } export function createBuildCreateInvisibleWindowMainDepsHandler(deps: { - createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow; + createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow; setInvisibleWindow: (window: TWindow | null) => void; }) { return () => ({ @@ -55,7 +55,7 @@ export function createBuildCreateInvisibleWindowMainDepsHandler(deps: { } export function createBuildCreateSecondaryWindowMainDepsHandler(deps: { - createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow; + createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow; setSecondaryWindow: (window: TWindow | null) => void; }) { return () => ({ @@ -63,3 +63,13 @@ export function createBuildCreateSecondaryWindowMainDepsHandler(deps: { setSecondaryWindow: deps.setSecondaryWindow, }); } + +export function createBuildCreateModalWindowMainDepsHandler(deps: { + createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow; + setModalWindow: (window: TWindow | null) => void; +}) { + return () => ({ + createOverlayWindow: deps.createOverlayWindow, + setModalWindow: deps.setModalWindow, + }); +} diff --git a/src/main/runtime/overlay-window-factory.test.ts b/src/main/runtime/overlay-window-factory.test.ts index 0d1b3e3..5ad937b 100644 --- a/src/main/runtime/overlay-window-factory.test.ts +++ b/src/main/runtime/overlay-window-factory.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { createCreateInvisibleWindowHandler, createCreateMainWindowHandler, + createCreateModalWindowHandler, createCreateOverlayWindowHandler, createCreateSecondaryWindowHandler, } from './overlay-window-factory'; @@ -80,3 +81,18 @@ test('create secondary window handler stores secondary window', () => { assert.equal(createSecondaryWindow(), secondaryWindow); assert.deepEqual(calls, ['create:secondary', 'set:secondary']); }); + +test('create modal window handler stores modal window', () => { + const calls: string[] = []; + const modalWindow = { id: 'modal' }; + const createModalWindow = createCreateModalWindowHandler({ + createOverlayWindow: (kind) => { + calls.push(`create:${kind}`); + return modalWindow; + }, + setModalWindow: (window) => calls.push(`set:${(window as { id: string }).id}`), + }); + + assert.equal(createModalWindow(), modalWindow); + assert.deepEqual(calls, ['create:modal', 'set:modal']); +}); diff --git a/src/main/runtime/overlay-window-factory.ts b/src/main/runtime/overlay-window-factory.ts index dcdf639..c3431f6 100644 --- a/src/main/runtime/overlay-window-factory.ts +++ b/src/main/runtime/overlay-window-factory.ts @@ -1,4 +1,4 @@ -type OverlayWindowKind = 'visible' | 'invisible' | 'secondary'; +type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal'; export function createCreateOverlayWindowHandler(deps: { createOverlayWindowCore: ( @@ -69,3 +69,14 @@ export function createCreateSecondaryWindowHandler(deps: { return window; }; } + +export function createCreateModalWindowHandler(deps: { + createOverlayWindow: (kind: OverlayWindowKind) => TWindow; + setModalWindow: (window: TWindow | null) => void; +}) { + return (): TWindow => { + const window = deps.createOverlayWindow('modal'); + deps.setModalWindow(window); + return window; + }; +} diff --git a/src/main/runtime/overlay-window-runtime-handlers.test.ts b/src/main/runtime/overlay-window-runtime-handlers.test.ts index 39943ea..4de2d55 100644 --- a/src/main/runtime/overlay-window-runtime-handlers.test.ts +++ b/src/main/runtime/overlay-window-runtime-handlers.test.ts @@ -6,6 +6,7 @@ test('overlay window runtime handlers compose create/main/invisible handlers', ( let mainWindow: { kind: string } | null = null; let invisibleWindow: { kind: string } | null = null; let secondaryWindow: { kind: string } | null = null; + let modalWindow: { kind: string } | null = null; let debugEnabled = false; const calls: string[] = []; @@ -32,6 +33,9 @@ test('overlay window runtime handlers compose create/main/invisible handlers', ( setSecondaryWindow: (window) => { secondaryWindow = window; }, + setModalWindow: (window) => { + modalWindow = window; + }, }); assert.deepEqual(runtime.createOverlayWindow('visible'), { kind: 'visible' }); @@ -46,6 +50,8 @@ test('overlay window runtime handlers compose create/main/invisible handlers', ( assert.deepEqual(runtime.createSecondaryWindow(), { kind: 'secondary' }); assert.deepEqual(secondaryWindow, { kind: 'secondary' }); + assert.deepEqual(runtime.createModalWindow(), { kind: 'modal' }); + assert.deepEqual(modalWindow, { kind: 'modal' }); assert.equal(debugEnabled, false); assert.deepEqual(calls, []); diff --git a/src/main/runtime/overlay-window-runtime-handlers.ts b/src/main/runtime/overlay-window-runtime-handlers.ts index 7e84d5c..b3564c6 100644 --- a/src/main/runtime/overlay-window-runtime-handlers.ts +++ b/src/main/runtime/overlay-window-runtime-handlers.ts @@ -1,12 +1,14 @@ import { createCreateInvisibleWindowHandler, createCreateMainWindowHandler, + createCreateModalWindowHandler, createCreateOverlayWindowHandler, createCreateSecondaryWindowHandler, } from './overlay-window-factory'; import { createBuildCreateInvisibleWindowMainDepsHandler, createBuildCreateMainWindowMainDepsHandler, + createBuildCreateModalWindowMainDepsHandler, createBuildCreateOverlayWindowMainDepsHandler, createBuildCreateSecondaryWindowMainDepsHandler, } from './overlay-window-factory-main-deps'; @@ -20,6 +22,7 @@ export function createOverlayWindowRuntimeHandlers(deps: { setMainWindow: (window: TWindow | null) => void; setInvisibleWindow: (window: TWindow | null) => void; setSecondaryWindow: (window: TWindow | null) => void; + setModalWindow: (window: TWindow | null) => void; }) { const createOverlayWindow = createCreateOverlayWindowHandler( createBuildCreateOverlayWindowMainDepsHandler(deps.createOverlayWindowDeps)(), @@ -42,11 +45,18 @@ export function createOverlayWindowRuntimeHandlers(deps: { setSecondaryWindow: (window) => deps.setSecondaryWindow(window), })(), ); + const createModalWindow = createCreateModalWindowHandler( + createBuildCreateModalWindowMainDepsHandler({ + createOverlayWindow: (kind) => createOverlayWindow(kind), + setModalWindow: (window) => deps.setModalWindow(window), + })(), + ); return { createOverlayWindow, createMainWindow, createInvisibleWindow, createSecondaryWindow, + createModalWindow, }; } diff --git a/src/preload.ts b/src/preload.ts index 7c37390..0b5579d 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -57,7 +57,8 @@ const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length); const overlayLayer = overlayLayerFromArg === 'visible' || overlayLayerFromArg === 'invisible' || - overlayLayerFromArg === 'secondary' + overlayLayerFromArg === 'secondary' || + overlayLayerFromArg === 'modal' ? overlayLayerFromArg : null; @@ -253,7 +254,7 @@ const electronAPI: ElectronAPI = { }, appendClipboardVideoToQueue: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), - notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => { + notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => { ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); }, reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { diff --git a/src/renderer/error-recovery.test.ts b/src/renderer/error-recovery.test.ts index 7867aa4..65cde92 100644 --- a/src/renderer/error-recovery.test.ts +++ b/src/renderer/error-recovery.test.ts @@ -190,3 +190,38 @@ test('resolvePlatformInfo supports secondary layer and disables mouse-ignore tog }); } }); + +test('resolvePlatformInfo supports modal layer and disables mouse-ignore toggles', () => { + const previousWindow = (globalThis as { window?: unknown }).window; + const previousNavigator = (globalThis as { navigator?: unknown }).navigator; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + getOverlayLayer: () => 'modal', + }, + location: { search: '' }, + }, + }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + platform: 'MacIntel', + userAgent: 'Mozilla/5.0 (Macintosh)', + }, + }); + + try { + const info = resolvePlatformInfo(); + assert.equal(info.overlayLayer, 'modal'); + assert.equal(info.isModalLayer, true); + assert.equal(info.shouldToggleMouseIgnore, false); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: previousNavigator, + }); + } +}); diff --git a/src/renderer/error-recovery.ts b/src/renderer/error-recovery.ts index cffda23..a36de48 100644 --- a/src/renderer/error-recovery.ts +++ b/src/renderer/error-recovery.ts @@ -17,7 +17,7 @@ export type RendererRecoverySnapshot = { isOverlayInteractive: boolean; isOverSubtitle: boolean; invisiblePositionEditMode: boolean; - overlayLayer: 'visible' | 'invisible' | 'secondary'; + overlayLayer: 'visible' | 'invisible' | 'secondary' | 'modal'; }; type NormalizedRendererError = { diff --git a/src/renderer/modals/kiku.ts b/src/renderer/modals/kiku.ts index 11c84df..c8482a7 100644 --- a/src/renderer/modals/kiku.ts +++ b/src/renderer/modals/kiku.ts @@ -110,6 +110,7 @@ export function createKikuModal( setKikuPreviewError(null); ctx.dom.kikuPreviewJson.textContent = ''; + window.electronAPI.notifyOverlayModalClosed('kiku'); ctx.state.kikuPendingChoice = null; ctx.state.kikuPreviewCompactData = null; diff --git a/src/renderer/style.css b/src/renderer/style.css index 76df987..b31787c 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -567,6 +567,16 @@ body.layer-secondary #secondarySubContainer { justify-content: center; } +body.layer-modal #subtitleContainer, +body.layer-modal #secondarySubContainer { + display: none !important; + pointer-events: none !important; +} + +body.layer-modal #overlay { + justify-content: center; +} + #secondarySubRoot { text-align: center; font-size: 24px; diff --git a/src/renderer/utils/platform.ts b/src/renderer/utils/platform.ts index 72dfb44..10f1ce0 100644 --- a/src/renderer/utils/platform.ts +++ b/src/renderer/utils/platform.ts @@ -1,9 +1,10 @@ -export type OverlayLayer = 'visible' | 'invisible' | 'secondary'; +export type OverlayLayer = 'visible' | 'invisible' | 'secondary' | 'modal'; export type PlatformInfo = { overlayLayer: OverlayLayer; isInvisibleLayer: boolean; isSecondaryLayer: boolean; + isModalLayer: boolean; isLinuxPlatform: boolean; isMacOSPlatform: boolean; shouldToggleMouseIgnore: boolean; @@ -16,7 +17,10 @@ export function resolvePlatformInfo(): PlatformInfo { const overlayLayerFromPreload = window.electronAPI.getOverlayLayer(); const queryLayer = new URLSearchParams(window.location.search).get('layer'); const overlayLayerFromQuery: OverlayLayer | null = - queryLayer === 'visible' || queryLayer === 'invisible' || queryLayer === 'secondary' + queryLayer === 'visible' || + queryLayer === 'invisible' || + queryLayer === 'secondary' || + queryLayer === 'modal' ? queryLayer : null; @@ -24,12 +28,14 @@ export function resolvePlatformInfo(): PlatformInfo { overlayLayerFromQuery ?? (overlayLayerFromPreload === 'visible' || overlayLayerFromPreload === 'invisible' || - overlayLayerFromPreload === 'secondary' + overlayLayerFromPreload === 'secondary' || + overlayLayerFromPreload === 'modal' ? overlayLayerFromPreload : 'visible'); const isInvisibleLayer = overlayLayer === 'invisible'; const isSecondaryLayer = overlayLayer === 'secondary'; + const isModalLayer = overlayLayer === 'modal'; const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux'); const isMacOSPlatform = navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent); @@ -38,9 +44,10 @@ export function resolvePlatformInfo(): PlatformInfo { overlayLayer, isInvisibleLayer, isSecondaryLayer, + isModalLayer, isLinuxPlatform, isMacOSPlatform, - shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer, + shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer && !isModalLayer, invisiblePositionEditToggleCode: 'KeyP', invisiblePositionStepPx: 1, invisiblePositionStepFastPx: 4, diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 21531f3..beeae4b 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -1,6 +1,6 @@ import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types'; -export const OVERLAY_HOSTED_MODALS = ['runtime-options', 'subsync', 'jimaku'] as const; +export const OVERLAY_HOSTED_MODALS = ['runtime-options', 'subsync', 'jimaku', 'kiku'] as const; export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number]; export const IPC_CHANNELS = { diff --git a/src/types.ts b/src/types.ts index 7cbdc56..df59f63 100644 --- a/src/types.ts +++ b/src/types.ts @@ -728,7 +728,7 @@ export interface SubtitleHoverTokenPayload { } export interface ElectronAPI { - getOverlayLayer: () => 'visible' | 'invisible' | 'secondary' | null; + getOverlayLayer: () => 'visible' | 'invisible' | 'secondary' | 'modal' | null; onSubtitle: (callback: (data: SubtitleData) => void) => void; onVisibility: (callback: (visible: boolean) => void) => void; onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void; @@ -780,7 +780,7 @@ export interface ElectronAPI { onOpenRuntimeOptions: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void; appendClipboardVideoToQueue: () => Promise; - notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void; + notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; reportHoveredSubtitleToken: (tokenIndex: number | null) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;