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', () => { test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
const ctx = createMouseTestContext(); const ctx = createMouseTestContext();
const originalWindow = globalThis.window; 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.state.isOverSubtitle, false);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false); assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
assert.deepEqual(ignoreCalls, [ assert.equal(ignoreCalls[0]?.ignore, false);
{ ignore: false, forward: undefined }, assert.deepEqual(ignoreCalls.at(-1), { ignore: true, forward: true });
{ ignore: true, forward: true },
]);
} finally { } finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); 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 { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
import { import {
YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
YOMITAN_POPUP_SHOWN_EVENT, YOMITAN_POPUP_SHOWN_EVENT,
isYomitanPopupVisible, isYomitanPopupVisible,
isYomitanPopupIframe, isYomitanPopupIframe,
@@ -34,6 +36,17 @@ export function createMouseHandlers(
let lastPointerPosition: { clientX: number; clientY: number } | null = null; let lastPointerPosition: { clientX: number; clientY: number } | null = null;
let pendingPointerResync = false; 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 { function reclaimOverlayWindowFocusForPopup(): void {
if (!ctx.platform.shouldToggleMouseIgnore) { if (!ctx.platform.shouldToggleMouseIgnore) {
return; return;
@@ -52,11 +65,31 @@ export function createMouseHandlers(
} }
function sustainPopupInteraction(): void { function sustainPopupInteraction(): void {
yomitanPopupVisible = true; syncPopupVisibilityState(true);
ctx.state.yomitanPopupVisible = true;
syncOverlayMouseIgnoreState(ctx); 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 { function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
if (!element) { if (!element) {
return false; return false;
@@ -235,9 +268,7 @@ export function createMouseHandlers(
} }
function disablePopupInteractionIfIdle(): void { function disablePopupInteractionIfIdle(): void {
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) { if (reconcilePopupInteraction({ reclaimFocus: true })) {
sustainPopupInteraction();
reclaimOverlayWindowFocusForPopup();
return; return;
} }
@@ -377,19 +408,38 @@ export function createMouseHandlers(
} }
function setupYomitanObserver(): void { function setupYomitanObserver(): void {
yomitanPopupVisible = isYomitanPopupVisible(document); syncPopupVisibilityState();
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
void maybePauseForYomitanPopup(); void maybePauseForYomitanPopup();
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => { window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
enablePopupInteraction(); reconcilePopupInteraction({ assumeVisible: true, allowPause: true });
void maybePauseForYomitanPopup();
}); });
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => { window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
disablePopupInteractionIfIdle(); 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[]) => { const observer = new MutationObserver((mutations: MutationRecord[]) => {
for (const mutation of mutations) { for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => { mutation.addedNodes.forEach((node) => {

View File

@@ -61,3 +61,62 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact
Object.assign(globalThis, { window: originalWindow }); Object.assign(globalThis, { window: originalWindow });
} }
}); });
test('visible yomitan popup host keeps overlay interactive even when cached popup state is false', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
Object.assign(globalThis, {
window: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
},
document: {
querySelectorAll: (selector: string) =>
selector === '[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
? [{ getAttribute: () => 'true' }]
: [],
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
shouldToggleMouseIgnore: true,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
Object.assign(globalThis, { window: originalWindow, document: originalDocument });
}
});

View File

@@ -1,5 +1,6 @@
import type { RendererContext } from './context'; import type { RendererContext } from './context';
import type { RendererState } from './state'; import type { RendererState } from './state';
import { isYomitanPopupVisible } from './yomitan-popup.js';
function isBlockingOverlayModalOpen(state: RendererState): boolean { function isBlockingOverlayModalOpen(state: RendererState): boolean {
return Boolean( return Boolean(
@@ -14,11 +15,21 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
); );
} }
function isYomitanPopupInteractionActive(state: RendererState): boolean {
if (state.yomitanPopupVisible) {
return true;
}
if (typeof document === 'undefined') {
return false;
}
return isYomitanPopupVisible(document);
}
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void { export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
const shouldStayInteractive = const shouldStayInteractive =
ctx.state.isOverSubtitle || ctx.state.isOverSubtitle ||
ctx.state.isOverSubtitleSidebar || ctx.state.isOverSubtitleSidebar ||
ctx.state.yomitanPopupVisible || isYomitanPopupInteractionActive(ctx.state) ||
isBlockingOverlayModalOpen(ctx.state); isBlockingOverlayModalOpen(ctx.state);
if (shouldStayInteractive) { if (shouldStayInteractive) {

View File

@@ -34,8 +34,9 @@ export function isYomitanPopupIframe(element: Element | null): boolean {
export function hasYomitanPopupIframe(root: ParentNode = document): boolean { export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
return ( return (
root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null || typeof root.querySelector === 'function' &&
root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null (root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null ||
root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null)
); );
} }
@@ -57,20 +58,27 @@ function isMarkedVisiblePopupHost(element: Element): boolean {
return element.getAttribute(YOMITAN_POPUP_VISIBLE_ATTRIBUTE) === 'true'; return element.getAttribute(YOMITAN_POPUP_VISIBLE_ATTRIBUTE) === 'true';
} }
function queryPopupElements<T extends Element>(root: ParentNode, selector: string): T[] {
if (typeof root.querySelectorAll !== 'function') {
return [];
}
return Array.from(root.querySelectorAll<T>(selector));
}
export function isYomitanPopupVisible(root: ParentNode = document): boolean { export function isYomitanPopupVisible(root: ParentNode = document): boolean {
const visiblePopupHosts = root.querySelectorAll<HTMLElement>(YOMITAN_POPUP_VISIBLE_HOST_SELECTOR); const visiblePopupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
if (visiblePopupHosts.length > 0) { if (visiblePopupHosts.length > 0) {
return true; return true;
} }
const popupIframes = root.querySelectorAll<HTMLIFrameElement>(YOMITAN_POPUP_IFRAME_SELECTOR); const popupIframes = queryPopupElements<HTMLIFrameElement>(root, YOMITAN_POPUP_IFRAME_SELECTOR);
for (const iframe of popupIframes) { for (const iframe of popupIframes) {
if (isVisiblePopupElement(iframe)) { if (isVisiblePopupElement(iframe)) {
return true; return true;
} }
} }
const popupHosts = root.querySelectorAll<HTMLElement>(YOMITAN_POPUP_HOST_SELECTOR); const popupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_HOST_SELECTOR);
for (const host of popupHosts) { for (const host of popupHosts) {
if (isMarkedVisiblePopupHost(host)) { if (isMarkedVisiblePopupHost(host)) {
return true; return true;