From c2dac537dace9365c20f86d4e61434cca77314ea Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 4 Nov 2026 22:46:25 -0800 Subject: [PATCH] fix(overlay): keep popup pause working with Hachidori 0.4.0 Hachidori 0.4.0 closed its popup shadow root, so isHachidoriPopupOpen always returned false. The popup never took over the subtitle hover pause, and moving off the subtitle resumed playback with the popup open. Popup key and controller routing read the same check. The sidebar kept working because its hold follows the attention event instead of the pane. The fork now publishes the pane state on the host element, and the renderer reads that attribute instead of upstream's shadow root and CSS class. The unit stubs modelled an open root and hid this, so test:dictionary:electron now drives the real reader and asserts the attribute follows the pane. --- scripts/check-hachidori-parser.cjs | 56 ++++++++++++++++++++++++++ src/renderer/handlers/keyboard.test.ts | 9 ++--- src/renderer/handlers/mouse.test.ts | 45 ++++++++++++++------- src/renderer/yomitan-popup.ts | 13 +++--- vendor/hachidori | 2 +- 5 files changed, 95 insertions(+), 30 deletions(-) diff --git a/scripts/check-hachidori-parser.cjs b/scripts/check-hachidori-parser.cjs index c3fe747c..bea957cc 100644 --- a/scripts/check-hachidori-parser.cjs +++ b/scripts/check-hachidori-parser.cjs @@ -8,6 +8,9 @@ const http = require('node:http'); const root = path.resolve(__dirname, '..'); const parser = require(path.join(root, 'dist/core/services/tokenizer/yomitan-parser-runtime.js')); const { writeStoredZip } = require(path.join(root, 'dist/shared/stored-zip.js')); +const { HACHIDORI_POPUP_OPEN_SELECTOR } = require( + path.join(root, 'dist/renderer/yomitan-popup.js'), +); const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachidori-parser-')); protocol.registerSchemesAsPrivileged([ @@ -43,6 +46,58 @@ function fixture(name, title, bankName, entries, frequencyMode = 'rank-based') { return zipPath; } +// The renderer's popup auto-pause and key routing read this marker because the +// reader's shadow root is closed. Drives the real reader with real input, since +// stubbed DOM tests cannot notice an upstream merge that changes what it exposes. +async function assertPopupOpenMarkerFollowsPane(targetSession) { + const server = http.createServer((_request, response) => { + response.setHeader('Content-Type', 'text/html; charset=utf-8'); + response.end( + '

食べた

', + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const page = new BrowserWindow({ + width: 900, + height: 600, + show: true, + webPreferences: { session: targetSession, contextIsolation: true, nodeIntegration: false }, + }); + try { + await page.loadURL(`http://127.0.0.1:${server.address().port}/`); + const { webContents } = page; + const word = await webContents.executeJavaScript( + `(() => { const { x, y, height } = document.getElementById('word').getBoundingClientRect(); return { x, y, height }; })()`, + ); + const repeatUntilMarker = async (expected, act) => { + const query = `document.querySelector(${JSON.stringify(HACHIDORI_POPUP_OPEN_SELECTOR)}) !== null`; + for (let attempt = 0; attempt < 100; attempt += 1) { + // Content scripts load asynchronously, so keep acting until the reader reacts. + act(attempt); + await new Promise((resolve) => setTimeout(resolve, 100)); + if ((await webContents.executeJavaScript(query)) === expected) return; + } + assert.fail(`Hachidori popup-open marker never became ${expected}`); + }; + page.focus(); + webContents.focus(); + await repeatUntilMarker(true, (attempt) => + webContents.sendInputEvent({ + type: 'mouseMove', + x: Math.round(word.x + 24) + (attempt % 2), + y: Math.round(word.y + word.height / 2), + }), + ); + await repeatUntilMarker(false, () => { + webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }); + webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }); + }); + } finally { + page.destroy(); + await new Promise((resolve) => server.close(resolve)); + } +} + app .whenReady() .then(async () => { @@ -211,6 +266,7 @@ app assert.deepEqual(unmatched, []); assert.equal(await parser.getYomitanCurrentAnkiDeckName(deps, logger), 'Test Mining'); assert.equal((await targetSession.extensions.getAllExtensions()).length, 1); + await assertPopupOpenMarkerFollowsPane(targetSession); assert.equal( await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger), true, diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index de60fecf..125c3714 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -3,7 +3,7 @@ import test from 'node:test'; import { createKeyboardHandlers } from './keyboard.js'; import { createRendererState } from '../state.js'; -import { HACHIDORI_HOST_SELECTOR } from '../yomitan-popup.js'; +import { HACHIDORI_HOST_SELECTOR, HACHIDORI_POPUP_OPEN_SELECTOR } from '../yomitan-popup.js'; import type { CompiledSessionBinding } from '../../types'; import type { MpvInputBindingsSnapshot } from '../../types/session-bindings'; import { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions'; @@ -156,9 +156,6 @@ function installKeyboardTestGlobals() { let popupVisible = false; let hachidoriPaneOpen = false; - const hachidoriHost = { - shadowRoot: { querySelectorAll: () => [{ hidden: false }] }, - }; const popupIframe = { tagName: 'IFRAME', @@ -293,8 +290,8 @@ function installKeyboardTestGlobals() { documentListeners.set(type, listeners); }, querySelectorAll: (selector: string) => { - if (selector === HACHIDORI_HOST_SELECTOR) { - return hachidoriPaneOpen ? [hachidoriHost] : []; + if (selector === HACHIDORI_POPUP_OPEN_SELECTOR) { + return hachidoriPaneOpen ? [{}] : []; } if (popupVisible) { return [popupIframe]; diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index de23198a..d1000136 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -5,9 +5,8 @@ import { SUBTITLE_DEFAULT_CONFIG } from '../../config/definitions/defaults-subti import { createRendererState } from '../state'; import { createMouseHandlers } from './mouse.js'; import { - HACHIDORI_HOST_SELECTOR, HACHIDORI_POPUP_HIDDEN_EVENT, - HACHIDORI_POPUP_SELECTOR, + HACHIDORI_POPUP_OPEN_SELECTOR, HACHIDORI_POPUP_SHOWN_EVENT, YOMITAN_LOOKUP_EVENT, YOMITAN_POPUP_HIDDEN_EVENT, @@ -110,23 +109,11 @@ function createMouseHarness( }; const ignoreCalls: IgnoreCall[] = []; const mpvCommands: Array<(string | number)[]> = []; - const popupPane = { - get hidden() { - return !harness.popupPaneOpen; - }, - }; const popupHost = { tagName: options.hachidori ? 'HACHIDORI-HOST' : 'DIV', getAttribute: (name: string) => name === 'data-subminer-yomitan-popup-visible' ? String(harness.popupHostVisible) : null, - shadowRoot: { - querySelectorAll: (selector: string) => - selector === HACHIDORI_POPUP_SELECTOR ? [popupPane] : [], - }, }; - const hostSelectors = options.hachidori - ? [HACHIDORI_HOST_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR] - : [YOMITAN_POPUP_HOST_SELECTOR]; const harness = { ctx, @@ -183,7 +170,10 @@ function createMouseHarness( if (selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) { return harness.popupHostVisible ? [popupHost] : []; } - return hostSelectors.includes(selector) && + if (selector === HACHIDORI_POPUP_OPEN_SELECTOR) { + return harness.popupPaneOpen ? [popupHost] : []; + } + return selector === YOMITAN_POPUP_HOST_SELECTOR && (harness.popupHostVisible || harness.popupHostAttached) ? [popupHost] : []; @@ -529,6 +519,31 @@ test('Hachidori hover popup pauses when shown', async (t) => { assert.deepEqual(h.mpvCommands, [PAUSE]); }); +test('leaving the subtitle keeps playback paused while a Hachidori popup is open', async (t) => { + let paused = false; + const h = createMouseHarness(t, { + hoverPause: true, + popupPause: true, + hachidori: true, + getPlaybackPaused: async () => paused, + }); + h.handlers.setupYomitanObserver(); + + await h.handlers.handleMouseEnter(); + paused = true; + h.popupPaneOpen = true; + h.popupHostVisible = true; + h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT); + await waitForNextTick(); + await h.handlers.handleMouseLeave(); + assert.deepEqual(h.mpvCommands, [PAUSE]); + + h.popupPaneOpen = false; + h.popupHostVisible = false; + h.fire('window', HACHIDORI_POPUP_HIDDEN_EVENT); + assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]); +}); + test('popup shown reclaims overlay focus on macOS and captures click-away', (t) => { const h = createMouseHarness(t, { platform: 'macos' }); h.handlers.setupYomitanObserver(); diff --git a/src/renderer/yomitan-popup.ts b/src/renderer/yomitan-popup.ts index 44fce021..c092ff5f 100644 --- a/src/renderer/yomitan-popup.ts +++ b/src/renderer/yomitan-popup.ts @@ -12,11 +12,13 @@ export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup'; export const PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS = 'primary-sub-visible-on-yomitan-popup'; // Hachidori's shown/hidden pair means "the reader needs mouse events", which // also covers a left press on subtitle text that may start a selection. Its -// popup panes live in the host's open shadow root. +// popup panes live in a closed shadow root, so the fork's subminer-host.js +// publishes whether one is on screen as an attribute on the host. export const HACHIDORI_POPUP_SHOWN_EVENT = 'hachidori-popup-shown'; export const HACHIDORI_POPUP_HIDDEN_EVENT = 'hachidori-popup-hidden'; export const HACHIDORI_HOST_SELECTOR = 'hachidori-host'; -export const HACHIDORI_POPUP_SELECTOR = '.gsm-hoshidicts-popup'; +export const HACHIDORI_POPUP_OPEN_SELECTOR = + 'hachidori-host[data-subminer-hachidori-popup-open="true"]'; export type DictionaryReader = 'yomitan' | 'hachidori'; @@ -114,12 +116,7 @@ function queryPopupElements( * attaches its host on the first lookup, so no host means no popup. */ export function isHachidoriPopupOpen(root: ParentNode | null | undefined = document): boolean { - const hosts = queryPopupElements(root, HACHIDORI_HOST_SELECTOR); - return hosts.some((host) => - queryPopupElements(host.shadowRoot, HACHIDORI_POPUP_SELECTOR).some( - (pane) => !pane.hidden, - ), - ); + return queryPopupElements(root, HACHIDORI_POPUP_OPEN_SELECTOR).length > 0; } /** diff --git a/vendor/hachidori b/vendor/hachidori index 323d0143..0792c09e 160000 --- a/vendor/hachidori +++ b/vendor/hachidori @@ -1 +1 @@ -Subproject commit 323d0143dd4f6d8f638c6ea87c00ba7a12972eee +Subproject commit 0792c09e05c7cc6b810b2aa4d17af039b93b30ff