From 3091d7b2a7bb4cab8f7aeea8cd4e69c2a7b4a30a Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 1 Oct 2026 23:56:14 -0700 Subject: [PATCH] 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. --- docs/architecture/README.md | 2 +- src/renderer/handlers/keyboard.test.ts | 42 +++++++++++++++++++++++++- src/renderer/handlers/keyboard.ts | 11 ++++--- src/renderer/handlers/mouse.ts | 6 ++-- src/renderer/renderer.ts | 4 +-- src/renderer/state.ts | 5 +++ src/renderer/yomitan-popup.ts | 13 ++++++++ vendor/hachidori | 2 +- 8 files changed, 73 insertions(+), 12 deletions(-) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 3b6e89d1..ce62b737 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -50,7 +50,7 @@ The dictionary backend is selected once at startup by `dictionaryBackend`. Yomit `setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart. -`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, keeps the lookup highlight on in the overlay first-install options (SubMiner captures media from mpv, not the overlay viewport), removes the unsupported `userScripts` permission, and sets a fixed manifest `key` so the extension ID (and the storage origin holding its dictionaries and settings) does not depend on the userData path, only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. Attention also covers a left press anywhere on the overlay that may start a selection, and the host element only exists after the first lookup, so once a Hachidori event has been seen popup auto-pause requires an unhidden popup pane in the host's shadow root and rechecks after each successful lookup. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes. +`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, keeps the lookup highlight on in the overlay first-install options (SubMiner captures media from mpv, not the overlay viewport), removes the unsupported `userScripts` permission, and sets a fixed manifest `key` so the extension ID (and the storage origin holding its dictionaries and settings) does not depend on the userData path, only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. Attention also covers a left press anywhere on the overlay that may start a selection, and the host element only exists after the first lookup, so once a Hachidori event has been seen, popup auto-pause, popup key routing, controller lookup state, and the gate on imported mpv keys and mouse buttons require an unhidden popup pane in the host's shadow root. Auto-pause rechecks after each successful lookup. Hachidori's popup lives in the overlay document rather than an iframe, so wheel events over its host scroll the popup instead of forwarding to mpv. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes. - Small units, explicit boundaries - Composition over monoliths diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 3ca8a668..2e5ea9ba 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -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) => { @@ -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(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 38d198b9..0406c748 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -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; diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index ae0bf956..394a1fee 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -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, diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index eec6e991..fb19100b 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -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(), diff --git a/src/renderer/state.ts b/src/renderer/state.ts index d7feb5e0..216de219 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -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', }; diff --git a/src/renderer/yomitan-popup.ts b/src/renderer/yomitan-popup.ts index 8b08b524..44fce021 100644 --- a/src/renderer/yomitan-popup.ts +++ b/src/renderer/yomitan-popup.ts @@ -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( root, diff --git a/vendor/hachidori b/vendor/hachidori index 32e53469..b1789ce2 160000 --- a/vendor/hachidori +++ b/vendor/hachidori @@ -1 +1 @@ -Subproject commit 32e53469c1fae67ddf5e71ccb4eacb25d9514804 +Subproject commit b1789ce22d54c33eb5564fb2a147ca2073e9ecc7