mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-14 03:13:33 -07:00
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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user