Compare commits

...

1 Commits

Author SHA1 Message Date
sudacode 0ff77eebc3 fix(overlay): restore macOS Yomitan popup focus without breaking click-a
- On macOS, skip blur-triggered focus reclaim so click-away closes popup cleanly
- Reclaim focus on popup shown event instead (card mining / reload case)
- Route focusMainWindow through focusMacOSOverlayWindow on darwin
- Refactor blur/focus test into shared harness; add macOS-specific cases
2026-06-12 23:03:43 -07:00
4 changed files with 127 additions and 39 deletions
+4
View File
@@ -0,0 +1,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.
+9
View File
@@ -5307,6 +5307,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
focusMainWindow: () => {
const mainWindow = overlayManager.getMainWindow();
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()) {
mainWindow.focus();
}
+93 -22
View File
@@ -842,7 +842,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 previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document;
@@ -855,6 +860,7 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
let overlayFocusCalls = 0;
ctx.platform.shouldToggleMouseIgnore = true;
ctx.platform.isMacOSPlatform = options.isMacOSPlatform === true;
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
overlayFocusCalls += 1;
};
@@ -902,8 +908,8 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
querySelector: () => null,
querySelectorAll: (selector: string) => {
if (
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
selector === YOMITAN_POPUP_HOST_SELECTOR
(options.visiblePopupHost === true && selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) ||
(options.visiblePopupHost === true && selector === YOMITAN_POPUP_HOST_SELECTOR)
) {
return [visiblePopupHost];
}
@@ -927,7 +933,6 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
@@ -941,32 +946,98 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
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') ?? []) {
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 {
return {
ctx,
windowListeners,
ignoreCalls,
focusMainWindowCalls: () => focusMainWindowCalls,
windowFocusCalls: () => windowFocusCalls,
overlayFocusCalls: () => overlayFocusCalls,
restore: () => {
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', {
configurable: true,
value: previousMutationObserver,
});
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', () => {
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();
}
});
+7 -3
View File
@@ -67,7 +67,7 @@ export function createMouseHandlers(
if (!ctx.platform.shouldToggleMouseIgnore) {
return;
}
if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) {
if (ctx.platform.isLinuxPlatform) {
return;
}
@@ -467,7 +467,11 @@ export function createMouseHandlers(
reconcilePopupInteraction({ allowPause: true });
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, () => {
@@ -491,7 +495,7 @@ export function createMouseHandlers(
if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
return;
}
reconcilePopupInteraction({ reclaimFocus: true });
reconcilePopupInteraction({ reclaimFocus: !ctx.platform.isMacOSPlatform });
});
});