mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-06 19:57:26 -08:00
feat(renderer): add keyboard-driven yomitan navigation and popup controls
This commit is contained in:
37
vendor/yomitan/js/app/frontend.js
vendored
37
vendor/yomitan/js/app/frontend.js
vendored
@@ -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();
|
||||
|
||||
84
vendor/yomitan/js/app/popup.js
vendored
84
vendor/yomitan/js/app/popup.js
vendored
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
109
vendor/yomitan/js/display/display-audio.js
vendored
109
vendor/yomitan/js/display/display-audio.js
vendored
@@ -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
|
||||
|
||||
54
vendor/yomitan/js/display/display.js
vendored
54
vendor/yomitan/js/display/display.js
vendored
@@ -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();
|
||||
|
||||
44
vendor/yomitan/js/display/popup-main.js
vendored
44
vendor/yomitan/js/display/popup-main.js
vendored
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user