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', () => {
|
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
|
||||||
const ctx = createMouseTestContext();
|
const ctx = createMouseTestContext();
|
||||||
const originalWindow = globalThis.window;
|
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.state.isOverSubtitle, false);
|
||||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
||||||
assert.deepEqual(ignoreCalls, [
|
assert.equal(ignoreCalls[0]?.ignore, false);
|
||||||
{ ignore: false, forward: undefined },
|
assert.deepEqual(ignoreCalls.at(-1), { ignore: true, forward: true });
|
||||||
{ ignore: true, forward: true },
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
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 { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
|
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||||
|
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
isYomitanPopupVisible,
|
isYomitanPopupVisible,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
@@ -34,6 +36,17 @@ export function createMouseHandlers(
|
|||||||
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
||||||
let pendingPointerResync = false;
|
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 {
|
function reclaimOverlayWindowFocusForPopup(): void {
|
||||||
if (!ctx.platform.shouldToggleMouseIgnore) {
|
if (!ctx.platform.shouldToggleMouseIgnore) {
|
||||||
return;
|
return;
|
||||||
@@ -52,11 +65,31 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sustainPopupInteraction(): void {
|
function sustainPopupInteraction(): void {
|
||||||
yomitanPopupVisible = true;
|
syncPopupVisibilityState(true);
|
||||||
ctx.state.yomitanPopupVisible = true;
|
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
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 {
|
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return false;
|
return false;
|
||||||
@@ -235,9 +268,7 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function disablePopupInteractionIfIdle(): void {
|
function disablePopupInteractionIfIdle(): void {
|
||||||
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
|
if (reconcilePopupInteraction({ reclaimFocus: true })) {
|
||||||
sustainPopupInteraction();
|
|
||||||
reclaimOverlayWindowFocusForPopup();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,19 +408,38 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupYomitanObserver(): void {
|
function setupYomitanObserver(): void {
|
||||||
yomitanPopupVisible = isYomitanPopupVisible(document);
|
syncPopupVisibilityState();
|
||||||
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
|
|
||||||
void maybePauseForYomitanPopup();
|
void maybePauseForYomitanPopup();
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||||
enablePopupInteraction();
|
reconcilePopupInteraction({ assumeVisible: true, allowPause: true });
|
||||||
void maybePauseForYomitanPopup();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
disablePopupInteractionIfIdle();
|
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[]) => {
|
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
mutation.addedNodes.forEach((node) => {
|
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 });
|
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 { RendererContext } from './context';
|
||||||
import type { RendererState } from './state';
|
import type { RendererState } from './state';
|
||||||
|
import { isYomitanPopupVisible } from './yomitan-popup.js';
|
||||||
|
|
||||||
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||||
return 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 {
|
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
||||||
const shouldStayInteractive =
|
const shouldStayInteractive =
|
||||||
ctx.state.isOverSubtitle ||
|
ctx.state.isOverSubtitle ||
|
||||||
ctx.state.isOverSubtitleSidebar ||
|
ctx.state.isOverSubtitleSidebar ||
|
||||||
ctx.state.yomitanPopupVisible ||
|
isYomitanPopupInteractionActive(ctx.state) ||
|
||||||
isBlockingOverlayModalOpen(ctx.state);
|
isBlockingOverlayModalOpen(ctx.state);
|
||||||
|
|
||||||
if (shouldStayInteractive) {
|
if (shouldStayInteractive) {
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ export function isYomitanPopupIframe(element: Element | null): boolean {
|
|||||||
|
|
||||||
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
||||||
return (
|
return (
|
||||||
root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null ||
|
typeof root.querySelector === 'function' &&
|
||||||
root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null
|
(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';
|
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 {
|
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) {
|
if (visiblePopupHosts.length > 0) {
|
||||||
return true;
|
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) {
|
for (const iframe of popupIframes) {
|
||||||
if (isVisiblePopupElement(iframe)) {
|
if (isVisiblePopupElement(iframe)) {
|
||||||
return true;
|
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) {
|
for (const host of popupHosts) {
|
||||||
if (isMarkedVisiblePopupHost(host)) {
|
if (isMarkedVisiblePopupHost(host)) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user