mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-04 00:41:33 -07:00
Windows update (#49)
This commit is contained in:
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { createRendererRecoveryController } from './error-recovery.js';
|
||||
import {
|
||||
YOMITAN_POPUP_HOST_SELECTOR,
|
||||
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||
hasYomitanPopupIframe,
|
||||
isYomitanPopupIframe,
|
||||
isYomitanPopupVisible,
|
||||
@@ -228,6 +230,42 @@ test('resolvePlatformInfo supports modal layer and disables mouse-ignore toggles
|
||||
}
|
||||
});
|
||||
|
||||
test('resolvePlatformInfo flags Windows platforms', () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
getOverlayLayer: () => 'visible',
|
||||
},
|
||||
location: { search: '' },
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
platform: 'Win32',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const info = resolvePlatformInfo();
|
||||
assert.equal(info.isWindowsPlatform, true);
|
||||
assert.equal(info.isMacOSPlatform, false);
|
||||
assert.equal(info.isLinuxPlatform, false);
|
||||
assert.equal(info.shouldToggleMouseIgnore, true);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: previousNavigator,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => {
|
||||
const createElement = (options: {
|
||||
tagName: string;
|
||||
@@ -284,9 +322,25 @@ test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
|
||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||
});
|
||||
|
||||
test('hasYomitanPopupIframe falls back to popup host selector for shadow-hosted popups', () => {
|
||||
const selectors: string[] = [];
|
||||
const root = {
|
||||
querySelector: (value: string) => {
|
||||
selectors.push(value);
|
||||
if (value === YOMITAN_POPUP_HOST_SELECTOR) {
|
||||
return {};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as ParentNode;
|
||||
|
||||
assert.equal(hasYomitanPopupIframe(root), true);
|
||||
assert.deepEqual(selectors, [YOMITAN_POPUP_IFRAME_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR]);
|
||||
});
|
||||
|
||||
test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
let selector = '';
|
||||
const selectors: string[] = [];
|
||||
const visibleFrame = {
|
||||
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
||||
} as unknown as HTMLIFrameElement;
|
||||
@@ -309,18 +363,40 @@ test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
||||
try {
|
||||
const root = {
|
||||
querySelectorAll: (value: string) => {
|
||||
selector = value;
|
||||
selectors.push(value);
|
||||
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || value === YOMITAN_POPUP_HOST_SELECTOR) {
|
||||
return [];
|
||||
}
|
||||
return [hiddenFrame, visibleFrame];
|
||||
},
|
||||
} as unknown as ParentNode;
|
||||
|
||||
assert.equal(isYomitanPopupVisible(root), true);
|
||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||
assert.deepEqual(selectors, [
|
||||
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||
]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('isYomitanPopupVisible detects visible shadow-hosted popup marker without iframe access', () => {
|
||||
let selector = '';
|
||||
const root = {
|
||||
querySelectorAll: (value: string) => {
|
||||
selector = value;
|
||||
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) {
|
||||
return [{ getAttribute: () => 'true' }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
} as unknown as ParentNode;
|
||||
|
||||
assert.equal(isYomitanPopupVisible(root), true);
|
||||
assert.equal(selector, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
|
||||
});
|
||||
|
||||
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
|
||||
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
|
||||
const activeItem = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import { createKeyboardHandlers } from './keyboard.js';
|
||||
import { createRendererState } from '../state.js';
|
||||
import type { CompiledSessionBinding } from '../../types';
|
||||
import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js';
|
||||
|
||||
type CommandEventDetail = {
|
||||
@@ -50,6 +51,8 @@ function installKeyboardTestGlobals() {
|
||||
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
const commandEvents: CommandEventDetail[] = [];
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
|
||||
let sessionBindings: CompiledSessionBinding[] = [];
|
||||
let playbackPausedResponse: boolean | null = false;
|
||||
let statsToggleKey = 'Backquote';
|
||||
let markWatchedKey = 'KeyW';
|
||||
@@ -66,11 +69,16 @@ function installKeyboardTestGlobals() {
|
||||
markAudioCard: '',
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openSessionHelp: 'CommandOrControl+Shift+H',
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: '',
|
||||
toggleVisibleOverlayGlobal: '',
|
||||
};
|
||||
let markActiveVideoWatchedResult = true;
|
||||
let markActiveVideoWatchedCalls = 0;
|
||||
let statsToggleOverlayCalls = 0;
|
||||
const openedModalNotifications: string[] = [];
|
||||
let selectionClearCount = 0;
|
||||
let selectionAddCount = 0;
|
||||
|
||||
@@ -153,10 +161,14 @@ function installKeyboardTestGlobals() {
|
||||
},
|
||||
electronAPI: {
|
||||
getKeybindings: async () => [],
|
||||
getSessionBindings: async () => sessionBindings,
|
||||
getConfiguredShortcuts: async () => configuredShortcuts,
|
||||
sendMpvCommand: (command: Array<string | number>) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
dispatchSessionAction: async (actionId: string, payload?: unknown) => {
|
||||
sessionActions.push({ actionId, payload });
|
||||
},
|
||||
getPlaybackPaused: async () => playbackPausedResponse,
|
||||
getStatsToggleKey: async () => statsToggleKey,
|
||||
getMarkWatchedKey: async () => markWatchedKey,
|
||||
@@ -172,6 +184,9 @@ function installKeyboardTestGlobals() {
|
||||
focusMainWindowCalls += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
notifyOverlayModalOpened: (modal: string) => {
|
||||
openedModalNotifications.push(modal);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -273,6 +288,7 @@ function installKeyboardTestGlobals() {
|
||||
return {
|
||||
commandEvents,
|
||||
mpvCommands,
|
||||
sessionActions,
|
||||
overlay,
|
||||
overlayFocusCalls,
|
||||
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||
@@ -292,11 +308,15 @@ function installKeyboardTestGlobals() {
|
||||
setConfiguredShortcuts: (value: typeof configuredShortcuts) => {
|
||||
configuredShortcuts = value;
|
||||
},
|
||||
setSessionBindings: (value: CompiledSessionBinding[]) => {
|
||||
sessionBindings = value;
|
||||
},
|
||||
setMarkActiveVideoWatchedResult: (value: boolean) => {
|
||||
markActiveVideoWatchedResult = value;
|
||||
},
|
||||
markActiveVideoWatchedCalls: () => markActiveVideoWatchedCalls,
|
||||
statsToggleOverlayCalls: () => statsToggleOverlayCalls,
|
||||
openedModalNotifications,
|
||||
getPlaybackPaused: async () => playbackPausedResponse,
|
||||
setPlaybackPausedResponse: (value: boolean | null) => {
|
||||
playbackPausedResponse = value;
|
||||
@@ -310,9 +330,9 @@ function installKeyboardTestGlobals() {
|
||||
function createKeyboardHandlerHarness() {
|
||||
const testGlobals = installKeyboardTestGlobals();
|
||||
const subtitleRootClassList = createClassList();
|
||||
let controllerSelectOpenCount = 0;
|
||||
let controllerDebugOpenCount = 0;
|
||||
let controllerSelectKeydownCount = 0;
|
||||
let openControllerSelectCount = 0;
|
||||
let openControllerDebugCount = 0;
|
||||
let playlistBrowserKeydownCount = 0;
|
||||
|
||||
const createWordNode = (left: number) => ({
|
||||
@@ -360,23 +380,23 @@ function createKeyboardHandlerHarness() {
|
||||
},
|
||||
handleSessionHelpKeydown: () => false,
|
||||
openSessionHelpModal: () => {},
|
||||
appendClipboardVideoToQueue: () => {},
|
||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||
openControllerSelectModal: () => {
|
||||
controllerSelectOpenCount += 1;
|
||||
openControllerSelectCount += 1;
|
||||
},
|
||||
openControllerDebugModal: () => {
|
||||
controllerDebugOpenCount += 1;
|
||||
openControllerDebugCount += 1;
|
||||
},
|
||||
appendClipboardVideoToQueue: () => {},
|
||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
handlers,
|
||||
testGlobals,
|
||||
controllerSelectOpenCount: () => controllerSelectOpenCount,
|
||||
controllerDebugOpenCount: () => controllerDebugOpenCount,
|
||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||
openControllerSelectCount: () => openControllerSelectCount,
|
||||
openControllerDebugCount: () => openControllerDebugCount,
|
||||
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
@@ -384,6 +404,88 @@ function createKeyboardHandlerHarness() {
|
||||
};
|
||||
}
|
||||
|
||||
test('session help chord resolver follows remapped session bindings', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
|
||||
bindingKey: 'KeyH',
|
||||
fallbackUsed: false,
|
||||
fallbackUnavailable: false,
|
||||
});
|
||||
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'KeyH',
|
||||
key: { code: 'KeyH', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openJimaku',
|
||||
},
|
||||
{
|
||||
sourcePath: 'keybindings[1].key',
|
||||
originalKey: 'KeyJ',
|
||||
key: { code: 'KeyJ', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['cycle', 'pause'],
|
||||
},
|
||||
] as never);
|
||||
|
||||
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
|
||||
bindingKey: 'KeyK',
|
||||
fallbackUsed: true,
|
||||
fallbackUnavailable: false,
|
||||
});
|
||||
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'KeyH',
|
||||
key: { code: 'KeyH', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSessionHelp',
|
||||
},
|
||||
{
|
||||
sourcePath: 'keybindings[1].key',
|
||||
originalKey: 'KeyK',
|
||||
key: { code: 'KeyK', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openControllerSelect',
|
||||
},
|
||||
] as never);
|
||||
|
||||
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
|
||||
bindingKey: 'KeyK',
|
||||
fallbackUsed: true,
|
||||
fallbackUnavailable: true,
|
||||
});
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('numeric selection ignores non-digit keys instead of falling through to other shortcuts', async () => {
|
||||
const { handlers, testGlobals, ctx } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.beginSessionNumericSelection('copySubtitleMultiple');
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' });
|
||||
|
||||
assert.equal(ctx.state.chordPending, false);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.equal(
|
||||
testGlobals.commandEvents.some((event) => event.type === 'forwardKeyDown'),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: left and right move token selection while popup remains open', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -521,13 +623,19 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => {
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateKeybindings([
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
key: 'Space',
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'Space',
|
||||
key: { code: 'Space', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['cycle', 'pause'],
|
||||
},
|
||||
{
|
||||
key: 'KeyQ',
|
||||
sourcePath: 'keybindings[1].key',
|
||||
originalKey: 'KeyQ',
|
||||
key: { code: 'KeyQ', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['quit'],
|
||||
},
|
||||
] as never);
|
||||
@@ -549,9 +657,12 @@ test('paused configured subtitle-jump keybinding re-applies pause after backward
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateKeybindings([
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
key: 'Shift+KeyH',
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'Shift+KeyH',
|
||||
key: { code: 'KeyH', modifiers: ['shift'] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['sub-seek', -1],
|
||||
},
|
||||
] as never);
|
||||
@@ -574,9 +685,12 @@ test('configured subtitle-jump keybinding preserves pause when pause state is un
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateKeybindings([
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
key: 'Shift+KeyH',
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'Shift+KeyH',
|
||||
key: { code: 'KeyH', modifiers: ['shift'] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['sub-seek', -1],
|
||||
},
|
||||
] as never);
|
||||
@@ -614,6 +728,44 @@ test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus',
|
||||
}
|
||||
});
|
||||
|
||||
test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.setConfiguredShortcuts({
|
||||
copySubtitle: '',
|
||||
copySubtitleMultiple: '',
|
||||
updateLastCardFromClipboard: '',
|
||||
triggerFieldGrouping: '',
|
||||
triggerSubsync: 'Ctrl+Alt+S',
|
||||
mineSentence: '',
|
||||
mineSentenceMultiple: '',
|
||||
multiCopyTimeoutMs: 3333,
|
||||
toggleSecondarySub: '',
|
||||
markAudioCard: '',
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openSessionHelp: 'CommandOrControl+Shift+H',
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: '',
|
||||
toggleVisibleOverlayGlobal: '',
|
||||
});
|
||||
testGlobals.setStatsToggleKey('');
|
||||
testGlobals.setMarkWatchedKey('');
|
||||
|
||||
await handlers.refreshConfiguredShortcuts();
|
||||
|
||||
assert.equal(ctx.state.sessionActionTimeoutMs, 3333);
|
||||
assert.equal(ctx.state.statsToggleKey, '');
|
||||
assert.equal(ctx.state.markWatchedKey, '');
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -636,31 +788,111 @@ test('keyboard mode: controller helpers dispatch popup audio play/cycle and scro
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => {
|
||||
const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
||||
test('keyboard mode: configured controller select binding opens locally without dispatching a session action', async () => {
|
||||
const { testGlobals, handlers, openControllerSelectCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openControllerSelect',
|
||||
originalKey: 'Alt+D',
|
||||
key: { code: 'KeyD', modifiers: ['alt'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openControllerSelect',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'C',
|
||||
code: 'KeyC',
|
||||
key: 'd',
|
||||
code: 'KeyD',
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(controllerDebugOpenCount(), 1);
|
||||
assert.equal(openControllerSelectCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-select']);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => {
|
||||
const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
||||
test('keyboard mode: configured controller debug binding opens locally without dispatching a session action', async () => {
|
||||
const { testGlobals, handlers, openControllerDebugCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openControllerDebug',
|
||||
originalKey: 'Alt+Shift+D',
|
||||
key: { code: 'KeyD', modifiers: ['alt', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openControllerDebug',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'D',
|
||||
code: 'KeyD',
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(openControllerDebugCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: configured controller debug binding is not swallowed while popup is visible', async () => {
|
||||
const { ctx, testGlobals, handlers, openControllerDebugCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openControllerDebug',
|
||||
originalKey: 'Alt+Shift+D',
|
||||
key: { code: 'KeyD', modifiers: ['alt', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openControllerDebug',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'D',
|
||||
code: 'KeyD',
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(openControllerDebugCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: former fixed Alt+Shift+C does nothing when controller debug is remapped', async () => {
|
||||
const { testGlobals, handlers } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openControllerDebug',
|
||||
originalKey: 'Alt+Shift+D',
|
||||
key: { code: 'KeyD', modifiers: ['alt', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openControllerDebug',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'C',
|
||||
@@ -669,7 +901,7 @@ test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup i
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(controllerDebugOpenCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
@@ -758,18 +990,47 @@ test('keyboard mode: configured stats toggle works even while popup is open', as
|
||||
}
|
||||
});
|
||||
|
||||
test('refreshConfiguredShortcuts updates refreshed stats and mark-watched keys', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.setStatsToggleKey('KeyG');
|
||||
testGlobals.setMarkWatchedKey('KeyM');
|
||||
await handlers.refreshConfiguredShortcuts();
|
||||
|
||||
const beforeMarkWatchedCalls = testGlobals.markActiveVideoWatchedCalls();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
testGlobals.dispatchKeydown({ key: 'm', code: 'KeyM' });
|
||||
await wait(10);
|
||||
|
||||
assert.equal(testGlobals.statsToggleOverlayCalls(), 1);
|
||||
assert.equal(testGlobals.markActiveVideoWatchedCalls(), beforeMarkWatchedCalls + 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('youtube picker: unhandled keys still dispatch mpv keybindings', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateKeybindings([
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
key: 'Space',
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'Space',
|
||||
key: { code: 'Space', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['cycle', 'pause'],
|
||||
},
|
||||
{
|
||||
key: 'KeyQ',
|
||||
sourcePath: 'keybindings[1].key',
|
||||
originalKey: 'KeyQ',
|
||||
key: { code: 'KeyQ', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['quit'],
|
||||
},
|
||||
] as never);
|
||||
@@ -785,46 +1046,72 @@ test('youtube picker: unhandled keys still dispatch mpv keybindings', async () =
|
||||
}
|
||||
});
|
||||
|
||||
test('linux overlay shortcut: Ctrl+Alt+S dispatches subsync special command locally', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
test('session binding: Ctrl+Alt+S dispatches subsync action locally', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
ctx.platform.isLinuxPlatform = true;
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.triggerSubsync',
|
||||
originalKey: 'Ctrl+Alt+S',
|
||||
key: { code: 'KeyS', modifiers: ['ctrl', 'alt'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'triggerSubsync',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS', ctrlKey: true, altKey: true });
|
||||
|
||||
assert.deepEqual(testGlobals.mpvCommands, [['__subsync-trigger']]);
|
||||
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'triggerSubsync', payload: undefined }]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('linux overlay shortcut: Ctrl+Shift+J dispatches jimaku special command locally', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
test('session binding: Ctrl+Shift+J dispatches jimaku action locally', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
ctx.platform.isLinuxPlatform = true;
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openJimaku',
|
||||
originalKey: 'Ctrl+Shift+J',
|
||||
key: { code: 'KeyJ', modifiers: ['ctrl', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openJimaku',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'J', code: 'KeyJ', ctrlKey: true, shiftKey: true });
|
||||
|
||||
assert.deepEqual(testGlobals.mpvCommands, [['__jimaku-open']]);
|
||||
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openJimaku', payload: undefined }]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('linux overlay shortcut: CommandOrControl+Shift+O dispatches runtime options locally', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
test('session binding: Ctrl+Shift+O dispatches runtime options locally', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
ctx.platform.isLinuxPlatform = true;
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openRuntimeOptions',
|
||||
originalKey: 'CommandOrControl+Shift+O',
|
||||
key: { code: 'KeyO', modifiers: ['ctrl', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openRuntimeOptions',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', ctrlKey: true, shiftKey: true });
|
||||
|
||||
assert.deepEqual(testGlobals.mpvCommands, [['__runtime-options-open']]);
|
||||
assert.deepEqual(testGlobals.sessionActions, [
|
||||
{ actionId: 'openRuntimeOptions', payload: undefined },
|
||||
]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
|
||||
+185
-176
@@ -1,5 +1,4 @@
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
import type { Keybinding, ShortcutsConfig } from '../../types';
|
||||
import type { CompiledSessionBinding, ShortcutsConfig } from '../../types';
|
||||
import type { RendererContext } from '../context';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
@@ -26,21 +25,26 @@ export function createKeyboardHandlers(
|
||||
fallbackUsed: boolean;
|
||||
fallbackUnavailable: boolean;
|
||||
}) => void;
|
||||
openControllerSelectModal?: () => void;
|
||||
openControllerDebugModal?: () => void;
|
||||
appendClipboardVideoToQueue: () => void;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
openControllerSelectModal: () => void;
|
||||
openControllerDebugModal: () => void;
|
||||
toggleSubtitleSidebarModal?: () => void;
|
||||
},
|
||||
) {
|
||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||
const CHORD_TIMEOUT_MS = 1000;
|
||||
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
||||
const linuxOverlayShortcutCommands = new Map<string, (string | number)[]>();
|
||||
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
||||
let pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
let resetSelectionToStartOnNextSubtitleSync = false;
|
||||
let lookupScanFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingNumericSelection:
|
||||
| {
|
||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple';
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
const CHORD_MAP = new Map<
|
||||
string,
|
||||
@@ -76,113 +80,143 @@ export function createKeyboardHandlers(
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
function acceleratorToKeyToken(token: string): string | null {
|
||||
const normalized = token.trim();
|
||||
if (!normalized) return null;
|
||||
if (/^[a-z]$/i.test(normalized)) {
|
||||
return `Key${normalized.toUpperCase()}`;
|
||||
function updateConfiguredShortcuts(
|
||||
shortcuts: Required<ShortcutsConfig>,
|
||||
statsToggleKey?: string,
|
||||
markWatchedKey?: string,
|
||||
): void {
|
||||
ctx.state.sessionActionTimeoutMs = shortcuts.multiCopyTimeoutMs;
|
||||
if (typeof statsToggleKey === 'string') {
|
||||
ctx.state.statsToggleKey = statsToggleKey;
|
||||
}
|
||||
if (/^[0-9]$/.test(normalized)) {
|
||||
return `Digit${normalized}`;
|
||||
}
|
||||
const exactMap: Record<string, string> = {
|
||||
space: 'Space',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
return: 'Enter',
|
||||
esc: 'Escape',
|
||||
escape: 'Escape',
|
||||
up: 'ArrowUp',
|
||||
down: 'ArrowDown',
|
||||
left: 'ArrowLeft',
|
||||
right: 'ArrowRight',
|
||||
backspace: 'Backspace',
|
||||
delete: 'Delete',
|
||||
slash: 'Slash',
|
||||
backslash: 'Backslash',
|
||||
minus: 'Minus',
|
||||
plus: 'Equal',
|
||||
equal: 'Equal',
|
||||
comma: 'Comma',
|
||||
period: 'Period',
|
||||
quote: 'Quote',
|
||||
semicolon: 'Semicolon',
|
||||
bracketleft: 'BracketLeft',
|
||||
bracketright: 'BracketRight',
|
||||
backquote: 'Backquote',
|
||||
};
|
||||
const lower = normalized.toLowerCase();
|
||||
if (exactMap[lower]) return exactMap[lower];
|
||||
if (/^key[a-z]$/i.test(normalized) || /^digit[0-9]$/i.test(normalized)) {
|
||||
return normalized[0]!.toUpperCase() + normalized.slice(1);
|
||||
}
|
||||
if (/^arrow(?:up|down|left|right)$/i.test(normalized)) {
|
||||
return normalized[0]!.toUpperCase() + normalized.slice(1);
|
||||
}
|
||||
if (/^f\d{1,2}$/i.test(normalized)) {
|
||||
return normalized.toUpperCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function acceleratorToKeyString(accelerator: string): string | null {
|
||||
const normalized = accelerator.replace(/\s+/g, '').replace(/cmdorctrl/gi, 'CommandOrControl');
|
||||
if (!normalized) return null;
|
||||
const parts = normalized.split('+').filter(Boolean);
|
||||
const keyToken = parts.pop();
|
||||
if (!keyToken) return null;
|
||||
|
||||
const eventParts: string[] = [];
|
||||
for (const modifier of parts) {
|
||||
const lower = modifier.toLowerCase();
|
||||
if (lower === 'ctrl' || lower === 'control') {
|
||||
eventParts.push('Ctrl');
|
||||
continue;
|
||||
}
|
||||
if (lower === 'alt' || lower === 'option') {
|
||||
eventParts.push('Alt');
|
||||
continue;
|
||||
}
|
||||
if (lower === 'shift') {
|
||||
eventParts.push('Shift');
|
||||
continue;
|
||||
}
|
||||
if (lower === 'meta' || lower === 'super' || lower === 'command' || lower === 'cmd') {
|
||||
eventParts.push('Meta');
|
||||
continue;
|
||||
}
|
||||
if (lower === 'commandorcontrol') {
|
||||
eventParts.push(ctx.platform.isMacOSPlatform ? 'Meta' : 'Ctrl');
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedKey = acceleratorToKeyToken(keyToken);
|
||||
if (!normalizedKey) return null;
|
||||
eventParts.push(normalizedKey);
|
||||
return eventParts.join('+');
|
||||
}
|
||||
|
||||
function updateConfiguredShortcuts(shortcuts: Required<ShortcutsConfig>): void {
|
||||
linuxOverlayShortcutCommands.clear();
|
||||
const bindings: Array<[string | null, (string | number)[]]> = [
|
||||
[shortcuts.triggerSubsync, [SPECIAL_COMMANDS.SUBSYNC_TRIGGER]],
|
||||
[shortcuts.openRuntimeOptions, [SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN]],
|
||||
[shortcuts.openJimaku, [SPECIAL_COMMANDS.JIMAKU_OPEN]],
|
||||
];
|
||||
|
||||
for (const [accelerator, command] of bindings) {
|
||||
if (!accelerator) continue;
|
||||
const keyString = acceleratorToKeyString(accelerator);
|
||||
if (keyString) {
|
||||
linuxOverlayShortcutCommands.set(keyString, command);
|
||||
}
|
||||
if (typeof markWatchedKey === 'string') {
|
||||
ctx.state.markWatchedKey = markWatchedKey;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshConfiguredShortcuts(): Promise<void> {
|
||||
updateConfiguredShortcuts(await window.electronAPI.getConfiguredShortcuts());
|
||||
const [shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
|
||||
window.electronAPI.getConfiguredShortcuts(),
|
||||
window.electronAPI.getStatsToggleKey(),
|
||||
window.electronAPI.getMarkWatchedKey(),
|
||||
]);
|
||||
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
|
||||
}
|
||||
|
||||
function updateSessionBindings(bindings: CompiledSessionBinding[]): void {
|
||||
ctx.state.sessionBindings = bindings;
|
||||
ctx.state.sessionBindingMap = new Map(
|
||||
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
|
||||
);
|
||||
}
|
||||
|
||||
function keyEventToStringFromBinding(binding: CompiledSessionBinding): string {
|
||||
const parts: string[] = [];
|
||||
for (const modifier of binding.key.modifiers) {
|
||||
if (modifier === 'ctrl') parts.push('Ctrl');
|
||||
else if (modifier === 'alt') parts.push('Alt');
|
||||
else if (modifier === 'shift') parts.push('Shift');
|
||||
else if (modifier === 'meta') parts.push('Meta');
|
||||
}
|
||||
parts.push(binding.key.code);
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
function isTextEntryTarget(target: EventTarget | null): boolean {
|
||||
if (!target || typeof target !== 'object' || !('closest' in target)) return false;
|
||||
const element = target as { closest: (selector: string) => unknown };
|
||||
if (element.closest('[contenteditable="true"]')) return true;
|
||||
return Boolean(element.closest('input, textarea, select'));
|
||||
}
|
||||
|
||||
function showSessionSelectionMessage(message: string): void {
|
||||
window.electronAPI.sendMpvCommand(['show-text', message, '3000']);
|
||||
}
|
||||
|
||||
function cancelPendingNumericSelection(showCancelled: boolean): void {
|
||||
if (!pendingNumericSelection) return;
|
||||
if (pendingNumericSelection.timeout !== null) {
|
||||
clearTimeout(pendingNumericSelection.timeout);
|
||||
}
|
||||
pendingNumericSelection = null;
|
||||
if (showCancelled) {
|
||||
showSessionSelectionMessage('Cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
function startPendingNumericSelection(
|
||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
||||
): void {
|
||||
cancelPendingNumericSelection(false);
|
||||
const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout';
|
||||
const promptMessage =
|
||||
actionId === 'copySubtitleMultiple'
|
||||
? 'Copy how many lines? Press 1-9 (Esc to cancel)'
|
||||
: 'Mine how many lines? Press 1-9 (Esc to cancel)';
|
||||
pendingNumericSelection = {
|
||||
actionId,
|
||||
timeout: setTimeout(() => {
|
||||
pendingNumericSelection = null;
|
||||
showSessionSelectionMessage(timeoutMessage);
|
||||
}, ctx.state.sessionActionTimeoutMs),
|
||||
};
|
||||
showSessionSelectionMessage(promptMessage);
|
||||
}
|
||||
|
||||
function beginSessionNumericSelection(
|
||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
||||
): void {
|
||||
startPendingNumericSelection(actionId);
|
||||
}
|
||||
|
||||
function handlePendingNumericSelection(e: KeyboardEvent): boolean {
|
||||
if (!pendingNumericSelection) return false;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelPendingNumericSelection(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!/^[1-9]$/.test(e.key) || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const count = Number(e.key);
|
||||
const actionId = pendingNumericSelection.actionId;
|
||||
cancelPendingNumericSelection(false);
|
||||
void window.electronAPI.dispatchSessionAction(actionId, { count });
|
||||
return true;
|
||||
}
|
||||
|
||||
function dispatchSessionBinding(binding: CompiledSessionBinding): void {
|
||||
if (
|
||||
binding.actionType === 'session-action' &&
|
||||
(binding.actionId === 'copySubtitleMultiple' || binding.actionId === 'mineSentenceMultiple')
|
||||
) {
|
||||
startPendingNumericSelection(binding.actionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') {
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||
options.openControllerSelectModal?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerDebug') {
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||
options.openControllerDebugModal?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding.actionType === 'mpv-command') {
|
||||
dispatchConfiguredMpvCommand(binding.command);
|
||||
return;
|
||||
}
|
||||
|
||||
void window.electronAPI.dispatchSessionAction(binding.actionId, binding.payload);
|
||||
}
|
||||
|
||||
function dispatchYomitanPopupKeydown(
|
||||
@@ -292,10 +326,6 @@ export function createKeyboardHandlers(
|
||||
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
||||
}
|
||||
|
||||
function isControllerModalShortcut(e: KeyboardEvent): boolean {
|
||||
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
|
||||
}
|
||||
|
||||
function isSubtitleSidebarToggle(e: KeyboardEvent): boolean {
|
||||
const toggleKey = ctx.state.subtitleSidebarToggleKey;
|
||||
if (!toggleKey) return false;
|
||||
@@ -508,7 +538,7 @@ export function createKeyboardHandlers(
|
||||
clientY: number,
|
||||
modifiers: ScanModifierState = {},
|
||||
): void {
|
||||
if (typeof PointerEvent !== 'undefined') {
|
||||
if (typeof PointerEvent === 'function') {
|
||||
const pointerEventInit = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
@@ -531,23 +561,25 @@ export function createKeyboardHandlers(
|
||||
target.dispatchEvent(new PointerEvent('pointerup', pointerEventInit));
|
||||
}
|
||||
|
||||
const mouseEventInit = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
clientX,
|
||||
clientY,
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
shiftKey: modifiers.shiftKey ?? false,
|
||||
ctrlKey: modifiers.ctrlKey ?? false,
|
||||
altKey: modifiers.altKey ?? false,
|
||||
metaKey: modifiers.metaKey ?? false,
|
||||
} satisfies MouseEventInit;
|
||||
target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit));
|
||||
target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 }));
|
||||
target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit));
|
||||
target.dispatchEvent(new MouseEvent('click', mouseEventInit));
|
||||
if (typeof MouseEvent === 'function') {
|
||||
const mouseEventInit = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
clientX,
|
||||
clientY,
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
shiftKey: modifiers.shiftKey ?? false,
|
||||
ctrlKey: modifiers.ctrlKey ?? false,
|
||||
altKey: modifiers.altKey ?? false,
|
||||
metaKey: modifiers.metaKey ?? false,
|
||||
} satisfies MouseEventInit;
|
||||
target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit));
|
||||
target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 }));
|
||||
target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit));
|
||||
target.dispatchEvent(new MouseEvent('click', mouseEventInit));
|
||||
}
|
||||
}
|
||||
|
||||
function emitLookupScanFallback(target: Element, clientX: number, clientY: number): void {
|
||||
@@ -820,7 +852,7 @@ export function createKeyboardHandlers(
|
||||
if (modifierOnlyCodes.has(e.code)) return false;
|
||||
|
||||
const keyString = keyEventToString(e);
|
||||
if (ctx.state.keybindingsMap.has(keyString)) {
|
||||
if (ctx.state.sessionBindingMap.has(keyString)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -846,7 +878,7 @@ export function createKeyboardHandlers(
|
||||
fallbackUnavailable: boolean;
|
||||
} {
|
||||
const firstChoice = 'KeyH';
|
||||
if (!ctx.state.keybindingsMap.has('KeyH')) {
|
||||
if (!ctx.state.sessionBindingMap.has('KeyH')) {
|
||||
return {
|
||||
bindingKey: firstChoice,
|
||||
fallbackUsed: false,
|
||||
@@ -854,18 +886,18 @@ export function createKeyboardHandlers(
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.state.keybindingsMap.has('KeyK')) {
|
||||
if (!ctx.state.sessionBindingMap.has('KeyK')) {
|
||||
return {
|
||||
bindingKey: 'KeyK',
|
||||
fallbackUsed: true,
|
||||
fallbackUnavailable: true,
|
||||
fallbackUnavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bindingKey: 'KeyK',
|
||||
fallbackUsed: true,
|
||||
fallbackUnavailable: false,
|
||||
fallbackUnavailable: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -890,16 +922,14 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
async function setupMpvInputForwarding(): Promise<void> {
|
||||
const [keybindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
|
||||
window.electronAPI.getKeybindings(),
|
||||
const [sessionBindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
|
||||
window.electronAPI.getSessionBindings(),
|
||||
window.electronAPI.getConfiguredShortcuts(),
|
||||
window.electronAPI.getStatsToggleKey(),
|
||||
window.electronAPI.getMarkWatchedKey(),
|
||||
]);
|
||||
updateKeybindings(keybindings);
|
||||
updateConfiguredShortcuts(shortcuts);
|
||||
ctx.state.statsToggleKey = statsToggleKey;
|
||||
ctx.state.markWatchedKey = markWatchedKey;
|
||||
updateSessionBindings(sessionBindings);
|
||||
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
|
||||
syncKeyboardTokenSelection();
|
||||
|
||||
const subtitleMutationObserver = new MutationObserver(() => {
|
||||
@@ -1006,6 +1036,14 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextEntryTarget(e.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handlePendingNumericSelection(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStatsOverlayToggle(e)) {
|
||||
e.preventDefault();
|
||||
window.electronAPI.toggleStatsOverlay();
|
||||
@@ -1024,10 +1062,7 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
||||
!isControllerModalShortcut(e)
|
||||
) {
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
if (handleYomitanPopupKeybind(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
@@ -1084,30 +1119,11 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isControllerModalShortcut(e)) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
options.openControllerDebugModal();
|
||||
} else {
|
||||
options.openControllerSelectModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const keyString = keyEventToString(e);
|
||||
const linuxOverlayCommand = ctx.platform.isLinuxPlatform
|
||||
? linuxOverlayShortcutCommands.get(keyString)
|
||||
: undefined;
|
||||
if (linuxOverlayCommand) {
|
||||
const binding = ctx.state.sessionBindingMap.get(keyString);
|
||||
if (binding) {
|
||||
e.preventDefault();
|
||||
dispatchConfiguredMpvCommand(linuxOverlayCommand);
|
||||
return;
|
||||
}
|
||||
const command = ctx.state.keybindingsMap.get(keyString);
|
||||
|
||||
if (command) {
|
||||
e.preventDefault();
|
||||
dispatchConfiguredMpvCommand(command);
|
||||
dispatchSessionBinding(binding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1125,19 +1141,12 @@ export function createKeyboardHandlers(
|
||||
});
|
||||
}
|
||||
|
||||
function updateKeybindings(keybindings: Keybinding[]): void {
|
||||
ctx.state.keybindingsMap = new Map();
|
||||
for (const binding of keybindings) {
|
||||
if (binding.command) {
|
||||
ctx.state.keybindingsMap.set(binding.key, binding.command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
beginSessionNumericSelection,
|
||||
getSessionHelpOpeningInfo: resolveSessionHelpChordBinding,
|
||||
setupMpvInputForwarding,
|
||||
refreshConfiguredShortcuts,
|
||||
updateKeybindings,
|
||||
updateSessionBindings,
|
||||
syncKeyboardTokenSelection,
|
||||
handleSubtitleContentUpdated,
|
||||
handleKeyboardModeToggleRequested,
|
||||
|
||||
@@ -3,7 +3,12 @@ import test from 'node:test';
|
||||
|
||||
import type { SubtitleSidebarConfig } from '../../types';
|
||||
import { createMouseHandlers } from './mouse.js';
|
||||
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_HOST_SELECTOR,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||
} from '../yomitan-popup.js';
|
||||
|
||||
function createClassList() {
|
||||
const classes = new Set<string>();
|
||||
@@ -78,11 +83,13 @@ function createMouseTestContext() {
|
||||
},
|
||||
platform: {
|
||||
shouldToggleMouseIgnore: false,
|
||||
isLinuxPlatform: false,
|
||||
isMacOSPlatform: false,
|
||||
},
|
||||
state: {
|
||||
isOverSubtitle: false,
|
||||
isOverSubtitleSidebar: false,
|
||||
yomitanPopupVisible: false,
|
||||
subtitleSidebarModalOpen: false,
|
||||
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
|
||||
isDragging: false,
|
||||
@@ -712,6 +719,257 @@ test('popup open pauses and popup close resumes when yomitan popup auto-pause is
|
||||
}
|
||||
});
|
||||
|
||||
test('nested popup close reasserts interactive state and focus when another popup remains visible on Windows', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||
const previousNode = (globalThis as { Node?: unknown }).Node;
|
||||
const windowListeners = new Map<string, Array<() => void>>();
|
||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||
let focusMainWindowCalls = 0;
|
||||
let windowFocusCalls = 0;
|
||||
let overlayFocusCalls = 0;
|
||||
|
||||
ctx.platform.shouldToggleMouseIgnore = true;
|
||||
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
|
||||
overlayFocusCalls += 1;
|
||||
};
|
||||
|
||||
const visiblePopupHost = {
|
||||
tagName: 'DIV',
|
||||
getAttribute: (name: string) =>
|
||||
name === 'data-subminer-yomitan-popup-visible' ? 'true' : null,
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: (type: string, listener: () => void) => {
|
||||
const bucket = windowListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
windowListeners.set(type, bucket);
|
||||
},
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
focusMainWindow: () => {
|
||||
focusMainWindowCalls += 1;
|
||||
},
|
||||
},
|
||||
focus: () => {
|
||||
windowFocusCalls += 1;
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
visibility: 'visible',
|
||||
display: 'block',
|
||||
opacity: '1',
|
||||
}),
|
||||
innerHeight: 1000,
|
||||
getSelection: () => null,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
querySelector: () => null,
|
||||
querySelectorAll: (selector: string) => {
|
||||
if (
|
||||
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
|
||||
selector === YOMITAN_POPUP_HOST_SELECTOR
|
||||
) {
|
||||
return [visiblePopupHost];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
body: {},
|
||||
elementFromPoint: () => null,
|
||||
addEventListener: () => {},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||
configurable: true,
|
||||
value: class {
|
||||
observe() {}
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Node', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ELEMENT_NODE: 1,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||
getYomitanPopupAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: () => {},
|
||||
});
|
||||
|
||||
handlers.setupYomitanObserver();
|
||||
ignoreCalls.length = 0;
|
||||
|
||||
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
|
||||
listener();
|
||||
}
|
||||
|
||||
assert.equal(ctx.state.yomitanPopupVisible, true);
|
||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||
assert.equal(focusMainWindowCalls, 1);
|
||||
assert.equal(windowFocusCalls, 1);
|
||||
assert.equal(overlayFocusCalls, 1);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||
configurable: true,
|
||||
value: previousMutationObserver,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
||||
}
|
||||
});
|
||||
|
||||
test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||
const previousNode = (globalThis as { Node?: unknown }).Node;
|
||||
const windowListeners = new Map<string, Array<() => void>>();
|
||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||
let focusMainWindowCalls = 0;
|
||||
let windowFocusCalls = 0;
|
||||
let overlayFocusCalls = 0;
|
||||
|
||||
ctx.platform.shouldToggleMouseIgnore = true;
|
||||
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
|
||||
overlayFocusCalls += 1;
|
||||
};
|
||||
|
||||
const visiblePopupHost = {
|
||||
tagName: 'DIV',
|
||||
getAttribute: (name: string) =>
|
||||
name === 'data-subminer-yomitan-popup-visible' ? 'true' : null,
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: (type: string, listener: () => void) => {
|
||||
const bucket = windowListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
windowListeners.set(type, bucket);
|
||||
},
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
focusMainWindow: () => {
|
||||
focusMainWindowCalls += 1;
|
||||
},
|
||||
},
|
||||
focus: () => {
|
||||
windowFocusCalls += 1;
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
visibility: 'visible',
|
||||
display: 'block',
|
||||
opacity: '1',
|
||||
}),
|
||||
innerHeight: 1000,
|
||||
getSelection: () => null,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
visibilityState: 'visible',
|
||||
querySelector: () => null,
|
||||
querySelectorAll: (selector: string) => {
|
||||
if (
|
||||
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
|
||||
selector === YOMITAN_POPUP_HOST_SELECTOR
|
||||
) {
|
||||
return [visiblePopupHost];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
body: {},
|
||||
elementFromPoint: () => null,
|
||||
addEventListener: () => {},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||
configurable: true,
|
||||
value: class {
|
||||
observe() {}
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Node', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ELEMENT_NODE: 1,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||
getYomitanPopupAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: () => {},
|
||||
});
|
||||
|
||||
handlers.setupYomitanObserver();
|
||||
assert.equal(ctx.state.yomitanPopupVisible, true);
|
||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||
ignoreCalls.length = 0;
|
||||
|
||||
for (const listener of windowListeners.get('blur') ?? []) {
|
||||
listener();
|
||||
}
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(ctx.state.yomitanPopupVisible, true);
|
||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||
assert.equal(focusMainWindowCalls, 1);
|
||||
assert.equal(windowFocusCalls, 1);
|
||||
assert.equal(overlayFocusCalls, 1);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||
configurable: true,
|
||||
value: previousMutationObserver,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
||||
}
|
||||
});
|
||||
|
||||
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -783,6 +1041,361 @@ test('restorePointerInteractionState re-enables subtitle hover when pointer is a
|
||||
}
|
||||
});
|
||||
|
||||
test('visibility recovery re-enables subtitle hover without needing a fresh pointer move', () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
let visibilityState: 'hidden' | 'visible' = 'visible';
|
||||
ctx.platform.shouldToggleMouseIgnore = true;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
visibility: 'hidden',
|
||||
display: 'none',
|
||||
opacity: '0',
|
||||
}),
|
||||
focus: () => {},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||
const bucket = documentListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
documentListeners.set(type, bucket);
|
||||
},
|
||||
get visibilityState() {
|
||||
return visibilityState;
|
||||
},
|
||||
elementFromPoint: () => ctx.dom.subtitleContainer,
|
||||
querySelectorAll: () => [],
|
||||
body: {},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||
getYomitanPopupAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: () => {},
|
||||
});
|
||||
|
||||
handlers.setupPointerTracking();
|
||||
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||
listener({ clientX: 120, clientY: 240 });
|
||||
}
|
||||
|
||||
ctx.state.isOverSubtitle = false;
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
ignoreCalls.length = 0;
|
||||
visibilityState = 'hidden';
|
||||
visibilityState = 'visible';
|
||||
|
||||
for (const listener of documentListeners.get('visibilitychange') ?? []) {
|
||||
listener({});
|
||||
}
|
||||
|
||||
assert.equal(ctx.state.isOverSubtitle, true);
|
||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('visibility recovery ignores synthetic subtitle enter until the pointer moves again', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
let hoveredElement: unknown = ctx.dom.subtitleContainer;
|
||||
let visibilityState: 'hidden' | 'visible' = 'visible';
|
||||
let subtitleHoverAutoPauseEnabled = false;
|
||||
ctx.platform.shouldToggleMouseIgnore = true;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
visibility: 'hidden',
|
||||
display: 'none',
|
||||
opacity: '0',
|
||||
}),
|
||||
focus: () => {},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||
const bucket = documentListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
documentListeners.set(type, bucket);
|
||||
},
|
||||
get visibilityState() {
|
||||
return visibilityState;
|
||||
},
|
||||
elementFromPoint: () => hoveredElement,
|
||||
querySelectorAll: () => [],
|
||||
body: {},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => subtitleHoverAutoPauseEnabled,
|
||||
getYomitanPopupAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: (command) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
});
|
||||
|
||||
handlers.setupPointerTracking();
|
||||
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||
listener({ clientX: 120, clientY: 240 });
|
||||
}
|
||||
await waitForNextTick();
|
||||
|
||||
ignoreCalls.length = 0;
|
||||
visibilityState = 'hidden';
|
||||
visibilityState = 'visible';
|
||||
subtitleHoverAutoPauseEnabled = true;
|
||||
for (const listener of documentListeners.get('visibilitychange') ?? []) {
|
||||
listener({});
|
||||
}
|
||||
|
||||
await handlers.handlePrimaryMouseEnter();
|
||||
assert.deepEqual(mpvCommands, []);
|
||||
|
||||
hoveredElement = null;
|
||||
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||
listener({ clientX: 32, clientY: 48 });
|
||||
}
|
||||
|
||||
hoveredElement = ctx.dom.subtitleContainer;
|
||||
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||
listener({ clientX: 120, clientY: 240 });
|
||||
}
|
||||
await waitForNextTick();
|
||||
|
||||
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('window resize ignores synthetic subtitle enter until the pointer moves again', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
const windowListeners = new Map<string, Array<() => void>>();
|
||||
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
let hoveredElement: unknown = ctx.dom.subtitleContainer;
|
||||
let subtitleHoverAutoPauseEnabled = false;
|
||||
ctx.platform.shouldToggleMouseIgnore = true;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: () => {},
|
||||
},
|
||||
addEventListener: (type: string, listener: () => void) => {
|
||||
const bucket = windowListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
windowListeners.set(type, bucket);
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
visibility: 'hidden',
|
||||
display: 'none',
|
||||
opacity: '0',
|
||||
}),
|
||||
focus: () => {},
|
||||
innerHeight: 1000,
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||
const bucket = documentListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
documentListeners.set(type, bucket);
|
||||
},
|
||||
elementFromPoint: () => hoveredElement,
|
||||
querySelectorAll: () => [],
|
||||
body: {},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => subtitleHoverAutoPauseEnabled,
|
||||
getYomitanPopupAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: (command) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
});
|
||||
|
||||
handlers.setupPointerTracking();
|
||||
handlers.setupResizeHandler();
|
||||
|
||||
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||
listener({ clientX: 120, clientY: 240 });
|
||||
}
|
||||
await waitForNextTick();
|
||||
|
||||
subtitleHoverAutoPauseEnabled = true;
|
||||
for (const listener of windowListeners.get('resize') ?? []) {
|
||||
listener();
|
||||
}
|
||||
|
||||
await handlers.handlePrimaryMouseEnter();
|
||||
assert.deepEqual(mpvCommands, []);
|
||||
|
||||
hoveredElement = null;
|
||||
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||
listener({ clientX: 32, clientY: 48 });
|
||||
}
|
||||
|
||||
hoveredElement = ctx.dom.subtitleContainer;
|
||||
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||
listener({ clientX: 120, clientY: 240 });
|
||||
}
|
||||
await waitForNextTick();
|
||||
|
||||
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('visibility recovery keeps overlay click-through when pointer is not over subtitles', () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
let hoveredElement: unknown = null;
|
||||
let visibilityState: 'hidden' | 'visible' = 'visible';
|
||||
ctx.platform.shouldToggleMouseIgnore = true;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
visibility: 'hidden',
|
||||
display: 'none',
|
||||
opacity: '0',
|
||||
}),
|
||||
focus: () => {},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||
const bucket = documentListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
documentListeners.set(type, bucket);
|
||||
},
|
||||
get visibilityState() {
|
||||
return visibilityState;
|
||||
},
|
||||
elementFromPoint: () => hoveredElement,
|
||||
querySelectorAll: () => [],
|
||||
body: {},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||
getYomitanPopupAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: () => {},
|
||||
});
|
||||
|
||||
handlers.setupPointerTracking();
|
||||
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||
listener({ clientX: 320, clientY: 180 });
|
||||
}
|
||||
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ignoreCalls.length = 0;
|
||||
visibilityState = 'hidden';
|
||||
visibilityState = 'visible';
|
||||
|
||||
for (const listener of documentListeners.get('visibilitychange') ?? []) {
|
||||
listener({});
|
||||
}
|
||||
|
||||
assert.equal(ctx.state.isOverSubtitle, false);
|
||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
||||
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('pointer tracking enables overlay interaction as soon as the cursor reaches subtitles', () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -916,10 +1529,8 @@ test('pointer tracking restores click-through after the cursor leaves subtitles'
|
||||
|
||||
assert.equal(ctx.state.isOverSubtitle, false);
|
||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
||||
assert.deepEqual(ignoreCalls, [
|
||||
{ ignore: false, forward: undefined },
|
||||
{ ignore: true, forward: true },
|
||||
]);
|
||||
assert.equal(ignoreCalls[0]?.ignore, false);
|
||||
assert.deepEqual(ignoreCalls.at(-1), { ignore: true, forward: true });
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||
|
||||
+123
-15
@@ -2,11 +2,16 @@ import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
isYomitanPopupVisible,
|
||||
isYomitanPopupIframe,
|
||||
} from '../yomitan-popup.js';
|
||||
|
||||
const VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE = 'visibility-recovery';
|
||||
const WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE = 'window-resize';
|
||||
|
||||
export function createMouseHandlers(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
@@ -33,6 +38,61 @@ export function createMouseHandlers(
|
||||
let pausedByYomitanPopup = false;
|
||||
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
||||
let pendingPointerResync = false;
|
||||
let suppressDirectHoverEnterSource: string | null = null;
|
||||
|
||||
function getPopupVisibilityFromDom(): boolean {
|
||||
return typeof document !== 'undefined' && isYomitanPopupVisible(document);
|
||||
}
|
||||
|
||||
function syncPopupVisibilityState(assumeVisible = false): boolean {
|
||||
const popupVisible = assumeVisible || getPopupVisibilityFromDom();
|
||||
yomitanPopupVisible = popupVisible;
|
||||
ctx.state.yomitanPopupVisible = popupVisible;
|
||||
return popupVisible;
|
||||
}
|
||||
|
||||
function reclaimOverlayWindowFocusForPopup(): void {
|
||||
if (!ctx.platform.shouldToggleMouseIgnore) {
|
||||
return;
|
||||
}
|
||||
if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.electronAPI.focusMainWindow === 'function') {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
}
|
||||
window.focus();
|
||||
if (typeof ctx.dom.overlay.focus === 'function') {
|
||||
ctx.dom.overlay.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function sustainPopupInteraction(): void {
|
||||
syncPopupVisibilityState(true);
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
function reconcilePopupInteraction(args: {
|
||||
assumeVisible?: boolean;
|
||||
reclaimFocus?: boolean;
|
||||
allowPause?: boolean;
|
||||
} = {}): boolean {
|
||||
const popupVisible = syncPopupVisibilityState(args.assumeVisible === true);
|
||||
if (!popupVisible) {
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
return false;
|
||||
}
|
||||
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
if (args.reclaimFocus === true) {
|
||||
reclaimOverlayWindowFocusForPopup();
|
||||
}
|
||||
if (args.allowPause === true) {
|
||||
void maybePauseForYomitanPopup();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
|
||||
if (!element) {
|
||||
@@ -86,6 +146,7 @@ export function createMouseHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
suppressDirectHoverEnterSource = null;
|
||||
const wasOverSubtitle = ctx.state.isOverSubtitle;
|
||||
const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains(
|
||||
'secondary-sub-hover-active',
|
||||
@@ -93,7 +154,7 @@ export function createMouseHandlers(
|
||||
const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY);
|
||||
|
||||
if (!wasOverSubtitle && hoverState.isOverSubtitle) {
|
||||
void handleMouseEnter(undefined, hoverState.overSecondarySubtitle);
|
||||
void handleMouseEnter(undefined, hoverState.overSecondarySubtitle, 'tracked-pointer');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,9 +171,13 @@ export function createMouseHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
function restorePointerInteractionState(): void {
|
||||
function resyncPointerInteractionState(options: {
|
||||
allowInteractiveFallback: boolean;
|
||||
suppressDirectHoverEnterSource?: string | null;
|
||||
}): void {
|
||||
const pointerPosition = lastPointerPosition;
|
||||
pendingPointerResync = false;
|
||||
suppressDirectHoverEnterSource = options.suppressDirectHoverEnterSource ?? null;
|
||||
if (pointerPosition) {
|
||||
syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY);
|
||||
} else {
|
||||
@@ -121,7 +186,11 @@ export function createMouseHandlers(
|
||||
}
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
|
||||
if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isOverSubtitle) {
|
||||
if (
|
||||
!options.allowInteractiveFallback ||
|
||||
!ctx.platform.shouldToggleMouseIgnore ||
|
||||
ctx.state.isOverSubtitle
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -130,6 +199,10 @@ export function createMouseHandlers(
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
|
||||
function restorePointerInteractionState(): void {
|
||||
resyncPointerInteractionState({ allowInteractiveFallback: true });
|
||||
}
|
||||
|
||||
function maybeResyncPointerHoverState(event: MouseEvent | PointerEvent): void {
|
||||
if (!pendingPointerResync) {
|
||||
return;
|
||||
@@ -205,18 +278,14 @@ export function createMouseHandlers(
|
||||
}
|
||||
|
||||
function enablePopupInteraction(): void {
|
||||
yomitanPopupVisible = true;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
sustainPopupInteraction();
|
||||
if (ctx.platform.isMacOSPlatform) {
|
||||
window.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function disablePopupInteractionIfIdle(): void {
|
||||
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
|
||||
yomitanPopupVisible = true;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
if (reconcilePopupInteraction({ reclaimFocus: true })) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,7 +297,15 @@ export function createMouseHandlers(
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
async function handleMouseEnter(_event?: MouseEvent, showSecondaryHover = false): Promise<void> {
|
||||
async function handleMouseEnter(
|
||||
_event?: MouseEvent,
|
||||
showSecondaryHover = false,
|
||||
source: 'direct' | 'tracked-pointer' = 'direct',
|
||||
): Promise<void> {
|
||||
if (source === 'direct' && suppressDirectHoverEnterSource !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.isOverSubtitle = true;
|
||||
if (showSecondaryHover) {
|
||||
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active');
|
||||
@@ -326,6 +403,10 @@ export function createMouseHandlers(
|
||||
function setupResizeHandler(): void {
|
||||
window.addEventListener('resize', () => {
|
||||
options.applyYPercent(options.getCurrentYPercent());
|
||||
resyncPointerInteractionState({
|
||||
allowInteractiveFallback: false,
|
||||
suppressDirectHoverEnterSource: WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -340,6 +421,15 @@ export function createMouseHandlers(
|
||||
syncHoverStateFromTrackedPointer(event);
|
||||
maybeResyncPointerHoverState(event);
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
resyncPointerInteractionState({
|
||||
allowInteractiveFallback: false,
|
||||
suppressDirectHoverEnterSource: VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupSelectionObserver(): void {
|
||||
@@ -356,19 +446,37 @@ export function createMouseHandlers(
|
||||
}
|
||||
|
||||
function setupYomitanObserver(): void {
|
||||
yomitanPopupVisible = isYomitanPopupVisible(document);
|
||||
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
|
||||
void maybePauseForYomitanPopup();
|
||||
reconcilePopupInteraction({ allowPause: true });
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||
enablePopupInteraction();
|
||||
void maybePauseForYomitanPopup();
|
||||
reconcilePopupInteraction({ assumeVisible: true, allowPause: true });
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||
disablePopupInteractionIfIdle();
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => {
|
||||
reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true });
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
|
||||
reconcilePopupInteraction({ assumeVisible: true });
|
||||
});
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
reconcilePopupInteraction();
|
||||
});
|
||||
|
||||
window.addEventListener('blur', () => {
|
||||
queueMicrotask(() => {
|
||||
if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
reconcilePopupInteraction({ reclaimFocus: true });
|
||||
});
|
||||
});
|
||||
|
||||
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { ElectronAPI, RuntimeOptionState } from '../../types';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createRuntimeOptionsModal } from './runtime-options.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.add(entry);
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.delete(entry);
|
||||
},
|
||||
toggle: (entry: string, force?: boolean) => {
|
||||
if (force === undefined) {
|
||||
if (tokens.has(entry)) {
|
||||
tokens.delete(entry);
|
||||
return false;
|
||||
}
|
||||
tokens.add(entry);
|
||||
return true;
|
||||
}
|
||||
if (force) tokens.add(entry);
|
||||
else tokens.delete(entry);
|
||||
return force;
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
function createElementStub() {
|
||||
return {
|
||||
className: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
classList: createClassList(),
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeOptionsListStub() {
|
||||
return {
|
||||
innerHTML: '',
|
||||
appendChild: () => {},
|
||||
querySelector: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
||||
resolve = nextResolve;
|
||||
reject = nextReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function flushAsyncWork(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function withRuntimeOptionsModal(
|
||||
getRuntimeOptions: () => Promise<RuntimeOptionState[]>,
|
||||
run: (input: {
|
||||
modal: ReturnType<typeof createRuntimeOptionsModal>;
|
||||
state: ReturnType<typeof createRendererState>;
|
||||
overlayClassList: ReturnType<typeof createClassList>;
|
||||
modalClassList: ReturnType<typeof createClassList>;
|
||||
statusNode: {
|
||||
textContent: string;
|
||||
classList: ReturnType<typeof createClassList>;
|
||||
};
|
||||
syncCalls: string[];
|
||||
}) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const statusNode = {
|
||||
textContent: '',
|
||||
classList: createClassList(),
|
||||
};
|
||||
const overlayClassList = createClassList();
|
||||
const modalClassList = createClassList(['hidden']);
|
||||
const syncCalls: string[] = [];
|
||||
const state = createRendererState();
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
getRuntimeOptions,
|
||||
setRuntimeOptionValue: async () => ({ ok: true }),
|
||||
notifyOverlayModalClosed: () => {},
|
||||
} satisfies Pick<
|
||||
ElectronAPI,
|
||||
'getRuntimeOptions' | 'setRuntimeOptionValue' | 'notifyOverlayModalClosed'
|
||||
>,
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
const modal = createRuntimeOptionsModal(
|
||||
{
|
||||
dom: {
|
||||
overlay: { classList: overlayClassList },
|
||||
runtimeOptionsModal: {
|
||||
classList: modalClassList,
|
||||
setAttribute: () => {},
|
||||
},
|
||||
runtimeOptionsClose: {
|
||||
addEventListener: () => {},
|
||||
},
|
||||
runtimeOptionsList: createRuntimeOptionsListStub(),
|
||||
runtimeOptionsStatus: statusNode,
|
||||
},
|
||||
state,
|
||||
} as never,
|
||||
{
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {
|
||||
syncCalls.push('sync');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() =>
|
||||
run({
|
||||
modal,
|
||||
state,
|
||||
overlayClassList,
|
||||
modalClassList,
|
||||
statusNode,
|
||||
syncCalls,
|
||||
}),
|
||||
)
|
||||
.finally(() => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: previousDocument,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('openRuntimeOptionsModal shows loading shell before runtime options resolve', async () => {
|
||||
const deferred = createDeferred<RuntimeOptionState[]>();
|
||||
|
||||
await withRuntimeOptionsModal(() => deferred.promise, async (input) => {
|
||||
input.modal.openRuntimeOptionsModal();
|
||||
|
||||
assert.equal(input.state.runtimeOptionsModalOpen, true);
|
||||
assert.equal(input.overlayClassList.contains('interactive'), true);
|
||||
assert.equal(input.modalClassList.contains('hidden'), false);
|
||||
assert.equal(input.statusNode.textContent, 'Loading runtime options...');
|
||||
assert.deepEqual(input.syncCalls, ['sync']);
|
||||
|
||||
deferred.resolve([
|
||||
{
|
||||
id: 'anki.autoUpdateNewCards',
|
||||
label: 'Auto-update new cards',
|
||||
scope: 'ankiConnect',
|
||||
valueType: 'boolean',
|
||||
value: true,
|
||||
allowedValues: [true, false],
|
||||
requiresRestart: false,
|
||||
},
|
||||
]);
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(
|
||||
input.statusNode.textContent,
|
||||
'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
|
||||
);
|
||||
assert.equal(input.statusNode.classList.contains('error'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('openRuntimeOptionsModal keeps modal visible when loading fails', async () => {
|
||||
const deferred = createDeferred<RuntimeOptionState[]>();
|
||||
|
||||
await withRuntimeOptionsModal(() => deferred.promise, async (input) => {
|
||||
input.modal.openRuntimeOptionsModal();
|
||||
deferred.reject(new Error('boom'));
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(input.state.runtimeOptionsModalOpen, true);
|
||||
assert.equal(input.overlayClassList.contains('interactive'), true);
|
||||
assert.equal(input.modalClassList.contains('hidden'), false);
|
||||
assert.equal(input.statusNode.textContent, 'Failed to load runtime options');
|
||||
assert.equal(input.statusNode.classList.contains('error'), true);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,9 @@ export function createRuntimeOptionsModal(
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
const DEFAULT_STATUS_MESSAGE =
|
||||
'Use arrow keys. Click value to cycle. Enter or double-click to apply.';
|
||||
|
||||
function formatRuntimeOptionValue(value: RuntimeOptionValue): string {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'On' : 'Off';
|
||||
@@ -177,10 +180,13 @@ export function createRuntimeOptionsModal(
|
||||
}
|
||||
}
|
||||
|
||||
async function openRuntimeOptionsModal(): Promise<void> {
|
||||
async function refreshRuntimeOptions(): Promise<void> {
|
||||
const optionsList = await window.electronAPI.getRuntimeOptions();
|
||||
updateRuntimeOptions(optionsList);
|
||||
setRuntimeOptionsStatus(DEFAULT_STATUS_MESSAGE);
|
||||
}
|
||||
|
||||
function showRuntimeOptionsModalShell(): void {
|
||||
ctx.state.runtimeOptionsModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
@@ -188,9 +194,19 @@ export function createRuntimeOptionsModal(
|
||||
ctx.dom.runtimeOptionsModal.classList.remove('hidden');
|
||||
ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'false');
|
||||
|
||||
setRuntimeOptionsStatus(
|
||||
'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
|
||||
);
|
||||
setRuntimeOptionsStatus('Loading runtime options...');
|
||||
}
|
||||
|
||||
function openRuntimeOptionsModal(): void {
|
||||
if (!ctx.state.runtimeOptionsModalOpen) {
|
||||
showRuntimeOptionsModalShell();
|
||||
} else {
|
||||
setRuntimeOptionsStatus('Refreshing runtime options...');
|
||||
}
|
||||
|
||||
void refreshRuntimeOptions().catch(() => {
|
||||
setRuntimeOptionsStatus('Failed to load runtime options', true);
|
||||
});
|
||||
}
|
||||
|
||||
function handleRuntimeOptionsKeydown(e: KeyboardEvent): boolean {
|
||||
|
||||
@@ -96,6 +96,10 @@ const OVERLAY_SHORTCUTS: Array<{
|
||||
{ key: 'markAudioCard', label: 'Mark audio card' },
|
||||
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
|
||||
{ key: 'openJimaku', label: 'Open jimaku' },
|
||||
{ key: 'openSessionHelp', label: 'Open session help' },
|
||||
{ key: 'openControllerSelect', label: 'Open controller select' },
|
||||
{ key: 'openControllerDebug', label: 'Open controller debug' },
|
||||
{ key: 'toggleSubtitleSidebar', label: 'Toggle subtitle sidebar' },
|
||||
{ key: 'toggleVisibleOverlayGlobal', label: 'Show/hide visible overlay' },
|
||||
];
|
||||
|
||||
@@ -104,11 +108,12 @@ function buildOverlayShortcutSections(shortcuts: RuntimeShortcutConfig): Session
|
||||
|
||||
for (const shortcut of OVERLAY_SHORTCUTS) {
|
||||
const keybind = shortcuts[shortcut.key];
|
||||
if (typeof keybind !== 'string') continue;
|
||||
if (keybind.trim().length === 0) continue;
|
||||
|
||||
rows.push({
|
||||
shortcut: formatKeybinding(keybind),
|
||||
shortcut:
|
||||
typeof keybind === 'string' && keybind.trim().length > 0
|
||||
? formatKeybinding(keybind)
|
||||
: 'Unbound',
|
||||
action: shortcut.label,
|
||||
});
|
||||
}
|
||||
@@ -586,11 +591,25 @@ export function createSessionHelpModal(
|
||||
}
|
||||
}
|
||||
|
||||
async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> {
|
||||
function openSessionHelpModal(opening: SessionHelpBindingInfo): void {
|
||||
openBinding = opening;
|
||||
priorFocus = document.activeElement;
|
||||
|
||||
const dataLoaded = await render();
|
||||
ctx.state.sessionHelpModalOpen = true;
|
||||
helpSections = [];
|
||||
helpFilterValue = '';
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.sessionHelpModal.classList.remove('hidden');
|
||||
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'false');
|
||||
ctx.dom.sessionHelpModal.setAttribute('tabindex', '-1');
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
ctx.state.sessionHelpSelectedIndex = 0;
|
||||
ctx.dom.sessionHelpContent.innerHTML = '';
|
||||
ctx.dom.sessionHelpContent.classList.remove('session-help-content-no-results');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
|
||||
ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`;
|
||||
if (openBinding.fallbackUnavailable) {
|
||||
@@ -601,27 +620,7 @@ export function createSessionHelpModal(
|
||||
} else {
|
||||
ctx.dom.sessionHelpWarning.textContent = '';
|
||||
}
|
||||
if (dataLoaded) {
|
||||
ctx.dom.sessionHelpStatus.textContent =
|
||||
'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.';
|
||||
} else {
|
||||
ctx.dom.sessionHelpStatus.textContent =
|
||||
'Session help data is unavailable right now. Press Esc to close.';
|
||||
ctx.dom.sessionHelpWarning.textContent =
|
||||
'Unable to load latest shortcut settings from the runtime.';
|
||||
}
|
||||
|
||||
ctx.state.sessionHelpModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.sessionHelpModal.classList.remove('hidden');
|
||||
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'false');
|
||||
ctx.dom.sessionHelpModal.setAttribute('tabindex', '-1');
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
helpFilterValue = '';
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
ctx.dom.sessionHelpStatus.textContent = 'Loading session help data...';
|
||||
|
||||
if (focusGuard === null) {
|
||||
focusGuard = (event: FocusEvent) => {
|
||||
@@ -639,6 +638,19 @@ export function createSessionHelpModal(
|
||||
requestOverlayFocus();
|
||||
window.focus();
|
||||
enforceModalFocus();
|
||||
|
||||
void render().then((dataLoaded) => {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
if (dataLoaded) {
|
||||
ctx.dom.sessionHelpStatus.textContent =
|
||||
'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.';
|
||||
} else {
|
||||
ctx.dom.sessionHelpStatus.textContent =
|
||||
'Session help data is unavailable right now. Press Esc to close.';
|
||||
ctx.dom.sessionHelpWarning.textContent =
|
||||
'Unable to load latest shortcut settings from the runtime.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeSessionHelpModal(): void {
|
||||
@@ -648,6 +660,7 @@ export function createSessionHelpModal(
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.sessionHelpModal.classList.add('hidden');
|
||||
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('session-help');
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
@@ -15,6 +15,53 @@ function createClassList() {
|
||||
};
|
||||
}
|
||||
|
||||
test('idle visible overlay starts click-through on platforms that toggle mouse ignore', () => {
|
||||
const classList = createClassList();
|
||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
syncOverlayMouseIgnoreState({
|
||||
dom: {
|
||||
overlay: { classList },
|
||||
},
|
||||
platform: {
|
||||
shouldToggleMouseIgnore: true,
|
||||
},
|
||||
state: {
|
||||
isOverSubtitle: false,
|
||||
isOverSubtitleSidebar: false,
|
||||
yomitanPopupVisible: false,
|
||||
controllerSelectModalOpen: false,
|
||||
controllerDebugModalOpen: false,
|
||||
jimakuModalOpen: false,
|
||||
youtubePickerModalOpen: false,
|
||||
kikuModalOpen: false,
|
||||
runtimeOptionsModalOpen: false,
|
||||
subsyncModalOpen: false,
|
||||
sessionHelpModalOpen: false,
|
||||
subtitleSidebarModalOpen: false,
|
||||
subtitleSidebarConfig: null,
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.equal(classList.contains('interactive'), false);
|
||||
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
|
||||
} finally {
|
||||
Object.assign(globalThis, { window: originalWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('youtube picker keeps overlay interactive even when subtitle hover is inactive', () => {
|
||||
const classList = createClassList();
|
||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||
@@ -61,3 +108,62 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact
|
||||
Object.assign(globalThis, { window: originalWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('visible yomitan popup host keeps overlay interactive even when cached popup state is false', () => {
|
||||
const classList = createClassList();
|
||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
visibility: 'visible',
|
||||
display: 'block',
|
||||
opacity: '1',
|
||||
}),
|
||||
},
|
||||
document: {
|
||||
querySelectorAll: (selector: string) =>
|
||||
selector === '[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
|
||||
? [{ getAttribute: () => 'true' }]
|
||||
: [],
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
syncOverlayMouseIgnoreState({
|
||||
dom: {
|
||||
overlay: { classList },
|
||||
},
|
||||
platform: {
|
||||
shouldToggleMouseIgnore: true,
|
||||
},
|
||||
state: {
|
||||
isOverSubtitle: false,
|
||||
isOverSubtitleSidebar: false,
|
||||
yomitanPopupVisible: false,
|
||||
controllerSelectModalOpen: false,
|
||||
controllerDebugModalOpen: false,
|
||||
jimakuModalOpen: false,
|
||||
youtubePickerModalOpen: false,
|
||||
kikuModalOpen: false,
|
||||
runtimeOptionsModalOpen: false,
|
||||
subsyncModalOpen: false,
|
||||
sessionHelpModalOpen: false,
|
||||
subtitleSidebarModalOpen: false,
|
||||
subtitleSidebarConfig: null,
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.equal(classList.contains('interactive'), true);
|
||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||
} finally {
|
||||
Object.assign(globalThis, { window: originalWindow, document: originalDocument });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RendererContext } from './context';
|
||||
import type { RendererState } from './state';
|
||||
import { isYomitanPopupVisible } from './yomitan-popup.js';
|
||||
|
||||
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||
return Boolean(
|
||||
@@ -14,11 +15,21 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isYomitanPopupInteractionActive(state: RendererState): boolean {
|
||||
if (state.yomitanPopupVisible) {
|
||||
return true;
|
||||
}
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return isYomitanPopupVisible(document);
|
||||
}
|
||||
|
||||
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
||||
const shouldStayInteractive =
|
||||
ctx.state.isOverSubtitle ||
|
||||
ctx.state.isOverSubtitleSidebar ||
|
||||
ctx.state.yomitanPopupVisible ||
|
||||
isYomitanPopupInteractionActive(ctx.state) ||
|
||||
isBlockingOverlayModalOpen(ctx.state);
|
||||
|
||||
if (shouldStayInteractive) {
|
||||
|
||||
+59
-29
@@ -55,6 +55,8 @@ import { resolveRendererDom } from './utils/dom.js';
|
||||
import { resolvePlatformInfo } from './utils/platform.js';
|
||||
import {
|
||||
buildMpvLoadfileCommands,
|
||||
buildMpvSubtitleAddCommands,
|
||||
collectDroppedSubtitlePaths,
|
||||
collectDroppedVideoPaths,
|
||||
} from '../core/services/overlay-drop.js';
|
||||
|
||||
@@ -172,18 +174,16 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||
openControllerSelectModal: () => {
|
||||
controllerSelectModal.openControllerSelectModal();
|
||||
},
|
||||
openControllerDebugModal: () => {
|
||||
controllerDebugModal.openControllerDebugModal();
|
||||
},
|
||||
appendClipboardVideoToQueue: () => {
|
||||
void window.electronAPI.appendClipboardVideoToQueue();
|
||||
},
|
||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||
openControllerSelectModal: () => {
|
||||
controllerSelectModal.openControllerSelectModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||
},
|
||||
openControllerDebugModal: () => {
|
||||
controllerDebugModal.openControllerDebugModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||
},
|
||||
toggleSubtitleSidebarModal: () => {
|
||||
void subtitleSidebarModal.toggleSubtitleSidebarModal();
|
||||
},
|
||||
@@ -430,15 +430,27 @@ registerRendererGlobalErrorHandlers(window, recovery);
|
||||
|
||||
function registerModalOpenHandlers(): void {
|
||||
window.electronAPI.onOpenRuntimeOptions(() => {
|
||||
runGuardedAsync('runtime-options:open', async () => {
|
||||
try {
|
||||
await runtimeOptionsModal.openRuntimeOptionsModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('runtime-options');
|
||||
} catch {
|
||||
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);
|
||||
window.electronAPI.notifyOverlayModalClosed('runtime-options');
|
||||
syncSettingsModalSubtitleSuppression();
|
||||
}
|
||||
runGuarded('runtime-options:open', () => {
|
||||
runtimeOptionsModal.openRuntimeOptionsModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('runtime-options');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenSessionHelp(() => {
|
||||
runGuarded('session-help:open', () => {
|
||||
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo());
|
||||
window.electronAPI.notifyOverlayModalOpened('session-help');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenControllerSelect(() => {
|
||||
runGuarded('controller-select:open', () => {
|
||||
controllerSelectModal.openControllerSelectModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenControllerDebug(() => {
|
||||
runGuarded('controller-debug:open', () => {
|
||||
controllerDebugModal.openControllerDebugModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenJimaku(() => {
|
||||
@@ -496,6 +508,12 @@ function registerKeyboardCommandHandlers(): void {
|
||||
keyboardHandlers.handleLookupWindowToggleRequested();
|
||||
});
|
||||
});
|
||||
|
||||
window.electronAPI.onSubtitleSidebarToggle(() => {
|
||||
runGuardedAsync('subtitle-sidebar:toggle', async () => {
|
||||
await subtitleSidebarModal.toggleSubtitleSidebarModal();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function runGuarded(action: string, fn: () => void): void {
|
||||
@@ -527,6 +545,12 @@ async function init(): Promise<void> {
|
||||
if (ctx.platform.isMacOSPlatform) {
|
||||
document.body.classList.add('platform-macos');
|
||||
}
|
||||
if (ctx.platform.isWindowsPlatform) {
|
||||
document.body.classList.add('platform-windows');
|
||||
}
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||
runGuarded('subtitle:update', () => {
|
||||
@@ -620,7 +644,7 @@ async function init(): Promise<void> {
|
||||
});
|
||||
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
|
||||
runGuarded('config:hot-reload', () => {
|
||||
keyboardHandlers.updateKeybindings(payload.keybindings);
|
||||
keyboardHandlers.updateSessionBindings(payload.sessionBindings);
|
||||
void keyboardHandlers.refreshConfiguredShortcuts();
|
||||
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
||||
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
|
||||
@@ -654,10 +678,6 @@ async function init(): Promise<void> {
|
||||
);
|
||||
measurementReporter.schedule();
|
||||
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
measurementReporter.emitNow();
|
||||
}
|
||||
|
||||
@@ -706,18 +726,28 @@ function setupDragDropToMpvQueue(): void {
|
||||
if (!event.dataTransfer) return;
|
||||
event.preventDefault();
|
||||
|
||||
const droppedPaths = collectDroppedVideoPaths(event.dataTransfer);
|
||||
const loadCommands = buildMpvLoadfileCommands(droppedPaths, event.shiftKey);
|
||||
const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer);
|
||||
const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer);
|
||||
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, event.shiftKey);
|
||||
const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths);
|
||||
for (const command of loadCommands) {
|
||||
window.electronAPI.sendMpvCommand(command);
|
||||
}
|
||||
for (const command of subtitleCommands) {
|
||||
window.electronAPI.sendMpvCommand(command);
|
||||
}
|
||||
const osdParts: string[] = [];
|
||||
if (loadCommands.length > 0) {
|
||||
const action = event.shiftKey ? 'Queued' : 'Loaded';
|
||||
window.electronAPI.sendMpvCommand([
|
||||
'show-text',
|
||||
`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`,
|
||||
'1500',
|
||||
]);
|
||||
osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (subtitleCommands.length > 0) {
|
||||
osdParts.push(
|
||||
`Loaded ${subtitleCommands.length} subtitle file${subtitleCommands.length === 1 ? '' : 's'}`,
|
||||
);
|
||||
}
|
||||
if (osdParts.length > 0) {
|
||||
window.electronAPI.sendMpvCommand(['show-text', osdParts.join(' | '), '1500']);
|
||||
}
|
||||
|
||||
clearDropInteractive();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
CompiledSessionBinding,
|
||||
PlaylistBrowserSnapshot,
|
||||
ControllerButtonSnapshot,
|
||||
ControllerDeviceInfo,
|
||||
@@ -116,7 +117,9 @@ export type RendererState = {
|
||||
frequencyDictionaryBand4Color: string;
|
||||
frequencyDictionaryBand5Color: string;
|
||||
|
||||
keybindingsMap: Map<string, (string | number)[]>;
|
||||
sessionBindings: CompiledSessionBinding[];
|
||||
sessionBindingMap: Map<string, CompiledSessionBinding>;
|
||||
sessionActionTimeoutMs: number;
|
||||
statsToggleKey: string;
|
||||
markWatchedKey: string;
|
||||
chordPending: boolean;
|
||||
@@ -219,7 +222,9 @@ export function createRendererState(): RendererState {
|
||||
frequencyDictionaryBand4Color: '#8bd5ca',
|
||||
frequencyDictionaryBand5Color: '#8aadf4',
|
||||
|
||||
keybindingsMap: new Map(),
|
||||
sessionBindings: [],
|
||||
sessionBindingMap: new Map(),
|
||||
sessionActionTimeoutMs: 3000,
|
||||
statsToggleKey: 'Backquote',
|
||||
markWatchedKey: 'KeyW',
|
||||
chordPending: false,
|
||||
|
||||
@@ -684,7 +684,8 @@ body.settings-modal-open #subtitleContainer {
|
||||
}
|
||||
|
||||
body.settings-modal-open iframe.yomitan-popup,
|
||||
body.settings-modal-open iframe[id^='yomitan-popup'] {
|
||||
body.settings-modal-open iframe[id^='yomitan-popup'],
|
||||
body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
display: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
@@ -1130,6 +1131,11 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body.platform-windows #secondarySubContainer.secondary-sub-hover {
|
||||
top: 40px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
#secondarySubContainer.secondary-sub-hover #secondarySubRoot {
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
@@ -1151,7 +1157,8 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
|
||||
}
|
||||
|
||||
iframe.yomitan-popup,
|
||||
iframe[id^='yomitan-popup'] {
|
||||
iframe[id^='yomitan-popup'],
|
||||
[data-subminer-yomitan-popup-host='true'] {
|
||||
pointer-events: auto !important;
|
||||
z-index: 2147483647 !important;
|
||||
}
|
||||
|
||||
@@ -989,6 +989,13 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
||||
/transform:\s*translateX\(calc\(var\(--subtitle-sidebar-reserved-width\)\s*\*\s*-0\.5\)\);/,
|
||||
);
|
||||
|
||||
const secondaryHoverWindowsBlock = extractClassBlock(
|
||||
cssText,
|
||||
'body.platform-windows #secondarySubContainer.secondary-sub-hover',
|
||||
);
|
||||
assert.match(secondaryHoverWindowsBlock, /top:\s*40px;/);
|
||||
assert.match(secondaryHoverWindowsBlock, /padding-top:\s*0;/);
|
||||
|
||||
const subtitleSidebarListBlock = extractClassBlock(cssText, '.subtitle-sidebar-list');
|
||||
assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export type PlatformInfo = {
|
||||
isModalLayer: boolean;
|
||||
isLinuxPlatform: boolean;
|
||||
isMacOSPlatform: boolean;
|
||||
isWindowsPlatform: boolean;
|
||||
shouldToggleMouseIgnore: boolean;
|
||||
};
|
||||
|
||||
@@ -24,12 +25,15 @@ export function resolvePlatformInfo(): PlatformInfo {
|
||||
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
|
||||
const isMacOSPlatform =
|
||||
navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent);
|
||||
const isWindowsPlatform =
|
||||
navigator.platform.toLowerCase().includes('win') || /windows/i.test(navigator.userAgent);
|
||||
|
||||
return {
|
||||
overlayLayer,
|
||||
isModalLayer,
|
||||
isLinuxPlatform,
|
||||
isMacOSPlatform,
|
||||
isWindowsPlatform,
|
||||
shouldToggleMouseIgnore: !isLinuxPlatform && !isModalLayer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { YOMITAN_LOOKUP_EVENT, registerYomitanLookupListener } from './yomitan-popup.js';
|
||||
import {
|
||||
YOMITAN_LOOKUP_EVENT,
|
||||
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||
isYomitanPopupVisible,
|
||||
registerYomitanLookupListener,
|
||||
} from './yomitan-popup.js';
|
||||
|
||||
test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event', () => {
|
||||
const target = new EventTarget();
|
||||
@@ -16,3 +21,12 @@ test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event',
|
||||
|
||||
assert.deepEqual(calls, ['lookup']);
|
||||
});
|
||||
|
||||
test('isYomitanPopupVisible falls back to querySelector when querySelectorAll is unavailable', () => {
|
||||
const root = {
|
||||
querySelector: (selector: string) =>
|
||||
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ? ({} as Element) : null,
|
||||
} as ParentNode;
|
||||
|
||||
assert.equal(isYomitanPopupVisible(root), true);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const YOMITAN_POPUP_IFRAME_SELECTOR = 'iframe.yomitan-popup, iframe[id^="yomitan-popup"]';
|
||||
export const YOMITAN_POPUP_HOST_SELECTOR = '[data-subminer-yomitan-popup-host="true"]';
|
||||
export const YOMITAN_POPUP_VISIBLE_HOST_SELECTOR =
|
||||
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]';
|
||||
const YOMITAN_POPUP_VISIBLE_ATTRIBUTE = 'data-subminer-yomitan-popup-visible';
|
||||
export const YOMITAN_POPUP_SHOWN_EVENT = 'yomitan-popup-shown';
|
||||
export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
|
||||
export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
|
||||
@@ -28,22 +32,64 @@ export function isYomitanPopupIframe(element: Element | null): boolean {
|
||||
return hasModernPopupClass || hasLegacyPopupId;
|
||||
}
|
||||
|
||||
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
||||
return root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null;
|
||||
export function hasYomitanPopupIframe(root: ParentNode | null | undefined = document): boolean {
|
||||
return (
|
||||
typeof root?.querySelector === 'function' &&
|
||||
(root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null ||
|
||||
root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null)
|
||||
);
|
||||
}
|
||||
|
||||
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
|
||||
const popupIframes = root.querySelectorAll<HTMLIFrameElement>(YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||
for (const iframe of popupIframes) {
|
||||
const rect = iframe.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
continue;
|
||||
}
|
||||
const styles = window.getComputedStyle(iframe);
|
||||
if (styles.visibility === 'hidden' || styles.display === 'none' || styles.opacity === '0') {
|
||||
continue;
|
||||
}
|
||||
function isVisiblePopupElement(element: Element): boolean {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const styles = window.getComputedStyle(element);
|
||||
if (styles.visibility === 'hidden' || styles.display === 'none' || styles.opacity === '0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isMarkedVisiblePopupHost(element: Element): boolean {
|
||||
return element.getAttribute(YOMITAN_POPUP_VISIBLE_ATTRIBUTE) === 'true';
|
||||
}
|
||||
|
||||
function queryPopupElements<T extends Element>(
|
||||
root: ParentNode | null | undefined,
|
||||
selector: string,
|
||||
): T[] {
|
||||
if (typeof root?.querySelectorAll === 'function') {
|
||||
return Array.from(root.querySelectorAll<T>(selector));
|
||||
}
|
||||
if (typeof root?.querySelector === 'function') {
|
||||
const first = root.querySelector(selector) as T | null;
|
||||
return first ? [first] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function isYomitanPopupVisible(root: ParentNode | null | undefined = document): boolean {
|
||||
const visiblePopupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
|
||||
if (visiblePopupHosts.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const popupIframes = queryPopupElements<HTMLIFrameElement>(root, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||
for (const iframe of popupIframes) {
|
||||
if (isVisiblePopupElement(iframe)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const popupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_HOST_SELECTOR);
|
||||
for (const host of popupHosts) {
|
||||
if (isMarkedVisiblePopupHost(host)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user