From 3f7de737343f6777181b5b892c0af53446268371 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 7 Apr 2026 22:25:46 -0700 Subject: [PATCH] Keep overlay interactive while Yomitan popup is visible --- src/renderer/handlers/mouse.test.ts | 131 +++++++++++++++++++++- src/renderer/handlers/mouse.ts | 68 +++++++++-- src/renderer/overlay-mouse-ignore.test.ts | 59 ++++++++++ src/renderer/overlay-mouse-ignore.ts | 13 ++- src/renderer/yomitan-popup.ts | 18 ++- 5 files changed, 270 insertions(+), 19 deletions(-) diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index a841c80f..4f2712ee 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -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 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 }); diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index 8765edd6..e301d5be 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -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) => { diff --git a/src/renderer/overlay-mouse-ignore.test.ts b/src/renderer/overlay-mouse-ignore.test.ts index e191a8a1..dfaf115a 100644 --- a/src/renderer/overlay-mouse-ignore.test.ts +++ b/src/renderer/overlay-mouse-ignore.test.ts @@ -61,3 +61,62 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact 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 }); + } +}); diff --git a/src/renderer/overlay-mouse-ignore.ts b/src/renderer/overlay-mouse-ignore.ts index 401277a8..1025a963 100644 --- a/src/renderer/overlay-mouse-ignore.ts +++ b/src/renderer/overlay-mouse-ignore.ts @@ -1,5 +1,6 @@ import type { RendererContext } from './context'; import type { RendererState } from './state'; +import { isYomitanPopupVisible } from './yomitan-popup.js'; function isBlockingOverlayModalOpen(state: RendererState): 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 { const shouldStayInteractive = ctx.state.isOverSubtitle || ctx.state.isOverSubtitleSidebar || - ctx.state.yomitanPopupVisible || + isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state); if (shouldStayInteractive) { diff --git a/src/renderer/yomitan-popup.ts b/src/renderer/yomitan-popup.ts index 82c2f2ff..065a5953 100644 --- a/src/renderer/yomitan-popup.ts +++ b/src/renderer/yomitan-popup.ts @@ -34,8 +34,9 @@ export function isYomitanPopupIframe(element: Element | null): boolean { export function hasYomitanPopupIframe(root: ParentNode = document): boolean { return ( - root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null || - root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null + typeof root.querySelector === 'function' && + (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'; } +function queryPopupElements(root: ParentNode, selector: string): T[] { + if (typeof root.querySelectorAll !== 'function') { + return []; + } + return Array.from(root.querySelectorAll(selector)); +} + export function isYomitanPopupVisible(root: ParentNode = document): boolean { - const visiblePopupHosts = root.querySelectorAll(YOMITAN_POPUP_VISIBLE_HOST_SELECTOR); + const visiblePopupHosts = queryPopupElements(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR); if (visiblePopupHosts.length > 0) { return true; } - const popupIframes = root.querySelectorAll(YOMITAN_POPUP_IFRAME_SELECTOR); + const popupIframes = queryPopupElements(root, YOMITAN_POPUP_IFRAME_SELECTOR); for (const iframe of popupIframes) { if (isVisiblePopupElement(iframe)) { return true; } } - const popupHosts = root.querySelectorAll(YOMITAN_POPUP_HOST_SELECTOR); + const popupHosts = queryPopupElements(root, YOMITAN_POPUP_HOST_SELECTOR); for (const host of popupHosts) { if (isMarkedVisiblePopupHost(host)) { return true;