Keep overlay interactive while Yomitan popup is visible

This commit is contained in:
2026-04-07 22:25:46 -07:00
parent de9b887798
commit 3f7de73734
5 changed files with 270 additions and 19 deletions

View File

@@ -842,6 +842,131 @@ test('nested popup close reasserts interactive state and focus when another popu
}
});
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();
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;
@@ -1046,10 +1171,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 });

View File

@@ -2,6 +2,8 @@ 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,
@@ -34,6 +36,17 @@ export function createMouseHandlers(
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
let pendingPointerResync = false;
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;
@@ -52,11 +65,31 @@ export function createMouseHandlers(
}
function sustainPopupInteraction(): void {
yomitanPopupVisible = true;
ctx.state.yomitanPopupVisible = true;
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) {
return false;
@@ -235,9 +268,7 @@ export function createMouseHandlers(
}
function disablePopupInteractionIfIdle(): void {
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
sustainPopupInteraction();
reclaimOverlayWindowFocusForPopup();
if (reconcilePopupInteraction({ reclaimFocus: true })) {
return;
}
@@ -377,19 +408,38 @@ export function createMouseHandlers(
}
function setupYomitanObserver(): void {
yomitanPopupVisible = isYomitanPopupVisible(document);
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
syncPopupVisibilityState();
void maybePauseForYomitanPopup();
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) => {