Fix nested Yomitan popup focus loss

This commit is contained in:
2026-04-07 21:45:12 -07:00
parent 9b4de93283
commit de9b887798
10 changed files with 397 additions and 25 deletions

View File

@@ -2,6 +2,7 @@ import { SPECIAL_COMMANDS } from '../../config/definitions';
import type { Keybinding, ShortcutsConfig } from '../../types';
import type { RendererContext } from '../context';
import {
YOMITAN_POPUP_HOST_SELECTOR,
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
YOMITAN_POPUP_COMMAND_EVENT,
@@ -61,6 +62,9 @@ export function createKeyboardHandlers(
if (target.closest('.modal')) return true;
if (ctx.dom.subtitleContainer.contains(target)) return true;
if (isYomitanPopupIframe(target)) return true;
if (target.closest && target.closest(YOMITAN_POPUP_HOST_SELECTOR)) {
return true;
}
if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]'))
return true;
return false;

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,129 @@ 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('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;

View File

@@ -34,6 +34,29 @@ export function createMouseHandlers(
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
let pendingPointerResync = false;
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 {
yomitanPopupVisible = true;
ctx.state.yomitanPopupVisible = true;
syncOverlayMouseIgnoreState(ctx);
}
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
if (!element) {
return false;
@@ -205,9 +228,7 @@ export function createMouseHandlers(
}
function enablePopupInteraction(): void {
yomitanPopupVisible = true;
ctx.state.yomitanPopupVisible = true;
syncOverlayMouseIgnoreState(ctx);
sustainPopupInteraction();
if (ctx.platform.isMacOSPlatform) {
window.focus();
}
@@ -215,8 +236,8 @@ export function createMouseHandlers(
function disablePopupInteractionIfIdle(): void {
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
yomitanPopupVisible = true;
ctx.state.yomitanPopupVisible = true;
sustainPopupInteraction();
reclaimOverlayWindowFocusForPopup();
return;
}