mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
Keep overlay interactive while Yomitan popup is visible
This commit is contained in:
@@ -842,6 +842,131 @@ 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 () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||
const previousNode = (globalThis as { Node?: unknown }).Node;
|
||||
const windowListeners = new Map<string, Array<() => void>>();
|
||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||
let focusMainWindowCalls = 0;
|
||||
let windowFocusCalls = 0;
|
||||
let overlayFocusCalls = 0;
|
||||
|
||||
ctx.platform.shouldToggleMouseIgnore = true;
|
||||
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
|
||||
overlayFocusCalls += 1;
|
||||
};
|
||||
|
||||
const visiblePopupHost = {
|
||||
tagName: 'DIV',
|
||||
getAttribute: (name: string) =>
|
||||
name === 'data-subminer-yomitan-popup-visible' ? 'true' : null,
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: (type: string, listener: () => void) => {
|
||||
const bucket = windowListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
windowListeners.set(type, bucket);
|
||||
},
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
focusMainWindow: () => {
|
||||
focusMainWindowCalls += 1;
|
||||
},
|
||||
},
|
||||
focus: () => {
|
||||
windowFocusCalls += 1;
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
visibility: 'visible',
|
||||
display: 'block',
|
||||
opacity: '1',
|
||||
}),
|
||||
innerHeight: 1000,
|
||||
getSelection: () => null,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
visibilityState: 'visible',
|
||||
querySelector: () => null,
|
||||
querySelectorAll: (selector: string) => {
|
||||
if (
|
||||
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
|
||||
selector === YOMITAN_POPUP_HOST_SELECTOR
|
||||
) {
|
||||
return [visiblePopupHost];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
body: {},
|
||||
elementFromPoint: () => null,
|
||||
addEventListener: () => {},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||
configurable: true,
|
||||
value: class {
|
||||
observe() {}
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Node', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ELEMENT_NODE: 1,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
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();
|
||||
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 {
|
||||
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('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -1046,10 +1171,8 @@ test('pointer tracking restores click-through after the cursor leaves subtitles'
|
||||
|
||||
assert.equal(ctx.state.isOverSubtitle, false);
|
||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
||||
assert.deepEqual(ignoreCalls, [
|
||||
{ ignore: false, forward: undefined },
|
||||
{ ignore: true, forward: true },
|
||||
]);
|
||||
assert.equal(ignoreCalls[0]?.ignore, false);
|
||||
assert.deepEqual(ignoreCalls.at(-1), { ignore: true, forward: true });
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
isYomitanPopupVisible,
|
||||
isYomitanPopupIframe,
|
||||
@@ -34,6 +36,17 @@ export function createMouseHandlers(
|
||||
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
||||
let pendingPointerResync = false;
|
||||
|
||||
function getPopupVisibilityFromDom(): boolean {
|
||||
return typeof document !== 'undefined' && isYomitanPopupVisible(document);
|
||||
}
|
||||
|
||||
function syncPopupVisibilityState(assumeVisible = false): boolean {
|
||||
const popupVisible = assumeVisible || getPopupVisibilityFromDom();
|
||||
yomitanPopupVisible = popupVisible;
|
||||
ctx.state.yomitanPopupVisible = popupVisible;
|
||||
return popupVisible;
|
||||
}
|
||||
|
||||
function reclaimOverlayWindowFocusForPopup(): void {
|
||||
if (!ctx.platform.shouldToggleMouseIgnore) {
|
||||
return;
|
||||
@@ -52,11 +65,31 @@ export function createMouseHandlers(
|
||||
}
|
||||
|
||||
function sustainPopupInteraction(): void {
|
||||
yomitanPopupVisible = true;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
syncPopupVisibilityState(true);
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
function reconcilePopupInteraction(args: {
|
||||
assumeVisible?: boolean;
|
||||
reclaimFocus?: boolean;
|
||||
allowPause?: boolean;
|
||||
} = {}): boolean {
|
||||
const popupVisible = syncPopupVisibilityState(args.assumeVisible === true);
|
||||
if (!popupVisible) {
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
return false;
|
||||
}
|
||||
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
if (args.reclaimFocus === true) {
|
||||
reclaimOverlayWindowFocusForPopup();
|
||||
}
|
||||
if (args.allowPause === true) {
|
||||
void maybePauseForYomitanPopup();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
|
||||
if (!element) {
|
||||
return false;
|
||||
@@ -235,9 +268,7 @@ export function createMouseHandlers(
|
||||
}
|
||||
|
||||
function disablePopupInteractionIfIdle(): void {
|
||||
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
|
||||
sustainPopupInteraction();
|
||||
reclaimOverlayWindowFocusForPopup();
|
||||
if (reconcilePopupInteraction({ reclaimFocus: true })) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -377,19 +408,38 @@ export function createMouseHandlers(
|
||||
}
|
||||
|
||||
function setupYomitanObserver(): void {
|
||||
yomitanPopupVisible = isYomitanPopupVisible(document);
|
||||
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
|
||||
syncPopupVisibilityState();
|
||||
void maybePauseForYomitanPopup();
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||
enablePopupInteraction();
|
||||
void maybePauseForYomitanPopup();
|
||||
reconcilePopupInteraction({ assumeVisible: true, allowPause: true });
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||
disablePopupInteractionIfIdle();
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => {
|
||||
reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true });
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
|
||||
reconcilePopupInteraction({ assumeVisible: true });
|
||||
});
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
reconcilePopupInteraction();
|
||||
});
|
||||
|
||||
window.addEventListener('blur', () => {
|
||||
queueMicrotask(() => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
reconcilePopupInteraction({ reclaimFocus: true });
|
||||
});
|
||||
});
|
||||
|
||||
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
|
||||
@@ -61,3 +61,62 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact
|
||||
Object.assign(globalThis, { window: originalWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('visible yomitan popup host keeps overlay interactive even when cached popup state is false', () => {
|
||||
const classList = createClassList();
|
||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
visibility: 'visible',
|
||||
display: 'block',
|
||||
opacity: '1',
|
||||
}),
|
||||
},
|
||||
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: {
|
||||
shouldToggleMouseIgnore: true,
|
||||
},
|
||||
state: {
|
||||
isOverSubtitle: false,
|
||||
isOverSubtitleSidebar: 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 {
|
||||
Object.assign(globalThis, { window: originalWindow, document: originalDocument });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RendererContext } from './context';
|
||||
import type { RendererState } from './state';
|
||||
import { isYomitanPopupVisible } from './yomitan-popup.js';
|
||||
|
||||
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||
return Boolean(
|
||||
@@ -14,11 +15,21 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isYomitanPopupInteractionActive(state: RendererState): boolean {
|
||||
if (state.yomitanPopupVisible) {
|
||||
return true;
|
||||
}
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return isYomitanPopupVisible(document);
|
||||
}
|
||||
|
||||
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
||||
const shouldStayInteractive =
|
||||
ctx.state.isOverSubtitle ||
|
||||
ctx.state.isOverSubtitleSidebar ||
|
||||
ctx.state.yomitanPopupVisible ||
|
||||
isYomitanPopupInteractionActive(ctx.state) ||
|
||||
isBlockingOverlayModalOpen(ctx.state);
|
||||
|
||||
if (shouldStayInteractive) {
|
||||
|
||||
@@ -34,8 +34,9 @@ export function isYomitanPopupIframe(element: Element | null): boolean {
|
||||
|
||||
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
||||
return (
|
||||
root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null ||
|
||||
root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null
|
||||
typeof root.querySelector === 'function' &&
|
||||
(root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null ||
|
||||
root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,20 +58,27 @@ function isMarkedVisiblePopupHost(element: Element): boolean {
|
||||
return element.getAttribute(YOMITAN_POPUP_VISIBLE_ATTRIBUTE) === 'true';
|
||||
}
|
||||
|
||||
function queryPopupElements<T extends Element>(root: ParentNode, selector: string): T[] {
|
||||
if (typeof root.querySelectorAll !== 'function') {
|
||||
return [];
|
||||
}
|
||||
return Array.from(root.querySelectorAll<T>(selector));
|
||||
}
|
||||
|
||||
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
|
||||
const visiblePopupHosts = root.querySelectorAll<HTMLElement>(YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
|
||||
const visiblePopupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
|
||||
if (visiblePopupHosts.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const popupIframes = root.querySelectorAll<HTMLIFrameElement>(YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||
const popupIframes = queryPopupElements<HTMLIFrameElement>(root, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||
for (const iframe of popupIframes) {
|
||||
if (isVisiblePopupElement(iframe)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const popupHosts = root.querySelectorAll<HTMLElement>(YOMITAN_POPUP_HOST_SELECTOR);
|
||||
const popupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_HOST_SELECTOR);
|
||||
for (const host of popupHosts) {
|
||||
if (isMarkedVisiblePopupHost(host)) {
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user