Files
SubMiner/src/renderer/handlers/mouse.ts
T
sudacode c39dbb5a8d fix(overlay): keep Hachidori attention from pausing playback
Hachidori's hachidori-popup-shown/hidden pair means "the reader needs
mouse events", which includes a left press anywhere on the overlay that
may start a selection. SubMiner treated it as popup visibility, so a
click paused and resumed mpv, and holding the button kept it paused.
The host element only exists after the first lookup, so the first
attempt (gate on an open popup pane when a host exists) still paused
on clicks before any lookup.

Once a Hachidori event has been seen, popup auto-pause now requires an
unhidden popup pane in the host's shadow root, and rechecks after each
successful lookup so drag-select popups still pause.
2026-09-22 21:06:19 -07:00

566 lines
17 KiB
TypeScript

import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
import {
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
registerDictionaryPopupVisibilityListener,
registerYomitanLookupListener,
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
isHachidoriPopupOpen,
isYomitanPopupVisible,
isYomitanPopupIframe,
} from '../yomitan-popup.js';
const VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE = 'visibility-recovery';
const WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE = 'window-resize';
export function createMouseHandlers(
ctx: RendererContext,
options: {
modalStateReader: ModalStateReader;
applyYPercent: (yPercent: number) => void;
getCurrentYPercent: () => number;
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
getSubtitleHoverAutoPauseEnabled: () => boolean;
getYomitanPopupAutoPauseEnabled: () => boolean;
getPlaybackPaused: () => Promise<boolean | null>;
sendMpvCommand: (command: (string | number)[]) => void;
},
) {
type HoverPointState = {
overPrimarySubtitle: boolean;
overSecondarySubtitle: boolean;
isOverSubtitle: boolean;
};
let yomitanPopupVisible = false;
let hoverPauseRequestId = 0;
let popupPauseRequestId = 0;
let hachidoriReaderSeen = false;
let pausedBySubtitleHover = false;
let pausedByYomitanPopup = false;
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
let pendingPointerResync = false;
let suppressDirectHoverEnterSource: string | null = null;
function getPopupVisibilityFromDom(): boolean {
return typeof document !== 'undefined' && isYomitanPopupVisible(document);
}
function syncPrimaryVisibleOnYomitanPopupClass(popupVisible: boolean): void {
if (typeof document === 'undefined') {
return;
}
document.body?.classList?.toggle(
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
popupVisible && ctx.state.primaryVisibleOnYomitanPopup,
);
}
function syncPopupVisibilityState(assumeVisible = false): boolean {
const popupVisible = assumeVisible || getPopupVisibilityFromDom();
yomitanPopupVisible = popupVisible;
ctx.state.yomitanPopupVisible = popupVisible;
syncPrimaryVisibleOnYomitanPopupClass(popupVisible);
return popupVisible;
}
function reclaimOverlayWindowFocusForPopup(): void {
if (!ctx.platform.shouldToggleMouseIgnore) {
return;
}
if (ctx.platform.isLinuxPlatform) {
return;
}
if (typeof window.electronAPI.focusMainWindow === 'function') {
void window.electronAPI.focusMainWindow();
}
window.focus();
if (typeof ctx.dom.overlay.focus === 'function') {
ctx.dom.overlay.focus({ preventScroll: true });
}
}
function sustainPopupInteraction(): void {
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;
}
if (element === container) {
return true;
}
return typeof container.contains === 'function' ? container.contains(element) : false;
}
function updatePointerPosition(event: MouseEvent | PointerEvent): void {
lastPointerPosition = {
clientX: event.clientX,
clientY: event.clientY,
};
}
function getHoverStateFromPoint(clientX: number, clientY: number): HoverPointState {
const hoveredElement =
typeof document.elementFromPoint === 'function'
? document.elementFromPoint(clientX, clientY)
: null;
const overPrimarySubtitle = isElementWithinContainer(hoveredElement, ctx.dom.subtitleContainer);
const overSecondarySubtitle = isElementWithinContainer(
hoveredElement,
ctx.dom.secondarySubContainer,
);
return {
overPrimarySubtitle,
overSecondarySubtitle,
isOverSubtitle: overPrimarySubtitle || overSecondarySubtitle,
};
}
function syncHoverStateFromPoint(clientX: number, clientY: number): HoverPointState {
const hoverState = getHoverStateFromPoint(clientX, clientY);
ctx.state.isOverSubtitle = hoverState.isOverSubtitle;
ctx.dom.secondarySubContainer.classList.toggle(
'secondary-sub-hover-active',
hoverState.overSecondarySubtitle,
);
return hoverState;
}
function syncHoverStateFromTrackedPointer(event: MouseEvent | PointerEvent): void {
if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isDragging) {
return;
}
suppressDirectHoverEnterSource = null;
const wasOverSubtitle = ctx.state.isOverSubtitle;
const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains(
'secondary-sub-hover-active',
);
const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY);
if (!wasOverSubtitle && hoverState.isOverSubtitle) {
void handleMouseEnter(undefined, hoverState.overSecondarySubtitle, 'tracked-pointer');
return;
}
if (wasOverSubtitle && !hoverState.isOverSubtitle) {
void handleMouseLeave(undefined, wasOverSecondarySubtitle);
return;
}
if (
hoverState.isOverSubtitle &&
hoverState.overSecondarySubtitle !== wasOverSecondarySubtitle
) {
syncOverlayMouseIgnoreState(ctx);
}
}
function resyncPointerInteractionState(options: {
allowInteractiveFallback: boolean;
suppressDirectHoverEnterSource?: string | null;
}): void {
const pointerPosition = lastPointerPosition;
pendingPointerResync = false;
suppressDirectHoverEnterSource = options.suppressDirectHoverEnterSource ?? null;
if (pointerPosition) {
syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY);
} else {
ctx.state.isOverSubtitle = false;
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
}
syncOverlayMouseIgnoreState(ctx);
if (
!options.allowInteractiveFallback ||
!ctx.platform.shouldToggleMouseIgnore ||
ctx.state.isOverSubtitle
) {
return;
}
pendingPointerResync = true;
ctx.dom.overlay.classList.add('interactive');
window.electronAPI.setIgnoreMouseEvents(false);
}
function restorePointerInteractionState(): void {
resyncPointerInteractionState({ allowInteractiveFallback: true });
}
function maybeResyncPointerHoverState(event: MouseEvent | PointerEvent): void {
if (!pendingPointerResync) {
return;
}
pendingPointerResync = false;
syncHoverStateFromPoint(event.clientX, event.clientY);
syncOverlayMouseIgnoreState(ctx);
}
function isWithinOtherSubtitleContainer(
relatedTarget: EventTarget | null,
otherContainer: HTMLElement,
): boolean {
if (relatedTarget === otherContainer) {
return true;
}
if (typeof Node !== 'undefined' && relatedTarget instanceof Node) {
return otherContainer.contains(relatedTarget);
}
return false;
}
function maybeResumeHoverPause(): void {
if (!pausedBySubtitleHover) return;
if (pausedByYomitanPopup) return;
if (ctx.state.isOverSubtitle) return;
pausedBySubtitleHover = false;
options.sendMpvCommand(['set_property', 'pause', 'no']);
}
function maybeResumeYomitanPopupPause(): void {
if (!pausedByYomitanPopup) return;
pausedByYomitanPopup = false;
if (ctx.state.isOverSubtitle && options.getSubtitleHoverAutoPauseEnabled()) {
pausedBySubtitleHover = true;
return;
}
options.sendMpvCommand(['set_property', 'pause', 'no']);
}
// Hachidori also claims attention for a left press anywhere on the overlay
// that may become a selection, so once its events identify the reader only
// an open popup pane should pause playback.
function canPauseForYomitanPopup(): boolean {
return (
yomitanPopupVisible &&
options.getYomitanPopupAutoPauseEnabled() &&
(!hachidoriReaderSeen || (typeof document !== 'undefined' && isHachidoriPopupOpen(document)))
);
}
async function maybePauseForYomitanPopup(): Promise<void> {
if (!canPauseForYomitanPopup()) {
return;
}
const requestId = ++popupPauseRequestId;
if (pausedByYomitanPopup) return;
if (pausedBySubtitleHover) {
pausedBySubtitleHover = false;
pausedByYomitanPopup = true;
return;
}
let paused: boolean | null = null;
try {
paused = await options.getPlaybackPaused();
} catch {
return;
}
if (requestId !== popupPauseRequestId || !canPauseForYomitanPopup()) {
return;
}
if (paused !== false) return;
options.sendMpvCommand(['set_property', 'pause', 'yes']);
pausedByYomitanPopup = true;
}
function enablePopupInteraction(): void {
sustainPopupInteraction();
if (ctx.platform.isMacOSPlatform) {
window.focus();
}
}
function disablePopupInteractionIfIdle(): void {
if (reconcilePopupInteraction({ reclaimFocus: true })) {
return;
}
yomitanPopupVisible = false;
ctx.state.yomitanPopupVisible = false;
ctx.state.isOverYomitanPopup = false;
syncPrimaryVisibleOnYomitanPopupClass(false);
popupPauseRequestId += 1;
maybeResumeYomitanPopupPause();
maybeResumeHoverPause();
syncOverlayMouseIgnoreState(ctx);
}
async function handleMouseEnter(
event?: MouseEvent,
showSecondaryHover = false,
source: 'direct' | 'tracked-pointer' = 'direct',
): Promise<void> {
if (source === 'direct' && suppressDirectHoverEnterSource !== null) {
if (!event || !syncHoverStateFromPoint(event.clientX, event.clientY).isOverSubtitle) {
return;
}
suppressDirectHoverEnterSource = null;
}
ctx.state.isOverSubtitle = true;
if (showSecondaryHover) {
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active');
}
syncOverlayMouseIgnoreState(ctx);
if (yomitanPopupVisible && options.getYomitanPopupAutoPauseEnabled()) {
return;
}
if (!options.getSubtitleHoverAutoPauseEnabled()) {
return;
}
if (pausedBySubtitleHover) {
return;
}
const requestId = ++hoverPauseRequestId;
let paused: boolean | null = null;
try {
paused = await options.getPlaybackPaused();
} catch {
return;
}
if (requestId !== hoverPauseRequestId || !ctx.state.isOverSubtitle) {
return;
}
if (paused !== false) {
return;
}
options.sendMpvCommand(['set_property', 'pause', 'yes']);
pausedBySubtitleHover = true;
}
async function handleMouseLeave(_event?: MouseEvent, hideSecondaryHover = false): Promise<void> {
const relatedTarget = _event?.relatedTarget ?? null;
const otherContainer = hideSecondaryHover
? ctx.dom.subtitleContainer
: ctx.dom.secondarySubContainer;
if (relatedTarget && isWithinOtherSubtitleContainer(relatedTarget, otherContainer)) {
ctx.state.isOverSubtitle = false;
if (hideSecondaryHover) {
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
}
return;
}
ctx.state.isOverSubtitle = false;
if (hideSecondaryHover) {
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
}
hoverPauseRequestId += 1;
maybeResumeHoverPause();
if (yomitanPopupVisible) {
syncOverlayMouseIgnoreState(ctx);
return;
}
disablePopupInteractionIfIdle();
}
function setupDragging(): void {
ctx.dom.subtitleContainer.addEventListener('mousedown', (e: MouseEvent) => {
if (e.button === 2) {
e.preventDefault();
ctx.state.isDragging = true;
ctx.state.dragStartY = e.clientY;
ctx.state.startYPercent = options.getCurrentYPercent();
ctx.dom.subtitleContainer.style.cursor = 'grabbing';
}
});
document.addEventListener('mousemove', (e: MouseEvent) => {
if (!ctx.state.isDragging) return;
const deltaY = ctx.state.dragStartY - e.clientY;
const deltaPercent = (deltaY / window.innerHeight) * 100;
const newYPercent = ctx.state.startYPercent + deltaPercent;
options.applyYPercent(newYPercent);
});
document.addEventListener('mouseup', (e: MouseEvent) => {
if (ctx.state.isDragging && e.button === 2) {
ctx.state.isDragging = false;
ctx.dom.subtitleContainer.style.cursor = '';
const yPercent = options.getCurrentYPercent();
options.persistSubtitlePositionPatch({ yPercent });
}
});
ctx.dom.subtitleContainer.addEventListener('contextmenu', (e: Event) => {
e.preventDefault();
});
}
function setupResizeHandler(): void {
window.addEventListener('resize', () => {
options.applyYPercent(options.getCurrentYPercent());
resyncPointerInteractionState({
allowInteractiveFallback: false,
suppressDirectHoverEnterSource: WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE,
});
});
}
function setupPointerTracking(): void {
document.addEventListener('mousemove', (event: MouseEvent) => {
updatePointerPosition(event);
syncHoverStateFromTrackedPointer(event);
maybeResyncPointerHoverState(event);
});
document.addEventListener('pointermove', (event: PointerEvent) => {
updatePointerPosition(event);
syncHoverStateFromTrackedPointer(event);
maybeResyncPointerHoverState(event);
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') {
return;
}
resyncPointerInteractionState({
allowInteractiveFallback: false,
suppressDirectHoverEnterSource: VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE,
});
});
}
function setupSelectionObserver(): void {
document.addEventListener('selectionchange', () => {
const selection = window.getSelection();
const hasSelection = selection && selection.rangeCount > 0 && !selection.isCollapsed;
if (hasSelection) {
ctx.dom.subtitleRoot.classList.add('has-selection');
} else {
ctx.dom.subtitleRoot.classList.remove('has-selection');
}
});
}
function setupYomitanObserver(): void {
reconcilePopupInteraction({ allowPause: true });
registerDictionaryPopupVisibilityListener('shown', (reader) => {
if (reader === 'hachidori') hachidoriReaderSeen = true;
reconcilePopupInteraction({
assumeVisible: true,
allowPause: true,
reclaimFocus: ctx.platform.isMacOSPlatform,
});
});
registerDictionaryPopupVisibilityListener('hidden', () => {
disablePopupInteractionIfIdle();
});
// A Hachidori selection opens its popup while the drag's attention claim is
// still held, so no second shown event arrives; its lookup marks the open.
registerYomitanLookupListener(window, () => {
reconcilePopupInteraction({ allowPause: true });
});
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 });
});
window.addEventListener('focus', () => {
reconcilePopupInteraction();
});
window.addEventListener('blur', () => {
queueMicrotask(() => {
if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
return;
}
reconcilePopupInteraction({ reclaimFocus: !ctx.platform.isMacOSPlatform });
});
});
const observer = new MutationObserver((mutations: MutationRecord[]) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as Element;
if (isYomitanPopupIframe(element)) {
enablePopupInteraction();
void maybePauseForYomitanPopup();
}
});
mutation.removedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as Element;
if (isYomitanPopupIframe(element)) {
disablePopupInteractionIfIdle();
}
});
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
return {
handlePrimaryMouseEnter: (event?: MouseEvent) => handleMouseEnter(event, false),
handlePrimaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, false),
handleSecondaryMouseEnter: (event?: MouseEvent) => handleMouseEnter(event, true),
handleSecondaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, true),
handleMouseEnter,
handleMouseLeave,
restorePointerInteractionState,
setupDragging,
setupPointerTracking,
setupResizeHandler,
setupSelectionObserver,
setupYomitanObserver,
};
}