mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-06 07:21:33 -07:00
Windows update (#49)
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user