feat(renderer): add keyboard-driven yomitan navigation and popup controls

This commit is contained in:
2026-03-04 22:49:57 -08:00
parent 0a36d1aa99
commit fdbf769760
17 changed files with 831 additions and 14 deletions

View File

@@ -2,6 +2,7 @@ import { BrowserWindow } from 'electron';
import * as path from 'path';
import { WindowGeometry } from '../../types';
import { createLogger } from '../../logger';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
@@ -24,6 +25,24 @@ function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind)
export type OverlayWindowKind = 'visible' | 'modal';
function isLookupWindowToggleInput(input: Electron.Input): boolean {
if (input.type !== 'keyDown') return false;
if (input.alt) return false;
if (!input.control && !input.meta) return false;
if (input.shift) return false;
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
return input.code === 'KeyY' || normalizedKey === 'y';
}
function isKeyboardModeToggleInput(input: Electron.Input): boolean {
if (input.type !== 'keyDown') return false;
if (input.alt) return false;
if (!input.control && !input.meta) return false;
if (!input.shift) return false;
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
return input.code === 'KeyY' || normalizedKey === 'y';
}
export function updateOverlayWindowBounds(
geometry: WindowGeometry,
window: BrowserWindow | null,
@@ -118,6 +137,16 @@ export function createOverlayWindow(
window.webContents.on('before-input-event', (event, input) => {
if (kind === 'modal') return;
if (!window.isVisible()) return;
if (isKeyboardModeToggleInput(input)) {
event.preventDefault();
window.webContents.send(IPC_CHANNELS.event.keyboardModeToggleRequested);
return;
}
if (isLookupWindowToggleInput(input)) {
event.preventDefault();
window.webContents.send(IPC_CHANNELS.event.lookupWindowToggleRequested);
return;
}
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
event.preventDefault();
});

View File

@@ -118,6 +118,12 @@ function createQueuedIpcListenerWithPayload<T>(
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
IPC_CHANNELS.event.keyboardModeToggleRequested,
);
const onLookupWindowToggleRequestedEvent = createQueuedIpcListener(
IPC_CHANNELS.event.lookupWindowToggleRequested,
);
const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManualPayload>(
IPC_CHANNELS.event.subsyncOpenManual,
(payload) => payload as SubsyncManualPayload,
@@ -282,6 +288,8 @@ const electronAPI: ElectronAPI = {
},
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
onOpenJimaku: onOpenJimakuEvent,
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {

View File

@@ -6,6 +6,7 @@ import {
YOMITAN_POPUP_IFRAME_SELECTOR,
hasYomitanPopupIframe,
isYomitanPopupIframe,
isYomitanPopupVisible,
} from './yomitan-popup.js';
import { scrollActiveRuntimeOptionIntoView } from './modals/runtime-options.js';
import { resolvePlatformInfo } from './utils/platform.js';
@@ -283,6 +284,43 @@ test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
});
test('isYomitanPopupVisible requires visible iframe geometry', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
let selector = '';
const visibleFrame = {
getBoundingClientRect: () => ({ width: 320, height: 180 }),
} as unknown as HTMLIFrameElement;
const hiddenFrame = {
getBoundingClientRect: () => ({ width: 320, height: 180 }),
} as unknown as HTMLIFrameElement;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
getComputedStyle: (element: Element) => {
if (element === hiddenFrame) {
return { visibility: 'hidden', display: 'block', opacity: '1' } as CSSStyleDeclaration;
}
return { visibility: 'visible', display: 'block', opacity: '1' } as CSSStyleDeclaration;
},
},
});
try {
const root = {
querySelectorAll: (value: string) => {
selector = value;
return [hiddenFrame, visibleFrame];
},
} as unknown as ParentNode;
assert.equal(isYomitanPopupVisible(root), true);
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
const activeItem = {

View File

@@ -1,6 +1,11 @@
import type { Keybinding } from '../../types';
import type { RendererContext } from '../context';
import { hasYomitanPopupIframe, isYomitanPopupIframe } from '../yomitan-popup.js';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_COMMAND_EVENT,
isYomitanPopupVisible,
isYomitanPopupIframe,
} from '../yomitan-popup.js';
export function createKeyboardHandlers(
ctx: RendererContext,
@@ -20,6 +25,7 @@ export function createKeyboardHandlers(
) {
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
const CHORD_TIMEOUT_MS = 1000;
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
const CHORD_MAP = new Map<
string,
@@ -55,6 +61,293 @@ export function createKeyboardHandlers(
return parts.join('+');
}
function dispatchYomitanPopupKeydown(
key: string,
code: string,
modifiers: Array<'alt' | 'ctrl' | 'shift' | 'meta'>,
repeat: boolean,
) {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'forwardKeyDown',
key,
code,
modifiers,
repeat,
},
}),
);
}
function dispatchYomitanPopupVisibility(visible: boolean) {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'setVisible',
visible,
},
}),
);
}
function dispatchYomitanPopupMineSelected() {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'mineSelected',
},
}),
);
}
function dispatchYomitanFrontendScanSelectedText() {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'scanSelectedText',
},
}),
);
}
function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
return e.ctrlKey || e.metaKey;
}
function isKeyboardDrivenModeToggle(e: KeyboardEvent): boolean {
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
return isPrimaryModifierPressed(e) && !e.altKey && e.shiftKey && isYKey && !e.repeat;
}
function isLookupWindowToggle(e: KeyboardEvent): boolean {
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
}
function getSubtitleWordNodes(): HTMLElement[] {
return Array.from(ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'));
}
function syncKeyboardTokenSelection(): void {
const wordNodes = getSubtitleWordNodes();
for (const wordNode of wordNodes) {
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
}
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
ctx.state.keyboardSelectedWordIndex = null;
return;
}
const selectedIndex = Math.min(
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
wordNodes.length - 1,
);
ctx.state.keyboardSelectedWordIndex = selectedIndex;
const selectedWordNode = wordNodes[selectedIndex];
if (selectedWordNode) {
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
}
}
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
ctx.state.keyboardDrivenModeEnabled = enabled;
if (!enabled) {
ctx.state.keyboardSelectedWordIndex = null;
}
syncKeyboardTokenSelection();
}
function toggleKeyboardDrivenMode(): void {
setKeyboardDrivenModeEnabled(!ctx.state.keyboardDrivenModeEnabled);
}
function moveKeyboardSelection(delta: -1 | 1): boolean {
const wordNodes = getSubtitleWordNodes();
if (wordNodes.length === 0) {
ctx.state.keyboardSelectedWordIndex = null;
syncKeyboardTokenSelection();
return true;
}
const currentIndex = ctx.state.keyboardSelectedWordIndex ?? 0;
const nextIndex = Math.min(Math.max(currentIndex + delta, 0), wordNodes.length - 1);
ctx.state.keyboardSelectedWordIndex = nextIndex;
syncKeyboardTokenSelection();
return true;
}
type ScanModifierState = {
shiftKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
};
function emitSyntheticScanEvents(
target: Element,
clientX: number,
clientY: number,
modifiers: ScanModifierState = {},
): void {
if (typeof PointerEvent !== 'undefined') {
const pointerEventInit = {
bubbles: true,
cancelable: true,
composed: true,
clientX,
clientY,
pointerType: 'mouse',
isPrimary: true,
button: 0,
buttons: 0,
shiftKey: modifiers.shiftKey ?? false,
ctrlKey: modifiers.ctrlKey ?? false,
altKey: modifiers.altKey ?? false,
metaKey: modifiers.metaKey ?? false,
} satisfies PointerEventInit;
target.dispatchEvent(new PointerEvent('pointerover', pointerEventInit));
target.dispatchEvent(new PointerEvent('pointermove', pointerEventInit));
target.dispatchEvent(new PointerEvent('pointerdown', { ...pointerEventInit, buttons: 1 }));
target.dispatchEvent(new PointerEvent('pointerup', pointerEventInit));
}
const mouseEventInit = {
bubbles: true,
cancelable: true,
composed: true,
clientX,
clientY,
button: 0,
buttons: 0,
shiftKey: modifiers.shiftKey ?? false,
ctrlKey: modifiers.ctrlKey ?? false,
altKey: modifiers.altKey ?? false,
metaKey: modifiers.metaKey ?? false,
} satisfies MouseEventInit;
target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit));
target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 }));
target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit));
target.dispatchEvent(new MouseEvent('click', mouseEventInit));
}
function emitLookupScanFallback(target: Element, clientX: number, clientY: number): void {
emitSyntheticScanEvents(target, clientX, clientY, {});
}
function selectWordNodeText(wordNode: HTMLElement): void {
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(wordNode);
selection.removeAllRanges();
selection.addRange(range);
ctx.dom.subtitleRoot.classList.add('has-selection');
}
function triggerLookupForSelectedWord(): boolean {
const wordNodes = getSubtitleWordNodes();
if (wordNodes.length === 0) {
return false;
}
const selectedIndex = Math.min(
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
wordNodes.length - 1,
);
ctx.state.keyboardSelectedWordIndex = selectedIndex;
const selectedWordNode = wordNodes[selectedIndex];
if (!selectedWordNode) return false;
syncKeyboardTokenSelection();
selectWordNodeText(selectedWordNode);
const rect = selectedWordNode.getBoundingClientRect();
const clientX = rect.left + rect.width / 2;
const clientY = rect.top + rect.height / 2;
dispatchYomitanFrontendScanSelectedText();
// Fallback only if the explicit scan path did not open popup quickly.
setTimeout(() => {
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
return;
}
// Dispatch directly on the selected token span; when overlay pointer-events are disabled,
// elementFromPoint may resolve to the underlying video surface instead.
emitLookupScanFallback(selectedWordNode, clientX, clientY);
}, 60);
return true;
}
function handleKeyboardModeToggleRequested(): void {
toggleKeyboardDrivenMode();
}
function handleLookupWindowToggleRequested(): void {
if (ctx.state.yomitanPopupVisible) {
dispatchYomitanPopupVisibility(false);
if (ctx.state.keyboardDrivenModeEnabled) {
queueMicrotask(() => {
restoreOverlayKeyboardFocus();
});
}
return;
}
triggerLookupForSelectedWord();
}
function restoreOverlayKeyboardFocus(): void {
void window.electronAPI.focusMainWindow();
window.focus();
ctx.dom.overlay.focus({ preventScroll: true });
}
function handleKeyboardDrivenModeNavigation(e: KeyboardEvent): boolean {
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
return false;
}
const key = e.code;
if (key === 'ArrowLeft' || key === 'ArrowUp' || key === 'KeyH' || key === 'KeyK') {
return moveKeyboardSelection(-1);
}
if (key === 'ArrowRight' || key === 'ArrowDown' || key === 'KeyL' || key === 'KeyJ') {
return moveKeyboardSelection(1);
}
return false;
}
function handleYomitanPopupKeybind(e: KeyboardEvent): boolean {
if (e.repeat) return false;
const modifierOnlyCodes = new Set([
'ShiftLeft',
'ShiftRight',
'ControlLeft',
'ControlRight',
'AltLeft',
'AltRight',
'MetaLeft',
'MetaRight',
]);
if (modifierOnlyCodes.has(e.code)) return false;
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.code === 'KeyM') {
dispatchYomitanPopupMineSelected();
return true;
}
const modifiers: Array<'alt' | 'ctrl' | 'shift' | 'meta'> = [];
if (e.altKey) modifiers.push('alt');
if (e.ctrlKey) modifiers.push('ctrl');
if (e.shiftKey) modifiers.push('shift');
if (e.metaKey) modifiers.push('meta');
dispatchYomitanPopupKeydown(e.key, e.code, modifiers, e.repeat);
return true;
}
function resolveSessionHelpChordBinding(): {
bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean;
@@ -106,9 +399,42 @@ export function createKeyboardHandlers(
async function setupMpvInputForwarding(): Promise<void> {
updateKeybindings(await window.electronAPI.getKeybindings());
syncKeyboardTokenSelection();
const subtitleMutationObserver = new MutationObserver(() => {
syncKeyboardTokenSelection();
});
subtitleMutationObserver.observe(ctx.dom.subtitleRoot, {
childList: true,
subtree: true,
});
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
if (!ctx.state.keyboardDrivenModeEnabled) {
return;
}
restoreOverlayKeyboardFocus();
});
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (hasYomitanPopupIframe(document)) return;
if (isKeyboardDrivenModeToggle(e)) {
e.preventDefault();
handleKeyboardModeToggleRequested();
return;
}
if (isLookupWindowToggle(e)) {
e.preventDefault();
handleLookupWindowToggleRequested();
return;
}
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
if (handleYomitanPopupKeybind(e)) {
e.preventDefault();
}
return;
}
if (ctx.state.runtimeOptionsModalOpen) {
options.handleRuntimeOptionsKeydown(e);
@@ -131,6 +457,11 @@ export function createKeyboardHandlers(
return;
}
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
e.preventDefault();
return;
}
if (ctx.state.chordPending) {
const modifierKeys = [
'ShiftLeft',
@@ -211,5 +542,8 @@ export function createKeyboardHandlers(
return {
setupMpvInputForwarding,
updateKeybindings,
syncKeyboardTokenSelection,
handleKeyboardModeToggleRequested,
handleLookupWindowToggleRequested,
};
}

View File

@@ -2,7 +2,7 @@ import type { ModalStateReader, RendererContext } from '../context';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
hasYomitanPopupIframe,
isYomitanPopupVisible,
isYomitanPopupIframe,
} from '../yomitan-popup.js';
@@ -79,6 +79,7 @@ export function createMouseHandlers(
function enablePopupInteraction(): void {
yomitanPopupVisible = true;
ctx.state.yomitanPopupVisible = true;
ctx.dom.overlay.classList.add('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
@@ -89,12 +90,14 @@ export function createMouseHandlers(
}
function disablePopupInteractionIfIdle(): void {
if (typeof document !== 'undefined' && hasYomitanPopupIframe(document)) {
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
yomitanPopupVisible = true;
ctx.state.yomitanPopupVisible = true;
return;
}
yomitanPopupVisible = false;
ctx.state.yomitanPopupVisible = false;
popupPauseRequestId += 1;
maybeResumeYomitanPopupPause();
maybeResumeHoverPause();
@@ -202,7 +205,8 @@ export function createMouseHandlers(
}
function setupYomitanObserver(): void {
yomitanPopupVisible = hasYomitanPopupIframe(document);
yomitanPopupVisible = isYomitanPopupVisible(document);
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
void maybePauseForYomitanPopup();
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {

View File

@@ -140,6 +140,16 @@ function truncateForErrorLog(text: string): string {
return `${normalized.slice(0, 177)}...`;
}
function getSubtitleTextForPreview(data: SubtitleData | string): string {
if (typeof data === 'string') {
return data;
}
if (data && typeof data.text === 'string') {
return data.text;
}
return '';
}
function getActiveModal(): string | null {
if (ctx.state.jimakuModalOpen) return 'jimaku';
if (ctx.state.kikuModalOpen) return 'kiku';
@@ -245,6 +255,20 @@ function registerModalOpenHandlers(): void {
);
}
function registerKeyboardCommandHandlers(): void {
window.electronAPI.onKeyboardModeToggleRequested(() => {
runGuarded('keyboard-mode-toggle:requested', () => {
keyboardHandlers.handleKeyboardModeToggleRequested();
});
});
window.electronAPI.onLookupWindowToggleRequested(() => {
runGuarded('lookup-window-toggle:requested', () => {
keyboardHandlers.handleLookupWindowToggleRequested();
});
});
}
function runGuarded(action: string, fn: () => void): void {
try {
fn();
@@ -262,6 +286,7 @@ function runGuardedAsync(action: string, fn: () => Promise<void> | void): void {
}
registerModalOpenHandlers();
registerKeyboardCommandHandlers();
async function init(): Promise<void> {
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
@@ -271,11 +296,7 @@ async function init(): Promise<void> {
window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => {
if (typeof data === 'string') {
lastSubtitlePreview = truncateForErrorLog(data);
} else if (data && typeof data.text === 'string') {
lastSubtitlePreview = truncateForErrorLog(data.text);
}
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
subtitleRenderer.renderSubtitle(data);
measurementReporter.schedule();
});
@@ -288,8 +309,13 @@ async function init(): Promise<void> {
});
});
const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
lastSubtitlePreview = truncateForErrorLog(initialSubtitle);
let initialSubtitle: SubtitleData | string = '';
try {
initialSubtitle = await window.electronAPI.getCurrentSubtitle();
} catch {
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
}
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
subtitleRenderer.renderSubtitle(initialSubtitle);
measurementReporter.schedule();

View File

@@ -79,6 +79,9 @@ export type RendererState = {
keybindingsMap: Map<string, (string | number)[]>;
chordPending: boolean;
chordTimeout: ReturnType<typeof setTimeout> | null;
keyboardDrivenModeEnabled: boolean;
keyboardSelectedWordIndex: number | null;
yomitanPopupVisible: boolean;
};
export function createRendererState(): RendererState {
@@ -143,5 +146,8 @@ export function createRendererState(): RendererState {
keybindingsMap: new Map(),
chordPending: false,
chordTimeout: null,
keyboardDrivenModeEnabled: false,
keyboardSelectedWordIndex: null,
yomitanPopupVisible: false,
};
}

View File

@@ -340,6 +340,15 @@ body.settings-modal-open #subtitleContainer {
-webkit-text-fill-color: currentColor !important;
}
#subtitleRoot .word.keyboard-selected {
outline: 2px solid rgba(135, 201, 255, 0.92);
outline-offset: 2px;
border-radius: 4px;
box-shadow:
0 0 0 2px rgba(12, 18, 28, 0.68),
0 0 18px rgba(120, 188, 255, 0.45);
}
#subtitleRoot .word[data-frequency-rank]::before {
content: attr(data-frequency-rank);
position: absolute;
@@ -363,7 +372,8 @@ body.settings-modal-open #subtitleContainer {
z-index: 1;
}
#subtitleRoot .word[data-frequency-rank]:hover::before {
#subtitleRoot .word[data-frequency-rank]:hover::before,
#subtitleRoot .word.keyboard-selected[data-frequency-rank]::before {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
@@ -390,7 +400,8 @@ body.settings-modal-open #subtitleContainer {
z-index: 1;
}
#subtitleRoot .word[data-jlpt-level]:hover::after {
#subtitleRoot .word[data-jlpt-level]:hover::after,
#subtitleRoot .word.keyboard-selected[data-jlpt-level]::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}

View File

@@ -409,6 +409,11 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
'#subtitleRoot .word[data-frequency-rank]:hover::before',
);
assert.match(frequencyTooltipHoverBlock, /opacity:\s*1;/);
const frequencyTooltipKeyboardSelectedBlock = extractClassBlock(
cssText,
'#subtitleRoot .word.keyboard-selected[data-frequency-rank]::before',
);
assert.match(frequencyTooltipKeyboardSelectedBlock, /opacity:\s*1;/);
const jlptTooltipBaseBlock = extractClassBlock(
cssText,
@@ -424,6 +429,11 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
'#subtitleRoot .word[data-jlpt-level]:hover::after',
);
assert.match(jlptTooltipHoverBlock, /opacity:\s*1;/);
const jlptTooltipKeyboardSelectedBlock = extractClassBlock(
cssText,
'#subtitleRoot .word.keyboard-selected[data-jlpt-level]::after',
);
assert.match(jlptTooltipKeyboardSelectedBlock, /opacity:\s*1;/);
assert.match(
cssText,

View File

@@ -1,6 +1,9 @@
export const YOMITAN_POPUP_IFRAME_SELECTOR = 'iframe.yomitan-popup, iframe[id^="yomitan-popup"]';
export const YOMITAN_POPUP_SHOWN_EVENT = 'yomitan-popup-shown';
export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave';
export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
export function isYomitanPopupIframe(element: Element | null): boolean {
if (!element) return false;
@@ -14,3 +17,19 @@ export function isYomitanPopupIframe(element: Element | null): boolean {
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
return root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null;
}
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
const popupIframes = root.querySelectorAll<HTMLIFrameElement>(YOMITAN_POPUP_IFRAME_SELECTOR);
for (const iframe of popupIframes) {
const rect = iframe.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const styles = window.getComputedStyle(iframe);
if (styles.visibility === 'hidden' || styles.display === 'none' || styles.opacity === '0') {
continue;
}
return true;
}
return false;
}

View File

@@ -64,6 +64,8 @@ export const IPC_CHANNELS = {
runtimeOptionsChanged: 'runtime-options:changed',
runtimeOptionsOpen: 'runtime-options:open',
jimakuOpen: 'jimaku:open',
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
configHotReload: 'config:hot-reload',
},
} as const;

View File

@@ -843,6 +843,8 @@ export interface ElectronAPI {
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
onOpenRuntimeOptions: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void;
onKeyboardModeToggleRequested: (callback: () => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void;
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;