fix(overlay): restore macOS Yomitan popup focus without breaking click-away (#125)

This commit is contained in:
2026-06-14 16:46:13 -07:00
committed by GitHub
parent 1158be5b39
commit ae7e6f82a8
7 changed files with 317 additions and 40 deletions
+5
View File
@@ -0,0 +1,5 @@
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.
+9
View File
@@ -5307,6 +5307,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
focusMainWindow: () => { focusMainWindow: () => {
const mainWindow = overlayManager.getMainWindow(); const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return; if (!mainWindow || mainWindow.isDestroyed()) return;
if (process.platform === 'darwin') {
focusMacOSOverlayWindow({
platform: process.platform,
getOverlayWindow: () => mainWindow,
stealAppFocus: () => app.focus({ steal: true }),
warn: (message, details) => logger.warn(message, details),
});
return;
}
if (!mainWindow.isFocused()) { if (!mainWindow.isFocused()) {
mainWindow.focus(); mainWindow.focus();
} }
+167 -22
View File
@@ -6,6 +6,8 @@ import { createMouseHandlers } from './mouse.js';
import { import {
YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_HOST_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR,
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
YOMITAN_POPUP_SHOWN_EVENT, YOMITAN_POPUP_SHOWN_EVENT,
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
} from '../yomitan-popup.js'; } from '../yomitan-popup.js';
@@ -89,6 +91,7 @@ function createMouseTestContext() {
state: { state: {
isOverSubtitle: false, isOverSubtitle: false,
isOverSubtitleSidebar: false, isOverSubtitleSidebar: false,
isOverYomitanPopup: false,
yomitanPopupVisible: false, yomitanPopupVisible: false,
subtitleSidebarModalOpen: false, subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null as SubtitleSidebarConfig | null, subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
@@ -842,7 +845,12 @@ 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 () => { function setupYomitanPopupFocusHarness(
options: {
isMacOSPlatform?: boolean;
visiblePopupHost?: boolean;
} = {},
) {
const ctx = createMouseTestContext(); const ctx = createMouseTestContext();
const previousWindow = (globalThis as { window?: unknown }).window; const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document; const previousDocument = (globalThis as { document?: unknown }).document;
@@ -853,8 +861,10 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
let focusMainWindowCalls = 0; let focusMainWindowCalls = 0;
let windowFocusCalls = 0; let windowFocusCalls = 0;
let overlayFocusCalls = 0; let overlayFocusCalls = 0;
let visiblePopupHostPresent = options.visiblePopupHost === true;
ctx.platform.shouldToggleMouseIgnore = true; ctx.platform.shouldToggleMouseIgnore = true;
ctx.platform.isMacOSPlatform = options.isMacOSPlatform === true;
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => { (ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
overlayFocusCalls += 1; overlayFocusCalls += 1;
}; };
@@ -902,8 +912,8 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
querySelector: () => null, querySelector: () => null,
querySelectorAll: (selector: string) => { querySelectorAll: (selector: string) => {
if ( if (
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || (visiblePopupHostPresent && selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) ||
selector === YOMITAN_POPUP_HOST_SELECTOR (visiblePopupHostPresent && selector === YOMITAN_POPUP_HOST_SELECTOR)
) { ) {
return [visiblePopupHost]; return [visiblePopupHost];
} }
@@ -927,7 +937,6 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
}, },
}); });
try {
const handlers = createMouseHandlers(ctx as never, { const handlers = createMouseHandlers(ctx as never, {
modalStateReader: { modalStateReader: {
isAnySettingsModalOpen: () => false, isAnySettingsModalOpen: () => false,
@@ -941,32 +950,168 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
getPlaybackPaused: async () => false, getPlaybackPaused: async () => false,
sendMpvCommand: () => {}, sendMpvCommand: () => {},
}); });
handlers.setupYomitanObserver(); handlers.setupYomitanObserver();
assert.equal(ctx.state.yomitanPopupVisible, true);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
ignoreCalls.length = 0;
for (const listener of windowListeners.get('blur') ?? []) { return {
listener(); ctx,
} windowListeners,
await Promise.resolve(); ignoreCalls,
focusMainWindowCalls: () => focusMainWindowCalls,
assert.equal(ctx.state.yomitanPopupVisible, true); windowFocusCalls: () => windowFocusCalls,
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true); overlayFocusCalls: () => overlayFocusCalls,
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); setVisiblePopupHost: (visible: boolean) => {
assert.equal(focusMainWindowCalls, 1); visiblePopupHostPresent = visible;
assert.equal(windowFocusCalls, 1); },
assert.equal(overlayFocusCalls, 1); restore: () => {
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); Object.defineProperty(globalThis, 'document', {
configurable: true,
value: previousDocument,
});
Object.defineProperty(globalThis, 'MutationObserver', { Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true, configurable: true,
value: previousMutationObserver, value: previousMutationObserver,
}); });
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode }); Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
},
};
}
test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => {
const harness = setupYomitanPopupFocusHarness({ visiblePopupHost: true });
try {
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
harness.ignoreCalls.length = 0;
for (const listener of harness.windowListeners.get('blur') ?? []) {
listener();
}
await Promise.resolve();
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
assert.equal(harness.focusMainWindowCalls(), 1);
assert.equal(harness.windowFocusCalls(), 1);
assert.equal(harness.overlayFocusCalls(), 1);
} finally {
harness.restore();
}
});
test('window blur on macOS keeps yomitan popup interactive without stealing click-away focus', async () => {
const harness = setupYomitanPopupFocusHarness({
isMacOSPlatform: true,
visiblePopupHost: true,
});
try {
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
harness.ignoreCalls.length = 0;
for (const listener of harness.windowListeners.get('blur') ?? []) {
listener();
}
await Promise.resolve();
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
assert.equal(harness.focusMainWindowCalls(), 0);
assert.equal(harness.windowFocusCalls(), 0);
assert.equal(harness.overlayFocusCalls(), 0);
} finally {
harness.restore();
}
});
test('popup shown reclaims overlay focus on macOS and captures click-away', () => {
const harness = setupYomitanPopupFocusHarness({ isMacOSPlatform: true });
try {
harness.ignoreCalls.length = 0;
for (const listener of harness.windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
listener();
}
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
assert.equal(harness.focusMainWindowCalls(), 1);
assert.equal(harness.windowFocusCalls(), 1);
assert.equal(harness.overlayFocusCalls(), 1);
} finally {
harness.restore();
}
});
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();
} }
}); });
+14 -4
View File
@@ -67,7 +67,7 @@ export function createMouseHandlers(
if (!ctx.platform.shouldToggleMouseIgnore) { if (!ctx.platform.shouldToggleMouseIgnore) {
return; return;
} }
if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) { if (ctx.platform.isLinuxPlatform) {
return; return;
} }
@@ -305,6 +305,7 @@ export function createMouseHandlers(
yomitanPopupVisible = false; yomitanPopupVisible = false;
ctx.state.yomitanPopupVisible = false; ctx.state.yomitanPopupVisible = false;
ctx.state.isOverYomitanPopup = false;
syncPrimaryVisibleOnYomitanPopupClass(false); syncPrimaryVisibleOnYomitanPopupClass(false);
popupPauseRequestId += 1; popupPauseRequestId += 1;
maybeResumeYomitanPopupPause(); maybeResumeYomitanPopupPause();
@@ -378,7 +379,10 @@ export function createMouseHandlers(
} }
hoverPauseRequestId += 1; hoverPauseRequestId += 1;
maybeResumeHoverPause(); maybeResumeHoverPause();
if (yomitanPopupVisible) return; if (yomitanPopupVisible) {
syncOverlayMouseIgnoreState(ctx);
return;
}
disablePopupInteractionIfIdle(); disablePopupInteractionIfIdle();
} }
@@ -467,7 +471,11 @@ export function createMouseHandlers(
reconcilePopupInteraction({ allowPause: true }); reconcilePopupInteraction({ allowPause: true });
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => { window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
reconcilePopupInteraction({ assumeVisible: true, allowPause: true }); reconcilePopupInteraction({
assumeVisible: true,
allowPause: true,
reclaimFocus: ctx.platform.isMacOSPlatform,
});
}); });
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => { window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
@@ -475,10 +483,12 @@ export function createMouseHandlers(
}); });
window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => { window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => {
ctx.state.isOverYomitanPopup = true;
reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true }); reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true });
}); });
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => { window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
ctx.state.isOverYomitanPopup = false;
reconcilePopupInteraction({ assumeVisible: true }); reconcilePopupInteraction({ assumeVisible: true });
}); });
@@ -491,7 +501,7 @@ export function createMouseHandlers(
if (typeof document === 'undefined' || document.visibilityState !== 'visible') { if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
return; return;
} }
reconcilePopupInteraction({ reclaimFocus: true }); reconcilePopupInteraction({ reclaimFocus: !ctx.platform.isMacOSPlatform });
}); });
}); });
+105
View File
@@ -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', () => { test('Linux subtitle hover keeps root passive and does not report whole-window interactive hint', () => {
const classList = createClassList(); const classList = createClassList();
const interactiveHints: boolean[] = []; const interactiveHints: boolean[] = [];
+1
View File
@@ -31,6 +31,7 @@ export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
const shouldStayInteractive = const shouldStayInteractive =
ctx.state.isOverSubtitle || ctx.state.isOverSubtitle ||
ctx.state.isOverSubtitleSidebar || ctx.state.isOverSubtitleSidebar ||
ctx.state.isOverYomitanPopup ||
ctx.state.isOverOverlayNotification || ctx.state.isOverOverlayNotification ||
ctx.state.isOverNotificationHistory || ctx.state.isOverNotificationHistory ||
shouldKeepWindowInteractive; shouldKeepWindowInteractive;
+2
View File
@@ -139,6 +139,7 @@ export type RendererState = {
keyboardSelectionVisible: boolean; keyboardSelectionVisible: boolean;
keyboardSelectedWordIndex: number | null; keyboardSelectedWordIndex: number | null;
yomitanPopupVisible: boolean; yomitanPopupVisible: boolean;
isOverYomitanPopup: boolean;
primarySubtitleMode: PrimarySubMode; primarySubtitleMode: PrimarySubMode;
}; };
@@ -254,6 +255,7 @@ export function createRendererState(): RendererState {
keyboardSelectionVisible: false, keyboardSelectionVisible: false,
keyboardSelectedWordIndex: null, keyboardSelectedWordIndex: null,
yomitanPopupVisible: false, yomitanPopupVisible: false,
isOverYomitanPopup: false,
primarySubtitleMode: 'visible', primarySubtitleMode: 'visible',
}; };
} }