diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index e585ee2..d9bc308 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -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(); @@ -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(); }); diff --git a/src/preload.ts b/src/preload.ts index 7789ad3..c2862cd 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -118,6 +118,12 @@ function createQueuedIpcListenerWithPayload( 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( 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 => ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => { diff --git a/src/renderer/error-recovery.test.ts b/src/renderer/error-recovery.test.ts index ffbbdd3..d282b66 100644 --- a/src/renderer/error-recovery.test.ts +++ b/src/renderer/error-recovery.test.ts @@ -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 = { diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 2749eff..7b64788 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -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('.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 { 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, }; } diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index 3b7aa76..0d66d3a 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -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, () => { diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 7e6149e..59767fb 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -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 { } registerModalOpenHandlers(); +registerKeyboardCommandHandlers(); async function init(): Promise { document.body.classList.add(`layer-${ctx.platform.overlayLayer}`); @@ -271,11 +296,7 @@ async function init(): Promise { 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 { }); }); - 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(); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 4d7e09c..4572387 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -79,6 +79,9 @@ export type RendererState = { keybindingsMap: Map; chordPending: boolean; chordTimeout: ReturnType | 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, }; } diff --git a/src/renderer/style.css b/src/renderer/style.css index 9d4f969..5912893 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -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); } diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index f03931e..e566b28 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -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, diff --git a/src/renderer/yomitan-popup.ts b/src/renderer/yomitan-popup.ts index 0950dde..6a5be7f 100644 --- a/src/renderer/yomitan-popup.ts +++ b/src/renderer/yomitan-popup.ts @@ -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(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; +} diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 16d0a22..d201a8a 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -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; diff --git a/src/types.ts b/src/types.ts index 8b00984..4bedde7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void; notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void; diff --git a/vendor/yomitan/js/app/frontend.js b/vendor/yomitan/js/app/frontend.js index 6fe0a18..13bddb5 100644 --- a/vendor/yomitan/js/app/frontend.js +++ b/vendor/yomitan/js/app/frontend.js @@ -28,6 +28,40 @@ import {TextSourceGenerator} from '../dom/text-source-generator.js'; import {TextSourceRange} from '../dom/text-source-range.js'; import {TextScanner} from '../language/text-scanner.js'; +const SUBMINER_FRONTEND_COMMAND_EVENT = 'subminer-yomitan-popup-command'; +const subminerFrontendInstances = new Set(); +let subminerFrontendCommandBridgeRegistered = false; + +function getActiveFrontendForSubminerCommand() { + /** @type {?Frontend} */ + let fallback = null; + for (const frontend of subminerFrontendInstances) { + if (frontend._textScanner?.isEnabled?.()) { + return frontend; + } + if (fallback === null) { + fallback = frontend; + } + } + return fallback; +} + +function registerSubminerFrontendCommandBridge() { + if (subminerFrontendCommandBridgeRegistered) { return; } + subminerFrontendCommandBridgeRegistered = true; + + window.addEventListener(SUBMINER_FRONTEND_COMMAND_EVENT, (event) => { + const frontend = getActiveFrontendForSubminerCommand(); + if (frontend === null) { return; } + const detail = event.detail; + if (typeof detail !== 'object' || detail === null) { return; } + + if (detail.type === 'scanSelectedText') { + frontend._onApiScanSelectedText(); + } + }); +} + /** * This is the main class responsible for scanning and handling webpage content. */ @@ -158,6 +192,9 @@ export class Frontend { * Prepares the instance for use. */ async prepare() { + registerSubminerFrontendCommandBridge(); + subminerFrontendInstances.add(this); + await this.updateOptions(); try { const {zoomFactor} = await this._application.api.getZoom(); diff --git a/vendor/yomitan/js/app/popup.js b/vendor/yomitan/js/app/popup.js index d92fbe7..aab2c46 100644 --- a/vendor/yomitan/js/app/popup.js +++ b/vendor/yomitan/js/app/popup.js @@ -28,6 +28,85 @@ import {loadStyle} from '../dom/style-util.js'; import {checkPopupPreviewURL} from '../pages/settings/popup-preview-controller.js'; import {ThemeController} from './theme-controller.js'; +const SUBMINER_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command'; +const subminerPopupInstances = new Set(); +let subminerPopupCommandBridgeRegistered = false; + +function getActivePopupForSubminerCommand() { + /** @type {?Popup} */ + let fallback = null; + for (const popup of subminerPopupInstances) { + if (!popup.isVisibleSync()) { continue; } + fallback = popup; + if (popup.isPointerOverSelfOrChildren()) { + return popup; + } + } + return fallback; +} + +function registerSubminerPopupCommandBridge() { + if (subminerPopupCommandBridgeRegistered) { return; } + subminerPopupCommandBridgeRegistered = true; + window.addEventListener(SUBMINER_POPUP_COMMAND_EVENT, (event) => { + const popup = getActivePopupForSubminerCommand(); + if (popup === null) { return; } + const detail = event.detail; + if (typeof detail !== 'object' || detail === null) { return; } + + if (detail.type === 'simulateHotkey') { + const key = detail.key; + const rawModifiers = detail.modifiers; + if (typeof key !== 'string' || !Array.isArray(rawModifiers)) { return; } + const modifiers = rawModifiers.filter((modifier) => ( + modifier === 'alt' || + modifier === 'ctrl' || + modifier === 'shift' || + modifier === 'meta' + )); + void popup._invokeSafe('displaySimulateHotkey', {key, modifiers}); + return; + } + + if (detail.type === 'forwardKeyDown') { + const code = detail.code; + const key = detail.key; + const rawModifiers = detail.modifiers; + if (typeof code !== 'string' || typeof key !== 'string' || !Array.isArray(rawModifiers)) { return; } + const modifiers = rawModifiers.filter((modifier) => ( + modifier === 'alt' || + modifier === 'ctrl' || + modifier === 'shift' || + modifier === 'meta' + )); + void popup._invokeSafe('displayForwardKeyDown', { + key, + code, + modifiers, + repeat: detail.repeat === true, + }); + return; + } + + if (detail.type === 'mineSelected') { + void popup._invokeSafe('displayMineSelected', void 0); + return; + } + + if (detail.type === 'cycleAudioSource') { + const direction = detail.direction === -1 ? -1 : 1; + void popup._invokeSafe('displayAudioCycleSource', {direction}); + return; + } + + if (detail.type === 'setVisible') { + if (detail.visible === false) { + popup.hide(false); + } + } + }); +} + /** * This class is the container which hosts the display of search results. * @augments EventDispatcher @@ -209,6 +288,8 @@ export class Popup extends EventDispatcher { * Prepares the popup for use. */ prepare() { + registerSubminerPopupCommandBridge(); + subminerPopupInstances.add(this); this._frame.addEventListener('mouseover', this._onFrameMouseOver.bind(this)); this._frame.addEventListener('mouseout', this._onFrameMouseOut.bind(this)); this._frame.addEventListener('mousedown', (e) => e.stopPropagation()); @@ -471,6 +552,7 @@ export class Popup extends EventDispatcher { */ _onFrameMouseOver() { this._isPointerOverPopup = true; + window.dispatchEvent(new CustomEvent('yomitan-popup-mouse-enter')); this.stopHideDelayed(); this.trigger('mouseOver', {}); @@ -486,6 +568,7 @@ export class Popup extends EventDispatcher { */ _onFrameMouseOut() { this._isPointerOverPopup = false; + window.dispatchEvent(new CustomEvent('yomitan-popup-mouse-leave')); this.trigger('mouseOut', {}); @@ -836,6 +919,7 @@ export class Popup extends EventDispatcher { * @returns {void} */ _onExtensionUnloaded() { + subminerPopupInstances.delete(this); this._invokeWindow('displayExtensionUnloaded', void 0); } diff --git a/vendor/yomitan/js/display/display-audio.js b/vendor/yomitan/js/display/display-audio.js index e2e048b..5dcd1ba 100644 --- a/vendor/yomitan/js/display/display-audio.js +++ b/vendor/yomitan/js/display/display-audio.js @@ -69,6 +69,10 @@ export class DisplayAudio { ]); /** @type {?boolean} */ this._enableDefaultAudioSources = null; + /** @type {?number} */ + this._audioCycleSourceIndex = null; + /** @type {Map} */ + this._audioCycleAudioInfoIndexMap = new Map(); /** @type {(event: MouseEvent) => void} */ this._onAudioPlayButtonClickBind = this._onAudioPlayButtonClick.bind(this); /** @type {(event: MouseEvent) => void} */ @@ -96,6 +100,7 @@ export class DisplayAudio { ]); this._display.registerDirectMessageHandlers([ ['displayAudioClearAutoPlayTimer', this._onMessageClearAutoPlayTimer.bind(this)], + ['displayAudioCycleSource', this._onMessageCycleAudioSource.bind(this)], ]); /* eslint-enable @stylistic/no-multi-spaces */ this._display.on('optionsUpdated', this._onOptionsUpdated.bind(this)); @@ -186,6 +191,8 @@ export class DisplayAudio { /** @type {Map} */ const nameMap = new Map(); this._audioSources.length = 0; + this._audioCycleSourceIndex = null; + this._audioCycleAudioInfoIndexMap.clear(); for (const {type, url, voice} of sources) { this._addAudioSourceInfo(type, url, voice, true, nameMap); requiredAudioSources.delete(type); @@ -204,6 +211,8 @@ export class DisplayAudio { _onContentClear() { this._entriesToken = {}; this._cache.clear(); + this._audioCycleSourceIndex = null; + this._audioCycleAudioInfoIndexMap.clear(); this.clearAutoPlayTimer(); this._eventListeners.removeAllEventListeners(); } @@ -273,6 +282,73 @@ export class DisplayAudio { this.clearAutoPlayTimer(); } + /** + * @param {{direction?: number}} details + * @returns {Promise} + */ + async _onMessageCycleAudioSource({direction}) { + /** @type {import('display-audio').AudioSource[]} */ + const configuredSources = this._audioSources.filter((source) => source.isInOptions); + const sources = configuredSources.length > 0 ? configuredSources : this._audioSources; + if (sources.length === 0) { return false; } + + const dictionaryEntryIndex = this._display.selectedIndex; + const headwordIndex = 0; + const headword = this._getHeadword(dictionaryEntryIndex, headwordIndex); + if (headword === null) { return false; } + const {term, reading} = headword; + + const primaryCardAudio = this._getPrimaryCardAudio(term, reading); + let source = null; + if (primaryCardAudio !== null) { + source = sources.find((item) => item.index === primaryCardAudio.index) ?? null; + } + if (source === null) { + const fallbackIndex = ( + this._audioCycleSourceIndex !== null && + this._audioCycleSourceIndex >= 0 && + this._audioCycleSourceIndex < sources.length + ) ? this._audioCycleSourceIndex : 0; + source = sources[fallbackIndex] ?? null; + this._audioCycleSourceIndex = fallbackIndex; + } + if (source === null) { return false; } + + const infoList = await this._getSourceAudioInfoList(source, term, reading); + const infoListLength = infoList.length; + if (infoListLength === 0) { return false; } + + const step = direction === -1 ? -1 : 1; + let currentSubIndex = this._audioCycleAudioInfoIndexMap.get(source.index); + if ( + typeof currentSubIndex !== 'number' && + primaryCardAudio !== null && + primaryCardAudio.index === source.index && + primaryCardAudio.subIndex !== null + ) { + currentSubIndex = primaryCardAudio.subIndex; + } + if (typeof currentSubIndex !== 'number') { + currentSubIndex = step > 0 ? -1 : infoListLength; + } + + for (let i = 0; i < infoListLength; ++i) { + currentSubIndex = (currentSubIndex + step + infoListLength) % infoListLength; + const {valid} = await this._playAudio( + dictionaryEntryIndex, + headwordIndex, + [source], + currentSubIndex, + ); + if (valid) { + this._audioCycleAudioInfoIndexMap.set(source.index, currentSubIndex); + return true; + } + } + + return false; + } + /** * @param {import('settings').AudioSourceType} type * @param {string} url @@ -691,6 +767,39 @@ export class DisplayAudio { return infoList.map((info) => ({info, audioPromise: null, audioResolved: false, audio: null})); } + /** + * @param {import('display-audio').AudioSource} source + * @param {string} term + * @param {string} reading + * @returns {Promise} + */ + async _getSourceAudioInfoList(source, term, reading) { + const cacheItem = this._getCacheItem(term, reading, true); + if (typeof cacheItem === 'undefined') { return []; } + const {sourceMap} = cacheItem; + + let cacheUpdated = false; + let sourceInfo = sourceMap.get(source.index); + if (typeof sourceInfo === 'undefined') { + const infoListPromise = this._getTermAudioInfoList(source, term, reading); + sourceInfo = {infoListPromise, infoList: null}; + sourceMap.set(source.index, sourceInfo); + cacheUpdated = true; + } + + let {infoList} = sourceInfo; + if (infoList === null) { + infoList = await sourceInfo.infoListPromise; + sourceInfo.infoList = infoList; + cacheUpdated = true; + } + + if (cacheUpdated) { + this._updateOpenMenu(); + } + return infoList; + } + /** * @param {number} dictionaryEntryIndex * @param {number} headwordIndex diff --git a/vendor/yomitan/js/display/display.js b/vendor/yomitan/js/display/display.js index f86dfa2..3cb759d 100644 --- a/vendor/yomitan/js/display/display.js +++ b/vendor/yomitan/js/display/display.js @@ -224,6 +224,9 @@ export class Display extends EventDispatcher { ['displaySetContentScale', this._onMessageSetContentScale.bind(this)], ['displayConfigure', this._onMessageConfigure.bind(this)], ['displayVisibilityChanged', this._onMessageVisibilityChanged.bind(this)], + ['displaySimulateHotkey', this._onMessageSimulateHotkey.bind(this)], + ['displayForwardKeyDown', this._onMessageForwardKeyDown.bind(this)], + ['displayMineSelected', this._onMessageMineSelected.bind(this)], ]); this.registerWindowMessageHandlers([ ['displayExtensionUnloaded', this._onMessageExtensionUnloaded.bind(this)], @@ -785,6 +788,57 @@ export class Display extends EventDispatcher { this.trigger('frameVisibilityChange', {value}); } + /** + * @param {{key: string, modifiers: unknown[]}} details + * @returns {boolean} + */ + _onMessageSimulateHotkey({key, modifiers}) { + if (typeof key !== 'string' || !Array.isArray(modifiers)) { return false; } + const normalizedModifiers = modifiers.filter((modifier) => ( + modifier === 'alt' || + modifier === 'ctrl' || + modifier === 'shift' || + modifier === 'meta' + )); + return this._hotkeyHandler.simulate(key, normalizedModifiers); + } + + /** + * @param {{key: string, code: string, modifiers: unknown[], repeat?: boolean}} details + * @returns {boolean} + */ + _onMessageForwardKeyDown({key, code, modifiers, repeat = false}) { + if (typeof key !== 'string' || typeof code !== 'string' || !Array.isArray(modifiers)) { return false; } + const normalizedModifiers = modifiers.filter((modifier) => ( + modifier === 'alt' || + modifier === 'ctrl' || + modifier === 'shift' || + modifier === 'meta' + )); + const eventInit = { + key, + code, + repeat, + bubbles: true, + cancelable: true, + composed: true, + altKey: normalizedModifiers.includes('alt'), + ctrlKey: normalizedModifiers.includes('ctrl'), + shiftKey: normalizedModifiers.includes('shift'), + metaKey: normalizedModifiers.includes('meta'), + }; + document.dispatchEvent(new KeyboardEvent('keydown', eventInit)); + return true; + } + + /** + * @returns {boolean} + */ + _onMessageMineSelected() { + document.dispatchEvent(new CustomEvent('subminer-display-mine-selected')); + return true; + } + /** @type {import('display').WindowApiHandler<'displayExtensionUnloaded'>} */ _onMessageExtensionUnloaded() { this._application.webExtension.triggerUnloaded(); diff --git a/vendor/yomitan/js/display/popup-main.js b/vendor/yomitan/js/display/popup-main.js index 6b78109..d4d4f4e 100644 --- a/vendor/yomitan/js/display/popup-main.js +++ b/vendor/yomitan/js/display/popup-main.js @@ -47,6 +47,50 @@ await Application.main(true, async (application) => { const displayResizer = new DisplayResizer(display); displayResizer.prepare(); + document.addEventListener('keydown', (event) => { + if (event.defaultPrevented || event.repeat) { return; } + if (event.ctrlKey || event.metaKey || event.altKey) { return; } + + const target = /** @type {?Element} */ (event.target instanceof Element ? event.target : null); + if (target !== null) { + if (target.closest('input, textarea, select, [contenteditable="true"]')) { + return; + } + } + + const code = event.code; + if (code === 'KeyJ' || code === 'KeyK') { + const scanningOptions = display.getOptions()?.scanning; + const scale = Number.isFinite(scanningOptions?.reducedMotionScrollingScale) + ? scanningOptions.reducedMotionScrollingScale + : 1; + display._scrollByPopupHeight(code === 'KeyJ' ? 1 : -1, scale); + event.preventDefault(); + return; + } + + if (code === 'KeyM') { + displayAnki._hotkeySaveAnkiNoteForSelectedEntry('0'); + event.preventDefault(); + return; + } + + if (code === 'KeyP') { + void displayAudio.playAudio(display.selectedIndex, 0); + event.preventDefault(); + return; + } + + if (code === 'BracketLeft' || code === 'BracketRight') { + displayAudio._onMessageCycleAudioSource({direction: code === 'BracketLeft' ? 1 : -1}); + event.preventDefault(); + } + }); + + document.addEventListener('subminer-display-mine-selected', () => { + displayAnki._hotkeySaveAnkiNoteForSelectedEntry('0'); + }); + display.initializeState(); document.documentElement.dataset.loaded = 'true';