mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-06 19:57:26 -08:00
fix: improve yomitan keyboard navigation and payload handling
This commit is contained in:
@@ -71,6 +71,20 @@ When a Yomitan popup is open, SubMiner also provides popup control shortcuts:
|
|||||||
| `[` | Play previous available audio (selected source) |
|
| `[` | Play previous available audio (selected source) |
|
||||||
| `]` | Play next available audio (selected source) |
|
| `]` | Play next available audio (selected source) |
|
||||||
|
|
||||||
|
## Keyboard-Driven Lookup Mode
|
||||||
|
|
||||||
|
These shortcuts are fixed (not configurable) and require overlay focus.
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| ------------------ | --------------------------------------------------------------------- |
|
||||||
|
| `Ctrl/Cmd+Shift+Y` | Toggle keyboard-driven token selection mode on/off |
|
||||||
|
| `Ctrl/Cmd+Y` | Toggle lookup popup for selected token (open when closed, close when open) |
|
||||||
|
| `ArrowLeft/Right`, `H`, or `L` | Move selected token (previous/next) |
|
||||||
|
| `ArrowUp` or `J` | Open lookup popup for selected token |
|
||||||
|
| `ArrowDown` | Close lookup popup |
|
||||||
|
|
||||||
|
Keyboard-driven mode draws a selection outline around the active token. While keyboard-driven mode is enabled, `J` opens lookup and `H` moves to the previous token. Other popup-local keys still work (`M`, `P`, `[`, `]`). Focus is forced back to the overlay after lookup open/close so token navigation can continue without clicking subtitle text again.
|
||||||
|
|
||||||
## Subtitle & Feature Shortcuts
|
## Subtitle & Feature Shortcuts
|
||||||
|
|
||||||
| Shortcut | Action | Config key |
|
| Shortcut | Action | Config key |
|
||||||
|
|||||||
@@ -213,7 +213,9 @@ By default, hovering over subtitle text pauses mpv playback. Playback resumes as
|
|||||||
|
|
||||||
If you want playback to stay paused while a Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true`. When enabled, SubMiner auto-resumes on popup close only if SubMiner paused playback for that popup.
|
If you want playback to stay paused while a Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true`. When enabled, SubMiner auto-resumes on popup close only if SubMiner paused playback for that popup.
|
||||||
|
|
||||||
If the Yomitan popup is open, you can control it directly from the overlay: `J/K` scroll definitions, `M` mines/adds the selected term, `P` plays term audio, `[` plays the previous available audio, and `]` plays the next available audio in the selected source.
|
Keyboard-driven lookup mode is available with fixed shortcuts: `Ctrl/Cmd+Shift+Y` toggles token-selection mode, `ArrowLeft/Right` (or `H/L`) moves the selected token, `ArrowUp/J` opens lookup for the selected token, `ArrowDown` closes lookup, and `Ctrl/Cmd+Y` toggles lookup for that token.
|
||||||
|
|
||||||
|
If the Yomitan popup is open, you can control it directly from the overlay: `J/K` scroll definitions, `M` mines/adds the selected term, `P` plays term audio, `[` plays the previous available audio, and `]` plays the next available audio in the selected source. In keyboard-driven lookup mode, `J` opens lookup, `H` moves to the previous token, `L` (or `ArrowRight`) moves to the next token, and `ArrowDown` closes lookup.
|
||||||
|
|
||||||
### Drag-and-drop Queueing
|
### Drag-and-drop Queueing
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ test('buildSubminerScriptOpts includes aniskip payload fields', () => {
|
|||||||
assert.match(opts, /subminer-aniskip_intro_end=62/);
|
assert.match(opts, /subminer-aniskip_intro_end=62/);
|
||||||
assert.match(opts, /subminer-aniskip_lookup_status=ready/);
|
assert.match(opts, /subminer-aniskip_lookup_status=ready/);
|
||||||
assert.ok(payloadMatch !== null);
|
assert.ok(payloadMatch !== null);
|
||||||
const payload = JSON.parse(decodeURIComponent(payloadMatch[1]));
|
assert.equal(payloadMatch[1].includes('%'), false);
|
||||||
|
const payloadJson = Buffer.from(payloadMatch[1], 'base64url').toString('utf-8');
|
||||||
|
const payload = JSON.parse(payloadJson);
|
||||||
assert.equal(payload.found, true);
|
assert.equal(payload.found, true);
|
||||||
const first = payload.results?.[0];
|
const first = payload.results?.[0];
|
||||||
assert.equal(first.skip_type, 'op');
|
assert.equal(first.skip_type, 'op');
|
||||||
|
|||||||
@@ -532,7 +532,9 @@ function buildLauncherAniSkipPayload(aniSkipMetadata: AniSkipMetadata): string |
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
return encodeURIComponent(JSON.stringify(payload));
|
// mpv --script-opts treats `%` as an escape prefix, so URL-encoding can break parsing.
|
||||||
|
// Base64url stays script-opts-safe and is decoded by the plugin launcher payload parser.
|
||||||
|
return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSubminerScriptOpts(
|
export function buildSubminerScriptOpts(
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
|
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
|
||||||
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||||
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
||||||
|
|||||||
441
src/renderer/handlers/keyboard.test.ts
Normal file
441
src/renderer/handlers/keyboard.test.ts
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createKeyboardHandlers } from './keyboard.js';
|
||||||
|
import { createRendererState } from '../state.js';
|
||||||
|
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
|
||||||
|
|
||||||
|
type CommandEventDetail = {
|
||||||
|
type?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
key?: string;
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createClassList() {
|
||||||
|
const classes = new Set<string>();
|
||||||
|
return {
|
||||||
|
add: (...tokens: string[]) => {
|
||||||
|
for (const token of tokens) {
|
||||||
|
classes.add(token);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove: (...tokens: string[]) => {
|
||||||
|
for (const token of tokens) {
|
||||||
|
classes.delete(token);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contains: (token: string) => classes.has(token),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installKeyboardTestGlobals() {
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||||
|
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||||
|
const previousCustomEvent = (globalThis as { CustomEvent?: unknown }).CustomEvent;
|
||||||
|
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
|
||||||
|
|
||||||
|
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
|
const commandEvents: CommandEventDetail[] = [];
|
||||||
|
|
||||||
|
let popupVisible = false;
|
||||||
|
|
||||||
|
const popupIframe = {
|
||||||
|
tagName: 'IFRAME',
|
||||||
|
classList: {
|
||||||
|
contains: (token: string) => token === 'yomitan-popup',
|
||||||
|
},
|
||||||
|
id: 'yomitan-popup-1',
|
||||||
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const selection = {
|
||||||
|
removeAllRanges: () => {},
|
||||||
|
addRange: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
|
||||||
|
let focusMainWindowCalls = 0;
|
||||||
|
let windowFocusCalls = 0;
|
||||||
|
|
||||||
|
class TestCustomEvent extends Event {
|
||||||
|
detail: unknown;
|
||||||
|
|
||||||
|
constructor(type: string, init?: { detail?: unknown }) {
|
||||||
|
super(type);
|
||||||
|
this.detail = init?.detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestMouseEvent extends Event {
|
||||||
|
constructor(type: string) {
|
||||||
|
super(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'CustomEvent', {
|
||||||
|
configurable: true,
|
||||||
|
value: TestCustomEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'MouseEvent', {
|
||||||
|
configurable: true,
|
||||||
|
value: TestMouseEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: () => {},
|
||||||
|
dispatchEvent: (event: Event) => {
|
||||||
|
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
|
||||||
|
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
|
||||||
|
commandEvents.push(detail ?? {});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'visible',
|
||||||
|
display: 'block',
|
||||||
|
opacity: '1',
|
||||||
|
}),
|
||||||
|
getSelection: () => selection,
|
||||||
|
focus: () => {
|
||||||
|
windowFocusCalls += 1;
|
||||||
|
},
|
||||||
|
electronAPI: {
|
||||||
|
getKeybindings: async () => [],
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
toggleDevTools: () => {},
|
||||||
|
focusMainWindow: () => {
|
||||||
|
focusMainWindowCalls += 1;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||||
|
const listeners = documentListeners.get(type) ?? [];
|
||||||
|
listeners.push(listener);
|
||||||
|
documentListeners.set(type, listeners);
|
||||||
|
},
|
||||||
|
querySelectorAll: () => {
|
||||||
|
if (popupVisible) {
|
||||||
|
return [popupIframe];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
createRange: () => ({
|
||||||
|
selectNodeContents: () => {},
|
||||||
|
}),
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: class {
|
||||||
|
observe() {}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function dispatchKeydown(event: {
|
||||||
|
key: string;
|
||||||
|
code: string;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
metaKey?: boolean;
|
||||||
|
altKey?: boolean;
|
||||||
|
shiftKey?: boolean;
|
||||||
|
repeat?: boolean;
|
||||||
|
}): void {
|
||||||
|
const listeners = documentListeners.get('keydown') ?? [];
|
||||||
|
const keyboardEvent = {
|
||||||
|
key: event.key,
|
||||||
|
code: event.code,
|
||||||
|
ctrlKey: event.ctrlKey ?? false,
|
||||||
|
metaKey: event.metaKey ?? false,
|
||||||
|
altKey: event.altKey ?? false,
|
||||||
|
shiftKey: event.shiftKey ?? false,
|
||||||
|
repeat: event.repeat ?? false,
|
||||||
|
preventDefault: () => {},
|
||||||
|
target: null,
|
||||||
|
};
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(keyboardEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchFocusInOnPopup(): void {
|
||||||
|
const listeners = documentListeners.get('focusin') ?? [];
|
||||||
|
const focusEvent = {
|
||||||
|
target: popupIframe,
|
||||||
|
};
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(focusEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousMutationObserver,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'CustomEvent', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousCustomEvent,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'MouseEvent', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousMouseEvent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = {
|
||||||
|
focus: (options?: { preventScroll?: boolean }) => {
|
||||||
|
overlayFocusCalls.push(options ?? {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
commandEvents,
|
||||||
|
overlay,
|
||||||
|
overlayFocusCalls,
|
||||||
|
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||||
|
windowFocusCalls: () => windowFocusCalls,
|
||||||
|
dispatchKeydown,
|
||||||
|
dispatchFocusInOnPopup,
|
||||||
|
setPopupVisible: (value: boolean) => {
|
||||||
|
popupVisible = value;
|
||||||
|
},
|
||||||
|
restore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createKeyboardHandlerHarness() {
|
||||||
|
const testGlobals = installKeyboardTestGlobals();
|
||||||
|
const subtitleRootClassList = createClassList();
|
||||||
|
|
||||||
|
const wordNodes = [
|
||||||
|
{
|
||||||
|
classList: createClassList(),
|
||||||
|
getBoundingClientRect: () => ({ left: 10, top: 10, width: 30, height: 20 }),
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
classList: createClassList(),
|
||||||
|
getBoundingClientRect: () => ({ left: 80, top: 10, width: 30, height: 20 }),
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
classList: createClassList(),
|
||||||
|
getBoundingClientRect: () => ({ left: 150, top: 10, width: 30, height: 20 }),
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
subtitleRoot: {
|
||||||
|
classList: subtitleRootClassList,
|
||||||
|
querySelectorAll: () => wordNodes,
|
||||||
|
},
|
||||||
|
subtitleContainer: {
|
||||||
|
contains: () => false,
|
||||||
|
},
|
||||||
|
overlay: testGlobals.overlay,
|
||||||
|
},
|
||||||
|
platform: {
|
||||||
|
shouldToggleMouseIgnore: false,
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
overlayLayer: 'always-on-top',
|
||||||
|
},
|
||||||
|
state: createRendererState(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlers = createKeyboardHandlers(ctx as never, {
|
||||||
|
handleRuntimeOptionsKeydown: () => false,
|
||||||
|
handleSubsyncKeydown: () => false,
|
||||||
|
handleKikuKeydown: () => false,
|
||||||
|
handleJimakuKeydown: () => false,
|
||||||
|
handleSessionHelpKeydown: () => false,
|
||||||
|
openSessionHelpModal: () => {},
|
||||||
|
appendClipboardVideoToQueue: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ctx, handlers, testGlobals };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keyboard mode: left and right move token selection while popup remains open', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 1;
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, 2);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, 1);
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
const closeEvents = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'setVisible' && event.visible === false,
|
||||||
|
);
|
||||||
|
assert.equal(closeEvents.length, 0);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: up and j open yomitan lookup for selected token', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' });
|
||||||
|
testGlobals.dispatchKeydown({ key: 'j', code: 'KeyJ' });
|
||||||
|
|
||||||
|
await wait(80);
|
||||||
|
|
||||||
|
const openEvents = testGlobals.commandEvents.filter((event) => event.type === 'scanSelectedText');
|
||||||
|
assert.equal(openEvents.length, 2);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: down closes yomitan lookup window', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
const closeEvents = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'setVisible' && event.visible === false,
|
||||||
|
);
|
||||||
|
assert.equal(closeEvents.length, 1);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: h moves left when popup is closed', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 2;
|
||||||
|
ctx.state.yomitanPopupVisible = false;
|
||||||
|
testGlobals.setPopupVisible(false);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, 1);
|
||||||
|
|
||||||
|
const closeEvents = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'setVisible' && event.visible === false,
|
||||||
|
);
|
||||||
|
assert.equal(closeEvents.length, 0);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: h moves left while popup is open and keeps lookup active', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 2;
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
|
||||||
|
await wait(80);
|
||||||
|
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, 1);
|
||||||
|
const openEvents = testGlobals.commandEvents.filter((event) => event.type === 'scanSelectedText');
|
||||||
|
assert.equal(openEvents.length > 0, true);
|
||||||
|
const closeEvents = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'setVisible' && event.visible === false,
|
||||||
|
);
|
||||||
|
assert.equal(closeEvents.length, 0);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: opening lookup restores overlay keyboard focus', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' });
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
assert.equal(testGlobals.focusMainWindowCalls() > 0, true);
|
||||||
|
assert.equal(testGlobals.windowFocusCalls() > 0, true);
|
||||||
|
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: popup iframe focusin reclaims overlay keyboard focus', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
|
||||||
|
const before = testGlobals.focusMainWindowCalls();
|
||||||
|
testGlobals.dispatchFocusInOnPopup();
|
||||||
|
await wait(260);
|
||||||
|
|
||||||
|
assert.equal(testGlobals.focusMainWindowCalls() > before, true);
|
||||||
|
assert.equal(testGlobals.windowFocusCalls() > 0, true);
|
||||||
|
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import type { Keybinding } from '../../types';
|
|||||||
import type { RendererContext } from '../context';
|
import type { RendererContext } from '../context';
|
||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
YOMITAN_POPUP_COMMAND_EVENT,
|
YOMITAN_POPUP_COMMAND_EVENT,
|
||||||
isYomitanPopupVisible,
|
isYomitanPopupVisible,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
@@ -269,6 +270,13 @@ export function createKeyboardHandlers(
|
|||||||
const clientY = rect.top + rect.height / 2;
|
const clientY = rect.top + rect.height / 2;
|
||||||
|
|
||||||
dispatchYomitanFrontendScanSelectedText();
|
dispatchYomitanFrontendScanSelectedText();
|
||||||
|
if (ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
// Keep overlay as the keyboard focus owner so token navigation can continue
|
||||||
|
// while the popup is visible.
|
||||||
|
queueMicrotask(() => {
|
||||||
|
scheduleOverlayFocusReclaim(8);
|
||||||
|
});
|
||||||
|
}
|
||||||
// Fallback only if the explicit scan path did not open popup quickly.
|
// Fallback only if the explicit scan path did not open popup quickly.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||||
@@ -304,21 +312,92 @@ export function createKeyboardHandlers(
|
|||||||
ctx.dom.overlay.focus({ preventScroll: true });
|
ctx.dom.overlay.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleOverlayFocusReclaim(attempts: number = 0): void {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreOverlayKeyboardFocus();
|
||||||
|
if (attempts <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = attempts;
|
||||||
|
const reclaim = () => {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreOverlayKeyboardFocus();
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining > 0) {
|
||||||
|
setTimeout(reclaim, 25);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(reclaim, 25);
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeyboardDrivenModeNavigation(e: KeyboardEvent): boolean {
|
function handleKeyboardDrivenModeNavigation(e: KeyboardEvent): boolean {
|
||||||
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = e.code;
|
const key = e.code;
|
||||||
if (key === 'ArrowLeft' || key === 'ArrowUp' || key === 'KeyH' || key === 'KeyK') {
|
if (key === 'ArrowLeft') {
|
||||||
return moveKeyboardSelection(-1);
|
return moveKeyboardSelection(-1);
|
||||||
}
|
}
|
||||||
if (key === 'ArrowRight' || key === 'ArrowDown' || key === 'KeyL' || key === 'KeyJ') {
|
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||||
return moveKeyboardSelection(1);
|
return moveKeyboardSelection(1);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleKeyboardDrivenModeLookupControls(e: KeyboardEvent): boolean {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = e.code;
|
||||||
|
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||||
|
if (key === 'ArrowUp' || key === 'KeyJ') {
|
||||||
|
triggerLookupForSelectedWord();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'ArrowDown') {
|
||||||
|
if (popupVisible) {
|
||||||
|
dispatchYomitanPopupVisibility(false);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
restoreOverlayKeyboardFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'ArrowLeft' || key === 'KeyH') {
|
||||||
|
moveKeyboardSelection(-1);
|
||||||
|
if (popupVisible) {
|
||||||
|
triggerLookupForSelectedWord();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||||
|
moveKeyboardSelection(1);
|
||||||
|
if (popupVisible) {
|
||||||
|
triggerLookupForSelectedWord();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function handleYomitanPopupKeybind(e: KeyboardEvent): boolean {
|
function handleYomitanPopupKeybind(e: KeyboardEvent): boolean {
|
||||||
if (e.repeat) return false;
|
if (e.repeat) return false;
|
||||||
const modifierOnlyCodes = new Set([
|
const modifierOnlyCodes = new Set([
|
||||||
@@ -415,6 +494,35 @@ export function createKeyboardHandlers(
|
|||||||
}
|
}
|
||||||
restoreOverlayKeyboardFocus();
|
restoreOverlayKeyboardFocus();
|
||||||
});
|
});
|
||||||
|
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
scheduleOverlayFocusReclaim(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'focusin',
|
||||||
|
(e: FocusEvent) => {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = e.target;
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
typeof target === 'object' &&
|
||||||
|
'tagName' in target &&
|
||||||
|
isYomitanPopupIframe(target as Element)
|
||||||
|
) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
scheduleOverlayFocusReclaim(8);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
if (isKeyboardDrivenModeToggle(e)) {
|
if (isKeyboardDrivenModeToggle(e)) {
|
||||||
@@ -429,6 +537,11 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (handleKeyboardDrivenModeLookupControls(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||||
if (handleYomitanPopupKeybind(e)) {
|
if (handleYomitanPopupKeybind(e)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
Reference in New Issue
Block a user