From b29677f5be6f4fc3473dba410b5d46f8f770bd99 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 14 Jun 2026 02:15:53 -0700 Subject: [PATCH] fix(overlay): capture click-away on transparent space for macOS Yomitan - Add `isOverYomitanPopup` state; set on popup mouse enter/leave events - Keep overlay interactive while popup visible so transparent-space clicks close the popup before passing through to mpv - Release click capture when popup hidden --- changes/macos-yomitan-popup-focus.md | 1 + src/renderer/handlers/mouse.test.ts | 80 ++++++++++++++++- src/renderer/handlers/mouse.ts | 8 +- src/renderer/overlay-mouse-ignore.test.ts | 105 ++++++++++++++++++++++ src/renderer/overlay-mouse-ignore.ts | 1 + src/renderer/state.ts | 2 + 6 files changed, 193 insertions(+), 4 deletions(-) diff --git a/changes/macos-yomitan-popup-focus.md b/changes/macos-yomitan-popup-focus.md index 0a4d9ae5..b5aa84d5 100644 --- a/changes/macos-yomitan-popup-focus.md +++ b/changes/macos-yomitan-popup-focus.md @@ -2,3 +2,4 @@ type: fixed area: overlay - Fixed macOS Yomitan popup focus after card mining or popup reload while still allowing click-away to close the popup without a hide/reappear cycle. +- Fixed macOS Yomitan popups staying open when clicking transparent overlay space; click-away is captured for popup close, then passthrough returns to mpv. diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index 0f7a7d51..b9290f35 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -6,6 +6,8 @@ import { createMouseHandlers } from './mouse.js'; import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_HOST_SELECTOR, + YOMITAN_POPUP_MOUSE_ENTER_EVENT, + YOMITAN_POPUP_MOUSE_LEAVE_EVENT, YOMITAN_POPUP_SHOWN_EVENT, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR, } from '../yomitan-popup.js'; @@ -89,6 +91,7 @@ function createMouseTestContext() { state: { isOverSubtitle: false, isOverSubtitleSidebar: false, + isOverYomitanPopup: false, yomitanPopupVisible: false, subtitleSidebarModalOpen: false, subtitleSidebarConfig: null as SubtitleSidebarConfig | null, @@ -858,6 +861,7 @@ function setupYomitanPopupFocusHarness( let focusMainWindowCalls = 0; let windowFocusCalls = 0; let overlayFocusCalls = 0; + let visiblePopupHostPresent = options.visiblePopupHost === true; ctx.platform.shouldToggleMouseIgnore = true; ctx.platform.isMacOSPlatform = options.isMacOSPlatform === true; @@ -908,8 +912,8 @@ function setupYomitanPopupFocusHarness( querySelector: () => null, querySelectorAll: (selector: string) => { if ( - (options.visiblePopupHost === true && selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) || - (options.visiblePopupHost === true && selector === YOMITAN_POPUP_HOST_SELECTOR) + (visiblePopupHostPresent && selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) || + (visiblePopupHostPresent && selector === YOMITAN_POPUP_HOST_SELECTOR) ) { return [visiblePopupHost]; } @@ -955,6 +959,9 @@ function setupYomitanPopupFocusHarness( focusMainWindowCalls: () => focusMainWindowCalls, windowFocusCalls: () => windowFocusCalls, overlayFocusCalls: () => overlayFocusCalls, + setVisiblePopupHost: (visible: boolean) => { + visiblePopupHostPresent = visible; + }, restore: () => { Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); Object.defineProperty(globalThis, 'document', { @@ -1021,7 +1028,7 @@ test('window blur on macOS keeps yomitan popup interactive without stealing clic } }); -test('popup shown reclaims overlay focus on macOS', () => { +test('popup shown reclaims overlay focus on macOS and captures click-away', () => { const harness = setupYomitanPopupFocusHarness({ isMacOSPlatform: true }); try { harness.ignoreCalls.length = 0; @@ -1041,6 +1048,73 @@ test('popup shown reclaims overlay focus on macOS', () => { } }); +test('popup mouse enter marks macOS Yomitan popup hover interactive', () => { + const harness = setupYomitanPopupFocusHarness({ + isMacOSPlatform: true, + visiblePopupHost: true, + }); + try { + harness.ignoreCalls.length = 0; + + for (const listener of harness.windowListeners.get(YOMITAN_POPUP_MOUSE_ENTER_EVENT) ?? []) { + listener(); + } + + assert.equal(harness.ctx.state.yomitanPopupVisible, true); + assert.equal(harness.ctx.state.isOverYomitanPopup, true); + assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]); + } finally { + harness.restore(); + } +}); + +test('popup mouse leave on macOS keeps click-away captured while popup remains visible', () => { + const harness = setupYomitanPopupFocusHarness({ + isMacOSPlatform: true, + visiblePopupHost: true, + }); + try { + for (const listener of harness.windowListeners.get(YOMITAN_POPUP_MOUSE_ENTER_EVENT) ?? []) { + listener(); + } + harness.ignoreCalls.length = 0; + + for (const listener of harness.windowListeners.get(YOMITAN_POPUP_MOUSE_LEAVE_EVENT) ?? []) { + listener(); + } + + assert.equal(harness.ctx.state.yomitanPopupVisible, true); + assert.equal(harness.ctx.state.isOverYomitanPopup, false); + assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]); + } finally { + harness.restore(); + } +}); + +test('popup hidden on macOS releases click-away capture back to mpv', () => { + const harness = setupYomitanPopupFocusHarness({ + isMacOSPlatform: true, + visiblePopupHost: true, + }); + try { + assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true); + harness.ignoreCalls.length = 0; + harness.setVisiblePopupHost(false); + + for (const listener of harness.windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) { + listener(); + } + + assert.equal(harness.ctx.state.yomitanPopupVisible, false); + assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), false); + assert.deepEqual(harness.ignoreCalls.at(-1), { ignore: true, forward: true }); + } finally { + harness.restore(); + } +}); + test('yomitan popup visibility marks primary subtitle hover hold while enabled', () => { const ctx = createMouseTestContext(); (ctx.state as { primaryVisibleOnYomitanPopup?: boolean }).primaryVisibleOnYomitanPopup = true; diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index d322150b..36959d0e 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -305,6 +305,7 @@ export function createMouseHandlers( yomitanPopupVisible = false; ctx.state.yomitanPopupVisible = false; + ctx.state.isOverYomitanPopup = false; syncPrimaryVisibleOnYomitanPopupClass(false); popupPauseRequestId += 1; maybeResumeYomitanPopupPause(); @@ -378,7 +379,10 @@ export function createMouseHandlers( } hoverPauseRequestId += 1; maybeResumeHoverPause(); - if (yomitanPopupVisible) return; + if (yomitanPopupVisible) { + syncOverlayMouseIgnoreState(ctx); + return; + } disablePopupInteractionIfIdle(); } @@ -479,10 +483,12 @@ export function createMouseHandlers( }); window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => { + ctx.state.isOverYomitanPopup = true; reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true }); }); window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => { + ctx.state.isOverYomitanPopup = false; reconcilePopupInteraction({ assumeVisible: true }); }); diff --git a/src/renderer/overlay-mouse-ignore.test.ts b/src/renderer/overlay-mouse-ignore.test.ts index 763247b6..8e67ef15 100644 --- a/src/renderer/overlay-mouse-ignore.test.ts +++ b/src/renderer/overlay-mouse-ignore.test.ts @@ -176,6 +176,111 @@ test('visible yomitan popup host keeps overlay interactive even when cached popu } }); +test('visible yomitan popup host on macOS keeps overlay interactive so click-away reaches popup', () => { + const classList = createClassList(); + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + + const restoreWindow = replaceGlobalProperty('window', { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + getComputedStyle: () => ({ + visibility: 'visible', + display: 'block', + opacity: '1', + }), + }); + const restoreDocument = replaceGlobalProperty('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: { + isMacOSPlatform: true, + shouldToggleMouseIgnore: true, + }, + state: { + isOverSubtitle: false, + isOverSubtitleSidebar: false, + isOverYomitanPopup: 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 { + restoreDocument(); + restoreWindow(); + } +}); + +test('macOS pointer over a visible yomitan popup keeps the overlay interactive', () => { + const classList = createClassList(); + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + + const restoreWindow = replaceGlobalProperty('window', { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + }); + + try { + syncOverlayMouseIgnoreState({ + dom: { + overlay: { classList }, + }, + platform: { + isMacOSPlatform: true, + shouldToggleMouseIgnore: true, + }, + state: { + isOverSubtitle: false, + isOverSubtitleSidebar: false, + isOverYomitanPopup: true, + yomitanPopupVisible: true, + 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 { + restoreWindow(); + } +}); + test('Linux subtitle hover keeps root passive and does not report whole-window interactive hint', () => { const classList = createClassList(); const interactiveHints: boolean[] = []; diff --git a/src/renderer/overlay-mouse-ignore.ts b/src/renderer/overlay-mouse-ignore.ts index f06584ba..5a5d3b4b 100644 --- a/src/renderer/overlay-mouse-ignore.ts +++ b/src/renderer/overlay-mouse-ignore.ts @@ -31,6 +31,7 @@ export function syncOverlayMouseIgnoreState(ctx: RendererContext): void { const shouldStayInteractive = ctx.state.isOverSubtitle || ctx.state.isOverSubtitleSidebar || + ctx.state.isOverYomitanPopup || ctx.state.isOverOverlayNotification || ctx.state.isOverNotificationHistory || shouldKeepWindowInteractive; diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 34bd575b..2c4a9871 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -139,6 +139,7 @@ export type RendererState = { keyboardSelectionVisible: boolean; keyboardSelectedWordIndex: number | null; yomitanPopupVisible: boolean; + isOverYomitanPopup: boolean; primarySubtitleMode: PrimarySubMode; }; @@ -254,6 +255,7 @@ export function createRendererState(): RendererState { keyboardSelectionVisible: false, keyboardSelectedWordIndex: null, yomitanPopupVisible: false, + isOverYomitanPopup: false, primarySubtitleMode: 'visible', }; }