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