fix(overlay): forward imported mpv input while Hachidori only claims attention

Hachidori's popup-shown event also fires for any press that may start a
selection, so treating it as an open popup blocked imported mpv mouse
buttons and routed every key to the popup keybind handler. Once Hachidori
is the reader, forwarding, popup key routing and the controller lookup
state now require an unhidden popup pane. Wheel events over an in-document
dictionary popup stay with the popup.

Bump vendor/hachidori so presses outside a text box no longer start a
glyph drag that prevented the press.
This commit is contained in:
sudacode committed 2026-10-01 23:56:14 -07:00
1 parent 4423dd56af
commit 3091d7b2a7
8 files changed
+73 -12

No files matched your search

+41 -1
View File
@@ -479,7 +479,12 @@ function installKeyboardTestGlobals() {
createDictionaryPopupHostTarget: () => {
const target = new TestElement();
target.closest = (selector: string) =>
selector === YOMITAN_POPUP_HOST_SELECTOR ? target : null;
selector
.split(',')
.map((part) => part.trim())
.includes(YOMITAN_POPUP_HOST_SELECTOR)
? target
: null;
return target;
},
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
@@ -1203,7 +1208,13 @@ test('imported mpv wheel bindings forward from the overlay but not over modals',
testGlobals.dispatchWheel({ deltaY: 120 });
testGlobals.dispatchWheel({ deltaY: -120, target: testGlobals.createInteractiveTarget() });
// A Hachidori popup scrolls itself; its events arrive retargeted to its host.
const popupScrolled = !testGlobals.dispatchWheel({
deltaY: -120,
target: testGlobals.createDictionaryPopupHostTarget(),
});
assert.equal(popupScrolled, true);
assert.deepEqual(testGlobals.mpvCommands, [['keypress', 'WHEEL_DOWN', 1]]);
} finally {
testGlobals.restore();
@@ -1248,6 +1259,35 @@ test('imported mpv mouse buttons forward only after SubMiner mouse handling', as
}
});
test('Hachidori attention without an open popup pane still forwards imported mpv input', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(async () => ({
keys: ['0', 'MBTN_LEFT_DBL'],
blockedKeys: [],
}));
await handlers.setupMpvInputForwarding();
await wait(0);
// Hachidori claims attention for any press that may start a selection.
ctx.state.hachidoriReaderSeen = true;
ctx.state.yomitanPopupVisible = true;
testGlobals.dispatchKeydown({ key: '0', code: 'Digit0' });
testGlobals.dispatchMousedown({ button: 0 });
testGlobals.dispatchMousedown({ button: 0, type: 'mouseup' });
assert.deepEqual(testGlobals.commandEvents, []);
assert.deepEqual(testGlobals.mpvCommands, [
['keydown', '0'],
['keydown', 'MBTN_LEFT'],
['keyup', 'MBTN_LEFT'],
]);
} finally {
testGlobals.restore();
}
});
test('configured subtitle-jump keybinding preserves pause when pause state is unknown', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
+7 -4
View File
@@ -5,8 +5,10 @@ import { MPV_MOUSE_BUTTON_BY_BUTTON, wheelEventToMpvWheel } from '../../shared/m
import { dispatchConfiguredMpvCommand } from '../utils/mpv-command-dispatch';
import {
registerDictionaryPopupVisibilityListener,
HACHIDORI_HOST_SELECTOR,
YOMITAN_POPUP_COMMAND_EVENT,
YOMITAN_POPUP_HOST_SELECTOR,
isLookupPopupOpen,
isYomitanPopupVisible,
isYomitanPopupIframe,
} from '../yomitan-popup.js';
@@ -108,11 +110,13 @@ export function createKeyboardHandlers(
}
// Overlay UI that handles its own mouse input (menus, sidebar, notifications, controls).
// Hachidori's popup lives in the overlay document, unlike Yomitan's iframe, so its
// wheel events reach these handlers retargeted to its host.
function isOverlayControlTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return Boolean(
target.closest(
'.modal, .notification-history, .overlay-notification-stack, button, a, input, select, textarea',
`.modal, .notification-history, .overlay-notification-stack, button, a, input, select, textarea, ${YOMITAN_POPUP_HOST_SELECTOR}, ${HACHIDORI_HOST_SELECTOR}`,
),
);
}
@@ -124,8 +128,7 @@ export function createKeyboardHandlers(
!ctx.state.playlistBrowserModalOpen &&
!ctx.state.youtubePickerModalOpen &&
!ctx.state.subtitleSidebarModalOpen &&
!ctx.state.yomitanPopupVisible &&
!isYomitanPopupVisible(document) &&
!isLookupPopupOpen(ctx.state, document) &&
!isInteractiveTarget(target)
);
}
@@ -1262,7 +1265,7 @@ export function createKeyboardHandlers(
return;
}
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
if (isLookupPopupOpen(ctx.state, document)) {
if (handleYomitanPopupKeybind(e)) {
e.preventDefault();
return;
+3 -3
View File
@@ -36,7 +36,6 @@ export function createMouseHandlers(
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;
@@ -266,7 +265,8 @@ export function createMouseHandlers(
return (
yomitanPopupVisible &&
options.getYomitanPopupAutoPauseEnabled() &&
(!hachidoriReaderSeen || (typeof document !== 'undefined' && isHachidoriPopupOpen(document)))
(!ctx.state.hachidoriReaderSeen ||
(typeof document !== 'undefined' && isHachidoriPopupOpen(document)))
);
}
@@ -480,7 +480,7 @@ export function createMouseHandlers(
reconcilePopupInteraction({ allowPause: true });
registerDictionaryPopupVisibilityListener('shown', (reader) => {
if (reader === 'hachidori') hachidoriReaderSeen = true;
if (reader === 'hachidori') ctx.state.hachidoriReaderSeen = true;
reconcilePopupInteraction({
assumeVisible: true,
allowPause: true,
+2 -2
View File
@@ -61,7 +61,7 @@ import { createOverlayNotificationHistoryPanel } from './overlay-notification-hi
import { createRendererState } from './state.js';
import { createModalRegistry, type ModalDescriptor } from './modal-registry.js';
import { createSubtitleRenderer } from './subtitle-render.js';
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
import { isLookupPopupOpen, registerYomitanLookupListener } from './yomitan-popup.js';
import {
createRendererRecoveryController,
registerRendererGlobalErrorHandlers,
@@ -457,7 +457,7 @@ function startControllerPolling(): void {
profiles: {},
},
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
getLookupWindowOpen: () => isLookupPopupOpen(ctx.state, document),
getInteractionBlocked: () => isControllerInputBlocked(),
toggleKeyboardMode: () => keyboardHandlers.handleKeyboardModeToggleRequested(),
toggleLookup: () => keyboardHandlers.handleLookupWindowToggleRequested(),
+5
View File
@@ -161,6 +161,10 @@ export type RendererState = {
keyboardSelectionVisible: boolean;
keyboardSelectedWordIndex: number | null;
yomitanPopupVisible: boolean;
// Set once Hachidori's attention events identify it as the reader. Its attention
// also covers presses that may start a selection, so yomitanPopupVisible alone
// does not mean a popup is open.
hachidoriReaderSeen: boolean;
isOverYomitanPopup: boolean;
primarySubtitleMode: PrimarySubMode;
};
@@ -296,6 +300,7 @@ export function createRendererState(): RendererState {
keyboardSelectionVisible: false,
keyboardSelectedWordIndex: null,
yomitanPopupVisible: false,
hachidoriReaderSeen: false,
isOverYomitanPopup: false,
primarySubtitleMode: 'visible',
};
+13
View File
@@ -122,6 +122,19 @@ export function isHachidoriPopupOpen(root: ParentNode | null | undefined = docum
);
}
/**
* Whether a lookup popup is open, for input that should go to it instead of mpv.
* Once Hachidori is the reader only an open pane counts, because its attention
* (the shown event and the host's visible marker) also covers a plain press.
*/
export function isLookupPopupOpen(
state: { yomitanPopupVisible: boolean; hachidoriReaderSeen: boolean },
root: ParentNode | null | undefined = document,
): boolean {
if (state.hachidoriReaderSeen) return isHachidoriPopupOpen(root);
return state.yomitanPopupVisible || isYomitanPopupVisible(root);
}
export function isYomitanPopupVisible(root: ParentNode | null | undefined = document): boolean {
const visiblePopupHosts = queryPopupElements<HTMLElement>(
root,