mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-06 19:21:31 -07:00
fix(overlay): restore macOS Yomitan popup focus without breaking click-away (#125)
This commit is contained in:
@@ -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,
|
||||
@@ -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 previousWindow = (globalThis as { window?: unknown }).window;
|
||||
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 windowFocusCalls = 0;
|
||||
let overlayFocusCalls = 0;
|
||||
let visiblePopupHostPresent = options.visiblePopupHost === true;
|
||||
|
||||
ctx.platform.shouldToggleMouseIgnore = true;
|
||||
ctx.platform.isMacOSPlatform = options.isMacOSPlatform === true;
|
||||
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
|
||||
overlayFocusCalls += 1;
|
||||
};
|
||||
@@ -902,8 +912,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
|
||||
(visiblePopupHostPresent && selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) ||
|
||||
(visiblePopupHostPresent && selector === YOMITAN_POPUP_HOST_SELECTOR)
|
||||
) {
|
||||
return [visiblePopupHost];
|
||||
}
|
||||
@@ -927,46 +937,181 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
return {
|
||||
ctx,
|
||||
windowListeners,
|
||||
ignoreCalls,
|
||||
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||
windowFocusCalls: () => windowFocusCalls,
|
||||
overlayFocusCalls: () => overlayFocusCalls,
|
||||
setVisiblePopupHost: (visible: boolean) => {
|
||||
visiblePopupHostPresent = visible;
|
||||
},
|
||||
restore: () => {
|
||||
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('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => {
|
||||
const harness = setupYomitanPopupFocusHarness({ visiblePopupHost: true });
|
||||
try {
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||
getYomitanPopupAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: () => {},
|
||||
});
|
||||
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;
|
||||
|
||||
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') ?? []) {
|
||||
for (const listener of harness.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);
|
||||
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 {
|
||||
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 });
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export function createMouseHandlers(
|
||||
if (!ctx.platform.shouldToggleMouseIgnore) {
|
||||
return;
|
||||
}
|
||||
if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) {
|
||||
if (ctx.platform.isLinuxPlatform) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -467,7 +471,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, () => {
|
||||
@@ -475,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 });
|
||||
});
|
||||
|
||||
@@ -491,7 +501,7 @@ export function createMouseHandlers(
|
||||
if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
reconcilePopupInteraction({ reclaimFocus: true });
|
||||
reconcilePopupInteraction({ reclaimFocus: !ctx.platform.isMacOSPlatform });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user