Ensure overlay modal grabs input

This commit is contained in:
2026-02-23 19:54:58 -08:00
parent fe8a71990a
commit 978cb8c401
24 changed files with 562 additions and 63 deletions

View File

@@ -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)!({}, 'not-a-modal');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync'); 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']);
}); });

View File

@@ -63,6 +63,8 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
getResolver: options.getResolver, getResolver: options.getResolver,
setResolver: options.setResolver, setResolver: options.setResolver,
sendRequestToVisibleOverlay: (data) => sendRequestToVisibleOverlay: (data) =>
options.sendToVisibleOverlay('kiku:field-grouping-request', data), options.sendToVisibleOverlay('kiku:field-grouping-request', data, {
restoreOnModalClose: 'kiku' as T,
}),
}); });
} }

View File

@@ -11,6 +11,7 @@ test('overlay manager initializes with empty windows and hidden overlays', () =>
assert.equal(manager.getMainWindow(), null); assert.equal(manager.getMainWindow(), null);
assert.equal(manager.getInvisibleWindow(), null); assert.equal(manager.getInvisibleWindow(), null);
assert.equal(manager.getSecondaryWindow(), null); assert.equal(manager.getSecondaryWindow(), null);
assert.equal(manager.getModalWindow(), null);
assert.equal(manager.getVisibleOverlayVisible(), false); assert.equal(manager.getVisibleOverlayVisible(), false);
assert.equal(manager.getInvisibleOverlayVisible(), false); assert.equal(manager.getInvisibleOverlayVisible(), false);
assert.deepEqual(manager.getOverlayWindows(), []); assert.deepEqual(manager.getOverlayWindows(), []);
@@ -27,14 +28,19 @@ test('overlay manager stores window references and returns stable window order',
const secondaryWindow = { const secondaryWindow = {
isDestroyed: () => false, isDestroyed: () => false,
} as unknown as Electron.BrowserWindow; } as unknown as Electron.BrowserWindow;
const modalWindow = {
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
manager.setMainWindow(visibleWindow); manager.setMainWindow(visibleWindow);
manager.setInvisibleWindow(invisibleWindow); manager.setInvisibleWindow(invisibleWindow);
manager.setSecondaryWindow(secondaryWindow); manager.setSecondaryWindow(secondaryWindow);
manager.setModalWindow(modalWindow);
assert.equal(manager.getMainWindow(), visibleWindow); assert.equal(manager.getMainWindow(), visibleWindow);
assert.equal(manager.getInvisibleWindow(), invisibleWindow); assert.equal(manager.getInvisibleWindow(), invisibleWindow);
assert.equal(manager.getSecondaryWindow(), secondaryWindow); assert.equal(manager.getSecondaryWindow(), secondaryWindow);
assert.equal(manager.getModalWindow(), modalWindow);
assert.equal(manager.getOverlayWindow('visible'), visibleWindow); assert.equal(manager.getOverlayWindow('visible'), visibleWindow);
assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow); assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow);
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]); assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]);
@@ -51,6 +57,9 @@ test('overlay manager excludes destroyed windows', () => {
manager.setSecondaryWindow({ manager.setSecondaryWindow({
isDestroyed: () => true, isDestroyed: () => true,
} as unknown as Electron.BrowserWindow); } as unknown as Electron.BrowserWindow);
manager.setModalWindow({
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow);
assert.equal(manager.getOverlayWindows().length, 1); assert.equal(manager.getOverlayWindows().length, 1);
}); });
@@ -93,6 +102,10 @@ test('overlay manager broadcasts to non-destroyed windows', () => {
manager.setMainWindow(aliveWindow); manager.setMainWindow(aliveWindow);
manager.setInvisibleWindow(deadWindow); manager.setInvisibleWindow(deadWindow);
manager.setSecondaryWindow(secondaryWindow); manager.setSecondaryWindow(secondaryWindow);
manager.setModalWindow({
isDestroyed: () => false,
webContents: { send: () => {} },
} as unknown as Electron.BrowserWindow);
manager.broadcastToOverlayWindows('x', 1, 'a'); manager.broadcastToOverlayWindows('x', 1, 'a');
assert.deepEqual(calls, [ assert.deepEqual(calls, [
@@ -123,9 +136,17 @@ test('overlay manager applies bounds by layer', () => {
invisibleCalls.push(bounds); invisibleCalls.push(bounds);
}, },
} as unknown as Electron.BrowserWindow; } 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.setMainWindow(visibleWindow);
manager.setInvisibleWindow(invisibleWindow); manager.setInvisibleWindow(invisibleWindow);
manager.setSecondaryWindow(secondaryWindow); manager.setSecondaryWindow(secondaryWindow);
manager.setModalWindow(modalWindow);
manager.setOverlayWindowBounds('visible', { manager.setOverlayWindowBounds('visible', {
x: 10, x: 10,
@@ -145,12 +166,19 @@ test('overlay manager applies bounds by layer', () => {
width: 10, width: 10,
height: 11, 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(visibleCalls, [{ x: 10, y: 20, width: 30, height: 40 }]);
assert.deepEqual(invisibleCalls, [ assert.deepEqual(invisibleCalls, [
{ x: 1, y: 2, width: 3, height: 4 }, { x: 1, y: 2, width: 3, height: 4 },
{ x: 8, y: 9, width: 10, height: 11 }, { 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', () => { test('runtime-option and debug broadcasts use expected channels', () => {

View File

@@ -1,4 +1,4 @@
import { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';
import { RuntimeOptionState, WindowGeometry } from '../../types'; import { RuntimeOptionState, WindowGeometry } from '../../types';
import { updateOverlayWindowBounds } from './overlay-window'; import { updateOverlayWindowBounds } from './overlay-window';
@@ -11,9 +11,12 @@ export interface OverlayManager {
setInvisibleWindow: (window: BrowserWindow | null) => void; setInvisibleWindow: (window: BrowserWindow | null) => void;
getSecondaryWindow: () => BrowserWindow | null; getSecondaryWindow: () => BrowserWindow | null;
setSecondaryWindow: (window: BrowserWindow | null) => void; setSecondaryWindow: (window: BrowserWindow | null) => void;
getModalWindow: () => BrowserWindow | null;
setModalWindow: (window: BrowserWindow | null) => void;
getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null; getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null;
setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void; setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void;
setSecondaryWindowBounds: (geometry: WindowGeometry) => void; setSecondaryWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => void;
getVisibleOverlayVisible: () => boolean; getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
getInvisibleOverlayVisible: () => boolean; getInvisibleOverlayVisible: () => boolean;
@@ -26,6 +29,7 @@ export function createOverlayManager(): OverlayManager {
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let invisibleWindow: BrowserWindow | null = null; let invisibleWindow: BrowserWindow | null = null;
let secondaryWindow: BrowserWindow | null = null; let secondaryWindow: BrowserWindow | null = null;
let modalWindow: BrowserWindow | null = null;
let visibleOverlayVisible = false; let visibleOverlayVisible = false;
let invisibleOverlayVisible = false; let invisibleOverlayVisible = false;
@@ -42,6 +46,10 @@ export function createOverlayManager(): OverlayManager {
setSecondaryWindow: (window) => { setSecondaryWindow: (window) => {
secondaryWindow = window; secondaryWindow = window;
}, },
getModalWindow: () => modalWindow,
setModalWindow: (window) => {
modalWindow = window;
},
getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow), getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow),
setOverlayWindowBounds: (layer, geometry) => { setOverlayWindowBounds: (layer, geometry) => {
updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow); updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow);
@@ -49,6 +57,9 @@ export function createOverlayManager(): OverlayManager {
setSecondaryWindowBounds: (geometry) => { setSecondaryWindowBounds: (geometry) => {
updateOverlayWindowBounds(geometry, secondaryWindow); updateOverlayWindowBounds(geometry, secondaryWindow);
}, },
setModalWindowBounds: (geometry) => {
updateOverlayWindowBounds(geometry, modalWindow);
},
getVisibleOverlayVisible: () => visibleOverlayVisible, getVisibleOverlayVisible: () => visibleOverlayVisible,
setVisibleOverlayVisible: (visible) => { setVisibleOverlayVisible: (visible) => {
visibleOverlayVisible = visible; visibleOverlayVisible = visible;

View File

@@ -5,7 +5,7 @@ import { createLogger } from '../../logger';
const logger = createLogger('main:overlay-window'); const logger = createLogger('main:overlay-window');
export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary'; export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal';
export function updateOverlayWindowBounds( export function updateOverlayWindowBounds(
geometry: WindowGeometry, geometry: WindowGeometry,
@@ -71,6 +71,7 @@ export function createOverlayWindow(
resizable: false, resizable: false,
hasShadow: false, hasShadow: false,
focusable: true, focusable: true,
acceptFirstMouse: true,
webPreferences: { webPreferences: {
preload: path.join(__dirname, '..', '..', 'preload.js'), preload: path.join(__dirname, '..', '..', 'preload.js'),
contextIsolation: true, contextIsolation: true,
@@ -115,6 +116,7 @@ export function createOverlayWindow(
} }
window.webContents.on('before-input-event', (event, input) => { window.webContents.on('before-input-event', (event, input) => {
if (kind === 'modal') return;
if (!options.isOverlayVisible(kind)) return; if (!options.isOverlayVisible(kind)) return;
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return; if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
event.preventDefault(); event.preventDefault();

View File

@@ -567,6 +567,26 @@ process.on('SIGTERM', () => {
}); });
const overlayManager = createOverlayManager(); 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 = const buildOverlayContentMeasurementStoreMainDepsHandler =
createBuildOverlayContentMeasurementStoreMainDepsHandler({ createBuildOverlayContentMeasurementStoreMainDepsHandler({
now: () => Date.now(), now: () => Date.now(),
@@ -575,6 +595,10 @@ const buildOverlayContentMeasurementStoreMainDepsHandler =
const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({ const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
getInvisibleWindow: () => overlayManager.getInvisibleWindow(), getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getModalWindow: () => overlayManager.getModalWindow(),
createModalWindow: () => createModalWindow(),
getModalGeometry: () => getCurrentOverlayGeometry(),
setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry),
}); });
const overlayContentMeasurementStoreMainDeps = buildOverlayContentMeasurementStoreMainDepsHandler(); const overlayContentMeasurementStoreMainDeps = buildOverlayContentMeasurementStoreMainDepsHandler();
const overlayContentMeasurementStore = createOverlayContentMeasurementStore( const overlayContentMeasurementStore = createOverlayContentMeasurementStore(
@@ -582,6 +606,9 @@ const overlayContentMeasurementStore = createOverlayContentMeasurementStore(
); );
const overlayModalRuntime = createOverlayModalRuntimeService( const overlayModalRuntime = createOverlayModalRuntimeService(
buildOverlayModalRuntimeMainDepsHandler(), buildOverlayModalRuntimeMainDepsHandler(),
{
onModalStateChange: (isActive: boolean) => handleModalInputStateChange(isActive),
},
); );
const appState = createAppState({ const appState = createAppState({
mpvSocketPath: getDefaultSocketPath(), mpvSocketPath: getDefaultSocketPath(),
@@ -789,6 +816,13 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
}, },
})(), })(),
); );
syncOverlayShortcutsForModal = (isActive: boolean): void => {
if (isActive) {
overlayShortcutsRuntime.unregisterOverlayShortcuts();
} else {
overlayShortcutsRuntime.syncOverlayShortcuts();
}
};
const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler( const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler(
{ {
@@ -2216,6 +2250,7 @@ function applyOverlayRegions(layer: 'visible' | 'invisible', geometry: WindowGeo
const regions = splitOverlayGeometryForSecondaryBar(geometry); const regions = splitOverlayGeometryForSecondaryBar(geometry);
overlayManager.setOverlayWindowBounds(layer, regions.primary); overlayManager.setOverlayWindowBounds(layer, regions.primary);
overlayManager.setSecondaryWindowBounds(regions.secondary); overlayManager.setSecondaryWindowBounds(regions.secondary);
overlayManager.setModalWindowBounds(geometry);
syncSecondaryOverlayWindowVisibility(); syncSecondaryOverlayWindowVisibility();
} }
@@ -2276,10 +2311,20 @@ async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
return yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); return yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
} }
function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary'): BrowserWindow { function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary' | 'modal'): BrowserWindow {
return createOverlayWindowHandler(kind); 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 { function createSecondaryWindow(): BrowserWindow {
const existingWindow = overlayManager.getSecondaryWindow(); const existingWindow = overlayManager.getSecondaryWindow();
if (existingWindow && !existingWindow.isDestroyed()) { if (existingWindow && !existingWindow.isDestroyed()) {
@@ -2742,6 +2787,7 @@ const {
createMainWindow: createMainWindowHandler, createMainWindow: createMainWindowHandler,
createInvisibleWindow: createInvisibleWindowHandler, createInvisibleWindow: createInvisibleWindowHandler,
createSecondaryWindow: createSecondaryWindowHandler, createSecondaryWindow: createSecondaryWindowHandler,
createModalWindow: createModalWindowHandler,
} = createOverlayWindowRuntimeHandlers<BrowserWindow>({ } = createOverlayWindowRuntimeHandlers<BrowserWindow>({
createOverlayWindowDeps: { createOverlayWindowDeps: {
createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options),
@@ -2763,14 +2809,17 @@ const {
overlayManager.setMainWindow(null); overlayManager.setMainWindow(null);
} else if (windowKind === 'invisible') { } else if (windowKind === 'invisible') {
overlayManager.setInvisibleWindow(null); overlayManager.setInvisibleWindow(null);
} else { } else if (windowKind === 'secondary') {
overlayManager.setSecondaryWindow(null); overlayManager.setSecondaryWindow(null);
} else {
overlayManager.setModalWindow(null);
} }
}, },
}, },
setMainWindow: (window) => overlayManager.setMainWindow(window), setMainWindow: (window) => overlayManager.setMainWindow(window),
setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window), setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window),
setSecondaryWindow: (window) => overlayManager.setSecondaryWindow(window), setSecondaryWindow: (window) => overlayManager.setSecondaryWindow(window),
setModalWindow: (window) => overlayManager.setModalWindow(window),
}); });
const { const {
resolveTrayIconPath: resolveTrayIconPathHandler, resolveTrayIconPath: resolveTrayIconPathHandler,

View File

@@ -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<typeof createMockWindow> | 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);
});

View File

@@ -1,11 +1,16 @@
import type { BrowserWindow } from 'electron'; 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'; type OverlayHostLayer = 'visible' | 'invisible';
export interface OverlayWindowResolver { export interface OverlayWindowResolver {
getMainWindow: () => BrowserWindow | null; getMainWindow: () => BrowserWindow | null;
getInvisibleWindow: () => BrowserWindow | null; getInvisibleWindow: () => BrowserWindow | null;
getModalWindow: () => BrowserWindow | null;
createModalWindow: () => BrowserWindow | null;
getModalGeometry: () => WindowGeometry;
setModalWindowBounds: (geometry: WindowGeometry) => void;
} }
export interface OverlayModalRuntime { export interface OverlayModalRuntime {
@@ -19,9 +24,34 @@ export interface OverlayModalRuntime {
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>; getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
} }
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<OverlayHostedModal>(); const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
const overlayModalAutoShownLayer = new Map<OverlayHostedModal, OverlayHostLayer>(); 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 = (): { const getTargetOverlayWindow = (): {
window: BrowserWindow; window: BrowserWindow;
@@ -41,6 +71,15 @@ export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): O
return null; 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 => { const showOverlayWindowForModal = (window: BrowserWindow, layer: OverlayHostLayer): void => {
if (layer === 'invisible' && typeof window.showInactive === 'function') { if (layer === 'invisible' && typeof window.showInactive === 'function') {
window.showInactive(); window.showInactive();
@@ -57,39 +96,66 @@ export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): O
payload?: unknown, payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
): boolean => { ): 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(); const target = getTargetOverlayWindow();
if (!target) return false; if (!target) return false;
const { window: targetWindow, layer } = target; const { window: targetWindow, layer } = target;
const wasVisible = targetWindow.isVisible(); 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) { if (!wasVisible) {
showOverlayWindowForModal(targetWindow, layer); showOverlayWindowForModal(targetWindow, layer);
} }
if (!wasVisible && restoreOnModalClose) {
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
overlayModalAutoShownLayer.set(restoreOnModalClose, layer);
}
if (targetWindow.webContents.isLoading()) { if (targetWindow.webContents.isLoading()) {
targetWindow.webContents.once('did-finish-load', () => { targetWindow.webContents.once('did-finish-load', () => {
if (targetWindow && !targetWindow.isDestroyed() && !targetWindow.webContents.isLoading()) { if (targetWindow && !targetWindow.isDestroyed() && !targetWindow.webContents.isLoading()) {
sendNow(); sendNow(targetWindow);
} }
}); });
return true; return true;
} }
sendNow(); sendNow(targetWindow);
return true; return true;
}; };
@@ -102,24 +168,13 @@ export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): O
const handleOverlayModalClosed = (modal: OverlayHostedModal): void => { const handleOverlayModalClosed = (modal: OverlayHostedModal): void => {
if (!restoreVisibleOverlayOnModalClose.has(modal)) return; if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
restoreVisibleOverlayOnModalClose.delete(modal); restoreVisibleOverlayOnModalClose.delete(modal);
const layer = overlayModalAutoShownLayer.get(modal); const modalWindow = deps.getModalWindow();
overlayModalAutoShownLayer.delete(modal); if (!modalWindow || modalWindow.isDestroyed()) return;
if (!layer) return; if (restoreVisibleOverlayOnModalClose.size === 0) {
const shouldKeepLayerVisible = [...restoreVisibleOverlayOnModalClose].some( notifyModalStateChange(false);
(pendingModal) => overlayModalAutoShownLayer.get(pendingModal) === layer,
);
if (shouldKeepLayerVisible) return;
if (layer === 'visible') {
const mainWindow = deps.getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.hide();
}
return;
} }
const invisibleWindow = deps.getInvisibleWindow(); if (restoreVisibleOverlayOnModalClose.size === 0) {
if (invisibleWindow && !invisibleWindow.isDestroyed()) { modalWindow.hide();
invisibleWindow.hide();
} }
}; };

View File

@@ -20,11 +20,23 @@ test('overlay content measurement store main deps builder maps callbacks', () =>
test('overlay modal runtime main deps builder maps window resolvers', () => { test('overlay modal runtime main deps builder maps window resolvers', () => {
const mainWindow = { id: 'main' }; const mainWindow = { id: 'main' };
const invisibleWindow = { id: 'invisible' }; const invisibleWindow = { id: 'invisible' };
const modalWindow = { id: 'modal' };
const calls: string[] = [];
const deps = createBuildOverlayModalRuntimeMainDepsHandler({ const deps = createBuildOverlayModalRuntimeMainDepsHandler({
getMainWindow: () => mainWindow as never, getMainWindow: () => mainWindow as never,
getInvisibleWindow: () => invisibleWindow 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.getMainWindow(), mainWindow);
assert.equal(deps.getInvisibleWindow(), invisibleWindow); 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']);
}); });

View File

@@ -14,9 +14,15 @@ export function createBuildOverlayContentMeasurementStoreMainDepsHandler(
}); });
} }
export function createBuildOverlayModalRuntimeMainDepsHandler(deps: OverlayWindowResolver) { export function createBuildOverlayModalRuntimeMainDepsHandler(
deps: OverlayWindowResolver,
) {
return (): OverlayWindowResolver => ({ return (): OverlayWindowResolver => ({
getMainWindow: () => deps.getMainWindow(), getMainWindow: () => deps.getMainWindow(),
getInvisibleWindow: () => deps.getInvisibleWindow(), getInvisibleWindow: () => deps.getInvisibleWindow(),
getModalWindow: () => deps.getModalWindow(),
createModalWindow: () => deps.createModalWindow(),
getModalGeometry: () => deps.getModalGeometry(),
setModalWindowBounds: (geometry) => deps.setModalWindowBounds(geometry),
}); });
} }

View File

@@ -3,6 +3,7 @@ import test from 'node:test';
import { import {
createBuildCreateInvisibleWindowMainDepsHandler, createBuildCreateInvisibleWindowMainDepsHandler,
createBuildCreateMainWindowMainDepsHandler, createBuildCreateMainWindowMainDepsHandler,
createBuildCreateModalWindowMainDepsHandler,
createBuildCreateOverlayWindowMainDepsHandler, createBuildCreateOverlayWindowMainDepsHandler,
createBuildCreateSecondaryWindowMainDepsHandler, createBuildCreateSecondaryWindowMainDepsHandler,
} from './overlay-window-factory-main-deps'; } from './overlay-window-factory-main-deps';
@@ -47,5 +48,12 @@ test('overlay window factory main deps builders return mapped handlers', () => {
const secondaryDeps = buildSecondaryDeps(); const secondaryDeps = buildSecondaryDeps();
secondaryDeps.setSecondaryWindow(null); 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']);
}); });

View File

@@ -1,15 +1,15 @@
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: { export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindowCore: ( createOverlayWindowCore: (
kind: 'visible' | 'invisible' | 'secondary', kind: 'visible' | 'invisible' | 'secondary' | 'modal',
options: { options: {
isDev: boolean; isDev: boolean;
overlayDebugVisualizationEnabled: boolean; overlayDebugVisualizationEnabled: boolean;
ensureOverlayWindowLevel: (window: TWindow) => void; ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void; onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary') => boolean; isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary') => void; onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => void;
}, },
) => TWindow; ) => TWindow;
isDev: boolean; isDev: boolean;
@@ -17,9 +17,9 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
ensureOverlayWindowLevel: (window: TWindow) => void; ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void; onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary') => boolean; isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary') => void; onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => void;
}) { }) {
return () => ({ return () => ({
createOverlayWindowCore: deps.createOverlayWindowCore, createOverlayWindowCore: deps.createOverlayWindowCore,
@@ -35,7 +35,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
} }
export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: { export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow; createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
setMainWindow: (window: TWindow | null) => void; setMainWindow: (window: TWindow | null) => void;
}) { }) {
return () => ({ return () => ({
@@ -45,7 +45,7 @@ export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
} }
export function createBuildCreateInvisibleWindowMainDepsHandler<TWindow>(deps: { export function createBuildCreateInvisibleWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow; createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
setInvisibleWindow: (window: TWindow | null) => void; setInvisibleWindow: (window: TWindow | null) => void;
}) { }) {
return () => ({ return () => ({
@@ -55,7 +55,7 @@ export function createBuildCreateInvisibleWindowMainDepsHandler<TWindow>(deps: {
} }
export function createBuildCreateSecondaryWindowMainDepsHandler<TWindow>(deps: { export function createBuildCreateSecondaryWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow; createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
setSecondaryWindow: (window: TWindow | null) => void; setSecondaryWindow: (window: TWindow | null) => void;
}) { }) {
return () => ({ return () => ({
@@ -63,3 +63,13 @@ export function createBuildCreateSecondaryWindowMainDepsHandler<TWindow>(deps: {
setSecondaryWindow: deps.setSecondaryWindow, setSecondaryWindow: deps.setSecondaryWindow,
}); });
} }
export function createBuildCreateModalWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
setModalWindow: (window: TWindow | null) => void;
}) {
return () => ({
createOverlayWindow: deps.createOverlayWindow,
setModalWindow: deps.setModalWindow,
});
}

View File

@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { import {
createCreateInvisibleWindowHandler, createCreateInvisibleWindowHandler,
createCreateMainWindowHandler, createCreateMainWindowHandler,
createCreateModalWindowHandler,
createCreateOverlayWindowHandler, createCreateOverlayWindowHandler,
createCreateSecondaryWindowHandler, createCreateSecondaryWindowHandler,
} from './overlay-window-factory'; } from './overlay-window-factory';
@@ -80,3 +81,18 @@ test('create secondary window handler stores secondary window', () => {
assert.equal(createSecondaryWindow(), secondaryWindow); assert.equal(createSecondaryWindow(), secondaryWindow);
assert.deepEqual(calls, ['create:secondary', 'set:secondary']); 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']);
});

View File

@@ -1,4 +1,4 @@
type OverlayWindowKind = 'visible' | 'invisible' | 'secondary'; type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal';
export function createCreateOverlayWindowHandler<TWindow>(deps: { export function createCreateOverlayWindowHandler<TWindow>(deps: {
createOverlayWindowCore: ( createOverlayWindowCore: (
@@ -69,3 +69,14 @@ export function createCreateSecondaryWindowHandler<TWindow>(deps: {
return window; return window;
}; };
} }
export function createCreateModalWindowHandler<TWindow>(deps: {
createOverlayWindow: (kind: OverlayWindowKind) => TWindow;
setModalWindow: (window: TWindow | null) => void;
}) {
return (): TWindow => {
const window = deps.createOverlayWindow('modal');
deps.setModalWindow(window);
return window;
};
}

View File

@@ -6,6 +6,7 @@ test('overlay window runtime handlers compose create/main/invisible handlers', (
let mainWindow: { kind: string } | null = null; let mainWindow: { kind: string } | null = null;
let invisibleWindow: { kind: string } | null = null; let invisibleWindow: { kind: string } | null = null;
let secondaryWindow: { kind: string } | null = null; let secondaryWindow: { kind: string } | null = null;
let modalWindow: { kind: string } | null = null;
let debugEnabled = false; let debugEnabled = false;
const calls: string[] = []; const calls: string[] = [];
@@ -32,6 +33,9 @@ test('overlay window runtime handlers compose create/main/invisible handlers', (
setSecondaryWindow: (window) => { setSecondaryWindow: (window) => {
secondaryWindow = window; secondaryWindow = window;
}, },
setModalWindow: (window) => {
modalWindow = window;
},
}); });
assert.deepEqual(runtime.createOverlayWindow('visible'), { kind: 'visible' }); 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(runtime.createSecondaryWindow(), { kind: 'secondary' });
assert.deepEqual(secondaryWindow, { kind: 'secondary' }); assert.deepEqual(secondaryWindow, { kind: 'secondary' });
assert.deepEqual(runtime.createModalWindow(), { kind: 'modal' });
assert.deepEqual(modalWindow, { kind: 'modal' });
assert.equal(debugEnabled, false); assert.equal(debugEnabled, false);
assert.deepEqual(calls, []); assert.deepEqual(calls, []);

View File

@@ -1,12 +1,14 @@
import { import {
createCreateInvisibleWindowHandler, createCreateInvisibleWindowHandler,
createCreateMainWindowHandler, createCreateMainWindowHandler,
createCreateModalWindowHandler,
createCreateOverlayWindowHandler, createCreateOverlayWindowHandler,
createCreateSecondaryWindowHandler, createCreateSecondaryWindowHandler,
} from './overlay-window-factory'; } from './overlay-window-factory';
import { import {
createBuildCreateInvisibleWindowMainDepsHandler, createBuildCreateInvisibleWindowMainDepsHandler,
createBuildCreateMainWindowMainDepsHandler, createBuildCreateMainWindowMainDepsHandler,
createBuildCreateModalWindowMainDepsHandler,
createBuildCreateOverlayWindowMainDepsHandler, createBuildCreateOverlayWindowMainDepsHandler,
createBuildCreateSecondaryWindowMainDepsHandler, createBuildCreateSecondaryWindowMainDepsHandler,
} from './overlay-window-factory-main-deps'; } from './overlay-window-factory-main-deps';
@@ -20,6 +22,7 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
setMainWindow: (window: TWindow | null) => void; setMainWindow: (window: TWindow | null) => void;
setInvisibleWindow: (window: TWindow | null) => void; setInvisibleWindow: (window: TWindow | null) => void;
setSecondaryWindow: (window: TWindow | null) => void; setSecondaryWindow: (window: TWindow | null) => void;
setModalWindow: (window: TWindow | null) => void;
}) { }) {
const createOverlayWindow = createCreateOverlayWindowHandler<TWindow>( const createOverlayWindow = createCreateOverlayWindowHandler<TWindow>(
createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps.createOverlayWindowDeps)(), createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps.createOverlayWindowDeps)(),
@@ -42,11 +45,18 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
setSecondaryWindow: (window) => deps.setSecondaryWindow(window), setSecondaryWindow: (window) => deps.setSecondaryWindow(window),
})(), })(),
); );
const createModalWindow = createCreateModalWindowHandler<TWindow>(
createBuildCreateModalWindowMainDepsHandler<TWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind),
setModalWindow: (window) => deps.setModalWindow(window),
})(),
);
return { return {
createOverlayWindow, createOverlayWindow,
createMainWindow, createMainWindow,
createInvisibleWindow, createInvisibleWindow,
createSecondaryWindow, createSecondaryWindow,
createModalWindow,
}; };
} }

View File

@@ -57,7 +57,8 @@ const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length);
const overlayLayer = const overlayLayer =
overlayLayerFromArg === 'visible' || overlayLayerFromArg === 'visible' ||
overlayLayerFromArg === 'invisible' || overlayLayerFromArg === 'invisible' ||
overlayLayerFromArg === 'secondary' overlayLayerFromArg === 'secondary' ||
overlayLayerFromArg === 'modal'
? overlayLayerFromArg ? overlayLayerFromArg
: null; : null;
@@ -253,7 +254,7 @@ const electronAPI: ElectronAPI = {
}, },
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> => appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), 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); ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
}, },
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {

View File

@@ -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,
});
}
});

View File

@@ -17,7 +17,7 @@ export type RendererRecoverySnapshot = {
isOverlayInteractive: boolean; isOverlayInteractive: boolean;
isOverSubtitle: boolean; isOverSubtitle: boolean;
invisiblePositionEditMode: boolean; invisiblePositionEditMode: boolean;
overlayLayer: 'visible' | 'invisible' | 'secondary'; overlayLayer: 'visible' | 'invisible' | 'secondary' | 'modal';
}; };
type NormalizedRendererError = { type NormalizedRendererError = {

View File

@@ -110,6 +110,7 @@ export function createKikuModal(
setKikuPreviewError(null); setKikuPreviewError(null);
ctx.dom.kikuPreviewJson.textContent = ''; ctx.dom.kikuPreviewJson.textContent = '';
window.electronAPI.notifyOverlayModalClosed('kiku');
ctx.state.kikuPendingChoice = null; ctx.state.kikuPendingChoice = null;
ctx.state.kikuPreviewCompactData = null; ctx.state.kikuPreviewCompactData = null;

View File

@@ -567,6 +567,16 @@ body.layer-secondary #secondarySubContainer {
justify-content: center; 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 { #secondarySubRoot {
text-align: center; text-align: center;
font-size: 24px; font-size: 24px;

View File

@@ -1,9 +1,10 @@
export type OverlayLayer = 'visible' | 'invisible' | 'secondary'; export type OverlayLayer = 'visible' | 'invisible' | 'secondary' | 'modal';
export type PlatformInfo = { export type PlatformInfo = {
overlayLayer: OverlayLayer; overlayLayer: OverlayLayer;
isInvisibleLayer: boolean; isInvisibleLayer: boolean;
isSecondaryLayer: boolean; isSecondaryLayer: boolean;
isModalLayer: boolean;
isLinuxPlatform: boolean; isLinuxPlatform: boolean;
isMacOSPlatform: boolean; isMacOSPlatform: boolean;
shouldToggleMouseIgnore: boolean; shouldToggleMouseIgnore: boolean;
@@ -16,7 +17,10 @@ export function resolvePlatformInfo(): PlatformInfo {
const overlayLayerFromPreload = window.electronAPI.getOverlayLayer(); const overlayLayerFromPreload = window.electronAPI.getOverlayLayer();
const queryLayer = new URLSearchParams(window.location.search).get('layer'); const queryLayer = new URLSearchParams(window.location.search).get('layer');
const overlayLayerFromQuery: OverlayLayer | null = const overlayLayerFromQuery: OverlayLayer | null =
queryLayer === 'visible' || queryLayer === 'invisible' || queryLayer === 'secondary' queryLayer === 'visible' ||
queryLayer === 'invisible' ||
queryLayer === 'secondary' ||
queryLayer === 'modal'
? queryLayer ? queryLayer
: null; : null;
@@ -24,12 +28,14 @@ export function resolvePlatformInfo(): PlatformInfo {
overlayLayerFromQuery ?? overlayLayerFromQuery ??
(overlayLayerFromPreload === 'visible' || (overlayLayerFromPreload === 'visible' ||
overlayLayerFromPreload === 'invisible' || overlayLayerFromPreload === 'invisible' ||
overlayLayerFromPreload === 'secondary' overlayLayerFromPreload === 'secondary' ||
overlayLayerFromPreload === 'modal'
? overlayLayerFromPreload ? overlayLayerFromPreload
: 'visible'); : 'visible');
const isInvisibleLayer = overlayLayer === 'invisible'; const isInvisibleLayer = overlayLayer === 'invisible';
const isSecondaryLayer = overlayLayer === 'secondary'; const isSecondaryLayer = overlayLayer === 'secondary';
const isModalLayer = overlayLayer === 'modal';
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux'); const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
const isMacOSPlatform = const isMacOSPlatform =
navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent); navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent);
@@ -38,9 +44,10 @@ export function resolvePlatformInfo(): PlatformInfo {
overlayLayer, overlayLayer,
isInvisibleLayer, isInvisibleLayer,
isSecondaryLayer, isSecondaryLayer,
isModalLayer,
isLinuxPlatform, isLinuxPlatform,
isMacOSPlatform, isMacOSPlatform,
shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer, shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer && !isModalLayer,
invisiblePositionEditToggleCode: 'KeyP', invisiblePositionEditToggleCode: 'KeyP',
invisiblePositionStepPx: 1, invisiblePositionStepPx: 1,
invisiblePositionStepFastPx: 4, invisiblePositionStepFastPx: 4,

View File

@@ -1,6 +1,6 @@
import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types'; 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 type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
export const IPC_CHANNELS = { export const IPC_CHANNELS = {

View File

@@ -728,7 +728,7 @@ export interface SubtitleHoverTokenPayload {
} }
export interface ElectronAPI { export interface ElectronAPI {
getOverlayLayer: () => 'visible' | 'invisible' | 'secondary' | null; getOverlayLayer: () => 'visible' | 'invisible' | 'secondary' | 'modal' | null;
onSubtitle: (callback: (data: SubtitleData) => void) => void; onSubtitle: (callback: (data: SubtitleData) => void) => void;
onVisibility: (callback: (visible: boolean) => void) => void; onVisibility: (callback: (visible: boolean) => void) => void;
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void; onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
@@ -780,7 +780,7 @@ export interface ElectronAPI {
onOpenRuntimeOptions: (callback: () => void) => void; onOpenRuntimeOptions: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void;
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>; appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void; notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
reportHoveredSubtitleToken: (tokenIndex: number | null) => void; reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;