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

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