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

@@ -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();

View File

@@ -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<import('popup').Events>
@@ -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);
}

View File

@@ -69,6 +69,10 @@ export class DisplayAudio {
]);
/** @type {?boolean} */
this._enableDefaultAudioSources = null;
/** @type {?number} */
this._audioCycleSourceIndex = null;
/** @type {Map<number, number>} */
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<string, import('display-audio').AudioSource[]>} */
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<boolean>}
*/
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<import('display-audio').AudioInfoList>}
*/
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

View File

@@ -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();

View File

@@ -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';