Windows update (#49)

This commit is contained in:
2026-04-11 21:45:52 -07:00
committed by GitHub
parent 49e46e6b9b
commit 52bab1d611
168 changed files with 9732 additions and 1422 deletions
+326 -39
View File
@@ -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
View File
@@ -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,
+616 -5
View File
@@ -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
View File
@@ -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) => {