feat(dictionary): add Hachidori backend support (#266)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-24 21:08:18 -07:00
1 parent e690ffba07
commit cbaa4626e1
143 files changed
+5564 -305

No files matched your search

+127 -1
View File
@@ -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();
+33 -21
View File
@@ -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;
+100 -9
View File
@@ -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();
+26 -10
View File
@@ -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 });
+2 -2
View File
@@ -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' }],
},
];
}
+11 -6
View File
@@ -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');
}
+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',
};
+25
View File
@@ -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> } = {}) {
+62
View File
@@ -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,