mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 16:46:06 -08:00
feat(dictionary): add Hachidori backend support (#266)
This commit is contained in:
1 parent
e690ffba07
commit
cbaa4626e1
143 files changed
+5564
-305
No files matched your search
@@ -3,6 +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 type { CompiledSessionBinding } from '../../types';
|
||||
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
|
||||
import { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
@@ -152,6 +153,10 @@ function installKeyboardTestGlobals() {
|
||||
let selectionAddCount = 0;
|
||||
|
||||
let popupVisible = false;
|
||||
let hachidoriPaneOpen = false;
|
||||
const hachidoriHost = {
|
||||
shadowRoot: { querySelectorAll: () => [{ hidden: false }] },
|
||||
};
|
||||
|
||||
const popupIframe = {
|
||||
tagName: 'IFRAME',
|
||||
@@ -285,7 +290,10 @@ function installKeyboardTestGlobals() {
|
||||
listeners.push(listener);
|
||||
documentListeners.set(type, listeners);
|
||||
},
|
||||
querySelectorAll: () => {
|
||||
querySelectorAll: (selector: string) => {
|
||||
if (selector === HACHIDORI_HOST_SELECTOR) {
|
||||
return hachidoriPaneOpen ? [hachidoriHost] : [];
|
||||
}
|
||||
if (popupVisible) {
|
||||
return [popupIframe];
|
||||
}
|
||||
@@ -455,6 +463,9 @@ function installKeyboardTestGlobals() {
|
||||
setPopupVisible: (value: boolean) => {
|
||||
popupVisible = value;
|
||||
},
|
||||
setHachidoriPaneOpen: (value: boolean) => {
|
||||
hachidoriPaneOpen = value;
|
||||
},
|
||||
setStatsToggleKey: (value: string) => {
|
||||
statsToggleKey = value;
|
||||
},
|
||||
@@ -475,6 +486,18 @@ function installKeyboardTestGlobals() {
|
||||
target.closest = (selector: string) => (selector.includes('.modal') ? target : null);
|
||||
return target;
|
||||
},
|
||||
// Events inside Hachidori's shadow root reach the document retargeted to its host.
|
||||
createDictionaryPopupHostTarget: () => {
|
||||
const target = new TestElement();
|
||||
target.closest = (selector: string) =>
|
||||
selector
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.includes(HACHIDORI_HOST_SELECTOR)
|
||||
? target
|
||||
: null;
|
||||
return target;
|
||||
},
|
||||
createTextInputTarget: () => {
|
||||
const target = new TestElement();
|
||||
target.tagName = 'INPUT';
|
||||
@@ -714,6 +737,23 @@ test('right-clicking interactive overlay controls does not raise playback window
|
||||
}
|
||||
});
|
||||
|
||||
test('right-clicking inside a dictionary popup host does not raise playback window or toggle pause', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
const popupHost = testGlobals.createDictionaryPopupHostTarget();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.dispatchDocumentMouseDown({ button: 2, target: popupHost });
|
||||
await wait(0);
|
||||
|
||||
assert.deepEqual(testGlobals.interactionActivations, []);
|
||||
assert.deepEqual(testGlobals.mpvCommands, []);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('mpv input forwarding retries a transient keyboard config IPC failure', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
let calls = 0;
|
||||
@@ -1177,7 +1217,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();
|
||||
@@ -1222,6 +1268,86 @@ 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('keyboard mode: lookup toggle opens a lookup during Hachidori attention without a pane', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
ctx.state.hachidoriReaderSeen = true;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), true);
|
||||
assert.deepEqual(
|
||||
testGlobals.commandEvents.filter((event) => event.type === 'setVisible'),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: lookup toggle closes an open Hachidori pane after attention is released', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
// A selection drag leaves the pane open while the Yomitan-shaped visible marker clears.
|
||||
ctx.state.hachidoriReaderSeen = true;
|
||||
ctx.state.yomitanPopupVisible = false;
|
||||
testGlobals.setHachidoriPaneOpen(true);
|
||||
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
assert.deepEqual(
|
||||
testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
|
||||
),
|
||||
[{ type: 'setVisible', visible: false }, { type: 'clearActiveTextSource' }],
|
||||
);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('configured subtitle-jump keybinding preserves pause when pause state is unknown', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import { createMpvInputForwarding } from './mpv-input-forwarding';
|
||||
import { MPV_MOUSE_BUTTON_BY_BUTTON, wheelEventToMpvWheel } from '../../shared/mpv-input-bindings';
|
||||
import { dispatchConfiguredMpvCommand } from '../utils/mpv-command-dispatch';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
registerDictionaryPopupVisibilityListener,
|
||||
HACHIDORI_HOST_SELECTOR,
|
||||
YOMITAN_POPUP_COMMAND_EVENT,
|
||||
YOMITAN_POPUP_HOST_SELECTOR,
|
||||
isHachidoriPopupOpen,
|
||||
isLookupPopupOpen,
|
||||
isYomitanPopupVisible,
|
||||
isYomitanPopupIframe,
|
||||
} from '../yomitan-popup.js';
|
||||
@@ -80,6 +83,8 @@ export function createKeyboardHandlers(
|
||||
if (target.closest('.modal')) return true;
|
||||
if (ctx.dom.subtitleContainer.contains(target)) return true;
|
||||
if (isYomitanPopupIframe(target)) return true;
|
||||
// Hachidori's popup lives in a shadow root, so its events arrive retargeted to the host.
|
||||
if (target.closest(`${YOMITAN_POPUP_HOST_SELECTOR}, ${HACHIDORI_HOST_SELECTOR}`)) return true;
|
||||
if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]'))
|
||||
return true;
|
||||
return false;
|
||||
@@ -106,11 +111,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}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -122,8 +129,7 @@ export function createKeyboardHandlers(
|
||||
!ctx.state.playlistBrowserModalOpen &&
|
||||
!ctx.state.youtubePickerModalOpen &&
|
||||
!ctx.state.subtitleSidebarModalOpen &&
|
||||
!ctx.state.yomitanPopupVisible &&
|
||||
!isYomitanPopupVisible(document) &&
|
||||
!isLookupPopupOpen(ctx.state, document) &&
|
||||
!isInteractiveTarget(target)
|
||||
);
|
||||
}
|
||||
@@ -515,8 +521,7 @@ export function createKeyboardHandlers(
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
const shouldRefreshLookup =
|
||||
pendingLookupRefreshAfterSubtitleSeek &&
|
||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
|
||||
pendingLookupRefreshAfterSubtitleSeek && isLookupPopupOpen(ctx.state, document);
|
||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
if (shouldRefreshLookup) {
|
||||
queueMicrotask(() => {
|
||||
@@ -708,7 +713,7 @@ export function createKeyboardHandlers(
|
||||
if (lookupScanFallbackTimer !== null) clearTimeout(lookupScanFallbackTimer);
|
||||
lookupScanFallbackTimer = setTimeout(() => {
|
||||
lookupScanFallbackTimer = null;
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
if (isLookupPopupOpen(ctx.state, document)) {
|
||||
return;
|
||||
}
|
||||
// Dispatch directly on the selected token span; when overlay pointer-events are disabled,
|
||||
@@ -738,15 +743,22 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function handleLookupWindowToggleRequested(): void {
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
if (isLookupPopupOpen(ctx.state, document)) {
|
||||
closeLookupWindow();
|
||||
return;
|
||||
}
|
||||
triggerLookupForSelectedWord();
|
||||
}
|
||||
|
||||
// Closes any popup state: an open Hachidori pane (its attention may already be
|
||||
// released) and attention without a pane, so callers like the Kiku modal
|
||||
// opener leave nothing behind.
|
||||
function closeLookupWindow(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (
|
||||
!ctx.state.yomitanPopupVisible &&
|
||||
!isYomitanPopupVisible(document) &&
|
||||
!isHachidoriPopupOpen(document)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -770,7 +782,7 @@ export function createKeyboardHandlers(
|
||||
return false;
|
||||
}
|
||||
|
||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||
const popupVisible = isLookupPopupOpen(ctx.state, document);
|
||||
const result = moveKeyboardSelection(delta);
|
||||
if (result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
|
||||
@@ -791,7 +803,7 @@ export function createKeyboardHandlers(
|
||||
code: string,
|
||||
repeat: boolean = true,
|
||||
): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (!isLookupPopupOpen(ctx.state, document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupKeydown(key, code, [], repeat);
|
||||
@@ -799,7 +811,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function mineSelectedFromController(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (!isLookupPopupOpen(ctx.state, document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupMineSelected();
|
||||
@@ -807,7 +819,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function cyclePopupAudioSourceForController(direction: -1 | 1): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (!isLookupPopupOpen(ctx.state, document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupCycleAudioSource(direction);
|
||||
@@ -815,7 +827,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function playCurrentAudioForController(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (!isLookupPopupOpen(ctx.state, document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupPlayCurrentAudio();
|
||||
@@ -823,7 +835,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function scrollPopupByController(deltaX: number, deltaY: number): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (!isLookupPopupOpen(ctx.state, document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupScrollBy(deltaX, deltaY);
|
||||
@@ -850,7 +862,7 @@ export function createKeyboardHandlers(
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
return;
|
||||
}
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (!isLookupPopupOpen(ctx.state, document)) {
|
||||
return;
|
||||
}
|
||||
restoreOverlayKeyboardFocus();
|
||||
@@ -895,7 +907,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
const key = e.code;
|
||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||
const popupVisible = isLookupPopupOpen(ctx.state, document);
|
||||
if (key === 'ArrowLeft' || key === 'KeyH') {
|
||||
const result = moveKeyboardSelection(-1);
|
||||
if (result === 'start-boundary' || result === 'no-words') {
|
||||
@@ -1086,7 +1098,7 @@ export function createKeyboardHandlers(
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||
registerDictionaryPopupVisibilityListener('hidden', () => {
|
||||
clearNativeSubtitleSelection();
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
syncKeyboardTokenSelection();
|
||||
@@ -1094,7 +1106,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
restoreOverlayKeyboardFocus();
|
||||
});
|
||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||
registerDictionaryPopupVisibilityListener('shown', () => {
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
return;
|
||||
}
|
||||
@@ -1251,7 +1263,7 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
if (isLookupPopupOpen(ctx.state, document)) {
|
||||
if (handleYomitanPopupKeybind(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
|
||||
@@ -5,6 +5,11 @@ 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_SHOWN_EVENT,
|
||||
YOMITAN_LOOKUP_EVENT,
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_HOST_SELECTOR,
|
||||
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||
@@ -50,6 +55,8 @@ const MOCKED_GLOBALS = ['window', 'document', 'MutationObserver', 'Node'] as con
|
||||
* Installs window/document/MutationObserver/Node stubs (restored via `t.after`) and builds
|
||||
* mouse handlers with no-op defaults. `hoverPause`, `popupPause`, `hovered` and
|
||||
* `popupHostVisible` are mutable mid-test; `fire` dispatches to the registered listeners.
|
||||
* With `hachidori`, the popup host is a Hachidori shadow host: `popupHostVisible` is its
|
||||
* attention flag, `popupPaneOpen` its popup pane, and `popupHostAttached` its presence.
|
||||
*/
|
||||
function createMouseHarness(
|
||||
t: TestContext,
|
||||
@@ -61,6 +68,7 @@ function createMouseHarness(
|
||||
getPlaybackPaused?: () => Promise<boolean | null>;
|
||||
popupHostVisible?: boolean;
|
||||
hovered?: HoverTarget;
|
||||
hachidori?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const overlayClassList = createClassList();
|
||||
@@ -102,11 +110,23 @@ function createMouseHarness(
|
||||
};
|
||||
const ignoreCalls: IgnoreCall[] = [];
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
const popupHost = {
|
||||
tagName: 'DIV',
|
||||
getAttribute: (name: string) =>
|
||||
name === 'data-subminer-yomitan-popup-visible' ? 'true' : null,
|
||||
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,
|
||||
@@ -118,6 +138,8 @@ function createMouseHarness(
|
||||
popupPause: options.popupPause ?? false,
|
||||
hovered: options.hovered ?? null,
|
||||
popupHostVisible: options.popupHostVisible ?? false,
|
||||
popupHostAttached: options.hachidori ?? false,
|
||||
popupPaneOpen: false,
|
||||
handlers: null as unknown as ReturnType<typeof createMouseHandlers>,
|
||||
fire: (target: keyof typeof listeners, type: string, event: unknown = {}) => {
|
||||
for (const listener of listeners[target].get(type) ?? []) listener(event);
|
||||
@@ -157,12 +179,15 @@ function createMouseHarness(
|
||||
body: { classList: bodyClassList },
|
||||
addEventListener: addListener('document'),
|
||||
querySelector: () => null,
|
||||
querySelectorAll: (selector: string) =>
|
||||
harness.popupHostVisible &&
|
||||
(selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
|
||||
selector === YOMITAN_POPUP_HOST_SELECTOR)
|
||||
querySelectorAll: (selector: string) => {
|
||||
if (selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) {
|
||||
return harness.popupHostVisible ? [popupHost] : [];
|
||||
}
|
||||
return hostSelectors.includes(selector) &&
|
||||
(harness.popupHostVisible || harness.popupHostAttached)
|
||||
? [popupHost]
|
||||
: [],
|
||||
: [];
|
||||
},
|
||||
elementFromPoint: () =>
|
||||
harness.hovered === 'primary'
|
||||
? ctx.dom.subtitleContainer
|
||||
@@ -375,6 +400,72 @@ for (const c of [
|
||||
});
|
||||
}
|
||||
|
||||
// Hachidori publishes one attention signal for an open popup and for a press on
|
||||
// subtitle text that may become a selection, so its popup pane is the source of
|
||||
// truth for what is on screen.
|
||||
|
||||
test('Hachidori press on subtitle text does not pause before a popup opens', async (t) => {
|
||||
const h = createMouseHarness(t, { popupPause: true, hachidori: true });
|
||||
h.handlers.setupYomitanObserver();
|
||||
|
||||
h.popupHostVisible = true;
|
||||
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
|
||||
await waitForNextTick();
|
||||
h.popupHostVisible = false;
|
||||
h.fire('window', HACHIDORI_POPUP_HIDDEN_EVENT);
|
||||
await waitForNextTick();
|
||||
|
||||
assert.deepEqual(h.mpvCommands, []);
|
||||
});
|
||||
|
||||
test('Hachidori press before any lookup does not pause while its host is unattached', async (t) => {
|
||||
const h = createMouseHarness(t, { popupPause: true, hachidori: true });
|
||||
h.handlers.setupYomitanObserver();
|
||||
|
||||
// Hachidori attaches its host on the first lookup, so a press anywhere on
|
||||
// the overlay before that claims attention with nothing in the DOM.
|
||||
h.popupHostAttached = false;
|
||||
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
|
||||
await waitForNextTick();
|
||||
h.fire('window', HACHIDORI_POPUP_HIDDEN_EVENT);
|
||||
await waitForNextTick();
|
||||
|
||||
assert.deepEqual(h.mpvCommands, []);
|
||||
});
|
||||
|
||||
test('Hachidori selection lookup pauses once its popup opens and resumes on close', async (t) => {
|
||||
const h = createMouseHarness(t, { popupPause: true, hachidori: true });
|
||||
h.handlers.setupYomitanObserver();
|
||||
|
||||
h.popupHostVisible = true;
|
||||
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
|
||||
await waitForNextTick();
|
||||
assert.deepEqual(h.mpvCommands, []);
|
||||
|
||||
// The drag's attention carries over to its lookup, so no second shown event arrives.
|
||||
h.popupPaneOpen = true;
|
||||
h.fire('window', YOMITAN_LOOKUP_EVENT);
|
||||
await waitForNextTick();
|
||||
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('Hachidori hover popup pauses when shown', async (t) => {
|
||||
const h = createMouseHarness(t, { popupPause: true, hachidori: true });
|
||||
h.handlers.setupYomitanObserver();
|
||||
|
||||
h.popupPaneOpen = true;
|
||||
h.popupHostVisible = true;
|
||||
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
|
||||
await waitForNextTick();
|
||||
|
||||
assert.deepEqual(h.mpvCommands, [PAUSE]);
|
||||
});
|
||||
|
||||
test('popup shown reclaims overlay focus on macOS and captures click-away', (t) => {
|
||||
const h = createMouseHarness(t, { platform: 'macos' });
|
||||
h.handlers.setupYomitanObserver();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
registerDictionaryPopupVisibilityListener,
|
||||
registerYomitanLookupListener,
|
||||
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
|
||||
isHachidoriPopupOpen,
|
||||
isYomitanPopupVisible,
|
||||
isYomitanPopupIframe,
|
||||
} from '../yomitan-popup.js';
|
||||
@@ -257,8 +258,20 @@ export function createMouseHandlers(
|
||||
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() &&
|
||||
(!ctx.state.hachidoriReaderSeen ||
|
||||
(typeof document !== 'undefined' && isHachidoriPopupOpen(document)))
|
||||
);
|
||||
}
|
||||
|
||||
async function maybePauseForYomitanPopup(): Promise<void> {
|
||||
if (!yomitanPopupVisible || !options.getYomitanPopupAutoPauseEnabled()) {
|
||||
if (!canPauseForYomitanPopup()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -278,11 +291,7 @@ export function createMouseHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
requestId !== popupPauseRequestId ||
|
||||
!yomitanPopupVisible ||
|
||||
!options.getYomitanPopupAutoPauseEnabled()
|
||||
) {
|
||||
if (requestId !== popupPauseRequestId || !canPauseForYomitanPopup()) {
|
||||
return;
|
||||
}
|
||||
if (paused !== false) return;
|
||||
@@ -470,7 +479,8 @@ export function createMouseHandlers(
|
||||
function setupYomitanObserver(): void {
|
||||
reconcilePopupInteraction({ allowPause: true });
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||
registerDictionaryPopupVisibilityListener('shown', (reader) => {
|
||||
if (reader === 'hachidori') ctx.state.hachidoriReaderSeen = true;
|
||||
reconcilePopupInteraction({
|
||||
assumeVisible: true,
|
||||
allowPause: true,
|
||||
@@ -478,10 +488,16 @@ export function createMouseHandlers(
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||
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 });
|
||||
|
||||
@@ -403,7 +403,7 @@ function buildFixedOverlaySections(): SessionHelpSection[] {
|
||||
{ shortcut: 'Y then S', action: 'Start overlay' },
|
||||
{ shortcut: 'Y then Shift + S', action: 'Stop overlay' },
|
||||
{ shortcut: 'Y then T', action: 'Toggle visible overlay' },
|
||||
{ shortcut: 'Y then O', action: 'Open Yomitan settings' },
|
||||
{ shortcut: 'Y then O', action: 'Open dictionary settings' },
|
||||
{ shortcut: 'Y then R', action: 'Restart overlay' },
|
||||
{ shortcut: 'Y then C', action: 'Check overlay status' },
|
||||
{ shortcut: 'Y then H/K', action: 'Open session help' },
|
||||
@@ -412,7 +412,7 @@ function buildFixedOverlaySections(): SessionHelpSection[] {
|
||||
},
|
||||
{
|
||||
title: 'Global shortcuts',
|
||||
rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open Yomitan settings' }],
|
||||
rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open dictionary settings' }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
hasSubtitleSidebarSelection,
|
||||
} from './subtitle-sidebar-selection.js';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
registerDictionaryPopupVisibilityListener,
|
||||
isYomitanPopupVisible,
|
||||
} from '../yomitan-popup.js';
|
||||
|
||||
@@ -850,12 +849,18 @@ export function createSubtitleSidebarModal(
|
||||
syncEmbeddedSidebarLayout();
|
||||
};
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown);
|
||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden);
|
||||
const disposeShown = registerDictionaryPopupVisibilityListener(
|
||||
'shown',
|
||||
handleYomitanPopupShown,
|
||||
);
|
||||
const disposeHidden = registerDictionaryPopupVisibilityListener(
|
||||
'hidden',
|
||||
handleYomitanPopupHidden,
|
||||
);
|
||||
disposeDomEvents = () => {
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
window.removeEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown);
|
||||
window.removeEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden);
|
||||
disposeShown();
|
||||
disposeHidden();
|
||||
disposeDomEvents = null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
|
||||
|
||||
assert.equal(state.youtubePickerModalOpen, false);
|
||||
assert.deepEqual(notifications, ['youtube-track-picker']);
|
||||
assert.deepEqual(frontendCommands, [{ type: 'refreshOptions' }]);
|
||||
assert.deepEqual(frontendCommands, []);
|
||||
assert.equal(overlay.classList.contains('interactive'), false);
|
||||
assert.equal(overlayFocused, true);
|
||||
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { YoutubePickerOpenPayload } from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
|
||||
|
||||
function createOption(value: string, label: string): HTMLOptionElement {
|
||||
const option = document.createElement('option');
|
||||
@@ -191,13 +190,6 @@ export function createYoutubeTrackPickerModal(
|
||||
ctx.dom.youtubePickerModal.classList.add('hidden');
|
||||
ctx.dom.youtubePickerModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('youtube-track-picker');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'refreshOptions',
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -7,11 +7,36 @@ import {
|
||||
hasYomitanPopupIframe,
|
||||
isYomitanPopupIframe,
|
||||
isYomitanPopupVisible,
|
||||
registerDictionaryPopupVisibilityListener,
|
||||
} from './yomitan-popup.js';
|
||||
|
||||
type FakeStyle = Pick<CSSStyleDeclaration, 'visibility' | 'display' | 'opacity'>;
|
||||
type FakeFrame = Element & { fakeStyle: FakeStyle };
|
||||
|
||||
test('native popup attention events from either backend have the same lifecycle', () => {
|
||||
for (const backend of ['yomitan', 'hachidori']) {
|
||||
const target = new EventTarget();
|
||||
const calls: string[] = [];
|
||||
const disposeShown = registerDictionaryPopupVisibilityListener(
|
||||
'shown',
|
||||
() => calls.push('shown'),
|
||||
target,
|
||||
);
|
||||
const disposeHidden = registerDictionaryPopupVisibilityListener(
|
||||
'hidden',
|
||||
() => calls.push('hidden'),
|
||||
target,
|
||||
);
|
||||
target.dispatchEvent(new CustomEvent(`${backend}-popup-shown`));
|
||||
target.dispatchEvent(new CustomEvent(`${backend}-popup-hidden`));
|
||||
disposeShown();
|
||||
disposeHidden();
|
||||
target.dispatchEvent(new CustomEvent(`${backend}-popup-shown`));
|
||||
target.dispatchEvent(new CustomEvent(`${backend}-popup-hidden`));
|
||||
assert.deepEqual(calls, ['shown', 'hidden']);
|
||||
}
|
||||
});
|
||||
|
||||
const VISIBLE_STYLE: FakeStyle = { visibility: 'visible', display: 'block', opacity: '1' };
|
||||
|
||||
function frame(options: { width?: number; height?: number; style?: Partial<FakeStyle> } = {}) {
|
||||
|
||||
@@ -10,6 +10,41 @@ export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave';
|
||||
export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
|
||||
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.
|
||||
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 type DictionaryReader = 'yomitan' | 'hachidori';
|
||||
|
||||
// Only the active backend injects a reader. Consume its native attention events.
|
||||
export function registerDictionaryPopupVisibilityListener(
|
||||
state: 'shown' | 'hidden',
|
||||
listener: (reader: DictionaryReader) => void,
|
||||
target: EventTarget = window,
|
||||
): () => void {
|
||||
const events: Array<[string, DictionaryReader]> =
|
||||
state === 'shown'
|
||||
? [
|
||||
[YOMITAN_POPUP_SHOWN_EVENT, 'yomitan'],
|
||||
[HACHIDORI_POPUP_SHOWN_EVENT, 'hachidori'],
|
||||
]
|
||||
: [
|
||||
[YOMITAN_POPUP_HIDDEN_EVENT, 'yomitan'],
|
||||
[HACHIDORI_POPUP_HIDDEN_EVENT, 'hachidori'],
|
||||
];
|
||||
const wrapped = events.map(([event, reader]) => {
|
||||
const handler = (): void => listener(reader);
|
||||
target.addEventListener(event, handler);
|
||||
return [event, handler] as const;
|
||||
});
|
||||
return () => {
|
||||
for (const [event, handler] of wrapped) target.removeEventListener(event, handler);
|
||||
};
|
||||
}
|
||||
|
||||
export function registerYomitanLookupListener(
|
||||
target: EventTarget = window,
|
||||
@@ -73,6 +108,33 @@ function queryPopupElements<T extends Element>(
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Hachidori popup pane is on screen. Auto-pause reads this because
|
||||
* the host's visible marker only tracks Hachidori's attention signal. Hachidori
|
||||
* 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<HTMLElement>(root, HACHIDORI_HOST_SELECTOR);
|
||||
return hosts.some((host) =>
|
||||
queryPopupElements<HTMLElement>(host.shadowRoot, HACHIDORI_POPUP_SELECTOR).some(
|
||||
(pane) => !pane.hidden,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
Reference in new issue
Block a user